Skill: nadalpiantini/config-sync (version 1.0.0)

--- name: config-sync description: "Sync AI coding assistant config files from a single source. Generates CLAUDE.md, AGENTS.md, GEMINI.md, .cursor/rules/, .github/copilot-instructions.md, .windsurf/rules/, .clinerules/, CONVENTIONS.md, .kiro/steering/, .amazonq/rules/, .goosehints, .trae/rules/ from your canonical rules. ACTIVAR cuando el usuario dice 'sync configs', 'generate ai rules', 'configurar para otros tools', 'unificar instrucciones', 'config sync'." --- # Config Sync — 16 AI Tools, 1 Source Reads `.claude/rules/` (or `CLAUDE.md`) as canonical source and generates native config files for every major AI coding assistant. ## Problem Each AI coding tool uses a different config file: - Claude Code → `CLAUDE.md` + `.claude/rules/*.md` - Codex CLI → `AGENTS.md` - Gemini CLI → `GEMINI.md` - Cursor → `.cursor/rules/*.mdc` - GitHub Copilot → `.github/copilot-instructions.md` - Windsurf → `.windsurf/rules/*.md` - Cline/Roo → `.clinerules/*.md` - Aider → `CONVENTIONS.md` - Kiro → `.kiro/steering/*.md` - Amazon Q → `.amazonq/rules/*.md` - Goose → `.goosehints` - Trae AI → `.trae/rules/*.md` - Zed → `.rules` - Amp/OpenCode/Warp → `AGENTS.md` (shared) Maintaining 16 files for the same instructions is absurd. Config-sync fixes it. ## How It Works 1. **Source of truth**: `.claude/rules/*.md` (most expressive format — supports glob patterns, frontmatter conditions, always/attached/triggered modes) 2. **Fallback source**: `CLAUDE.md` at repo root if `.claude/rules/` doesn't exist 3. **Transform**: Concatenates all rule files, strips Claude-specific frontmatter, generates each format 4. **Output**: Only generates configs for tools you select (or all by default) ## Usage ### Generate all configs ```bash python3 ~/.openclaw/skills/config-sync/scripts/config-sync.py /path/to/repo ``` ### Generate specific tools only ```bash python3 ~/.openclaw/skills/config-sync/scripts/config-sync.py /path/to/repo --tools codex,gemini,cursor ``` ### Preview without writing ```bash python3 ~/.openclaw/skills/config-sync/scripts/config-sync.py /path/to/repo --dry-run ``` ### Init from existing CLAUDE.md ```bash python3 ~/.openclaw/skills/config-sync/scripts/config-sync.py /path/to/repo --init ``` This creates `.claude/rules/` from your existing `CLAUDE.md` as the canonical source. ## Supported Tools | Tool | Output File | Notes | |------|-----------|-------| | Claude Code | `.claude/rules/*.md` (source, not generated) | Already the canonical format | | Codex CLI | `AGENTS.md` | Flat markdown, no frontmatter | | Gemini CLI | `GEMINI.md` | Flat markdown | | Cursor | `.cursor/rules/config-sync.mdc` | MDC format with description + globs | | GitHub Copilot | `.github/copilot-instructions.md` | Flat markdown | | Windsurf | `.windsurf/rules/config-sync.md` | Flat markdown | | Cline/Roo | `.clinerules/config-sync.md` | Flat markdown | | Aider | `CONVENTIONS.md` | Flat markdown, concise style | | Kiro | `.kiro/steering/config-sync.md` | Flat markdown | | Amazon Q | `.amazonq/rules/config-sync.md` | Flat markdown | | Goose | `.goosehints` | Line-per-rule, no markdown | | Trae AI | `.trae/rules/config-sync.md` | Flat markdown | | Zed | `.rules` | Flat markdown | ## Transformation Rules ### Stripped from source - YAML frontmatter (--- blocks) — tool-specific, not portable - `alwaysApply: true/false` — Claude Code specific - Glob patterns in frontmatter — converted to natural language headers ### Added to each output - Header comment: `<!-- Generated by config-sync. Edit source at .claude/rules/ -->` - Tool-specific header if needed (e.g., MDC frontmatter for Cursor) ### Format adaptations - **Goose** (`.goosehints`): Each rule becomes one line, no markdown - **Cursor** (`.mdc`): Gets MDC frontmatter with `description` and `globs: **` - **Aider** (`CONVENTIONS.md`): Condensed to bullet points, no long explanations - **AGENTS.md**: Standard markdown, used by Codex/Amp/OpenCode/Warp (4 tools, 1 file) ## Workflow 1. Edit `.claude/rules/*.md` (your canonical source) 2. Run `config-sync` to generate all other formats 3. Commit all generated files together 4. Repeat when rules change ## Auto-detect installed tools The script checks for: - `.cursor/` directory → Cursor installed - `.github/copilot-instructions.md` exists → Copilot in use - `.windsurf/` → Windsurf installed - `.clinerules/` → Cline installed - `.kiro/` → Kiro installed - `.amazonq/` → Amazon Q installed - `.trae/` → Trae installed - `AGENTS.md` exists → Codex/Amp/OpenCode in use - `GEMINI.md` exists → Gemini CLI in use If `--tools` is not specified, generates all formats (belt-and-suspenders approach). ## Philosophy One source of truth. Many outputs. Like a compiler: write once in the richest language, emit to every target.

