spine-2d-animation

SkillFiles & storage

Turn pre-existing 2D character assets into fully animated, interactive Spine animations.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the spine-2d-animation skill

About this capability

A curated guide to convention files AI agents read, write, and act on: AGENTS.md, CLAUDE.md, SKILL.md, llms.txt, MCP configs, rules, and examples.

What this skill tells your AI

The instructions your AI receives, as published by itamarzand88/awesome-agent-conventions in conventions/skill-md/examples/design-creative/spine-2d-animation/SKILL.md and read by ahel’s review.


name: spine-animation description: > Create Spine 2D skeletal animations from pre-existing character assets (separated body-part PNGs, atlas spritesheet, or a full character image). Use this skill whenever the user wants to animate a 2D character, create Spine JSON from existing art assets, rig a character with bones, build walk/idle/run/attack animations, produce an interactive Spine Web Player preview, or generate Spine-compatible export files (.json + .atlas + .png). Also trigger when the user mentions "Spine animation", "2D rigging", "skeletal animation", "bone animation", "cutout animation", "animate this character", "make this walk", "create walk cycle", or uploads separated character body parts and wants them animated. This skill handles the full pipeline: asset analysis, skeleton rigging, animation keyframing, Spine JSON export, and interactive HTML5 preview.

Spine Animation Skill

Turn pre-existing 2D character assets into fully animated, interactive Spine animations.

Step 0: Set Up Scripts

This skill includes Python scripts that do the heavy lifting. Claude MUST write them to disk before use. Each script is embedded below — Claude should save them to /home/claude/spine-scripts/ at the start of every session.

mkdir -p /home/claude/spine-scripts
pip install opencv-python Pillow numpy google-generativeai --break-system-packages -q

Embedded Scripts

The following scripts are auto-injected from the repository's scripts/ directory. Claude: read these carefully, then write each one to /home/claude/spine-scripts/ before running the pipeline.

#!/usr/bin/env python3
"""
split_character.py — Generate a sprite-sheet atlas from a full character image
using Google Gemini image generation, then segment individual body parts via
OpenCV connected-components analysis.

Usage:
    python split_character.py <input_image> [--output-dir output_parts]
        [--atlas-out atlas.png] [--min-area 500] [--padding 12]
        [--bg-threshold 240]

Requires:
    pip install google-generativeai opencv-python Pillow numpy
    Environment variable GEMINI_API_KEY must be set.
"""

import argparse
import os
import sys

import cv2
import numpy as np
from PIL import Image


def get_gemini_client():
    """Initialise the Gemini generative-AI client, or exit with a helpful
    error if the API key is missing."""
    api_key = os.environ.get("GEMINI_API_KEY")
    if not api_key:
        print(
            "ERROR: GEMINI_API_KEY environment variable is not set.\n"
            "Get a free API key at: https://aistudio.google.com/app/apikey\n"
            "Then run:\n"
            "  export GEMINI_API_KEY=your_key_here",
            file=sys.stderr,
        )
        sys.exit(1)

    from google import genai

    client = genai.Client(api_key=api_key)
    return client


POSITIVE_PROMPT = (
    "A complete 2D game sprite sheet texture atlas for Spine animation of the "
    "exact character in the reference image. The character is completely "
    "deconstructed into separated, isolated body parts. Separated individual "
    "parts laid out flatly: isolated head, isolated torso, isolated upper arms, "
    "lower arms, hands, upper legs, lower legs, and feet. Spread out with clear "
    "space between every single body part. No overlapping parts. Clean solid "
    "white background. CRITICAL: Maintain the exact same art style, exact same "
    "shading, exact face, and exact color palette as the reference image. "
    "Identical style match, 2D game asset, flat layout, character design sheet."
)

NEGATIVE_PROMPT = (
    "3D, realistic, altered style, different art style, different face, "
    "redesign, overlapping parts, connected limbs, full body standing, dynamic "
    "pose, background scenery, shadows, gradients on background, messy layout, "
    "missing limbs, merged layers, text, watermarks."
)


