Skill: wpank/release-skills (version 0.1.0)

--- name: release-skills model: fast description: | WHAT: Universal release workflow with auto-detection, multi-language changelogs, semantic versioning, and git tagging. WHEN: User wants to create a release, bump version, update changelog, push a new version, or prepare for deployment. KEYWORDS: "release", "发布", "new version", "新版本", "bump version", "update version", "更新版本", "push", "推送", "create release", "prepare release", "tag version" --- # Release Skills Universal release workflow supporting any project type with multi-language changelog generation. ## Supported Projects | Type | Version File | Auto-Detected | |------|--------------|---------------| | Node.js | package.json | ✓ | | Python | pyproject.toml | ✓ | | Rust | Cargo.toml | ✓ | | Claude Plugin | marketplace.json | ✓ | | Generic | VERSION / version.txt | ✓ | ## Options | Flag | Description | |------|-------------| | `--dry-run` | Preview changes without executing | | `--major` | Force major version bump | | `--minor` | Force minor version bump | | `--patch` | Force patch version bump | --- ## Workflow ### Step 1: Detect Configuration 1. Check for `.releaserc.yml` (optional config) 2. Auto-detect version file (priority: package.json → pyproject.toml → Cargo.toml → marketplace.json → VERSION) 3. Scan for changelog files: `CHANGELOG*.md`, `HISTORY*.md`, `CHANGES*.md` 4. Identify language of each changelog by suffix **Language Detection**: | Pattern | Language | |---------|----------| | `CHANGELOG.md` (no suffix) | en | | `CHANGELOG.zh.md` / `CHANGELOG_CN.md` | zh | | `CHANGELOG.ja.md` / `CHANGELOG_JP.md` | ja | | `CHANGELOG.{lang}.md` | Corresponding language | Output: ``` Project detected: Version file: package.json (1.2.3) Changelogs: CHANGELOG.md (en), CHANGELOG.zh.md (zh) ``` ### Step 2: Analyze Changes ```bash LAST_TAG=$(git tag --sort=-v:refname | head -1) git log ${LAST_TAG}..HEAD --oneline ``` Categorize by conventional commit: - `feat:` → Features - `fix:` → Fixes - `docs:` → Documentation - `refactor:` → Refactor - `perf:` → Performance - `chore:` → Skip in changelog **Breaking Change Detection**: - `BREAKING CHANGE` in message or body - Removed public APIs, renamed exports Warn if breaking changes: "Consider major version bump (--major)." ### Step 3: Determine Version Priority: 1. User flag (`--major/--minor/--patch`) 2. BREAKING CHANGE → Major (1.x.x → 2.0.0) 3. `feat:` present → Minor (1.2.x → 1.3.0) 4. Otherwise → Patch (1.2.3 → 1.2.4) Display: `1.2.3 → 1.3.0` ### Step 4: Generate Changelogs For each changelog file: 1. Identify language from filename 2. Detect third-party contributors via merged PRs 3. Generate content in that language: - Section titles in target language - Date format: YYYY-MM-DD - Attribution: `(by @username)` for non-owner contributors 4. Insert at file head, preserve existing content **Section Titles**: | Type | en | zh | ja | |------|----|----|-----| | feat | Features | 新功能 | 新機能 | | fix | Fixes | 修复 | 修正 | | docs | Documentation | 文档 | ドキュメント | | breaking | Breaking Changes | 破坏性变更 | 破壊的変更 | **Format**: ```markdown ## 1.3.0 - 2026-01-22 ### Features - Add user authentication (by @contributor1) - Support OAuth2 login ### Fixes - Fix memory leak in connection pool ``` ### Step 5: Group by Module (Optional) For monorepos, group commits by affected skill/module: ``` baoyu-cover-image: - feat: add new style options → README updates: options table baoyu-comic: - refactor: improve panel layout → No README updates ``` ### Step 6: User Confirmation Present: - Changelog preview - Proposed version bump - Changes summary Ask: 1. Confirm version bump (show recommended) 2. Push to remote? (Yes/No) ### Step 7: Create Release ```bash # Stage files git add <version-file> CHANGELOG*.md # Commit git commit -m "chore: release v{VERSION}" # Tag git tag v{VERSION} # Push (if confirmed) git push origin main git push origin v{VERSION} ``` **Output**: ``` Release v1.3.0 created. Tag: v1.3.0 Status: Pushed to origin ``` --- ## Scripts | Script | Purpose | |--------|---------| | `scripts/prepare_release.py` | Prepare release with version bump | | `scripts/release_notes.py` | Generate release notes from commits | | `scripts/roadmap_changelog.py` | Generate changelog from roadmap | --- ## Configuration (.releaserc.yml) Optional overrides: ```yaml version: file: package.json path: $.version changelog: files: - path: CHANGELOG.md lang: en - path: CHANGELOG.zh.md lang: zh commit: message: "chore: release v{version}" tag: prefix: v ``` --- ## Dry-Run Mode With `--dry-run`: - Show all proposed changes - Preview changelog entries - List commits to create - No actual changes made --- ## Version Paths | File | Path | |------|------| | package.json | `$.version` | | pyproject.toml | `project.version` | | Cargo.toml | `package.version` | | marketplace.json | `$.metadata.version` | | VERSION | Direct content | --- ## Quality Criteria Good releases: - Clear changelog entries describing user-facing changes - Proper contributor attribution - Consistent multi-language content - No orphaned tags (always with commit) - Version bump matches change significance --- ## NEVER - Force push to main/master - Skip user confirmation before push - Create tags without commits - Include internal/chore changes in user-facing changelog - Push without explicit user consent - Add Co-Authored-By to release commits (they're automated)

path: README.md
content: # Release Skills

Universal release workflow with auto-detection, multi-language changelogs, semantic versioning, and git tagging. Supports Node.js, Python, Rust, Claude Plugin, and generic projects.

## What's Inside

