diff --git a/README.md b/README.md index d5e7906d..e8bbce52 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ Browse the agents below and copy/adapt the ones you need! ./scripts/install.sh --tool windsurf ``` +> **Windows users or Python fans**: Alternative cross-platform Python scripts (`python scripts/convert.py` and `python scripts/install.py`) are also available with identical functionality and zero dependencies. + See the [Multi-Tool Integrations](#-multi-tool-integrations) section below for full details. --- @@ -505,11 +507,19 @@ The Agency works natively with Claude Code, and ships conversion + install scrip ```bash ./scripts/convert.sh ``` +**Or using Python (Windows-compatible):** +```bash +python scripts/convert.py +``` **Step 2 -- Install (interactive, auto-detects your tools):** ```bash ./scripts/install.sh ``` +**Or using Python:** +```bash +python scripts/install.py +``` The installer scans your system for installed tools, shows a checkbox UI, and lets you pick exactly what to install: @@ -539,8 +549,12 @@ The installer scans your system for installed tools, shows a checkbox UI, and le ```bash ./scripts/install.sh --tool cursor ./scripts/install.sh --tool opencode -./scripts/install.sh --tool openclaw -./scripts/install.sh --tool antigravity +``` + +**Python equivalent:** +```bash +python scripts/install.py --tool cursor +python scripts/install.py --tool opencode ``` **Non-interactive (CI/scripts):** @@ -548,6 +562,11 @@ The installer scans your system for installed tools, shows a checkbox UI, and le ./scripts/install.sh --no-interactive --tool all ``` +**Python equivalent:** +```bash +python scripts/install.py --no-interactive --tool all +``` + --- ### Tool-Specific Instructions @@ -742,6 +761,12 @@ When you add new agents or edit existing ones, regenerate all integration files: ./scripts/convert.sh --tool cursor # regenerate just one tool ``` +**Or using Python:** +```bash +python scripts/convert.py +python scripts/convert.py --tool cursor +``` + --- ## πŸ—ΊοΈ Roadmap diff --git a/scripts/convert.py b/scripts/convert.py new file mode 100644 index 00000000..9c0e77e9 --- /dev/null +++ b/scripts/convert.py @@ -0,0 +1,442 @@ +#!/usr/bin/env python3 +""" +convert.py β€” Convert agency agent .md files into tool-specific formats. + +Reads all agent files from the standard category directories and outputs +converted files to integrations//. Run this to regenerate all +integration files after adding or modifying agents. + +Usage: + python scripts/convert.py [--tool ] [--out ] [--help] + +Tools: + antigravity β€” Antigravity skill files (~/.gemini/antigravity/skills/) + gemini-cli β€” Gemini CLI extension (skills/ + gemini-extension.json) + opencode β€” OpenCode agent files (.opencode/agent/*.md) + cursor β€” Cursor rule files (.cursor/rules/*.mdc) + aider β€” Single CONVENTIONS.md for Aider + windsurf β€” Single .windsurfrules for Windsurf + openclaw β€” OpenClaw SOUL.md files (openclaw_workspace//SOUL.md) + qwen β€” Qwen Code SubAgent files (~/.qwen/agents/*.md) + all β€” All tools (default) + +Output is written to integrations// relative to the repo root. +This script never touches user config dirs β€” see install.py for that. + +Compatible with Python 3.7+. No third-party dependencies. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from datetime import date +from pathlib import Path +from typing import Dict, List, Optional + +# Resolve this script's own directory so we can import the sibling module. +_SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(_SCRIPT_DIR)) + +from utils import ( # noqa: E402 + AGENT_DIRS, + find_agent_files, + get_body, + get_field, + header, + info, + error, + slugify, +) + +# --------------------------------------------------------------------------- +# Globals +# --------------------------------------------------------------------------- +VALID_TOOLS = [ + "antigravity", + "gemini-cli", + "opencode", + "cursor", + "aider", + "windsurf", + "openclaw", + "qwen", + "all", +] + +TODAY = date.today().isoformat() # YYYY-MM-DD + + +# --------------------------------------------------------------------------- +# OpenCode colour resolution +# --------------------------------------------------------------------------- +_OPENCODE_COLOR_MAP: Dict[str, str] = { + "cyan": "#00FFFF", + "blue": "#3498DB", + "green": "#2ECC71", + "red": "#E74C3C", + "purple": "#9B59B6", + "orange": "#F39C12", + "teal": "#008080", + "indigo": "#6366F1", + "pink": "#E84393", + "gold": "#EAB308", + "amber": "#F59E0B", + "neon-green": "#10B981", + "neon-cyan": "#06B6D4", + "metallic-blue": "#3B82F6", + "yellow": "#EAB308", + "violet": "#8B5CF6", + "rose": "#F43F5E", + "lime": "#84CC16", + "gray": "#6B7280", + "fuchsia": "#D946EF", +} + +_HEX6_RE = re.compile(r"^#?[0-9a-fA-F]{6}$") + + +def resolve_opencode_color(raw: str) -> str: + """Map a named colour or hex string to an OpenCode-safe ``#RRGGBB``.""" + c = raw.strip().lower() + mapped = _OPENCODE_COLOR_MAP.get(c, c) + + bare = mapped.lstrip("#") + if _HEX6_RE.match(bare): + return f"#{bare.upper()}" + + return "#6B7280" # fallback grey + + +# --------------------------------------------------------------------------- +# Per-tool converters +# --------------------------------------------------------------------------- + +def convert_antigravity(filepath: str, out_dir: Path) -> None: + name = get_field("name", filepath) + description = get_field("description", filepath) + slug = f"agency-{slugify(name)}" + body = get_body(filepath) + + dest = out_dir / "antigravity" / slug + dest.mkdir(parents=True, exist_ok=True) + + (dest / "SKILL.md").write_text( + f"---\n" + f"name: {slug}\n" + f"description: {description}\n" + f"risk: low\n" + f"source: community\n" + f"date_added: '{TODAY}'\n" + f"---\n" + f"{body}\n", + encoding="utf-8", + ) + + +def convert_gemini_cli(filepath: str, out_dir: Path) -> None: + name = get_field("name", filepath) + description = get_field("description", filepath) + slug = slugify(name) + body = get_body(filepath) + + dest = out_dir / "gemini-cli" / "skills" / slug + dest.mkdir(parents=True, exist_ok=True) + + (dest / "SKILL.md").write_text( + f"---\n" + f"name: {slug}\n" + f"description: {description}\n" + f"---\n" + f"{body}\n", + encoding="utf-8", + ) + + +def convert_opencode(filepath: str, out_dir: Path) -> None: + name = get_field("name", filepath) + description = get_field("description", filepath) + color = resolve_opencode_color(get_field("color", filepath)) + slug = slugify(name) + body = get_body(filepath) + + dest = out_dir / "opencode" / "agents" + dest.mkdir(parents=True, exist_ok=True) + + (dest / f"{slug}.md").write_text( + f"---\n" + f"name: {name}\n" + f"description: {description}\n" + f"mode: subagent\n" + f"color: '{color}'\n" + f"---\n" + f"{body}\n", + encoding="utf-8", + ) + + +def convert_cursor(filepath: str, out_dir: Path) -> None: + name = get_field("name", filepath) + description = get_field("description", filepath) + slug = slugify(name) + body = get_body(filepath) + + dest = out_dir / "cursor" / "rules" + dest.mkdir(parents=True, exist_ok=True) + + (dest / f"{slug}.mdc").write_text( + f"---\n" + f"description: {description}\n" + f'globs: ""\n' + f"alwaysApply: false\n" + f"---\n" + f"{body}\n", + encoding="utf-8", + ) + + +def convert_openclaw(filepath: str, out_dir: Path) -> None: + name = get_field("name", filepath) + description = get_field("description", filepath) + slug = slugify(name) + body = get_body(filepath) + + dest = out_dir / "openclaw" / slug + dest.mkdir(parents=True, exist_ok=True) + + # Split body into SOUL (persona) vs AGENTS (operations) by ## header keywords. + soul_keywords = re.compile( + r"identity|communication|style|critical.rule|rules.you.must.follow", + re.IGNORECASE, + ) + + soul_content: List[str] = [] + agents_content: List[str] = [] + current_section: List[str] = [] + current_target = "agents" # default bucket + + for line in body.split("\n"): + if line.startswith("## "): + # Flush previous section + if current_section: + if current_target == "soul": + soul_content.extend(current_section) + else: + agents_content.extend(current_section) + current_section = [] + + # Classify header + if soul_keywords.search(line): + current_target = "soul" + else: + current_target = "agents" + + current_section.append(line) + + # Flush final section + if current_section: + if current_target == "soul": + soul_content.extend(current_section) + else: + agents_content.extend(current_section) + + (dest / "SOUL.md").write_text("\n".join(soul_content) + "\n", encoding="utf-8") + (dest / "AGENTS.md").write_text("\n".join(agents_content) + "\n", encoding="utf-8") + + # IDENTITY.md β€” emoji + name + vibe, or fallback to description + emoji = get_field("emoji", filepath) + vibe = get_field("vibe", filepath) + + if emoji and vibe: + identity = f"# {emoji} {name}\n{vibe}\n" + else: + identity = f"# {name}\n{description}\n" + + (dest / "IDENTITY.md").write_text(identity, encoding="utf-8") + + +def convert_qwen(filepath: str, out_dir: Path) -> None: + name = get_field("name", filepath) + description = get_field("description", filepath) + tools = get_field("tools", filepath) + slug = slugify(name) + body = get_body(filepath) + + dest = out_dir / "qwen" / "agents" + dest.mkdir(parents=True, exist_ok=True) + + frontmatter = f"---\nname: {slug}\ndescription: {description}\n" + if tools: + frontmatter += f"tools: {tools}\n" + frontmatter += "---\n" + + (dest / f"{slug}.md").write_text( + frontmatter + body + "\n", + encoding="utf-8", + ) + + +# --------------------------------------------------------------------------- +# Aider / Windsurf accumulators (single-file outputs) +# --------------------------------------------------------------------------- + +_AIDER_HEADER = """\ +# The Agency β€” AI Agent Conventions +# +# This file provides Aider with the full roster of specialized AI agents from +# The Agency (https://github.com/msitarzewski/agency-agents). +# +# To activate an agent, reference it by name in your Aider session prompt, e.g.: +# "Use the Frontend Developer agent to review this component." +# +# Generated by scripts/convert.py β€” do not edit manually. +""" + +_WINDSURF_HEADER = """\ +# The Agency β€” AI Agent Rules for Windsurf +# +# Full roster of specialized AI agents from The Agency. +# To activate an agent, reference it by name in your Windsurf conversation. +# +# Generated by scripts/convert.py β€” do not edit manually. +""" + + +def _accumulate_aider(filepath: str) -> str: + name = get_field("name", filepath) + description = get_field("description", filepath) + body = get_body(filepath) + return f"\n---\n\n## {name}\n\n> {description}\n\n{body}\n" + + +def _accumulate_windsurf(filepath: str) -> str: + name = get_field("name", filepath) + description = get_field("description", filepath) + body = get_body(filepath) + sep = "=" * 80 + return f"\n{sep}\n## {name}\n{description}\n{sep}\n\n{body}\n\n" + + +# --------------------------------------------------------------------------- +# Main conversion loop +# --------------------------------------------------------------------------- + +def run_conversions( + tool: str, + repo_root: str, + out_dir: Path, + aider_parts: List[str], + windsurf_parts: List[str], +) -> int: + """Run conversions for *tool*, returning the number of agents processed.""" + count = 0 + for _category, filepath in find_agent_files(repo_root): + fp = str(filepath) + if tool == "antigravity": + convert_antigravity(fp, out_dir) + elif tool == "gemini-cli": + convert_gemini_cli(fp, out_dir) + elif tool == "opencode": + convert_opencode(fp, out_dir) + elif tool == "cursor": + convert_cursor(fp, out_dir) + elif tool == "openclaw": + convert_openclaw(fp, out_dir) + elif tool == "qwen": + convert_qwen(fp, out_dir) + elif tool == "aider": + aider_parts.append(_accumulate_aider(fp)) + elif tool == "windsurf": + windsurf_parts.append(_accumulate_windsurf(fp)) + count += 1 + return count + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main(argv: Optional[List[str]] = None) -> None: + parser = argparse.ArgumentParser( + description="Convert agency agent .md files into tool-specific formats.", + ) + parser.add_argument( + "--tool", + default="all", + choices=VALID_TOOLS, + help="Convert for a specific tool (default: all)", + ) + parser.add_argument( + "--out", + default=None, + help="Output directory (default: /integrations)", + ) + args = parser.parse_args(argv) + + repo_root = str(_SCRIPT_DIR.parent) + out_dir = Path(args.out) if args.out else Path(repo_root) / "integrations" + + header("The Agency -- Converting agents to tool-specific formats") + print(f" Repo: {repo_root}") + print(f" Output: {out_dir}") + print(f" Tool: {args.tool}") + print(f" Date: {TODAY}") + + tools_to_run: List[str] + if args.tool == "all": + tools_to_run = [t for t in VALID_TOOLS if t != "all"] + else: + tools_to_run = [args.tool] + + total = 0 + aider_parts: List[str] = [] + windsurf_parts: List[str] = [] + + for t in tools_to_run: + header(f"Converting: {t}") + count = run_conversions(t, repo_root, out_dir, aider_parts, windsurf_parts) + total += count + + # Gemini CLI also needs the extension manifest + if t == "gemini-cli": + manifest_dir = out_dir / "gemini-cli" + manifest_dir.mkdir(parents=True, exist_ok=True) + (manifest_dir / "gemini-extension.json").write_text( + json.dumps( + {"name": "agency-agents", "version": "1.0.0"}, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + info("Wrote gemini-extension.json") + + info(f"Converted {count} agents for {t}") + + # Write single-file outputs after accumulation + if args.tool in ("all", "aider"): + aider_dir = out_dir / "aider" + aider_dir.mkdir(parents=True, exist_ok=True) + (aider_dir / "CONVENTIONS.md").write_text( + _AIDER_HEADER + "".join(aider_parts), + encoding="utf-8", + ) + info("Wrote integrations/aider/CONVENTIONS.md") + + if args.tool in ("all", "windsurf"): + ws_dir = out_dir / "windsurf" + ws_dir.mkdir(parents=True, exist_ok=True) + (ws_dir / ".windsurfrules").write_text( + _WINDSURF_HEADER + "".join(windsurf_parts), + encoding="utf-8", + ) + info("Wrote integrations/windsurf/.windsurfrules") + + print() + info(f"Done. Total conversions: {total}") + + +if __name__ == "__main__": + main() diff --git a/scripts/install.py b/scripts/install.py new file mode 100644 index 00000000..1e04e736 --- /dev/null +++ b/scripts/install.py @@ -0,0 +1,603 @@ +#!/usr/bin/env python3 +""" +install.py β€” Install The Agency agents into your local agentic tool(s). + +Reads converted files from integrations/ and copies them to the appropriate +config directory for each tool. Run ``python scripts/convert.py`` first if +integrations/ is missing or stale. + +Usage: + python scripts/install.py [--tool ] [--interactive] [--no-interactive] [--help] + +Tools: + claude-code β€” Copy agents to ~/.claude/agents/ + copilot β€” Copy agents to ~/.github/agents/ and ~/.copilot/agents/ + antigravity β€” Copy skills to ~/.gemini/antigravity/skills/ + gemini-cli β€” Install extension to ~/.gemini/extensions/agency-agents/ + opencode β€” Copy agents to .opencode/agent/ in current directory + cursor β€” Copy rules to .cursor/rules/ in current directory + aider β€” Copy CONVENTIONS.md to current directory + windsurf β€” Copy .windsurfrules to current directory + openclaw β€” Copy workspaces to ~/.openclaw/agency-agents/ + qwen β€” Copy SubAgents to ~/.qwen/agents/ (user-wide) or .qwen/agents/ (project) + all β€” Install for all detected tools (default) + +Flags: + --tool Install only the specified tool + --interactive Show interactive selector (default when run in a terminal) + --no-interactive Skip interactive selector, install all detected tools + --help Show this help + +Platform support: Windows, macOS, Linux (Python 3.7+, no dependencies). +""" + +from __future__ import annotations + +import argparse +import re +import shutil +import subprocess +import sys +import time +from pathlib import Path +from typing import Callable, Dict, List, Optional, Tuple + +# Resolve this script's own directory so we can import the sibling module. +_SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(_SCRIPT_DIR)) + +from utils import ( # noqa: E402 + AGENT_DIRS, + BOLD, + CYAN, + DIM, + GREEN, + RED, + RESET, + YELLOW, + dim, + error, + header, + info, + warn, +) + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- +REPO_ROOT = _SCRIPT_DIR.parent +INTEGRATIONS = REPO_ROOT / "integrations" +HOME = Path.home() + +ALL_TOOLS: List[str] = [ + "claude-code", + "copilot", + "antigravity", + "gemini-cli", + "opencode", + "openclaw", + "cursor", + "aider", + "windsurf", + "qwen", +] + +# --------------------------------------------------------------------------- +# Box drawing (pure ASCII, fixed 52-char wide β€” matches bash version) +# --------------------------------------------------------------------------- +_BOX_INNER = 48 + + +def _box_line() -> str: + return " +" + "-" * _BOX_INNER + "+" + + +def _box_row(text: str) -> str: + """Content row, right-padded to fit. *text* may contain ANSI escapes.""" + visible = re.sub(r"\033\[[0-9;]*m", "", text) + pad = _BOX_INNER - 2 - len(visible) + if pad < 0: + pad = 0 + return f" | {text}{' ' * pad} |" + + +# --------------------------------------------------------------------------- +# Tool detection +# --------------------------------------------------------------------------- + +def _detect_claude_code() -> bool: + return (HOME / ".claude").is_dir() + + +def _detect_copilot() -> bool: + return ( + shutil.which("code") is not None + or (HOME / ".github").is_dir() + or (HOME / ".copilot").is_dir() + ) + + +def _detect_antigravity() -> bool: + return (HOME / ".gemini" / "antigravity" / "skills").is_dir() + + +def _detect_gemini_cli() -> bool: + return shutil.which("gemini") is not None or (HOME / ".gemini").is_dir() + + +def _detect_cursor() -> bool: + return shutil.which("cursor") is not None or (HOME / ".cursor").is_dir() + + +def _detect_opencode() -> bool: + return ( + shutil.which("opencode") is not None + or (HOME / ".config" / "opencode").is_dir() + ) + + +def _detect_aider() -> bool: + return shutil.which("aider") is not None + + +def _detect_openclaw() -> bool: + return shutil.which("openclaw") is not None or (HOME / ".openclaw").is_dir() + + +def _detect_windsurf() -> bool: + return shutil.which("windsurf") is not None or (HOME / ".codeium").is_dir() + + +def _detect_qwen() -> bool: + return shutil.which("qwen") is not None or (HOME / ".qwen").is_dir() + + +_DETECTORS: Dict[str, Callable[[], bool]] = { + "claude-code": _detect_claude_code, + "copilot": _detect_copilot, + "antigravity": _detect_antigravity, + "gemini-cli": _detect_gemini_cli, + "opencode": _detect_opencode, + "openclaw": _detect_openclaw, + "cursor": _detect_cursor, + "aider": _detect_aider, + "windsurf": _detect_windsurf, + "qwen": _detect_qwen, +} + + +def is_detected(tool: str) -> bool: + fn = _DETECTORS.get(tool) + if fn is None: + return False + try: + return fn() + except Exception: + return False + + +# Fixed-width labels β€” mirrors the bash version +_TOOL_LABELS: Dict[str, Tuple[str, str]] = { + "claude-code": ("Claude Code", "(claude.ai/code)"), + "copilot": ("Copilot", "(~/.github + ~/.copilot)"), + "antigravity": ("Antigravity", "(~/.gemini/antigravity)"), + "gemini-cli": ("Gemini CLI", "(gemini extension)"), + "opencode": ("OpenCode", "(opencode.ai)"), + "openclaw": ("OpenClaw", "(~/.openclaw)"), + "cursor": ("Cursor", "(.cursor/rules)"), + "aider": ("Aider", "(CONVENTIONS.md)"), + "windsurf": ("Windsurf", "(.windsurfrules)"), + "qwen": ("Qwen Code", "(~/.qwen/agents)"), +} + + +def tool_label(tool: str) -> str: + name, detail = _TOOL_LABELS.get(tool, (tool, "")) + return f"{name:<14} {detail}" + + +# --------------------------------------------------------------------------- +# Interactive selector +# --------------------------------------------------------------------------- + +def interactive_select() -> List[str]: + """Show an interactive TUI and return the list of selected tool names.""" + selected = [is_detected(t) for t in ALL_TOOLS] + detected_map = list(selected) + n = len(ALL_TOOLS) + + while True: + # Header + print() + print(_box_line()) + print(_box_row(f"{BOLD} The Agency -- Tool Installer{RESET}")) + print(_box_line()) + print() + print(f" {DIM}System scan: [*] = detected on this machine{RESET}") + print() + + # Tool rows + for i, t in enumerate(ALL_TOOLS): + num = i + 1 + label = tool_label(t) + dot = f"{GREEN}[*]{RESET}" if detected_map[i] else f"{DIM}[ ]{RESET}" + chk = f"{GREEN}[x]{RESET}" if selected[i] else f"{DIM}[ ]{RESET}" + print(f" {chk} {num}) {dot} {label}") + + # Controls + print() + print(" " + "-" * 48) + print( + f" {CYAN}[1-{n}]{RESET} toggle " + f"{CYAN}[a]{RESET} all " + f"{CYAN}[n]{RESET} none " + f"{CYAN}[d]{RESET} detected" + ) + print(f" {GREEN}[Enter]{RESET} install {RED}[q]{RESET} quit") + print() + + try: + user_input = input(" >> ").strip() + except (EOFError, KeyboardInterrupt): + print() + info("Aborted.") + sys.exit(0) + + # Total lines we printed (for clear / redraw) + total_lines = n + 14 + + if user_input.lower() == "q": + print() + info("Aborted.") + sys.exit(0) + elif user_input.lower() == "a": + selected = [True] * n + elif user_input.lower() == "n": + selected = [False] * n + elif user_input.lower() == "d": + selected = list(detected_map) + elif user_input == "": + if any(selected): + break + else: + print(f" {YELLOW}Nothing selected -- pick a tool or press q to quit.{RESET}") + time.sleep(1) + total_lines += 1 + else: + toggled = False + for token in user_input.split(): + if token.isdigit(): + idx = int(token) - 1 + if 0 <= idx < n: + selected[idx] = not selected[idx] + toggled = True + if not toggled: + print(f" {RED}Invalid. Enter a number 1-{n}, or a command.{RESET}") + time.sleep(1) + total_lines += 1 + + # Clear previous UI for redraw (move cursor up + clear lines) + for _ in range(total_lines): + print("\033[1A\033[2K", end="") + + return [t for i, t in enumerate(ALL_TOOLS) if selected[i]] + + +# --------------------------------------------------------------------------- +# Installers +# --------------------------------------------------------------------------- + +def _copy_agent_sources(dest: Path) -> int: + """Copy raw agent .md files from category dirs into *dest*. Return count.""" + count = 0 + for dirname in AGENT_DIRS: + dirpath = REPO_ROOT / dirname + if not dirpath.is_dir(): + continue + for md in sorted(dirpath.rglob("*.md")): + try: + with open(md, encoding="utf-8") as fh: + first_line = fh.readline().rstrip("\n\r") + except (OSError, UnicodeDecodeError): + continue + if first_line != "---": + continue + shutil.copy2(str(md), str(dest / md.name)) + count += 1 + return count + + +def install_claude_code() -> None: + dest = HOME / ".claude" / "agents" + dest.mkdir(parents=True, exist_ok=True) + count = _copy_agent_sources(dest) + info(f"Claude Code: {count} agents -> {dest}") + + +def install_copilot() -> None: + dest_github = HOME / ".github" / "agents" + dest_copilot = HOME / ".copilot" / "agents" + dest_github.mkdir(parents=True, exist_ok=True) + dest_copilot.mkdir(parents=True, exist_ok=True) + count = _copy_agent_sources(dest_github) + # Copy the same files to the second location + _copy_agent_sources(dest_copilot) + info(f"Copilot: {count} agents -> {dest_github}") + info(f"Copilot: {count} agents -> {dest_copilot}") + + +def install_antigravity() -> None: + src = INTEGRATIONS / "antigravity" + dest = HOME / ".gemini" / "antigravity" / "skills" + if not src.is_dir(): + error("integrations/antigravity missing. Run convert.py first.") + return + dest.mkdir(parents=True, exist_ok=True) + count = 0 + for skill_dir in sorted(src.iterdir()): + if not skill_dir.is_dir(): + continue + skill_file = skill_dir / "SKILL.md" + if not skill_file.is_file(): + continue + target = dest / skill_dir.name + target.mkdir(parents=True, exist_ok=True) + shutil.copy2(str(skill_file), str(target / "SKILL.md")) + count += 1 + info(f"Antigravity: {count} skills -> {dest}") + + +def install_gemini_cli() -> None: + src = INTEGRATIONS / "gemini-cli" + dest = HOME / ".gemini" / "extensions" / "agency-agents" + if not src.is_dir(): + error("integrations/gemini-cli missing. Run convert.py first.") + return + manifest = src / "gemini-extension.json" + skills_dir = src / "skills" + if not manifest.is_file(): + error("integrations/gemini-cli/gemini-extension.json missing. Run convert.py first.") + return + if not skills_dir.is_dir(): + error("integrations/gemini-cli/skills missing. Run convert.py first.") + return + dest.mkdir(parents=True, exist_ok=True) + (dest / "skills").mkdir(parents=True, exist_ok=True) + shutil.copy2(str(manifest), str(dest / "gemini-extension.json")) + count = 0 + for skill_dir in sorted(skills_dir.iterdir()): + if not skill_dir.is_dir(): + continue + skill_file = skill_dir / "SKILL.md" + if not skill_file.is_file(): + continue + target = dest / "skills" / skill_dir.name + target.mkdir(parents=True, exist_ok=True) + shutil.copy2(str(skill_file), str(target / "SKILL.md")) + count += 1 + info(f"Gemini CLI: {count} skills -> {dest}") + + +def install_opencode() -> None: + src = INTEGRATIONS / "opencode" / "agents" + dest = Path.cwd() / ".opencode" / "agents" + if not src.is_dir(): + error("integrations/opencode missing. Run convert.py first.") + return + dest.mkdir(parents=True, exist_ok=True) + count = 0 + for md in sorted(src.glob("*.md")): + shutil.copy2(str(md), str(dest / md.name)) + count += 1 + info(f"OpenCode: {count} agents -> {dest}") + warn("OpenCode: project-scoped. Run from your project root to install there.") + + +def install_openclaw() -> None: + src = INTEGRATIONS / "openclaw" + dest = HOME / ".openclaw" / "agency-agents" + if not src.is_dir(): + error("integrations/openclaw missing. Run convert.py first.") + return + dest.mkdir(parents=True, exist_ok=True) + count = 0 + for ws_dir in sorted(src.iterdir()): + if not ws_dir.is_dir(): + continue + target = dest / ws_dir.name + target.mkdir(parents=True, exist_ok=True) + for fname in ("SOUL.md", "AGENTS.md", "IDENTITY.md"): + src_f = ws_dir / fname + if src_f.is_file(): + shutil.copy2(str(src_f), str(target / fname)) + # Register with OpenClaw if available + if shutil.which("openclaw"): + try: + subprocess.run( + ["openclaw", "agents", "add", ws_dir.name, + "--workspace", str(target), "--non-interactive"], + check=False, + capture_output=True, + ) + except Exception: + pass + count += 1 + info(f"OpenClaw: {count} workspaces -> {dest}") + if shutil.which("openclaw"): + warn("OpenClaw: run 'openclaw gateway restart' to activate new agents") + + +def install_cursor() -> None: + src = INTEGRATIONS / "cursor" / "rules" + dest = Path.cwd() / ".cursor" / "rules" + if not src.is_dir(): + error("integrations/cursor missing. Run convert.py first.") + return + dest.mkdir(parents=True, exist_ok=True) + count = 0 + for mdc in sorted(src.glob("*.mdc")): + shutil.copy2(str(mdc), str(dest / mdc.name)) + count += 1 + info(f"Cursor: {count} rules -> {dest}") + warn("Cursor: project-scoped. Run from your project root to install there.") + + +def install_aider() -> None: + src = INTEGRATIONS / "aider" / "CONVENTIONS.md" + dest = Path.cwd() / "CONVENTIONS.md" + if not src.is_file(): + error("integrations/aider/CONVENTIONS.md missing. Run convert.py first.") + return + if dest.is_file(): + warn(f"Aider: CONVENTIONS.md already exists at {dest} (remove to reinstall).") + return + shutil.copy2(str(src), str(dest)) + info(f"Aider: installed -> {dest}") + warn("Aider: project-scoped. Run from your project root to install there.") + + +def install_windsurf() -> None: + src = INTEGRATIONS / "windsurf" / ".windsurfrules" + dest = Path.cwd() / ".windsurfrules" + if not src.is_file(): + error("integrations/windsurf/.windsurfrules missing. Run convert.py first.") + return + if dest.is_file(): + warn(f"Windsurf: .windsurfrules already exists at {dest} (remove to reinstall).") + return + shutil.copy2(str(src), str(dest)) + info(f"Windsurf: installed -> {dest}") + warn("Windsurf: project-scoped. Run from your project root to install there.") + + +def install_qwen() -> None: + src = INTEGRATIONS / "qwen" / "agents" + dest = Path.cwd() / ".qwen" / "agents" + if not src.is_dir(): + error("integrations/qwen missing. Run convert.py first.") + return + dest.mkdir(parents=True, exist_ok=True) + count = 0 + for md in sorted(src.glob("*.md")): + shutil.copy2(str(md), str(dest / md.name)) + count += 1 + info(f"Qwen Code: installed {count} agents to {dest}") + warn("Qwen Code: project-scoped. Run from your project root to install there.") + warn("Tip: Run '/agents manage' in Qwen Code to refresh, or restart session") + + +_INSTALLERS: Dict[str, Callable[[], None]] = { + "claude-code": install_claude_code, + "copilot": install_copilot, + "antigravity": install_antigravity, + "gemini-cli": install_gemini_cli, + "opencode": install_opencode, + "openclaw": install_openclaw, + "cursor": install_cursor, + "aider": install_aider, + "windsurf": install_windsurf, + "qwen": install_qwen, +} + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main(argv: Optional[List[str]] = None) -> None: + parser = argparse.ArgumentParser( + description="Install The Agency agents into your local agentic tool(s).", + ) + parser.add_argument( + "--tool", + default="all", + choices=ALL_TOOLS + ["all"], + help="Install only the specified tool (default: all)", + ) + group = parser.add_mutually_exclusive_group() + group.add_argument( + "--interactive", + action="store_true", + default=False, + help="Show interactive selector (default when run in a terminal)", + ) + group.add_argument( + "--no-interactive", + action="store_true", + default=False, + help="Skip interactive selector, install all detected tools", + ) + args = parser.parse_args(argv) + + # Preflight + if not INTEGRATIONS.is_dir(): + error("integrations/ not found. Run 'python scripts/convert.py' first.") + sys.exit(1) + + # Decide interactive mode + interactive_mode: Optional[bool] = None + if args.interactive: + interactive_mode = True + elif args.no_interactive: + interactive_mode = False + elif args.tool != "all": + interactive_mode = False + + # If auto, show interactive when stdin/stdout are TTYs and tool is "all" + if interactive_mode is None: + interactive_mode = ( + sys.stdin.isatty() + and sys.stdout.isatty() + and args.tool == "all" + ) + + selected_tools: List[str] = [] + + if interactive_mode: + selected_tools = interactive_select() + elif args.tool != "all": + selected_tools = [args.tool] + else: + # Non-interactive: auto-detect + header("The Agency -- Scanning for installed tools...") + print() + for t in ALL_TOOLS: + if is_detected(t): + selected_tools.append(t) + print(f" {GREEN}[*]{RESET} {tool_label(t)} {DIM}detected{RESET}") + else: + print(f" {DIM}[ ] {tool_label(t)} not found{RESET}") + + if not selected_tools: + warn("No tools selected or detected. Nothing to install.") + print() + dim(" Tip: use --tool to force-install a specific tool.") + dim(f" Available: {' '.join(ALL_TOOLS)}") + sys.exit(0) + + print() + header("The Agency -- Installing agents") + print(f" Repo: {REPO_ROOT}") + print(f" Installing: {' '.join(selected_tools)}") + print() + + installed = 0 + for t in selected_tools: + fn = _INSTALLERS.get(t) + if fn: + fn() + installed += 1 + + # Done box + msg = f" Done! Installed {installed} tool(s)." + print() + print(_box_line()) + print(_box_row(f"{GREEN}{BOLD}{msg}{RESET}")) + print(_box_line()) + print() + dim(" Run 'python scripts/convert.py' to regenerate after adding or editing agents.") + print() + + +if __name__ == "__main__": + main() diff --git a/scripts/lint-agents.py b/scripts/lint-agents.py new file mode 100644 index 00000000..b8bedd05 --- /dev/null +++ b/scripts/lint-agents.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +""" +lint-agents.py β€” Validate agent markdown files. + +Checks: + 1. YAML frontmatter must exist with name, description, color (ERROR) + 2. Recommended sections checked but only warned (WARN) + 3. File must have meaningful content (WARN) + +Usage: + python scripts/lint-agents.py [file ...] + If no files given, scans all agent directories. + +Compatible with Python 3.7+. No third-party dependencies. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import List, Optional, Tuple + +# Resolve this script's own directory so we can import the sibling module. +_SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(_SCRIPT_DIR)) + +from utils import AGENT_DIRS, get_body, get_field # noqa: E402 + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +REQUIRED_FRONTMATTER = ["name", "description", "color"] +RECOMMENDED_SECTIONS = ["Identity", "Core Mission", "Critical Rules"] + +# --------------------------------------------------------------------------- +# Linter +# --------------------------------------------------------------------------- + +def lint_file(filepath: str) -> Tuple[int, int]: + """ + Lint a single agent file. + + Returns ``(errors, warnings)`` counts. + """ + errors = 0 + warnings = 0 + + # 1. Check frontmatter opening + try: + with open(filepath, encoding="utf-8") as fh: + first_line = fh.readline().rstrip("\n\r") + except (OSError, UnicodeDecodeError) as exc: + print(f"ERROR {filepath}: cannot read file ({exc})") + return 1, 0 + + if first_line != "---": + print(f"ERROR {filepath}: missing frontmatter opening ---") + return 1, 0 + + # Extract frontmatter (between first and second ---) + frontmatter_lines: List[str] = [] + try: + with open(filepath, encoding="utf-8") as fh: + fh.readline() # skip first --- + for line in fh: + stripped = line.rstrip("\n\r") + if stripped == "---": + break + frontmatter_lines.append(stripped) + except (OSError, UnicodeDecodeError): + pass + + if not frontmatter_lines: + print(f"ERROR {filepath}: empty or malformed frontmatter") + return 1, 0 + + # 2. Check required frontmatter fields + for field in REQUIRED_FRONTMATTER: + # Match field at the start of a line + if not any(line.startswith(f"{field}:") for line in frontmatter_lines): + print(f"ERROR {filepath}: missing frontmatter field '{field}'") + errors += 1 + + # 3. Check recommended sections (warn only) + body = get_body(filepath) + body_lower = body.lower() + for section in RECOMMENDED_SECTIONS: + if section.lower() not in body_lower: + print(f"WARN {filepath}: missing recommended section '{section}'") + warnings += 1 + + # 4. Check file has meaningful content + word_count = len(body.split()) + if word_count < 50: + print(f"WARN {filepath}: body seems very short (< 50 words)") + warnings += 1 + + return errors, warnings + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main(argv: Optional[List[str]] = None) -> None: + if argv is None: + argv = sys.argv[1:] + + files: List[str] = [] + if argv: + files = argv + else: + # Scan all agent directories (relative to repo root) + repo_root = _SCRIPT_DIR.parent + for dirname in AGENT_DIRS: + dirpath = repo_root / dirname + if not dirpath.is_dir(): + continue + for md in sorted(dirpath.rglob("*.md")): + files.append(str(md)) + + if not files: + print("No agent files found.") + sys.exit(1) + + print(f"Linting {len(files)} agent files...") + print() + + total_errors = 0 + total_warnings = 0 + for filepath in files: + e, w = lint_file(filepath) + total_errors += e + total_warnings += w + + print() + print(f"Results: {total_errors} error(s), {total_warnings} warning(s) in {len(files)} files.") + + if total_errors > 0: + print("FAILED: fix the errors above before merging.") + sys.exit(1) + else: + print("PASSED") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/scripts/utils.py b/scripts/utils.py new file mode 100644 index 00000000..3c09c259 --- /dev/null +++ b/scripts/utils.py @@ -0,0 +1,207 @@ +""" +Shared utilities for the agency-agents Python scripts. + +Provides colour output, YAML frontmatter parsing, slug generation, +and agent-file discovery β€” all with zero third-party dependencies. + +Compatible with Python 3.7+. +""" + +from __future__ import annotations + +import os +import re +import sys +from pathlib import Path +from typing import Iterator, List, Optional, Tuple + +# --------------------------------------------------------------------------- +# Canonical agent category directories (same order as the bash scripts) +# --------------------------------------------------------------------------- +AGENT_DIRS: List[str] = [ + "design", + "engineering", + "game-development", + "marketing", + "paid-media", + "sales", + "product", + "project-management", + "testing", + "support", + "spatial-computing", + "specialized", +] + +# --------------------------------------------------------------------------- +# Colour helpers β€” auto-disabled when stdout is not a TTY, NO_COLOR is set, +# or TERM is "dumb". On Windows, attempt to enable ANSI via the console API. +# --------------------------------------------------------------------------- + +def _supports_color() -> bool: + """Return True if stdout can render ANSI escape codes.""" + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("TERM", "") == "dumb": + return False + if not hasattr(sys.stdout, "isatty") or not sys.stdout.isatty(): + return False + # On Windows, enable virtual-terminal processing if possible. + if sys.platform == "win32": + try: + import ctypes + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + # STD_OUTPUT_HANDLE = -11 + handle = kernel32.GetStdHandle(-11) + # ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 + mode = ctypes.c_uint32() + if kernel32.GetConsoleMode(handle, ctypes.byref(mode)): + kernel32.SetConsoleMode(handle, mode.value | 0x0004) + return True + return False + except Exception: + return False + return True + + +_COLOR = _supports_color() + +GREEN = "\033[0;32m" if _COLOR else "" +YELLOW = "\033[1;33m" if _COLOR else "" +RED = "\033[0;31m" if _COLOR else "" +CYAN = "\033[0;36m" if _COLOR else "" +BOLD = "\033[1m" if _COLOR else "" +DIM = "\033[2m" if _COLOR else "" +RESET = "\033[0m" if _COLOR else "" + + +def info(msg: str) -> None: + """Print a green [OK] message.""" + print(f"{GREEN}[OK]{RESET} {msg}") + + +def warn(msg: str) -> None: + """Print a yellow [!!] warning message.""" + print(f"{YELLOW}[!!]{RESET} {msg}") + + +def error(msg: str) -> None: + """Print a red [ERR] error message to stderr.""" + print(f"{RED}[ERR]{RESET} {msg}", file=sys.stderr) + + +def header(msg: str) -> None: + """Print a bold header line (with a blank line above).""" + print(f"\n{BOLD}{msg}{RESET}") + + +def dim(msg: str) -> None: + """Print a dimmed message.""" + print(f"{DIM}{msg}{RESET}") + + +# --------------------------------------------------------------------------- +# YAML frontmatter helpers +# --------------------------------------------------------------------------- + +def get_field(field: str, filepath: str) -> str: + """ + Extract a single ``field: value`` from the YAML frontmatter of *filepath*. + + Returns the value as a stripped string, or ``""`` if the field is absent. + The frontmatter is the block between the first and second ``---`` lines. + """ + with open(filepath, encoding="utf-8") as fh: + in_frontmatter = False + for line in fh: + stripped = line.rstrip("\n") + if stripped == "---": + if not in_frontmatter: + in_frontmatter = True + continue + else: + # reached closing --- + break + if in_frontmatter: + prefix = field + ": " + if stripped.startswith(prefix): + return stripped[len(prefix):].strip() + return "" + + +def get_body(filepath: str) -> str: + """ + Return file content after the closing ``---`` of the frontmatter block. + + Leading/trailing whitespace on the whole body is preserved (matching the + bash ``awk`` behaviour), but a single leading newline is stripped if present. + """ + with open(filepath, encoding="utf-8") as fh: + content = fh.read() + + dashes_count = 0 + lines = content.split("\n") + body_start = 0 + for i, line in enumerate(lines): + if line.rstrip("\r") == "---": + dashes_count += 1 + if dashes_count == 2: + body_start = i + 1 + break + + body = "\n".join(lines[body_start:]) + # Strip exactly one leading newline to match bash heredoc output + if body.startswith("\n"): + body = body[1:] + return body + + +def slugify(name: str) -> str: + """ + Convert a human-readable name to a lowercase kebab-case slug. + + ``"Frontend Developer"`` β†’ ``"frontend-developer"`` + """ + s = name.lower() + s = re.sub(r"[^a-z0-9]", "-", s) + s = re.sub(r"-+", "-", s) + s = s.strip("-") + return s + + +# --------------------------------------------------------------------------- +# Agent file discovery +# --------------------------------------------------------------------------- + +def find_agent_files( + repo_root: str, + agent_dirs: Optional[List[str]] = None, +) -> Iterator[Tuple[str, Path]]: + """ + Yield ``(category_dir_name, path)`` for every agent ``.md`` file. + + A file qualifies if its first line is ``---`` and it has a ``name:`` field + in its frontmatter. Files are yielded in sorted order within each + category directory. + """ + if agent_dirs is None: + agent_dirs = AGENT_DIRS + + root = Path(repo_root) + for dirname in agent_dirs: + dirpath = root / dirname + if not dirpath.is_dir(): + continue + md_files = sorted(dirpath.rglob("*.md")) + for md in md_files: + try: + with open(md, encoding="utf-8") as fh: + first_line = fh.readline().rstrip("\n\r") + except (OSError, UnicodeDecodeError): + continue + if first_line != "---": + continue + name = get_field("name", str(md)) + if not name: + continue + yield dirname, md