def generate_atlas(client, input_image_path: str, atlas_out: str) -> str:
    """Send the reference image to Gemini and save the generated atlas PNG."""
    from google.genai import types

    ref_image = Image.open(input_image_path)

    response = client.models.generate_content(
        model="gemini-3.1-flash-image-preview",
        contents=[
            POSITIVE_PROMPT,
            f"Negative prompt: {NEGATIVE_PROMPT}",
            ref_image,
        ],
        config=types.GenerateContentConfig(
            response_modalities=["IMAGE", "TEXT"],
        ),
    )

    # Extract the generated image from the response parts
    for part in response.candidates[0].content.parts:
        if part.inline_data is not None:
            image_data = part.inline_data.data
            with open(atlas_out, "wb") as f:
                f.write(image_data)
            return atlas_out

    print("ERROR: Gemini did not return an image in its response.", file=sys.stderr)
    sys.exit(1)


def segment_parts(
    atlas_path: str,
    output_dir: str,
    min_area: int = 500,
    padding: int = 12,
    bg_threshold: int = 240,
) -> list[str]:
    """Detect individual parts in the atlas using connected-components analysis.

    Returns a list of saved part file paths.
    """
    img = cv2.imread(atlas_path, cv2.IMREAD_UNCHANGED)
    if img is None:
        print(f"ERROR: Could not read atlas image: {atlas_path}", file=sys.stderr)
        sys.exit(1)

    # Convert to RGBA if needed
    if img.shape[2] == 3:
        img = cv2.cvtColor(img, cv2.COLOR_BGR2BGRA)

    # Build a foreground mask: pixels whose RGB channels are all below the
    # background threshold are considered foreground.
    bgr = img[:, :, :3]
    gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
    _, mask = cv2.threshold(gray, bg_threshold, 255, cv2.THRESH_BINARY_INV)

    # Connected-components analysis (8-connectivity)
    num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(
        mask, connectivity=8
    )

    os.makedirs(output_dir, exist_ok=True)

    saved: list[str] = []
    part_idx = 0
    h_img, w_img = img.shape[:2]

    for label_id in range(1, num_labels):  # skip background (label 0)
        area = stats[label_id, cv2.CC_STAT_AREA]
        if area < min_area:
            continue

        x = stats[label_id, cv2.CC_STAT_LEFT]
        y = stats[label_id, cv2.CC_STAT_TOP]
        w = stats[label_id, cv2.CC_STAT_WIDTH]
        h = stats[label_id, cv2.CC_STAT_HEIGHT]

        # Apply padding (clamped to image bounds)
        x1 = max(x - padding, 0)
        y1 = max(y - padding, 0)
        x2 = min(x + w + padding, w_img)
        y2 = min(y + h + padding, h_img)

        # Crop the RGBA region
        crop = img[y1:y2, x1:x2].copy()

        # Zero-out pixels that don't belong to this component (make transparent)
        label_region = labels[y1:y2, x1:x2]
        component_mask = label_region == label_id
        crop[~component_mask] = [0, 0, 0, 0]

        out_path = os.path.join(output_dir, f"part_{part_idx:02d}.png")
        cv2.imwrite(out_path, crop)
        saved.append(out_path)
        part_idx += 1

    return saved


def main():
    parser = argparse.ArgumentParser(
        description="Generate a sprite atlas from a character image using "
        "Gemini, then segment into individual body parts."
    )
    parser.add_argument("input_image", help="Path to the character reference image")
    parser.add_argument(
        "--output-dir",
        default="output_parts",
        help="Directory for cropped part PNGs (default: output_parts)",
    )
    parser.add_argument(
        "--atlas-out",
        default="atlas.png",
        help="Output path for the generated atlas PNG (default: atlas.png)",
    )
    parser.add_argument(
        "--min-area",
        type=int,
        default=500,
        help="Minimum component area in pixels to keep (default: 500)",
    )
    parser.add_argument(
        "--padding",
        type=int,
        default=12,
        help="Padding in pixels around each cropped part (default: 12)",
    )
    parser.add_argument(
        "--bg-threshold",
        type=int,
        default=240,
        help="Grayscale threshold above which pixels are treated as background (default: 240)",
    )
    args = parser.parse_args()

    if not os.path.isfile(args.input_image):
        print(f"ERROR: Input image not found: {args.input_image}", file=sys.stderr)
        sys.exit(1)

    # --- Step 1: Generate atlas ---
    print("[1/3] Generating atlas …")
    client = get_gemini_client()
    generate_atlas(client, args.input_image, args.atlas_out)
    print(f"      Atlas saved to {args.atlas_out}")

    # --- Step 2: Segment parts ---
    print("[2/3] Segmenting parts …")
    parts = segment_parts(
        args.atlas_out,
        args.output_dir,
        min_area=args.min_area,
        padding=args.padding,
        bg_threshold=args.bg_threshold,
    )
    print(f"      Found {len(parts)} parts → {args.output_dir}/")
    for p in parts:
        print(f"        • {os.path.basename(p)}")

    # --- Step 3: Done ---
    print("[3/3] Done ✓")
    print(f"\nParts are in: {args.output_dir}/")
    print("You can now feed them into position_parts.py (Step 1 of the Spine pipeline).")