- Auto-detection of project type and version files
- Change analysis with conventional commit categorization
- Semantic version determination (major/minor/patch)
- Multi-language changelog generation (English, Chinese, Japanese, and more)
- Monorepo module grouping
- User confirmation flow before pushing
- Git tagging and release creation
- Configuration via `.releaserc.yml`
- Scripts for release preparation, notes generation, and roadmap changelogs

## When to Use

- User wants to create a release or bump a version
- Updating changelogs before a deploy
- Preparing for deployment with proper tagging
- Triggered by: "release", "new version", "bump version", "update version", "push", "create release", "prepare release", "tag version"

## Installation

```bash
npx add https://github.com/wpank/ai/tree/main/skills/tools/release-skills
```

### Manual Installation

#### Cursor (per-project)

From your project root:

```bash
mkdir -p .cursor/skills
cp -r ~/.ai-skills/skills/tools/release-skills .cursor/skills/release-skills
```

#### Cursor (global)

```bash
mkdir -p ~/.cursor/skills
cp -r ~/.ai-skills/skills/tools/release-skills ~/.cursor/skills/release-skills
```

#### Claude Code (per-project)

From your project root:

```bash
mkdir -p .claude/skills
cp -r ~/.ai-skills/skills/tools/release-skills .claude/skills/release-skills
```

#### Claude Code (global)

```bash
mkdir -p ~/.claude/skills
cp -r ~/.ai-skills/skills/tools/release-skills ~/.claude/skills/release-skills
```

---

Part of the [Tools](..) skill category.


path: scripts/prepare_release.py
content: #!/usr/bin/env python3
"""
prepare_release.py -- Prepare a skill release by bumping version, generating
changelog, validating quality, and producing a release summary.

Usage:
    python prepare_release.py .agents/skills/clean-code --minor
    python prepare_release.py .agents/skills/clean-code --major
    python prepare_release.py --all --minor       # bump all skills
    python prepare_release.py --dry-run --minor    # preview without writing

No external dependencies required (stdlib only).
"""
from __future__ import annotations

import argparse
import os
import re
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
from typing import Optional


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
SKILLS_DIR = Path(".agents/skills")
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---", re.DOTALL)
VERSION_LINE_RE = re.compile(r"^(version:\s*)[\"']?(\d+(?:\.\d+)*)(?:\.\d+)?[\"']?\s*$", re.MULTILINE)
SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
TWO_PART_RE = re.compile(r"^(\d+)\.(\d+)$")

# Validation constants (mirrors validate_all_skills.py)
ALLOWED_FM_KEYS = {"name", "description", "license", "allowed-tools", "metadata",
                   "version", "priority", "category", "color", "displayName"}
LINE_WARN = 500
LINE_ERROR = 800


# ---------------------------------------------------------------------------
# Terminal colors
# ---------------------------------------------------------------------------
def _has_color() -> bool:
    if os.environ.get("NO_COLOR"):
        return False
    if os.environ.get("FORCE_COLOR"):
        return True
    return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()

_COLOR = _has_color()

def _c(code: str, t: str) -> str:
    return f"\033[{code}m{t}\033[0m" if _COLOR else t

def red(t: str) -> str:    return _c("31", t)
def green(t: str) -> str:  return _c("32", t)
def yellow(t: str) -> str: return _c("33", t)
def cyan(t: str) -> str:   return _c("36", t)
def bold(t: str) -> str:   return _c("1", t)
def dim(t: str) -> str:    return _c("2", t)


# ---------------------------------------------------------------------------
# Version helpers
# ---------------------------------------------------------------------------
@dataclass
class Version:
    major: int
    minor: int
    patch: int

    @classmethod
    def parse(cls, raw: str) -> Optional["Version"]:
        raw = raw.strip().strip("\"'")
        m = SEMVER_RE.match(raw)
        if m:
            return cls(int(m.group(1)), int(m.group(2)), int(m.group(3)))
        m2 = TWO_PART_RE.match(raw)
        if m2:
            return cls(int(m2.group(1)), int(m2.group(2)), 0)
        return None

    def bump_major(self) -> "Version":
        return Version(self.major + 1, 0, 0)

    def bump_minor(self) -> "Version":
        return Version(self.major, self.minor + 1, 0)

    def __str__(self) -> str:
        return f"{self.major}.{self.minor}.{self.patch}"


# ---------------------------------------------------------------------------
# Git helpers
# ---------------------------------------------------------------------------
def git_root() -> Path:
    """Find the repository root."""
    try:
        out = subprocess.check_output(
            ["git", "rev-parse", "--show-toplevel"],
            stderr=subprocess.DEVNULL, text=True
        ).strip()
        return Path(out)
    except (subprocess.CalledProcessError, FileNotFoundError):
        return Path.cwd()


def last_version_tag(skill_name: str) -> Optional[str]:
    """Find the most recent tag matching the skill name pattern."""
    patterns = [f"{skill_name}/v*", f"{skill_name}-v*", "v*"]
    for pattern in patterns:
        try:
            out = subprocess.check_output(
                ["git", "tag", "-l", pattern, "--sort=-v:refname"],
                stderr=subprocess.DEVNULL, text=True
            ).strip()
            if out:
                return out.splitlines()[0]
        except (subprocess.CalledProcessError, FileNotFoundError):
            continue
    return None


def git_log_since(ref: Optional[str], skill_path: str) -> list[str]:
    """Get commit messages since a ref, scoped to the skill path."""
    cmd = ["git", "log", "--oneline", "--no-merges"]
    if ref:
        cmd.append(f"{ref}..HEAD")
    cmd.extend(["--", skill_path])
    try:
        out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, text=True).strip()
        return [line for line in out.splitlines() if line.strip()] if out else []
    except (subprocess.CalledProcessError, FileNotFoundError):
        return []