path: scripts/config-sync.py
content: #!/usr/bin/env python3
"""
config-sync — Generate AI coding assistant config files from a single source.

Reads .claude/rules/*.md (or CLAUDE.md) and outputs native configs for
16+ AI coding tools: Codex, Gemini, Cursor, Copilot, Windsurf, Cline, etc.

Usage:
    python3 config-sync.py /path/to/repo [--tools codex,gemini,cursor] [--dry-run] [--init]
"""

import argparse
import os
import sys
import re
from pathlib import Path

GENERATED_HEADER = "<!-- Generated by config-sync. Edit source at .claude/rules/ -->\n"

TOOLS = {
    "codex": {
        "file": "AGENTS.md",
        "desc": "Codex CLI / Amp / OpenCode / Warp",
    },
    "gemini": {
        "file": "GEMINI.md",
        "desc": "Gemini CLI",
    },
    "cursor": {
        "file": ".cursor/rules/config-sync.mdc",
        "desc": "Cursor",
    },
    "copilot": {
        "file": ".github/copilot-instructions.md",
        "desc": "GitHub Copilot",
    },
    "windsurf": {
        "file": ".windsurf/rules/config-sync.md",
        "desc": "Windsurf",
    },
    "cline": {
        "file": ".clinerules/config-sync.md",
        "desc": "Cline / Roo Code",
    },
    "aider": {
        "file": "CONVENTIONS.md",
        "desc": "Aider",
    },
    "kiro": {
        "file": ".kiro/steering/config-sync.md",
        "desc": "Kiro (AWS)",
    },
    "amazonq": {
        "file": ".amazonq/rules/config-sync.md",
        "desc": "Amazon Q Developer",
    },
    "goose": {
        "file": ".goosehints",
        "desc": "Goose (Block)",
    },
    "trae": {
        "file": ".trae/rules/config-sync.md",
        "desc": "Trae AI (ByteDance)",
    },
    "zed": {
        "file": ".rules",
        "desc": "Zed",
    },
}


def strip_frontmatter(content: str) -> str:
    """Remove YAML frontmatter (--- ... ---) from content."""
    return re.sub(r"^---\n.*?\n---\n?", "", content, count=1, flags=re.DOTALL).strip()


def read_source(repo: Path) -> str:
    """Read canonical source: .claude/rules/*.md or CLAUDE.md fallback."""
    rules_dir = repo / ".claude" / "rules"
    parts = []

    if rules_dir.is_dir():
        md_files = sorted(rules_dir.glob("*.md"))
        if md_files:
            for f in md_files:
                raw = f.read_text(encoding="utf-8")
                clean = strip_frontmatter(raw)
                if clean:
                    parts.append(f"## {f.stem}\n\n{clean}")
            return "\n\n".join(parts)

    # Fallback to CLAUDE.md
    claude_md = repo / "CLAUDE.md"
    if claude_md.exists():
        raw = claude_md.read_text(encoding="utf-8")
        clean = strip_frontmatter(raw)
        if clean:
            return clean

    print("ERROR: No .claude/rules/*.md or CLAUDE.md found.", file=sys.stderr)
    sys.exit(1)