if __name__ == "__main__":
    main()
#!/usr/bin/env python3
"""
position_parts.py — Part positioning via SIFT + RANSAC homography, z-order via occlusion.

Given a fully assembled character image and individual body-part PNGs,
determines where each part goes (x, y, scale, rotation) and the draw order.

Algorithm:
  Phase 1 — SIFT keypoint matching + RANSAC homography
    - Extract SIFT features from each part (alpha-masked) and the reference
    - Match descriptors via FLANN (knnMatch + Lowe's ratio test)
    - Estimate homography via RANSAC → extract position, scale, rotation
    - For small/low-texture parts that fail SIFT: fall back to template matching

  Phase 2 — Pairwise occlusion voting for z-order
    - Sample overlap pixels, compare to reference → occlusion graph → topo sort

Usage:
  python3 position_parts.py \
    --reference character.png \
    --parts parts_folder/ \
    --output layout.json \
    [--min-matches 4] \
    [--ratio 0.80] \
    [--debug debug_folder/]
"""

import argparse, json, os, sys, math
from pathlib import Path
from collections import defaultdict

import cv2
import numpy as np
from PIL import Image


def load_rgba(path):
    return np.array(Image.open(path).convert("RGBA"))

def create_foreground_mask(rgba, bg_color=(255,255,255), bg_threshold=30):
    alpha = rgba[:, :, 3]
    is_opaque = alpha > 128
    rgb = rgba[:, :, :3].astype(float)
    dist = np.sqrt(np.sum((rgb - np.array(bg_color, dtype=float)) ** 2, axis=2))
    mask = (is_opaque & (dist > bg_threshold)).astype(np.uint8) * 255
    k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
    return cv2.morphologyEx(cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k), cv2.MORPH_OPEN, k)


# ─────────────────────────────────────────────────────────────────
# Phase 1: SIFT + RANSAC
# ─────────────────────────────────────────────────────────────────