# ---------------------------------------------------------------------------
# Skill discovery and frontmatter parsing
# ---------------------------------------------------------------------------
def discover_skills(root: Path, skill_arg: Optional[str] = None) -> list[Path]:
    """Find skill directories. If skill_arg is given, resolve that one skill."""
    if skill_arg:
        # Accept both relative path and bare name
        candidate = root / skill_arg
        if not candidate.is_dir():
            candidate = root / SKILLS_DIR / skill_arg
        if not candidate.is_dir() or not (candidate / "SKILL.md").exists():
            print(red(f"Skill not found: {skill_arg}"), file=sys.stderr)
            sys.exit(1)
        return [candidate]

    base = root / SKILLS_DIR
    if not base.is_dir():
        print(red(f"Skills directory not found: {base}"), file=sys.stderr)
        sys.exit(1)
    return sorted(p for p in base.iterdir() if p.is_dir() and (p / "SKILL.md").exists())


def read_frontmatter_version(skill_path: Path) -> tuple[str, Optional[Version]]:
    """Read SKILL.md and extract the version from frontmatter. Returns (content, version)."""
    content = (skill_path / "SKILL.md").read_text()
    fm_match = FRONTMATTER_RE.match(content)
    if not fm_match:
        return content, None
    fm_block = fm_match.group(1)
    ver_match = re.search(r"^version:\s*[\"']?(\d+(?:\.\d+)*)[\"']?\s*$", fm_block, re.MULTILINE)
    if not ver_match:
        return content, None
    return content, Version.parse(ver_match.group(1))


def write_bumped_version(skill_path: Path, content: str, new_version: Version) -> str:
    """Replace the version in frontmatter and write the file. Returns updated content."""
    fm_match = FRONTMATTER_RE.match(content)
    if not fm_match:
        return content
    fm_block = fm_match.group(1)
    new_fm = re.sub(
        r"^(version:\s*)[\"']?\d+(?:\.\d+)*[\"']?\s*$",
        f"\\g<1>{new_version}",
        fm_block,
        flags=re.MULTILINE,
    )
    updated = content[:fm_match.start(1)] + new_fm + content[fm_match.end(1):]
    (skill_path / "SKILL.md").write_text(updated)
    return updated


# ---------------------------------------------------------------------------
# Validation (lightweight, inline — mirrors key checks from validate_all_skills.py)
# ---------------------------------------------------------------------------
@dataclass
class ValidationResult:
    passed: bool
    errors: list[str] = field(default_factory=list)
    warnings: list[str] = field(default_factory=list)


def validate_skill(skill_path: Path) -> ValidationResult:
    """Run core quality checks on a skill. Returns a ValidationResult."""
    result = ValidationResult(passed=True)
    md = skill_path / "SKILL.md"
    if not md.exists():
        result.passed = False
        result.errors.append("SKILL.md not found")
        return result

    content = md.read_text()

    # Frontmatter exists
    if not content.startswith("---"):
        result.passed = False
        result.errors.append("No YAML frontmatter (must start with '---')")
        return result

    fm_match = FRONTMATTER_RE.match(content)
    if not fm_match:
        result.passed = False
        result.errors.append("Invalid frontmatter (missing closing '---')")
        return result

    # Parse frontmatter fields manually (no yaml dependency)
    fm_text = fm_match.group(1)
    has_name = bool(re.search(r"^name:", fm_text, re.MULTILINE))
    has_desc = bool(re.search(r"^description:", fm_text, re.MULTILINE))
    if not has_name:
        result.passed = False
        result.errors.append("Missing required 'name' field in frontmatter")
    if not has_desc:
        result.passed = False
        result.errors.append("Missing required 'description' field in frontmatter")

    # Name matches directory
    name_match = re.search(r"^name:\s*(.+)$", fm_text, re.MULTILINE)
    if name_match:
        name_val = name_match.group(1).strip().strip("\"'")
        if name_val != skill_path.name:
            result.passed = False
            result.errors.append(f"Name '{name_val}' does not match directory '{skill_path.name}'")

    # Line count
    line_count = content.count("\n") + 1
    if line_count > LINE_ERROR:
        result.passed = False
        result.errors.append(f"{line_count} lines exceeds maximum of {LINE_ERROR}")
    elif line_count > LINE_WARN:
        result.warnings.append(f"{line_count} lines exceeds recommended {LINE_WARN}")

    # Broken internal links
    broken_links: list[str] = []
    for m in re.finditer(r"\[([^\]]*)\]\(([^)]+)\)", content):
        target = m.group(2)
        if target.startswith(("http://", "https://", "mailto:", "#")):
            continue
        path_part = target.split("#")[0]
        if path_part and not (skill_path / path_part).exists():
            broken_links.append(f"[{m.group(1)}]({target})")
    if broken_links:
        result.passed = False
        result.errors.append(f"Broken links: {'; '.join(broken_links)}")

    # Headings exist
    body_match = re.match(r"^---\n.*?\n---\n?(.*)", content, re.DOTALL)
    body = body_match.group(1) if body_match else content
    headings = re.findall(r"^##\s+(.+)$", body, re.MULTILINE)
    if not headings:
        result.warnings.append("No ## headings found in body")

    return result


# ---------------------------------------------------------------------------
# Changelog generation
# ---------------------------------------------------------------------------
def generate_changelog(skill_name: str, old_ver: Optional[Version],
                       new_ver: Version, commits: list[str]) -> str:
    """Generate a markdown changelog entry from commit messages."""
    today = date.today().isoformat()
    lines = [f"## {new_ver} - {today}", ""]

    if not commits:
        lines.append("- Version bump (no new commits)")
        lines.append("")
        return "\n".join(lines)

    # Categorize by conventional commit prefix
    categories: dict[str, list[str]] = {
        "Features": [], "Fixes": [], "Documentation": [],
        "Refactor": [], "Performance": [], "Other": [],
    }
    prefix_map = {
        "feat": "Features", "fix": "Fixes", "docs": "Documentation",
        "refactor": "Refactor", "perf": "Performance",
    }

    for commit in commits:
        # Strip leading hash
        msg = re.sub(r"^[a-f0-9]+\s+", "", commit)
        matched = False
        for prefix, category in prefix_map.items():
            pattern = re.compile(rf"^{prefix}(?:\(.*?\))?:\s*(.+)", re.IGNORECASE)
            m = pattern.match(msg)
            if m:
                categories[category].append(m.group(1).strip())
                matched = True
                break
        if not matched:
            # Skip chore/style/test from changelog
            if re.match(r"^(chore|style|test|ci|build)(\(.*?\))?:", msg, re.IGNORECASE):
                continue
            categories["Other"].append(msg)

    for section, items in categories.items():
        if items:
            lines.append(f"### {section}")
            for item in items:
                lines.append(f"- {item}")
            lines.append("")

    return "\n".join(lines)