def format_codex(source: str) -> str:
    """Flat markdown for AGENTS.md (Codex, Amp, OpenCode, Warp)."""
    return f"{GENERATED_HEADER}# Project Instructions\n\n{source}\n"


def format_gemini(source: str) -> str:
    """Flat markdown for GEMINI.md."""
    return f"{GENERATED_HEADER}# Project Instructions\n\n{source}\n"


def format_cursor(source: str) -> str:
    """MDC format for Cursor rules."""
    mdc_front = "---\ndescription: Project coding rules (auto-synced)\nglobs: **\nalwaysApply: true\n---\n\n"
    return f"{mdc_front}{source}\n"


def format_copilot(source: str) -> str:
    """Flat markdown for GitHub Copilot."""
    return f"{GENERATED_HEADER}# Copilot Instructions\n\n{source}\n"


def format_windsurf(source: str) -> str:
    """Flat markdown for Windsurf rules."""
    return f"{GENERATED_HEADER}# Windsurf Rules\n\n{source}\n"


def format_cline(source: str) -> str:
    """Flat markdown for Cline/Roo."""
    return f"{GENERATED_HEADER}# Cline Rules\n\n{source}\n"


def format_aider(source: str) -> str:
    """Condensed bullet-point style for Aider CONVENTIONS.md."""
    lines = source.split("\n")
    condensed = []
    for line in lines:
        stripped = line.strip()
        if not stripped:
            continue
        if stripped.startswith("## "):
            condensed.append(f"\n# {stripped[3:]}")
        elif stripped.startswith("- ") or stripped.startswith("* "):
            condensed.append(stripped)
        elif stripped.startswith("#"):
            condensed.append(stripped)
        else:
            condensed.append(f"- {stripped}")
    return f"{GENERATED_HEADER}# Conventions\n{'  '.join(condensed)}\n"


def format_kiro(source: str) -> str:
    """Flat markdown for Kiro steering."""
    return f"{GENERATED_HEADER}# Steering Rules\n\n{source}\n"


def format_amazonq(source: str) -> str:
    """Flat markdown for Amazon Q."""
    return f"{GENERATED_HEADER}# Amazon Q Rules\n\n{source}\n"


def format_goose(source: str) -> str:
    """Line-per-rule for .goosehints (no markdown)."""
    lines = source.split("\n")
    hints = []
    for line in lines:
        stripped = line.strip()
        if not stripped:
            continue
        # Strip markdown headers
        stripped = re.sub(r"^#+\s*", "", stripped)
        # Strip list markers
        stripped = re.sub(r"^[-*]\s+", "", stripped)
        if stripped:
            hints.append(stripped)
    return "\n".join(hints) + "\n"


def format_trae(source: str) -> str:
    """Flat markdown for Trae AI."""
    return f"{GENERATED_HEADER}# Trae Rules\n\n{source}\n"


def format_zed(source: str) -> str:
    """Flat markdown for Zed."""
    return f"{GENERATED_HEADER}# Rules\n\n{source}\n"


FORMATTERS = {
    "codex": format_codex,
    "gemini": format_gemini,
    "cursor": format_cursor,
    "copilot": format_copilot,
    "windsurf": format_windsurf,
    "cline": format_cline,
    "aider": format_aider,
    "kiro": format_kiro,
    "amazonq": format_amazonq,
    "goose": format_goose,
    "trae": format_trae,
    "zed": format_zed,
}


