* docs: restructure README.md — 2,539 → 209 lines (#247)

- Cut from 2,539 lines / 73 sections to 209 lines / 18 sections
- Consolidated 4 install methods into one unified section
- Moved all skill details to domain-level READMEs (linked from table)
- Front-loaded value prop and keywords for SEO
- Added POWERFUL tier highlight section
- Added skill-security-auditor showcase section
- Removed stale Q4 2025 roadmap, outdated ROI claims, duplicate content
- Fixed all internal links
- Clean heading hierarchy (H2 for main sections only)

Closes #233

Co-authored-by: Leo <leo@openclaw.ai>

* fix: enhance 5 skills with scripts, references, and Anthropic best practices (#248)

* fix(skill): enhance git-worktree-manager with scripts, references, and Anthropic best practices

* fix(skill): enhance mcp-server-builder with scripts, references, and Anthropic best practices

* fix(skill): enhance changelog-generator with scripts, references, and Anthropic best practices

* fix(skill): enhance ci-cd-pipeline-builder with scripts, references, and Anthropic best practices

* fix(skill): enhance prompt-engineer-toolkit with scripts, references, and Anthropic best practices

* docs: update README, CHANGELOG, and plugin metadata

* fix: correct marketing plugin count, expand thin references

---------

Co-authored-by: Leo <leo@openclaw.ai>

---------

Co-authored-by: Leo <leo@openclaw.ai>
This commit is contained in:
Alireza Rezvani
2026-03-04 08:38:06 +01:00
committed by GitHub
parent 3960661ae5
commit 3d9d1d2d92
39 changed files with 3835 additions and 4711 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "engineering-advanced-skills",
"description": "11 advanced engineering skills covering tech debt tracking, API design review, database design, dependency auditing, release management, RAG architecture, agent design, migration planning, observability, interview system design, and skill testing",
"version": "1.0.0",
"description": "25 advanced engineering skills covering architecture, automation, CI/CD, MCP servers, release management, security, observability, migration, and platform operations",
"version": "1.1.0",
"author": {
"name": "Alireza Rezvani",
"url": "https://alirezarezvani.com"

View File

@@ -0,0 +1,48 @@
# Changelog Generator
Automates release notes from Conventional Commits with Keep a Changelog output and strict commit linting. Designed for CI-friendly release workflows.
## Quick Start
```bash
# Generate entry from git range
python3 scripts/generate_changelog.py \
--from-tag v1.2.0 \
--to-tag v1.3.0 \
--next-version v1.3.0 \
--format markdown
# Lint commit subjects
python3 scripts/commit_linter.py --from-ref origin/main --to-ref HEAD --strict --format text
```
## Included Tools
- `scripts/generate_changelog.py`: parse commits, infer semver bump, render markdown/JSON, optional file prepend
- `scripts/commit_linter.py`: validate commit subjects against Conventional Commits rules
## References
- `references/ci-integration.md`
- `references/changelog-formatting-guide.md`
- `references/monorepo-strategy.md`
## Installation
### Claude Code
```bash
cp -R engineering/changelog-generator ~/.claude/skills/changelog-generator
```
### OpenAI Codex
```bash
cp -R engineering/changelog-generator ~/.codex/skills/changelog-generator
```
### OpenClaw
```bash
cp -R engineering/changelog-generator ~/.openclaw/skills/changelog-generator
```

View File

@@ -2,486 +2,159 @@
**Tier:** POWERFUL
**Category:** Engineering
**Domain:** Release Management / Documentation
---
**Domain:** Release Management / Documentation
## Overview
Parse conventional commits, determine semantic version bumps, and generate structured changelogs in Keep a Changelog format. Supports monorepo changelogs, GitHub Releases integration, and separates user-facing from developer changelogs.
Use this skill to produce consistent, auditable release notes from Conventional Commits. It separates commit parsing, semantic bump logic, and changelog rendering so teams can automate releases without losing editorial control.
## Core Capabilities
- **Conventional commit parsing** — feat, fix, chore, docs, refactor, perf, test, build, ci
- **SemVer bump determination** — breaking change → major, feat → minor, fix → patch
- **Keep a Changelog format** — Added, Changed, Deprecated, Removed, Fixed, Security
- **Monorepo support** — per-package changelogs with shared version strategy
- **GitHub/GitLab Releases** — auto-create release with changelog body
- **Audience-aware output** — user-facing (what changed) vs developer (why + technical details)
---
- Parse commit messages using Conventional Commit rules
- Detect semantic bump (`major`, `minor`, `patch`) from commit stream
- Render Keep a Changelog sections (`Added`, `Changed`, `Fixed`, etc.)
- Generate release entries from git ranges or provided commit input
- Enforce commit format with a dedicated linter script
- Support CI integration via machine-readable JSON output
## When to Use
- Before every release to generate the CHANGELOG.md entry
- Setting up automated changelog generation in CI
- Converting git log into readable release notes for GitHub Releases
- Maintaining monorepo changelogs for individual packages
- Generating internal release notes for the engineering team
- Before publishing a release tag
- During CI to generate release notes automatically
- During PR checks to block invalid commit message formats
- In monorepos where package changelogs require scoped filtering
- When converting raw git history into user-facing notes
---
## Key Workflows
## Conventional Commits Reference
```
<type>(<scope>): <description>
[optional body]
[optional footer(s)]
```
### Types and SemVer impact
| Type | Changelog section | SemVer bump |
|------|------------------|-------------|
| `feat` | Added | minor |
| `fix` | Fixed | patch |
| `perf` | Changed | patch |
| `refactor` | Changed (internal) | patch |
| `docs` | — (omit or include) | patch |
| `chore` | — (omit) | patch |
| `test` | — (omit) | patch |
| `build` | — (omit) | patch |
| `ci` | — (omit) | patch |
| `security` | Security | patch |
| `deprecated` | Deprecated | minor |
| `remove` | Removed | major (if breaking) |
| `BREAKING CHANGE:` footer | — (major bump) | major |
| `!` after type | — (major bump) | major |
### Examples
```
feat(auth): add OAuth2 login with Google
fix(api): correct pagination offset calculation
feat!: rename /users endpoint to /accounts (BREAKING)
perf(db): add index on users.email column
security: patch XSS vulnerability in comment renderer
docs: update API reference for v2 endpoints
```
---
## Changelog Generation Script
### 1. Generate Changelog Entry From Git
```bash
#!/usr/bin/env bash
# generate-changelog.sh — generate CHANGELOG entry for the latest release
set -euo pipefail
CURRENT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${CURRENT_TAG}^" 2>/dev/null || echo "")
DATE=$(date +%Y-%m-%d)
if [ -z "$CURRENT_TAG" ]; then
echo "No tags found. Create a tag first: git tag v1.0.0"
exit 1
fi
RANGE="${PREVIOUS_TAG:+${PREVIOUS_TAG}..}${CURRENT_TAG}"
echo "Generating changelog for: $RANGE"
# Parse commits
ADDED=""
CHANGED=""
DEPRECATED=""
REMOVED=""
FIXED=""
SECURITY=""
BREAKING=""
while IFS= read -r line; do
# Skip empty lines
[ -z "$line" ] && continue
# Detect type
if [[ "$line" =~ ^feat(\([^)]+\))?\!:\ (.+)$ ]]; then
desc="${BASH_REMATCH[2]}"
BREAKING="${BREAKING}- **BREAKING** ${desc}\n"
ADDED="${ADDED}- ${desc}\n"
elif [[ "$line" =~ ^feat(\([^)]+\))?:\ (.+)$ ]]; then
ADDED="${ADDED}- ${BASH_REMATCH[2]}\n"
elif [[ "$line" =~ ^fix(\([^)]+\))?:\ (.+)$ ]]; then
FIXED="${FIXED}- ${BASH_REMATCH[2]}\n"
elif [[ "$line" =~ ^perf(\([^)]+\))?:\ (.+)$ ]]; then
CHANGED="${CHANGED}- ${BASH_REMATCH[2]}\n"
elif [[ "$line" =~ ^security(\([^)]+\))?:\ (.+)$ ]]; then
SECURITY="${SECURITY}- ${BASH_REMATCH[2]}\n"
elif [[ "$line" =~ ^deprecated(\([^)]+\))?:\ (.+)$ ]]; then
DEPRECATED="${DEPRECATED}- ${BASH_REMATCH[2]}\n"
elif [[ "$line" =~ ^remove(\([^)]+\))?:\ (.+)$ ]]; then
REMOVED="${REMOVED}- ${BASH_REMATCH[2]}\n"
elif [[ "$line" =~ ^refactor(\([^)]+\))?:\ (.+)$ ]]; then
CHANGED="${CHANGED}- ${BASH_REMATCH[2]}\n"
fi
done < <(git log "${RANGE}" --pretty=format:"%s" --no-merges)
# Build output
OUTPUT="## [${CURRENT_TAG}] - ${DATE}\n\n"
[ -n "$BREAKING" ] && OUTPUT="${OUTPUT}### ⚠ BREAKING CHANGES\n${BREAKING}\n"
[ -n "$SECURITY" ] && OUTPUT="${OUTPUT}### Security\n${SECURITY}\n"
[ -n "$ADDED" ] && OUTPUT="${OUTPUT}### Added\n${ADDED}\n"
[ -n "$CHANGED" ] && OUTPUT="${OUTPUT}### Changed\n${CHANGED}\n"
[ -n "$DEPRECATED" ] && OUTPUT="${OUTPUT}### Deprecated\n${DEPRECATED}\n"
[ -n "$REMOVED" ] && OUTPUT="${OUTPUT}### Removed\n${REMOVED}\n"
[ -n "$FIXED" ] && OUTPUT="${OUTPUT}### Fixed\n${FIXED}\n"
printf "$OUTPUT"
# Optionally prepend to CHANGELOG.md
if [ "${1:-}" = "--write" ]; then
TEMP=$(mktemp)
printf "$OUTPUT" > "$TEMP"
if [ -f CHANGELOG.md ]; then
# Insert after the first line (# Changelog header)
head -n 1 CHANGELOG.md >> "$TEMP"
echo "" >> "$TEMP"
printf "$OUTPUT" >> "$TEMP"
tail -n +2 CHANGELOG.md >> "$TEMP"
else
echo "# Changelog" > CHANGELOG.md
echo "All notable changes to this project will be documented here." >> CHANGELOG.md
echo "" >> CHANGELOG.md
cat "$TEMP" >> CHANGELOG.md
fi
mv "$TEMP" CHANGELOG.md
echo "✅ CHANGELOG.md updated"
fi
python3 scripts/generate_changelog.py \
--from-tag v1.3.0 \
--to-tag v1.4.0 \
--next-version v1.4.0 \
--format markdown
```
---
## Python Changelog Generator (more robust)
```python
#!/usr/bin/env python3
"""generate_changelog.py — parse conventional commits and emit Keep a Changelog"""
import subprocess
import re
import sys
from datetime import date
from dataclasses import dataclass, field
from typing import Optional
COMMIT_RE = re.compile(
r"^(?P<type>feat|fix|perf|refactor|docs|test|chore|build|ci|security|deprecated|remove)"
r"(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?: (?P<desc>.+)$"
)
SECTION_MAP = {
"feat": "Added",
"fix": "Fixed",
"perf": "Changed",
"refactor": "Changed",
"security": "Security",
"deprecated": "Deprecated",
"remove": "Removed",
}
@dataclass
class Commit:
type: str
scope: Optional[str]
breaking: bool
desc: str
body: str = ""
sha: str = ""
@dataclass
class ChangelogEntry:
version: str
date: str
added: list[str] = field(default_factory=list)
changed: list[str] = field(default_factory=list)
deprecated: list[str] = field(default_factory=list)
removed: list[str] = field(default_factory=list)
fixed: list[str] = field(default_factory=list)
security: list[str] = field(default_factory=list)
breaking: list[str] = field(default_factory=list)
def get_commits(from_tag: str, to_tag: str) -> list[Commit]:
range_spec = f"{from_tag}..{to_tag}" if from_tag else to_tag
result = subprocess.run(
["git", "log", range_spec, "--pretty=format:%H|%s|%b", "--no-merges"],
capture_output=True, text=True, check=True
)
commits = []
for line in result.stdout.splitlines():
if not line.strip():
continue
parts = line.split("|", 2)
sha = parts[0] if len(parts) > 0 else ""
subject = parts[1] if len(parts) > 1 else ""
body = parts[2] if len(parts) > 2 else ""
m = COMMIT_RE.match(subject)
if m:
commits.append(Commit(
type=m.group("type"),
scope=m.group("scope"),
breaking=m.group("breaking") == "!" or "BREAKING CHANGE" in body,
desc=m.group("desc"),
body=body,
sha=sha[:8],
))
return commits
def determine_bump(commits: list[Commit], current_version: str) -> str:
parts = current_version.lstrip("v").split(".")
major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2])
has_breaking = any(c.breaking for c in commits)
has_feat = any(c.type == "feat" for c in commits)
if has_breaking:
return f"v{major + 1}.0.0"
elif has_feat:
return f"v{major}.{minor + 1}.0"
else:
return f"v{major}.{minor}.{patch + 1}"
def build_entry(commits: list[Commit], version: str) -> ChangelogEntry:
entry = ChangelogEntry(version=version, date=date.today().isoformat())
for c in commits:
scope_prefix = f"**{c.scope}**: " if c.scope else ""
desc = f"{scope_prefix}{c.desc}"
if c.breaking:
entry.breaking.append(desc)
section = SECTION_MAP.get(c.type)
if section == "Added":
entry.added.append(desc)
elif section == "Fixed":
entry.fixed.append(desc)
elif section == "Changed":
entry.changed.append(desc)
elif section == "Security":
entry.security.append(desc)
elif section == "Deprecated":
entry.deprecated.append(desc)
elif section == "Removed":
entry.removed.append(desc)
return entry
def render_entry(entry: ChangelogEntry) -> str:
lines = [f"## [{entry.version}] - {entry.date}", ""]
sections = [
("⚠ BREAKING CHANGES", entry.breaking),
("Security", entry.security),
("Added", entry.added),
("Changed", entry.changed),
("Deprecated", entry.deprecated),
("Removed", entry.removed),
("Fixed", entry.fixed),
]
for title, items in sections:
if items:
lines.append(f"### {title}")
for item in items:
lines.append(f"- {item}")
lines.append("")
return "\n".join(lines)
if __name__ == "__main__":
tags = subprocess.run(
["git", "tag", "--sort=-version:refname"],
capture_output=True, text=True
).stdout.splitlines()
current_tag = tags[0] if tags else ""
previous_tag = tags[1] if len(tags) > 1 else ""
if not current_tag:
print("No tags found. Create a tag first.")
sys.exit(1)
commits = get_commits(previous_tag, current_tag)
entry = build_entry(commits, current_tag)
print(render_entry(entry))
```
---
## Monorepo Changelog Strategy
For repos with multiple packages (e.g., pnpm workspaces, nx, turborepo):
### 2. Generate Entry From stdin/File Input
```bash
# packages/api/CHANGELOG.md — API package only
# packages/ui/CHANGELOG.md — UI package only
# CHANGELOG.md — Root (affects all)
git log v1.3.0..v1.4.0 --pretty=format:'%s' | \
python3 scripts/generate_changelog.py --next-version v1.4.0 --format markdown
# Filter commits by package path
git log v1.2.0..v1.3.0 --pretty=format:"%s" -- packages/api/
python3 scripts/generate_changelog.py --input commits.txt --next-version v1.4.0 --format json
```
With Changesets (recommended for monorepos):
### 3. Update `CHANGELOG.md`
```bash
# Install changesets
pnpm add -D @changesets/cli
pnpm changeset init
# Developer workflow: create a changeset for each PR
pnpm changeset
# → prompts for: which packages changed, bump type, description
# On release branch: version all packages
pnpm changeset version
# Publish and create GitHub release
pnpm changeset publish
python3 scripts/generate_changelog.py \
--from-tag v1.3.0 \
--to-tag HEAD \
--next-version v1.4.0 \
--write CHANGELOG.md
```
---
## GitHub Releases Integration
### 4. Lint Commits Before Merge
```bash
#!/usr/bin/env bash
# create-github-release.sh
set -euo pipefail
VERSION=$(git describe --tags --abbrev=0)
NOTES=$(python3 generate_changelog.py)
# Using GitHub CLI
gh release create "$VERSION" \
--title "Release $VERSION" \
--notes "$NOTES" \
--verify-tag
# Or via API
curl -s -X POST \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Content-Type: application/json" \
"https://api.github.com/repos/${REPO}/releases" \
-d "$(jq -n \
--arg tag "$VERSION" \
--arg name "Release $VERSION" \
--arg body "$NOTES" \
'{tag_name: $tag, name: $name, body: $body, draft: false}')"
python3 scripts/commit_linter.py --from-ref origin/main --to-ref HEAD --strict --format text
```
---
Or file/stdin:
## User-Facing vs Developer Changelog
### User-facing (product changelog)
- Plain language, no jargon
- Focus on what changed, not how
- Skip: refactor, test, chore, ci, docs
- Include: feat, fix, security, perf (if user-visible)
```markdown
## Version 2.3.0 — March 1, 2026
**New:** You can now log in with Google.
**Fixed:** Dashboard no longer freezes when loading large datasets.
**Improved:** Search results load 3x faster.
```bash
python3 scripts/commit_linter.py --input commits.txt --strict
cat commits.txt | python3 scripts/commit_linter.py --format json
```
### Developer changelog (CHANGELOG.md)
- Technical details, scope, SemVer impact
- Include all breaking changes with migration notes
- Reference PR numbers and issue IDs
## Conventional Commit Rules
```markdown
## [2.3.0] - 2026-03-01
Supported types:
### Added
- **auth**: OAuth2 Google login via passport-google (#234)
- **api**: GraphQL subscriptions for real-time updates (#241)
- `feat`, `fix`, `perf`, `refactor`, `docs`, `test`, `build`, `ci`, `chore`
- `security`, `deprecated`, `remove`
### Fixed
- **dashboard**: resolve infinite re-render on large datasets (closes #228)
Breaking changes:
### Performance
- **search**: switch from Elasticsearch to Typesense, P99 latency -67% (#239)
```
- `type(scope)!: summary`
- Footer/body includes `BREAKING CHANGE:`
---
SemVer mapping:
## GitHub Actions — Automated Changelog CI
- breaking -> `major`
- non-breaking `feat` -> `minor`
- all others -> `patch`
```yaml
name: Release
## Script Interfaces
on:
push:
tags: ['v*']
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for git log
- name: Generate changelog
id: changelog
run: |
NOTES=$(python3 scripts/generate_changelog.py)
echo "notes<<EOF" >> $GITHUB_OUTPUT
echo "$NOTES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
body: ${{ steps.changelog.outputs.notes }}
generate_release_notes: false
```
---
- `python3 scripts/generate_changelog.py --help`
- Reads commits from git or stdin/`--input`
- Renders markdown or JSON
- Optional in-place changelog prepend
- `python3 scripts/commit_linter.py --help`
- Validates commit format
- Returns non-zero in `--strict` mode on violations
## Common Pitfalls
- **`--depth=1` in CI** — git log needs full history; use `fetch-depth: 0`
- **Merge commits polluting log** — always use `--no-merges`
- **No conventional commits discipline** — enforce with `commitlint` in CI
- **Missing previous tag** — handle first-release case (no previous tag)
- **Version in multiple places** — single source of truth; read from git tag, not package.json
---
1. Mixing merge commit messages with release commit parsing
2. Using vague commit summaries that cannot become release notes
3. Failing to include migration guidance for breaking changes
4. Treating docs/chore changes as user-facing features
5. Overwriting historical changelog sections instead of prepending
## Best Practices
1. **commitlint in CI** — enforce conventional commits before merge
2. **Tag before generating** — tag the release commit first, then generate
3. **Separate user/dev changelog** — product team wants plain English
4. **Keep a link section**`[2.3.0]: https://github.com/org/repo/compare/v2.2.0...v2.3.0`
5. **Automate but review** — generate in CI, human reviews before publish
1. Keep commits small and intent-driven.
2. Scope commit messages (`feat(api): ...`) in multi-package repos.
3. Enforce linter checks in PR pipelines.
4. Review generated markdown before publishing.
5. Tag releases only after changelog generation succeeds.
6. Keep an `[Unreleased]` section for manual curation when needed.
## References
- [references/ci-integration.md](references/ci-integration.md)
- [references/changelog-formatting-guide.md](references/changelog-formatting-guide.md)
- [references/monorepo-strategy.md](references/monorepo-strategy.md)
- [README.md](README.md)
## Release Governance
Use this release flow for predictability:
1. Lint commit history for target release range.
2. Generate changelog draft from commits.
3. Manually adjust wording for customer clarity.
4. Validate semver bump recommendation.
5. Tag release only after changelog is approved.
## Output Quality Checks
- Each bullet is user-meaningful, not implementation noise.
- Breaking changes include migration action.
- Security fixes are isolated in `Security` section.
- Sections with no entries are omitted.
- Duplicate bullets across sections are removed.
## CI Policy
- Run `commit_linter.py --strict` on all PRs.
- Block merge on invalid conventional commits.
- Auto-generate draft release notes on tag push.
- Require human approval before writing into `CHANGELOG.md` on main branch.
## Monorepo Guidance
- Prefer commit scopes aligned to package names.
- Filter commit stream by scope for package-specific releases.
- Keep infra-wide changes in root changelog.
- Store package changelogs near package roots for ownership clarity.
## Failure Handling
- If no valid conventional commits found: fail early, do not generate misleading empty notes.
- If git range invalid: surface explicit range in error output.
- If write target missing: create safe changelog header scaffolding.

View File

@@ -0,0 +1,17 @@
# Changelog Formatting Guide
Use Keep a Changelog section ordering:
1. Security
2. Added
3. Changed
4. Deprecated
5. Removed
6. Fixed
Rules:
- One bullet = one user-visible change.
- Lead with impact, not implementation detail.
- Keep bullets short and actionable.
- Include migration note for breaking changes.

View File

@@ -0,0 +1,26 @@
# CI Integration Examples
## GitHub Actions
```yaml
name: Changelog Check
on: [pull_request]
jobs:
changelog:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python3 engineering/changelog-generator/scripts/commit_linter.py \
--from-ref origin/main --to-ref HEAD --strict
```
## GitLab CI
```yaml
changelog_lint:
image: python:3.12
stage: test
script:
- python3 engineering/changelog-generator/scripts/commit_linter.py --to-ref HEAD --strict
```

View File

@@ -0,0 +1,39 @@
# Monorepo Changelog Strategy
## Approaches
| Strategy | When to use | Tradeoff |
|----------|-------------|----------|
| Single root changelog | Product-wide releases, small teams | Simple but loses package-level detail |
| Per-package changelogs | Independent versioning, large teams | Clear ownership but harder to see full picture |
| Hybrid model | Root summary + package-specific details | Best of both, more maintenance |
## Commit Scoping Pattern
Enforce scoped conventional commits to enable per-package filtering:
```
feat(payments): add Stripe webhook handler
fix(auth): handle expired refresh tokens
chore(infra): bump base Docker image
```
**Rules:**
- Scope must match a package/directory name exactly
- Unscoped commits go to root changelog only
- Multi-package changes get separate scoped commits (not one mega-commit)
## Filtering for Package Releases
```bash
# Generate changelog for 'payments' package only
git log v1.3.0..HEAD --pretty=format:'%s' | grep '^[a-z]*\(payments\)' | \
python3 scripts/generate_changelog.py --next-version v1.4.0 --format markdown
```
## Ownership Model
- Package maintainers own their scoped changelog
- Platform/infra team owns root changelog
- CI enforces scope presence on all commits touching package directories
- Root changelog aggregates breaking changes from all packages for visibility

View File

@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Lint commit messages against Conventional Commits.
Input sources (priority order):
1) --input file (one commit subject per line)
2) stdin lines
3) git range via --from-ref/--to-ref
Use --strict for non-zero exit on violations.
"""
import argparse
import json
import re
import subprocess
import sys
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import List, Optional
CONVENTIONAL_RE = re.compile(
r"^(feat|fix|perf|refactor|docs|test|build|ci|chore|security|deprecated|remove)"
r"(\([a-z0-9._/-]+\))?(!)?:\s+.{1,120}$"
)
class CLIError(Exception):
"""Raised for expected CLI errors."""
@dataclass
class LintReport:
total: int
valid: int
invalid: int
violations: List[str]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Validate conventional commit subjects.")
parser.add_argument("--input", help="File with commit subjects (one per line).")
parser.add_argument("--from-ref", help="Git ref start (exclusive).")
parser.add_argument("--to-ref", help="Git ref end (inclusive).")
parser.add_argument("--strict", action="store_true", help="Exit non-zero when violations exist.")
parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format.")
return parser.parse_args()
def lines_from_file(path: str) -> List[str]:
try:
return [line.strip() for line in Path(path).read_text(encoding="utf-8").splitlines() if line.strip()]
except Exception as exc:
raise CLIError(f"Failed reading --input file: {exc}") from exc
def lines_from_stdin() -> List[str]:
if sys.stdin.isatty():
return []
data = sys.stdin.read()
return [line.strip() for line in data.splitlines() if line.strip()]
def lines_from_git(args: argparse.Namespace) -> List[str]:
if not args.to_ref:
return []
range_spec = f"{args.from_ref}..{args.to_ref}" if args.from_ref else args.to_ref
try:
proc = subprocess.run(
["git", "log", range_spec, "--pretty=format:%s", "--no-merges"],
text=True,
capture_output=True,
check=True,
)
except subprocess.CalledProcessError as exc:
raise CLIError(f"git log failed for range '{range_spec}': {exc.stderr.strip()}") from exc
return [line.strip() for line in proc.stdout.splitlines() if line.strip()]
def load_lines(args: argparse.Namespace) -> List[str]:
if args.input:
return lines_from_file(args.input)
stdin_lines = lines_from_stdin()
if stdin_lines:
return stdin_lines
git_lines = lines_from_git(args)
if git_lines:
return git_lines
raise CLIError("No commit input found. Use --input, stdin, or --to-ref.")
def lint(lines: List[str]) -> LintReport:
violations: List[str] = []
valid = 0
for idx, line in enumerate(lines, start=1):
if CONVENTIONAL_RE.match(line):
valid += 1
continue
violations.append(f"line {idx}: {line}")
return LintReport(total=len(lines), valid=valid, invalid=len(violations), violations=violations)
def format_text(report: LintReport) -> str:
lines = [
"Conventional commit lint report",
f"- total: {report.total}",
f"- valid: {report.valid}",
f"- invalid: {report.invalid}",
]
if report.violations:
lines.append("Violations:")
lines.extend([f"- {v}" for v in report.violations])
return "\n".join(lines)
def main() -> int:
args = parse_args()
lines = load_lines(args)
report = lint(lines)
if args.format == "json":
print(json.dumps(asdict(report), indent=2))
else:
print(format_text(report))
if args.strict and report.invalid > 0:
return 1
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except CLIError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(2)

View File

@@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""Generate changelog entries from Conventional Commits.
Input sources (priority order):
1) --input file with one commit subject per line
2) stdin commit subjects
3) git log from --from-tag/--to-tag or --from-ref/--to-ref
Outputs markdown or JSON and can prepend into CHANGELOG.md.
"""
import argparse
import json
import re
import subprocess
import sys
from dataclasses import dataclass, asdict, field
from datetime import date
from pathlib import Path
from typing import Dict, List, Optional
COMMIT_RE = re.compile(
r"^(?P<type>feat|fix|perf|refactor|docs|test|build|ci|chore|security|deprecated|remove)"
r"(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<summary>.+)$"
)
SECTION_MAP = {
"feat": "Added",
"fix": "Fixed",
"perf": "Changed",
"refactor": "Changed",
"security": "Security",
"deprecated": "Deprecated",
"remove": "Removed",
}
class CLIError(Exception):
"""Raised for expected CLI failures."""
@dataclass
class ParsedCommit:
raw: str
ctype: str
scope: Optional[str]
summary: str
breaking: bool
@dataclass
class ChangelogEntry:
version: str
release_date: str
sections: Dict[str, List[str]] = field(default_factory=dict)
breaking_changes: List[str] = field(default_factory=list)
bump: str = "patch"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate changelog from conventional commits.")
parser.add_argument("--input", help="Text file with one commit subject per line.")
parser.add_argument("--from-tag", help="Git tag start (exclusive).")
parser.add_argument("--to-tag", help="Git tag end (inclusive).")
parser.add_argument("--from-ref", help="Git ref start (exclusive).")
parser.add_argument("--to-ref", help="Git ref end (inclusive).")
parser.add_argument("--next-version", default="Unreleased", help="Version label for the generated entry.")
parser.add_argument("--date", dest="entry_date", default=str(date.today()), help="Release date (YYYY-MM-DD).")
parser.add_argument("--format", choices=["markdown", "json"], default="markdown", help="Output format.")
parser.add_argument("--write", help="Prepend generated markdown entry into this changelog file.")
return parser.parse_args()
def read_lines_from_file(path: str) -> List[str]:
try:
return [line.strip() for line in Path(path).read_text(encoding="utf-8").splitlines() if line.strip()]
except Exception as exc:
raise CLIError(f"Failed reading --input file: {exc}") from exc
def read_lines_from_stdin() -> List[str]:
if sys.stdin.isatty():
return []
payload = sys.stdin.read()
return [line.strip() for line in payload.splitlines() if line.strip()]
def read_lines_from_git(args: argparse.Namespace) -> List[str]:
if args.from_tag or args.to_tag:
if not args.to_tag:
raise CLIError("--to-tag is required when using tag range.")
start = args.from_tag
end = args.to_tag
elif args.from_ref or args.to_ref:
if not args.to_ref:
raise CLIError("--to-ref is required when using ref range.")
start = args.from_ref
end = args.to_ref
else:
return []
range_spec = f"{start}..{end}" if start else end
try:
proc = subprocess.run(
["git", "log", range_spec, "--pretty=format:%s", "--no-merges"],
text=True,
capture_output=True,
check=True,
)
except subprocess.CalledProcessError as exc:
raise CLIError(f"git log failed for range '{range_spec}': {exc.stderr.strip()}") from exc
return [line.strip() for line in proc.stdout.splitlines() if line.strip()]
def load_commits(args: argparse.Namespace) -> List[str]:
if args.input:
return read_lines_from_file(args.input)
stdin_lines = read_lines_from_stdin()
if stdin_lines:
return stdin_lines
git_lines = read_lines_from_git(args)
if git_lines:
return git_lines
raise CLIError("No commit input found. Use --input, stdin, or git range flags.")
def parse_commits(lines: List[str]) -> List[ParsedCommit]:
parsed: List[ParsedCommit] = []
for line in lines:
match = COMMIT_RE.match(line)
if not match:
continue
ctype = match.group("type")
scope = match.group("scope")
summary = match.group("summary")
breaking = bool(match.group("breaking")) or "BREAKING CHANGE" in line
parsed.append(ParsedCommit(raw=line, ctype=ctype, scope=scope, summary=summary, breaking=breaking))
return parsed
def determine_bump(commits: List[ParsedCommit]) -> str:
if any(c.breaking for c in commits):
return "major"
if any(c.ctype == "feat" for c in commits):
return "minor"
return "patch"
def build_entry(commits: List[ParsedCommit], version: str, entry_date: str) -> ChangelogEntry:
sections: Dict[str, List[str]] = {
"Security": [],
"Added": [],
"Changed": [],
"Deprecated": [],
"Removed": [],
"Fixed": [],
}
breaking_changes: List[str] = []
for commit in commits:
if commit.breaking:
breaking_changes.append(commit.summary)
section = SECTION_MAP.get(commit.ctype)
if section:
line = commit.summary if not commit.scope else f"{commit.scope}: {commit.summary}"
sections[section].append(line)
sections = {k: v for k, v in sections.items() if v}
return ChangelogEntry(
version=version,
release_date=entry_date,
sections=sections,
breaking_changes=breaking_changes,
bump=determine_bump(commits),
)
def render_markdown(entry: ChangelogEntry) -> str:
lines = [f"## [{entry.version}] - {entry.release_date}", ""]
if entry.breaking_changes:
lines.append("### Breaking")
lines.extend([f"- {item}" for item in entry.breaking_changes])
lines.append("")
ordered_sections = ["Security", "Added", "Changed", "Deprecated", "Removed", "Fixed"]
for section in ordered_sections:
items = entry.sections.get(section, [])
if not items:
continue
lines.append(f"### {section}")
lines.extend([f"- {item}" for item in items])
lines.append("")
lines.append(f"<!-- recommended-semver-bump: {entry.bump} -->")
return "\n".join(lines).strip() + "\n"
def prepend_changelog(path: Path, entry_md: str) -> None:
if path.exists():
original = path.read_text(encoding="utf-8")
else:
original = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n"
if original.startswith("# Changelog"):
first_break = original.find("\n")
head = original[: first_break + 1]
tail = original[first_break + 1 :].lstrip("\n")
combined = f"{head}\n{entry_md}\n{tail}"
else:
combined = f"# Changelog\n\n{entry_md}\n{original}"
path.write_text(combined, encoding="utf-8")
def main() -> int:
args = parse_args()
lines = load_commits(args)
parsed = parse_commits(lines)
if not parsed:
raise CLIError("No valid conventional commit messages found in input.")
entry = build_entry(parsed, args.next_version, args.entry_date)
if args.format == "json":
print(json.dumps(asdict(entry), indent=2))
else:
markdown = render_markdown(entry)
print(markdown, end="")
if args.write:
prepend_changelog(Path(args.write), markdown)
if args.format == "json" and args.write:
prepend_changelog(Path(args.write), render_markdown(entry))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except CLIError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(2)

View File

@@ -0,0 +1,48 @@
# CI/CD Pipeline Builder
Detects your repository stack and generates practical CI pipeline templates for GitHub Actions and GitLab CI. Designed as a fast baseline you can extend with deployment controls.
## Quick Start
```bash
# Detect stack
python3 scripts/stack_detector.py --repo . --format json > stack.json
# Generate GitHub Actions workflow
python3 scripts/pipeline_generator.py \
--input stack.json \
--platform github \
--output .github/workflows/ci.yml \
--format text
```
## Included Tools
- `scripts/stack_detector.py`: repository signal detection with JSON/text output
- `scripts/pipeline_generator.py`: generate GitHub/GitLab CI YAML from detection payload
## References
- `references/github-actions-templates.md`
- `references/gitlab-ci-templates.md`
- `references/deployment-gates.md`
## Installation
### Claude Code
```bash
cp -R engineering/ci-cd-pipeline-builder ~/.claude/skills/ci-cd-pipeline-builder
```
### OpenAI Codex
```bash
cp -R engineering/ci-cd-pipeline-builder ~/.codex/skills/ci-cd-pipeline-builder
```
### OpenClaw
```bash
cp -R engineering/ci-cd-pipeline-builder ~/.openclaw/skills/ci-cd-pipeline-builder
```

View File

@@ -2,516 +2,141 @@
**Tier:** POWERFUL
**Category:** Engineering
**Domain:** DevOps / Automation
---
**Domain:** DevOps / Automation
## Overview
Analyzes your project stack and generates production-ready CI/CD pipeline configurations for GitHub Actions, GitLab CI, and Bitbucket Pipelines. Handles matrix testing, caching strategies, deployment stages, environment promotion, and secret management — tailored to your actual tech stack.
Use this skill to generate pragmatic CI/CD pipelines from detected project stack signals, not guesswork. It focuses on fast baseline generation, repeatable checks, and environment-aware deployment stages.
## Core Capabilities
- **Stack detection** — reads `package.json`, `Dockerfile`, `pyproject.toml`, `go.mod`, etc.
- **Pipeline generation** — GitHub Actions, GitLab CI, Bitbucket Pipelines
- **Matrix testing** — multi-version, multi-OS, multi-environment
- **Smart caching** — npm, pip, Docker layer, Gradle, Maven
- **Deployment stages** — build → test → staging → production with approvals
- **Environment promotion** — automatic on green tests, manual gate for production
- **Secret management** — patterns for GitHub Secrets, GitLab CI Variables, Vault, AWS SSM
---
- Detect language/runtime/tooling from repository files
- Recommend CI stages (`lint`, `test`, `build`, `deploy`)
- Generate GitHub Actions or GitLab CI starter pipelines
- Include caching and matrix strategy based on detected stack
- Emit machine-readable detection output for automation
- Keep pipeline logic aligned with project lockfiles and build commands
## When to Use
- Starting a new project and need a CI/CD baseline
- Migrating from one CI platform to another
- Adding deployment stages to an existing pipeline
- Auditing a slow pipeline and optimizing caching
- Setting up environment promotion with manual approval gates
- Bootstrapping CI for a new repository
- Replacing brittle copied pipeline files
- Migrating between GitHub Actions and GitLab CI
- Auditing whether pipeline steps match actual stack
- Creating a reproducible baseline before custom hardening
---
## Key Workflows
## Workflow
### 1. Detect Stack
### Step 1 — Stack Detection
Ask Claude to analyze your repo:
```
Analyze my repo and generate a GitHub Actions CI/CD pipeline.
Check: package.json, Dockerfile, .nvmrc, pyproject.toml, go.mod
```bash
python3 scripts/stack_detector.py --repo . --format text
python3 scripts/stack_detector.py --repo . --format json > detected-stack.json
```
Claude will inspect:
Supports input via stdin or `--input` file for offline analysis payloads.
| File | Signals |
|------|---------|
| `package.json` | Node version, test runner, build tool |
| `.nvmrc` / `.node-version` | Exact Node version |
| `Dockerfile` | Base image, multi-stage build |
| `pyproject.toml` | Python version, test runner |
| `go.mod` | Go version |
| `vercel.json` | Vercel deployment config |
| `k8s/` or `helm/` | Kubernetes deployment |
### 2. Generate Pipeline From Detection
---
## Complete Example: Next.js + Vercel
```yaml
# .github/workflows/ci.yml
name: CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
NODE_VERSION: '20'
PNPM_VERSION: '8'
jobs:
lint-typecheck:
name: Lint & Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
test:
name: Test (Node ${{ matrix.node }})
runs-on: ubuntu-latest
strategy:
matrix:
node: ['18', '20', '22']
fail-fast: false
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Run tests with coverage
run: pnpm test:ci
env:
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
name: Build
runs-on: ubuntu-latest
needs: [lint-typecheck, test]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
env:
NEXT_PUBLIC_API_URL: ${{ vars.NEXT_PUBLIC_API_URL }}
- uses: actions/upload-artifact@v4
with:
name: build-${{ github.sha }}
path: .next/
retention-days: 7
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/develop'
environment:
name: staging
url: https://staging.myapp.com
steps:
- uses: actions/checkout@v4
- uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
environment:
name: production
url: https://myapp.com
steps:
- uses: actions/checkout@v4
- uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'
```bash
python3 scripts/pipeline_generator.py \
--input detected-stack.json \
--platform github \
--output .github/workflows/ci.yml \
--format text
```
---
Or end-to-end from repo directly:
## Complete Example: Python + AWS Lambda
```yaml
# .github/workflows/deploy.yml
name: Python Lambda CI/CD
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.11', '3.12']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- run: pip install -r requirements-dev.txt
- run: pytest tests/ -v --cov=src --cov-report=xml
- run: mypy src/
- run: ruff check src/ tests/
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install bandit safety
- run: bandit -r src/ -ll
- run: safety check
package:
needs: [test, security]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Build Lambda zip
run: |
pip install -r requirements.txt --target ./package
cd package && zip -r ../lambda.zip .
cd .. && zip lambda.zip -r src/
- uses: actions/upload-artifact@v4
with:
name: lambda-${{ github.sha }}
path: lambda.zip
deploy-staging:
needs: package
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/download-artifact@v4
with:
name: lambda-${{ github.sha }}
- uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: eu-west-1
- run: |
aws lambda update-function-code \
--function-name myapp-staging \
--zip-file fileb://lambda.zip
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/download-artifact@v4
with:
name: lambda-${{ github.sha }}
- uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: eu-west-1
- run: |
aws lambda update-function-code \
--function-name myapp-production \
--zip-file fileb://lambda.zip
VERSION=$(aws lambda publish-version \
--function-name myapp-production \
--query 'Version' --output text)
aws lambda update-alias \
--function-name myapp-production \
--name live \
--function-version $VERSION
```bash
python3 scripts/pipeline_generator.py --repo . --platform gitlab --output .gitlab-ci.yml
```
---
### 3. Validate Before Merge
## Complete Example: Docker + Kubernetes
1. Confirm commands exist in project (`test`, `lint`, `build`).
2. Run generated pipeline locally where possible.
3. Ensure required secrets/env vars are documented.
4. Keep deploy jobs gated by protected branches/environments.
```yaml
# .github/workflows/k8s-deploy.yml
name: Docker + Kubernetes
### 4. Add Deployment Stages Safely
on:
push:
branches: [main]
tags: ['v*']
- Start with CI-only (`lint/test/build`).
- Add staging deploy with explicit environment context.
- Add production deploy with manual gate/approval.
- Keep rollout/rollback commands explicit and auditable.
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
## Script Interfaces
jobs:
build-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image-digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha,prefix=sha-
- name: Build and push
id: push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy-staging:
needs: build-push
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- uses: azure/setup-kubectl@v3
- name: Set kubeconfig
run: |
echo "${{ secrets.KUBE_CONFIG_STAGING }}" | base64 -d > /tmp/kubeconfig
echo "KUBECONFIG=/tmp/kubeconfig" >> $GITHUB_ENV
- name: Deploy
run: |
kubectl set image deployment/myapp \
myapp=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build-push.outputs.image-digest }} \
-n staging
kubectl rollout status deployment/myapp -n staging --timeout=5m
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: azure/setup-kubectl@v3
- name: Set kubeconfig
run: |
echo "${{ secrets.KUBE_CONFIG_PROD }}" | base64 -d > /tmp/kubeconfig
echo "KUBECONFIG=/tmp/kubeconfig" >> $GITHUB_ENV
- name: Canary deploy
run: |
kubectl set image deployment/myapp-canary \
myapp=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build-push.outputs.image-digest }} \
-n production
kubectl rollout status deployment/myapp-canary -n production --timeout=5m
sleep 120
kubectl set image deployment/myapp \
myapp=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build-push.outputs.image-digest }} \
-n production
kubectl rollout status deployment/myapp -n production --timeout=10m
```
---
## GitLab CI Equivalent
```yaml
# .gitlab-ci.yml
stages: [lint, test, build, deploy-staging, deploy-production]
variables:
NODE_VERSION: "20"
DOCKER_BUILDKIT: "1"
.node-cache: &node-cache
cache:
key:
files: [pnpm-lock.yaml]
paths:
- node_modules/
- .pnpm-store/
lint:
stage: lint
image: node:${NODE_VERSION}-alpine
<<: *node-cache
script:
- corepack enable && pnpm install --frozen-lockfile
- pnpm lint && pnpm typecheck
test:
stage: test
image: node:${NODE_VERSION}-alpine
<<: *node-cache
parallel:
matrix:
- NODE_VERSION: ["18", "20", "22"]
script:
- corepack enable && pnpm install --frozen-lockfile
- pnpm test:ci
coverage: '/Lines\s*:\s*(\d+\.?\d*)%/'
deploy-staging:
stage: deploy-staging
environment:
name: staging
url: https://staging.myapp.com
only: [develop]
script:
- npx vercel --token=$VERCEL_TOKEN
deploy-production:
stage: deploy-production
environment:
name: production
url: https://myapp.com
only: [main]
when: manual
script:
- npx vercel --prod --token=$VERCEL_TOKEN
```
---
## Secret Management Patterns
### GitHub Actions — Secret Hierarchy
```
Repository secrets → all branches
Environment secrets → only that environment
Organization secrets → all repos in org
```
### Fetching from AWS SSM at runtime
```yaml
- name: Load secrets from SSM
run: |
DB_URL=$(aws ssm get-parameter \
--name "/myapp/production/DATABASE_URL" \
--with-decryption \
--query 'Parameter.Value' --output text)
echo "DATABASE_URL=$DB_URL" >> $GITHUB_ENV
env:
AWS_REGION: eu-west-1
```
### HashiCorp Vault integration
```yaml
- uses: hashicorp/vault-action@v2
with:
url: ${{ secrets.VAULT_ADDR }}
token: ${{ secrets.VAULT_TOKEN }}
secrets: |
secret/data/myapp/prod DATABASE_URL | DATABASE_URL ;
secret/data/myapp/prod API_KEY | API_KEY
```
---
## Caching Cheat Sheet
| Stack | Cache key | Cache path |
|-------|-----------|------------|
| npm | `package-lock.json` | `~/.npm` |
| pnpm | `pnpm-lock.yaml` | `~/.pnpm-store` |
| pip | `requirements.txt` | `~/.cache/pip` |
| poetry | `poetry.lock` | `~/.cache/pypoetry` |
| Docker | SHA of Dockerfile | GHA cache (type=gha) |
| Go | `go.sum` | `~/go/pkg/mod` |
---
- `python3 scripts/stack_detector.py --help`
- Detects stack signals from repository files
- Reads optional JSON input from stdin/`--input`
- `python3 scripts/pipeline_generator.py --help`
- Generates GitHub/GitLab YAML from detection payload
- Writes to stdout or `--output`
## Common Pitfalls
- **Secrets in logs** — never `echo $SECRET`; use `::add-mask::$SECRET` if needed
- **No concurrency limits** — add `concurrency:` to cancel stale runs on PR push
- **Skipping `--frozen-lockfile`** — lockfile drift breaks reproducibility
- **No rollback plan** — test `kubectl rollout undo` or `vercel rollback` before you need it
- **Mutable image tags** — never use `latest` in production; tag by git SHA
- **Missing environment protection rules** — set required reviewers in GitHub Environments
---
1. Copying a Node pipeline into Python/Go repos
2. Enabling deploy jobs before stable tests
3. Forgetting dependency cache keys
4. Running expensive matrix builds for every trivial branch
5. Missing branch protections around prod deploy jobs
6. Hardcoding secrets in YAML instead of CI secret stores
## Best Practices
1. **Fail fast** — lint/typecheck before expensive test jobs
2. **Artifact immutability** — Docker image tagged by git SHA
3. **Environment parity** — same image through all envs, config via env vars
4. **Canary first** — 10% traffic + error rate check before 100%
5. **Pin action versions**`@v4` not `@main`
6. **Least privilege** — each job gets only the IAM scopes it needs
7. **Notify on failure** — Slack webhook for production deploy failures
1. Detect stack first, then generate pipeline.
2. Keep generated baseline under version control.
3. Add one optimization at a time (cache, matrix, split jobs).
4. Require green CI before deployment jobs.
5. Use protected environments for production credentials.
6. Regenerate pipeline when stack changes significantly.
## References
- [references/github-actions-templates.md](references/github-actions-templates.md)
- [references/gitlab-ci-templates.md](references/gitlab-ci-templates.md)
- [references/deployment-gates.md](references/deployment-gates.md)
- [README.md](README.md)
## Detection Heuristics
The stack detector prioritizes deterministic file signals over heuristics:
- Lockfiles determine package manager preference
- Language manifests determine runtime families
- Script commands (if present) drive lint/test/build commands
- Missing scripts trigger conservative placeholder commands
## Generation Strategy
Start with a minimal, reliable pipeline:
1. Checkout and setup runtime
2. Install dependencies with cache strategy
3. Run lint, test, build in separate steps
4. Publish artifacts only after passing checks
Then layer advanced behavior (matrix builds, security scans, deploy gates).
## Platform Decision Notes
- GitHub Actions for tight GitHub ecosystem integration
- GitLab CI for integrated SCM + CI in self-hosted environments
- Keep one canonical pipeline source per repo to reduce drift
## Validation Checklist
1. Generated YAML parses successfully.
2. All referenced commands exist in the repo.
3. Cache strategy matches package manager.
4. Required secrets are documented, not embedded.
5. Branch/protected-environment rules match org policy.
## Scaling Guidance
- Split long jobs by stage when runtime exceeds 10 minutes.
- Introduce test matrix only when compatibility truly requires it.
- Separate deploy jobs from CI jobs to keep feedback fast.
- Track pipeline duration and flakiness as first-class metrics.

View File

@@ -0,0 +1,17 @@
# Deployment Gates
## Minimum Gate Policy
- `lint` must pass before `test`.
- `test` must pass before `build`.
- `build` artifact required for deploy jobs.
- Production deploy requires manual approval and protected branch.
## Environment Pattern
- `develop` -> auto deploy to staging
- `main` -> manual promote to production
## Rollback Requirement
Every deploy job should define a rollback command or procedure reference.

View File

@@ -0,0 +1,41 @@
# GitHub Actions Templates
## Node.js Baseline
```yaml
name: Node CI
on: [push, pull_request]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm run build
```
## Python Baseline
```yaml
name: Python CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: python3 -m pip install -U pip
- run: python3 -m pip install -r requirements.txt
- run: python3 -m pytest
```

View File

@@ -0,0 +1,39 @@
# GitLab CI Templates
## Node.js Baseline
```yaml
stages:
- lint
- test
- build
node_lint:
image: node:20
stage: lint
script:
- npm ci
- npm run lint
node_test:
image: node:20
stage: test
script:
- npm ci
- npm test
```
## Python Baseline
```yaml
stages:
- test
python_test:
image: python:3.12
stage: test
script:
- python3 -m pip install -U pip
- python3 -m pip install -r requirements.txt
- python3 -m pytest
```

View File

@@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""Generate CI pipeline YAML from detected stack data.
Input sources:
- --input stack report JSON file
- stdin stack report JSON
- --repo path (auto-detect stack)
Output:
- text/json summary
- pipeline YAML written via --output or printed to stdout
"""
import argparse
import json
import sys
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any, Dict, List, Optional
class CLIError(Exception):
"""Raised for expected CLI failures."""
@dataclass
class PipelineSummary:
platform: str
output: str
stages: List[str]
uses_cache: bool
languages: List[str]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate CI/CD pipeline YAML from detected stack.")
parser.add_argument("--input", help="Stack report JSON file. If omitted, can read stdin JSON.")
parser.add_argument("--repo", help="Repository path for auto-detection fallback.")
parser.add_argument("--platform", choices=["github", "gitlab"], required=True, help="Target CI platform.")
parser.add_argument("--output", help="Write YAML to this file; otherwise print to stdout.")
parser.add_argument("--format", choices=["text", "json"], default="text", help="Summary output format.")
return parser.parse_args()
def load_json_input(input_path: Optional[str]) -> Optional[Dict[str, Any]]:
if input_path:
try:
return json.loads(Path(input_path).read_text(encoding="utf-8"))
except Exception as exc:
raise CLIError(f"Failed reading --input: {exc}") from exc
if not sys.stdin.isatty():
raw = sys.stdin.read().strip()
if raw:
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
raise CLIError(f"Invalid JSON from stdin: {exc}") from exc
return None
def detect_stack(repo: Path) -> Dict[str, Any]:
scripts = {}
pkg_file = repo / "package.json"
if pkg_file.exists():
try:
pkg = json.loads(pkg_file.read_text(encoding="utf-8"))
raw_scripts = pkg.get("scripts", {})
if isinstance(raw_scripts, dict):
scripts = raw_scripts
except Exception:
scripts = {}
languages: List[str] = []
if pkg_file.exists():
languages.append("node")
if (repo / "pyproject.toml").exists() or (repo / "requirements.txt").exists():
languages.append("python")
if (repo / "go.mod").exists():
languages.append("go")
return {
"languages": sorted(set(languages)),
"signals": {
"pnpm_lock": (repo / "pnpm-lock.yaml").exists(),
"yarn_lock": (repo / "yarn.lock").exists(),
"npm_lock": (repo / "package-lock.json").exists(),
"dockerfile": (repo / "Dockerfile").exists(),
},
"lint_commands": ["npm run lint"] if "lint" in scripts else [],
"test_commands": ["npm test"] if "test" in scripts else [],
"build_commands": ["npm run build"] if "build" in scripts else [],
}
def select_node_install(signals: Dict[str, Any]) -> str:
if signals.get("pnpm_lock"):
return "pnpm install --frozen-lockfile"
if signals.get("yarn_lock"):
return "yarn install --frozen-lockfile"
return "npm ci"
def github_yaml(stack: Dict[str, Any]) -> str:
langs = stack.get("languages", [])
signals = stack.get("signals", {})
lint_cmds = stack.get("lint_commands", []) or ["echo 'No lint command configured'"]
test_cmds = stack.get("test_commands", []) or ["echo 'No test command configured'"]
build_cmds = stack.get("build_commands", []) or ["echo 'No build command configured'"]
lines: List[str] = [
"name: CI",
"on:",
" push:",
" branches: [main, develop]",
" pull_request:",
" branches: [main, develop]",
"",
"jobs:",
]
if "node" in langs:
lines.extend(
[
" node-ci:",
" runs-on: ubuntu-latest",
" steps:",
" - uses: actions/checkout@v4",
" - uses: actions/setup-node@v4",
" with:",
" node-version: '20'",
" cache: 'npm'",
f" - run: {select_node_install(signals)}",
]
)
for cmd in lint_cmds + test_cmds + build_cmds:
lines.append(f" - run: {cmd}")
if "python" in langs:
lines.extend(
[
" python-ci:",
" runs-on: ubuntu-latest",
" steps:",
" - uses: actions/checkout@v4",
" - uses: actions/setup-python@v5",
" with:",
" python-version: '3.12'",
" - run: python3 -m pip install -U pip",
" - run: python3 -m pip install -r requirements.txt || true",
" - run: python3 -m pytest || true",
]
)
if "go" in langs:
lines.extend(
[
" go-ci:",
" runs-on: ubuntu-latest",
" steps:",
" - uses: actions/checkout@v4",
" - uses: actions/setup-go@v5",
" with:",
" go-version: '1.22'",
" - run: go test ./...",
" - run: go build ./...",
]
)
return "\n".join(lines) + "\n"
def gitlab_yaml(stack: Dict[str, Any]) -> str:
langs = stack.get("languages", [])
signals = stack.get("signals", {})
lint_cmds = stack.get("lint_commands", []) or ["echo 'No lint command configured'"]
test_cmds = stack.get("test_commands", []) or ["echo 'No test command configured'"]
build_cmds = stack.get("build_commands", []) or ["echo 'No build command configured'"]
lines: List[str] = [
"stages:",
" - lint",
" - test",
" - build",
"",
]
if "node" in langs:
install_cmd = select_node_install(signals)
lines.extend(
[
"node_lint:",
" image: node:20",
" stage: lint",
" script:",
f" - {install_cmd}",
]
)
for cmd in lint_cmds:
lines.append(f" - {cmd}")
lines.extend(
[
"",
"node_test:",
" image: node:20",
" stage: test",
" script:",
f" - {install_cmd}",
]
)
for cmd in test_cmds:
lines.append(f" - {cmd}")
lines.extend(
[
"",
"node_build:",
" image: node:20",
" stage: build",
" script:",
f" - {install_cmd}",
]
)
for cmd in build_cmds:
lines.append(f" - {cmd}")
if "python" in langs:
lines.extend(
[
"",
"python_test:",
" image: python:3.12",
" stage: test",
" script:",
" - python3 -m pip install -U pip",
" - python3 -m pip install -r requirements.txt || true",
" - python3 -m pytest || true",
]
)
if "go" in langs:
lines.extend(
[
"",
"go_test:",
" image: golang:1.22",
" stage: test",
" script:",
" - go test ./...",
" - go build ./...",
]
)
return "\n".join(lines) + "\n"
def main() -> int:
args = parse_args()
stack = load_json_input(args.input)
if stack is None:
if not args.repo:
raise CLIError("Provide stack input via --input/stdin or set --repo for auto-detection.")
repo = Path(args.repo).resolve()
if not repo.exists() or not repo.is_dir():
raise CLIError(f"Invalid repo path: {repo}")
stack = detect_stack(repo)
if args.platform == "github":
yaml_content = github_yaml(stack)
else:
yaml_content = gitlab_yaml(stack)
output_path = args.output or "stdout"
if args.output:
out = Path(args.output)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(yaml_content, encoding="utf-8")
else:
print(yaml_content, end="")
summary = PipelineSummary(
platform=args.platform,
output=output_path,
stages=["lint", "test", "build"],
uses_cache=True,
languages=stack.get("languages", []),
)
if args.format == "json":
print(json.dumps(asdict(summary), indent=2), file=sys.stderr if not args.output else sys.stdout)
else:
text = (
"Pipeline generated\n"
f"- platform: {summary.platform}\n"
f"- output: {summary.output}\n"
f"- stages: {', '.join(summary.stages)}\n"
f"- languages: {', '.join(summary.languages) if summary.languages else 'none'}"
)
print(text, file=sys.stderr if not args.output else sys.stdout)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except CLIError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(2)

View File

@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Detect project stack/tooling signals for CI/CD pipeline generation.
Input sources:
- repository scan via --repo
- JSON via --input file
- JSON via stdin
Output:
- text summary or JSON payload
"""
import argparse
import json
import sys
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Dict, List, Optional
class CLIError(Exception):
"""Raised for expected CLI failures."""
@dataclass
class StackReport:
repo: str
languages: List[str]
package_managers: List[str]
ci_targets: List[str]
test_commands: List[str]
build_commands: List[str]
lint_commands: List[str]
signals: Dict[str, bool]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Detect stack/tooling from a repository.")
parser.add_argument("--input", help="JSON input file (precomputed signal payload).")
parser.add_argument("--repo", default=".", help="Repository path to scan.")
parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format.")
return parser.parse_args()
def load_payload(input_path: Optional[str]) -> Optional[dict]:
if input_path:
try:
return json.loads(Path(input_path).read_text(encoding="utf-8"))
except Exception as exc:
raise CLIError(f"Failed reading --input file: {exc}") from exc
if not sys.stdin.isatty():
raw = sys.stdin.read().strip()
if raw:
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
raise CLIError(f"Invalid JSON from stdin: {exc}") from exc
return None
def read_package_scripts(repo: Path) -> Dict[str, str]:
pkg = repo / "package.json"
if not pkg.exists():
return {}
try:
data = json.loads(pkg.read_text(encoding="utf-8"))
except Exception:
return {}
scripts = data.get("scripts", {})
return scripts if isinstance(scripts, dict) else {}
def detect(repo: Path) -> StackReport:
signals = {
"package_json": (repo / "package.json").exists(),
"pnpm_lock": (repo / "pnpm-lock.yaml").exists(),
"yarn_lock": (repo / "yarn.lock").exists(),
"npm_lock": (repo / "package-lock.json").exists(),
"pyproject": (repo / "pyproject.toml").exists(),
"requirements": (repo / "requirements.txt").exists(),
"go_mod": (repo / "go.mod").exists(),
"dockerfile": (repo / "Dockerfile").exists(),
"vercel": (repo / "vercel.json").exists(),
"helm": (repo / "helm").exists() or (repo / "charts").exists(),
"k8s": (repo / "k8s").exists() or (repo / "kubernetes").exists(),
}
languages: List[str] = []
package_managers: List[str] = []
ci_targets: List[str] = ["github", "gitlab"]
if signals["package_json"]:
languages.append("node")
if signals["pnpm_lock"]:
package_managers.append("pnpm")
elif signals["yarn_lock"]:
package_managers.append("yarn")
else:
package_managers.append("npm")
if signals["pyproject"] or signals["requirements"]:
languages.append("python")
package_managers.append("pip")
if signals["go_mod"]:
languages.append("go")
scripts = read_package_scripts(repo)
lint_commands: List[str] = []
test_commands: List[str] = []
build_commands: List[str] = []
if "lint" in scripts:
lint_commands.append("npm run lint")
if "test" in scripts:
test_commands.append("npm test")
if "build" in scripts:
build_commands.append("npm run build")
if "python" in languages:
lint_commands.append("python3 -m ruff check .")
test_commands.append("python3 -m pytest")
if "go" in languages:
lint_commands.append("go vet ./...")
test_commands.append("go test ./...")
build_commands.append("go build ./...")
return StackReport(
repo=str(repo.resolve()),
languages=sorted(set(languages)),
package_managers=sorted(set(package_managers)),
ci_targets=ci_targets,
test_commands=sorted(set(test_commands)),
build_commands=sorted(set(build_commands)),
lint_commands=sorted(set(lint_commands)),
signals=signals,
)
def format_text(report: StackReport) -> str:
lines = [
"Detected stack",
f"- repo: {report.repo}",
f"- languages: {', '.join(report.languages) if report.languages else 'none'}",
f"- package managers: {', '.join(report.package_managers) if report.package_managers else 'none'}",
f"- lint commands: {', '.join(report.lint_commands) if report.lint_commands else 'none'}",
f"- test commands: {', '.join(report.test_commands) if report.test_commands else 'none'}",
f"- build commands: {', '.join(report.build_commands) if report.build_commands else 'none'}",
]
return "\n".join(lines)
def main() -> int:
args = parse_args()
payload = load_payload(args.input)
if payload:
try:
report = StackReport(**payload)
except TypeError as exc:
raise CLIError(f"Invalid input payload for StackReport: {exc}") from exc
else:
repo = Path(args.repo).resolve()
if not repo.exists() or not repo.is_dir():
raise CLIError(f"Invalid repo path: {repo}")
report = detect(repo)
if args.format == "json":
print(json.dumps(asdict(report), indent=2))
else:
print(format_text(report))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except CLIError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(2)

View File

@@ -0,0 +1,51 @@
# Git Worktree Manager
Production workflow for parallel branch development with isolated ports, env sync, and cleanup safety checks. This skill packages practical CLI tooling and operating guidance for multi-worktree teams.
## Quick Start
```bash
# Create + prepare a worktree
python scripts/worktree_manager.py \
--repo . \
--branch feature/api-hardening \
--name wt-api-hardening \
--base-branch main \
--install-deps \
--format text
# Review stale worktrees
python scripts/worktree_cleanup.py --repo . --stale-days 14 --format text
```
## Included Tools
- `scripts/worktree_manager.py`: create/list-prep workflow, deterministic ports, `.env*` sync, optional dependency install
- `scripts/worktree_cleanup.py`: stale/dirty/merged analysis with optional safe removal
Both support `--input <json-file>` and stdin JSON for automation.
## References
- `references/port-allocation-strategy.md`
- `references/docker-compose-patterns.md`
## Installation
### Claude Code
```bash
cp -R engineering/git-worktree-manager ~/.claude/skills/git-worktree-manager
```
### OpenAI Codex
```bash
cp -R engineering/git-worktree-manager ~/.codex/skills/git-worktree-manager
```
### OpenClaw
```bash
cp -R engineering/git-worktree-manager ~/.openclaw/skills/git-worktree-manager
```

View File

@@ -6,152 +6,183 @@
## Overview
The Git Worktree Manager skill provides systematic management of Git worktrees for parallel development workflows. It handles worktree creation with automatic port allocation, environment file management, secret copying, and cleanup — enabling developers to run multiple Claude Code instances on separate features simultaneously without conflicts.
Use this skill to run parallel feature work safely with Git worktrees. It standardizes branch isolation, port allocation, environment sync, and cleanup so each worktree behaves like an independent local app without stepping on another branch.
This skill is optimized for multi-agent workflows where each agent or terminal session owns one worktree.
## Core Capabilities
- **Worktree Lifecycle Management** — create, list, switch, and cleanup worktrees with automated setup
- **Port Allocation & Isolation** — automatic port assignment per worktree to avoid dev server conflicts
- **Environment Synchronization** — copy .env files, secrets, and config between main and worktrees
- **Docker Compose Overrides** — generate per-worktree port override files for multi-service stacks
- **Conflict Prevention** — detect and warn about shared resources, database names, and API endpoints
- **Cleanup & Pruning** — safe removal with stale branch detection and uncommitted work warnings
- Create worktrees from new or existing branches with deterministic naming
- Auto-allocate non-conflicting ports per worktree and persist assignments
- Copy local environment files (`.env*`) from main repo to new worktree
- Optionally install dependencies based on lockfile detection
- Detect stale worktrees and uncommitted changes before cleanup
- Identify merged branches and safely remove outdated worktrees
## When to Use This Skill
## When to Use
- Running multiple Claude Code sessions on different features simultaneously
- Working on a hotfix while a feature branch has uncommitted work
- Reviewing a PR while continuing development on your branch
- Parallel CI/testing against multiple branches
- Monorepo development with isolated package changes
- You need 2+ concurrent branches open locally
- You want isolated dev servers for feature, hotfix, and PR validation
- You are working with multiple agents that must not share a branch
- Your current branch is blocked but you need to ship a quick fix now
- You want repeatable cleanup instead of ad-hoc `rm -rf` operations
## Worktree Creation Workflow
## Key Workflows
### Step 1: Create Worktree
### 1. Create a Fully-Prepared Worktree
1. Pick a branch name and worktree name.
2. Run the manager script (creates branch if missing).
3. Review generated port map.
4. Start app using allocated ports.
```bash
# Create worktree for a new feature branch
git worktree add ../project-feature-auth -b feature/auth
# Create worktree from an existing remote branch
git worktree add ../project-fix-123 origin/fix/issue-123
# Create worktree with tracking
git worktree add --track -b feature/new-api ../project-new-api origin/main
python scripts/worktree_manager.py \
--repo . \
--branch feature/new-auth \
--name wt-auth \
--base-branch main \
--install-deps \
--format text
```
### Step 2: Environment Setup
After creating the worktree, automatically:
1. **Copy environment files:**
```bash
cp .env ../project-feature-auth/.env
cp .env.local ../project-feature-auth/.env.local 2>/dev/null
```
2. **Install dependencies:**
```bash
cd ../project-feature-auth
[ -f "pnpm-lock.yaml" ] && pnpm install
[ -f "yarn.lock" ] && yarn install
[ -f "package-lock.json" ] && npm install
[ -f "bun.lockb" ] && bun install
```
3. **Allocate ports:**
```
Main worktree: localhost:3000 (dev), :5432 (db), :6379 (redis)
Worktree 1: localhost:3010 (dev), :5442 (db), :6389 (redis)
Worktree 2: localhost:3020 (dev), :5452 (db), :6399 (redis)
```
### Step 3: Docker Compose Override
For Docker Compose projects, generate per-worktree override:
```yaml
# docker-compose.worktree.yml (auto-generated)
services:
app:
ports:
- "3010:3000"
db:
ports:
- "5442:5432"
redis:
ports:
- "6389:6379"
```
Usage: `docker compose -f docker-compose.yml -f docker-compose.worktree.yml up`
### Step 4: Database Isolation
If you use JSON automation input:
```bash
# Option A: Separate database per worktree
createdb myapp_feature_auth
# Option B: DATABASE_URL override
echo 'DATABASE_URL="postgresql://localhost:5442/myapp_wt1"' >> .env.local
# Option C: SQLite — file-based, automatic isolation
cat config.json | python scripts/worktree_manager.py --format json
# or
python scripts/worktree_manager.py --input config.json --format json
```
## Monorepo Optimization
### 2. Run Parallel Sessions
Combine worktrees with sparse checkout for large repos:
Recommended convention:
- Main repo: integration branch (`main`/`develop`) on default port
- Worktree A: feature branch + offset ports
- Worktree B: hotfix branch + next offset
Each worktree contains `.worktree-ports.json` with assigned ports.
### 3. Cleanup with Safety Checks
1. Scan all worktrees and stale age.
2. Inspect dirty trees and branch merge status.
3. Remove only merged + clean worktrees, or force explicitly.
```bash
git worktree add --no-checkout ../project-packages-only
cd ../project-packages-only
git sparse-checkout init --cone
git sparse-checkout set packages/shared packages/api
git checkout feature/api-refactor
python scripts/worktree_cleanup.py --repo . --stale-days 14 --format text
python scripts/worktree_cleanup.py --repo . --remove-merged --format text
```
## Claude Code Integration
### 4. Docker Compose Pattern
Each worktree gets auto-generated CLAUDE.md:
Use per-worktree override files mapped from allocated ports. The script outputs a deterministic port map; apply it to `docker-compose.worktree.yml`.
```markdown
# Worktree: feature/auth
# Dev server port: 3010
# Created: 2026-03-01
See [docker-compose-patterns.md](references/docker-compose-patterns.md) for concrete templates.
## Scope
Focus on changes related to this branch only.
### 5. Port Allocation Strategy
## Commands
- Dev: PORT=3010 npm run dev
- Test: npm test -- --related
- Lint: npm run lint
```
Default strategy is `base + (index * stride)` with collision checks:
Run parallel sessions:
```bash
# Terminal 1: Main feature
cd ~/project && claude
# Terminal 2: Hotfix
cd ~/project-hotfix && claude
# Terminal 3: PR review
cd ~/project-pr-review && claude
```
- App: `3000`
- Postgres: `5432`
- Redis: `6379`
- Stride: `10`
See [port-allocation-strategy.md](references/port-allocation-strategy.md) for the full strategy and edge cases.
## Script Interfaces
- `python scripts/worktree_manager.py --help`
- Create/list worktrees
- Allocate/persist ports
- Copy `.env*` files
- Optional dependency installation
- `python scripts/worktree_cleanup.py --help`
- Stale detection by age
- Dirty-state detection
- Merged-branch detection
- Optional safe removal
Both tools support stdin JSON and `--input` file mode for automation pipelines.
## Common Pitfalls
1. **Shared node_modules** — Worktrees share git dir but NOT node_modules. Always install deps.
2. **Port conflicts** — Two dev servers on :3000 = silent failures. Always allocate unique ports.
3. **Database migrations** — Migrations in one worktree affect all if sharing same DB. Isolate.
4. **Git hooks** — Live in `.git/hooks` (shared). Worktree-specific hooks need symlinks.
5. **IDE confusion** — VSCode may show wrong branch. Open as separate window.
6. **Stale worktrees** — Prune regularly: `git worktree prune`.
1. Creating worktrees inside the main repo directory
2. Reusing `localhost:3000` across all branches
3. Sharing one database URL across isolated feature branches
4. Removing a worktree with uncommitted changes
5. Forgetting to prune old metadata after branch deletion
6. Assuming merged status without checking against the target branch
## Best Practices
1. Name worktrees by purpose: `project-auth`, `project-hotfix-123`, `project-pr-456`
2. Never create worktrees inside the main repo directory
3. Keep worktrees short-lived — merge and cleanup within days
4. Use the setup script — manual creation skips env/port/deps
5. One Claude Code instance per worktree — isolation is the point
6. Commit before switching — even WIP commits prevent lost work
1. One branch per worktree, one agent per worktree.
2. Keep worktrees short-lived; remove after merge.
3. Use a deterministic naming pattern (`wt-<topic>`).
4. Persist port mappings in file, not memory or terminal notes.
5. Run cleanup scan weekly in active repos.
6. Use `--format json` for machine flows and `--format text` for human review.
7. Never force-remove dirty worktrees unless changes are intentionally discarded.
## Validation Checklist
Before claiming setup complete:
1. `git worktree list` shows expected path + branch.
2. `.worktree-ports.json` exists and contains unique ports.
3. `.env` files copied successfully (if present in source repo).
4. Dependency install command exits with code `0` (if enabled).
5. Cleanup scan reports no unintended stale dirty trees.
## References
- [port-allocation-strategy.md](references/port-allocation-strategy.md)
- [docker-compose-patterns.md](references/docker-compose-patterns.md)
- [README.md](README.md) for quick start and installation details
## Decision Matrix
Use this quick selector before creating a new worktree:
- Need isolated dependencies and server ports -> create a new worktree
- Need only a quick local diff review -> stay on current tree
- Need hotfix while feature branch is dirty -> create dedicated hotfix worktree
- Need ephemeral reproduction branch for bug triage -> create temporary worktree and cleanup same day
## Operational Checklist
### Before Creation
1. Confirm main repo has clean baseline or intentional WIP commits.
2. Confirm target branch naming convention.
3. Confirm required base branch exists (`main`/`develop`).
4. Confirm no reserved local ports are already occupied by non-repo services.
### After Creation
1. Verify `git status` branch matches expected branch.
2. Verify `.worktree-ports.json` exists.
3. Verify app boots on allocated app port.
4. Verify DB and cache endpoints target isolated ports.
### Before Removal
1. Verify branch has upstream and is merged when intended.
2. Verify no uncommitted files remain.
3. Verify no running containers/processes depend on this worktree path.
## CI and Team Integration
- Use worktree path naming that maps to task ID (`wt-1234-auth`).
- Include the worktree path in terminal title to avoid wrong-window commits.
- In automated setups, persist creation metadata in CI artifacts/logs.
- Trigger cleanup report in scheduled jobs and post summary to team channel.
## Failure Recovery
- If `git worktree add` fails due to existing path: inspect path, do not overwrite.
- If dependency install fails: keep worktree created, mark status and continue manual recovery.
- If env copy fails: continue with warning and explicit missing file list.
- If port allocation collides with external service: rerun with adjusted base ports.

View File

@@ -0,0 +1,62 @@
# Docker Compose Patterns For Worktrees
## Pattern 1: Override File Per Worktree
Base compose file remains shared; each worktree has a local override.
`docker-compose.worktree.yml`:
```yaml
services:
app:
ports:
- "3010:3000"
db:
ports:
- "5442:5432"
redis:
ports:
- "6389:6379"
```
Run:
```bash
docker compose -f docker-compose.yml -f docker-compose.worktree.yml up -d
```
## Pattern 2: `.env` Driven Ports
Use compose variable substitution and write worktree-specific values into `.env.local`.
`docker-compose.yml` excerpt:
```yaml
services:
app:
ports: ["${APP_PORT:-3000}:3000"]
db:
ports: ["${DB_PORT:-5432}:5432"]
```
Worktree `.env.local`:
```env
APP_PORT=3010
DB_PORT=5442
REDIS_PORT=6389
```
## Pattern 3: Project Name Isolation
Use unique compose project name so container, network, and volume names do not collide.
```bash
docker compose -p myapp_wt_auth up -d
```
## Common Mistakes
- Reusing default `5432` from multiple worktrees simultaneously
- Sharing one database volume across incompatible migration branches
- Forgetting to scope compose project name per worktree

View File

@@ -0,0 +1,46 @@
# Port Allocation Strategy
## Objective
Allocate deterministic, non-overlapping local ports for each worktree to avoid collisions across concurrent development sessions.
## Default Mapping
- App HTTP: `3000`
- Postgres: `5432`
- Redis: `6379`
- Stride per worktree: `10`
Formula by slot index `n`:
- `app = 3000 + (10 * n)`
- `db = 5432 + (10 * n)`
- `redis = 6379 + (10 * n)`
Examples:
- Slot 0: `3000/5432/6379`
- Slot 1: `3010/5442/6389`
- Slot 2: `3020/5452/6399`
## Collision Avoidance
1. Read `.worktree-ports.json` from existing worktrees.
2. Skip any slot where one or more ports are already assigned.
3. Persist selected mapping in the new worktree.
## Operational Notes
- Keep stride >= number of services to avoid accidental overlaps when adding ports later.
- For custom service sets, reserve a contiguous block per worktree.
- If you also run local infra outside worktrees, offset bases to avoid global collisions.
## Recommended File Format
```json
{
"app": 3010,
"db": 5442,
"redis": 6389
}
```

View File

@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Inspect and clean stale git worktrees with safety checks.
Supports:
- JSON input from stdin or --input file
- Stale age detection
- Dirty working tree detection
- Merged branch detection
- Optional removal of merged, clean stale worktrees
"""
import argparse
import json
import subprocess
import sys
import time
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any, Dict, List, Optional
class CLIError(Exception):
"""Raised for expected CLI errors."""
@dataclass
class WorktreeInfo:
path: str
branch: str
is_main: bool
age_days: int
stale: bool
dirty: bool
merged_into_base: bool
def run(cmd: List[str], cwd: Optional[Path] = None, check: bool = True) -> subprocess.CompletedProcess[str]:
return subprocess.run(cmd, cwd=cwd, text=True, capture_output=True, check=check)
def load_json_input(input_file: Optional[str]) -> Dict[str, Any]:
if input_file:
try:
return json.loads(Path(input_file).read_text(encoding="utf-8"))
except Exception as exc:
raise CLIError(f"Failed reading --input file: {exc}") from exc
if not sys.stdin.isatty():
raw = sys.stdin.read().strip()
if raw:
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
raise CLIError(f"Invalid JSON from stdin: {exc}") from exc
return {}
def parse_worktrees(repo: Path) -> List[Dict[str, str]]:
proc = run(["git", "worktree", "list", "--porcelain"], cwd=repo)
entries: List[Dict[str, str]] = []
current: Dict[str, str] = {}
for line in proc.stdout.splitlines():
if not line.strip():
if current:
entries.append(current)
current = {}
continue
key, _, value = line.partition(" ")
current[key] = value
if current:
entries.append(current)
return entries
def get_branch(path: Path) -> str:
proc = run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=path)
return proc.stdout.strip()
def get_last_commit_age_days(path: Path) -> int:
proc = run(["git", "log", "-1", "--format=%ct"], cwd=path)
timestamp = int(proc.stdout.strip() or "0")
age_seconds = int(time.time()) - timestamp
return max(0, age_seconds // 86400)
def is_dirty(path: Path) -> bool:
proc = run(["git", "status", "--porcelain"], cwd=path)
return bool(proc.stdout.strip())
def is_merged(repo: Path, branch: str, base_branch: str) -> bool:
if branch in ("HEAD", base_branch):
return False
try:
run(["git", "merge-base", "--is-ancestor", branch, base_branch], cwd=repo)
return True
except subprocess.CalledProcessError:
return False
def format_text(items: List[WorktreeInfo], removed: List[str]) -> str:
lines = ["Worktree cleanup report"]
for item in items:
lines.append(
f"- {item.path} | branch={item.branch} | age={item.age_days}d | "
f"stale={item.stale} dirty={item.dirty} merged={item.merged_into_base}"
)
if removed:
lines.append("Removed:")
for path in removed:
lines.append(f"- {path}")
return "\n".join(lines)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Analyze and optionally cleanup stale git worktrees.")
parser.add_argument("--input", help="Path to JSON input file. If omitted, reads JSON from stdin when piped.")
parser.add_argument("--repo", default=".", help="Repository root path.")
parser.add_argument("--base-branch", default="main", help="Base branch to evaluate merged branches.")
parser.add_argument("--stale-days", type=int, default=14, help="Threshold for stale worktrees.")
parser.add_argument("--remove-merged", action="store_true", help="Remove worktrees that are stale, clean, and merged.")
parser.add_argument("--force", action="store_true", help="Allow removal even if dirty (use carefully).")
parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format.")
return parser.parse_args()
def main() -> int:
args = parse_args()
payload = load_json_input(args.input)
repo = Path(str(payload.get("repo", args.repo))).resolve()
stale_days = int(payload.get("stale_days", args.stale_days))
base_branch = str(payload.get("base_branch", args.base_branch))
remove_merged = bool(payload.get("remove_merged", args.remove_merged))
force = bool(payload.get("force", args.force))
try:
run(["git", "rev-parse", "--is-inside-work-tree"], cwd=repo)
except subprocess.CalledProcessError as exc:
raise CLIError(f"Not a git repository: {repo}") from exc
try:
run(["git", "rev-parse", "--verify", base_branch], cwd=repo)
except subprocess.CalledProcessError as exc:
raise CLIError(f"Base branch not found: {base_branch}") from exc
entries = parse_worktrees(repo)
if not entries:
raise CLIError("No worktrees found.")
main_path = Path(entries[0].get("worktree", "")).resolve()
infos: List[WorktreeInfo] = []
removed: List[str] = []
for entry in entries:
path = Path(entry.get("worktree", "")).resolve()
branch = get_branch(path)
age = get_last_commit_age_days(path)
dirty = is_dirty(path)
stale = age >= stale_days
merged = is_merged(repo, branch, base_branch)
info = WorktreeInfo(
path=str(path),
branch=branch,
is_main=path == main_path,
age_days=age,
stale=stale,
dirty=dirty,
merged_into_base=merged,
)
infos.append(info)
if remove_merged and not info.is_main and info.stale and info.merged_into_base and (force or not info.dirty):
try:
cmd = ["git", "worktree", "remove", str(path)]
if force:
cmd.append("--force")
run(cmd, cwd=repo)
removed.append(str(path))
except subprocess.CalledProcessError as exc:
raise CLIError(f"Failed removing worktree {path}: {exc.stderr}") from exc
if args.format == "json":
print(json.dumps({"worktrees": [asdict(i) for i in infos], "removed": removed}, indent=2))
else:
print(format_text(infos, removed))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except CLIError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(2)

View File

@@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""Create and prepare git worktrees with deterministic port allocation.
Supports:
- JSON input from stdin or --input file
- Worktree creation from existing/new branch
- .env file sync from main repo
- Optional dependency installation
- JSON or text output
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any, Dict, List, Optional
ENV_FILES = [".env", ".env.local", ".env.development", ".envrc"]
LOCKFILE_COMMANDS = [
("pnpm-lock.yaml", ["pnpm", "install"]),
("yarn.lock", ["yarn", "install"]),
("package-lock.json", ["npm", "install"]),
("bun.lockb", ["bun", "install"]),
("requirements.txt", [sys.executable, "-m", "pip", "install", "-r", "requirements.txt"]),
]
@dataclass
class WorktreeResult:
repo: str
worktree_path: str
branch: str
created: bool
ports: Dict[str, int]
copied_env_files: List[str]
dependency_install: str
class CLIError(Exception):
"""Raised for expected CLI errors."""
def run(cmd: List[str], cwd: Optional[Path] = None, check: bool = True) -> subprocess.CompletedProcess[str]:
return subprocess.run(cmd, cwd=cwd, text=True, capture_output=True, check=check)
def load_json_input(input_file: Optional[str]) -> Dict[str, Any]:
if input_file:
try:
return json.loads(Path(input_file).read_text(encoding="utf-8"))
except Exception as exc:
raise CLIError(f"Failed reading --input file: {exc}") from exc
if not sys.stdin.isatty():
data = sys.stdin.read().strip()
if data:
try:
return json.loads(data)
except json.JSONDecodeError as exc:
raise CLIError(f"Invalid JSON from stdin: {exc}") from exc
return {}
def parse_worktree_list(repo: Path) -> List[Dict[str, str]]:
proc = run(["git", "worktree", "list", "--porcelain"], cwd=repo)
entries: List[Dict[str, str]] = []
current: Dict[str, str] = {}
for line in proc.stdout.splitlines():
if not line.strip():
if current:
entries.append(current)
current = {}
continue
key, _, value = line.partition(" ")
current[key] = value
if current:
entries.append(current)
return entries
def find_next_ports(repo: Path, app_base: int, db_base: int, redis_base: int, stride: int) -> Dict[str, int]:
used_ports = set()
for entry in parse_worktree_list(repo):
wt_path = Path(entry.get("worktree", ""))
ports_file = wt_path / ".worktree-ports.json"
if ports_file.exists():
try:
payload = json.loads(ports_file.read_text(encoding="utf-8"))
used_ports.update(int(v) for v in payload.values() if isinstance(v, int))
except Exception:
continue
index = 0
while True:
ports = {
"app": app_base + (index * stride),
"db": db_base + (index * stride),
"redis": redis_base + (index * stride),
}
if all(p not in used_ports for p in ports.values()):
return ports
index += 1
def sync_env_files(src_repo: Path, dest_repo: Path) -> List[str]:
copied = []
for name in ENV_FILES:
src = src_repo / name
if src.exists() and src.is_file():
dst = dest_repo / name
shutil.copy2(src, dst)
copied.append(name)
return copied
def install_dependencies_if_requested(worktree_path: Path, install: bool) -> str:
if not install:
return "skipped"
for lockfile, command in LOCKFILE_COMMANDS:
if (worktree_path / lockfile).exists():
try:
run(command, cwd=worktree_path, check=True)
return f"installed via {' '.join(command)}"
except subprocess.CalledProcessError as exc:
raise CLIError(f"Dependency install failed: {' '.join(command)}\n{exc.stderr}") from exc
return "no known lockfile found"
def ensure_worktree(repo: Path, branch: str, name: str, base_branch: str) -> Path:
wt_parent = repo.parent
wt_path = wt_parent / name
existing_paths = {Path(e.get("worktree", "")) for e in parse_worktree_list(repo)}
if wt_path in existing_paths:
return wt_path
try:
run(["git", "show-ref", "--verify", f"refs/heads/{branch}"], cwd=repo)
run(["git", "worktree", "add", str(wt_path), branch], cwd=repo)
except subprocess.CalledProcessError:
try:
run(["git", "worktree", "add", "-b", branch, str(wt_path), base_branch], cwd=repo)
except subprocess.CalledProcessError as exc:
raise CLIError(f"Failed to create worktree: {exc.stderr}") from exc
return wt_path
def format_text(result: WorktreeResult) -> str:
lines = [
"Worktree prepared",
f"- repo: {result.repo}",
f"- path: {result.worktree_path}",
f"- branch: {result.branch}",
f"- created: {result.created}",
f"- ports: app={result.ports['app']} db={result.ports['db']} redis={result.ports['redis']}",
f"- copied env files: {', '.join(result.copied_env_files) if result.copied_env_files else 'none'}",
f"- dependency install: {result.dependency_install}",
]
return "\n".join(lines)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Create and prepare a git worktree.")
parser.add_argument("--input", help="Path to JSON input file. If omitted, reads JSON from stdin when piped.")
parser.add_argument("--repo", default=".", help="Path to repository root (default: current directory).")
parser.add_argument("--branch", help="Branch name for the worktree.")
parser.add_argument("--name", help="Worktree directory name (created adjacent to repo).")
parser.add_argument("--base-branch", default="main", help="Base branch when creating a new branch.")
parser.add_argument("--app-base", type=int, default=3000, help="Base app port.")
parser.add_argument("--db-base", type=int, default=5432, help="Base DB port.")
parser.add_argument("--redis-base", type=int, default=6379, help="Base Redis port.")
parser.add_argument("--stride", type=int, default=10, help="Port stride between worktrees.")
parser.add_argument("--install-deps", action="store_true", help="Install dependencies in the new worktree.")
parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format.")
return parser.parse_args()
def main() -> int:
args = parse_args()
payload = load_json_input(args.input)
repo = Path(str(payload.get("repo", args.repo))).resolve()
branch = payload.get("branch", args.branch)
name = payload.get("name", args.name)
base_branch = str(payload.get("base_branch", args.base_branch))
app_base = int(payload.get("app_base", args.app_base))
db_base = int(payload.get("db_base", args.db_base))
redis_base = int(payload.get("redis_base", args.redis_base))
stride = int(payload.get("stride", args.stride))
install_deps = bool(payload.get("install_deps", args.install_deps))
if not branch or not name:
raise CLIError("Missing required values: --branch and --name (or provide via JSON input).")
try:
run(["git", "rev-parse", "--is-inside-work-tree"], cwd=repo)
except subprocess.CalledProcessError as exc:
raise CLIError(f"Not a git repository: {repo}") from exc
wt_path = ensure_worktree(repo, branch, name, base_branch)
created = (wt_path / ".worktree-ports.json").exists() is False
ports = find_next_ports(repo, app_base, db_base, redis_base, stride)
(wt_path / ".worktree-ports.json").write_text(json.dumps(ports, indent=2), encoding="utf-8")
copied = sync_env_files(repo, wt_path)
install_status = install_dependencies_if_requested(wt_path, install_deps)
result = WorktreeResult(
repo=str(repo),
worktree_path=str(wt_path),
branch=branch,
created=created,
ports=ports,
copied_env_files=copied,
dependency_install=install_status,
)
if args.format == "json":
print(json.dumps(asdict(result), indent=2))
else:
print(format_text(result))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except CLIError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(2)

View File

@@ -0,0 +1,50 @@
# MCP Server Builder
Generate and validate MCP servers from OpenAPI contracts with production-focused tooling. This skill helps teams bootstrap fast and enforce schema quality before shipping.
## Quick Start
```bash
# Generate scaffold from OpenAPI
python3 scripts/openapi_to_mcp.py \
--input openapi.json \
--server-name my-mcp \
--language python \
--output-dir ./generated \
--format text
# Validate generated manifest
python3 scripts/mcp_validator.py --input generated/tool_manifest.json --strict --format text
```
## Included Tools
- `scripts/openapi_to_mcp.py`: OpenAPI -> `tool_manifest.json` + starter server scaffold
- `scripts/mcp_validator.py`: structural and quality validation for MCP tool definitions
## References
- `references/openapi-extraction-guide.md`
- `references/python-server-template.md`
- `references/typescript-server-template.md`
- `references/validation-checklist.md`
## Installation
### Claude Code
```bash
cp -R engineering/mcp-server-builder ~/.claude/skills/mcp-server-builder
```
### OpenAI Codex
```bash
cp -R engineering/mcp-server-builder ~/.codex/skills/mcp-server-builder
```
### OpenClaw
```bash
cp -R engineering/mcp-server-builder ~/.openclaw/skills/mcp-server-builder
```

View File

@@ -2,574 +2,158 @@
**Tier:** POWERFUL
**Category:** Engineering
**Domain:** AI / API Integration
---
**Domain:** AI / API Integration
## Overview
Design and implement Model Context Protocol (MCP) servers that expose any REST API, database, or service as structured tools for Claude and other LLMs. Covers both FastMCP (Python) and the TypeScript MCP SDK, with patterns for reading OpenAPI/Swagger specs, generating tool definitions, handling auth, errors, and testing.
Use this skill to design and ship production-ready MCP servers from API contracts instead of hand-written one-off tool wrappers. It focuses on fast scaffolding, schema quality, validation, and safe evolution.
The workflow supports both Python and TypeScript MCP implementations and treats OpenAPI as the source of truth.
## Core Capabilities
- **OpenAPI → MCP tools** — parse Swagger/OpenAPI specs and generate tool definitions
- **FastMCP (Python)** — decorator-based server with automatic schema generation
- **TypeScript MCP SDK** — typed server with zod validation
- **Auth handling** — API keys, Bearer tokens, OAuth2, mTLS
- **Error handling** — structured error responses LLMs can reason about
- **Testing** — unit tests for tool handlers, integration tests with MCP inspector
---
- Convert OpenAPI paths/operations into MCP tool definitions
- Generate starter server scaffolds (Python or TypeScript)
- Enforce naming, descriptions, and schema consistency
- Validate MCP tool manifests for common production failures
- Apply versioning and backward-compatibility checks
- Separate transport/runtime decisions from tool contract design
## When to Use
- Exposing a REST API to Claude without writing a custom integration
- Building reusable tool packs for a team's Claude setup
- Wrapping internal company APIs (Jira, HubSpot, custom microservices)
- Creating database-backed tools (read/write structured data)
- Replacing brittle browser automation with typed API calls
- You need to expose an internal/external REST API to an LLM agent
- You are replacing brittle browser automation with typed tools
- You want one MCP server shared across teams and assistants
- You need repeatable quality checks before publishing MCP tools
- You want to bootstrap an MCP server from existing OpenAPI specs
---
## Key Workflows
## MCP Architecture
### 1. OpenAPI to MCP Scaffold
```
Claude / LLM
│ MCP Protocol (JSON-RPC over stdio or HTTP/SSE)
MCP Server
│ calls
External API / Database / Service
```
1. Start from a valid OpenAPI spec.
2. Generate tool manifest + starter server code.
3. Review naming and auth strategy.
4. Add endpoint-specific runtime logic.
Each MCP server exposes:
- **Tools** — callable functions with typed inputs/outputs
- **Resources** — readable data (files, DB rows, API responses)
- **Prompts** — reusable prompt templates
---
## Reading an OpenAPI Spec
Given a Swagger/OpenAPI file, extract tool definitions:
```python
import yaml
import json
def openapi_to_tools(spec_path: str) -> list[dict]:
with open(spec_path) as f:
spec = yaml.safe_load(f)
tools = []
for path, methods in spec.get("paths", {}).items():
for method, op in methods.items():
if method not in ("get", "post", "put", "patch", "delete"):
continue
# Build parameter schema
properties = {}
required = []
# Path/query parameters
for param in op.get("parameters", []):
name = param["name"]
schema = param.get("schema", {"type": "string"})
properties[name] = {
"type": schema.get("type", "string"),
"description": param.get("description", ""),
}
if param.get("required"):
required.append(name)
# Request body
if "requestBody" in op:
content = op["requestBody"].get("content", {})
json_schema = content.get("application/json", {}).get("schema", {})
if "$ref" in json_schema:
ref_name = json_schema["$ref"].split("/")[-1]
json_schema = spec["components"]["schemas"][ref_name]
for prop_name, prop_schema in json_schema.get("properties", {}).items():
properties[prop_name] = prop_schema
required.extend(json_schema.get("required", []))
tool_name = op.get("operationId") or f"{method}_{path.replace('/', '_').strip('_')}"
tools.append({
"name": tool_name,
"description": op.get("summary", op.get("description", "")),
"inputSchema": {
"type": "object",
"properties": properties,
"required": required,
}
})
return tools
```
---
## Full Example: FastMCP Python Server for CRUD API
This builds a complete MCP server for a hypothetical Task Management REST API.
```python
# server.py
from fastmcp import FastMCP
from pydantic import BaseModel, Field
import httpx
import os
from typing import Optional
# Initialize MCP server
mcp = FastMCP(
name="task-manager",
description="MCP server for Task Management API",
)
# Config
API_BASE = os.environ.get("TASK_API_BASE", "https://api.tasks.example.com")
API_KEY = os.environ["TASK_API_KEY"] # Fail fast if missing
# Shared HTTP client with auth
def get_client() -> httpx.Client:
return httpx.Client(
base_url=API_BASE,
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
timeout=30.0,
)
# ── Pydantic models for input validation ──────────────────────────────────────
class CreateTaskInput(BaseModel):
title: str = Field(..., description="Task title", min_length=1, max_length=200)
description: Optional[str] = Field(None, description="Task description")
assignee_id: Optional[str] = Field(None, description="User ID to assign to")
due_date: Optional[str] = Field(None, description="Due date in ISO 8601 format (YYYY-MM-DD)")
priority: str = Field("medium", description="Priority: low, medium, high, critical")
class UpdateTaskInput(BaseModel):
task_id: str = Field(..., description="Task ID to update")
title: Optional[str] = Field(None, description="New title")
status: Optional[str] = Field(None, description="New status: todo, in_progress, done, cancelled")
assignee_id: Optional[str] = Field(None, description="Reassign to user ID")
due_date: Optional[str] = Field(None, description="New due date (YYYY-MM-DD)")
# ── Tool implementations ───────────────────────────────────────────────────────
@mcp.tool()
def list_tasks(
status: Optional[str] = None,
assignee_id: Optional[str] = None,
limit: int = 20,
offset: int = 0,
) -> dict:
"""
List tasks with optional filtering by status or assignee.
Returns paginated results with total count.
"""
params = {"limit": limit, "offset": offset}
if status:
params["status"] = status
if assignee_id:
params["assignee_id"] = assignee_id
with get_client() as client:
resp = client.get("/tasks", params=params)
resp.raise_for_status()
return resp.json()
@mcp.tool()
def get_task(task_id: str) -> dict:
"""
Get a single task by ID including full details and comments.
"""
with get_client() as client:
resp = client.get(f"/tasks/{task_id}")
if resp.status_code == 404:
return {"error": f"Task {task_id} not found"}
resp.raise_for_status()
return resp.json()
@mcp.tool()
def create_task(input: CreateTaskInput) -> dict:
"""
Create a new task. Returns the created task with its ID.
"""
with get_client() as client:
resp = client.post("/tasks", json=input.model_dump(exclude_none=True))
if resp.status_code == 422:
return {"error": "Validation failed", "details": resp.json()}
resp.raise_for_status()
task = resp.json()
return {
"success": True,
"task_id": task["id"],
"task": task,
}
@mcp.tool()
def update_task(input: UpdateTaskInput) -> dict:
"""
Update an existing task's title, status, assignee, or due date.
Only provided fields are updated (PATCH semantics).
"""
payload = input.model_dump(exclude_none=True)
task_id = payload.pop("task_id")
if not payload:
return {"error": "No fields to update provided"}
with get_client() as client:
resp = client.patch(f"/tasks/{task_id}", json=payload)
if resp.status_code == 404:
return {"error": f"Task {task_id} not found"}
resp.raise_for_status()
return {"success": True, "task": resp.json()}
@mcp.tool()
def delete_task(task_id: str, confirm: bool = False) -> dict:
"""
Delete a task permanently. Set confirm=true to proceed.
This action cannot be undone.
"""
if not confirm:
return {
"error": "Deletion requires explicit confirmation",
"hint": "Call again with confirm=true to permanently delete this task",
}
with get_client() as client:
resp = client.delete(f"/tasks/{task_id}")
if resp.status_code == 404:
return {"error": f"Task {task_id} not found"}
resp.raise_for_status()
return {"success": True, "deleted_task_id": task_id}
@mcp.tool()
def search_tasks(query: str, limit: int = 10) -> dict:
"""
Full-text search across task titles and descriptions.
Returns matching tasks ranked by relevance.
"""
with get_client() as client:
resp = client.get("/tasks/search", params={"q": query, "limit": limit})
resp.raise_for_status()
results = resp.json()
return {
"query": query,
"total": results.get("total", 0),
"tasks": results.get("items", []),
}
# ── Resource: expose task list as readable resource ───────────────────────────
@mcp.resource("tasks://recent")
def recent_tasks_resource() -> str:
"""Returns the 10 most recently updated tasks as JSON."""
with get_client() as client:
resp = client.get("/tasks", params={"sort": "-updated_at", "limit": 10})
resp.raise_for_status()
return resp.text
if __name__ == "__main__":
mcp.run()
```
---
## TypeScript MCP SDK Version
```typescript
// server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const API_BASE = process.env.TASK_API_BASE ?? "https://api.tasks.example.com";
const API_KEY = process.env.TASK_API_KEY!;
if (!API_KEY) throw new Error("TASK_API_KEY is required");
const server = new McpServer({
name: "task-manager",
version: "1.0.0",
});
async function apiRequest(
method: string,
path: string,
body?: unknown,
params?: Record<string, string>
): Promise<unknown> {
const url = new URL(`${API_BASE}${path}`);
if (params) {
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
}
const resp = await fetch(url.toString(), {
method,
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`API error ${resp.status}: ${text}`);
}
return resp.json();
}
// List tasks
server.tool(
"list_tasks",
"List tasks with optional status/assignee filter",
{
status: z.enum(["todo", "in_progress", "done", "cancelled"]).optional(),
assignee_id: z.string().optional(),
limit: z.number().int().min(1).max(100).default(20),
},
async ({ status, assignee_id, limit }) => {
const params: Record<string, string> = { limit: String(limit) };
if (status) params.status = status;
if (assignee_id) params.assignee_id = assignee_id;
const data = await apiRequest("GET", "/tasks", undefined, params);
return {
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
};
}
);
// Create task
server.tool(
"create_task",
"Create a new task",
{
title: z.string().min(1).max(200),
description: z.string().optional(),
priority: z.enum(["low", "medium", "high", "critical"]).default("medium"),
due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
},
async (input) => {
const task = await apiRequest("POST", "/tasks", input);
return {
content: [
{
type: "text",
text: `Created task: ${JSON.stringify(task, null, 2)}`,
},
],
};
}
);
// Start server
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Task Manager MCP server running");
```
---
## Auth Patterns
### API Key (header)
```python
headers={"X-API-Key": os.environ["API_KEY"]}
```
### Bearer token
```python
headers={"Authorization": f"Bearer {os.environ['ACCESS_TOKEN']}"}
```
### OAuth2 client credentials (auto-refresh)
```python
import httpx
from datetime import datetime, timedelta
_token_cache = {"token": None, "expires_at": datetime.min}
def get_access_token() -> str:
if datetime.now() < _token_cache["expires_at"]:
return _token_cache["token"]
resp = httpx.post(
os.environ["TOKEN_URL"],
data={
"grant_type": "client_credentials",
"client_id": os.environ["CLIENT_ID"],
"client_secret": os.environ["CLIENT_SECRET"],
"scope": "api.read api.write",
},
)
resp.raise_for_status()
data = resp.json()
_token_cache["token"] = data["access_token"]
_token_cache["expires_at"] = datetime.now() + timedelta(seconds=data["expires_in"] - 30)
return _token_cache["token"]
```
---
## Error Handling Best Practices
LLMs reason better when errors are descriptive:
```python
@mcp.tool()
def get_user(user_id: str) -> dict:
"""Get user by ID."""
try:
with get_client() as client:
resp = client.get(f"/users/{user_id}")
if resp.status_code == 404:
return {
"error": "User not found",
"user_id": user_id,
"suggestion": "Use list_users to find valid user IDs",
}
if resp.status_code == 403:
return {
"error": "Access denied",
"detail": "Current API key lacks permission to read this user",
}
resp.raise_for_status()
return resp.json()
except httpx.TimeoutException:
return {"error": "Request timed out", "suggestion": "Try again in a few seconds"}
except httpx.HTTPError as e:
return {"error": f"HTTP error: {str(e)}"}
```
---
## Testing MCP Servers
### Unit tests (pytest)
```python
# tests/test_server.py
import pytest
from unittest.mock import patch, MagicMock
from server import create_task, list_tasks
@pytest.fixture(autouse=True)
def mock_api_key(monkeypatch):
monkeypatch.setenv("TASK_API_KEY", "test-key")
def test_create_task_success():
mock_resp = MagicMock()
mock_resp.status_code = 201
mock_resp.json.return_value = {"id": "task-123", "title": "Test task"}
with patch("httpx.Client.post", return_value=mock_resp):
from server import CreateTaskInput
result = create_task(CreateTaskInput(title="Test task"))
assert result["success"] is True
assert result["task_id"] == "task-123"
def test_create_task_validation_error():
mock_resp = MagicMock()
mock_resp.status_code = 422
mock_resp.json.return_value = {"detail": "title too long"}
with patch("httpx.Client.post", return_value=mock_resp):
from server import CreateTaskInput
result = create_task(CreateTaskInput(title="x" * 201)) # Over limit
assert "error" in result
```
### Integration test with MCP Inspector
```bash
# Install MCP inspector
npx @modelcontextprotocol/inspector python server.py
# Or for TypeScript
npx @modelcontextprotocol/inspector node dist/server.js
python3 scripts/openapi_to_mcp.py \
--input openapi.json \
--server-name billing-mcp \
--language python \
--output-dir ./out \
--format text
```
---
Supports stdin as well:
## Packaging and Distribution
### pyproject.toml for FastMCP server
```toml
[project]
name = "my-mcp-server"
version = "1.0.0"
dependencies = [
"fastmcp>=0.4",
"httpx>=0.27",
"pydantic>=2.0",
]
[project.scripts]
my-mcp-server = "server:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
```bash
cat openapi.json | python3 scripts/openapi_to_mcp.py --server-name billing-mcp --language typescript
```
### Claude Desktop config (~/.claude/config.json)
```json
{
"mcpServers": {
"task-manager": {
"command": "python",
"args": ["/path/to/server.py"],
"env": {
"TASK_API_KEY": "your-key-here",
"TASK_API_BASE": "https://api.tasks.example.com"
}
}
}
}
### 2. Validate MCP Tool Definitions
Run validator before integration tests:
```bash
python3 scripts/mcp_validator.py --input out/tool_manifest.json --strict --format text
```
---
Checks include duplicate names, invalid schema shape, missing descriptions, empty required fields, and naming hygiene.
### 3. Runtime Selection
- Choose **Python** for fast iteration and data-heavy backends.
- Choose **TypeScript** for unified JS stacks and tighter frontend/backend contract reuse.
- Keep tool contracts stable even if transport/runtime changes.
### 4. Auth & Safety Design
- Keep secrets in env, not in tool schemas.
- Prefer explicit allowlists for outbound hosts.
- Return structured errors (`code`, `message`, `details`) for agent recovery.
- Avoid destructive operations without explicit confirmation inputs.
### 5. Versioning Strategy
- Additive fields only for non-breaking updates.
- Never rename tool names in-place.
- Introduce new tool IDs for breaking behavior changes.
- Maintain changelog of tool contracts per release.
## Script Interfaces
- `python3 scripts/openapi_to_mcp.py --help`
- Reads OpenAPI from stdin or `--input`
- Produces manifest + server scaffold
- Emits JSON summary or text report
- `python3 scripts/mcp_validator.py --help`
- Validates manifests and optional runtime config
- Returns non-zero exit in strict mode when errors exist
## Common Pitfalls
- **Returning raw API errors** — LLMs can't act on HTTP 422; translate to human-readable messages
- **No confirmation on destructive actions** — add `confirm: bool = False` pattern for deletes
- **Blocking I/O without timeout** — always set `timeout=30.0` on HTTP clients
- **Leaking API keys in tool responses** — never echo env vars back in responses
- **Tool names with hyphens** — use underscores; some LLM routers break on hyphens
- **Giant response payloads** — truncate/paginate; LLMs have context limits
---
1. Tool names derived directly from raw paths (`get__v1__users___id`)
2. Missing operation descriptions (agents choose tools poorly)
3. Ambiguous parameter schemas with no required fields
4. Mixing transport errors and domain errors in one opaque message
5. Building tool contracts that expose secret values
6. Breaking clients by changing schema keys without versioning
## Best Practices
1. **One tool, one action** — don't build "swiss army knife" tools; compose small tools
2. **Descriptive tool descriptions** — LLMs use them for routing; be explicit about what it does
3. **Return structured data** — JSON dicts, not formatted strings, so LLMs can reason about fields
4. **Validate inputs with Pydantic/zod** — catch bad inputs before hitting the API
5. **Idempotency hints** — note in description if a tool is safe to retry
6. **Resource vs Tool** — use resources for read-only data LLMs reference; tools for actions
1. Use `operationId` as canonical tool name when available.
2. Keep one task intent per tool; avoid mega-tools.
3. Add concise descriptions with action verbs.
4. Validate contracts in CI using strict mode.
5. Keep generated scaffold committed, then customize incrementally.
6. Pair contract changes with changelog entries.
## Reference Material
- [references/openapi-extraction-guide.md](references/openapi-extraction-guide.md)
- [references/python-server-template.md](references/python-server-template.md)
- [references/typescript-server-template.md](references/typescript-server-template.md)
- [references/validation-checklist.md](references/validation-checklist.md)
- [README.md](README.md)
## Architecture Decisions
Choose the server approach per constraint:
- Python runtime: faster iteration, data pipelines, backend-heavy teams
- TypeScript runtime: shared types with JS stack, frontend-heavy teams
- Single MCP server: easiest operations, broader blast radius
- Split domain servers: cleaner ownership and safer change boundaries
## Contract Quality Gates
Before publishing a manifest:
1. Every tool has clear verb-first name.
2. Every tool description explains intent and expected result.
3. Every required field is explicitly typed.
4. Destructive actions include confirmation parameters.
5. Error payload format is consistent across all tools.
6. Validator returns zero errors in strict mode.
## Testing Strategy
- Unit: validate transformation from OpenAPI operation to MCP tool schema.
- Contract: snapshot `tool_manifest.json` and review diffs in PR.
- Integration: call generated tool handlers against staging API.
- Resilience: simulate 4xx/5xx upstream errors and verify structured responses.
## Deployment Practices
- Pin MCP runtime dependencies per environment.
- Roll out server updates behind versioned endpoint/process.
- Keep backward compatibility for one release window minimum.
- Add changelog notes for new/removed/changed tool contracts.
## Security Controls
- Keep outbound host allowlist explicit.
- Do not proxy arbitrary URLs from user-provided input.
- Redact secrets and auth headers from logs.
- Rate-limit high-cost tools and add request timeouts.

View File

@@ -0,0 +1,34 @@
# OpenAPI Extraction Guide
## Goal
Turn stable API operations into stable MCP tools with clear names and reliable schemas.
## Extraction Rules
1. Prefer `operationId` as tool name.
2. Fallback naming: `<method>_<path>` sanitized to snake_case.
3. Pull `summary` for tool description; fallback to `description`.
4. Merge path/query parameters into `inputSchema.properties`.
5. Merge `application/json` request-body object properties when available.
6. Preserve required fields from both parameters and request body.
## Naming Guidance
Good names:
- `list_customers`
- `create_invoice`
- `archive_project`
Avoid:
- `tool1`
- `run`
- `get__v1__customer___id`
## Schema Guidance
- `inputSchema.type` must be `object`.
- Every `required` key must exist in `properties`.
- Include concise descriptions on high-risk fields (IDs, dates, money, destructive flags).

View File

@@ -0,0 +1,22 @@
# Python MCP Server Template
```python
from fastmcp import FastMCP
import httpx
import os
mcp = FastMCP(name="my-server")
API_BASE = os.environ["API_BASE"]
API_TOKEN = os.environ["API_TOKEN"]
@mcp.tool()
def list_items(input: dict) -> dict:
with httpx.Client(base_url=API_BASE, headers={"Authorization": f"Bearer {API_TOKEN}"}) as client:
resp = client.get("/items", params=input)
if resp.status_code >= 400:
return {"error": {"code": "upstream_error", "message": "List failed", "details": resp.text}}
return resp.json()
if __name__ == "__main__":
mcp.run()
```

View File

@@ -0,0 +1,19 @@
# TypeScript MCP Server Template
```ts
import { FastMCP } from "fastmcp";
const server = new FastMCP({ name: "my-server" });
server.tool(
"list_items",
"List items from upstream service",
async (input) => {
return {
content: [{ type: "text", text: JSON.stringify({ status: "todo", input }) }],
};
}
);
server.run();
```

View File

@@ -0,0 +1,30 @@
# MCP Validation Checklist
## Structural Integrity
- [ ] Tool names are unique across the manifest
- [ ] Tool names use lowercase snake_case (3-64 chars, `[a-z0-9_]`)
- [ ] `inputSchema.type` is always `"object"`
- [ ] Every `required` field exists in `properties`
- [ ] No empty `properties` objects (warn if inputs truly optional)
## Descriptive Quality
- [ ] All tools include actionable descriptions (≥10 chars)
- [ ] Descriptions start with a verb ("Create…", "Retrieve…", "Delete…")
- [ ] Parameter descriptions explain expected values, not just types
## Security & Safety
- [ ] Auth tokens and secrets are NOT exposed in tool schemas
- [ ] Destructive tools require explicit confirmation input parameters
- [ ] No tool accepts arbitrary URLs or file paths without validation
- [ ] Outbound host allowlists are explicit where applicable
## Versioning & Compatibility
- [ ] Breaking tool changes use new tool IDs (never rename in-place)
- [ ] Additive-only changes for non-breaking updates
- [ ] Contract changelog is maintained per release
- [ ] Deprecated tools include sunset timeline in description
## Runtime & Error Handling
- [ ] Error responses use consistent structure (`code`, `message`, `details`)
- [ ] Timeout and rate-limit behaviors are documented
- [ ] Large response payloads are paginated or truncated

View File

@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""Validate MCP tool manifest files for common contract issues.
Input sources:
- --input <manifest.json>
- stdin JSON
Validation domains:
- structural correctness
- naming hygiene
- schema consistency
- descriptive completeness
"""
import argparse
import json
import re
import sys
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
TOOL_NAME_RE = re.compile(r"^[a-z0-9_]{3,64}$")
class CLIError(Exception):
"""Raised for expected CLI failures."""
@dataclass
class ValidationResult:
errors: List[str]
warnings: List[str]
tool_count: int
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Validate MCP tool definitions.")
parser.add_argument("--input", help="Path to manifest JSON file. If omitted, reads from stdin.")
parser.add_argument("--strict", action="store_true", help="Exit non-zero when errors are found.")
parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format.")
return parser.parse_args()
def load_manifest(input_path: Optional[str]) -> Dict[str, Any]:
if input_path:
try:
data = Path(input_path).read_text(encoding="utf-8")
except Exception as exc:
raise CLIError(f"Failed reading --input: {exc}") from exc
else:
if sys.stdin.isatty():
raise CLIError("No input provided. Use --input or pipe manifest JSON via stdin.")
data = sys.stdin.read().strip()
if not data:
raise CLIError("Empty stdin.")
try:
payload = json.loads(data)
except json.JSONDecodeError as exc:
raise CLIError(f"Invalid JSON input: {exc}") from exc
if not isinstance(payload, dict):
raise CLIError("Manifest root must be a JSON object.")
return payload
def validate_schema(tool_name: str, schema: Dict[str, Any]) -> Tuple[List[str], List[str]]:
errors: List[str] = []
warnings: List[str] = []
if schema.get("type") != "object":
errors.append(f"{tool_name}: inputSchema.type must be 'object'.")
props = schema.get("properties", {})
if not isinstance(props, dict):
errors.append(f"{tool_name}: inputSchema.properties must be an object.")
props = {}
required = schema.get("required", [])
if not isinstance(required, list):
errors.append(f"{tool_name}: inputSchema.required must be an array.")
required = []
prop_keys = set(props.keys())
for req in required:
if req not in prop_keys:
errors.append(f"{tool_name}: required field '{req}' is not defined in properties.")
if not props:
warnings.append(f"{tool_name}: no input properties declared.")
for pname, pdef in props.items():
if not isinstance(pdef, dict):
errors.append(f"{tool_name}: property '{pname}' must be an object.")
continue
ptype = pdef.get("type")
if not ptype:
warnings.append(f"{tool_name}: property '{pname}' has no explicit type.")
return errors, warnings
def validate_manifest(payload: Dict[str, Any]) -> ValidationResult:
errors: List[str] = []
warnings: List[str] = []
tools = payload.get("tools")
if not isinstance(tools, list):
raise CLIError("Manifest must include a 'tools' array.")
seen_names = set()
for idx, tool in enumerate(tools):
if not isinstance(tool, dict):
errors.append(f"tool[{idx}] is not an object.")
continue
name = str(tool.get("name", "")).strip()
desc = str(tool.get("description", "")).strip()
schema = tool.get("inputSchema")
if not name:
errors.append(f"tool[{idx}] missing name.")
continue
if name in seen_names:
errors.append(f"duplicate tool name: {name}")
seen_names.add(name)
if not TOOL_NAME_RE.match(name):
warnings.append(
f"{name}: non-standard naming; prefer lowercase snake_case (3-64 chars, [a-z0-9_])."
)
if len(desc) < 10:
warnings.append(f"{name}: description too short; provide actionable purpose.")
if not isinstance(schema, dict):
errors.append(f"{name}: missing or invalid inputSchema object.")
continue
schema_errors, schema_warnings = validate_schema(name, schema)
errors.extend(schema_errors)
warnings.extend(schema_warnings)
return ValidationResult(errors=errors, warnings=warnings, tool_count=len(tools))
def to_text(result: ValidationResult) -> str:
lines = [
"MCP manifest validation",
f"- tools: {result.tool_count}",
f"- errors: {len(result.errors)}",
f"- warnings: {len(result.warnings)}",
]
if result.errors:
lines.append("Errors:")
lines.extend([f"- {item}" for item in result.errors])
if result.warnings:
lines.append("Warnings:")
lines.extend([f"- {item}" for item in result.warnings])
return "\n".join(lines)
def main() -> int:
args = parse_args()
payload = load_manifest(args.input)
result = validate_manifest(payload)
if args.format == "json":
print(json.dumps(asdict(result), indent=2))
else:
print(to_text(result))
if args.strict and result.errors:
return 1
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except CLIError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(2)

View File

@@ -0,0 +1,284 @@
#!/usr/bin/env python3
"""Generate MCP scaffold files from an OpenAPI specification.
Input sources:
- --input <file>
- stdin (JSON or YAML when PyYAML is available)
Output:
- tool_manifest.json
- server.py or server.ts scaffold
- summary in text/json
"""
import argparse
import json
import re
import sys
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any, Dict, List, Optional
HTTP_METHODS = {"get", "post", "put", "patch", "delete"}
class CLIError(Exception):
"""Raised for expected CLI failures."""
@dataclass
class GenerationSummary:
server_name: str
language: str
operations_total: int
tools_generated: int
output_dir: str
manifest_path: str
scaffold_path: str
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate MCP server scaffold from OpenAPI.")
parser.add_argument("--input", help="OpenAPI file path (JSON or YAML). If omitted, reads from stdin.")
parser.add_argument("--server-name", required=True, help="MCP server name.")
parser.add_argument("--language", choices=["python", "typescript"], default="python", help="Scaffold language.")
parser.add_argument("--output-dir", default=".", help="Directory to write generated files.")
parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format.")
return parser.parse_args()
def load_raw_input(input_path: Optional[str]) -> str:
if input_path:
try:
return Path(input_path).read_text(encoding="utf-8")
except Exception as exc:
raise CLIError(f"Failed to read --input file: {exc}") from exc
if sys.stdin.isatty():
raise CLIError("No input provided. Use --input <spec-file> or pipe OpenAPI via stdin.")
data = sys.stdin.read().strip()
if not data:
raise CLIError("Stdin was provided but empty.")
return data
def parse_openapi(raw: str) -> Dict[str, Any]:
try:
return json.loads(raw)
except json.JSONDecodeError:
try:
import yaml # type: ignore
parsed = yaml.safe_load(raw)
if not isinstance(parsed, dict):
raise CLIError("YAML OpenAPI did not parse into an object.")
return parsed
except ImportError as exc:
raise CLIError("Input is not valid JSON and PyYAML is unavailable for YAML parsing.") from exc
except Exception as exc:
raise CLIError(f"Failed to parse OpenAPI input: {exc}") from exc
def sanitize_tool_name(name: str) -> str:
cleaned = re.sub(r"[^a-zA-Z0-9_]+", "_", name).strip("_")
cleaned = re.sub(r"_+", "_", cleaned)
return cleaned.lower() or "unnamed_tool"
def schema_from_parameter(param: Dict[str, Any]) -> Dict[str, Any]:
schema = param.get("schema", {})
if not isinstance(schema, dict):
schema = {}
out = {
"type": schema.get("type", "string"),
"description": param.get("description", ""),
}
if "enum" in schema:
out["enum"] = schema["enum"]
return out
def extract_tools(spec: Dict[str, Any]) -> List[Dict[str, Any]]:
paths = spec.get("paths", {})
if not isinstance(paths, dict):
raise CLIError("OpenAPI spec missing valid 'paths' object.")
tools = []
for path, methods in paths.items():
if not isinstance(methods, dict):
continue
for method, operation in methods.items():
method_l = str(method).lower()
if method_l not in HTTP_METHODS or not isinstance(operation, dict):
continue
op_id = operation.get("operationId")
if op_id:
name = sanitize_tool_name(str(op_id))
else:
name = sanitize_tool_name(f"{method_l}_{path}")
description = str(operation.get("summary") or operation.get("description") or f"{method_l.upper()} {path}")
properties: Dict[str, Any] = {}
required: List[str] = []
for param in operation.get("parameters", []):
if not isinstance(param, dict):
continue
pname = str(param.get("name", "")).strip()
if not pname:
continue
properties[pname] = schema_from_parameter(param)
if bool(param.get("required")):
required.append(pname)
request_body = operation.get("requestBody", {})
if isinstance(request_body, dict):
content = request_body.get("content", {})
if isinstance(content, dict):
app_json = content.get("application/json", {})
if isinstance(app_json, dict):
schema = app_json.get("schema", {})
if isinstance(schema, dict) and schema.get("type") == "object":
rb_props = schema.get("properties", {})
if isinstance(rb_props, dict):
for key, val in rb_props.items():
if isinstance(val, dict):
properties[key] = val
rb_required = schema.get("required", [])
if isinstance(rb_required, list):
required.extend([str(x) for x in rb_required])
tool = {
"name": name,
"description": description,
"inputSchema": {
"type": "object",
"properties": properties,
"required": sorted(set(required)),
},
"x-openapi": {"path": path, "method": method_l},
}
tools.append(tool)
return tools
def python_scaffold(server_name: str, tools: List[Dict[str, Any]]) -> str:
handlers = []
for tool in tools:
fname = sanitize_tool_name(tool["name"])
handlers.append(
f"@mcp.tool()\ndef {fname}(input: dict) -> dict:\n"
f" \"\"\"{tool['description']}\"\"\"\n"
f" return {{\"tool\": \"{tool['name']}\", \"status\": \"todo\", \"input\": input}}\n"
)
return "\n".join(
[
"#!/usr/bin/env python3",
'"""Generated MCP server scaffold."""',
"",
"from fastmcp import FastMCP",
"",
f"mcp = FastMCP(name={server_name!r})",
"",
*handlers,
"",
"if __name__ == '__main__':",
" mcp.run()",
"",
]
)
def typescript_scaffold(server_name: str, tools: List[Dict[str, Any]]) -> str:
registrations = []
for tool in tools:
const_name = sanitize_tool_name(tool["name"])
registrations.append(
"server.tool(\n"
f" '{tool['name']}',\n"
f" '{tool['description']}',\n"
" async (input) => ({\n"
f" content: [{{ type: 'text', text: JSON.stringify({{ tool: '{const_name}', status: 'todo', input }}) }}],\n"
" })\n"
");"
)
return "\n".join(
[
"// Generated MCP server scaffold",
"import { FastMCP } from 'fastmcp';",
"",
f"const server = new FastMCP({{ name: '{server_name}' }});",
"",
*registrations,
"",
"server.run();",
"",
]
)
def write_outputs(server_name: str, language: str, output_dir: Path, tools: List[Dict[str, Any]]) -> GenerationSummary:
output_dir.mkdir(parents=True, exist_ok=True)
manifest_path = output_dir / "tool_manifest.json"
manifest = {"server": server_name, "tools": tools}
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
if language == "python":
scaffold_path = output_dir / "server.py"
scaffold_path.write_text(python_scaffold(server_name, tools), encoding="utf-8")
else:
scaffold_path = output_dir / "server.ts"
scaffold_path.write_text(typescript_scaffold(server_name, tools), encoding="utf-8")
return GenerationSummary(
server_name=server_name,
language=language,
operations_total=len(tools),
tools_generated=len(tools),
output_dir=str(output_dir.resolve()),
manifest_path=str(manifest_path.resolve()),
scaffold_path=str(scaffold_path.resolve()),
)
def main() -> int:
args = parse_args()
raw = load_raw_input(args.input)
spec = parse_openapi(raw)
tools = extract_tools(spec)
if not tools:
raise CLIError("No operations discovered in OpenAPI paths.")
summary = write_outputs(
server_name=args.server_name,
language=args.language,
output_dir=Path(args.output_dir),
tools=tools,
)
if args.format == "json":
print(json.dumps(asdict(summary), indent=2))
else:
print("MCP scaffold generated")
print(f"- server: {summary.server_name}")
print(f"- language: {summary.language}")
print(f"- tools: {summary.tools_generated}")
print(f"- manifest: {summary.manifest_path}")
print(f"- scaffold: {summary.scaffold_path}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except CLIError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(2)