# ---------------------------------------------------------------------------
# Release summary
# ---------------------------------------------------------------------------
def print_release_summary(results: list[dict[str, object]]) -> None:
    """Print a formatted release summary for all processed skills."""
    print(f"\n{bold('=' * 70)}")
    print(f"{bold('  Release Summary')}")
    print(f"{bold('=' * 70)}\n")

    for r in results:
        status = green("READY") if r["valid"] else red("BLOCKED")
        print(f"  [{status}] {bold(str(r['name']))}")
        print(f"           Version: {r['old_version']} -> {r['new_version']}")
        if r["commits_count"]:
            print(f"           Commits: {r['commits_count']}")
        if r["validation_warnings"]:
            for w in r["validation_warnings"]:  # type: ignore[union-attr]
                print(f"           {yellow('WARN')}: {w}")
        if r["validation_errors"]:
            for e in r["validation_errors"]:  # type: ignore[union-attr]
                print(f"           {red('ERROR')}: {e}")
        print()

    ready = sum(1 for r in results if r["valid"])
    blocked = sum(1 for r in results if not r["valid"])
    print(f"{bold('-' * 70)}")
    print(f"  {green('Ready')}: {ready}   {red('Blocked')}: {blocked}   Total: {len(results)}")
    print(f"{bold('-' * 70)}\n")


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        description="Prepare a skill release: bump version, generate changelog, validate quality.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=(
            "Examples:\n"
            "  python prepare_release.py .agents/skills/clean-code --minor\n"
            "  python prepare_release.py .agents/skills/clean-code --major\n"
            "  python prepare_release.py --all --minor\n"
            "  python prepare_release.py --dry-run --minor\n"
        ),
    )
    p.add_argument("skill", nargs="?", default=None,
                   help="Path to skill directory (e.g. .agents/skills/clean-code)")
    p.add_argument("--all", action="store_true",
                   help="Process all skills in .agents/skills/")

    bump = p.add_mutually_exclusive_group(required=True)
    bump.add_argument("--major", action="store_true", help="Major version bump (X.0.0)")
    bump.add_argument("--minor", action="store_true", help="Minor version bump (x.Y.0)")

    p.add_argument("--dry-run", action="store_true",
                   help="Preview changes without writing files")
    return p


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()

    if not args.skill and not args.all:
        parser.error("Provide a skill path or use --all")

    root = git_root()
    os.chdir(root)

    if args.all:
        skill_paths = discover_skills(root)
    else:
        skill_paths = discover_skills(root, args.skill)

    if not skill_paths:
        print(red("No skills found."), file=sys.stderr)
        return 1

    if args.dry_run:
        print(f"\n{cyan('=== DRY RUN MODE ===')}\n")

    results: list[dict[str, object]] = []

    for skill_path in skill_paths:
        name = skill_path.name
        content, current_ver = read_frontmatter_version(skill_path)

        if current_ver is None:
            # No version in frontmatter — default to 0.0.0
            current_ver = Version(0, 0, 0)

        new_ver = current_ver.bump_major() if args.major else current_ver.bump_minor()

        # Git log
        tag = last_version_tag(name)
        rel_path = str(skill_path.relative_to(root))
        commits = git_log_since(tag, rel_path)

        # Validation
        vr = validate_skill(skill_path)

        # Changelog
        changelog = generate_changelog(name, current_ver, new_ver, commits)

        result: dict[str, object] = {
            "name": name,
            "old_version": str(current_ver),
            "new_version": str(new_ver),
            "commits_count": len(commits),
            "valid": vr.passed,
            "validation_errors": vr.errors,
            "validation_warnings": vr.warnings,
            "changelog": changelog,
        }
        results.append(result)

        if args.dry_run:
            print(f"  {bold(name)}: {current_ver} -> {new_ver}")
            if commits:
                print(f"    {len(commits)} commit(s) since {tag or 'beginning'}")
            print(f"    Validation: {'PASS' if vr.passed else 'FAIL'}")
            if vr.errors:
                for e in vr.errors:
                    print(f"      {red('ERROR')}: {e}")
            if vr.warnings:
                for w in vr.warnings:
                    print(f"      {yellow('WARN')}: {w}")
            print(f"\n    Changelog preview:\n")
            for line in changelog.splitlines():
                print(f"      {line}")
            print()
            continue

        # Write version bump
        write_bumped_version(skill_path, content, new_ver)

        # Write changelog
        changelog_path = skill_path / "CHANGELOG.md"
        if changelog_path.exists():
            existing = changelog_path.read_text()
            changelog_path.write_text(changelog + "\n" + existing)
        else:
            changelog_path.write_text(changelog)

        print(f"  {green('✓')} {bold(name)}: {current_ver} -> {new_ver}")

    # Summary
    if args.dry_run:
        print(f"{cyan('No changes made. Run without --dry-run to execute.')}\n")
    else:
        print_release_summary(results)

    # Return non-zero if any skill failed validation
    if any(not r["valid"] for r in results):
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: scripts/release_notes.py
content: #!/usr/bin/env python3
"""
release_notes.py -- Generate aggregated release notes across all skills.

Scans .agents/skills/*/SKILL.md for version information and git history,
then produces formatted release notes grouped by category.

Usage:
    python release_notes.py --since "2024-01-01"
    python release_notes.py --version "1.3.0"
    python release_notes.py --json
    python release_notes.py --since "2024-06-01" --json

No external dependencies required (stdlib only).
"""
from __future__ import annotations

import argparse
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import date, datetime
from pathlib import Path
from typing import Optional


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
SKILLS_DIR = Path(".agents/skills")
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---", re.DOTALL)