def detect_tools(repo: Path) -> list[str]:
    """Detect which tools are already configured in the repo."""
    detected = []
    checks = {
        "cursor": [".cursor"],
        "copilot": [".github/copilot-instructions.md"],
        "windsurf": [".windsurf"],
        "cline": [".clinerules"],
        "kiro": [".kiro"],
        "amazonq": [".amazonq"],
        "trae": [".trae"],
        "codex": ["AGENTS.md"],
        "gemini": ["GEMINI.md"],
        "aider": ["CONVENTIONS.md"],
        "goose": [".goosehints"],
        "zed": [".rules"],
    }
    for tool, paths in checks.items():
        for p in paths:
            if (repo / p).exists():
                detected.append(tool)
                break
    return detected


def init_rules(repo: Path) -> None:
    """Create .claude/rules/ from existing CLAUDE.md."""
    claude_md = repo / "CLAUDE.md"
    if not claude_md.exists():
        print("ERROR: No CLAUDE.md to init from.", file=sys.stderr)
        sys.exit(1)

    rules_dir = repo / ".claude" / "rules"
    rules_dir.mkdir(parents=True, exist_ok=True)

    content = claude_md.read_text(encoding="utf-8")
    content = strip_frontmatter(content)

    # Split on ## headers into separate files
    sections = re.split(r"(?=^## )", content, flags=re.MULTILINE)

    if len(sections) <= 1:
        # No headers, write as single file
        (rules_dir / "project-rules.md").write_text(content, encoding="utf-8")
        print(f"  Created .claude/rules/project-rules.md")
    else:
        for section in sections:
            section = section.strip()
            if not section:
                continue
            # Extract header for filename
            match = re.match(r"^## (.+)", section)
            if match:
                name = re.sub(r"[^a-z0-9]+", "-", match.group(1).lower()).strip("-")
                filename = f"{name}.md"
            else:
                filename = "general.md"
            (rules_dir / filename).write_text(section, encoding="utf-8")
            print(f"  Created .claude/rules/{filename}")

    print(f"  Init complete. {len(list(rules_dir.glob('*.md')))} rule files created.")


def main():
    parser = argparse.ArgumentParser(description="Sync AI config files from .claude/rules/")
    parser.add_argument("repo", help="Path to project repository")
    parser.add_argument("--tools", help="Comma-separated tool list (default: all)")
    parser.add_argument("--dry-run", action="store_true", help="Preview without writing")
    parser.add_argument("--init", action="store_true", help="Create .claude/rules/ from CLAUDE.md")
    args = parser.parse_args()

    repo = Path(args.repo).resolve()
    if not repo.is_dir():
        print(f"ERROR: {repo} is not a directory", file=sys.stderr)
        sys.exit(1)

    if args.init:
        print(f"Initializing .claude/rules/ from CLAUDE.md...")
        init_rules(repo)
        return

    source = read_source(repo)
    if not source:
        print("ERROR: Source is empty.", file=sys.stderr)
        sys.exit(1)

    # Determine which tools to generate
    if args.tools:
        selected = [t.strip() for t in args.tools.split(",") if t.strip() in TOOLS]
    else:
        selected = list(TOOLS.keys())

    if not selected:
        print("ERROR: No valid tools selected.", file=sys.stderr)
        sys.exit(1)

    detected = detect_tools(repo)
    print(f"Source: {len(source)} chars from .claude/rules/")
    print(f"Detected tools: {', '.join(detected) if detected else 'none (generating all)'}")
    print(f"Generating: {', '.join(selected)}")
    print()

    for tool in selected:
        config = TOOLS[tool]
        formatter = FORMATTERS[tool]
        output = formatter(source)
        outpath = repo / config["file"]

        if args.dry_run:
            print(f"  [{config['desc']}] {config['file']} ({len(output)} chars)")
            print(f"    Preview (first 200 chars): {output[:200]}...")
            print()
        else:
            outpath.parent.mkdir(parents=True, exist_ok=True)
            outpath.write_text(output, encoding="utf-8")
            print(f"  ✓ {config['file']} ({config['desc']}) — {len(output)} chars")

    if not args.dry_run:
        print(f"\nDone. {len(selected)} config files generated. Edit source at .claude/rules/")


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)