def sift_match_part(ref_gray, ref_kp, ref_des, part_rgba,
                    sift, ratio_thresh=0.80, min_matches=4):
    """
    Match a part to the reference using SIFT + FLANN + RANSAC affine transform.
    Uses estimateAffinePartial2D (4 DOF: translate + scale + rotation) instead
    of full homography — much more robust with sparse matches on game art.
    Returns dict with position/scale/rotation/score, or None.
    """
    part_h, part_w = part_rgba.shape[:2]
    part_gray = cv2.cvtColor(part_rgba[:, :, :3], cv2.COLOR_RGB2GRAY)
    part_mask = (part_rgba[:, :, 3] > 128).astype(np.uint8) * 255

    part_kp, part_des = sift.detectAndCompute(part_gray, part_mask)
    if part_des is None or len(part_kp) < 2:
        return None

    # FLANN matching
    flann = cv2.FlannBasedMatcher(dict(algorithm=1, trees=5), dict(checks=150))
    try:
        matches = flann.knnMatch(part_des, ref_des, k=2)
    except cv2.error:
        return None

    # Lowe's ratio test
    good = []
    for pair in matches:
        if len(pair) == 2 and pair[0].distance < ratio_thresh * pair[1].distance:
            good.append(pair[0])

    if len(good) < min_matches:
        return None

    src_pts = np.float32([part_kp[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
    dst_pts = np.float32([ref_kp[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)

    # RANSAC similarity transform (4 DOF: translate + uniform scale + rotation)
    # This is much more constrained than homography (8 DOF) and needs only 2 points
    M, inliers_mask = cv2.estimateAffinePartial2D(
        src_pts, dst_pts, method=cv2.RANSAC, ransacReprojThreshold=5.0)

    if M is None or inliers_mask is None:
        return None

    inliers = int(inliers_mask.sum())
    if inliers < min_matches:
        return None

    # Extract scale and rotation from 2x3 affine matrix
    # M = [[s*cos(θ), -s*sin(θ), tx], [s*sin(θ), s*cos(θ), ty]]
    scale = np.sqrt(M[0,0]**2 + M[1,0]**2)
    rotation = math.degrees(math.atan2(M[1,0], M[0,0]))

    # Sanity: game parts should be ~0.5–2.0x scale, ~0° rotation
    if scale < 0.3 or scale > 3.0:
        return None
    if abs(rotation) > 20:
        return None

    # Transform corners via the affine matrix
    corners = np.float32([[0,0],[part_w,0],[part_w,part_h],[0,part_h]]).reshape(-1,1,2)
    transformed = cv2.transform(corners, M).reshape(-1, 2)

    x_min, y_min = transformed[:, 0].min(), transformed[:, 1].min()
    x_max, y_max = transformed[:, 0].max(), transformed[:, 1].max()
    out_w, out_h = x_max - x_min, y_max - y_min

    if out_w < 5 or out_h < 5:
        return None

    inlier_ratio = inliers / len(good) if good else 0

    return {
        "x": int(round(x_min)), "y": int(round(y_min)),
        "width": int(round(out_w)), "height": int(round(out_h)),
        "original_width": part_w, "original_height": part_h,
        "scale": round(scale, 4), "rotation": round(rotation, 2),
        "score": round(inlier_ratio, 4),
        "n_matches": inliers, "n_good": len(good),
        "n_keypoints": len(part_kp), "method": "sift",
    }


def template_match_fallback(ref_bgr, ref_fg_mask, part_bgra,
                            scales=None):
    """Fallback for parts too small/featureless for SIFT."""
    if scales is None:
        scales = (0.85, 0.9, 0.95, 1.0, 1.05, 1.1, 1.15)
    ref_h, ref_w = ref_bgr.shape[:2]
    best = None

    for scale in scales:
        sw = max(1, int(part_bgra.shape[1] * scale))
        sh = max(1, int(part_bgra.shape[0] * scale))
        if sw >= ref_w - 2 or sh >= ref_h - 2:
            continue

        interp = cv2.INTER_AREA if scale < 1 else cv2.INTER_LINEAR
        scaled = cv2.resize(part_bgra, (sw, sh), interpolation=interp)
        tmpl_bgr = cv2.cvtColor(scaled, cv2.COLOR_BGRA2BGR)
        mask = (scaled[:, :, 3] > 128).astype(np.uint8) * 255
        opaque = np.count_nonzero(mask)
        if opaque < 20:
            continue

        try:
            result = cv2.matchTemplate(ref_bgr, tmpl_bgr, cv2.TM_CCORR_NORMED, mask=mask)
        except cv2.error:
            continue

        _, max_val, _, max_loc = cv2.minMaxLoc(result)

        fg_region = ref_fg_mask[max_loc[1]:max_loc[1]+sh, max_loc[0]:max_loc[0]+sw]
        fg_ratio = 0.0
        if fg_region.shape == (sh, sw):
            fg_ratio = np.count_nonzero(fg_region[mask > 128] > 128) / max(1, opaque)

        combined = max_val * (0.3 + 0.7 * fg_ratio)

        if best is None or combined > best["score"]:
            best = {
                "x": int(max_loc[0]), "y": int(max_loc[1]),
                "width": sw, "height": sh,
                "original_width": part_bgra.shape[1], "original_height": part_bgra.shape[0],
                "scale": round(scale, 4), "rotation": 0.0,
                "score": round(combined, 4),
                "n_matches": 0, "n_good": 0, "n_keypoints": 0,
                "method": "template",
            }
    return best


def find_all_positions(reference_path, parts_folder, ratio_thresh, min_matches):
    ref_rgba = load_rgba(reference_path)
    ref_gray = cv2.cvtColor(ref_rgba[:, :, :3], cv2.COLOR_RGB2GRAY)
    ref_bgra = cv2.cvtColor(ref_rgba, cv2.COLOR_RGBA2BGRA)
    ref_bgr = cv2.cvtColor(ref_bgra, cv2.COLOR_BGRA2BGR)

    fg_mask = create_foreground_mask(ref_rgba)

    # Tuned SIFT: lower contrast threshold to find more features on game art
    sift = cv2.SIFT_create(nfeatures=0, contrastThreshold=0.02, edgeThreshold=20)

    print("Computing SIFT on reference...")
    ref_kp, ref_des = sift.detectAndCompute(ref_gray, None)
    print(f"  Reference: {ref_gray.shape[1]}x{ref_gray.shape[0]}, {len(ref_kp)} keypoints\n")

    part_files = sorted([f for f in os.listdir(parts_folder) if f.lower().endswith(('.png','.webp'))])

    # First pass: try SIFT on all parts
    sift_results = {}
    failed_parts = []
    for fname in part_files:
        name = Path(fname).stem
        part_rgba = load_rgba(os.path.join(parts_folder, fname))
        if np.count_nonzero(part_rgba[:,:,3] > 128) / part_rgba[:,:,3].size < 0.01:
            print(f"  SKIP {name}: <1% opaque")
            continue

        result = sift_match_part(ref_gray, ref_kp, ref_des, part_rgba,
                                 sift, ratio_thresh, min_matches)
        if result:
            sift_results[name] = result
            print(f"  SIFT {name:>20}: pos=({result['x']},{result['y']}) "
                  f"scale={result['scale']:.3f} rot={result['rotation']:.1f}° "
                  f"inliers={result['n_matches']}/{result['n_good']} "
                  f"score={result['score']:.3f}")
        else:
            failed_parts.append((name, part_rgba))

    # Derive template matching scales from SIFT results
    tmpl_scales = (0.85, 0.9, 0.95, 1.0, 1.05, 1.1, 1.15)
    if sift_results:
        sift_scales = [r["scale"] for r in sift_results.values()]
        median_scale = float(np.median(sift_scales))
        # Generate scale range around the SIFT median: ±20%
        tmpl_scales = tuple(round(median_scale * f, 4)
                            for f in (0.80, 0.85, 0.90, 0.95, 1.0, 1.05, 1.10, 1.15, 1.20))
        print(f"\n  SIFT median scale: {median_scale:.3f} → template range: "
              f"{tmpl_scales[0]:.3f}–{tmpl_scales[-1]:.3f}")

    # Second pass: template matching for failed parts using SIFT-derived scales
    positions = dict(sift_results)
    for name, part_rgba in failed_parts:
        part_bgra = cv2.cvtColor(part_rgba, cv2.COLOR_RGBA2BGRA)
        result = template_match_fallback(ref_bgr, fg_mask, part_bgra, scales=tmpl_scales)
        if result:
            positions[name] = result
            print(f"  TMPL {name:>20}: pos=({result['x']},{result['y']}) "
                  f"scale={result['scale']:.3f} score={result['score']:.3f}")
        else:
            print(f"  FAIL {name:>20}: no match")

    return positions, fg_mask


# ─────────────────────────────────────────────────────────────────
# Phase 2: Z-Order via Occlusion
# ─────────────────────────────────────────────────────────────────

def compute_z_order(reference_path, parts_folder, positions):
    reference = load_rgba(reference_path)
    ref_h, ref_w = reference.shape[:2]

    part_images = {}
    for name, pos in positions.items():
        fp = None
        for ext in ['.png','.webp']:
            c = os.path.join(parts_folder, name+ext)
            if os.path.exists(c): fp = c; break
        if not fp: continue
        img = load_rgba(fp)
        tw, th = pos["width"], pos["height"]
        if (tw, th) != (img.shape[1], img.shape[0]):
            img = np.array(Image.fromarray(img).resize((tw, th), Image.LANCZOS))
        part_images[name] = img

    names = list(part_images.keys())
    n = len(names)
    wins = defaultdict(lambda: defaultdict(int))

    print(f"\nZ-order analysis ({n} parts):")
    for i in range(n):
        for j in range(i+1, n):
            a, b = names[i], names[j]
            ap, bp = positions[a], positions[b]
            ai, bi = part_images[a], part_images[b]

            ox1 = max(ap["x"], bp["x"])
            oy1 = max(ap["y"], bp["y"])
            ox2 = min(ap["x"]+ap["width"], bp["x"]+bp["width"])
            oy2 = min(ap["y"]+ap["height"], bp["y"]+bp["height"])
            if ox1 >= ox2 or oy1 >= oy2: continue

            step = max(1, int(math.sqrt((ox2-ox1)*(oy2-oy1)/500)))
            aw, bw, tot = 0, 0, 0

            for sy in range(oy1, oy2, step):
                for sx in range(ox1, ox2, step):
                    if sy >= ref_h or sx >= ref_w: continue
                    rp = reference[sy, sx]
                    if rp[3] < 128: continue
                    aly, alx = sy-ap["y"], sx-ap["x"]
                    bly, blx = sy-bp["y"], sx-bp["x"]
                    if not (0<=alx<ai.shape[1] and 0<=aly<ai.shape[0]): continue
                    if not (0<=blx<bi.shape[1] and 0<=bly<bi.shape[0]): continue
                    apx, bpx = ai[aly, alx], bi[bly, blx]
                    if apx[3] < 128 or bpx[3] < 128: continue
                    ad = np.sqrt(np.sum((rp[:3].astype(float)-apx[:3].astype(float))**2))
                    bd = np.sqrt(np.sum((rp[:3].astype(float)-bpx[:3].astype(float))**2))
                    tot += 1
                    if ad < bd - 5: aw += 1
                    elif bd < ad - 5: bw += 1

            if tot > 5:
                if aw > bw * 1.2:
                    wins[a][b] += aw
                    print(f"  {a} OVER {b} ({aw}/{tot})")
                elif bw > aw * 1.2:
                    wins[b][a] += bw
                    print(f"  {b} OVER {a} ({bw}/{tot})")

    depth = {nm: 0.0 for nm in names}
    for a in names:
        for b in names:
            if a != b and wins[a][b] > 0:
                depth[b] -= wins[a][b]
                depth[a] += wins[a][b]

    result = sorted(names, key=lambda nm: depth[nm])
    print(f"\nDraw order (back -> front):")
    for i, nm in enumerate(result):
        print(f"  z={i:>2}: {nm} (depth={depth[nm]:.0f}, {positions[nm]['method']})")
    return result, depth


# ─────────────────────────────────────────────────────────────────
# Debug Visualization
# ─────────────────────────────────────────────────────────────────

def generate_debug(ref_path, parts_folder, positions, z_order, fg_mask, debug_dir):
    os.makedirs(debug_dir, exist_ok=True)
    ref = load_rgba(ref_path)
    rh, rw = ref.shape[:2]

    # Composite
    comp = np.zeros((rh, rw, 4), dtype=np.uint8)
    comp[:,:,:3] = 255; comp[:,:,3] = 255

    for name in z_order:
        if name not in positions: continue
        pos = positions[name]
        fp = None
        for ext in ['.png','.webp']:
            c = os.path.join(parts_folder, name+ext)
            if os.path.exists(c): fp = c; break
        if not fp: continue
        img = load_rgba(fp)
        tw, th = pos["width"], pos["height"]
        if (tw, th) != (img.shape[1], img.shape[0]):
            img = np.array(Image.fromarray(img).resize((tw, th), Image.LANCZOS))

        x, y = pos["x"], pos["y"]
        ph, pw = img.shape[:2]
        sx1, sy1 = max(0,-x), max(0,-y)
        dx1, dy1 = max(0,x), max(0,y)
        sx2, sy2 = min(pw, rw-x), min(ph, rh-y)
        dx2, dy2 = dx1+(sx2-sx1), dy1+(sy2-sy1)
        if sx2<=sx1 or sy2<=sy1: continue

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
31
Forks
3
Last commit
Jul 2026
Advanced
Catalog kind
skill
Gateway key
spine-2d-animation
Source
github.com/itamarzand88/awesome-agent-conventions