CATEGORIES = {
    "New Skills": "Skills added since the cutoff",
    "Updated Skills": "Skills with version bumps",
    "New Scripts": "Automation scripts added",
    "New Commands": "Commands or templates added",
}


# ---------------------------------------------------------------------------
# Terminal colors
# ---------------------------------------------------------------------------
def _has_color() -> bool:
    if os.environ.get("NO_COLOR"):
        return False
    if os.environ.get("FORCE_COLOR"):
        return True
    return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()

_COLOR = _has_color()

def _c(code: str, t: str) -> str:
    return f"\033[{code}m{t}\033[0m" if _COLOR else t

def bold(t: str) -> str:  return _c("1", t)
def dim(t: str) -> str:   return _c("2", t)
def cyan(t: str) -> str:  return _c("36", t)
def green(t: str) -> str: return _c("32", t)


# ---------------------------------------------------------------------------
# Git helpers
# ---------------------------------------------------------------------------
def git_root() -> Path:
    """Find the repository root."""
    try:
        out = subprocess.check_output(
            ["git", "rev-parse", "--show-toplevel"],
            stderr=subprocess.DEVNULL, text=True
        ).strip()
        return Path(out)
    except (subprocess.CalledProcessError, FileNotFoundError):
        return Path.cwd()


def git_log_since_date(since: str, path: str = "") -> list[dict[str, str]]:
    """Get commits since a date, optionally scoped to a path.
    Returns list of dicts with 'hash', 'date', 'message'."""
    cmd = [
        "git", "log", f"--since={since}",
        "--format=%H|%aI|%s", "--no-merges",
    ]
    if path:
        cmd.extend(["--", path])
    try:
        out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, text=True).strip()
    except (subprocess.CalledProcessError, FileNotFoundError):
        return []
    if not out:
        return []
    results: list[dict[str, str]] = []
    for line in out.splitlines():
        parts = line.split("|", 2)
        if len(parts) == 3:
            results.append({"hash": parts[0], "date": parts[1], "message": parts[2]})
    return results


def file_added_after(filepath: Path, since: str) -> bool:
    """Check if a file was first added to git after the given date."""
    try:
        out = subprocess.check_output(
            ["git", "log", "--diff-filter=A", "--format=%aI",
             "--follow", "--", str(filepath)],
            stderr=subprocess.DEVNULL, text=True
        ).strip()
    except (subprocess.CalledProcessError, FileNotFoundError):
        return False
    if not out:
        # Not tracked yet — treat as new
        return True
    # The last line is the earliest (first add)
    first_date = out.splitlines()[-1][:10]
    return first_date >= since[:10]


# ---------------------------------------------------------------------------
# Skill scanning
# ---------------------------------------------------------------------------
@dataclass
class SkillInfo:
    name: str
    path: Path
    version: Optional[str] = None
    description: str = ""
    is_new: bool = False
    commits: list[dict[str, str]] = field(default_factory=list)
    new_scripts: list[str] = field(default_factory=list)
    new_commands: list[str] = field(default_factory=list)


def parse_frontmatter_field(fm_text: str, key: str) -> str:
    """Extract a simple scalar value from frontmatter text."""
    m = re.search(rf"^{re.escape(key)}:\s*(.+)$", fm_text, re.MULTILINE)
    if not m:
        return ""
    val = m.group(1).strip().strip("\"'")
    return val


def scan_skills(root: Path, since: str) -> list[SkillInfo]:
    """Scan all skills and collect change information since a date."""
    base = root / SKILLS_DIR
    if not base.is_dir():
        return []

    skills: list[SkillInfo] = []
    for skill_dir in sorted(base.iterdir()):
        if not skill_dir.is_dir():
            continue
        skill_md = skill_dir / "SKILL.md"
        if not skill_md.exists():
            continue

        content = skill_md.read_text()
        fm_match = FRONTMATTER_RE.match(content)
        fm_text = fm_match.group(1) if fm_match else ""

        info = SkillInfo(
            name=skill_dir.name,
            path=skill_dir,
            version=parse_frontmatter_field(fm_text, "version") or None,
            description=parse_frontmatter_field(fm_text, "description"),
        )

        # Check if skill is new (SKILL.md added after since date)
        info.is_new = file_added_after(skill_md, since)

        # Get commits for this skill since date
        rel_path = str(skill_dir.relative_to(root))
        info.commits = git_log_since_date(since, rel_path)

        # Detect new scripts
        scripts_dir = skill_dir / "scripts"
        if scripts_dir.is_dir():
            for f in sorted(scripts_dir.iterdir()):
                if f.is_file() and file_added_after(f, since):
                    info.new_scripts.append(f.name)

        # Detect new commands/templates
        for subdir_name in ("templates", "commands", "assets"):
            subdir = skill_dir / subdir_name
            if subdir.is_dir():
                for f in sorted(subdir.iterdir()):
                    if f.is_file() and file_added_after(f, since):
                        info.new_commands.append(f"{subdir_name}/{f.name}")

        skills.append(info)

    return skills


# ---------------------------------------------------------------------------
# Aggregation
# ---------------------------------------------------------------------------
@dataclass
class ReleaseNotes:
    version: Optional[str]
    since: str
    generated: str
    new_skills: list[SkillInfo] = field(default_factory=list)
    updated_skills: list[SkillInfo] = field(default_factory=list)
    new_scripts: list[tuple[str, str]] = field(default_factory=list)  # (skill, script)
    new_commands: list[tuple[str, str]] = field(default_factory=list)  # (skill, file)


def aggregate(skills: list[SkillInfo], version: Optional[str], since: str) -> ReleaseNotes:
    """Aggregate skill data into structured release notes."""
    notes = ReleaseNotes(
        version=version,
        since=since,
        generated=date.today().isoformat(),
    )

    for info in skills:
        if not info.commits and not info.is_new:
            continue

        if info.is_new:
            notes.new_skills.append(info)
        elif info.commits:
            notes.updated_skills.append(info)

        for script in info.new_scripts:
            notes.new_scripts.append((info.name, script))
        for cmd in info.new_commands:
            notes.new_commands.append((info.name, cmd))

    return notes


