diff --git a/agent-docs/mcp-patterns.md b/agent-docs/mcp-patterns.md index f814b3f..f52b54d 100644 --- a/agent-docs/mcp-patterns.md +++ b/agent-docs/mcp-patterns.md @@ -40,11 +40,13 @@ Always define input schemas with validation — never accept raw unvalidated inp from mcp.server import Server from pydantic import BaseModel, Field + class QueryInput(BaseModel): dataset: str = Field(description="Dataset identifier") filters: dict[str, str] = Field(default_factory=dict) limit: int = Field(default=100, le=10000, description="Max rows to return") + @server.tool("query_data", "Query a dataset with filters") async def query_data(input: QueryInput): # Input is already validated by Pydantic @@ -79,12 +81,14 @@ headers = {"Authorization": f"Bearer {user_context.token}"} # Validate and sanitize all inputs before use import re + def validate_jira_key(key: str) -> str: """Only allow valid Jira key format.""" - if not re.match(r'^[A-Z][A-Z0-9]+-\d+$', key): + if not re.match(r"^[A-Z][A-Z0-9]+-\d+$", key): raise ValueError(f"Invalid Jira key format: {key}") return key + # Never pass raw user input to shell commands # BAD: os.system(f"grep {user_input} data.json") @@ -98,6 +102,7 @@ subprocess.run(["grep", user_input, "data.json"], capture_output=True) # Require confirmation for destructive or sensitive actions RISKY_OPERATIONS = {"delete", "bulk_update", "export_pii", "drop_table"} + @server.tool("delete_record") async def delete_record(record_id: str): # MCP framework handles approval — tool description should state: @@ -110,6 +115,7 @@ async def delete_record(record_id: str): ```python import logging + @server.tool("query_data") async def query_data(input: QueryInput, context: RequestContext): logging.info( @@ -119,7 +125,7 @@ async def query_data(input: QueryInput, context: RequestContext): "user": context.user_id, "params": {"dataset": input.dataset, "limit": input.limit}, "timestamp": datetime.now().isoformat(), - } + }, ) # ... execute query ``` diff --git a/skills/ci-guard/SKILL.md b/skills/ci-guard/SKILL.md new file mode 100644 index 0000000..7c4f175 --- /dev/null +++ b/skills/ci-guard/SKILL.md @@ -0,0 +1,118 @@ +--- +name: ci-guard +version: "1.0" +description: Use when starting work in a repository under repositories/ that may lack CI configuration. Detects missing CI workflows (GitHub Actions, GitLab CI, CircleCI) and alerts the user to add one. Skips repos marked as research-only. +--- + +# CI Guard + +Every repository using this basecamp should have CI unless explicitly marked as research. This skill checks for CI configuration and alerts when it's missing. + +## When to Activate + +- At session start, after repository status is reported +- When a user clones or creates a new repo under `repositories/` +- When running `/verify` or `/quality-gate` in a repo without CI + +## How to Check + +Run the check script against any repo: + +```bash +uv run skills/ci-guard/scripts/check-ci.py +``` + +The script checks for common CI indicators and the research exemption. + +## CI Indicators (any one is sufficient) + +| Provider | Path | +|----------|------| +| GitHub Actions | `.github/workflows/` (with at least one `.yml`/`.yaml`) | +| GitLab CI | `.gitlab-ci.yml` | +| CircleCI | `.circleci/config.yml` | +| Jenkins | `Jenkinsfile` | +| Travis | `.travis.yml` | +| Azure | `azure-pipelines.yml` | + +## Research Exemption + +A repo is exempt from the CI requirement if: + +1. A `.research` file exists in the repo root, OR +2. `pyproject.toml` contains `purpose = "research"` in `[project.optional]` or as a comment marker + +To mark a repo as research: `touch /.research` + +## Alert Behavior + +When CI is missing and the repo is not research: + +1. **Warn clearly** at session start: "This repo has no CI configuration." +2. **Suggest a starter workflow** based on the detected stack (Python/Node/Rust/Go) +3. **Reference the basecamp's own CI** (`.github/workflows/ci.yml`) as a working template +4. **Do not block work** - this is an alert, not a gate + +## Starter Templates + +### Python (uv-based, matching basecamp conventions) + +```yaml +name: CI +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + - run: uv python install 3.12 + - run: uv sync --group dev + - run: uv run ruff check . + - run: uv run ruff format --check . + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + - run: uv python install 3.12 + - run: uv sync --group dev + - run: uv run pytest -v +``` + +### Node.js + +```yaml +name: CI +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - run: npm run lint + - run: npm test +``` + +## Common Mistakes + +| Mistake | Fix | +|---------|-----| +| Forgetting CI on quick prototype repos | Use `.research` file for true prototypes, add CI for everything else | +| Adding CI but not running tests | CI without tests is a false safety net - at minimum lint | +| Blocking the user from working | This is an alert, not a blocker - warn and continue | diff --git a/skills/ci-guard/scripts/check-ci.py b/skills/ci-guard/scripts/check-ci.py new file mode 100644 index 0000000..4335500 --- /dev/null +++ b/skills/ci-guard/scripts/check-ci.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Check if a repository has CI configuration. + +Exit codes: + 0 - CI found or repo is research-exempt + 1 - No CI configuration found +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +CI_INDICATORS = [ + (".github/workflows", True), # (path, is_directory) + (".gitlab-ci.yml", False), + (".circleci/config.yml", False), + ("Jenkinsfile", False), + (".travis.yml", False), + ("azure-pipelines.yml", False), + ("bitbucket-pipelines.yml", False), +] + + +def has_ci(repo_root: Path) -> str | None: + """Return the CI provider name if found, None otherwise.""" + for indicator, is_dir in CI_INDICATORS: + path = repo_root / indicator + if is_dir: + if path.is_dir() and any(path.glob("*.y*ml")): + return indicator + elif path.is_file(): + return indicator + return None + + +def is_research(repo_root: Path) -> bool: + """Check if the repo is marked as research-only.""" + if (repo_root / ".research").exists(): + return True + toml = repo_root / "pyproject.toml" + if toml.is_file(): + content = toml.read_text() + if 'purpose = "research"' in content or "purpose = 'research'" in content: + return True + return False + + +def main() -> None: + repo_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(".") + repo_path = repo_path.resolve() + + if not repo_path.is_dir(): + print(f"ERROR: {repo_path} is not a directory", file=sys.stderr) + sys.exit(2) + + if is_research(repo_path): + print(f"SKIP: {repo_path.name} is marked as research — CI not required") + sys.exit(0) + + ci = has_ci(repo_path) + if ci: + print(f"OK: CI found ({ci}) in {repo_path.name}") + sys.exit(0) + + print(f"WARNING: No CI configuration found in {repo_path.name}") + print(" Add a CI workflow or mark as research: touch .research") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills/data-pipeline-patterns/SKILL.md b/skills/data-pipeline-patterns/SKILL.md index c10ca43..6f83829 100644 --- a/skills/data-pipeline-patterns/SKILL.md +++ b/skills/data-pipeline-patterns/SKILL.md @@ -41,7 +41,7 @@ def main(argv=None): "generated_at": datetime.now().isoformat(), "items_processed": len(result), }, - "data": result + "data": result, } save_json_file(output, output_path) ``` diff --git a/skills/python-conventions/skills/conventions.md b/skills/python-conventions/skills/conventions.md index 7434511..87884aa 100644 --- a/skills/python-conventions/skills/conventions.md +++ b/skills/python-conventions/skills/conventions.md @@ -46,14 +46,16 @@ LLM outputs often include markdown fences that break JSON parsing. Always clean ```python import json + def clean_llm_response(text): for prefix in ("```markdown", "```json", "```"): if text.startswith(prefix): - text = text[len(prefix):].strip() + text = text[len(prefix) :].strip() if text.endswith("```"): text = text[:-3].strip() return text + def parse_llm_json(text): text = clean_llm_response(text) try: @@ -86,6 +88,7 @@ def parse_llm_json(text): # DataFrame testing import pandas.testing as tm + def test_feature_engineering(): input_df = pd.DataFrame({"price": [100, 200], "quantity": [2, 3]}) result = add_total_column(input_df) diff --git a/skills/semantic-versioning/SKILL.md b/skills/semantic-versioning/SKILL.md new file mode 100644 index 0000000..d6044df --- /dev/null +++ b/skills/semantic-versioning/SKILL.md @@ -0,0 +1,93 @@ +--- +name: semantic-versioning +version: "1.0" +description: Use when committing or pushing changes and the repository needs a version bump. Analyzes commits using conventional commit prefixes to determine whether the next release is a major, minor, or patch increment. Also use when the user asks about versioning, release planning, or changelog generation. +--- + +# Semantic Versioning + +Determine the next version from commit history. Every pushed change gets classified; the highest-impact commit drives the bump. + +## When to Activate + +- Before or after pushing changes (suggest the version bump) +- When the user asks "what version should this be?" +- When preparing a release or tag +- When reviewing a set of commits for release notes + +## Version Rules + +Follows [Semantic Versioning 2.0.0](https://semver.org): + +| Bump | Trigger | Examples | +|------|---------|----------| +| **Major** (X.0.0) | Breaking change | `feat!:`, `fix!:`, `BREAKING CHANGE:` in body/footer | +| **Minor** (x.Y.0) | New feature | `feat:`, `feature:` | +| **Patch** (x.y.Z) | Everything else | `fix:`, `chore:`, `docs:`, `style:`, `refactor:`, `test:`, `ci:`, `perf:` | + +The highest bump wins: if any commit is `major`, the release is major — regardless of how many patches are in the batch. + +## How to Check + +Run the analysis script: + +```bash +uv run skills/semantic-versioning/scripts/version-bump.py [repo-path] +``` + +Options: +- `--apply` — update the version file after confirmation +- `--tag` — also create a git tag + +## Version File Detection + +The script auto-detects where the version lives: + +| File | Field | +|------|-------| +| `pyproject.toml` | `[project] version = "x.y.z"` | +| `package.json` | `"version": "x.y.z"` | +| `Cargo.toml` | `[package] version = "x.y.z"` | +| `VERSION` | Plain text file | + +## Workflow + +1. **Analyze** — scan commits since last tag, classify each +2. **Report** — show the bump type, commit breakdown, and suggested next version +3. **Confirm** — wait for user approval before making changes +4. **Apply** (if `--apply`) — update version file and optionally tag + +Never auto-apply version changes without user confirmation. + +## Commit Format Guide + +For best results, follow conventional commits: + +``` +[optional scope][!]: + +[optional body] + +[optional footer(s)] +``` + +If commits don't follow conventional format, fall back to keyword analysis: +- Words like "add", "new", "feature" → minor +- Words like "fix", "bug", "patch", "correct" → patch +- Words like "breaking", "remove", "drop", "rename API" → major + +## Pre-1.0 Semantics + +For versions `0.y.z` (pre-stable): +- API is considered unstable +- Minor bumps may include breaking changes +- Use `0.y.z` until the project declares stability + +## Common Mistakes + +| Mistake | Fix | +|---------|-----| +| Bumping major for every `feat!` in pre-1.0 | Pre-1.0: breaking changes go in minor | +| Forgetting to tag after version bump | Always tag: `git tag v` | +| Version in multiple files getting out of sync | Use the script to update all detected version files | +| Non-conventional commit messages | Fall back to keyword analysis, but encourage conventional commits | diff --git a/skills/semantic-versioning/scripts/version-bump.py b/skills/semantic-versioning/scripts/version-bump.py new file mode 100644 index 0000000..3ff8907 --- /dev/null +++ b/skills/semantic-versioning/scripts/version-bump.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Analyze commits since last tag and suggest the next semantic version. + +Usage: + uv run version-bump.py [repo-path] Show recommended version bump + uv run version-bump.py --apply Update version file (with confirmation) + uv run version-bump.py --tag Also create a git tag after applying + +Exit codes: + 0 - Success (version info printed) + 1 - No commits to analyze +""" + +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path + +CONVENTIONAL_RE = re.compile( + r"^(?Pfeat|feature|fix|chore|docs|style|refactor|test|ci|perf|build|revert)" + r"(?:\([^)]*\))?" + r"(?P!)?" + r":\s*(?P.+)$", + re.IGNORECASE, +) + +MAJOR_KEYWORDS = re.compile(r"\b(breaking|removed?|dropped?|rename\s+api)\b", re.IGNORECASE) +MINOR_KEYWORDS = re.compile(r"\b(add|new|feature|implement)\b", re.IGNORECASE) + +VERSION_FILES = [ + ("pyproject.toml", re.compile(r'(version\s*=\s*")(\d+\.\d+\.\d+)(")')), + ("package.json", re.compile(r'("version"\s*:\s*")(\d+\.\d+\.\d+)(")')), + ("Cargo.toml", re.compile(r'(version\s*=\s*")(\d+\.\d+\.\d+)(")')), + ("VERSION", re.compile(r"^(\d+\.\d+\.\d+)$", re.MULTILINE)), +] + + +def git(*args: str, cwd: Path | None = None) -> str: + result = subprocess.run(["git", *args], capture_output=True, text=True, cwd=cwd) + return result.stdout.strip() + + +def get_last_tag(cwd: Path) -> str | None: + tag = git("describe", "--tags", "--abbrev=0", cwd=cwd) + return tag if tag else None + + +def get_commits_since(tag: str | None, cwd: Path) -> list[str]: + cmd = ["log", "--oneline", "--format=%s"] + if tag: + cmd.append(f"{tag}..HEAD") + output = git(*cmd, cwd=cwd) + return output.split("\n") if output else [] + + +def parse_version(tag: str | None) -> tuple[int, int, int]: + match = re.match(r"v?(\d+)\.(\d+)\.(\d+)", tag or "0.0.0") + if not match: + return (0, 0, 0) + return (int(match.group(1)), int(match.group(2)), int(match.group(3))) + + +def classify_commit(msg: str) -> str: + m = CONVENTIONAL_RE.match(msg) + if m: + if m.group("breaking") or "BREAKING CHANGE" in msg.upper(): + return "major" + if m.group("type").lower() in ("feat", "feature"): + return "minor" + return "patch" + + if MAJOR_KEYWORDS.search(msg): + return "major" + if MINOR_KEYWORDS.search(msg): + return "minor" + return "patch" + + +def highest_bump(classifications: list[str]) -> str: + if "major" in classifications: + return "major" + if "minor" in classifications: + return "minor" + return "patch" + + +def next_version(current: tuple[int, int, int], bump: str) -> str: + major, minor, patch = current + if bump == "major": + if major == 0: + return f"0.{minor + 1}.0" + return f"{major + 1}.0.0" + if bump == "minor": + return f"{major}.{minor + 1}.0" + return f"{major}.{minor}.{patch + 1}" + + +def find_version_file(cwd: Path) -> tuple[Path, re.Pattern[str]] | None: + for filename, pattern in VERSION_FILES: + path = cwd / filename + if path.is_file(): + content = path.read_text() + if pattern.search(content): + return path, pattern + return None + + +def update_version_file(path: Path, pattern: re.Pattern[str], new_version: str) -> None: + content = path.read_text() + if path.name == "VERSION": + updated = pattern.sub(new_version, content) + else: + updated = pattern.sub(rf"\g<1>{new_version}\g<3>", content) + path.write_text(updated) + + +def main() -> None: + args = sys.argv[1:] + apply_flag = "--apply" in args + tag_flag = "--tag" in args + repo_args = [a for a in args if not a.startswith("--")] + cwd = Path(repo_args[0]).resolve() if repo_args else Path.cwd() + + last_tag = get_last_tag(cwd) + commits = get_commits_since(last_tag, cwd) + + if not commits: + print("No commits since last tag. No version bump needed.") + sys.exit(1) + + classifications = [classify_commit(msg) for msg in commits] + bump = highest_bump(classifications) + current = parse_version(last_tag) + new_ver = next_version(current, bump) + + print(f"Current version: {last_tag or '0.0.0 (no tags)'}") + print(f"Commits analyzed: {len(commits)}") + print(f"Recommended bump: {bump}") + print(f"Next version: v{new_ver}") + print() + + counts = {"major": 0, "minor": 0, "patch": 0} + for msg, cls in zip(commits, classifications, strict=True): + counts[cls] += 1 + print(f" [{cls:5s}] {msg}") + + print(f"\nBreakdown: {counts['major']} major, {counts['minor']} minor, {counts['patch']} patch") + + if apply_flag: + vf = find_version_file(cwd) + if vf: + path, pattern = vf + update_version_file(path, pattern, new_ver) + print(f"\nUpdated {path.name} to {new_ver}") + else: + print("\nNo version file found to update.") + + if tag_flag: + git("tag", f"v{new_ver}", cwd=cwd) + print(f"Created tag v{new_ver}") + + result = { + "current": last_tag or "0.0.0", + "next": f"v{new_ver}", + "bump": bump, + "commits": len(commits), + "breakdown": counts, + } + print(f"\n{json.dumps(result)}") + + +if __name__ == "__main__": + main()