# ---------------------------------------------------------------------------
# Output formatters
# ---------------------------------------------------------------------------
def format_markdown(notes: ReleaseNotes) -> str:
    """Render release notes as markdown."""
    lines: list[str] = []
    title = f"Release {notes.version}" if notes.version else "Release Notes"
    lines.append(f"# {title}")
    lines.append("")
    lines.append(f"**Generated**: {notes.generated}  ")
    lines.append(f"**Changes since**: {notes.since}")
    lines.append("")

    if notes.new_skills:
        lines.append("## New Skills")
        lines.append("")
        for info in notes.new_skills:
            ver = f" (v{info.version})" if info.version else ""
            lines.append(f"- **{info.name}**{ver}")
            if info.description:
                # Truncate long descriptions
                desc = info.description[:120]
                if len(info.description) > 120:
                    desc += "..."
                lines.append(f"  {desc}")
        lines.append("")

    if notes.updated_skills:
        lines.append("## Updated Skills")
        lines.append("")
        for info in notes.updated_skills:
            ver = f" (v{info.version})" if info.version else ""
            commit_count = len(info.commits)
            lines.append(f"- **{info.name}**{ver} — {commit_count} commit(s)")
        lines.append("")

    if notes.new_scripts:
        lines.append("## New Scripts")
        lines.append("")
        for skill_name, script in notes.new_scripts:
            lines.append(f"- `{skill_name}/scripts/{script}`")
        lines.append("")

    if notes.new_commands:
        lines.append("## New Commands & Templates")
        lines.append("")
        for skill_name, cmd_file in notes.new_commands:
            lines.append(f"- `{skill_name}/{cmd_file}`")
        lines.append("")

    # Statistics
    total_commits = sum(len(s.commits) for s in notes.new_skills + notes.updated_skills)
    lines.append("---")
    lines.append("")
    lines.append(f"**Stats**: {len(notes.new_skills)} new skill(s), "
                 f"{len(notes.updated_skills)} updated, "
                 f"{total_commits} total commit(s)")
    lines.append("")

    return "\n".join(lines)


def format_json(notes: ReleaseNotes) -> str:
    """Render release notes as JSON."""
    data = {
        "version": notes.version,
        "since": notes.since,
        "generated": notes.generated,
        "new_skills": [
            {"name": s.name, "version": s.version, "description": s.description}
            for s in notes.new_skills
        ],
        "updated_skills": [
            {"name": s.name, "version": s.version, "commits": len(s.commits)}
            for s in notes.updated_skills
        ],
        "new_scripts": [
            {"skill": skill, "script": script}
            for skill, script in notes.new_scripts
        ],
        "new_commands": [
            {"skill": skill, "file": cmd}
            for skill, cmd in notes.new_commands
        ],
        "stats": {
            "new_skills": len(notes.new_skills),
            "updated_skills": len(notes.updated_skills),
            "new_scripts": len(notes.new_scripts),
            "new_commands": len(notes.new_commands),
            "total_commits": sum(
                len(s.commits) for s in notes.new_skills + notes.updated_skills
            ),
        },
    }
    return json.dumps(data, indent=2)


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        description="Generate aggregated release notes across all skills.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=(
            "Examples:\n"
            '  python release_notes.py --since "2024-01-01"\n'
            '  python release_notes.py --version "1.3.0"\n'
            "  python release_notes.py --json\n"
            '  python release_notes.py --since "2024-06-01" --json\n'
        ),
    )
    p.add_argument("--since", metavar="DATE", default=None,
                   help="Include changes since this date (YYYY-MM-DD). "
                        "Defaults to 30 days ago.")
    p.add_argument("--version", metavar="VER", default=None,
                   help="Label the release notes with this version string")
    p.add_argument("--json", action="store_true", dest="json_output",
                   help="Output as JSON instead of markdown")
    return p


def default_since() -> str:
    """Default to 30 days ago."""
    from datetime import timedelta
    d = date.today() - timedelta(days=30)
    return d.isoformat()


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()

    since = args.since or default_since()

    # Validate date format
    try:
        datetime.strptime(since[:10], "%Y-%m-%d")
    except ValueError:
        print(f"Invalid date format: {since}. Use YYYY-MM-DD.", file=sys.stderr)
        return 1

    root = git_root()
    os.chdir(root)

    skills = scan_skills(root, since)
    if not skills:
        print("No skills found.", file=sys.stderr)
        return 1

    notes = aggregate(skills, args.version, since)

    if args.json_output:
        print(format_json(notes))
    else:
        print(format_markdown(notes))

    return 0


if __name__ == "__main__":
    sys.exit(main())


path: scripts/roadmap_changelog.py
content: #!/usr/bin/env python3
"""
roadmap_changelog.py -- Generate changelog entries from completed roadmap items.

Reads roadmap.md (or TODO.md as fallback), finds completed items since a given
date, categorizes them by changelog type, cross-references against CHANGELOG.md
to avoid duplicates, and outputs in Keep a Changelog format.

Usage:
    python roadmap_changelog.py                          # generate from roadmap.md
    python roadmap_changelog.py --source TODO.md         # use TODO.md instead
    python roadmap_changelog.py --since "2024-01-01"     # since date
    python roadmap_changelog.py --version "1.3.0"        # set version
    python roadmap_changelog.py --append                 # append to CHANGELOG.md
    python roadmap_changelog.py --dry-run                # preview only

No external dependencies required (stdlib only).
"""
from __future__ import annotations

import argparse
import os
import re
import sys
from datetime import date, datetime
from pathlib import Path
from typing import Optional


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
CHANGELOG_CATEGORIES = [
    "Added", "Changed", "Fixed", "Removed", "Security", "Deprecated",
]

# Keywords that map completed items to changelog categories.
# Order matters — first match wins.
CATEGORY_RULES: list[tuple[str, list[str]]] = [
    ("Security", [
        r"\bsecurity\b", r"\bauth\b", r"\bvulnerab", r"\baudit\b",
        r"\bCVE\b", r"\bencrypt", r"\bsecret",
    ]),
    ("Fixed", [
        r"\bfix\b", r"\bbug\b", r"\bresolve[ds]?\b", r"\bpatch\b",
        r"\bhotfix\b", r"\bcorrect\b", r"\brepair\b",
    ]),
    ("Removed", [
        r"\bremov\w+", r"\bdelet\w+", r"\bdrop\w+", r"\bdeprecated\b.*remov",
        r"\bstrip\b",
    ]),
    ("Deprecated", [
        r"\bdeprecat\w+",
    ]),
    ("Changed", [
        r"\bupdat\w+", r"\brefactor\w*", r"\breorganiz\w+", r"\brestruc\w+",
        r"\bimprov\w+", r"\benhance\w+", r"\bmigrat\w+", r"\bmov\w+ .+ to\b",
        r"\bexpand\w*", r"\brename\w*", r"\bconvert\w*",
    ]),
    # Default — anything else is "Added"
    ("Added", []),
]

# Regex for completed checkbox items: lines starting with `- [x]`
COMPLETED_RE = re.compile(
    r"^[\s]*-\s+\[x\]\s+(.+)$", re.MULTILINE | re.IGNORECASE
)

# Match a "Completed:" trailing line that often follows the checkbox line
COMPLETED_DATE_RE = re.compile(r"Completed.*?(\d{4}-\d{2}-\d{2})")

# Strip priority tags like `[high]`, `[medium]`, `[low]`
PRIORITY_RE = re.compile(r"\s*`\[(?:high|medium|low)\]`\s*")

# Strip bold markers
BOLD_RE = re.compile(r"\*\*(.+?)\*\*")


# ---------------------------------------------------------------------------
# Terminal colors
# ---------------------------------------------------------------------------
def _has_color() -> bool:
    if os.environ.get("NO_COLOR"):
        return False
    if os.environ.get("FORCE_COLOR"):
        return True
    return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()

_COLOR = _has_color()

def _c(code: str, t: str) -> str:
    return f"\033[{code}m{t}\033[0m" if _COLOR else t

def bold(t: str) -> str:   return _c("1", t)
def dim(t: str) -> str:    return _c("2", t)
def cyan(t: str) -> str:   return _c("36", t)
def green(t: str) -> str:  return _c("32", t)
def yellow(t: str) -> str: return _c("33", t)
def red(t: str) -> str:    return _c("31", t)


# ---------------------------------------------------------------------------
# Source file resolution
# ---------------------------------------------------------------------------
def resolve_source(explicit: Optional[str]) -> Optional[Path]:
    """Find the roadmap/TODO source file.  Priority:
    1. Explicitly provided path
    2. roadmap.md
    3. TODO.md
    """
    if explicit:
        p = Path(explicit)
        return p if p.is_file() else None

    for candidate in ("roadmap.md", "TODO.md"):
        p = Path(candidate)
        if p.is_file():
            return p
    return None


# ---------------------------------------------------------------------------
# Parsing completed items
# ---------------------------------------------------------------------------
def parse_completed_items(text: str, since: Optional[str]) -> list[dict[str, str]]:
    """Extract completed checkbox items from markdown text.

    Each item is a dict with keys: 'raw', 'summary', 'detail', 'section'.
    Items are filtered by *since* date if a 'Completed:' annotation or
    surrounding context contains a date.
    """
    lines = text.splitlines()
    items: list[dict[str, str]] = []
    current_section = ""

    for i, line in enumerate(lines):
        # Track the current H3 section heading
        h3 = re.match(r"^###\s+(.+)", line)
        if h3:
            current_section = h3.group(1).strip()
            continue

        m = re.match(r"^[\s]*-\s+\[x\]\s+(.+)$", line, re.IGNORECASE)
        if not m:
            continue

        raw = m.group(1).strip()

        # Look ahead for a "Completed:" line with a date (up to 5 lines)
        item_date: Optional[str] = None
        for j in range(i + 1, min(i + 6, len(lines))):
            dm = COMPLETED_DATE_RE.search(lines[j])
            if dm:
                item_date = dm.group(1)
                break
            # Stop scanning at the next checkbox or heading
            if re.match(r"^[\s]*-\s+\[", lines[j]) or re.match(r"^##", lines[j]):
                break

        # Filter by since date
        if since and item_date:
            if item_date < since:
                continue

        # Build a clean summary
        summary = _clean_summary(raw)

        # Gather the indented detail block (lines indented under this item)
        detail_lines: list[str] = []
        for j in range(i + 1, len(lines)):
            nxt = lines[j]
            if nxt.strip() == "":
                detail_lines.append("")
                continue
            if nxt.startswith("  ") or nxt.startswith("\t"):
                detail_lines.append(nxt.strip())
            else:
                break
        detail = " ".join(l for l in detail_lines if l).strip()

        items.append({
            "raw": raw,
            "summary": summary,
            "detail": detail,
            "section": current_section,
            "date": item_date or "",
        })

    return items


def _clean_summary(raw: str) -> str:
    """Produce a concise one-line summary from a raw checkbox label."""
    text = PRIORITY_RE.sub("", raw)
    # Un-bold
    text = BOLD_RE.sub(r"\1", text)
    # Remove trailing colon descriptions after first sentence
    text = text.split(":")[0] if ":" in text else text
    # Trim leading/trailing junk
    text = text.strip(" *")
    return text


# ---------------------------------------------------------------------------
# Categorization
# ---------------------------------------------------------------------------
def categorize(item: dict[str, str]) -> str:
    """Assign a Keep a Changelog category to an item."""
    blob = f"{item['raw']} {item['detail']}".lower()
    for category, patterns in CATEGORY_RULES:
        for pat in patterns:
            if re.search(pat, blob, re.IGNORECASE):
                return category
    return "Added"


# ---------------------------------------------------------------------------
# Duplicate detection
# ---------------------------------------------------------------------------
def load_existing_entries(changelog_path: Path) -> set[str]:
    """Load existing changelog entries to avoid duplicates.

    Returns a set of normalised entry strings (lowercase, stripped of
    leading '- ' and whitespace) for fuzzy matching.
    """
    if not changelog_path.is_file():
        return set()

    entries: set[str] = set()
    text = changelog_path.read_text()
    for line in text.splitlines():
        stripped = line.strip()
        if stripped.startswith("- "):
            normalised = stripped[2:].strip().lower()
            # Also store a condensed version (alpha-only) for fuzzy match
            condensed = re.sub(r"[^a-z0-9]", "", normalised)
            entries.add(normalised)
            if condensed:
                entries.add(condensed)
    return entries


def is_duplicate(summary: str, existing: set[str]) -> bool:
    """Check whether a summary already appears in the changelog."""
    norm = summary.strip().lower()
    condensed = re.sub(r"[^a-z0-9]", "", norm)
    if norm in existing:
        return True
    if condensed and condensed in existing:
        return True
    return False


# ---------------------------------------------------------------------------
# Changelog generation
# ---------------------------------------------------------------------------
def build_changelog(
    items: list[dict[str, str]],
    version: str,
    today: str,
    existing: set[str],
) -> tuple[str, int, int]:
    """Build a changelog section in Keep a Changelog format.

    Returns (changelog_text, included_count, duplicate_count).
    """
    categorised: dict[str, list[str]] = {cat: [] for cat in CHANGELOG_CATEGORIES}
    duplicates = 0

    for item in items:
        if is_duplicate(item["summary"], existing):
            duplicates += 1
            continue
        cat = categorize(item)
        # Build the entry line with optional detail
        entry = item["summary"]
        detail = item.get("detail", "")
        # If detail has a "Completed:" note, extract the useful part
        completed_m = re.match(r"^Completed:\s*(.+)", detail)
        if completed_m:
            extra = completed_m.group(1).strip()
            if extra and len(extra) < 120:
                entry = f"{entry} — {extra}"
        categorised[cat].append(entry)

    # Build markdown
    lines: list[str] = [f"## [{version}] — {today}", ""]
    included = 0
    for cat in CHANGELOG_CATEGORIES:
        entries = categorised[cat]
        if not entries:
            continue
        lines.append(f"### {cat}")
        for e in entries:
            lines.append(f"- {e}")
            included += 1
        lines.append("")

    if included == 0:
        lines.append("No new entries.")
        lines.append("")

    return "\n".join(lines), included, duplicates


# ---------------------------------------------------------------------------
# Append to CHANGELOG.md
# ---------------------------------------------------------------------------
def append_to_changelog(changelog_path: Path, new_section: str) -> None:
    """Insert the new section into CHANGELOG.md after the header."""
    if not changelog_path.is_file():
        # Create a new file with Keep a Changelog header
        header = (
            "# Changelog\n\n"
            "All notable changes to this project will be documented in this file.\n\n"
            "The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\n\n"
        )
        changelog_path.write_text(header + new_section)
        return

    content = changelog_path.read_text()

    # Insert after the header block (first ## or end of preamble)
    insert_re = re.search(r"^## ", content, re.MULTILINE)
    if insert_re:
        pos = insert_re.start()
        updated = content[:pos] + new_section + "\n" + content[pos:]
    else:
        updated = content.rstrip("\n") + "\n\n" + new_section

    changelog_path.write_text(updated)


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        description="Generate changelog entries from completed roadmap/TODO items.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=(
            "Examples:\n"
            "  python roadmap_changelog.py\n"
            '  python roadmap_changelog.py --source TODO.md\n'
            '  python roadmap_changelog.py --since "2024-01-01"\n'
            '  python roadmap_changelog.py --version "1.3.0"\n'
            "  python roadmap_changelog.py --append\n"
            "  python roadmap_changelog.py --dry-run\n"
        ),
    )
    p.add_argument("--source", metavar="FILE", default=None,
                   help="Source file (default: roadmap.md, fallback: TODO.md)")
    p.add_argument("--since", metavar="DATE", default=None,
                   help="Only include items completed since this date (YYYY-MM-DD)")
    p.add_argument("--version", metavar="VER", default=None,
                   help="Version label for the changelog section (default: Unreleased)")
    p.add_argument("--changelog", metavar="FILE", default="CHANGELOG.md",
                   help="Path to CHANGELOG.md for dedup / append (default: CHANGELOG.md)")
    p.add_argument("--append", action="store_true",
                   help="Append the generated section to CHANGELOG.md")
    p.add_argument("--dry-run", action="store_true",
                   help="Preview output without writing anything")
    return p


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()

    # Validate --since date
    if args.since:
        try:
            datetime.strptime(args.since[:10], "%Y-%m-%d")
        except ValueError:
            print(red(f"Invalid date format: {args.since}. Use YYYY-MM-DD."),
                  file=sys.stderr)
            return 1

    # Resolve source file
    source = resolve_source(args.source)
    if source is None:
        tried = args.source or "roadmap.md / TODO.md"
        print(red(f"Source file not found: {tried}"), file=sys.stderr)
        return 1

    print(dim(f"Reading: {source}"))

    text = source.read_text()
    items = parse_completed_items(text, args.since)

    if not items:
        print(yellow("No completed items found."))
        return 0

    print(dim(f"Found {len(items)} completed item(s)"))

    # Cross-reference with existing changelog
    changelog_path = Path(args.changelog)
    existing = load_existing_entries(changelog_path)

    version = args.version or "Unreleased"
    today = date.today().isoformat()

    changelog_text, included, duplicates = build_changelog(
        items, version, today, existing,
    )

    # Print summary
    if duplicates:
        print(dim(f"Skipped {duplicates} duplicate(s) already in {args.changelog}"))
    print(dim(f"Included {included} entry/entries\n"))

    # Output
    if args.dry_run:
        print(cyan("=== DRY RUN — preview only ===\n"))

    print(changelog_text)

    if args.append and not args.dry_run:
        append_to_changelog(changelog_path, changelog_text)
        print(green(f"Appended to {changelog_path}"))
    elif args.append and args.dry_run:
        print(dim(f"Would append to {changelog_path} (dry-run, skipped)"))

    return 0


if __name__ == "__main__":
    sys.exit(main())


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