{ "version": "1.0.0", "generated_at": "2026-08-02T00:00:00Z", "portfolio": { "name": "Hermes Skills Portfolio", "owner": "Owen", "tagline": "Empowering skills for the Hermes agent — install one, your agent can now do that for you.", "total_skills": 57, "github_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio" }, "categories": { "devops": { "name": "DevOps & Infrastructure", "description": "Deploy, host, and operate services.", "skill_count": 7 }, "frontend": { "name": "Frontend", "description": "Build distinctive, non-generic UIs.", "skill_count": 4 }, "backend": { "name": "Backend", "description": "APIs, tests, and server-side architecture.", "skill_count": 8 }, "utility": { "name": "Utility", "description": "General-purpose capabilities for everyday workflows.", "skill_count": 23 }, "meta": { "name": "Meta", "description": "Skills about skills — portfolios, publishing, catalogs.", "skill_count": 4 }, "integrations": { "name": "Integrations", "description": "Connect agents to external platforms and services.", "skill_count": 5 }, "media": { "name": "Media & Streaming", "description": "Build catalogue apps for film, TV, and anime.", "skill_count": 6 } }, "skills": [ { "name": "tailscale-deploy", "category": "devops", "tier": "core", "description": "Deploy a service on your Tailscale tailnet so it's privately accessible from any of your devices.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/tailscale-deploy/SKILL.md", "path": "skills/tailscale-deploy", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "tailscale-deploy", "description": "Deploy a service on the user's Tailscale tailnet so it's privately accessible from any device.", "version": "1.0.0" }, "agent_use": "- The user wants to run a web service and access it from their laptop, phone, or other machines without exposing it to the internet.\n- The user wants to share a local dev server with someone on their tailnet.\n- The user wants to deploy a Docker service with private network access.\n- The user says \"deploy this on my tailnet\", \"make this accessible via Tailscale\", or \"I want to access this from my phone\".", "user_use": "The agent deploys a web service — Docker container, local dev server, or anything running on a port — onto your Tailscale tailnet. The service becomes reachable from your laptop, phone, and any other device on your tailnet. No public internet exposure, no port forwarding, no cloud relay.", "skillmd_content": "---\nname: tailscale-deploy\ndescription: Use when the user wants a service reachable privately from their own devices (laptop, phone) without exposing it to the public internet, wants to share a local dev server with a specific person on their tailnet, or says \"deploy this on my tailnet\" / \"make this accessible via Tailscale\".\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [tailscale, vpn, tailnet, wireguard, private-networking, docker-sidecar]\n related_skills: [caddy-reverse-proxy, docker-umbrella]\n---\n\n# tailscale-deploy\n\n## Overview\n\nDeploy a service on a Tailscale tailnet. The service becomes privately accessible from any device on the user's tailnet — no public exposure, no port forwarding, no cloud relay.\n\n## When to Use\n\n- The user wants to run a web service and access it from their laptop, phone, or other machines without exposing it to the internet.\n- The user wants to share a local dev server with someone on their tailnet.\n- The user wants to deploy a Docker service with private network access.\n- The user says \"deploy this on my tailnet\", \"make this accessible via Tailscale\", or \"I want to access this from my phone\".\n\n## Prerequisites\n\n1. **Tailscale installed** — check with `tailscale status`. If not installed:\n - **Linux**: `curl -fsSL https://tailscale.com/install.sh | sh`\n - **macOS**: `brew install tailscale` or install from the App Store\n - **Windows**: download from https://tailscale.com/download\n\n2. **Tailscale authenticated** — `tailscale up` if not already authenticated. The user needs a Tailscale account (free for personal use, up to 100 devices).\n\n3. **Docker installed** — for container-based deployments. Check with `docker --version`.\n\n## Workflow\n\n### Step 1: Verify Tailscale is running\n\n```bash\ntailscale status\n```\n\nIf the output shows the machine as `idle` or not connected, run:\n\n```bash\nsudo tailscale up\n```\n\nOn Windows, run `tailscale up` in an elevated terminal.\n\n### Step 2: Choose a deployment method\n\nTwo methods, depending on what the user is deploying:\n\n**Method A — Direct serve (existing local service):**\nThe service is already running on a local port (e.g., `localhost:8080`). Use `tailscale serve` to expose it over the tailnet with HTTPS.\n\n```bash\n# Expose localhost:8080 over the tailnet with HTTPS\ntailscale serve --https 8080\n```\n\nThe service is now accessible at `https://..ts.net` from any device on the tailnet.\n\nTo check what's being served:\n```bash\ntailscale serve status\n```\n\nTo stop serving:\n```bash\ntailscale serve --https off\n```\n\n**Method B — Docker with Tailscale sidecar:**\nDeploy a Docker container with a Tailscale sidecar that joins the tailnet and routes traffic to the service.\n\nCreate a `docker-compose.yml`:\n\n```yaml\nversion: \"3.8\"\nservices:\n app:\n image: your-app-image\n restart: unless-stopped\n networks:\n - tsnet\n\n tailscale:\n image: tailscale/tailscale:latest\n restart: unless-stopped\n hostname: my-service\n environment:\n - TS_AUTHKEY=tskey-auth-XXXXX # generate at https://login.tailscale.com/admin/settings/keys\n volumes:\n - tailscale-state:/var/lib/tailscale\n networks:\n - tsnet\n\nnetworks:\n tsnet:\n driver: bridge\n\nvolumes:\n tailscale-state:\n```\n\nThen:\n```bash\ndocker compose up -d\n```\n\nThe service is accessible at `http://my-service..ts.net:PORT` from any tailnet device.\n\n### Step 3: Verify accessibility\n\nFrom another device on the same tailnet:\n\n```bash\n# Method A\ncurl https://..ts.net\n\n# Method B\ncurl http://my-service..ts.net:PORT\n```\n\nOr just open the URL in a browser on any tailnet device.\n\n### Step 4: Clean up (when the user wants to stop)\n\n```bash\n# Method A\ntailscale serve --https off\n\n# Method B\ndocker compose down\n```\n\n## Tailscale Serve Reference\n\n| Command | What it does |\n|---|---|\n| `tailscale serve --https PORT` | Expose localhost:PORT over HTTPS on the tailnet |\n| `tailscale serve --http PORT` | Expose localhost:PORT over HTTP on the tailnet |\n| `tailscale serve --https off` | Stop serving |\n| `tailscale serve status` | Show what's being served |\n| `tailscale funnel PORT` | Expose to the PUBLIC internet (not tailnet-only) — use with caution |\n\n**Important:** `tailscale serve` is tailnet-only (private). `tailscale funnel` is public internet exposure. Most users want `serve`, not `funnel`. Always confirm with the user before using `funnel`.\n\n## Common Pitfalls\n\n1. **Docker sidecar `TS_AUTHKEY` expired or non-ephemeral.** Auth keys expire and non-ephemeral\n keys leave stale devices in the admin console after teardown. Generate a fresh ephemeral key\n at https://login.tailscale.com/admin/settings/keys for container use.\n2. **Assuming `tailscale serve --https 8080` serves on localhost:8080.** It actually serves on\n port 443 of the *tailnet* interface; the local service keeps its original port. Don't look for\n it on `localhost:443`.\n3. **Putting the app and the Tailscale sidecar on the default Docker network.** The sidecar can't\n route to the app unless both share a custom bridge network, as in the compose example — the\n default network isolates them.\n4. **Reaching for `tailscale funnel` when `serve` was meant.** `funnel` exposes the service to the\n entire public internet; `serve` is tailnet-only. Default to `serve` and confirm explicitly\n before ever using `funnel`.\n5. **Not checking `tailscale status` after `tailscale up`.** If the machine doesn't appear in the\n tailnet device list, `tailscale up` didn't complete — re-authenticate rather than assuming the\n service is reachable.\n6. **Corporate/restrictive networks blocking WireGuard.** If UDP 41641 outbound is blocked,\n Tailscale falls back to its DERP relay automatically, but performance suffers — flag this to\n the user rather than treating a slow connection as a bug.\n\n## Verification Checklist\n\n- [ ] `tailscale status` shows the machine as connected (not `idle`) before deploying.\n- [ ] Service responds to `curl` from a *second* device on the tailnet, not just from localhost\n on the host machine.\n- [ ] Confirmed with the user whether `serve` (tailnet-only) or `funnel` (public) was intended —\n `funnel` was never used without explicit confirmation.\n- [ ] For Docker sidecar deployments, `tailscale serve status` or the sidecar's logs show it\n joined the tailnet under the expected hostname.\n- [ ] Cleanup step (`tailscale serve --https off` or `docker compose down`) documented for when\n the user wants to stop serving.\n", "readme_content": "# tailscale-deploy\n\nDeploy a service on your Tailscale tailnet so it's privately accessible from any of your devices.\n\n## What it does\n\nThe agent deploys a web service — Docker container, local dev server, or anything running on a port — onto your Tailscale tailnet. The service becomes reachable from your laptop, phone, and any other device on your tailnet. No public internet exposure, no port forwarding, no cloud relay.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/tailscale-deploy/SKILL.md\n```\n\n## How to use\n\n**You have a local service running on port 8080 and want to access it from your phone:**\n\n```\n\"Deploy localhost:8080 on my tailnet\"\n```\n\nThe agent runs `tailscale serve --https 8080` and gives you a URL like `https://my-machine.tailnet.ts.net` that opens on any tailnet device.\n\n**You want to deploy a Docker container privately:**\n\n```\n\"Deploy this Docker image on my tailnet so only I can access it\"\n```\n\nThe agent generates a `docker-compose.yml` with a Tailscale sidecar, brings it up, and verifies accessibility.\n\n## Prerequisites\n\n- [Tailscale](https://tailscale.com) installed and authenticated (`tailscale up`)\n- Docker (for container deployments)\n- A Tailscale account (free for personal use, up to 100 devices)\n\n## What you get\n\n| Method | Command | Result |\n|---|---|---|\n| Direct serve | `tailscale serve --https 8080` | HTTPS URL on your tailnet for an existing local service |\n| Docker sidecar | `docker compose up -d` with Tailscale sidecar | Private Docker service accessible by hostname on your tailnet |\n\nBoth methods keep the service private to your tailnet. No public exposure unless you explicitly use `tailscale funnel`.\n\n## Example\n\n```\nUser: \"I have a Flask app running on localhost:5000. I want to check it from my phone.\"\n\nAgent:\n 1. Verifies tailscale status → connected, machine is \"laptop\"\n 2. Runs: tailscale serve --https 5000\n 3. Returns: \"Your app is now at https://laptop.tailnet.ts.net — open it on your phone.\"\n\nUser opens the URL on their phone (which is on the same tailnet) → the Flask app loads.\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/tailscale-deploy/SKILL.md" }, { "name": "hallmark-readme", "category": "utility", "tier": "core", "description": "Write a non-AI-slop README for any project — one that sounds like a human wrote it.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/hallmark-readme/SKILL.md", "path": "skills/hallmark-readme", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "generalized", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Generalized from the internal hallmark frontend skill's README anti-slop rules.", "license": "MIT", "derived": true }, "frontmatter": { "name": "hallmark-readme", "description": "Write a non-AI-slop README for any project.", "version": "1.0.0" }, "agent_use": "- The user asks for a README for their project.\n- The user wants to improve an existing README that reads as AI-generated.\n- The user says \"write a readme\", \"make my readme better\", or \"this readme sounds like AI wrote it\".\n- Any time you're about to write a README — default to this skill.", "user_use": "The agent reads your project and writes a README that follows anti-AI-slop rules: no filler phrases, no invented metrics, no templated structure, honest about limitations, concrete examples, real voice. The result reads like a human wrote it because it follows the patterns that distinguish human writing from LLM defaults.", "skillmd_content": "---\nname: hallmark-readme\ndescription: Use when a user asks for a project README, wants an existing README that reads as AI-generated fixed, or you are about to write a README for any project.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [readme, documentation, anti-ai-slop, writing]\n related_skills: [changelog-generator, skills-portfolio-scaffold, portfolio-upkeep]\n---\n\n# hallmark-readme\n\n## Overview\n\nWrite a README that doesn't read like it was generated by an LLM. Most AI-written READMEs share the same tells: generic headings, filler phrases, invented metrics, templated structure, no voice. This skill encodes the rules that produce READMEs which sound like a person wrote them.\n\n## When to Use\n\n- The user asks for a README for their project.\n- The user wants to improve an existing README that reads as AI-generated.\n- The user says \"write a readme\", \"make my readme better\", or \"this readme sounds like AI wrote it\".\n- Any time you're about to write a README — default to this skill.\n\n## Rules\n\n### 1. No filler phrases\n\nBanned words and phrases (never use these):\n\n| Banned | Use instead |\n|---|---|\n| empower, empowering | (delete — the verb does the work) |\n| seamless | (delete — nothing is seamless) |\n| leverage | use |\n| robust | (delete — show it with specifics) |\n| cutting-edge, state-of-the-art | (delete — let the reader judge) |\n| comprehensive | (delete — the structure shows comprehensiveness) |\n| powerful | (delete — show power with specifics) |\n| \"built with love\" | (delete) |\n| \"designed for developers\" | (delete — who else would it be for?) |\n| \"in today's fast-paced world\" | (delete — always) |\n\n### 2. No invented metrics\n\nNever fabricate numbers. If the user didn't supply a metric, don't invent one.\n\n| Wrong | Right |\n|---|---|\n| \"Trusted by 50,000+ developers\" | (omit — or use the real number if the user provided it) |\n| \"10x faster than alternatives\" | (omit — or cite a real benchmark) |\n| \"Used by teams at Google, Meta, Stripe\" | (omit — unless the user confirmed these) |\n| \"Increases productivity by 47%\" | (omit — or cite the study) |\n\nIf a section needs a metric and you don't have one, use a different structure. A feature list doesn't need a metric. A comparison table doesn't need a metric. Skip it.\n\n### 3. No templated structure\n\nAI READMEs all follow the same shape: Title → Badges → Tagline → Features → Installation → Usage → Contributing → License. Break this pattern.\n\nReal READMEs vary:\n\n- A CLI tool README leads with installation and a one-line example, not features.\n- A library README leads with the problem it solves, not badges.\n- A skill README leads with what the agent + skill delivers, not a tagline.\n- A small project README can be 20 lines. Don't pad it to 200.\n\n### 4. Honest about limitations\n\nName what the project doesn't do. This is the single most reliable signal that a human wrote the README — LLMs avoid limitations.\n\n| Wrong | Right |\n|---|---|\n| \"Works with any framework\" | \"Works with React and Vue. Svelte support is planned.\" |\n| \"Production-ready\" | \"Used in production by the author. Not yet tested at scale.\" |\n| (no limitations section) | A \"Limitations\" or \"Known Issues\" section with real items |\n\n### 5. Real structure, not generic headings\n\n| Generic (AI tell) | Specific (human signal) |\n|---|---|\n| ## Features | ## What it does |\n| ## Getting Started | ## Install |\n| ## Usage | ## How to use (with a concrete example) |\n| ## Contributing | ## For contributors (only if the project accepts contributions) |\n| ## FAQ | (only if there are actually frequently asked questions) |\n\n### 6. Voice\n\nWrite in first person or direct second person. Not corporate third person.\n\n| Wrong | Right |\n|---|---|\n| \"This project enables developers to...\" | \"You get a running service on your tailnet.\" |\n| \"The tool provides functionality for...\" | \"The agent deploys your app and gives you a URL.\" |\n| \"It is recommended that users...\" | \"Run this before you start.\" |\n\n### 7. Examples are concrete\n\nEvery README must have at least one concrete, runnable example. Not a placeholder. Not a \"TODO: add example\". A real command or code block the reader can copy and run.\n\n| Wrong | Right |\n|---|---|\n| `my-tool --input --output ` | `my-tool --input data.csv --output cleaned.json` |\n| \"See the docs for usage examples\" | A 5-line code block that does something real |\n| \"TODO: add example\" | (write the example) |\n\n### 8. No badge spam\n\nBadges are fine in moderation. AI READMEs pile on 8-12 badges (CI, coverage, npm, downloads, stars, license, contributions welcome, good-first-issues, Discord, Twitter). Use at most 3, and only if they carry real information:\n\n- License badge: yes (MIT/Apache/etc.)\n- CI badge: yes (if CI exists and passes)\n- Version badge: yes (if published to a registry)\n- Everything else: skip unless it adds real signal\n\n## Workflow\n\n### Step 1: Read the project\n\nBefore writing anything, read:\n- The project's `package.json`, `pyproject.toml`, or equivalent (name, description, dependencies)\n- The source code's entry point (what does it actually do?)\n- Any existing README (what needs fixing?)\n- The license file\n\n### Step 2: Identify the audience\n\nWho reads this README? A stranger on GitHub. They have 10 seconds to decide if this project is worth their time. The first 3 lines must tell them what it does and whether it's relevant.\n\n### Step 3: Write the first 3 lines\n\nTitle (the project name). One-sentence description (what it does, not what it is). One-sentence \"who is this for\" or \"what you get\".\n\n```markdown\n# tailscale-deploy\n\nDeploy a service on your Tailscale tailnet so it's privately accessible from any of your devices.\n\n## What it does\n\nThe agent deploys a web service onto your Tailscale tailnet. The service becomes reachable\nfrom your laptop, phone, and any other device on your tailnet. No public exposure, no port\nforwarding, no cloud relay.\n```\n\n### Step 4: Add install + one example\n\nInstall instructions. One concrete example. These go near the top — not buried after features.\n\n### Step 5: Add the rest\n\nOnly add sections that carry real information:\n- What it does (2-3 sentences)\n- Install (code block)\n- How to use (concrete example)\n- Prerequisites (if any)\n- What you get (table or list, if useful)\n- Limitations (always — what doesn't it do?)\n- License\n\nSkip sections that would be empty or filler. A README with 5 useful sections is better than one with 10 sections where 5 are filler.\n\n### Step 6: Run the slop check\n\nRead the README aloud. If any sentence sounds like it could appear in any other project's README, rewrite it. If any section could be deleted without losing information, delete it.\n\n## Common Pitfalls\n\n1. **Leading with badges, not content.** Badges are decorative. The first thing the reader sees should be what the project does, not a row of SVG badges.\n2. **Describing what it is, not what it does.** \"A Python library for...\" tells the reader nothing. \"You get a running service on your tailnet\" tells them what they get.\n3. **Overlong READMEs.** A README is not documentation. Link to docs for deep content. Keep the README scannable in under 2 minutes.\n4. **No example.** A README without a runnable example forces the reader to read the source to understand the project. Always include one.\n5. **Invented limitations.** Don't write \"None known\" in the limitations section. If you can't name a real limitation, you don't understand the project well enough to write the README.\n\n## Verification Checklist\n\n- [ ] No banned filler words (empower, seamless, leverage, robust, cutting-edge, comprehensive, powerful) appear anywhere in the README\n- [ ] Every number or metric in the README was supplied by the user or is independently verifiable — none invented\n- [ ] At least one concrete, runnable example (real command or code, not a placeholder) is present\n- [ ] A \"Limitations\" or equivalent section names at least one real limitation\n- [ ] The structure isn't the generic Title → Badges → Tagline → Features → Installation → Usage → Contributing → License template\n- [ ] Read aloud: no sentence could be copy-pasted unchanged into another project's README\n", "readme_content": "# hallmark-readme\n\nWrite a README that doesn't sound like an LLM generated it.\n\n## What it does\n\nThe agent reads your project and writes a README that follows anti-AI-slop rules: no filler phrases, no invented metrics, no templated structure, honest about limitations, concrete examples, real voice. The result reads like a human wrote it because it follows the patterns that distinguish human writing from LLM defaults.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/hallmark-readme/SKILL.md\n```\n\n## How to use\n\n```\n\"Write a README for my project\"\n```\n\nThe agent reads your project files, identifies the audience, and writes a README with:\n- A one-sentence description of what it does (not what it is)\n- Install instructions near the top\n- One concrete, runnable example\n- Real section headings (not generic \"Features\" / \"Getting Started\")\n- A limitations section naming what the project doesn't do\n- No filler, no invented metrics, no badge spam\n\n## What it checks for\n\n| AI tell | This skill |\n|---|---|\n| \"empower\", \"seamless\", \"leverage\", \"robust\" | Banned — deleted on sight |\n| \"Trusted by 50,000+ developers\" | Never invented — omitted if no real number exists |\n| Title → Badges → Features → Installation → Usage | Structure varies by project type, not templated |\n| No limitations section | Always includes real limitations |\n| \"This project enables developers to...\" | First person or direct second person, not corporate third |\n| `my-tool --input ` | Concrete examples with real values, not placeholders |\n\n## Example\n\n**Before (AI-generated):**\n\n```markdown\n# Awesome Tool\n\nA powerful, comprehensive tool that empowers developers to seamlessly leverage\ncutting-edge features. Built with love. Trusted by 50,000+ teams worldwide.\n\n## Features\n- Robust architecture\n- Seamless integration\n- Comprehensive documentation\n```\n\n**After (hallmark-readme):**\n\n```markdown\n# tailscale-deploy\n\nDeploy a service on your Tailscale tailnet so it's privately accessible from any of your devices.\n\n## What it does\n\nThe agent deploys a web service onto your Tailscale tailnet. The service becomes reachable\nfrom your laptop, phone, and any other device on your tailnet.\n\n## Install\n\n hermes skills install https://github.com/...\n\n## Example\n\n User: \"Deploy localhost:8080 on my tailnet\"\n Agent: runs tailscale serve --https 8080\n Result: https://my-machine.tailnet.ts.net\n\n## Limitations\n\n- Requires Tailscale installed and authenticated on both the host and the accessing device.\n- `tailscale serve` is tailnet-only; `tailscale funnel` exposes to the public internet.\n```\n\nThe second one sounds like a person wrote it because it follows the rules: no filler, no invented numbers, real structure, concrete example, honest limitations.\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/hallmark-readme/SKILL.md" }, { "name": "generate-dockerfile", "category": "backend", "tier": "core", "description": "Generate an optimized multi-stage Dockerfile for your detected project stack.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/generate-dockerfile/SKILL.md", "path": "skills/generate-dockerfile", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "generalized", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Generalized from the internal generate-dockerfile skill used in the agentsoul profile.", "license": "MIT", "derived": true }, "frontmatter": { "name": "generate-dockerfile", "description": "Generate an optimized multi-stage Dockerfile for a detected project stack.", "version": "1.0.0" }, "agent_use": "- The user says \"dockerize this\", \"write a Dockerfile\", or \"containerize my app\".\n- You are scaffolding a new project and it will be deployed as a container.\n- An existing project has no Dockerfile, or has one that copies source before\n dependencies, runs as root, or ships a single oversized stage.\n- You are setting up local development with multiple services (app + database +\n cache) and need a `docker-compose.yml`.", "user_use": "`generate-dockerfile` reads a project's manifests (`package.json`, `requirements.txt`, `go.mod`, `Cargo.toml`, `pyproject.toml`, `uv.lock`, …), figures out what runtime it uses, and writes a multi-stage `Dockerfile` plus the files that make the build safe and reproducible: `.dockerignore`, `.env.example`, and a `docker-compose.yml` when the service has dependencies. The agent runs the detection and writes the files; the skill supplies the rules and the templates. It covers Python (pip/poetry/uv), Node.js (npm/yarn/pnpm), Go, Rust, and static sites.", "skillmd_content": "---\nname: generate-dockerfile\ndescription: \"Use when the user asks to dockerize, containerize, or write a Dockerfile for a project, or an existing project has no Dockerfile — or has one that copies source before dependencies, runs as root, or ships a single oversized stage.\"\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [dockerfile, multi-stage-build, docker-compose, containerization, non-root-user]\n related_skills: [docker-umbrella, env-config-manager, github-actions-ci]\n---\n\n# generate-dockerfile\n\n## Overview\n\nRead a project, detect its stack, and emit a multi-stage Dockerfile plus the\nsupporting files (`.dockerignore`, `.env.example`, `docker-compose.yml`) needed\nfor a reproducible, small, non-root container build. The agent writes the files;\nthis skill carries the detection rules and the templates.\n\n## When to Use\n- The user says \"dockerize this\", \"write a Dockerfile\", or \"containerize my app\".\n- You are scaffolding a new project and it will be deployed as a container.\n- An existing project has no Dockerfile, or has one that copies source before\n dependencies, runs as root, or ships a single oversized stage.\n- You are setting up local development with multiple services (app + database +\n cache) and need a `docker-compose.yml`.\n\n## Workflow\n1. **Inventory the project root.** List files and read the manifest(s):\n `package.json`, `requirements.txt`, `pyproject.toml`, `Pipfile`, `go.mod`,\n `Cargo.toml`, `uv.lock`, `yarn.lock`, `pnpm-lock.yaml`, `index.html`.\n2. **Detect the stack** using the signals in the table below. One project can\n mix stacks (e.g. Node frontend + Python API) — generate one Dockerfile per\n deployable service, not one mega-image.\n3. **Pick the matching template** and adjust versions to the constraints you\n found (Node `engines`, Python version in `pyproject.toml`, Go toolchain).\n4. **Write `Dockerfile`** to the service root. Always write `.dockerignore`\n alongside it — without it the build context leaks `.env`, `node_modules`,\n and `.git` into the image.\n5. **Emit `.env.example`** if the code reads env vars (database URLs, API keys,\n ports). Never write real secrets into the image.\n6. **Emit `docker-compose.yml`** when the service has dependencies (Postgres,\n Redis, a sibling API). Skip it for a standalone static site.\n7. **Verify** by running `docker build` (or `docker compose build`) if Docker is\n available and the user wants validation. Otherwise hand back the files with\n the exact build command to run.\n\n## Stack Detection\n\n| Stack | Primary signal(s) | Package manager |\n|--------------|-----------------------------------------------------|------------------------|\n| Node.js | `package.json` | npm / yarn / pnpm |\n| Python (pip) | `requirements.txt` | pip |\n| Python (poetry) | `pyproject.toml` with `[tool.poetry]` | poetry |\n| Python (uv) | `pyproject.toml` + `uv.lock` | uv |\n| Go | `go.mod` | go modules |\n| Rust | `Cargo.toml` + `Cargo.lock` | cargo |\n| Static site | `index.html` / SPA build output, no backend runtime | (none — nginx serves) |\n\nDetection priority when multiple exist: a backend runtime manifest\n(`go.mod`, `Cargo.toml`, `pyproject.toml`, `requirements.txt`) wins over a\nfrontend `package.json`. A `package.json` with only a `build` script and no\nserver start script is a static-site build step, not a Node service.\n\n## Dockerfile Templates\n\n### Python — pip (primary)\n```dockerfile\nFROM python:3.12-slim AS builder\nWORKDIR /app\nENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1\nCOPY requirements.txt ./\nRUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt\n\nFROM python:3.12-slim AS runtime\nWORKDIR /app\nRUN groupadd --system app && useradd --system --gid app --home /app app\nCOPY --from=builder /wheels /wheels\nCOPY requirements.txt ./\nRUN pip install --no-cache-dir --no-index --find-links /wheels -r requirements.txt \\\n && rm -rf /wheels\nCOPY --chown=app:app . /app\nUSER app\nEXPOSE 8000\nHEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \\\n CMD python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')\" || exit 1\nCMD [\"uvicorn\", \"app.main:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8000\"]\n```\nPoetry / uv differ only in how the dependency set is produced before the\ninstall. Replace the `builder` stage's copy+install with:\n- **Poetry:** `RUN pip install poetry` → `COPY pyproject.toml poetry.lock ./` →\n `RUN poetry config virtualenvs.create false && poetry export -f requirements.txt --without-hashes -o requirements.txt` → then the same `pip wheel` step.\n- **uv:** `RUN pip install uv` → `COPY pyproject.toml uv.lock* ./` →\n `RUN uv pip compile -o requirements.txt pyproject.toml || true` → then the same `pip wheel` step. (If you install straight into a venv target instead, drop the wheel stage.)\n\n### Node.js — npm (primary)\n```dockerfile\nFROM node:20-alpine AS builder\nWORKDIR /app\nCOPY package.json package-lock.json ./\nRUN npm ci --ignore-scripts\nCOPY . .\nRUN npm run build\n\nFROM node:20-alpine AS runtime\nWORKDIR /app\nENV NODE_ENV=production\nRUN addgroup -S app && adduser -S app -G app\nCOPY package.json package-lock.json ./\nRUN npm ci --omit=dev --ignore-scripts\nCOPY --from=builder /app/dist ./dist\nCOPY --from=builder /app/public ./public\nUSER app\nEXPOSE 3000\nHEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \\\n CMD wget -q -O - http://127.0.0.1:3000/health || exit 1\nCMD [\"node\", \"dist/index.js\"]\n```\nPackage-manager variants change the manifest copy and the install command:\n\n| Manager | Manifest(s) copied | Install command (builder / runtime) |\n|---------|---------------------------|--------------------------------------------------------------|\n| npm | `package.json package-lock.json` | `npm ci --ignore-scripts` / `npm ci --omit=dev --ignore-scripts` |\n| yarn | `package.json yarn.lock` | `yarn install --frozen-lockfile --ignore-scripts` / `--production` |\n| pnpm | `package.json pnpm-lock.yaml` | `pnpm install --frozen-lockfile --ignore-scripts` / `--prod` (needs `corepack enable`) |\n\n### Go (static binary)\n```dockerfile\nFROM golang:1.22-alpine AS builder\nWORKDIR /src\nRUN apk add --no-cache git\nCOPY go.mod go.sum ./\nRUN go mod download\nCOPY . .\nRUN CGO_ENABLED=0 GOOS=linux go build -o /app/bin ./cmd/server\n\nFROM gcr.io/distroless/static-debian12\nCOPY --from=builder /app/bin /app/bin\nUSER nonroot:nonroot\nEXPOSE 8080\nENTRYPOINT [\"/app/bin\"]\n```\nFor a `HEALTHCHECK` under distroless you need a binary in the image, so either\nadd a small health subcommand to the server and call it from `HEALTHCHECK`, or\ndrop the `HEALTHCHECK` here and rely on the compose-level check.\n\n### Rust (static binary)\n```dockerfile\nFROM rust:1.78 AS builder\nWORKDIR /app\n# Build dependencies first: the dummy crate lets Cargo cache the dep layer.\nCOPY Cargo.toml Cargo.lock ./\nRUN mkdir src && echo \"fn main() {}\" > src/main.rs \\\n && cargo build --release \\\n && rm -rf src\nCOPY . .\nRUN cargo build --release\n\nFROM gcr.io/distroless/cc-debian12\nCOPY --from=builder /app/target/release/app /app/app\nUSER nonroot:nonroot\nEXPOSE 8080\nENTRYPOINT [\"/app/app\"]\n```\n\n### Static site (SPA built with Node, served by nginx)\n```dockerfile\nFROM node:20-alpine AS builder\nWORKDIR /app\nCOPY package.json package-lock.json ./\nRUN npm ci --ignore-scripts\nCOPY . .\nRUN npm run build\n\nFROM nginx:1.27-alpine\nCOPY --from=builder /app/dist /usr/share/nginx/html\nCOPY nginx.conf /etc/nginx/conf.d/default.conf\nEXPOSE 80\nHEALTHCHECK --interval=30s --timeout=5s --retries=3 \\\n CMD wget -q -O - http://127.0.0.1/ || exit 1\n```\nFor client-side routing (React/Vue history mode) the `nginx.conf` needs an\ninternal fallback to `index.html`:\n```nginx\nserver {\n listen 80;\n root /usr/share/nginx/html;\n location / { try_files $uri $uri/ /index.html; }\n}\n```\n\n## Supporting Files\n\n### `.dockerignore`\nAlways write this. It keeps build context small and stops `.env` from landing\nin the image.\n```\n.git\n.gitignore\nnode_modules\n__pycache__\n*.pyc\n.venv\nvenv\ndist\nbuild\ntarget\n.env\n.env.*\nDockerfile\ndocker-compose.yml\n.dockerignore\n*.md\n.vscode\n.idea\n.DS_Store\n```\n\n### `.env.example`\nWrite this only if the code reads environment variables. Keep it secret-free.\n```\n# Copy to .env and fill in real values — never commit the filled-in .env.\nDATABASE_URL=postgres://app:app@db:5432/app\nREDIS_URL=redis://cache:6379\nPORT=8000\nLOG_LEVEL=info\n```\n\n### `docker-compose.yml`\nWrite this when the service has dependencies. The `depends_on` + healthcheck\npattern avoids \"container started but DB not ready\" races.\n```yaml\nservices:\n app:\n build:\n context: .\n dockerfile: Dockerfile\n ports:\n - \"8000:8000\"\n env_file:\n - .env\n depends_on:\n db:\n condition: service_healthy\n restart: unless-stopped\n healthcheck:\n test: [\"CMD\", \"wget\", \"-q\", \"-O\", \"-\", \"http://127.0.0.1:8000/health\"]\n interval: 30s\n timeout: 5s\n retries: 3\n start_period: 10s\n db:\n image: postgres:16-alpine\n environment:\n POSTGRES_USER: app\n POSTGRES_PASSWORD: app\n POSTGRES_DB: app\n volumes:\n - db_data:/var/lib/postgresql/data\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U app\"]\n interval: 10s\n timeout: 5s\n retries: 5\n restart: unless-stopped\nvolumes:\n db_data:\n```\n\n## Health Checks\n- Point the `HEALTHCHECK` at a real endpoint the app actually serves\n (`/health`, `/ready`). A `wget` against `/` that returns 200 for any HTML page\n hides a broken backend.\n- `wget` and `curl` are not present in `distroless` or `slim` Python by default.\n The Python template uses `python -c` for the check; the Node/alpine and nginx\n templates use `wget` (present in `-alpine`).\n\n## Non-root User\n- Alpine: `addgroup -S app && adduser -S app -G app` then `USER app`.\n- Debian-slim: `groupadd --system app && useradd --system --gid app --home /app app`.\n- distroless: the `nonroot` user already exists — just `USER nonroot:nonroot`.\n- When you `COPY` source after creating the user, pass `--chown=app:app` (or the\n distroless `nonroot`) so the runtime user can read it.\n\n## Common Pitfalls\n1. **`npm install` instead of `npm ci`.** `npm ci` requires a lockfile and\n installs exactly what it pins — reproducible. `npm install` can drift. Use\n `npm ci`, `yarn install --frozen-lockfile`, or `pnpm install --frozen-lockfile`.\n2. **Lifecycle scripts.** Plain `npm ci` runs `postinstall` scripts from\n dependencies. Default to `--ignore-scripts` and only enable it for a specific\n package you have inspected.\n3. **Copying source before dependencies.** If `COPY . .` comes before the dep\n install, every source edit invalidates the dependency layer and re-downloads\n everything. Copy the manifest, install, then copy source.\n4. **Missing `.dockerignore`.** Without it, `node_modules`, `.git`, and `.env`\n get sent as build context and can be baked into the image.\n5. **Running as root.** The default container user is root; add a non-root user\n in the runtime stage.\n6. **`--latest` base tags.** Pin (`python:3.12-slim`, `node:20-alpine`). Unpinned\n builds break unpredictably when upstream moves.\n7. **SPA `__dirname` layout mismatch.** A Node server that resolves a sibling\n `frontend/` via `path.join(__dirname, '..', 'frontend')` breaks when the\n Dockerfile copies the backend to `/app` and the frontend elsewhere — every\n static asset 404s while `/api/*` still works. Copy the frontend into the same\n directory the server expects and have the resolver try both candidate paths.\n8. **Host loopback on some setups.** When testing from the host, prefer\n `http://127.0.0.1:` over `localhost` — on some systems `localhost`\n resolves to IPv6 `::1` first and the request hangs even though the container\n is healthy. Confirm the app is alive from inside the container\n (`docker compose exec app wget -q -O - http://127.0.0.1:8000/health`) before\n blaming the code.\n\n## Verification Checklist\n- [ ] `docker build -t .` (or `docker compose build`) succeeds and the final image is small\n- [ ] Container runs and the health endpoint responds from inside it\n- [ ] `docker run --rm id` reports a non-zero uid (not running as root)\n- [ ] `docker run --rm ls -la` (or a build-context review) confirms no `.env` landed in the image\n", "readme_content": "# generate-dockerfile\n\nDetect a project's stack and generate an optimized multi-stage Dockerfile with the supporting files for a reproducible, non-root build.\n\n## What it does\n\n`generate-dockerfile` reads a project's manifests (`package.json`, `requirements.txt`, `go.mod`, `Cargo.toml`, `pyproject.toml`, `uv.lock`, …), figures out what runtime it uses, and writes a multi-stage `Dockerfile` plus the files that make the build safe and reproducible: `.dockerignore`, `.env.example`, and a `docker-compose.yml` when the service has dependencies. The agent runs the detection and writes the files; the skill supplies the rules and the templates. It covers Python (pip/poetry/uv), Node.js (npm/yarn/pnpm), Go, Rust, and static sites.\n\n## Install\n\nThis skill ships in the Hermes skills portfolio. Install it with:\n\n```bash\nhermes skills install generate-dockerfile\n```\n\nIf you are vendoring it into your own profile, copy the `skills/generate-dockerfile` directory (the `SKILL.md`) into your Hermes skills folder.\n\n## How to use\n\nAsk Hermes to dockerize a project, or trigger it during scaffolding:\n\n```\ndockerize this project\n```\n\n```\nwrite a Dockerfile for ./api\n```\n\n```\ncontainerize my Go service and add a compose file with Postgres\n```\n\nThe agent will:\n\n1. Read the project root and identify the manifest(s).\n2. Detect the stack from the signals in the supported-stacks table.\n3. Write `Dockerfile`, `.dockerignore`, and (when relevant) `.env.example` and `docker-compose.yml`.\n4. Optionally run `docker build` to confirm it works.\n\nA mixed project (a Node frontend and a Python API, for example) produces one Dockerfile per deployable service, not a single combined image.\n\n## Supported stacks\n\n| Stack | Detected from | Package manager |\n|-----------------|----------------------------------------|----------------------|\n| Node.js | `package.json` | npm / yarn / pnpm |\n| Python (pip) | `requirements.txt` | pip |\n| Python (poetry) | `pyproject.toml` with `[tool.poetry]` | poetry |\n| Python (uv) | `pyproject.toml` + `uv.lock` | uv |\n| Go | `go.mod` | go modules |\n| Rust | `Cargo.toml` + `Cargo.lock` | cargo |\n| Static site | `index.html` / SPA build output | none (nginx serves) |\n\n## Example\n\nGiven a FastAPI project with `requirements.txt`:\n\n```\nmyapi/\n app/\n main.py\n requirements.txt\n```\n\nRunning `generate-dockerfile` produces:\n\n- A two-stage `Dockerfile` that builds wheels in a `builder` stage, installs them into a clean `python:3.12-slim` runtime stage, runs as a non-root `app` user, and exposes a `HEALTHCHECK` against `/health`.\n- A `.dockerignore` that keeps `node_modules`, `.git`, `.env`, and build output out of the image.\n- A `.env.example` listing `DATABASE_URL`, `PORT`, and `LOG_LEVEL` (no secrets).\n- A `docker-compose.yml` wiring the API to a `postgres:16-alpine` container with a health-gated `depends_on`, so the app only starts once the database is ready.\n\nYou then build and run it:\n\n```bash\ndocker compose up --build\n```\n\nThe full templates for every stack live in `SKILL.md`.\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/generate-dockerfile/SKILL.md" }, { "name": "forgejo-self-host", "category": "devops", "tier": "core", "description": "Set up a self-hosted Forgejo Git server with Docker — repos, issues, pull requests, and Actions CI.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/forgejo-self-host/SKILL.md", "path": "skills/forgejo-self-host", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "generalized", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Generalized from the internal soul-forgejo-access and forgejo skills used in the agentsoul profile.", "license": "MIT", "derived": true }, "frontmatter": { "name": "forgejo-self-host", "description": "Set up a self-hosted Forgejo instance with Docker.", "version": "1.0.0" }, "agent_use": "- The user wants their own private Git server instead of GitHub/GitLab.\n- The user wants to host repos locally for development or backup.\n- The user wants CI/CD without relying on external services.\n- The user says \"set up Forgejo\", \"self-host my git\", or \"I want a local GitHub\".", "user_use": "The agent deploys a Forgejo instance using Docker Compose, creates an admin account, sets up an access token, and shows you how to push code. Forgejo is a lightweight self-hosted Git server (soft fork of Gitea) with a web UI, issues, pull requests, wiki, and Actions CI. It runs on your machine and holds your repos privately.", "skillmd_content": "---\nname: forgejo-self-host\ndescription: \"Use when the user wants a self-hosted, private Git server (repos, issues, pull requests, CI, wiki) running locally via Docker instead of relying on GitHub or GitLab.\"\nversion: 1.1.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [forgejo, self-hosted-git, docker-compose, ci-cd, gitea-fork]\n related_skills: [caddy-reverse-proxy, git-backup, github-actions-ci]\n---\n\n# forgejo-self-host\n\n## Overview\n\nDeploy a self-hosted Forgejo instance using Docker. Forgejo is a lightweight,\nself-hosted Git server (a soft fork of Gitea) with repos, issues, pull requests,\nActions CI, and a wiki.\n\nThe whole skill turns on one decision: **configure the instance from environment\nvariables and skip the web install wizard**, rather than booting a blank\ncontainer and clicking through it. A container booted without that configuration\ncomes up in *install mode*, where the entire HTTP API is unreachable and the\nadmin CLI refuses to run — which is the failure most first-time setups hit.\n\n## When to Use\n\n- The user wants their own private Git server instead of GitHub/GitLab.\n- The user wants to host repos locally for development or backup.\n- The user wants CI/CD without relying on external services.\n- The user says \"set up Forgejo\", \"self-host my git\", or \"I want a local GitHub\".\n\n## Prerequisites\n\n- **Docker Engine + Compose v2.** Check with `docker --version` and\n `docker compose version` (the space-separated v2 form, not `docker-compose`).\n- **On Windows: Docker Desktop with the WSL2 backend.** The Linux-host tricks in\n most Forgejo guides (`/etc/timezone` bind mounts, `$(pwd)` in `docker run`) do\n not work there — see Pitfalls 2 and 9.\n- **Two free host ports** — one for HTTP, one for SSH. Defaults below are `3000`\n and `2222`. Port `3000` collides with a lot of things (Grafana, Next.js, Rails,\n many dev servers), so check first:\n - Linux/macOS: `ss -ltnp | grep -E ':(3000|2222)\\b'`\n - Windows: `netstat -an | findstr /R /C:\":3000 \" /C:\":2222 \"`\n If either is taken, change the **left** side of the port mapping and change\n `ROOT_URL` / `SSH_PORT` to match — see Pitfall 4.\n- **A named Docker volume, not a host bind mount**, for `/data`. Forgejo runs as\n uid 1000 inside the container; a Windows or macOS bind mount will not carry\n that ownership and the container fails to write its repositories.\n\n## Workflow\n\n### Step 1: Create the Forgejo deployment\n\nCreate a directory and a `docker-compose.yml`:\n\n```yaml\nname: forgejo\n\nservices:\n forgejo:\n # Forgejo publishes NO `latest` tag — pin a major line.\n image: codeberg.org/forgejo/forgejo:15\n container_name: forgejo\n restart: unless-stopped\n environment:\n USER_UID: \"1000\"\n USER_GID: \"1000\"\n TZ: \"UTC\"\n # Skip the web install wizard. Without this the container boots into\n # install mode and the API + admin CLI are both unusable.\n FORGEJO__security__INSTALL_LOCK: \"true\"\n FORGEJO__database__DB_TYPE: sqlite3\n FORGEJO__database__PATH: /data/gitea/gitea.db\n # ROOT_URL must be the URL a BROWSER uses. Every generated link,\n # redirect, clone URL and OAuth callback is built from it.\n FORGEJO__server__DOMAIN: localhost\n FORGEJO__server__ROOT_URL: http://localhost:3000/\n # SSH_PORT is the PUBLISHED port (what users connect to).\n # SSH_LISTEN_PORT is the IN-CONTAINER port. They differ on purpose.\n FORGEJO__server__SSH_DOMAIN: localhost\n FORGEJO__server__SSH_PORT: \"2222\"\n FORGEJO__server__SSH_LISTEN_PORT: \"22\"\n volumes:\n - forgejo-data:/data\n ports:\n - \"3000:3000\"\n - \"2222:22\"\n\nvolumes:\n forgejo-data:\n```\n\nDo **not** add a `version:` key — Compose v2 warns `the attribute 'version' is\nobsolete, it will be ignored`. Do **not** bind-mount `/etc/timezone` or\n`/etc/localtime` (Pitfall 2); the `TZ` variable above replaces both portably.\n\nValidate before starting — this parses the file and starts nothing:\n\n```bash\ndocker compose -f docker-compose.yml config\n```\n\n### Step 2: Start Forgejo\n\n```bash\ndocker compose up -d\n```\n\nFirst boot takes roughly 10-30 seconds to create the SQLite schema. Wait for the\nhealth endpoint rather than guessing:\n\n```bash\n# /api/healthz needs no auth and works even on a private instance\ncurl -s http://localhost:3000/api/healthz\n```\n\nA configured instance returns `\"status\": \"pass\"` with a `database:ping` check.\n\nNow confirm it is **not** stuck in install mode — this is the check that matters:\n\n```bash\ncurl -s http://localhost:3000/ | grep -qi 'Installation' \\\n && echo \"STILL IN INSTALL MODE — INSTALL_LOCK not applied\" \\\n || echo \"installed OK\"\n```\n\n`/api/healthz` returns `200` *even in install mode*, so it proves the process is\nalive, not that setup succeeded. The title check is what distinguishes them.\n\n### Step 3: Create the admin user and its API token\n\nWith `INSTALL_LOCK=true` there is no wizard and no \"first registered user becomes\nadmin\" step — you create the admin directly. One command creates the user *and*\nprints an API token:\n\n```bash\ndocker exec -u git forgejo forgejo admin user create \\\n --username <admin-name> \\\n --email <admin-email> \\\n --password '<password>' \\\n --admin \\\n --must-change-password=false \\\n --access-token \\\n --access-token-name cli \\\n --access-token-scopes \"write:repository,write:issue,write:user\"\n```\n\nIt prints `New user '<admin-name>' has been successfully created!` followed by\n`Access token was successfully created... <token>`. **Store that token now** — it\nis not shown again.\n\n`-u git` is required: the Forgejo process and everything under `/data` are owned\nby the `git` user, and the CLI must run as that user.\n\nNeed another token later:\n\n```bash\ndocker exec -u git forgejo forgejo admin user generate-access-token \\\n --username <admin-name> --token-name ci \\\n --scopes \"write:repository,write:issue,write:user\"\n```\n\n**Scope note:** `POST /api/v1/user/repos` (Step 4) requires **`write:user`**, not\n`write:repository`. A token scoped only to repositories is rejected with\n`token does not have at least one of required scope(s): [write:user]`.\n\n### Step 4: Create your first repo\n\nVia web UI: log in at `http://localhost:3000`, click \"+\" → New Repository.\n\nVia API — returns `201 Created`:\n\n```bash\ncurl -s -o /dev/null -w '%{http_code}\\n' \\\n -X POST \"http://localhost:3000/api/v1/user/repos\" \\\n -H \"Authorization: token <token>\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\":\"my-project\",\"description\":\"My project\",\"private\":false,\"auto_init\":true}'\n```\n\n### Step 5: Push code to Forgejo\n\n```bash\ncd my-project\ngit init\ngit add -A\ngit commit -m \"Initial commit\"\n\n# HTTPS with a token\ngit remote add forgejo http://<admin-name>:<token>@localhost:3000/<admin-name>/my-project.git\ngit push -u forgejo main\n```\n\nEmbedding the token in the remote URL writes it to `.git/config` in plaintext.\nFor anything long-lived use a credential helper or the SSH remote below instead.\n\n### Step 6: (Optional) Set up CI with Forgejo Actions\n\nGet a registration token from Site Administration → Actions → Runners → Register\nNew Runner.\n\nRegister once (a one-shot container that exits), then run the daemon:\n\n```bash\n# 1. Register — this writes .runner into the volume, then exits.\ndocker run --rm \\\n -v forgejo-runner-data:/data \\\n -w /data \\\n codeberg.org/forgejo/runner:latest \\\n forgejo-runner register --no-interactive \\\n --instance http://host.docker.internal:3000 \\\n --token <runner-registration-token> \\\n --name local-runner\n\n# 2. Run the daemon — this is the long-lived container.\ndocker run -d --name forgejo-runner --restart unless-stopped \\\n -v /var/run/docker.sock:/var/run/docker.sock \\\n -v forgejo-runner-data:/data \\\n -w /data \\\n --add-host host.docker.internal:host-gateway \\\n codeberg.org/forgejo/runner:latest \\\n forgejo-runner daemon\n```\n\n`register` and `daemon` are two different commands. Running `register` with\n`-d` and then `docker start` re-runs *registration* every boot and never starts a\ndaemon — the runner will never appear as online.\n\nThen add `.forgejo/workflows/ci.yml` to a repo:\n\n```yaml\nname: CI\non: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - run: echo \"CI is running\"\n```\n\n## SSH Git Access\n\nThe container listens on `22` internally and is published on `2222`. Because\nStep 1 sets `SSH_PORT: \"2222\"`, the UI and API hand out clone URLs carrying the\npublished port:\n\n```\nssh://git@localhost:2222/<admin-name>/my-project.git\n```\n\n```bash\ngit remote add forgejo ssh://git@localhost:2222/<admin-name>/my-project.git\n```\n\nAdd your public key first under Settings → SSH / GPG Keys, or the push fails with\n`Permission denied (publickey)`.\n\nOptional `~/.ssh/config` entry — scope it to a dedicated alias rather than\n`Host localhost`, which would hijack every other SSH connection to localhost:\n\n```\nHost forgejo-local\n HostName localhost\n Port 2222\n User git\n```\n\nThen the remote becomes `forgejo-local:<admin-name>/my-project.git`.\n\n## Backup\n\nUse Forgejo's own dump command. It quiesces the database and produces a single\nconsistent archive — `tar`-ing a live SQLite file can capture a torn write:\n\n```bash\ndocker exec -u git forgejo forgejo dump --type tar.gz --file /data/backup.tar.gz\ndocker cp forgejo:/data/backup.tar.gz ./forgejo-backup-$(date +%Y%m%d).tar.gz\ndocker exec -u git forgejo rm /data/backup.tar.gz\n```\n\nIf you must snapshot the raw volume instead, stop the container first, and note\nthe Windows path caveat in Pitfall 9:\n\n```bash\ndocker compose stop forgejo\nMSYS_NO_PATHCONV=1 docker run --rm -v forgejo-data:/data -v \"$(pwd)\":/backup \\\n alpine tar czf /backup/forgejo-volume-$(date +%Y%m%d).tar.gz /data\ndocker compose start forgejo\n```\n\n## Common Pitfalls\n\n1. **`:latest` does not exist.** `codeberg.org/forgejo/forgejo:latest` fails the\n pull with `manifest unknown` — Forgejo publishes no `latest` tag. Use a major\n line (`:15`) or an exact version (`:15.0.3`). Pinning also matters because a\n major upgrade migrates the database irreversibly; there is no downgrade path\n once it runs.\n2. **Do not bind-mount `/etc/timezone` and `/etc/localtime`.** Nearly every\n Forgejo guide copies these two lines from a Linux-host example. On Windows the\n daemon fails the container with\n `Error response from daemon: mkdir C:\\Program Files\\Git\\etc\\timezone: Access is denied`,\n because there is no such host path. Set `TZ` instead — it works on every host.\n3. **Boot without `INSTALL_LOCK` and the instance is inert.** The container\n serves the *install wizard at `/`* (not at `/install`, which 404s), and every\n other route is swallowed by the installer: `/api/v1/version` returns a `404`\n HTML page, and `forgejo admin user create` aborts with\n `[F] Unable to load config file for a installed Forgejo instance`. Setting\n `FORGEJO__security__INSTALL_LOCK=true` plus the database settings skips it.\n4. **`ROOT_URL` is the single most common misconfiguration.** It is not\n cosmetic — redirects after login, asset URLs, clone URLs, webhook payloads and\n OAuth callbacks are all generated from it. If you remap the host port to\n `3001`, or front the instance with a reverse proxy at\n `https://git.example.com/`, `ROOT_URL` must be changed to exactly that\n browser-facing URL. Left at `http://localhost:3000/` behind a proxy, users log\n in and get bounced to `localhost:3000`, and assets fail to load.\n5. **`SSH_PORT` vs `SSH_LISTEN_PORT`.** `SSH_LISTEN_PORT` is where sshd listens\n inside the container (`22`); `SSH_PORT` is the port that goes into the clone\n URLs the UI shows. Publishing `2222:22` while leaving `SSH_PORT` at its `22`\n default makes the UI advertise `git@host:<repo>.git`, which clients try on\n port 22 and fail. Set `SSH_PORT` to the published port.\n6. **Anonymous API access may be disabled.** On an instance with\n `REQUIRE_SIGNIN_VIEW = true` (the common choice for a private server),\n `curl http://localhost:3000/api/v1/version` returns\n `403 {\"message\":\"Only signed in user is allowed to call APIs.\"}` — the server\n is perfectly healthy. Use `/api/healthz` for unauthenticated liveness, or send\n the token with the request.\n7. **SQLite is fine for one person; switch on concurrency, not size.** SQLite\n holds a single writer lock, so concurrent CI runners plus web users produce\n `database is locked` errors. Move to PostgreSQL when you add runners or users,\n by swapping the `FORGEJO__database__*` variables — but do it on a fresh\n instance or via `forgejo dump`/restore; changing `DB_TYPE` on a running\n instance does not migrate existing data.\n8. **Runner can't reach the instance.** Inside the runner container `localhost`\n is the runner itself. Use `http://host.docker.internal:3000` (adding\n `--add-host host.docker.internal:host-gateway` on Linux Engine), or put both\n containers on one Compose network and use the service name.\n9. **Windows/Git Bash mangles POSIX paths in `docker` arguments.** Any argument\n starting with `/` is rewritten to a Windows path by MSYS, so\n `-v $(pwd):/backup` silently mounts somewhere unexpected and the backup file\n never appears where you asked for it. Prefix the command with\n `MSYS_NO_PATHCONV=1`, or write the source as `\"/$(pwd)\"` with a leading slash.\n PowerShell and Linux/macOS shells are unaffected.\n10. **`--must-change-password` defaults to forcing a reset.** An admin created\n without `--must-change-password=false` must change its password at first web\n login, and API calls before that can be rejected. Pass the flag explicitly.\n11. **Port 3000 collides constantly.** If you remap to `3001:3000`, change\n `ROOT_URL` to `http://localhost:3001/` in the same edit — remapping the port\n alone leaves every generated link pointing at 3000.\n\n## Verification Checklist\n\n- [ ] `docker compose -f docker-compose.yml config` parses with no `version` obsolete warning\n- [ ] `docker compose ps` shows the container `running`\n- [ ] `curl -s http://localhost:3000/api/healthz` reports `\"status\": \"pass\"` including `database:ping`\n- [ ] `curl -s http://localhost:3000/ | grep -i '<title>Installation'` returns **nothing** — the instance is past the install wizard, not merely alive\n- [ ] `forgejo admin user create` printed both the user confirmation and an access token\n- [ ] The token creates a repo: `POST /api/v1/user/repos` returns `201` (not `403 ... [write:user]`)\n- [ ] `GET /api/v1/repos/<user>/<repo>` shows a `clone_url` and an `ssh_url` whose host and port match how you actually reach the server — not `localhost:3000` when you browse it at another address\n- [ ] Admin login succeeds in a real browser, and a page reload keeps you logged in (a wrong `ROOT_URL` shows up here as a redirect bounce)\n- [ ] A test repo pushes over the configured remote (HTTPS token or SSH on the published port)\n- [ ] If Actions was configured: the runner shows **online** under Site Administration → Actions → Runners, and a sample workflow run succeeds\n", "readme_content": "# forgejo-self-host\n\nSet up a self-hosted Forgejo Git server with Docker — repos, issues, pull requests, and CI.\n\n## What it does\n\nThe agent deploys a Forgejo instance using Docker Compose, creates an admin account and an API token, and shows you how to push code. Forgejo is a lightweight self-hosted Git server (soft fork of Gitea) with a web UI, issues, pull requests, wiki, and Actions CI. It runs on your machine and holds your repos privately.\n\nThe skill configures the instance entirely from environment variables so it comes up ready to use. A Forgejo container started without that configuration boots into its **web install wizard**, and in that state the HTTP API returns 404 and the admin CLI refuses to run — the setup looks healthy while nothing works. Avoiding that is most of what this skill does.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/forgejo-self-host/SKILL.md\n```\n\n## How to use\n\n```\n\"Set up Forgejo on my machine\"\n```\n\nThe agent:\n1. Generates a `docker-compose.yml` for Forgejo + SQLite, with the install wizard pre-locked\n2. Validates it with `docker compose config`, then runs `docker compose up -d`\n3. Verifies the instance is past install mode — not merely responding\n4. Creates an admin user and an API token in one command\n5. Shows you how to create repos and push code\n\n## Prerequisites\n\n- Docker Engine + Compose v2 (`docker compose version`)\n- On Windows: Docker Desktop with the WSL2 backend\n- Two free host ports (defaults: `3000` web, `2222` SSH) — `3000` collides often, check it first\n- A named Docker volume for `/data` (not a host bind mount — Forgejo runs as uid 1000)\n\n## What you get\n\n| Component | Default | Notes |\n|---|---|---|\n| Web UI | `http://localhost:3000` | Repos, issues, PRs, wiki, settings |\n| SSH git | `localhost:2222` | Published port; `SSH_PORT` must match it or clone URLs are wrong |\n| API | `http://localhost:3000/api/v1` | Token-authenticated; may 403 anonymously on a private instance |\n| Health | `http://localhost:3000/api/healthz` | No auth required — but returns 200 in install mode too |\n| CI | Forgejo Actions | Optional, needs a runner container |\n| Storage | Docker volume `forgejo-data` | SQLite by default; PostgreSQL once you add concurrency |\n\n## Image tags\n\nForgejo publishes **no `latest` tag** — `codeberg.org/forgejo/forgejo:latest` fails to pull with `manifest unknown`. Pin a major line (`:15`) or an exact version (`:15.0.3`). Major upgrades migrate the database irreversibly, so pinning is not just tidiness.\n\n## Three settings that decide whether it works\n\n| Variable | Why it matters |\n|---|---|\n| `FORGEJO__security__INSTALL_LOCK` | `true` skips the web install wizard. Without it the container serves the installer at `/`, `/api/v1/version` 404s, and the admin CLI aborts with \"Unable to load config file for a installed Forgejo instance\". |\n| `FORGEJO__server__ROOT_URL` | Every generated link, login redirect, clone URL and webhook payload is built from it. Must be the URL a **browser** uses. Behind a reverse proxy or on a remapped port, leaving it at `http://localhost:3000/` bounces users to a dead address after login. |\n| `FORGEJO__server__SSH_PORT` | The port written into the SSH clone URLs the UI shows. It is the **published** port (`2222`), not the in-container listen port (`SSH_LISTEN_PORT`, `22`). Mismatched, users copy a clone URL that tries port 22 and fails. |\n\n## Example\n\n```\nUser: \"I want a local Git server for my projects.\"\n\nAgent:\n 1. Writes docker-compose.yml (Forgejo + SQLite, INSTALL_LOCK set, ROOT_URL and SSH_PORT matched to the published ports)\n 2. docker compose config → validates, then docker compose up -d\n 3. Confirms /api/healthz reports \"status\": \"pass\" AND that / is not the install wizard\n 4. docker exec -u git forgejo forgejo admin user create ... --access-token\n → creates the admin and prints its API token in one step\n 5. Creates the first repo: POST /api/v1/user/repos → 201\n 6. Returns: \"Forgejo is running at http://localhost:3000. Your first repo is at\n http://localhost:3000/your-user/my-project\"\n\nUser pushes code:\n git remote add forgejo http://your-user:token@localhost:3000/your-user/my-project.git\n git push -u forgejo main\n```\n\n## Verifying it actually worked\n\n`curl http://localhost:3000/api/healthz` returning `200` is **not** proof of a working install — it returns `200` while the instance is still sitting on the install wizard. The distinguishing check:\n\n```bash\ncurl -s http://localhost:3000/ | grep -qi '<title>Installation' \\\n && echo \"STILL IN INSTALL MODE\" || echo \"installed OK\"\n```\n\nFull checklist is in `SKILL.md`.\n\n## Windows note\n\nGit Bash rewrites any `docker` argument starting with `/` into a Windows path. `-v $(pwd):/backup` therefore mounts somewhere unintended and backups silently land nowhere. Prefix with `MSYS_NO_PATHCONV=1` or write the source as `\"/$(pwd)\"`. Guides that bind-mount `/etc/timezone` and `/etc/localtime` fail outright on Windows — use the `TZ` environment variable instead.\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/forgejo-self-host/SKILL.md" }, { "name": "skill-publish", "category": "meta", "tier": "core", "description": "Publish one skill from a monorepo into its own dedicated GitHub repo with a standalone README.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/skill-publish/SKILL.md", "path": "skills/skill-publish", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "skill-publish", "description": "Publish one skill from a monorepo into its own dedicated GitHub repo.", "version": "1.0.0" }, "agent_use": "- The user wants to give one skill its own GitHub repo with its own page, separate from the monorepo.\n- The user says \"publish this skill\", \"give this skill its own repo\", or \"spotlight this skill\".\n- The user wants a single skill to be more discoverable than it would be inside a monorepo.", "user_use": "The agent extracts a single skill from a skills monorepo, generates a self-contained README, creates a new public GitHub repo for that skill, and pushes it. The monorepo stays the source of truth — the per-skill repo is a spotlight artifact for discoverability.", "skillmd_content": "---\nname: skill-publish\ndescription: Use when the user wants a single skill from a skills monorepo published to its own dedicated GitHub repo with a standalone README, separate from the monorepo — triggers include \"publish this skill\", \"give this skill its own repo\", or \"spotlight this skill\".\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [publishing, github, monorepo, skill-distribution, readme-generation]\n related_skills: [portfolio-upkeep, skills-portfolio-scaffold, skill-registry-catalog]\n---\n\n# skill-publish\n\n## Overview\n\nExtract one skill from a skills monorepo and publish it into its own dedicated GitHub repository with a self-contained README. This is the per-skill spotlight flow — the monorepo stays the source of truth, the per-skill repo is a derived artifact for discoverability.\n\n## When to Use\n\n- The user wants to give one skill its own GitHub repo with its own page, separate from the monorepo.\n- The user says \"publish this skill\", \"give this skill its own repo\", or \"spotlight this skill\".\n- The user wants a single skill to be more discoverable than it would be inside a monorepo.\n\n## Prerequisites\n\n1. **A skills monorepo** with skills in `skills/<skill-name>/` directories.\n2. **GitHub authentication** — a GitHub personal access token with `repo` scope. Set as `GITHUB_TOKEN` or `GH_TOKEN` environment variable, or use `gh auth login`.\n3. **The skill to publish** must have a valid `SKILL.md` with frontmatter (`name`, `description` minimum).\n\n## Workflow\n\n### Step 1: Identify the skill to publish\n\nConfirm the skill name with the user. The skill must exist in the monorepo at `skills/<skill-name>/`.\n\n```bash\n# Verify the skill exists\nls skills/<skill-name>/SKILL.md\n```\n\nRead the SKILL.md frontmatter to get the skill name and description.\n\n### Step 2: Create a temporary staging directory\n\n```bash\nSTAGING_DIR=\"/tmp/skill-publish-<skill-name>\"\nrm -rf \"$STAGING_DIR\"\nmkdir -p \"$STAGING_DIR\"\n\n# Copy the skill directory\ncp -r skills/<skill-name>/* \"$STAGING_DIR/\"\n```\n\n### Step 3: Generate a standalone README\n\nIf the skill already has a `README.md`, polish it for standalone context. If not, generate one from the SKILL.md content:\n\n```markdown\n# <skill-name>\n\n<description from frontmatter>\n\n## What it does\n<2-3 sentences from the SKILL.md body>\n\n## Install\n\\`\\`\\`bash\nhermes skills install https://raw.githubusercontent.com/<user>/<skill-name>/main/SKILL.md\n\\`\\`\\`\n\n## How to use\n<from the SKILL.md workflow section>\n```\n\nThe README must be self-contained — no references to a parent monorepo, no relative links that assume the reader is inside a larger repo.\n\n### Step 4: Create the GitHub repository\n\nUse the GitHub CLI or API:\n\n```bash\n# Using gh CLI\ngh repo create <skill-name> --public --description \"<description from frontmatter>\" --source \"$STAGING_DIR\" --push\n\n# Or using the API\ncurl -s -X POST https://api.github.com/user/repos \\\n -H \"Authorization: token $GITHUB_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\\\"name\\\":\\\"<skill-name>\\\",\\\"description\\\":\\\"<description>\\\",\\\"public\\\":true}\"\n```\n\n### Step 5: Initialize git and push\n\n```bash\ncd \"$STAGING_DIR\"\ngit init\ngit checkout -b main\ngit add -A\ngit commit -m \"Publish <skill-name> — standalone skill repo\"\ngit remote add origin https://github.com/<user>/<skill-name>.git\ngit push -u origin main\n```\n\n### Step 6: Verify\n\n```bash\n# Check the repo is public and the SKILL.md is accessible\ncurl -s -o /dev/null -w \"%{http_code}\" https://github.com/<user>/<skill-name>/blob/main/SKILL.md\n# Should return 200\n```\n\n### Step 7: Return the URL to the user\n\n```\nPublished: https://github.com/<user>/<skill-name>\nInstall: hermes skills install https://raw.githubusercontent.com/<user>/<skill-name>/main/SKILL.md\n```\n\n### Step 8: Clean up\n\n```bash\nrm -rf \"$STAGING_DIR\"\n```\n\n## Configuration\n\nThe skill reads these from the user's environment or asks interactively:\n\n| Setting | Env var | Default | Notes |\n|---|---|---|---|\n| GitHub username | `GITHUB_USER` | asks user | The repo will be created under this user/org |\n| GitHub token | `GITHUB_TOKEN` or `GH_TOKEN` | asks user | Needs `repo` scope |\n| Repo visibility | — | public | Can be set to private if the user asks |\n\n## Common Pitfalls\n\n1. **Token lacks `repo` scope.** A read-only GitHub token fails repo creation with 403 — generate one with `repo` scope at https://github.com/settings/tokens.\n2. **Repo name collision.** Creation fails with 422 if a repo with the same name already exists — pick a different name or delete the existing repo first.\n3. **Relative links surviving extraction.** Links like `../other-skill/` work inside the monorepo but break in a standalone repo — rewrite the README with absolute URLs or self-contained paths.\n4. **Missing frontmatter blocking README generation.** If SKILL.md lacks `name` or `description`, the publish fails at the README-generation step — validate frontmatter before starting.\n5. **Large files slowing or failing the push.** Binaries or datasets in the skill directory can make the push slow or fail — add a `.gitignore` to the staging dir for large/generated files.\n6. **Editing the spotlight repo directly.** The monorepo is the source of truth — changes made only in the per-skill repo get lost on the next publish; always edit the monorepo first, then re-publish.\n\n## Verification Checklist\n\n- [ ] `skills/<skill-name>/SKILL.md` frontmatter has both `name` and `description` before starting\n- [ ] Staging directory copy matches the monorepo skill directory (no missing files)\n- [ ] README rewritten with no relative links back into the monorepo\n- [ ] New GitHub repo created and push succeeded (`git push -u origin main` returned no errors)\n- [ ] `curl -o /dev/null -w \"%{http_code}\"` against the published SKILL.md URL returns 200\n- [ ] Staging directory cleaned up (`rm -rf \"$STAGING_DIR\"`) after a successful publish\n", "readme_content": "# skill-publish\n\nPublish one skill from a monorepo into its own dedicated GitHub repo with a standalone README.\n\n## What it does\n\nThe agent extracts a single skill from a skills monorepo, generates a self-contained README, creates a new public GitHub repo for that skill, and pushes it. The monorepo stays the source of truth — the per-skill repo is a spotlight artifact for discoverability.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/skill-publish/SKILL.md\n```\n\n## How to use\n\n```\n\"Publish the tailscale-deploy skill to its own GitHub repo\"\n```\n\nThe agent:\n1. Reads the skill from `skills/tailscale-deploy/` in your monorepo\n2. Generates a standalone README (rewrites relative links, adds install instructions)\n3. Creates `https://github.com/<your-user>/tailscale-deploy`\n4. Pushes the skill files\n5. Returns the repo URL and the one-line install command\n\n## Prerequisites\n\n- A skills monorepo with skills in `skills/<name>/` directories\n- A GitHub personal access token with `repo` scope (set as `GITHUB_TOKEN` or use `gh auth login`)\n- The skill to publish must have a `SKILL.md` with `name` and `description` frontmatter\n\n## What you get\n\n- A public GitHub repo containing one skill, self-contained\n- A standalone README with install instructions pointing to the new repo\n- The skill is installable via `hermes skills install <url>` from the new repo\n- The monorepo is unchanged — the per-skill repo is a derived artifact\n\n## Example\n\n```\nUser: \"Give the hallmark-readme skill its own repo\"\n\nAgent:\n 1. Reads skills/hallmark-readme/SKILL.md → name: hallmark-readme, description: \"...\"\n 2. Stages the skill files to a temp directory\n 3. Generates a standalone README.md (rewrites relative links)\n 4. Creates repo: gh repo create hallmark-readme --public\n 5. Pushes: git push -u origin main\n 6. Returns: \"Published at https://github.com/your-user/hallmark-readme\"\n\nThe skill is now installable from its own repo:\n hermes skills install https://raw.githubusercontent.com/your-user/hallmark-readme/main/SKILL.md\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/skill-publish/SKILL.md" }, { "name": "frontend-design-toolkit", "category": "frontend", "tier": "core", "description": "Build distinctive frontends using curated real-world design system patterns — no AI-generated look.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/frontend-design-toolkit/SKILL.md", "path": "skills/frontend-design-toolkit", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "generalized", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Generalized from the internal frontend-design-library, hallmark, and frontend-design-craft skills.", "license": "MIT", "derived": true }, "frontmatter": { "name": "frontend-design-toolkit", "description": "Build distinctive frontends using curated real-world design system patterns.", "version": "1.0.0" }, "agent_use": "- The user asks for a frontend, landing page, dashboard, web app, or UI component.\n- The user wants to redesign an existing UI that looks generic or AI-generated.\n- The user says \"build me a frontend\", \"design a page\", \"make this look better\", or \"I don't want it to look like AI made it\".\n- Any time you're about to write HTML/CSS/JSX for a user-facing interface.", "user_use": "The agent uses design patterns from real, recognizable products (Stripe, Linear, Vercel, Discord, Spotify, Notion, GitHub, Apple) combined with anti-AI-slop rules to build frontends with actual design intent. It picks a design system that matches your project type, sets up OKLCH color tokens and font pairings, and builds the UI from real component patterns — not LLM defaults.", "skillmd_content": "---\nname: frontend-design-toolkit\ndescription: \"Use when about to write HTML/CSS/JSX for a user-facing frontend, landing page, dashboard, or web app, or when redesigning a UI that reads as generic or AI-generated.\"\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [frontend-design, anti-ai-slop, design-systems, oklch, typography]\n related_skills: [color-palette-generator, hallmark-readme]\n---\n\n# frontend-design-toolkit\n\n## Overview\n\nBuild frontends that don't look like an LLM generated them. The agent uses curated design patterns from real, recognizable products (Stripe, Linear, Vercel, Discord, Spotify, Notion, etc.) combined with anti-AI-slop rules to produce UIs with actual design intent.\n\n## When to Use\n\n- The user asks for a frontend, landing page, dashboard, web app, or UI component.\n- The user wants to redesign an existing UI that looks generic or AI-generated.\n- The user says \"build me a frontend\", \"design a page\", \"make this look better\", or \"I don't want it to look like AI made it\".\n- Any time you're about to write HTML/CSS/JSX for a user-facing interface.\n\n## Anti-AI-Slop Rules\n\nThese are the tells that make a frontend immediately recognizable as LLM-generated. The agent must not produce them.\n\n### Banned patterns\n\n| Pattern | Why it's a tell | What to do instead |\n|---|---|---|\n| Purple-to-blue gradient hero | Every AI-generated SaaS landing page | Solid color, editorial photo, or typography-only hero |\n| Three equal feature cards with icons | The most generic layout in AI output | Asymmetric grid, numbered list, or narrative section |\n| Emoji as feature icons (🚀 ⚡ 🔒 ✨) | Instant AI signal | Custom SVG icons, or no icons — let typography carry it |\n| \"Powerful, seamless, comprehensive\" copy | Filler words that mean nothing | Concrete verbs and specifics |\n| Glassmorphism on everything | Overused AI aesthetic | Solid surfaces with real borders |\n| Centered everything | Default LLM layout | Intentional asymmetry, left-aligned editorial layouts |\n| Aurora/blob backgrounds | AI-generated background noise | Solid colors, grid lines, or real imagery |\n| Inter as the only font | Default AI font choice | Pair a display face with a body face |\n\n### Required patterns\n\n- **Color in OKLCH** — not random hex values. Use CSS custom properties.\n- **Font pairing** — a display face + a body face. Never a single font for everything.\n- **4pt spacing scale** — `--space-xs: 0.25rem`, `--space-sm: 0.5rem`, `--space-md: 1rem`, `--space-lg: 2rem`, `--space-xl: 4rem`.\n- **Intentional whitespace** — whitespace is a design element, not empty space to fill.\n- **Real structure** — sections should have a reason to exist, not be there because \"a landing page needs 8 sections\".\n\n## Design System Catalog\n\nReal, recognizable design systems the agent can draw from. Each entry has a concrete pattern to replicate, not just a \"vibe.\"\n\n### Stripe — payment SaaS\n- **Colors:** Indigo (#635BFF) on white, with soft gray surfaces\n- **Typography:** Sohne (sans) + Cambridge (serif for display)\n- **Pattern:** Pricing tables with per-feature comparison rows, clean alignment, subtle hover states\n- **Layout:** Generous whitespace, centered max-width content, subtle shadows\n- **Replicate for:** SaaS pricing pages, payment flows, API documentation\n\n### Linear — project management\n- **Colors:** Dark gray (#0D1117) with purple accent (#5E6AD2)\n- **Typography:** Inter Tight (display) + Inter (body)\n- **Pattern:** Command palette (⌘K), dense list views, keyboard-first navigation\n- **Layout:** Sidebar + main content, minimal chrome, fast transitions\n- **Replicate for:** Dashboards, admin panels, project management tools\n\n### Vercel — deployment platform\n- **Colors:** Black, white, with subtle gray accents\n- **Typography:** Geist Sans + Geist Mono\n- **Pattern:** Deployment cards with status indicators, clean monospace metadata\n- **Layout:** Centered hero, grid of feature cards (but not the generic 3-card pattern — use varied spans)\n- **Replicate for:** Dev tool landing pages, deployment dashboards, status pages\n\n### Discord — community platform\n- **Colors:** Dark gray (#36393F) with blurple (#5865F2)\n- **Typography:** Whitney (custom) or Inter as substitute\n- **Pattern:** Sidebar-heavy navigation, channel list, member list, chat area\n- **Layout:** Three-column (server rail + channel sidebar + main content)\n- **Replicate for:** Chat apps, community tools, real-time dashboards\n\n### Spotify — media platform\n- **Colors:** Black (#121212) with green accent (#1DB954)\n- **Typography:** Circular (custom) or Inter as substitute\n- **Pattern:** Large artwork cards, horizontal scroll rows, sticky player bar\n- **Layout:** Sidebar + content area with horizontal scroll rows\n- **Replicate for:** Media apps, content browsers, music/video players\n\n### Notion — productivity tool\n- **Colors:** White (or dark mode #191919) with subtle gray\n- **Typography:** Avenir Next / Inter\n- **Pattern:** Block-based editor, toggle accordions, clean tables\n- **Layout:** Full-width content, minimal chrome, document-first\n- **Replicate for:** Note apps, wikis, documentation, content management\n\n### GitHub — developer platform\n- **Colors:** White (or dark mode #0D1117) with blue accent\n- **Typography:** -apple-system / Segoe UI / Inter\n- **Pattern:** Tabbed navigation, code blocks with syntax highlighting, pull request diff view\n- **Layout:** Sidebar + main content, dense information display\n- **Replicate for:** Code hosting, developer tools, repository browsers\n\n### Apple — product marketing\n- **Colors:** White with product-specific accents\n- **Typography:** SF Pro Display + SF Pro Text\n- **Pattern:** Full-bleed product imagery, scroll-triggered animations, large typography\n- **Layout:** Full-width sections, massive whitespace, centered product shots\n- **Replicate for:** Product launches, hardware showcases, premium consumer apps\n\n## Color Discipline\n\nUse OKLCH for all colors. Define them as CSS custom properties at `:root`:\n\n```css\n:root {\n --paper: oklch(98% 0.002 240); /* main background */\n --paper-2: oklch(95% 0.004 240); /* elevated surface */\n --ink: oklch(20% 0.010 240); /* primary text */\n --ink-2: oklch(45% 0.008 240); /* secondary text */\n --ink-3: oklch(65% 0.006 240); /* tertiary text / borders */\n --accent: oklch(48% 0.12 250); /* brand accent */\n --accent-soft: oklch(92% 0.03 250); /* accent background */\n --rule: oklch(88% 0.004 240); /* borders and dividers */\n}\n```\n\nWhy OKLCH: perceptually uniform, predictable lightness adjustments, better contrast control than HSL or hex.\n\n## Typography\n\nAlways pair two fonts. A display face for headings and a body face for text.\n\n| Genre | Display | Body | Mood |\n|---|---|---|---|\n| Editorial / news | Serif (e.g., Source Serif) | Sans (e.g., Inter) | Authoritative, readable |\n| SaaS / dev tool | Geometric sans (e.g., Geist) | Grotesk sans (e.g., Inter) | Modern, technical |\n| Creative / agency | Display sans (e.g., Space Grotesk) | Humanist sans (e.g., Söhne) | Distinctive, brand-forward |\n| Terminal / data | Monospace (e.g., JetBrains Mono) | Monospace | Dense, technical |\n| Playful / consumer | Rounded sans (e.g., Plus Jakarta) | Humanist sans (e.g., Inter) | Friendly, approachable |\n\nUse Google Fonts or self-host. Never use a system font as the only font — that's an AI tell.\n\n## Layout\n\n- **4pt spacing scale**: all spacing values are multiples of 4px (0.25rem). Define as custom properties.\n- **Asymmetric grids**: not everything is a centered 3-column grid. Use `grid-template-columns: 2fr 1fr` or varied spans.\n- **Intentional whitespace**: a section with one sentence and lots of whitespace is stronger than a section crammed with 5 features.\n- **Responsive**: mobile-first. Test at 320px, 375px, 414px, 768px. No horizontal scroll.\n\n## Component Patterns (from real products)\n\n### Command palette (Linear-style)\n```\n⌘K opens a centered search box\n- Filtered list of actions\n- Keyboard navigation (arrow keys + enter)\n- Recent items at top\n```\n\n### Pricing table (Stripe-style)\n```\nThree tiers side by side\n- Feature comparison rows\n- \"Most popular\" badge on middle tier\n- Per-month / per-year toggle\n- Clear CTA buttons\n```\n\n### Deployment card (Vercel-style)\n```\nCard with:\n- Project name + framework icon\n- Status indicator (ready/building/error)\n- Timestamp + commit hash in monospace\n- Visit button\n```\n\n### Sidebar navigation (Discord-style)\n```\nServer rail (narrow, icon-only)\n → Channel sidebar (wider, text labels)\n → Main content area\n```\n\n## Workflow\n\n### Step 1: Ask what they're building\n\"What are you building? A landing page, dashboard, web app, or something else?\"\n\n### Step 2: Recommend a design system\nBased on the project type, recommend 2-3 design systems from the catalog. Let the user pick.\n\n### Step 3: Set up tokens\nDefine OKLCH color tokens, font pairing, and spacing scale as CSS custom properties.\n\n### Step 4: Build the structure\nHTML structure first — no styling. Verify the layout makes sense before adding visual design.\n\n### Step 5: Apply the design system\nColors, typography, spacing, component patterns from the chosen design system.\n\n### Step 6: Run the slop check\nRe-read the anti-AI-slop rules. Check every section against the banned patterns table. Fix any violations.\n\n### Step 7: Test responsive\nTest at 320px, 375px, 414px, 768px. Fix any horizontal scroll or broken layouts.\n\n## Common Pitfalls\n\n1. **Defaulting to the same style every time** — Don't always reach for Stripe-style. Match the design system to the project type.\n2. **Copying a design system too literally** — Use the patterns and principles, not the exact colors. Stripe's indigo doesn't work for every SaaS.\n3. **Skipping the font pairing** — A single-font page is an AI tell. Always pair a display face with a body face.\n4. **Forgetting mobile** — Test at 320px. If the layout breaks, fix it before shipping.\n5. **Too many animations** — Animate `transform` and `opacity` only. Most pages have too much motion, not too little.\n6. **Ignoring the slop check** — The anti-AI-slop rules are not optional. Run the check before shipping.\n\n## Verification Checklist\n\n- [ ] Every section checked against the banned-patterns table (no gradient hero, no 3-equal-card grid, no emoji-as-icon)\n- [ ] Two distinct fonts confirmed applied (display + body), not a single font for everything\n- [ ] Colors defined as OKLCH CSS custom properties at `:root`, not raw hex scattered through the CSS\n- [ ] Page tested at 320px, 375px, 414px, and 768px with no horizontal scroll\n", "readme_content": "# frontend-design-toolkit\n\nBuild frontends using curated real-world design system patterns so the result doesn't look AI-generated.\n\n## What it does\n\nThe agent uses design patterns from real, recognizable products (Stripe, Linear, Vercel, Discord, Spotify, Notion, GitHub, Apple) combined with anti-AI-slop rules to build frontends with actual design intent. It picks a design system that matches your project type, sets up OKLCH color tokens and font pairings, and builds the UI from real component patterns — not LLM defaults.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/frontend-design-toolkit/SKILL.md\n```\n\n## How to use\n\n```\n\"Build me a SaaS landing page\"\n```\n\nThe agent:\n1. Asks what you're building (landing page, dashboard, web app, etc.)\n2. Recommends 2-3 design systems from the catalog (e.g., \"Stripe for pricing, Linear for the dashboard\")\n3. Sets up OKLCH color tokens, font pairing, and a 4pt spacing scale\n4. Builds the HTML structure, then applies the design system\n5. Runs the anti-AI-slop check (no gradient heroes, no emoji icons, no filler copy)\n6. Tests responsive at 320/375/414/768px\n\n## Design systems included\n\n| System | Best for | Signature pattern |\n|---|---|---|\n| Stripe | SaaS pricing, payment flows, API docs | Per-feature pricing comparison rows |\n| Linear | Dashboards, admin panels, project tools | Command palette (⌘K), keyboard-first nav |\n| Vercel | Dev tool landing pages, deployment UIs | Deployment cards with status + commit hash |\n| Discord | Chat apps, community tools | Three-column layout (server rail + sidebar + content) |\n| Spotify | Media apps, content browsers | Horizontal scroll rows, large artwork cards |\n| Notion | Note apps, wikis, documentation | Block-based editor, minimal chrome |\n| GitHub | Code hosting, developer tools | Tabbed nav, code blocks with syntax highlighting |\n| Apple | Product launches, premium consumer | Full-bleed imagery, massive whitespace |\n\n## Anti-AI-slop rules\n\nThe skill enforces a ban list of patterns that make frontends immediately recognizable as LLM-generated:\n\n- No purple-to-blue gradient heroes\n- No three equal feature cards with emoji icons (🚀 ⚡ 🔒)\n- No \"powerful, seamless, comprehensive\" filler copy\n- No glassmorphism on everything\n- No centered-everything layouts\n- No Inter as the only font (must pair display + body)\n- No random hex colors (must use OKLCH tokens)\n\n## Example\n\n```\nUser: \"Build a dashboard for my project management tool\"\n\nAgent:\n 1. Recommends: Linear-style (dark gray + purple accent, command palette, dense lists)\n 2. Sets up tokens:\n --paper: oklch(15% 0.005 240) /* dark background */\n --accent: oklch(52% 0.14 270) /* purple accent */\n --font-display: \"Inter Tight\"\n --font-body: \"Inter\"\n 3. Builds: sidebar + main content area, command palette, issue list\n 4. Slop check: no gradient hero ✓, no emoji icons ✓, font pairing ✓\n 5. Tests at 320px / 768px / desktop\n 6. Returns: working HTML/CSS dashboard with Linear-inspired design\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/frontend-design-toolkit/SKILL.md" }, { "name": "skills-portfolio-scaffold", "category": "meta", "tier": "core", "description": "Set up a skills portfolio repo with categorized, ranked, sortable skills — the meta-skill for publishing portfolios.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/skills-portfolio-scaffold/SKILL.md", "path": "skills/skills-portfolio-scaffold", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "skills-portfolio-scaffold", "description": "Set up a skills portfolio repo with categorized, ranked, sortable skills.", "version": "1.0.0" }, "agent_use": "- The user wants to publish their own Hermes skills as a portfolio.\n- The user wants a structured, categorized, ranked collection of skills (not just a flat directory).\n- The user wants their skills to be discoverable by both humans (sortable site) and agents (structured index).\n- The user says \"set up a skills portfolio\", \"I want to publish my skills\", or \"make my skills installable\".", "user_use": "The agent asks what you want your portfolio called, then creates a portfolio repo structure for your skills: a monorepo with one directory per skill, a that serves as the single source of truth (for both humans and agents), a sortable static site, CI validation, and a Hallmark-quality README. This is the meta-skill that reproduces the portfolio structure so anyone can publish their own skills the same way — under their own name, not a clone of this one.", "skillmd_content": "---\nname: skills-portfolio-scaffold\ndescription: Use when a user wants to publish their own Hermes skills as a categorized, ranked, sortable skills portfolio — discoverable by both humans (sortable site) and agents (structured index) — or says \"set up a skills portfolio\" / \"make my skills installable\".\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [skills-portfolio, scaffolding, skills-index, static-site]\n related_skills: [hallmark-readme, skill-publish, portfolio-upkeep]\n---\n\n# skills-portfolio-scaffold\n\n## Overview\n\nScaffold a skills portfolio repository with the three-surface architecture: a monorepo of skills, a `skills-index.json` for agent-parseable metadata, a sortable static site, and CI validation. This is the meta-skill that reproduces the portfolio structure for anyone who wants to publish their own skills — under their own name and branding, never a clone of this one.\n\n## When to Use\n\n- The user wants to publish their own Hermes skills as a portfolio.\n- The user wants a structured, categorized, ranked collection of skills (not just a flat directory).\n- The user wants their skills to be discoverable by both humans (sortable site) and agents (structured index).\n- The user says \"set up a skills portfolio\", \"I want to publish my skills\", or \"make my skills installable\".\n\n## Prerequisites\n\n- Git installed and configured\n- A GitHub account (for the public shopfront)\n- Hermes Agent installed (for `hermes skills install` to work for end users)\n- Skills to publish — at least one `SKILL.md` with frontmatter\n\n## Workflow\n\n### Step 0: Name the portfolio\n\nBefore scaffolding anything, ask the user for three things: what they want their portfolio **called** (e.g. \"Jane's Automation Skills,\" not \"Hermes Skills Portfolio\" — this is their shopfront, not a copy of this one), their **name or handle** as it should appear in the README/site footer, and a one-sentence **tagline**. Use their answers everywhere `portfolio.name` / `portfolio.owner` / `portfolio.tagline` appear in Step 2 — never leave a placeholder value or default to \"Hermes\" in the generated output.\n\n### Step 1: Create the repo structure\n\n```\n<portfolio-name>/\n├── README.md ← the shopfront (Hallmark quality)\n├── skills-index.json ← single source of truth\n├── skills-index.schema.json ← schema for the index\n├── LICENSE ← MIT recommended\n├── .gitignore\n├── docs/adr/ ← architecture decisions\n├── skills/ ← one directory per skill\n│ └── <skill-name>/\n│ ├── SKILL.md\n│ └── README.md\n└── site/ ← sortable static site\n ├── index.html\n ├── styles.css\n └── app.js\n```\n\n### Step 2: Create skills-index.json\n\nThe index is the single source of truth. Both the README and the static site render from it. Schema:\n\n```json\n{\n \"version\": \"1.0.0\",\n \"generated_at\": \"ISO-8601 timestamp\",\n \"portfolio\": {\n \"name\": \"Your Portfolio Name\",\n \"owner\": \"Your Name\",\n \"tagline\": \"One sentence. No filler.\",\n \"total_skills\": 0,\n \"github_url\": \"https://github.com/your-user/your-portfolio\"\n },\n \"categories\": {\n \"devops\": { \"name\": \"DevOps\", \"description\": \"...\", \"skill_count\": 0 },\n \"frontend\": { \"name\": \"Frontend\", \"description\": \"...\", \"skill_count\": 0 }\n },\n \"skills\": [\n {\n \"name\": \"skill-name\",\n \"category\": \"devops\",\n \"tier\": \"core\",\n \"description\": \"One line. What agent + skill delivers.\",\n \"install_url\": \"https://github.com/your-user/your-portfolio/blob/main/skills/skill-name/SKILL.md\",\n \"path\": \"skills/skill-name\",\n \"usage\": { \"hub_installs\": 0, \"github_clones\": 0, \"stars\": 0 },\n \"recency\": \"2026-01-01\",\n \"source\": \"new\",\n \"source_attribution\": \"\"\n }\n ]\n}\n```\n\n### Step 3: Assign usefulness tiers\n\nEvery skill gets one of three tiers at publish time:\n\n| Tier | Meaning |\n|---|---|\n| `core` | Broadly empowering, nearly any user benefits |\n| `featured` | Highly useful within a category |\n| `utility` | Useful for specific workflows |\n\nThis is a curated judgment, not a metric. It's the day-one ranking — usage data enriches it later but never replaces it.\n\n### Step 4: Create the static site\n\nThe `site/` directory contains a self-contained HTML/CSS/JS app that:\n- Fetches `skills-index.json` on page load\n- Renders skill cards in a responsive grid\n- Supports sorting (tier-then-usage default, plus usage/recency/category/alphabetical)\n- Supports filtering (category, tier) and search\n- Uses OKLCH colors, a real font pairing, no AI-slop patterns\n\nSee the portfolio's own `site/` directory for a working reference implementation.\n\n### Step 5: Add CI validation\n\nCreate `.github/workflows/validate.yml` (or `.forgejo/workflows/validate.yml` for Forgejo):\n\n```yaml\nname: validate\non: push\njobs:\n lint:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - name: Validate SKILL.md frontmatter\n run: |\n for skill_md in skills/*/SKILL.md; do\n name=$(grep -m1 '^name:' \"$skill_md\" | sed 's/^name:[[:space:]]*//')\n [ -z \"$name\" ] && echo \"FAIL: $skill_md missing name\" && exit 1\n done\n```\n\n### Step 6: Write the README\n\nThe portfolio README is the shopfront. It should include:\n- A one-sentence tagline (no filler)\n- Install instructions for individual skills\n- A categories table\n- The ranking explanation (tiers + usage)\n- The repo structure\n- Links to ADRs (if any)\n- License info\n\n### Step 7: Publish\n\n```bash\ngit init\ngit add -A\ngit commit -m \"Initial portfolio scaffold\"\ngit remote add origin https://github.com/<user>/<portfolio-name>.git\ngit push -u origin main\n```\n\n### Step 8: Add skills incrementally\n\nEach new skill:\n1. Create `skills/<skill-name>/SKILL.md` with frontmatter\n2. Create `skills/<skill-name>/README.md` (Hallmark quality)\n3. Add an entry to `skills-index.json`\n4. Commit and push\n5. The CI validates the frontmatter\n\n## Skill Entry Requirements\n\nEvery skill in the portfolio must have:\n\n| Requirement | Where | Notes |\n|---|---|---|\n| `SKILL.md` with frontmatter | `skills/<name>/SKILL.md` | `name`, `description`, `version` minimum |\n| `README.md` | `skills/<name>/README.md` | What it does, install, how to use, example |\n| Index entry | `skills-index.json` | name, category, tier, description, install_url, path, source |\n\n## Site Features\n\nThe portfolio static site includes:\n- Dark mode default with light toggle (localStorage persistence)\n- Sortable skill cards (by tier+usage, usage, recency, category, alphabetical)\n- Category and tier filters with filter chips\n- Search with keyboard shortcut (`/`)\n- **Detail page overlay**: clicking a skill opens a full page with:\n - \"What it does\" (user-facing description)\n - \"How an agent uses it\" (agent-facing use cases)\n - SKILL.md tab (raw markdown rendered for reading)\n - README tab (raw markdown rendered for reading)\n - Install command with copy-to-clipboard\n - Close button (X icon), Esc key, click-outside-to-close\n - Shareable URL hash: `#skill/<name>`\n- Category distribution bar\n- Back-to-top button\n- Toast notifications\n- Keyboard: `/` search, `Esc` close detail, `t` toggle theme\n\n### GitHub Pages deployment\n\nGitHub Pages only serves from `/` or `/docs`. Deploy:\n1. Copy site files + skills-index.json into `docs/`\n2. Settings → Pages → Source → Deploy from branch → `main` → `/docs`\n3. Site live at `https://<username>.github.io/<repo-name>/`\n\n### skills-index.json enrichment\n\nEach skill entry should include `agent_use`, `user_use`, `skillmd_content`, and `readme_content` fields so the detail page can show all content without fetching individual files.\n\n## Common Pitfalls\n\n1. **Index drift.** If you add a skill directory but forget to add an entry to `skills-index.json`, the site won't show it and the CI should warn. Keep them in sync.\n2. **Relative links in README.** Links like `../other-skill/` break when a skill is published to its own repo via `skill-publish`. Use absolute URLs for cross-skill references.\n3. **Tier inflation.** Don't mark everything `core`. If all skills are core, the tier is meaningless. Reserve `core` for skills that nearly any user benefits from.\n4. **No categories.** Every skill must belong to a category. Uncategorized skills break the filter UI and the agent-parseable index.\n5. **Invented usage data.** Start all usage counts at 0. Don't fabricate install numbers — they'll be overwritten by real data once the portfolio has traffic, and fake numbers erode trust.\n6. **`skills-index.json` too large.** Embedding full SKILL.md and README.md content in the index makes it large (500KB+ for 50 skills). This is acceptable for a static site — it loads once and enables instant detail page rendering without per-skill fetches.\n7. **`docs/` vs `site/` drift.** When you update site files, always copy them to `docs/` too. The `docs/` directory is what GitHub Pages serves. Use a sync script or the portfolio-upkeep skill.\n\n## Verification Checklist\n\n- [ ] `skills-index.json` validates against `skills-index.schema.json` and every skill directory under `skills/` has a matching index entry\n- [ ] Every skill entry has a `tier` (`core`/`featured`/`utility`) and a `category`, and not everything is tagged `core`\n- [ ] `site/` files are mirrored into `docs/` (what GitHub Pages actually serves)\n- [ ] The CI validation workflow runs and fails a skill missing `name:` in its frontmatter\n- [ ] All `usage` counts in newly added entries start at 0 — no fabricated install/star numbers\n", "readme_content": "# skills-portfolio-scaffold\n\nScaffold a publishable skills portfolio with categorized, ranked, sortable skills and a static site — named and branded as *your own*, not a copy of this one.\n\n## What it does\n\nThe agent asks what you want your portfolio called, then creates a portfolio repo structure for your skills: a monorepo with one directory per skill, a `skills-index.json` that serves as the single source of truth (for both humans and agents), a sortable static site, CI validation, and a Hallmark-quality README. This is the meta-skill that reproduces the portfolio structure so anyone can publish their own skills the same way — under their own name.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/skills-portfolio-scaffold/SKILL.md\n```\n\n## How to use\n\n```\n\"Set up a skills portfolio for my Hermes skills\"\n```\n\nThe agent:\n1. Creates the repo structure (README, skills-index.json, site/, docs/adr/, skills/, CI workflow)\n2. Sets up the JSON schema for the index\n3. Generates the sortable static site (HTML/CSS/JS)\n4. Creates the CI validation workflow\n5. Shows you how to add skills incrementally\n\n## What you get\n\n| Component | Purpose |\n|---|---|\n| `skills-index.json` | Single source of truth — agents parse this, the site renders from it |\n| `site/` | Sortable, filterable static site (sort by tier, usage, category, recency) |\n| `skills/<name>/` | One directory per skill, each with SKILL.md + README.md |\n| CI workflow | Validates every SKILL.md has required frontmatter |\n| README.md | The shopfront — install instructions, categories, ranking explanation |\n\n## The ranking model\n\nEvery skill gets a usefulness tier at publish time:\n\n- **Core** — broadly empowering, nearly any user benefits\n- **Featured** — highly useful within a category\n- **Utility** — useful for specific workflows\n\nDefault sort: tier (Core → Featured → Utility), then usage within tier. Usage data accumulates over time from hub installs and GitHub clones.\n\n## Example\n\n```\nUser: \"I have 15 Hermes skills I want to publish as a portfolio.\"\n\nAgent:\n 1. Creates the repo structure with skills-index.json schema\n 2. Generates the sortable static site\n 3. For each of the 15 skills: creates skills/<name>/ with SKILL.md + README.md\n 4. Adds each skill to skills-index.json with category + tier\n 5. Creates the CI workflow\n 6. Returns: \"Portfolio scaffolded. Push to GitHub when ready.\"\n\nUser pushes to GitHub → strangers can browse the site, install individual skills,\nand agents can read skills-index.json to recommend skills.\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/skills-portfolio-scaffold/SKILL.md" }, { "name": "docker-umbrella", "category": "devops", "tier": "core", "description": "Consolidate multiple services under a single Docker front-end with routing and a themed index page.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/docker-umbrella/SKILL.md", "path": "skills/docker-umbrella", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "generalized", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Generalized from the internal personal-docker-umbrella and docker-pages-umbrella skills.", "license": "MIT", "derived": true }, "frontmatter": { "name": "docker-umbrella", "description": "Consolidate multiple services under a single Docker front-end.", "version": "1.0.0" }, "agent_use": "- The user runs several local web UIs, dashboards, doc sites, or media servers and\n wants one address instead of a row of ports.\n- \"One page for all my apps\", \"group my containers\", \"declutter Docker Desktop\",\n \"a landing page that links my services\".\n- They want TLS at the edge or a single health-checked entry point.\n- Do NOT use this for stateful runtimes you must operate directly — databases, game\n servers, bots. Link those from the hub; don't proxy them.", "user_use": "", "skillmd_content": "---\nname: docker-umbrella\ndescription: Use when the user runs several local web UIs, dashboards, doc sites, or media servers and wants one address instead of a row of ports, or says \"one page for all my apps\", \"group my containers\", \"declutter Docker Desktop\", or \"a landing page that links my services\". Do not use for stateful runtimes that must be operated directly — databases, game servers, bots — link those from the hub instead of proxying them.\nversion: 1.1.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [docker-compose, nginx, reverse-proxy, service-hub, self-hosting]\n related_skills: [caddy-reverse-proxy]\n---\n\n# docker-umbrella\n\n## Overview\n\nSet up one Docker container that fronts every local service: a themed landing\npage plus routing to each app, optional TLS at the edge, and a health check.\nThe user gets one address instead of a row of ports.\n\nThe decision that determines whether this works is **how each app is mounted**,\nand it is made per app, not once for the whole hub:\n\n- **Link-only** — the hub renders a card that links to the app on its own port.\n Always correct, zero proxy risk. Default to this.\n- **Own-port proxy** — a small nginx that proxies the app at `/` on a dedicated\n port. Correct for any app, including ones with hardcoded absolute paths.\n- **Path-mounted** (`/dash/`) — one port covers everything, but **only works if\n the app can be told it lives under a prefix**. Path-mounting an app that emits\n root-relative links produces a page that returns `200` and renders completely\n unstyled. See \"Path mounting\" below before choosing this.\n\nMost self-hosted apps fail path mounting. Reach for it only when the app has a\n`base href` / `ROOT_URL` / `--base-path` setting, or when you are willing to\nrewrite its HTML with `sub_filter`.\n\n## When to Use\n- The user runs several local web UIs, dashboards, doc sites, or media servers and\n wants one address instead of a row of ports.\n- \"One page for all my apps\", \"group my containers\", \"declutter Docker Desktop\",\n \"a landing page that links my services\".\n- They want TLS at the edge or a single health-checked entry point.\n- Do NOT use this for stateful runtimes you must operate directly — databases, game\n servers, bots. Link those from the hub; don't proxy them.\n\n## Architecture\n- One stock `nginx:alpine` container is the front-end. No custom baked image, so\n edits to the HTML or config show on reload with no rebuild.\n- The landing page (`/`) is bind-mounted HTML that lists every service as a card.\n- Proxied services are reached by `proxy_pass` to `host.docker.internal:<port>`\n (host network) or to a compose service name on a shared network.\n- One published host port replaces one port per service.\n- Optional TLS: a Caddy sidecar that terminates and proxies to the umbrella on `80`.\n\n```\n :8090 (the hub's own port)\n browser ──────────────► umbrella (nginx:alpine)\n │ / → themed landing (bind-mounted html)\n │ /docs/ → proxy_pass host.docker.internal:9000/\n └ /media/ → proxy_pass host.docker.internal:8096/\n\n the hub's port and every backend port must be DIFFERENT — see Pitfall 1\n```\n\n## Prerequisites\n- **Docker Engine + Compose v2** (`docker compose version` — the v2 space form).\n- **A free host port for the hub**, distinct from every backend port:\n - Linux/macOS: `ss -ltnp | grep LISTEN`\n - Windows: `netstat -an | findstr LISTENING`\n- **The bind-mount sources must exist before `up -d`.** If `./default.conf` does\n not exist, Docker creates a *directory* with that name and nginx fails to start\n with `is a directory`. Create `hub/index.html` and `default.conf` first.\n- **Backends reachable from the container** — either published on the host (reach\n them via `host.docker.internal`) or on a shared compose network (reach them by\n service name).\n- On Docker Desktop, `host.docker.internal` resolves automatically. On Linux\n Engine it does not; add the `extra_hosts` entry shown below. Including it on\n Docker Desktop is harmless, so include it always.\n\n## Workflow\n1. Inventory services: `docker ps -a --format '{{.Names}}\\t{{.Ports}}'`.\n2. Confirm a free host port for the hub that is **not** one of the backend ports.\n3. Decide per app: link-only, own-port proxy, or path-mounted.\n4. Write `docker-compose.yml`.\n5. Write `default.conf` — a landing `location /` plus one block per path-mounted app.\n6. Write `hub/index.html` (themed; cards link to each app).\n7. `docker compose config` to validate, then `docker compose up -d`.\n8. Verify by asserting on **content and content-type**, not status codes — a\n misrouted path returns `200` with the wrong body. See Verification.\n\n## Configuration\n`docker-compose.yml`:\n```yaml\nname: umbrella\n\nservices:\n umbrella:\n image: nginx:alpine\n container_name: umbrella\n ports:\n # Host port must not collide with any backend this proxies.\n - \"8090:80\"\n volumes:\n - ./hub:/usr/share/nginx/html:ro\n - ./default.conf:/etc/nginx/conf.d/default.conf:ro\n restart: unless-stopped\n healthcheck:\n test: [\"CMD\", \"curl\", \"-fsS\", \"http://localhost/index.html\"]\n interval: 30s\n timeout: 5s\n retries: 3\n start_period: 10s\n # Required on Linux Engine; harmless on Docker Desktop.\n extra_hosts:\n - \"host.docker.internal:host-gateway\"\n```\nNo `version:` key — Compose v2 warns `the attribute 'version' is obsolete`.\n\nIf the proxied apps live in the same compose file, skip `host.docker.internal`\nand `proxy_pass` to the compose service name instead (e.g. `http://dashboard:8080`).\nA service name only resolves if both containers share a network — a backend in a\n*different* compose project is not reachable by name unless you attach the\numbrella to that project's network with a top-level `networks: { external: true }`.\n\n## Routing\n\n### Own-port proxy (default, always works)\nOne small nginx per app, each on its own host port, proxying the app at `/`.\nNothing rewrites paths, so hardcoded absolute URLs, `/login` redirects and\nWebSocket upgrades all behave exactly as they do direct:\n\n```nginx\nserver {\n listen 80;\n server_name _;\n client_max_body_size 0; # large uploads / git push\n\n location / {\n proxy_pass http://host.docker.internal:3000; # no trailing slash\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_http_version 1.1;\n proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection $connection_upgrade;\n proxy_read_timeout 3600s;\n proxy_send_timeout 3600s;\n }\n}\n```\n\n`$connection_upgrade` needs a `map` at the `http` level — put it in a file\nmounted at `/etc/nginx/conf.d/upgrade.conf`:\n\n```nginx\nmap $http_upgrade $connection_upgrade {\n default upgrade;\n '' close;\n}\n```\n\nHardcoding `proxy_set_header Connection \"upgrade\"` instead sends an upgrade\nheader on every ordinary request, which breaks keepalive and confuses some\nbackends. The `map` sends it only when the client actually asked to upgrade.\n\n### Path mounting\n`location /dash/` + `proxy_pass .../` rewrites `/dash/foo` → `backend/foo`. The\nrequest reaches the app correctly. The **response** is the problem: the app emits\nlinks relative to *its* root — `href=\"/assets/app.css\"`, `src=\"/api/...\"` — which\nthe browser resolves against the hub's root, not `/dash/`. Those URLs miss the\n`location /dash/` block entirely and fall through to the landing page.\n\nWith a typical SPA-style landing (`try_files $uri $uri/ /index.html`) they do not\neven 404: every missing asset returns **`200 text/html`** containing the landing\npage. The CSS request receives HTML, so the page renders unstyled and its scripts\nthrow — while every `curl` check reports `200`.\n\nTwo ways to make it work:\n\n**A. Tell the app it lives under a prefix.** Always prefer this. Most apps have a\nsetting (`ROOT_URL`, `base href`, `--base-path`, `SUBURL`). Set it to `/dash/`,\nand the app emits correct links on its own.\n\n**B. Rewrite the HTML on the way out** when the app has no such setting:\n\n```nginx\nlocation ^~ /dash/ {\n proxy_pass http://host.docker.internal:9000/;\n proxy_set_header Host $host;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n\n # sub_filter cannot patch a compressed body — force plain text upstream.\n proxy_set_header Accept-Encoding \"\";\n\n sub_filter_once off;\n sub_filter 'href=\"/' 'href=\"/dash/';\n sub_filter 'src=\"/' 'src=\"/dash/';\n sub_filter \"href='/\" \"href='/dash/\";\n sub_filter \"src='/\" \"src='/dash/\";\n}\n```\n\n`^~` matters: it stops nginx from letting a later regex `location` win. And drop\nthe `try_files ... /index.html` fallback from `location /` (use `index\nindex.html;`) so a genuinely missing asset returns `404` instead of a misleading\n`200`.\n\nThis is a patch, not a fix — it only touches HTML bodies, so absolute paths built\nin JavaScript at runtime still escape. Apps that do that need option A or an\nown-port proxy.\n\n### Subdomain-based\nOne hostname per service — needs DNS or a wildcard record. Behaves like the\nown-port pattern (the app is at `/`), so it avoids the prefix problem entirely:\n```nginx\nserver {\n listen 80;\n server_name dash.example.com;\n location / {\n proxy_pass http://host.docker.internal:9000;\n # same proxy_set_header block as the own-port example\n }\n}\n```\n\n## Theming\nSet `data-theme` on `<html>` and swap CSS custom properties. Ship `light` / `dark`\nplus one custom palette; persist the choice in `localStorage` so a refresh keeps\nit. A two-button switcher is enough:\n```html\n<html data-theme=\"dark\">\n<head>\n <style>\n :root, [data-theme=\"dark\"] { --bg:#0e1116; --fg:#e6e6e6; --accent:#6ea8fe; --card:#161b22; }\n [data-theme=\"light\"] { --bg:#ffffff; --fg:#1a1a1a; --accent:#2563eb; --card:#f3f4f6; }\n [data-theme=\"custom\"] { --bg:#1a1423; --fg:#f3e8ff; --accent:#c084fc; --card:#241a30; }\n body { background:var(--bg); color:var(--fg); }\n .card { background:var(--card); border-left:3px solid var(--accent); }\n </style>\n</head>\n<body>\n <button onclick=\"setTheme('light')\">Light</button>\n <button onclick=\"setTheme('dark')\">Dark</button>\n <button onclick=\"setTheme('custom')\">Custom</button>\n <script>\n const saved = localStorage.getItem('theme') || 'dark';\n document.documentElement.setAttribute('data-theme', saved);\n function setTheme(n){ localStorage.setItem('theme', n); document.documentElement.setAttribute('data-theme', n); }\n </script>\n</body>\n```\nDefine tokens once (`--bg`, `--fg`, `--accent`, `--card`) so every card inherits\nthem. Cards are plain links: `<a class=\"card\" href=\"/dash/\">Dashboard</a>`.\n\n## Common Pitfalls\n1. **The hub proxying itself.** Publishing the umbrella on `8080:80` and also\n writing `proxy_pass http://host.docker.internal:8080/` points the route back at\n the hub's own published port. `/dash/` then returns **`200` with the landing\n page** — the route looks fine to `curl` and never reaches the app. Keep the\n hub's host port distinct from every backend port, and re-read the compose\n `ports:` line against every `proxy_pass` before starting.\n2. **Root-relative links break path mounts.** The dominant failure. A path-mounted\n app's assets resolve against the hub root and are answered by the landing page\n with `200 text/html`. See \"Path mounting\" — fix with the app's own prefix\n setting, `sub_filter`, or an own-port proxy.\n3. **`try_files $uri $uri/ /index.html` masks every 404.** The SPA fallback turns\n missing assets into `200` landing-page HTML, which is what makes pitfall 2 so\n hard to spot. Use `index index.html;` on a static hub and reserve the fallback\n for an actual SPA.\n4. **`nginx:alpine` ships both `curl` and `wget`.** (Verified on `nginx:alpine`\n 1.31.3: `/usr/bin/curl` and `/usr/bin/wget`.) The real trap is that `wget` is\n the **BusyBox** applet, not GNU wget — it rejects GNU-only flags such as\n `--version` and `--spider` semantics differ, so a healthcheck copied from a\n Debian example reports `unhealthy` while nginx serves `200`. Older\n `nginx:alpine` tags shipped no `curl` at all, so pin the tag you tested.\n5. **Trailing-slash mismatch.** `location /dash/` with `proxy_pass http://host:9000`\n (no slash) preserves `/dash/`; with a slash it strips it. Match the two\n deliberately and confirm with `curl -i`.\n6. **Port conflict on `up -d`.** A dead container holding the port blocks the\n bind (\"address already in use\"). Check listeners first; free the port or pick\n another.\n7. **Stale container holds the name.** An exited container with the same\n `container_name` blocks `up -d` (\"Conflict ... already in use\").\n `docker rm -f umbrella`, then retry.\n8. **Missing bind-mount source becomes a directory.** `up -d` before creating\n `default.conf` makes Docker create a directory of that name; nginx then fails\n with `is a directory`. Delete the directory, create the file, restart.\n9. **Healthcheck on a redirecting root.** If `/` 302-redirects, `curl -f` fails\n the check. Hit a known-200 path instead (`/index.html`, `/healthz`, or an app's\n `/user/login`).\n10. **Scheme-downgrading redirects behind TLS.** `return 301 /foo/;` and nginx's\n automatic directory redirect emit `http://` even when the user arrived over\n HTTPS, bouncing them out of TLS. Write redirects scheme-aware:\n `return 301 $scheme://$http_host/foo/;`\n11. **Binding `127.0.0.1` locks out the LAN.** `\"8090:80\"` publishes on all\n interfaces; `\"127.0.0.1:8090:80\"` does not. Choose deliberately — loopback-only\n is the right default for anything unauthenticated.\n12. **Hot edits to bind-mounts don't reload.** `docker compose up -d` will not\n reload a changed `default.conf`. Always test before reloading, or a syntax\n error takes the hub down:\n `docker exec umbrella nginx -t && docker exec umbrella nginx -s reload`\n13. **`sub_filter` silently does nothing on compressed responses.** If the\n upstream gzips, the filter never matches. Send\n `proxy_set_header Accept-Encoding \"\";` in any block using `sub_filter`.\n14. **`up -d` hangs in an agent shell.** Some shells trip a long-running-server\n guard. If it does, run it as a bounded background task instead.\n\n## Verification Checklist\n\nA `200` proves almost nothing here — pitfalls 1, 2 and 3 all return `200` with\nthe wrong body. Assert on what came back.\n\n- [ ] `docker compose config` parses with no `version` obsolete warning\n- [ ] `docker ps` shows `umbrella` as `healthy`, not `starting` or `unhealthy`\n- [ ] The hub's published port appears in **no** `proxy_pass` line:\n `grep proxy_pass default.conf` checked against the compose `ports:` entry\n- [ ] Each route returns the **backend's** content, not the hub's — e.g.\n `curl -s http://localhost:8090/dash/ | grep -q \"<title>Dashboard\"`, not just a status check\n- [ ] For every path-mounted app, one of its own assets returns the right\n content-type: `curl -s -o /dev/null -w '%{http_code} %{content_type}\\n' http://localhost:8090/dash/assets/app.css`\n shows `200 text/css` — `200 text/html` means the landing page answered it\n- [ ] A deliberately bogus path returns `404`, not `200` (proves no `try_files` mask)\n- [ ] Landing page renders in an actual browser with every card link working —\n open the devtools console and confirm no 404s or MIME-type errors\n- [ ] Config edits were applied with `nginx -t && nginx -s reload`, and the reload\n was confirmed by re-checking a changed route\n- [ ] If TLS terminates at the edge, redirects stay on `https://` (pitfall 10)\n", "readme_content": "# docker-umbrella\n\nOne Docker container in front of everything you run locally. Instead of remembering\nthat the dashboard is on `:9000`, the docs on `:9001`, and the media server on\n`:8096`, you open one address — say `http://localhost:8090/` — and get a landing\npage that links to all of them.\n\nThis skill is the pattern for building that front-end: a stock `nginx:alpine`\ncontainer that serves a themed index page and reverse-proxies your services, plus a\n`docker-compose.yml`, health checks, and theme switching. No custom image, no\nrebuild loop — edit the HTML or the config and it shows on reload.\n\n## The one decision that matters\n\nHow each app is mounted, chosen **per app**:\n\n| Mount | Works with | Cost |\n|---|---|---|\n| **Link-only** — card links to the app's own port | Everything | Still a row of ports, but zero risk |\n| **Own-port proxy** — one nginx per app, proxying at `/` | Everything, including apps with hardcoded absolute paths | One port per app |\n| **Path-mounted** — `/dash/` on the hub's single port | Only apps you can tell they live under a prefix | Breaks silently otherwise |\n\nPath mounting is what people picture when they ask for this, and it is the one\nthat fails. An app mounted at `/dash/` still emits `href=\"/assets/app.css\"` — a\nroot-relative link the browser resolves against the *hub's* root. That request\nmisses the `/dash/` block and is answered by the landing page. With an SPA-style\n`try_files` fallback it returns **`200 text/html`**, so the stylesheet request\nreceives HTML: the page renders completely unstyled while every `curl` check\nreports success.\n\nPath-mount only when the app has a `ROOT_URL` / `base href` / `--base-path`\nsetting, or when you accept rewriting its HTML with `sub_filter`. Otherwise give\nit its own port.\n\n## What you get\n\n- **One landing page.** A themed index linking every service, light/dark/custom.\n- **Routing that survives contact with real apps.** Own-port proxying by default,\n path mounting where the app supports it.\n- **Health checks.** A dead hub fails its container healthcheck instead of\n silently serving a stale page.\n- **Optional TLS.** Terminate HTTPS at the edge and proxy plain HTTP internally.\n\n## Before you start\n\n- Docker Engine + Compose v2 (`docker compose version`).\n- A free host port for the hub — and it must be **different from every backend\n port** you intend to proxy (see Pitfalls). Check with `ss -ltnp | grep LISTEN`\n (Linux/macOS) or `netstat -an | findstr LISTENING` (Windows).\n- `hub/index.html` and `default.conf` must exist **before** `docker compose up -d`.\n Docker creates a *directory* in place of a missing bind-mount source, and nginx\n then fails with `is a directory`.\n- Your services reachable from the container: published on the host (reach via\n `host.docker.internal`) or on a shared compose network (reach by service name).\n\nThis is for **web UIs, dashboards, doc sites, and media servers**. Databases,\ngame servers, and long-running bots don't belong behind the proxy — link to them\nfrom the landing page, don't route through it.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/docker-umbrella/SKILL.md\n```\n\nOr clone and install from a local path:\n\n```bash\ngit clone https://github.com/THEROCKSSS/hermes-skills-portfolio\nhermes skills install ./hermes-skills-portfolio/skills/docker-umbrella/SKILL.md\n```\n\n## Project layout\n\n```\ndocker-umbrella/\n├── docker-compose.yml ← umbrella container + optional TLS sidecar\n├── default.conf ← nginx routes: landing + one block per service\n├── upgrade.conf ← the $connection_upgrade map (websockets)\n└── hub/\n └── index.html ← themed landing page\n```\n\n## docker-compose.yml\n\n```yaml\nname: umbrella\n\nservices:\n umbrella:\n image: nginx:alpine\n container_name: umbrella\n ports:\n # Must not collide with any backend port this proxies.\n - \"8090:80\"\n volumes:\n - ./hub:/usr/share/nginx/html:ro\n - ./default.conf:/etc/nginx/conf.d/default.conf:ro\n - ./upgrade.conf:/etc/nginx/conf.d/upgrade.conf:ro\n restart: unless-stopped\n healthcheck:\n test: [\"CMD\", \"curl\", \"-fsS\", \"http://localhost/index.html\"]\n interval: 30s\n timeout: 5s\n retries: 3\n start_period: 10s\n # Required on Linux Engine; harmless on Docker Desktop.\n extra_hosts:\n - \"host.docker.internal:host-gateway\"\n```\n\nNo `version:` key — Compose v2 warns that it is obsolete and ignores it.\n\nIf your apps live in this same compose file, drop `host.docker.internal` and\n`proxy_pass` to the compose service name (e.g. `http://dashboard:8080`). Service\nnames only resolve between containers sharing a network.\n\n## upgrade.conf\n\n```nginx\nmap $http_upgrade $connection_upgrade {\n default upgrade;\n '' close;\n}\n```\n\nHardcoding `proxy_set_header Connection \"upgrade\"` sends an upgrade header on\nevery ordinary request, breaking keepalive. The map sends it only when the client\nactually asked to upgrade.\n\n## default.conf\n\n```nginx\nserver {\n listen 80;\n server_name _;\n\n # Landing page. `index`, NOT `try_files ... /index.html` — the SPA fallback\n # turns every missing asset into a 200 and hides broken routes.\n location / {\n root /usr/share/nginx/html;\n index index.html;\n }\n\n # Path-mounted app that CAN be told it lives under /docs/\n # (configure the app's own base-path setting to match).\n location ^~ /docs/ {\n proxy_pass http://host.docker.internal:9001/;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_http_version 1.1;\n proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection $connection_upgrade;\n }\n\n # Path-mounted app that CANNOT — rewrite its root-relative links on the way out.\n location ^~ /dash/ {\n proxy_pass http://host.docker.internal:9000/;\n proxy_set_header Host $host;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n\n # sub_filter cannot patch a compressed body.\n proxy_set_header Accept-Encoding \"\";\n sub_filter_once off;\n sub_filter 'href=\"/' 'href=\"/dash/';\n sub_filter 'src=\"/' 'src=\"/dash/';\n }\n}\n```\n\n`^~` stops a later regex `location` from winning. `sub_filter` only rewrites HTML\nbodies — absolute paths built by JavaScript at runtime still escape it, and those\napps need their own port.\n\n**Slash rule.** `location /dash/` + `proxy_pass http://host:9000/;` strips the\nprefix. Drop the trailing slash on `proxy_pass` to keep it. Pick one and confirm\nwith `curl -i`.\n\n## Own-port proxy (the safe default)\n\nFor anything with hardcoded absolute paths — Forgejo, most media servers,\nanything with a `/login` redirect — give it a dedicated port and proxy at `/`:\n\n```nginx\nserver {\n listen 80;\n server_name _;\n client_max_body_size 0;\n\n location / {\n proxy_pass http://host.docker.internal:3000; # no trailing slash\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_http_version 1.1;\n proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection $connection_upgrade;\n proxy_read_timeout 3600s;\n proxy_send_timeout 3600s;\n }\n}\n```\n\nNothing rewrites paths, so the app behaves exactly as it does direct. The hub\nlinks to it by port.\n\n## hub/index.html (themed)\n\n```html\n<!doctype html>\n<html data-theme=\"dark\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>Home\n \n\n\n

Services

\n \n
\n \n \n \n
\n \n\n\n```\n\nTheme tokens are defined once and every card inherits them. The switcher writes\nthe choice to `localStorage`. Add a service: drop an `` in the\ngrid, and a `location` block only if you are path-mounting it.\n\n## Run it\n\n```bash\ndocker compose config # validate, starts nothing\ndocker compose up -d\ndocker compose ps # umbrella is \"healthy\"\n```\n\nThen verify by content, not status — see below.\n\nEdit the HTML or config? No rebuild needed, but test before reloading or a syntax\nerror takes the hub down:\n\n```bash\ndocker exec umbrella nginx -t && docker exec umbrella nginx -s reload\n```\n\n## Optional TLS at the edge\n\nTerminate HTTPS once, in front of the plain-HTTP umbrella:\n\n```caddyfile\n# Caddyfile\ndash.example.com {\n reverse_proxy umbrella:80\n}\n```\n\n```yaml\n# add to docker-compose.yml\n caddy:\n image: caddy:alpine\n container_name: umbrella-caddy\n ports:\n - \"443:443\"\n volumes:\n - ./Caddyfile:/etc/caddy/Caddyfile:ro\n - caddy_data:/data\n - caddy_config:/config\n restart: unless-stopped\n\nvolumes:\n caddy_data:\n caddy_config:\n```\n\n`caddy_data` holds the certificates — losing it means re-issuing and risking\nrate limits, so keep it a named volume. Caddy proxies to the umbrella over the\ncompose network; the umbrella itself only listens on `80`.\n\nBehind TLS, make every nginx redirect scheme-aware —\n`return 301 $scheme://$http_host/foo/;`. A bare `return 301 /foo/;` emits\n`http://` and drops the user out of HTTPS.\n\n## Verification checklist\n\nA `200` proves almost nothing here — the two worst failure modes both return\n`200` with the wrong body.\n\n```bash\n# The route serves the BACKEND's content, not the hub's landing page\ncurl -s http://localhost:8090/dash/ | grep -q \"Dashboard\" && echo OK\n\n# A path-mounted app's own asset has the right content-type.\n# \"200 text/html\" means the landing page answered a stylesheet request.\ncurl -s -o /dev/null -w '%{http_code} %{content_type}\\n' \\\n http://localhost:8090/dash/assets/app.css\n\n# A bogus path 404s (proves no try_files mask)\ncurl -s -o /dev/null -w '%{http_code}\\n' http://localhost:8090/definitely-not-real\n```\n\n- [ ] `docker compose config` parses with no `version` obsolete warning.\n- [ ] `docker compose ps` shows `umbrella` as `healthy`.\n- [ ] The hub's published port appears in **no** `proxy_pass` line.\n- [ ] Each route returns the backend's content; each path-mounted asset returns\n its real content-type.\n- [ ] A bogus path returns `404`, not `200`.\n- [ ] The landing page renders in a real browser with the devtools console clear\n of 404s and MIME-type errors — `curl` returning 200 does not prove this.\n\n## Pitfalls\n\n- **The hub proxying itself.** Publishing the umbrella on `8080:80` *and* writing\n `proxy_pass http://host.docker.internal:8080/` points the route back at the\n hub. `/dash/` returns `200` with the landing page and never reaches the app.\n Keep the hub's port distinct from every backend port.\n- **Root-relative links break path mounts.** The dominant failure — assets\n resolve against the hub root and are answered by the landing page. Fix with the\n app's own base-path setting, `sub_filter`, or an own-port proxy.\n- **`try_files $uri $uri/ /index.html` masks every 404**, which is what makes the\n above so hard to spot. Use `index index.html;` on a static hub.\n- **`nginx:alpine` ships both `curl` and `wget`** (verified on 1.31.3). The trap\n is that `wget` is the BusyBox applet, not GNU wget — GNU-only flags fail, so a\n healthcheck copied from a Debian example reports `unhealthy` while nginx serves\n `200`. Older tags shipped no `curl` at all; pin the tag you tested.\n- **Missing bind-mount source becomes a directory.** `up -d` before creating\n `default.conf` makes Docker create a directory of that name and nginx fails\n with `is a directory`.\n- **`host.docker.internal` on Linux Engine.** Needs\n `extra_hosts: [\"host.docker.internal:host-gateway\"]`. Harmless on Docker\n Desktop, so include it always.\n- **Stale container holds the name.** An exited container with the same\n `container_name` blocks `up -d`. `docker rm -f umbrella`, then retry.\n- **Healthcheck on a redirecting root.** If `/` 302s, `curl -f` fails. Point it\n at a known-200 path.\n- **Scheme-downgrading redirects behind TLS.** Use `$scheme://$http_host`.\n- **`sub_filter` no-ops on compressed responses.** Send\n `proxy_set_header Accept-Encoding \"\";` in any block that uses it.\n- **Bind `0.0.0.0` vs `127.0.0.1` deliberately.** `\"8090:80\"` publishes on all\n interfaces; loopback-only is the right default for anything unauthenticated.\n- **Hot config edits.** `docker compose up -d` won't reload a changed\n `default.conf`. Run `nginx -t && nginx -s reload`.\n\n## Source\n\nGeneralized from the internal `personal-docker-umbrella` and\n`forgejo-pages-umbrella` patterns, with all host-specific ports, IPs, and\ninfrastructure coupling removed. Public under the Hermes Skills Portfolio by Alex.\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/docker-umbrella/SKILL.md" }, { "name": "api-test-suite", "category": "backend", "tier": "core", "description": "Generate a runnable API test suite — contract tests, integration tests, and CI config from an OpenAPI spec or existing API.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/api-test-suite/SKILL.md", "path": "skills/api-test-suite", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "generalized", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Generalized from the internal api-test-suite skill used in the agentsoul profile.", "license": "MIT", "derived": true }, "frontmatter": { "name": "api-test-suite", "description": "Generate a runnable API test suite from an OpenAPI spec or existing API.", "version": "1.0.0" }, "agent_use": "- A new API project needs test coverage before it ships.\n- The user says \"write tests for these endpoints\", \"add contract tests\", or \"test my API\".\n- Pre-release or pre-merge verification of an HTTP API.\n- You have an OpenAPI/Postman artifact and want it turned into executable tests.\n- You are onboarding to an API codebase and want a safety net first.\n\nDo not use it for browser UI flows (use a browser-test skill) or for pure unit tests of non-API logic (those belong in a unit test file, not the API suite).", "user_use": "The agent reads your API's source of truth (a spec file or the route code itself) and writes a test package you can run locally and wire into CI. It covers the happy path, the obvious error cases (auth, validation, not-found), and the edge cases people forget (empty bodies, malformed input, rate limits). Then it runs the suite and tells you what passed, what failed, and whether the failure is a bug in your API or a wrong expectation in the test.\n\nThis is a generator, not a hosted runner. The output is real files in your repo that you own and can edit.", "skillmd_content": "---\nname: api-test-suite\ndescription: Use when a new or existing API needs test coverage before a release or merge, when onboarding to an unfamiliar API codebase and wanting a safety net first, or when an OpenAPI spec/Postman collection exists and should be turned into a runnable pytest/vitest suite with contract and integration tests.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [api-testing, contract-tests, integration-tests, pytest, vitest, ci-cd]\n related_skills: [openapi-generator, http-api-tester, github-actions-ci]\n---\n\n# api-test-suite\n\n## Overview\n\nGenerate a runnable API test package from an OpenAPI spec, a Postman collection, or by scanning an existing API codebase. The agent produces real, locally-runnable test files — not a hosted-run stub — covering happy path, error cases, and edge cases, then runs the suite and reports pass/fail.\n\n## When to Use\n\n- A new API project needs test coverage before it ships.\n- The user says \"write tests for these endpoints\", \"add contract tests\", or \"test my API\".\n- Pre-release or pre-merge verification of an HTTP API.\n- You have an OpenAPI/Postman artifact and want it turned into executable tests.\n- You are onboarding to an API codebase and want a safety net first.\n\nDo not use it for browser UI flows (use a browser-test skill) or for pure unit tests of non-API logic (those belong in a unit test file, not the API suite).\n\n## Workflow\n\n1. **Pick the input.** One of:\n - An OpenAPI/Swagger spec (`openapi.yaml`, `swagger.json`).\n - A Postman collection (`collection.json`).\n - An API codebase (FastAPI/Express/Flask/Nest/etc.) — scan route definitions.\n2. **Enumerate endpoints** with method, path, auth scheme, request shape, and response codes.\n3. **Derive test cases per endpoint:**\n - Happy path — 2xx, response shape matches spec.\n - Auth missing/invalid — 401/403.\n - Validation failure — 400, with the documented error shape.\n - Not found — 404 for unknown IDs.\n - Edge cases — empty body, oversized input, malformed JSON, pagination bounds, rate limiting.\n4. **Choose a runner by stack:**\n - Node/TS → `vitest` + `supertest` (in-process) or `axios` (live server).\n - Python → `pytest` + `httpx` (live server) or `fastapi.testclient` (in-process).\n5. **Emit the file tree** (see Test Runner Setup).\n6. **Run the suite.** Report pass/fail per file. If a test fails, decide whether it is a real bug in the API or a wrong expectation in the test — fix the API when the expectation is correct, fix the test when it is not. Never delete or weaken an assertion just to turn it green.\n\n## Contract Tests\n\nContract tests assert that the live API's responses conform to the published schema. They catch drift between spec and implementation.\n\n**From an OpenAPI spec — Python with schemathesis (property-based, hits the running server):**\n\n```python\n# tests/contract/test_contract.py\nimport schemathesis\n\nschema = schemathesis.from_uri(\"http://localhost:8000/openapi.json\")\n\n@schema.parametrize()\ndef test_api_contract(case, base_url):\n # `case` is generated from the spec; this verifies the server honors it\n response = case.call(base_url)\n case.validate_response(response)\n```\n\nIf schemathesis is too heavy, validate responses against a JSON Schema extracted from the spec:\n\n```python\n# tests/contract/test_shapes.py\nimport json\nimport httpx\nfrom jsonschema import Draft202012Validator\n\nspec = json.load(open(\"openapi.json\"))\n# pull the response schema for GET /markets -> 200\nschema = spec[\"paths\"][\"/markets\"][\"get\"][\"responses\"][\"200\"][\"content\"][\n \"application/json\"\n][\"schema\"]\n\ndef test_markets_shape(base_url):\n r = httpx.get(f\"{base_url}/markets?limit=10\")\n assert r.status_code == 200\n Draft202012Validator(schema).validate(r.json())\n```\n\n**From an OpenAPI spec — Node with a lightweight assertion:**\n\n```ts\n// tests/contract/markets.spec.ts\nimport { expect } from 'vitest'\nimport { client } from '../helpers/http'\n\nit('GET /markets 200 matches spec shape', async () => {\n const r = await client.get('/markets?limit=10')\n expect(r.status).toBe(200)\n expect(Array.isArray(r.data.data)).toBe(true)\n expect(r.data).toHaveProperty('total')\n})\n```\n\nRule: contract tests must read the schema from the artifact, not hardcode a copy that can silently rot.\n\n## Integration Tests\n\nIntegration tests exercise real HTTP behavior against a running server (or an in-process app) with auth, test data, and cleanup.\n\n**Python — fixtures for auth, data, cleanup (pytest):**\n\n```python\n# tests/integration/test_orders.py\nimport pytest, httpx\n\n@pytest.fixture\ndef auth_headers():\n # mint a short-lived test token; never use a real user's credentials\n token = mint_test_token(scopes=[\"orders:write\"])\n return {\"Authorization\": f\"Bearer {token}\"}\n\n@pytest.fixture\ndef created_order(base_url, auth_headers):\n r = httpx.post(f\"{base_url}/orders\", json={\"item\": \"widget\", \"qty\": 1},\n headers=auth_headers)\n assert r.status_code == 201\n yield r.json()[\"id\"]\n # cleanup runs even if the test fails\n httpx.delete(f\"{base_url}/orders/{r.json()['id']}\", headers=auth_headers)\n\ndef test_create_order_happy(base_url, auth_headers, created_order):\n assert isinstance(created_order, str)\n\ndef test_create_order_requires_auth(base_url):\n r = httpx.post(f\"{base_url}/orders\", json={\"item\": \"widget\"})\n assert r.status_code == 401\n\ndef test_create_order_bad_payload(base_url, auth_headers):\n r = httpx.post(f\"{base_url}/orders\", json={}, headers=auth_headers)\n assert r.status_code == 400\n```\n\n**Node — vitest + supertest (in-process, no separate server):**\n\n```ts\n// tests/integration/orders.test.ts\nimport request from 'supertest'\nimport { app } from '../../src/app'\nimport { testToken } from '../helpers/auth'\n\ndescribe('POST /orders', () => {\n it('creates an order with valid auth', async () => {\n const r = await request(app)\n .post('/orders')\n .set('Authorization', `Bearer ${testToken()}`)\n .send({ item: 'widget', qty: 1 })\n expect(r.status).toBe(201)\n })\n\n it('rejects missing auth', async () => {\n const r = await request(app).post('/orders').send({ item: 'widget' })\n expect(r.status).toBe(401)\n })\n\n it('rejects empty body', async () => {\n const r = await request(app)\n .post('/orders')\n .set('Authorization', `Bearer ${testToken()}`)\n .send({})\n expect(r.status).toBe(400)\n })\n})\n```\n\n## Test Runner Setup\n\nEmit this layout so the suite is reproducible:\n\n```\ntests/\n conftest.py # pytest fixtures: base_url, auth_headers, data, cleanup\n contract/\n test_contract.py # schema-conformance checks\n test_shapes.py\n integration/\n test_orders.py # real HTTP happy/error/edge cases\nhelpers/\n http.ts / http.py # shared client, base URL from env\n auth.ts / auth.py # test-token minting\nopenapi.json # spec under test (if available)\npytest.ini / vitest.config.ts\n```\n\n**Python — `pytest.ini`:**\n\n```ini\n[pytest]\ntestpaths = tests\naddopts = -q --tb=short\nenv =\n BASE_URL=http://localhost:8000\n```\n\nInstall: `pip install pytest httpx schemathesis`\n\n**Node — `vitest.config.ts`:**\n\n```ts\nimport { defineConfig } from 'vitest/config'\nexport default defineConfig({\n test: {\n globals: true,\n environment: 'node',\n include: ['tests/**/*.test.ts', 'tests/**/*.spec.ts'],\n },\n})\n```\n\nInstall: `npm i -D vitest supertest`\n\n## CI Integration\n\nRun contract + integration tests against a service spun up in the pipeline. GitHub Actions / Forgejo Actions share the same YAML:\n\n```yaml\nname: api-tests\non: [push, pull_request]\njobs:\n test:\n runs-on: ubuntu-latest\n services:\n api:\n image: ghcr.io/your-org/your-api:ci\n ports: [\"8000:8000\"]\n options: >-\n --health-cmd \"curl -f http://localhost:8000/health\"\n --health-interval 10s --health-timeout 5s --health-retries 5\n env:\n BASE_URL: http://localhost:8000\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-python@v5\n with: { python-version: \"3.12\" }\n - run: pip install pytest httpx schemathesis\n - run: pytest tests/ --junitxml=report.xml\n - uses: actions/upload-artifact@v4\n if: always()\n with: { name: api-test-report, path: report.xml }\n```\n\nFor Node, swap the setup step for `actions/setup-node` and the run step for `npx vitest run`.\n\n## Common Pitfalls\n\n1. **Mocking the database so tests lie.** Use a real test database or per-test transaction rollback. A test that mocks storage at the wrong boundary passes while the API is broken.\n2. **Hardcoding the response shape instead of reading the spec.** Contract tests must derive expectations from the OpenAPI/Postman artifact, or they drift and become noise.\n3. **Shared mutable state across tests.** Each test must create and clean up its own data. Seeds that depend on order will flake in CI.\n4. **Using a real user's token for auth.** Mint short-lived test tokens scoped to the test tenant; rotate secrets from env, never commit them.\n5. **Rate limiting breaking CI.** Hit the API serially in contract runs, or raise the limit for the test tenant. A 429 is a test-harness problem, not an API bug, until proven otherwise.\n6. **Treating generated tests as a substitute for reading the spec.** The suite documents behavior; the agent should still confirm the API's intent with the user for ambiguous endpoints.\n7. **Flaky network timing.** Add bounded retries only on connection errors, never on assertion failures.\n\n## Verification Checklist\n\n- [ ] The suite runs with a single command (`pytest tests/` or `npx vitest run`) and exits 0, or every failure is a confirmed real API bug\n- [ ] Contract tests read the schema from the OpenAPI/Postman artifact at runtime, not a hardcoded copy\n- [ ] Every endpoint has at least a happy-path test and one auth/validation-failure test\n- [ ] Auth uses minted test tokens, never a real user's credentials\n- [ ] Fixtures that create data also tear it down (no orphaned test records after a run)\n- [ ] CI YAML (if emitted) targets the correct service image/port and uploads the test report artifact\n", "readme_content": "# api-test-suite\n\nTurn an API into a runnable test suite — contract tests, integration tests, and CI config — generated from an OpenAPI spec, a Postman collection, or an existing API codebase.\n\n## What it does\n\nThe agent reads your API's source of truth (a spec file or the route code itself) and writes a test package you can run locally and wire into CI. It covers the happy path, the obvious error cases (auth, validation, not-found), and the edge cases people forget (empty bodies, malformed input, rate limits). Then it runs the suite and tells you what passed, what failed, and whether the failure is a bug in your API or a wrong expectation in the test.\n\nThis is a generator, not a hosted runner. The output is real files in your repo that you own and can edit.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/api-test-suite/SKILL.md\n```\n\n## Inputs\n\nThe skill accepts any one of these as the source of truth:\n\n| Input | Example | What it drives |\n|---|---|---|\n| OpenAPI / Swagger spec | `openapi.yaml`, `swagger.json` | Endpoint list, shapes, response codes |\n| Postman collection | `collection.json` | Endpoint list, example requests |\n| API codebase | FastAPI, Express, Flask, Nest | Route scans, auth schemes |\n\nIf you have a spec, that wins — it is the contract. If you only have code, the agent scans route definitions and infers auth and shapes, then asks you to confirm anything ambiguous.\n\n## What you get\n\n```\ntests/\n conftest.py # pytest fixtures: base_url, auth_headers, cleanup\n contract/\n test_contract.py # schema-conformance checks (spec-driven)\n test_shapes.py\n integration/\n test_orders.py # real HTTP happy / error / edge cases\nhelpers/\n http.py # shared client, base URL from env\n auth.py # test-token minting\nopenapi.json # spec under test (if you provided one)\npytest.ini\n```\n\nNode/TS projects get the same shape with `vitest.config.ts` and `*.test.ts` / `*.spec.ts` files, plus a `helpers/http.ts` and `helpers/auth.ts`.\n\n## Contract tests\n\nContract tests assert the live API's responses conform to the published schema. They catch drift between what you documented and what you shipped.\n\n```python\n# tests/contract/test_shapes.py\nimport json\nimport httpx\nfrom jsonschema import Draft202012Validator\n\nspec = json.load(open(\"openapi.json\"))\nschema = (\n spec[\"paths\"][\"/markets\"][\"get\"][\"responses\"][\"200\"][\"content\"]\n [\"application/json\"][\"schema\"]\n)\n\ndef test_markets_shape(base_url):\n r = httpx.get(f\"{base_url}/markets?limit=10\")\n assert r.status_code == 200\n Draft202012Validator(schema).validate(r.json())\n```\n\nThe rule that makes this worth having: the expectation is read from the spec, not copied into the test. A hardcoded copy silently rots; a spec-derived check fails the moment the API and the contract disagree, which is the whole point.\n\nFor deeper coverage, `schemathesis` generates requests from the spec and validates every response against it automatically:\n\n```python\nimport schemathesis\nschema = schemathesis.from_uri(\"http://localhost:8000/openapi.json\")\n\n@schema.parametrize()\ndef test_api_contract(case, base_url):\n response = case.call(base_url)\n case.validate_response(response)\n```\n\n## Integration tests\n\nIntegration tests exercise real HTTP behavior against a running server (or an in-process app) with auth, test data, and cleanup. Fixtures create their own data and tear it down, so tests stay isolated.\n\n```python\n# tests/integration/test_orders.py\nimport pytest, httpx\n\n@pytest.fixture\ndef auth_headers():\n return {\"Authorization\": f\"Bearer {mint_test_token(scopes=['orders:write'])}\"}\n\n@pytest.fixture\ndef created_order(base_url, auth_headers):\n r = httpx.post(f\"{base_url}/orders\", json={\"item\": \"widget\", \"qty\": 1},\n headers=auth_headers)\n assert r.status_code == 201\n yield r.json()[\"id\"]\n httpx.delete(f\"{base_url}/orders/{r.json()['id']}\", headers=auth_headers)\n\ndef test_create_order_happy(base_url, auth_headers, created_order):\n assert isinstance(created_order, str)\n\ndef test_create_order_requires_auth(base_url):\n r = httpx.post(f\"{base_url}/orders\", json={\"item\": \"widget\"})\n assert r.status_code == 401\n\ndef test_create_order_empty_body(base_url, auth_headers):\n r = httpx.post(f\"{base_url}/orders\", json={}, headers=auth_headers)\n assert r.status_code == 400\n```\n\nNode/TS, in-process with `supertest`:\n\n```ts\n// tests/integration/orders.test.ts\nimport request from 'supertest'\nimport { app } from '../../src/app'\nimport { testToken } from '../helpers/auth'\n\ndescribe('POST /orders', () => {\n it('creates an order with valid auth', async () => {\n const r = await request(app)\n .post('/orders')\n .set('Authorization', `Bearer ${testToken()}`)\n .send({ item: 'widget', qty: 1 })\n expect(r.status).toBe(201)\n })\n\n it('rejects missing auth', async () => {\n const r = await request(app).post('/orders').send({ item: 'widget' })\n expect(r.status).toBe(401)\n })\n})\n```\n\n## Test runner setup\n\n**Python** — `pip install pytest httpx schemathesis`, then `pytest.ini`:\n\n```ini\n[pytest]\ntestpaths = tests\naddopts = -q --tb=short\nenv =\n BASE_URL=http://localhost:8000\n```\n\n**Node/TS** — `npm i -D vitest supertest`, then `vitest.config.ts`:\n\n```ts\nimport { defineConfig } from 'vitest/config'\nexport default defineConfig({\n test: { globals: true, environment: 'node',\n include: ['tests/**/*.test.ts', 'tests/**/*.spec.ts'] },\n})\n```\n\n## CI integration\n\nRun the suite against a service the pipeline spins up. This YAML works in both GitHub Actions and Forgejo Actions:\n\n```yaml\nname: api-tests\non: [push, pull_request]\njobs:\n test:\n runs-on: ubuntu-latest\n services:\n api:\n image: ghcr.io/your-org/your-api:ci\n ports: [\"8000:8000\"]\n options: >-\n --health-cmd \"curl -f http://localhost:8000/health\"\n --health-interval 10s --health-timeout 5s --health-retries 5\n env:\n BASE_URL: http://localhost:8000\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-python@v5\n with: { python-version: \"3.12\" }\n - run: pip install pytest httpx schemathesis\n - run: pytest tests/ --junitxml=report.xml\n - uses: actions/upload-artifact@v4\n if: always()\n with: { name: api-test-report, path: report.xml }\n```\n\nSwap the setup/run steps for `actions/setup-node` and `npx vitest run` on a Node project.\n\n## Coverage matrix\n\nThe agent generates at least one case per endpoint from each row:\n\n| Category | Case | Expected |\n|---|---|---|\n| Happy path | Valid request, valid auth | 2xx, shape matches spec |\n| Auth | Missing / invalid token | 401 / 403 |\n| Validation | Missing required field, bad type | 400, documented error shape |\n| Not found | Unknown ID | 404 |\n| Edge | Empty body, malformed JSON, oversized input | 400 / 413 |\n| Edge | Pagination bounds (`limit=0`, `limit=1000`) | 200 or 422, sane clamping |\n| Edge | Rate limit (burst) | 429 with retry header |\n\n## Honest limitations\n\n- The suite documents observed behavior. For ambiguous endpoints the agent confirms intent with you rather than guessing the \"right\" contract.\n- Contract tests check shape and codes; they do not prove business logic correctness. Pair them with a few hand-written behavior tests for your critical paths.\n- Generated fixtures use a test tenant and short-lived tokens. You must wire real secret injection (`BASE_URL`, token minting) for your environment — the skill scaffolds it, it does not know your auth system.\n- Rate-limited APIs will 429 under load; the skill serializes contract runs and raises the limit for the test tenant instead of masking failures.\n\n## Part of\n\n[Hermes Skills Portfolio](https://github.com/THEROCKSSS/hermes-skills-portfolio) — empowering skills for the Hermes agent.\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/api-test-suite/SKILL.md" }, { "name": "skill-registry-catalog", "category": "meta", "tier": "core", "description": "Catalog third-party AI agent skills from public registries — a categorized, tracked catalog of skills worth evaluating.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/skill-registry-catalog/SKILL.md", "path": "skills/skill-registry-catalog", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "generalized", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Generalized from the internal skill-registry-catalog skill used in the agentsoul profile.", "license": "MIT", "derived": true }, "frontmatter": { "name": "skill-registry-catalog", "description": "Catalog third-party AI agent skills from public registries.", "version": "1.0.0" }, "agent_use": "- Survey public skill registries (skills.md, skillsmp.com) for specific domains — Discord,\n security/pentest, backend, API, MCP, frontend, etc.\n- Produce a repo the user can browse and approve/reject one skill at a time.\n- Track ingestion volume over time (hour / day / week / month / year / all-time).\n- The user asks to \"look at these registries and catalog the skills\" or \"track external skills\n separated from my own.\"", "user_use": "The agent pulls skill data from public registries (skills.md, skillsmp.com), filters it by the\ncategories you care about, and builds a repository where every third-party skill gets its own\nfolder. Skills are split into three buckets — **approved** (already in your local library),\n**pending** (awaiting your review), and **other** (out of focus) — and a counter tracks how many\nskills you add per time window. Optional CI re-scans the registries on a schedule so the catalog\nstays current.\n\nThis is the \"what's out there worth evaluating\" skill. It deliberately keeps third-party skills\nseparate from your own so you can approve or reject them one at a time.", "skillmd_content": "---\nname: skill-registry-catalog\ndescription: Use when the user wants to survey public agent-skill registries (skills.md, skillsmp.com) for a domain and build a categorized, source-cited catalog repo with approved/pending buckets and live ingestion counters — not for organizing skills already adopted into the user's own library.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [skill-registry, cataloging, ci-automation, github-api, forgejo-api, skills-md]\n related_skills: [skill-publish, portfolio-upkeep, skills-portfolio-scaffold]\n---\n\n# skill-registry-catalog\n\n## Overview\n\nBuild a categorized, source-cited catalog of third-party agent skills harvested from public\nregistries. Each skill is separated from your own library so it is easy to triage — approved\nskills (matching your local library) and pending skills (awaiting human review) live in their\nown folders, with a master `CATALOG.md` and live ingestion counters.\n\n## When to Use\n\n- Survey public skill registries (skills.md, skillsmp.com) for specific domains — Discord,\n security/pentest, backend, API, MCP, frontend, etc.\n- Produce a repo the user can browse and approve/reject one skill at a time.\n- Track ingestion volume over time (hour / day / week / month / year / all-time).\n- The user asks to \"look at these registries and catalog the skills\" or \"track external skills\n separated from my own.\"\n\n## Sources\n\nTwo public registries are the defaults. Fetch structured data from their JSON APIs, not the\nHTML pages.\n\n| Registry | List page | JSON API | Notes |\n|---|---|---|---|\n| skills.md | `https://skills.md/skills` | `https://skills.md/api/skills` | Returns the full array in one call |\n| skillsmp.com | `https://skillsmp.com/search` | `https://skillsmp.com/api/skills?page=N&limit=100` | Caps at 100/page — must paginate |\n\nFetch from `terminal` with `curl`/`urllib`, or from the browser console with `fetch()`. Keep\nthe HTML pages for visual confirmation only; the catalog data comes from the JSON.\n\n### Response shapes (abridged)\n\nskills.md element:\n```json\n{\"name\":\"api-test-suite\",\"displayName\":\"API Test Suite\",\n \"description\":\"Generate and run API test suites...\",\"category\":\"Development Tools\",\n \"tags\":[\"api\",\"testing\",\"automation\",\"qa\"],\"pricing\":{\"tier\":\"free\"},\"source\":\"official\"}\n```\n\nskillsmp.com element:\n```json\n{\"id\":\"...\",\"name\":\"openspec-apply-change\",\"author\":\"Orient-Software-Development\",\n \"description\":\"...\",\"githubUrl\":\"https://github.com/.../tree/main/.claude/skills/...\",\n \"stars\":0,\"updatedAt\":\"1783911957\",\"path\":\"SKILL.md\",\n \"route\":{\"ownerSlug\":\"...\",\"repoSlug\":\"...\",\"routeSlug\":\"...\",\"sourceSkillPath\":\"...\"}}\n```\n\n### Parsing quirks (real, not theoretical)\n\n- **skillsmp.com caps at 100 per response** even if you pass `limit=1000`. Paginate `page=1..N`\n until a page returns `[]` or fewer than 100 items:\n ```python\n all_sk = []\n for p in range(1, 30):\n d = json.load(urlopen(f\"https://skillsmp.com/api/skills?page={p}&limit=100\"))\n s = d.get(\"skills\", [])\n if not s:\n break\n all_sk += s\n ```\n- **skills.md `/api/skills`** can return the JSON array concatenated with `===` plus an HTML\n error page if multiple candidate URLs were fetched together in one console call. Slice before\n parsing:\n ```python\n raw = wrapper[\"result\"]\n end = raw.find(\"}]\") + 2\n skills = json.loads(raw[:end])\n ```\n- **skillsmp.com `/api/search?q=` returns 404.** There is no text-search API — pull the full\n paginated `/api/skills` and filter client-side.\n\n## Workflow\n\n### 1. Confirm sources and categories\nAgree the registries and focus categories with the user (e.g. Discord, Security/Pentest, Backend,\nAPI, MCP). Categories are defined as regex keyword maps over each skill's\n`name + description + tags + category/author` (lower-cased).\n\n```python\nCATS = {\n \"Discord\": [r\"discord\"],\n \"Security/Pentest\":[r\"security\", r\"pentest\", r\"penetration\", r\"vuln\", r\"\\baudit\\b\",\n r\"exploit\", r\"\\bcve\\b\", r\"secure\", r\"threat\", r\"secret\", r\"\\bauth\\b\",\n r\"hardening\"],\n \"Backend\": [r\"backend\", r\"server\", r\"\\bdb\\b\", r\"database\", r\"orm\", r\"microservice\",\n r\"fastapi\", r\"django\", r\"express\", r\"\\bsql\\b\"],\n \"API\": [r\"\\bapi\\b\", r\"\\brest\\b\", r\"graphql\", r\"openapi\", r\"endpoint\",\n r\"webhook\", r\"\\bsdk\\b\"],\n \"MCP\": [r\"\\bmcp\\b\", r\"model context protocol\", r\"mcp server\", r\"mcp client\"],\n}\n```\n\n### 2. Extract + filter\n- Pull both datasets as JSON (see quirks above).\n- Run each skill through the category maps; a skill hits a category if any pattern matches.\n- Cross-reference each skill name against the user's **local skill library** to decide\n approved vs pending (see Filtering).\n- Dedupe by `(source, name)`.\n\n### 3. Build the catalog repo\n```\n<repo>/\n├── README.md # overview + live COUNTERS block\n├── CATALOG.md # 3-bucket master list (approved / pending / other)\n├── .github/workflows/ # validate.yml, counters.yml, rescan.yml\n├── scripts/ # build_catalog.py, counters.py\n└── skills/\n ├── approved/<name>__<source>/{README.md, SKILL.md}\n └── pending/<name>__<source>/{README.md, SKILL.md}\n```\n- Per-skill `SKILL.md` frontmatter: `name`, `source` (direct registry URL), `status`\n (`approved` | `pending`), `incorporated_as` (for approved).\n- Per-skill `README.md`: description, metadata table, Original URL, an action checkbox for review.\n- Folder names join skill and source so the same skill from two registries does not collide:\n `<name>__<source>` (e.g. `api-test-suite__skills.md`).\n\n### 4. Cross-reference against local library\nDecide `approved` vs `pending`:\n- **Approved** — the skill's name or function overlaps an existing local skill, or it is a\n known upgrade of one you already use. Keep the match loose-but-verified.\n- **Other** — does not fit any focus category and is not approved; still recorded so re-scans\n stay stable.\nRequire either exact local-skill-name containment **or** an explicit `known_upgrades` map\n(e.g. `api-test-suite`, `backend-patterns`, `generate-dockerfile`, `repo-security-audit`,\n`discord-suite`). Reject bare substring hits (`image`, `write`, `backend`) — they over-match.\n\n## Catalog Structure\n\n`CATALOG.md` is the master index with three buckets:\n\n```markdown\n# Catalog\n\n## Approved (already in your library)\n| Name | Source | Category | Local match |\n|---|---|---|---|\n| api-test-suite | skills.md | Backend/API | api-test-suite |\n\n## Pending (awaiting review)\n| Name | Source | Category | Original URL |\n|---|---|---|---|\n| some-new-skill | skillsmp.com | MCP | https://... |\n\n## Other (out of focus, not approved)\n| Name | Source | Category |\n|---|---|---|\n| ... | ... | ... |\n```\n\nEach per-skill folder has a self-contained `README.md` with an `[ ] reviewed` checkbox so a\nhuman can tick skills off one at a time.\n\n## Counters\n\nThe user wants time-window tracking. `README.md` carries a fenced block that the counter script\nrewrites from git history:\n\n```\n<!-- COUNTERS_START -->\n| Window | Skills Added |\n|---|---|\n| Past hour | 0 |\n| Past 24h | 0 |\n| Past 7d | 0 |\n| Past 30d | 0 |\n| Past 6mo | 0 |\n| Past 1y | 0 |\n| All time | 0 |\n<!-- COUNTERS_END -->\n```\n\n`scripts/counters.py --patch` computes the counts from the commit timestamps of added files under\n`skills/` and rewrites the block in place:\n\n```bash\ngit log --diff-filter=A --name-only --pretty=%ct -- skills/ \\\n | python scripts/counters.py --patch\n```\n\n## CI/Automation\n\nPut three workflows in your git host's CI directory (GitHub: `.github/workflows/`; Forgejo:\n`.forgejo/workflows/`; GitLab: `.gitlab-ci.yml`). Use `[skip ci]` in counter-commit messages to\navoid loops.\n\n- **validate.yml** — on push/PR, lint every `SKILL.md` frontmatter (required: `name`, `source`,\n `status`; `status ∈ {approved, pending}`).\n- **counters.yml** — scheduled hourly + on push; runs `counters.py --patch` and commits if the\n block changed.\n- **rescan.yml** — scheduled weekly; re-fetches both APIs, diffs new names against existing\n `skills/` entries, and opens one review issue per new focus skill (labels: `pending-approval`,\n `source:*`, `cat:*`).\n\nIssue posting uses your git host's REST API with a token. Labels usually require **numeric IDs**\n(GitHub: `labels: [id,...]`), so fetch the label list first and map `name → id`. Never embed the\ntoken in source; read it from a CI secret.\n\n## Common Pitfalls\n\n1. **Trusting a single `limit=1000` call on skillsmp.com.** The API caps at 100/page regardless\n of the requested limit. Loop `page=1..N` until a page returns `[]` or fewer than 100 items, or\n the catalog silently truncates.\n2. **Parsing the skills.md response with a bare `json.loads`.** When multiple candidate URLs are\n fetched together in one console call, the response can be the JSON array concatenated with\n `===` plus a trailing HTML error page. Slice at the first `}]` before parsing.\n3. **Calling `/api/search?q=` on skillsmp.com.** There is no text-search endpoint — it 404s. Pull\n the full paginated `/api/skills` and filter client-side instead.\n4. **Approving on a bare substring match.** Matching `image`, `write`, or `backend` as a\n substring over-matches unrelated skills. Require exact local-skill-name containment or an\n explicit `known_upgrades` map before marking a skill `approved`.\n5. **Declaring the catalog \"done.\"** It's a living artifact — re-scans keep adding entries. Hand\n the live repo URL to the user for visual verification instead of self-certifying completion.\n6. **Letting the counters workflow commit without `[skip ci]`.** A counter-update commit that\n doesn't skip CI retriggers the same workflow, creating a commit loop.\n7. **Using a read-only token for issue/label creation.** The `rescan.yml` workflow needs write\n scope on the target repo; a read-only token fails with 403. Labels also usually require\n numeric IDs — fetch the label list first and map `name → id`.\n\n## Verification Checklist\n\n- [ ] Both registries pulled from their JSON APIs (not scraped HTML), with skillsmp.com paginated\n to a page returning `[]` or `< 100` items.\n- [ ] Every entry deduped by `(source, name)` before it lands in `CATALOG.md`.\n- [ ] Every `approved` entry has a verified local-skill-name match or an explicit upgrade-map hit\n — no bare substring approvals.\n- [ ] `CATALOG.md`'s three buckets (Approved / Pending / Other) match the per-skill folders under\n `skills/approved/` and `skills/pending/`.\n- [ ] The `COUNTERS_START`/`COUNTERS_END` block reflects real git history, not placeholder zeros.\n- [ ] `validate.yml`, `counters.yml`, and `rescan.yml` are present and counter/rescan commits\n include `[skip ci]`.\n", "readme_content": "# skill-registry-catalog\n\nSurvey public AI-agent skill registries and turn them into a categorized, source-cited catalog\nyou can actually triage — not a bookmark dump.\n\n## What it does\n\nThe agent pulls skill data from public registries (skills.md, skillsmp.com), filters it by the\ncategories you care about, and builds a repository where every third-party skill gets its own\nfolder. Skills are split into three buckets — **approved** (already in your local library),\n**pending** (awaiting your review), and **other** (out of focus) — and a counter tracks how many\nskills you add per time window. Optional CI re-scans the registries on a schedule so the catalog\nstays current.\n\nThis is the \"what's out there worth evaluating\" skill. It deliberately keeps third-party skills\nseparate from your own so you can approve or reject them one at a time.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/skill-registry-catalog/SKILL.md\n```\n\n## How to use\n\n```\n\"Catalog the Discord, security, and MCP skills from skills.md and skillsmp.com,\n separated from my own library, and track how many I add per week.\"\n```\n\nThe agent will:\n\n1. Confirm which registries and focus categories you want.\n2. Fetch the structured data from each registry's JSON API.\n3. Filter by your categories and cross-reference against your local skill library.\n4. Build a repo with per-skill folders, a master `CATALOG.md`, and a `README.md` counter block.\n5. (Optional) Wire up CI to re-scan and update counters on a schedule.\n\n## Sources and APIs\n\nTwo registries are supported out of the box. Always pull from the JSON API, not the HTML page.\n\n| Registry | JSON API | Behavior |\n|---|---|---|\n| skills.md | `https://skills.md/api/skills` | Returns the full skill array in one response |\n| skillsmp.com | `https://skillsmp.com/api/skills?page=N&limit=100` | Caps at 100 items/page — paginate until empty |\n\nBoth return one JSON object per skill with at least `name`, `description`, and a source URL\n(skills.md adds `tags`/`category`; skillsmp.com adds `githubUrl`/`author`/`updatedAt`).\n\n### Fetching skillsmp.com (paginated)\n\n```python\nimport json, urllib.request\n\ndef fetch_skillsmp():\n out, page = [], 1\n while True:\n url = f\"https://skillsmp.com/api/skills?page={page}&limit=100\"\n with urllib.request.urlopen(url) as r:\n data = json.load(r)\n batch = data.get(\"skills\", [])\n if not batch:\n break\n out.extend(batch)\n if len(batch) < 100:\n break\n page += 1\n return out\n```\n\n### Fetching skills.md\n\n```python\nimport json, urllib.request\n\ndef fetch_skillsmd():\n with urllib.request.urlopen(\"https://skills.md/api/skills\") as r:\n raw = r.read().decode()\n end = raw.find(\"}]\") + 2 # guard against concatenated error HTML\n return json.loads(raw[:end])\n```\n\n### Known parsing quirks\n\n- **skillsmp.com** ignores `limit` above 100 — loop until a short or empty page.\n- **skills.md** `/api/skills` can append `===` and an HTML error page if several candidate URLs\n were fetched in one console call. Slice at the first `}]` before parsing.\n- **skillsmp.com** has no search API (`/api/search?q=` is 404). Pull all pages and filter locally.\n\n## Catalog structure\n\n```\n<repo>/\n├── README.md # overview + live COUNTERS block\n├── CATALOG.md # approved / pending / other\n├── .github/workflows/ # validate.yml, counters.yml, rescan.yml\n├── scripts/\n│ ├── build_catalog.py # scrape + filter + write folders\n│ └── counters.py # time-window counter + README patcher\n└── skills/\n ├── approved/<name>__<source>/{README.md, SKILL.md}\n └── pending/<name>__<source>/{README.md, SKILL.md}\n```\n\nFolder names join skill and source (`api-test-suite__skills.md`) so the same skill from two\nregistries never collides. Each per-skill `README.md` carries an `[ ] reviewed` checkbox so a\nhuman can tick skills off one at a time.\n\n### Per-skill SKILL.md frontmatter\n\n```yaml\n---\nname: api-test-suite\nsource: https://skills.md/skills/api-test-suite\nstatus: approved # approved | pending\nincorporated_as: api-test-suite # only for approved\n---\n```\n\n## Filtering\n\nCategories are regex keyword maps over each skill's `name + description + tags + category/author`\n(lower-cased). A skill lands in a category if any pattern matches.\n\n```python\nCATS = {\n \"Discord\": [r\"discord\"],\n \"Security/Pentest\": [r\"security\", r\"pentest\", r\"penetration\", r\"vuln\", r\"\\baudit\\b\",\n r\"exploit\", r\"\\bcve\\b\", r\"secure\", r\"threat\", r\"secret\",\n r\"\\bauth\\b\", r\"hardening\"],\n \"Backend\": [r\"backend\", r\"server\", r\"\\bdb\\b\", r\"database\", r\"orm\",\n r\"microservice\", r\"fastapi\", r\"django\", r\"express\", r\"\\bsql\\b\"],\n \"API\": [r\"\\bapi\\b\", r\"\\brest\\b\", r\"graphql\", r\"openapi\", r\"endpoint\",\n r\"webhook\", r\"\\bsdk\\b\"],\n \"MCP\": [r\"\\bmcp\\b\", r\"model context protocol\", r\"mcp server\", r\"mcp client\"],\n}\n```\n\n### Approved vs pending\n\n- **Approved** — the skill name or function overlaps a skill already in your local library, or it\n is a known upgrade of one you use.\n- **Pending** — everything that fits a focus category but is not yet approved.\n- **Other** — does not fit any focus category and is not approved; still recorded so re-scans are\n stable.\n\nMatch loosely but verify: require exact local-skill-name containment **or** an explicit\n`known_upgrades` allowlist. Reject bare substring hits (`image`, `write`, `backend`) — they\nover-match unrelated skills.\n\n## Counters\n\nThe `README.md` holds a fenced block the counter script rewrites from git history:\n\n```\n<!-- COUNTERS_START -->\n| Window | Skills Added |\n|---|---|\n| Past hour | 0 |\n| Past 24h | 0 |\n| Past 7d | 0 |\n| Past 30d | 0 |\n| Past 6mo | 0 |\n| Past 1y | 0 |\n| All time | 0 |\n<!-- COUNTERS_END -->\n```\n\n`scripts/counters.py --patch` derives the numbers from the timestamps of added files under\n`skills/`:\n\n```bash\ngit log --diff-filter=A --name-only --pretty=%ct -- skills/ \\\n | python scripts/counters.py --patch\n```\n\n## CI / automation\n\nUse your git host's CI directory (GitHub `.github/workflows/`, Forgejo `.forgejo/workflows/`,\nGitLab `.gitlab-ci.yml`). Put the token in a CI secret — never inline it.\n\n- **validate.yml** — on push/PR, lint every `SKILL.md` for required frontmatter\n (`name`, `source`, `status`; `status ∈ {approved, pending}`).\n- **counters.yml** — hourly + on push; runs `counters.py --patch` and commits if the block\n changed. Use `[skip ci]` in the commit message to avoid a self-trigger loop.\n- **rescan.yml** — weekly; re-fetches both APIs, diffs new names against existing `skills/`, and\n opens one review issue per new focus skill. Labels generally need **numeric IDs** — fetch the\n label list first and map `name → id`.\n\n## Requirements\n\n- A local skill library to cross-reference against (so \"approved\" is meaningful).\n- Network access to the registries' JSON APIs.\n- A git host account and a token with repo/issue write scope for the optional automation.\n\n## Pitfalls\n\n- **Page caps** — skillsmp.com returns at most 100 items per call. Loop until short or empty.\n- **Concatenated responses** — slice skills.md output at the first `}]` before `json.loads`.\n- **No search API** — skillsmp.com `/api/search` is 404; filter the full pull locally.\n- **False-positive approvals** — bare substring matches over-approve. Use exact-name or an\n allowlist.\n- **CI loops** — counter commits must carry a skip directive or they re-trigger themselves.\n- **Token scope** — repo/issue creation needs write scope; a read-only token fails with 403.\n\n## Example end state\n\nA repo where `CATALOG.md` lists 40 approved, 120 pending, 300 other skills; `README.md` shows\n23 skills added in the past 7 days; and a Monday CI job opens five new review issues for skills\nthat appeared in the registries since last scan.\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/skill-registry-catalog/SKILL.md" }, { "name": "mcp-server-build", "category": "backend", "tier": "featured", "description": "Build a working MCP server that exposes your tools to AI agents.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/mcp-server-build/SKILL.md", "path": "skills/mcp-server-build", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "mcp-server-build", "description": "Build a working MCP server that exposes your tools to AI agents.", "version": "1.0.0" }, "agent_use": "- The user wants to expose their tools or APIs to an AI agent via MCP.\n- The user wants to build an MCP server for a custom service.\n- The user says \"build an MCP server\", \"make my tool work with MCP\", or \"I want my agent to call my API\".", "user_use": "The agent creates a Model Context Protocol (MCP) server that exposes your tools, resources, and prompts to AI assistants. MCP is an open protocol — any agent that supports it (Hermes, Claude Desktop, etc.) can call your tools through a standardized interface. You define the tools, implement the handlers, and the agent handles the rest.", "skillmd_content": "---\nname: mcp-server-build\ndescription: Use when the user wants to expose their tools, APIs, or data to an AI agent via the Model Context Protocol — building a new MCP server (tools/resources/prompts), choosing stdio vs HTTP transport, writing tool JSON Schemas, or connecting a built server to Hermes or another MCP client.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [mcp, model-context-protocol, tool-schema, server, agent-integration]\n related_skills: [openapi-generator, webhook-receiver]\n---\n\n# mcp-server-build\n\n## Overview\n\nBuild a Model Context Protocol (MCP) server that exposes your tools, resources, and prompts to AI agents. MCP is an open protocol that lets AI assistants interact with external systems through a standardized interface.\n\n## When to Use\n\n- The user wants to expose their tools or APIs to an AI agent via MCP.\n- The user wants to build an MCP server for a custom service.\n- The user says \"build an MCP server\", \"make my tool work with MCP\", or \"I want my agent to call my API\".\n\n## MCP Protocol Basics\n\nMCP servers expose three types of capabilities:\n\n| Capability | What it does |\n|---|---|\n| **Tools** | Functions the agent can call (e.g., `search_database`, `send_email`) |\n| **Resources** | Data the agent can read (e.g., `file://config.json`, `db://schema`) |\n| **Prompts** | Pre-built prompt templates the agent can use |\n\nServers communicate via JSON-RPC over one of two transports:\n\n| Transport | Use case |\n|---|---|\n| **stdio** | Local servers, launched by the agent process |\n| **HTTP** | Remote servers, accessible over the network |\n\n## Workflow\n\n### Step 1: Choose your language and SDK\n\n| Language | SDK | Install |\n|---|---|---|\n| Python | `mcp` | `pip install mcp` |\n| TypeScript | `@modelcontextprotocol/sdk` | `npm install @modelcontextprotocol/sdk` |\n\n### Step 2: Define your tools\n\nEach tool has a name, description, and JSON Schema for its parameters:\n\n```python\nfrom mcp import Server, Tool\nfrom mcp.types import ToolSchema\n\nserver = Server(\"my-server\")\n\n@server.tool()\nasync def search_database(query: str, limit: int = 10) -> str:\n \"\"\"Search the database for matching records.\n\n Args:\n query: The search query string\n limit: Maximum number of results to return\n \"\"\"\n # Your search logic here\n results = await db.search(query, limit=limit)\n return json.dumps(results)\n```\n\n### Step 3: Implement handlers\n\nThe handler is the function that runs when the agent calls the tool. It receives the arguments as a dict and returns a string (or structured content).\n\n```python\n@server.tool()\nasync def get_file(path: str) -> str:\n \"\"\"Read a file from the local filesystem.\"\"\"\n try:\n with open(path, 'r') as f:\n return f.read()\n except FileNotFoundError:\n return f\"Error: File not found at {path}\"\n except PermissionError:\n return f\"Error: Permission denied for {path}\"\n```\n\n### Step 4: Register resources (optional)\n\nResources are URIs the agent can read:\n\n```python\n@server.resource(\"config://app\")\nasync def get_config() -> str:\n \"\"\"Return the application configuration.\"\"\"\n return json.dumps({\"version\": \"1.0\", \"debug\": False})\n```\n\n### Step 5: Choose transport and run\n\n**stdio (local):**\n```python\nimport asyncio\nfrom mcp.server.stdio import stdio_server\n\nasync def main():\n async with stdio_server() as (read_stream, write_stream):\n await server.run(read_stream, write_stream)\n\nasyncio.run(main())\n```\n\n**HTTP (remote):**\n```python\nfrom mcp.server.sse import sse_server\n\nasync def main():\n async with sse_server(\"0.0.0.0\", 8080) as (read_stream, write_stream):\n await server.run(read_stream, write_stream)\n\nasyncio.run(main())\n```\n\n### Step 6: Test with an MCP client\n\n```bash\n# Using the MCP inspector (comes with the SDK)\nmcp inspect python my_server.py\n\n# Or connect from an agent that supports MCP\n# Hermes: hermes mcp add my-server --command \"python my_server.py\"\n# Claude Desktop: add to claude_desktop_config.json\n```\n\n## Tool Schema\n\nEvery tool needs a clear JSON Schema so the agent knows what arguments to pass:\n\n```python\nTOOL_SCHEMA = {\n \"name\": \"search_database\",\n \"description\": \"Search the database for matching records.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\n \"type\": \"string\",\n \"description\": \"The search query string\"\n },\n \"limit\": {\n \"type\": \"integer\",\n \"description\": \"Maximum results to return\",\n \"default\": 10\n }\n },\n \"required\": [\"query\"]\n }\n}\n```\n\nGood descriptions are critical — the agent uses them to decide when to call the tool.\n\n## Full Example: File Search Server\n\n```python\n#!/usr/bin/env python3\nimport asyncio\nimport json\nimport os\nfrom mcp import Server\nfrom mcp.server.stdio import stdio_server\n\nserver = Server(\"file-search\")\n\n@server.tool()\nasync def search_files(directory: str, pattern: str) -> str:\n \"\"\"Search for files matching a pattern in a directory.\n\n Args:\n directory: The root directory to search in\n pattern: Filename pattern (e.g., '*.py', '*.json')\n \"\"\"\n import fnmatch\n matches = []\n for root, dirs, files in os.walk(directory):\n for filename in fnmatch.filter(files, pattern):\n matches.append(os.path.join(root, filename))\n return json.dumps(matches[:50], indent=2)\n\n@server.tool()\nasync def read_file(path: str) -> str:\n \"\"\"Read the contents of a file.\"\"\"\n try:\n with open(path, 'r') as f:\n return f.read()[:10000] # cap at 10k chars\n except Exception as e:\n return f\"Error: {e}\"\n\n@server.resource(\"stats://file-search\")\nasync def get_stats() -> str:\n \"\"\"Return search statistics.\"\"\"\n return json.dumps({\"server\": \"file-search\", \"version\": \"1.0\"})\n\nasync def main():\n async with stdio_server() as (read_stream, write_stream):\n await server.run(read_stream, write_stream)\n\nif __name__ == \"__main__\":\n asyncio.run(main())\n```\n\n## Connecting to Hermes\n\n```bash\n# Add as a stdio server\nhermes mcp add file-search --command \"python /path/to/file_search_server.py\"\n\n# Add as an HTTP server\nhermes mcp add file-search --url http://localhost:8080\n\n# Test the connection\nhermes mcp test file-search\n\n# List available tools from the server\nhermes mcp list\n```\n\n## Common Pitfalls\n\n1. **Vague tool descriptions.** The agent decides whether to call a tool based on its description alone. \"Searches stuff\" won't get called. \"Searches the database for records matching a query string, returns up to N results as JSON\" will.\n2. **No error handling.** Tools that crash on bad input break the agent's whole turn. Always catch exceptions and return error messages as strings — never let a handler raise unhandled.\n3. **Returning too much data.** Agents have context limits. Cap returns at a reasonable size (10k chars, 50 results as shown above) and let the agent ask for more if needed.\n4. **No input validation.** Validate paths, queries, and parameters before processing. Don't trust the agent to send valid input — it will occasionally pass malformed or out-of-range values.\n5. **Blocking operations inside async handlers.** MCP handlers should be async. Blocking I/O (file reads, database queries) run inline stalls the whole server; run them in a thread executor.\n6. **Transport mismatch.** stdio servers are launched by the agent process and read/write over the process pipes; HTTP servers must be running independently before the agent connects. Don't try to serve HTTP over stdio or vice versa.\n7. **Forgetting the session is cached.** After adding a new tool to a running server, some MCP clients (including Hermes) cache the tool list from initial connection — reconnect or restart the client to see new tools.\n\n## Verification Checklist\n\n- [ ] `mcp inspect python my_server.py` (or equivalent) lists every tool with its schema and description\n- [ ] Each tool returns a string/structured result for both valid and invalid input — no unhandled exception in the server logs\n- [ ] stdio servers are invoked by the client's own command (not left running as an orphan process); HTTP servers are confirmed reachable with `curl` before wiring into the client\n- [ ] `hermes mcp test <name>` (or the equivalent client-side test) succeeds and the tool list matches what the server defines\n- [ ] A sample call to at least one tool returns the expected data end-to-end, not just a schema-level pass\n", "readme_content": "# mcp-server-build\n\nBuild a working MCP server that exposes your tools to AI agents.\n\n## What it does\n\nThe agent creates a Model Context Protocol (MCP) server that exposes your tools, resources, and prompts to AI assistants. MCP is an open protocol — any agent that supports it (Hermes, Claude Desktop, etc.) can call your tools through a standardized interface. You define the tools, implement the handlers, and the agent handles the rest.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/mcp-server-build/SKILL.md\n```\n\n## How to use\n\n```\n\"Build an MCP server that lets my agent search my codebase\"\n```\n\nThe agent:\n1. Defines the tool (name, description, parameter schema)\n2. Implements the handler (the function that runs when the agent calls it)\n3. Chooses a transport (stdio for local, HTTP for remote)\n4. Tests the connection\n5. Shows you how to wire it into Hermes or Claude Desktop\n\n## Example\n\n```\nUser: \"I want my agent to query my Postgres database via MCP\"\n\nAgent:\n 1. Defines tool: query_database(sql: str, limit: int) -> str\n 2. Implements handler with psycopg2, caps results at 100 rows\n 3. Runs as a stdio server\n 4. Connects: hermes mcp add pg-server --command \"python pg_server.py\"\n 5. Tests: hermes mcp test pg-server → \"Connected, 1 tool available\"\n```\n\n## Transports\n\n| Transport | When to use |\n|---|---|\n| stdio | Local servers, launched by the agent |\n| HTTP | Remote servers, accessible over network |\n\n## Connecting to Hermes\n\n```bash\nhermes mcp add my-server --command \"python my_server.py\" # stdio\nhermes mcp add my-server --url http://localhost:8080 # HTTP\nhermes mcp test my-server # verify\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/mcp-server-build/SKILL.md" }, { "name": "webhook-receiver", "category": "backend", "tier": "featured", "description": "Set up a webhook endpoint that receives and processes incoming webhooks with signature verification.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/webhook-receiver/SKILL.md", "path": "skills/webhook-receiver", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "webhook-receiver", "description": "Set up a webhook endpoint that receives and processes incoming webhooks with signature verification.", "version": "1.0.0" }, "agent_use": "Use this skill when:\n\n- An external provider (payments, git host, CI, SaaS) needs to **push** events to\n you via an HTTPS callback instead of you polling their API.\n- You need to react to events in (near) real time: `payment.succeeded`,\n `push.created`, `issue.opened`, `order.fulfilled`, etc.\n- You want a single, auditable receive → verify → process → respond pipeline.\n- The integration must be replay-safe (a retried delivery must not double-fire a\n side effect).\n\nDo **not** use this skill for: outbound API calls (that's a normal HTTP client),\nlong-polling/streaming consumers (use the provider's SDK), or receiving files\nlarger than a few MB (use signed upload URLs instead).", "user_use": "", "skillmd_content": "---\nname: webhook-receiver\ndescription: Use when the user needs an HTTP endpoint that receives inbound webhooks from an external service (Stripe, GitHub, Slack, Shopify, CI) and must verify the sender's signature, parse the payload safely, dedupe retried deliveries, and return the right status code — not for outbound API calls or long-polling/streaming consumers.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [webhooks, hmac-verification, idempotency, http-api, event-ingestion]\n related_skills: [http-api-tester, env-config-manager, telegram-bot-build]\n---\n\n# webhook-receiver\n\n## Overview\n\nTurn any Hermes-managed host into a trustworthy webhook ingestion point. This\nskill gives you a production-ready pattern for receiving HTTP POSTs from external\nservices, verifying they really came from the sender, parsing the payload safely,\nand returning the status code the sender expects — without dropping events under\nload or leaking secrets in logs.\n\n## When to Use\n\nUse this skill when:\n\n- An external provider (payments, git host, CI, SaaS) needs to **push** events to\n you via an HTTPS callback instead of you polling their API.\n- You need to react to events in (near) real time: `payment.succeeded`,\n `push.created`, `issue.opened`, `order.fulfilled`, etc.\n- You want a single, auditable receive → verify → process → respond pipeline.\n- The integration must be replay-safe (a retried delivery must not double-fire a\n side effect).\n\nDo **not** use this skill for: outbound API calls (that's a normal HTTP client),\nlong-polling/streaming consumers (use the provider's SDK), or receiving files\nlarger than a few MB (use signed upload URLs instead).\n\n## Architecture\n\n```\n External Sender Your Endpoint Your System\n┌──────────────┐ HTTPS POST ┌──────────────────┐ enqueue/ ┌──────────────┐\n│ Stripe/GitHub│ ─────────────▶ │ /webhooks/<src> │ ───────────▶ │ handler /\n│ (signs body)│ body + HMAC │ 1. verify sig │ async job │ DB / action │\n└──────────────┘ header │ 2. parse JSON │ └──────────────┘\n │ 3. idempotent │\n │ 4. respond 2xx │\n └──────────────────┘\n```\n\nCore principles:\n\n- **Verify before you trust.** Reject any request whose signature does not match\n before parsing or acting on the body.\n- **Return fast.** Do the minimum in the request thread (verify + enqueue), then\n process asynchronously. A slow handler makes the sender retry and floods you.\n- **Be idempotent.** Senders often retry. Use the event ID + a dedup store so a\n redelivered event runs its side effect at most once.\n- **Separate routes per source.** `/webhooks/stripe`, `/webhooks/github` — each\n source has its own secret and verification scheme.\n\n## Workflow\n\n### 1. Create the endpoint\n\nExpose a single POST route per source behind TLS. Keep it thin:\n\n```python\nfrom flask import Flask, request, abort\nimport hmac, hashlib, json\n\napp = Flask(__name__)\n\n@app.post(\"/webhooks/stripe\")\ndef stripe_webhook():\n payload = request.get_data() # raw bytes — needed for HMAC\n sig = request.headers.get(\"Stripe-Signature\", \"\")\n if not verify_stripe(payload, sig):\n abort(400)\n event = json.loads(payload)\n handle_event(event) # enqueue, don't block\n return \"\", 200\n```\n\n### 2. Validate signatures\n\nNever act on an unverified body. Compute HMAC-SHA256 over the **raw** bytes with\nyour shared secret and compare against the sender's header using a constant-time\ncompare. See [Signature Verification](#signature-verification).\n\n### 3. Process the payload\n\n- Parse defensively: wrap `json.loads` in try/except; reject non-JSON with 400.\n- Extract the event type and a stable event ID.\n- Check the dedup store; if already processed, return 200 immediately.\n- Otherwise enqueue for async processing and record the event ID.\n\n```python\ndef handle_event(event):\n event_id = event.get(\"id\")\n if event_id and seen(event_id):\n return\n queue.put(event) # e.g. Redis/RQ, SQS, a worker\n mark_seen(event_id)\n```\n\n### 4. Respond\n\nSend the smallest correct response:\n\n- **200 / 204** — accepted. Sender stops retrying.\n- **400** — malformed or bad signature. (Most senders will not retry 4xx.)\n- **500** — only on genuine internal failure; senders will retry.\n- Keep the body empty or a tiny JSON ack. Do not echo the payload back.\n- Target a p95 response under ~1s; offload heavy work to the worker.\n\n## Signature Verification\n\nSenders use HMAC. The exact header/algorithm varies by provider:\n\n| Provider | Header | Scheme |\n|-----------|-----------------------|----------------------------------------------------|\n| Stripe | `Stripe-Signature` | `t=<ts>,v1=<HMAC-SHA256(raw, secret)>` |\n| GitHub | `X-Hub-Signature-256` | `sha256=<HMAC-SHA256(raw, secret)>` |\n| Slack | `X-Slack-Signature` | `v0=<HMAC-SHA256(\"v0:\"+ts+\":\"+raw, secret)>` |\n\nGeneric verifier:\n\n```python\ndef constant_time_equal(a, b):\n return hmac.compare_digest(a, b)\n\ndef verify(payload: bytes, header_sig: str, secret: str, prefix: str = \"sha256=\"):\n expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()\n provided = header_sig[len(prefix):] if header_sig.startswith(prefix) else header_sig\n return constant_time_equal(expected, provided)\n```\n\nRules:\n- Use `hmac.compare_digest`, never `==` (avoids timing leaks).\n- Verify over **raw bytes**, not the re-serialized JSON (whitespace/key order\n changes the digest).\n- For timestamped schemes (Stripe, Slack), reject if `|now - ts| > 300s` to\n prevent replay attacks.\n- Store the secret in an env var / secret manager. Never in source control.\n\n## Payload Handling\n\n- **Define a schema.** Map known event types to handlers; ignore unknown types\n with 200 (senders add new types without warning).\n- **Validate shape, not just presence.** Use a lightweight validator (pydantic,\n jsonschema) so a malformed event fails loudly rather than crashing mid-flight.\n- **Don't trust types.** A `price` field could arrive as string; coerce\n explicitly.\n- **Log minimally.** Record `event_id`, `type`, and `source` — never the full\n body or headers (they may contain PII or secrets).\n- **Backpressure.** If the queue is full, return 503 so the sender retries later\n instead of losing the event.\n\n## Error Handling\n\n- **Bad signature / malformed JSON** → 400, no retry expected. Log as security\n event.\n- **Unknown event type** → 200, drop silently (or to a dead-letter log).\n- **Transient processing failure** → raise so the worker retries with backoff.\n Keep the HTTP layer returning 200 once the event is durably enqueued.\n- **Duplicate delivery** → detected via dedup store, return 200, no side effect.\n- **Dead letters.** After N retries, move to a DLQ for human inspection rather\n than dropping.\n- Never let an unhandled exception bubble into a 500 for a *verified* event that\n you already accepted — that triggers wasteful redelivery.\n\n## Common Pitfalls\n\n1. **Verifying the wrong bytes.** Re-serializing JSON before computing the HMAC is the #1 bug —\n whitespace/key-order changes the digest. Always digest the raw body (`request.get_data()`),\n never `json.dumps(parsed_body)`.\n2. **Using `==` for signature comparison.** A naive string compare leaks timing information.\n Always use `hmac.compare_digest`.\n3. **Doing the real work inside the request handler.** Slow processing causes the sender to time\n out and retry, which compounds load. Verify + enqueue, then return — process asynchronously.\n4. **Skipping the dedup/idempotency check.** Senders retry on any ambiguous response. Without a\n dedup store keyed on event ID, a retried delivery re-runs the side effect (double-charge,\n double-post).\n5. **Leaving the shared secret in git history or logs.** Rotate immediately if leaked; store it\n in a secret manager, and never log the full request body or headers.\n6. **Skipping the replay-window check on timestamped schemes.** Stripe and Slack sign with a\n timestamp; without enforcing `|now - ts| > 300s` rejection, a captured request can be replayed\n later even with a valid signature.\n7. **Returning 500 for an unknown-but-harmless event type.** That triggers sender retries for\n something that was never going to be handled. Return 200 and drop (or log to a dead-letter\n sink) instead.\n8. **Sharing one route across sources instead of one per source.** A single shared endpoint means\n one compromised secret affects every integration, and there's no way to rotate one source's\n secret without touching the others.\n9. **Not capping request body size.** An unbounded `Content-Length` read on `/webhooks/*` is a\n trivial DoS vector — cap it (e.g. 1–5 MB) before reading the body.\n\n## Verification Checklist\n\n- [ ] Endpoint is HTTPS-only and returns 200 for a valid signed test event.\n- [ ] Invalid signature returns 400 and is logged as a security event.\n- [ ] Same event ID delivered twice runs the side effect once.\n- [ ] Response time < 1s even when processing is slow (offloaded to worker).\n- [ ] Secret is in a secret manager, absent from source and logs.\n- [ ] Replay with a stale timestamp is rejected.\n", "readme_content": "# webhook-receiver\n\nA public Hermes skill for building **secure, reliable inbound webhook endpoints**.\n\nReceive events from Stripe, GitHub, Slack, Shopify, CI systems, and any other\nprovider that pushes via HTTPS — verify they really came from the sender, process\nthem safely, and respond with the exact status code the sender expects.\n\n> Agent + skill = a working webhook endpoint, not a tutorial.\n\n## Why this exists\n\nMost webhook integrations break in the same three places: the signature check is\nwrong, retries cause double-processing, or a slow handler triggers a retry\nstorm. `webhook-receiver` encodes the verify → parse → dedupe → enqueue →\nrespond pipeline that fixes all three by default.\n\n## What you get\n\n- **Thin, TLS-only endpoints** — one POST route per source, returning in <1s.\n- **HMAC signature verification** — constant-time compare over raw bytes, with\n per-provider header schemes (Stripe, GitHub, Slack) and replay-window checks.\n- **Idempotent processing** — dedup on event ID so retried deliveries never\n double-fire a side effect.\n- **Safe payload handling** — schema validation, minimal logging (no PII/secrets\n in logs), and backpressure via 503.\n- **Clear error contract** — 200 accept, 400 reject, 500 retry; unknown event\n types dropped cleanly.\n\n## Install\n\nPoint Hermes at the skill manifest:\n\n```\nhttps://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/webhook-receiver/SKILL.md\n```\n\nOr clone the portfolio and load `skills/webhook-receiver` locally.\n\n## Quick start\n\n```python\nimport hmac, hashlib, json\nfrom flask import Flask, request, abort\n\napp = Flask(__name__)\nSECRET = \"your-secret-from-secret-manager\" # never hardcode\n\n@app.post(\"/webhooks/stripe\")\ndef stripe_webhook():\n payload = request.get_data() # raw bytes for HMAC\n sig = request.headers.get(\"Stripe-Signature\", \"\")\n expected = hmac.new(SECRET.encode(), payload, hashlib.sha256).hexdigest()\n if not hmac.compare_digest(expected, sig.split(\"v1=\")[-1]):\n abort(400) # verify before trust\n event = json.loads(payload)\n handle_event(event) # enqueue, don't block\n return \"\", 200\n```\n\nSee `SKILL.md` for the full workflow, signature tables, dedup patterns, and the\nverification checklist.\n\n## Author\n\nPart of the public [Hermes Skills Portfolio](https://github.com/THEROCKSSS/hermes-skills-portfolio)\nby **Alex**.\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/webhook-receiver/SKILL.md" }, { "name": "cron-task", "category": "utility", "tier": "featured", "description": "Set up scheduled agent tasks with delivery to messaging platforms.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/cron-task/SKILL.md", "path": "skills/cron-task", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "cron-task", "description": "Set up scheduled agent tasks with delivery to messaging platforms.", "version": "1.0.0" }, "agent_use": "- The user wants a recurring task (daily summary, weekly report, hourly check).\n- The user wants to be notified on a schedule (server health check, price alert, feed monitor).\n- The user says \"run this every day\", \"schedule a task\", \"check this hourly\", or \"send me a daily summary\".", "user_use": "The agent creates a scheduled task — either agent-driven (runs a prompt) or script-only (runs a script) — that executes on a schedule you define and delivers results to Telegram, Discord, Slack, or email. The task runs autonomously. You get the output in your chat; you don't need to be at a terminal.", "skillmd_content": "---\nname: cron-task\ndescription: Use when the user wants a recurring task (daily summary, weekly report, hourly check), wants to be notified on a schedule (server health check, price alert, feed monitor), or says \"run this every day\", \"schedule a task\", \"check this hourly\", or \"send me a daily summary\".\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [scheduled-tasks, cron, automation, recurring-jobs, delivery]\n related_skills: [ntfy-notifier, telegram-bot-build, rss-monitor]\n---\n\n# cron-task\n\n## Overview\n\nCreate scheduled tasks that run an agent or script on a recurring schedule and deliver results to a messaging platform (Telegram, Discord, Slack, email). The task runs autonomously — no human needs to be present.\n\n## When to Use\n\n- The user wants a recurring task (daily summary, weekly report, hourly check).\n- The user wants to be notified on a schedule (server health check, price alert, feed monitor).\n- The user says \"run this every day\", \"schedule a task\", \"check this hourly\", or \"send me a daily summary\".\n\n## Schedule Syntax\n\nThree formats are supported:\n\n| Format | Example | Meaning |\n|---|---|---|\n| Duration | `30m`, `2h`, `6h` | Every 30 min, 2 hours, 6 hours |\n| Cron expression | `0 9 * * *` | At 9:00 AM every day |\n| ISO timestamp | `2026-07-20T09:00:00` | One-shot at a specific time |\n\nCron fields (5 fields, minute-level granularity):\n```\n┌───── minute (0-59)\n│ ┌───── hour (0-23)\n│ │ ┌───── day of month (1-31)\n│ │ │ ┌───── month (1-12)\n│ │ │ │ ┌───── day of week (0-6, Sunday=0)\n0 9 * * * → every day at 9 AM\n*/30 * * * * → every 30 minutes\n0 9 * * 1 → every Monday at 9 AM\n0 0 1 * * → first of every month at midnight\n```\n\n## Task Types\n\n| Type | Description | Use case |\n|---|---|---|\n| **Agent-driven** | The agent runs a prompt on schedule | Summarize a feed, write a report, analyze data |\n| **Script-only** | A script runs and its output is delivered | Server health check, log scan, metric threshold |\n\nAgent-driven tasks: the scheduler runs the agent with the given prompt. The agent has tool access (web, terminal, file) and produces a response that gets delivered.\n\nScript-only tasks: the scheduler runs a script (bash or Python). The script's stdout is delivered verbatim. No agent, no tokens, no model call. If the script produces no output, nothing is delivered (silent on no-news).\n\n## Delivery\n\nResults are delivered to one or more messaging platforms:\n\n| Platform | Delivery format |\n|---|---|\n| Telegram | Message to a chat or topic |\n| Discord | Message to a channel or thread |\n| Slack | Message to a channel |\n| Email | Plain-text or HTML email |\n| SMS | Short text message |\n\nThe delivery includes a header identifying the job, the content, and a footer. The message is not mirrored into the target session — it's a one-way delivery that preserves session integrity.\n\n## Workflow\n\n### Step 1: Define the task\n\nConfirm with the user:\n- What should the task do? (the prompt or script)\n- How often? (the schedule)\n- Where should results go? (the delivery target)\n\n### Step 2: Create the job\n\n**Via the Hermes CLI:**\n```bash\nhermes cron create \"0 9 * * *\" --prompt \"Check server health and report any issues\" --deliver telegram\n```\n\n**Via the cronjob tool (in-session):**\n```\ncronjob(action=\"create\", schedule=\"0 9 * * *\", prompt=\"Check server health and report any issues\", deliver=\"telegram\")\n```\n\n### Step 3: Script-only task (if no agent needed)\n\n```\ncronjob(\n action=\"create\",\n schedule=\"*/30m\",\n script=\"scripts/health_check.py\",\n no_agent=True,\n deliver=\"telegram\"\n)\n```\n\nThe script runs every 30 minutes. Non-empty stdout is delivered. Empty stdout = silent (nothing sent). Non-zero exit = error alert sent.\n\n### Step 4: Chain jobs (optional)\n\nOne job's output can feed into another:\n\n```\ncronjob(\n action=\"create\",\n schedule=\"0 6 * * *\",\n prompt=\"Collect overnight metrics and summarize\",\n name=\"metrics-collector\"\n)\n\ncronjob(\n action=\"create\",\n schedule=\"0 9 * * *\",\n prompt=\"Write a daily briefing from the collected metrics\",\n context_from=[\"metrics-collector\"],\n deliver=\"telegram\"\n)\n```\n\nThe second job receives the first job's most recent output as context.\n\n### Step 5: Verify\n\n```bash\nhermes cron list # see all jobs\nhermes cron run <id> # trigger immediately for testing\n```\n\n## Common Patterns\n\n| Pattern | Schedule | Type | Example |\n|---|---|---|---|\n| Daily briefing | `0 9 * * *` | Agent | \"Summarize overnight activity and news\" |\n| Hourly health check | `1h` | Script | `health_check.py` — silent unless failure |\n| Weekly report | `0 18 * * 5` | Agent | \"Generate weekly metrics report\" |\n| Price alert | `*/30m` | Script | `price_check.py` — silent unless threshold hit |\n| Feed monitor | `1h` | Agent | \"Check the RSS feed for new entries, notify if any\" |\n\n## Common Pitfalls\n\n1. **Silent failures on bad output, not just bad exit codes.** A script that crashes with a non-zero exit code sends an error alert. But a script that runs successfully and produces wrong output won't alert anyone. Test scripts manually before scheduling them.\n2. **Hitting platform rate limits.** A task running every minute that sends a message every time will hit Telegram's rate limit within an hour. Only deliver when there's something to say — use the silent-on-no-news pattern.\n3. **Long-running tasks get killed.** There's a 3-minute hard interrupt per run. If your prompt or script takes longer, it gets killed mid-run. Break long work into chunks or use a background process instead.\n4. **Timezone confusion in cron expressions.** Cron expressions run in the host's local timezone by default, not UTC and not the user's timezone. Confirm the timezone with the user if the schedule needs to land at an exact wall-clock time.\n5. **Fighting the duplicate-tick lock.** A lock file prevents duplicate runs across processes. Don't try to work around it — if a tick is locked, the previous run is still in progress, not stuck.\n6. **Delivering to a misconfigured target.** The delivery target must already be configured in the gateway. A target that doesn't exist fails silently — verify the platform/channel is reachable before scheduling, not after the first missed delivery.\n7. **Context bloat in chained jobs.** `context_from` injects the *full* output of the upstream job into the downstream prompt. If the upstream produces a large report, the downstream job's prompt gets inflated every run. Keep upstream outputs concise by design.\n\n## Verification Checklist\n\n- [ ] `hermes cron run <id>` was used to trigger the job once manually and confirmed it delivers correctly before relying on the schedule\n- [ ] Script-only jobs were tested standalone (`python scripts/health_check.py`) to confirm stdout behavior on both success and failure\n- [ ] Delivery target (Telegram chat, Discord channel, etc.) is already configured in the gateway, not just assumed to exist\n- [ ] Schedule's timezone matches what the user expects (confirmed, not assumed to be UTC or local)\n- [ ] `hermes cron list` shows the new job with the correct schedule string\n- [ ] Chained jobs' upstream output is short enough not to bloat the downstream prompt on every run\n", "readme_content": "# cron-task\n\nSet up scheduled agent tasks that run on a recurring schedule and deliver results to your messaging platform.\n\n## What it does\n\nThe agent creates a scheduled task — either agent-driven (runs a prompt) or script-only (runs a script) — that executes on a schedule you define and delivers results to Telegram, Discord, Slack, or email. The task runs autonomously. You get the output in your chat; you don't need to be at a terminal.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/cron-task/SKILL.md\n```\n\n## How to use\n\n**Daily server health check delivered to Telegram:**\n```\n\"Send me a server health check every morning at 9 AM on Telegram\"\n```\n\nThe agent creates a cron job with schedule `0 9 * * *`, the health-check prompt, and delivery to Telegram. You get a message at 9 AM every day with the results.\n\n**Silent script that only alerts on failure:**\n```\n\"Check my API every 30 minutes, only message me if it's down\"\n```\n\nThe agent creates a script-only job that runs every 30 minutes. The script exits silently (empty stdout) when the API is healthy. On failure, it outputs an error message that gets delivered to you.\n\n## Schedule formats\n\n| Format | Example | Meaning |\n|---|---|---|\n| Duration | `30m`, `2h` | Every 30 minutes, every 2 hours |\n| Cron | `0 9 * * *` | Daily at 9 AM |\n| Cron | `0 18 * * 5` | Every Friday at 6 PM |\n| ISO timestamp | `2026-07-20T09:00:00` | One-shot at a specific time |\n\n## Task types\n\n| Type | How it works | Best for |\n|---|---|---|\n| Agent-driven | Agent runs a prompt with tool access | Summaries, reports, analysis |\n| Script-only | Script runs, stdout is delivered | Health checks, threshold alerts, log scans |\n\nScript-only tasks are silent when there's nothing to report (empty stdout = no message sent). This is the watchdog pattern — only alert on failure.\n\n## Example\n\n```\nUser: \"Check my website every 5 minutes and text me if it's down\"\n\nAgent:\n 1. Creates a script-only job: schedule */5m\n 2. Script: curl -sf https://mysite.com > /dev/null || echo \"SITE DOWN\"\n 3. Delivery: SMS\n 4. Tests: hermes cron run <id> → \"SITE DOWN\" (if actually down) or silent\n 5. Returns: \"Monitoring started. You'll get a text only if the site is unreachable.\"\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/cron-task/SKILL.md" }, { "name": "discord-bot-build", "category": "integrations", "tier": "featured", "description": "Build a working Discord bot with slash commands, events, and moderation.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/discord-bot-build/SKILL.md", "path": "skills/discord-bot-build", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "discord-bot-build", "description": "Build a working Discord bot with slash commands, events, and moderation.", "version": "1.0.0" }, "agent_use": "- The user wants a Discord bot for their server.\n- The user wants to automate moderation, send announcements, or add custom commands.\n- The user says \"build a Discord bot\", \"make a bot for my server\", or \"I need a Discord mod bot\".", "user_use": "The agent creates a Discord bot using discord.js — sets up the project, registers slash commands, implements event handlers, and adds moderation commands (kick, ban, mute, purge). The bot connects to your server and responds to commands in real time.", "skillmd_content": "---\nname: discord-bot-build\ndescription: Use when the user wants a Discord bot for their server, wants to automate moderation/announcements/custom commands, or says \"build a Discord bot\", \"make a bot for my server\", or \"I need a Discord mod bot\".\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [discord-bot, discordjs, slash-commands, moderation-bot, gateway-intents]\n related_skills: [telegram-bot-build, env-config-manager]\n---\n\n# discord-bot-build\n\n## Overview\n\nBuild a Discord bot with slash commands, event handlers, and moderation capabilities using discord.js. The bot runs as a Node.js process and connects to Discord via the Gateway.\n\n## When to Use\n\n- The user wants a Discord bot for their server.\n- The user wants to automate moderation, send announcements, or add custom commands.\n- The user says \"build a Discord bot\", \"make a bot for my server\", or \"I need a Discord mod bot\".\n\n## Prerequisites\n\n1. **Node.js 18+** — check with `node --version`\n2. **A Discord bot token** — create a bot at https://discord.com/developers/applications\n3. **Privileged Gateway Intents** — enable in the Developer Portal:\n - Presence Intent (if you need online/offline tracking)\n - Server Members Intent (if you need member lists)\n - Message Content Intent (required for reading message text)\n\n## Bot Setup\n\n### Step 1: Create the application\n\n1. Go to https://discord.com/developers/applications\n2. Click \"New Application\" → name it → go to the \"Bot\" tab\n3. Click \"Add Bot\" → copy the **token** (keep it secret)\n4. Under \"Privileged Gateway Intents\", enable Message Content Intent\n5. Under \"OAuth2 → URL Generator\", select `bot` + `applications.commands` scopes and the permissions you need\n6. Open the generated URL to invite the bot to your server\n\n### Step 2: Initialize the project\n\n```bash\nmkdir my-bot && cd my-bot\nnpm init -y\nnpm install discord.js dotenv\n```\n\n### Step 3: Create the bot file\n\n```javascript\n// index.js\nconst { Client, GatewayIntentBits, SlashCommandBuilder, Events } = require('discord.js');\nrequire('dotenv').config();\n\nconst client = new Client({\n intents: [\n GatewayIntentBits.Guilds,\n GatewayIntentBits.GuildMessages,\n GatewayIntentBits.MessageContent,\n GatewayIntentBits.GuildMembers,\n ],\n});\n\nclient.once(Events.ClientReady, c => {\n console.log(`Logged in as ${c.user.tag}`);\n});\n\n// Register slash commands\nclient.on(Events.InteractionCreate, async interaction => {\n if (!interaction.isChatInputCommand()) return;\n\n if (interaction.commandName === 'ping') {\n await interaction.reply('Pong!');\n }\n\n if (interaction.commandName === 'kick') {\n const target = interaction.options.getUser('user');\n const reason = interaction.options.getString('reason') ?? 'No reason provided';\n if (!interaction.memberPermissions.has('KickMembers')) {\n await interaction.reply('You do not have permission to kick members.');\n return;\n }\n try {\n await interaction.guild.members.kick(target, reason);\n await interaction.reply(`Kicked ${target.tag} for: ${reason}`);\n } catch (error) {\n await interaction.reply(`Failed to kick: ${error.message}`);\n }\n }\n});\n\nclient.login(process.env.DISCORD_TOKEN);\n```\n\n### Step 4: Create .env\n\n```\nDISCORD_TOKEN=your_bot_token_here\n```\n\n### Step 5: Register slash commands\n\n```javascript\n// deploy-commands.js\nconst { REST, Routes, SlashCommandBuilder } = require('discord.js');\nrequire('dotenv').config();\n\nconst commands = [\n new SlashCommandBuilder()\n .setName('ping')\n .setDescription('Replies with pong'),\n new SlashCommandBuilder()\n .setName('kick')\n .setDescription('Kick a member')\n .addUserOption(opt => opt.setName('user').setDescription('The user to kick').setRequired(true))\n .addStringOption(opt => opt.setName('reason').setDescription('Reason for kicking')),\n].map(cmd => cmd.toJSON());\n\nconst rest = new REST().setToken(process.env.DISCORD_TOKEN);\n\n(async () => {\n try {\n await rest.put(Routes.applicationCommands(process.env.CLIENT_ID), { body: commands });\n console.log('Slash commands registered.');\n } catch (error) {\n console.error(error);\n }\n})();\n```\n\nRun once: `node deploy-commands.js`\n\n### Step 6: Start the bot\n\n```bash\nnode index.js\n```\n\n## Command Registration\n\nCommands must be registered before they appear in Discord. Two scopes:\n\n| Scope | Where it appears | Registration |\n|---|---|---|\n| Global | All servers the bot is in | Up to 1 hour to propagate |\n| Guild | One specific server | Instant |\n\nFor development, use guild commands (instant). For production, use global commands.\n\n```javascript\n// Guild command (instant, for testing)\nawait rest.put(\n Routes.applicationGuildCommands(clientId, guildId),\n { body: commands }\n);\n\n// Global command (production, up to 1hr delay)\nawait rest.put(\n Routes.applicationCommands(clientId),\n { body: commands }\n);\n```\n\n## Event Handling\n\n```javascript\n// Member joined\nclient.on(Events.GuildMemberAdd, member => {\n const channel = member.guild.systemChannel;\n if (channel) channel.send(`Welcome ${member} to the server!`);\n});\n\n// Message deleted\nclient.on(Events.MessageDelete, message => {\n console.log(`Message deleted in #${message.channel.name}: ${message.content}`);\n});\n\n// Reaction added\nclient.on(Events.MessageReactionAdd, (reaction, user) => {\n if (reaction.emoji.name === '📌') {\n // Pin the message\n reaction.message.pin();\n }\n});\n```\n\n## Moderation Commands\n\n| Command | What it does | Required permission |\n|---|---|---|\n| `/kick @user [reason]` | Remove a member | KickMembers |\n| `/ban @user [reason]` | Ban a member | BanMembers |\n| `/mute @user [duration]` | Timeout a member | ModerateMembers |\n| `/purge <count>` | Delete recent messages | ManageMessages |\n| `/warn @user <reason>` | Issue a warning | ModerateMembers |\n\n## Keeping the Bot Running\n\n**With PM2 (recommended):**\n```bash\nnpm install -g pm2\npm2 start index.js --name my-bot\npm2 save\npm2 startup # auto-restart on reboot\n```\n\n**With Docker:**\n```dockerfile\nFROM node:20-slim\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --production\nCOPY . .\nCMD [\"node\", \"index.js\"]\n```\n\n## Common Pitfalls\n\n1. **Message Content Intent not enabled.** The bot can't read message text without this. Enable it in the Developer Portal under \"Privileged Gateway Intents\" — without it, `message.content` is always empty even though the event still fires.\n2. **Token committed or leaked.** Never commit the `.env` file — add it to `.gitignore`. If the token leaks, regenerate it immediately in the Developer Portal; a leaked token gives full control of the bot.\n3. **Commands not appearing after deploy.** Global commands take up to 1 hour to propagate. Use guild commands (`applicationGuildCommands`) for testing since they're instant. Also re-run `deploy-commands.js` after adding or changing any command definition — editing `index.js` alone doesn't re-register them.\n4. **Bot can't kick/ban despite having the permission.** The bot's role must sit higher in the role hierarchy than the target user's highest role, in addition to having the Kick/Ban permission — Discord enforces hierarchy regardless of permission flags.\n5. **Rate limits on bulk operations.** Discord enforces per-route rate limits. Bulk operations (mass ban, mass delete) should use `bulkDelete` and expect the library's automatic rate-limit handling to introduce delays — don't assume every call completes instantly.\n6. **No error handling crashes the whole bot.** An unhandled exception inside an interaction handler can crash the process if not caught. Always wrap command logic in try/catch and reply with the error so the user gets feedback instead of a silently dead bot.\n\n## Verification Checklist\n\n- [ ] `node index.js` logs \"Logged in as ...\" with no uncaught errors on startup\n- [ ] `deploy-commands.js` was run and slash commands appear in Discord (guild commands show instantly; confirm before assuming global propagation)\n- [ ] Message Content Intent is enabled in the Developer Portal if any command reads `message.content`\n- [ ] `.env` is listed in `.gitignore` and the token was never committed\n- [ ] Moderation commands (`/kick`, `/ban`, etc.) were tested against a low-permission test account, confirming both the permission check and the Discord role-hierarchy behavior\n- [ ] Every interaction handler has a try/catch that replies with an error message instead of leaving the interaction unanswered\n", "readme_content": "# discord-bot-build\n\nBuild a working Discord bot with slash commands, event handlers, and moderation tools.\n\n## What it does\n\nThe agent creates a Discord bot using discord.js — sets up the project, registers slash commands, implements event handlers, and adds moderation commands (kick, ban, mute, purge). The bot connects to your server and responds to commands in real time.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/discord-bot-build/SKILL.md\n```\n\n## How to use\n\n```\n\"Build a Discord bot for my server with kick, ban, and a ping command\"\n```\n\nThe agent:\n1. Initializes a Node.js project with discord.js\n2. Creates the bot file with slash commands and event handlers\n3. Creates the command deployment script\n4. Shows you how to invite the bot and start it\n\n## Prerequisites\n\n- Node.js 18+\n- A Discord bot token from https://discord.com/developers/applications\n- Message Content Intent enabled in the Developer Portal\n\n## Example\n\n```\nUser: \"I need a Discord bot that welcomes new members and has a /purge command\"\n\nAgent:\n 1. npm init + npm install discord.js dotenv\n 2. Creates index.js:\n - GuildMemberAdd event → sends welcome message\n - /purge command → deletes N messages (requires ManageMessages)\n 3. Creates deploy-commands.js\n 4. Returns: \"Run `node deploy-commands.js` to register commands, then `node index.js` to start the bot.\"\n```\n\n## Commands included\n\n| Command | Action | Permission |\n|---|---|---|\n| `/ping` | Replies \"Pong!\" | None |\n| `/kick @user [reason]` | Kicks a member | KickMembers |\n| `/ban @user [reason]` | Bans a member | BanMembers |\n| `/purge <count>` | Deletes recent messages | ManageMessages |\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/discord-bot-build/SKILL.md" }, { "name": "telegram-bot-build", "category": "integrations", "tier": "featured", "description": "Build a working Telegram bot with commands and inline keyboards.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/telegram-bot-build/SKILL.md", "path": "skills/telegram-bot-build", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "telegram-bot-build", "description": "Build a working Telegram bot with commands and inline keyboards.", "version": "1.0.0" }, "agent_use": "- The user says \"build a Telegram bot\", \"make a bot that does X\", or \"I have a BotFather token, now what\".\n- A project needs chat-based interaction: alerts, a menu-driven tool, a support front-end, a command wrapper around an API.\n- You are extending an existing bot with new commands or interactive menus.\n- The user wants inline keyboards (buttons under a message) rather than free-text replies.\n\nDo **not** use it for:\n- Long-polling over a web framework you already own for a different purpose — still valid, but the bot library handles this natively.\n- Anything that needs raw MTProto (telethon/Pyrogram) — that is client/account automation, a different skill.\n- Group moderation at scale — possible, but confirm the user wants admin tooling before adding it.", "user_use": "Builds a working Telegram bot in Python (`python-telegram-bot`) or Node (`telegraf`). Covers the full path: getting the token, wiring up the library, registering commands, building inline keyboard buttons, and choosing polling vs webhook. You end up with a runnable bot, not a skeleton with TODOs.", "skillmd_content": "---\nname: telegram-bot-build\ndescription: Use when the user has (or needs) a BotFather token and wants a runnable Telegram bot with slash commands and/or inline keyboards, in Python (python-telegram-bot) or Node (telegraf) — not for MTProto client/account automation or large-scale group moderation tooling.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [telegram, bot, python-telegram-bot, telegraf, botfather, inline-keyboards]\n related_skills: [discord-bot-build, webhook-receiver, env-config-manager]\n---\n\n# telegram-bot-build\n\n## Overview\n\nBuild a runnable Telegram bot that responds to slash commands and presents inline keyboard menus. The agent wires up the library, registers handlers, defines the keyboard markup, and runs the bot so the user can open a chat and see it work. Reference libraries: **python-telegram-bot** (v20+, async) and **telegraf** (Node).\n\n## When to Use\n\n- The user says \"build a Telegram bot\", \"make a bot that does X\", or \"I have a BotFather token, now what\".\n- A project needs chat-based interaction: alerts, a menu-driven tool, a support front-end, a command wrapper around an API.\n- You are extending an existing bot with new commands or interactive menus.\n- The user wants inline keyboards (buttons under a message) rather than free-text replies.\n\nDo **not** use it for:\n- Long-polling over a web framework you already own for a different purpose — still valid, but the bot library handles this natively.\n- Anything that needs raw MTProto (telethon/Pyrogram) — that is client/account automation, a different skill.\n- Group moderation at scale — possible, but confirm the user wants admin tooling before adding it.\n\n## Prerequisites\n\nThe bot needs a token from BotFather. If the user does not have one, the first step is to get it:\n\n1. Open a chat with [@BotFather](https://t.me/BotFather) in Telegram.\n2. Send `/newbot`, pick a display name, then a username ending in `bot` (e.g. `weather_poller_bot`).\n3. BotFather replies with a token like `123456789:AAE...longstring`. Treat it as a secret.\n4. Send `/setcommands` to BotFather to register the command list users see in the autocomplete menu.\n\nStore the token in an environment variable, never in source:\n\n```bash\n# .env (gitignored)\nTELEGRAM_BOT_TOKEN=123456789:AAE...\n```\n\nThen load it in code from the environment. If it is missing, the bot must fail with a clear message, not a cryptic stack trace.\n\n## Bot Setup\n\n**Python (python-telegram-bot v20+):**\n\n```bash\npip install python-telegram-bot python-dotenv\n```\n\n```python\n# bot.py\nimport os\nfrom dotenv import load_dotenv\nfrom telegram.ext import Application, CommandHandler\n\nload_dotenv()\nTOKEN = os.environ.get(\"TELEGRAM_BOT_TOKEN\")\nif not TOKEN:\n raise SystemExit(\"TELEGRAM_BOT_TOKEN is not set. Add it to .env\")\n\nasync def start(update, context):\n await update.message.reply_text(\"Bot is alive. Send /help.\")\n\ndef main():\n app = Application.builder().token(TOKEN).build()\n app.add_handler(CommandHandler(\"start\", start))\n # run_polling blocks; the bot is live after this returns nothing\n app.run_polling()\n\nif __name__ == \"__main__\":\n main()\n```\n\n**Node (telegraf):**\n\n```bash\nnpm init -y && npm install telegraf dotenv\n```\n\n```js\n// bot.js\nimport { Telegraf } from \"telegraf\";\nimport \"dotenv/config\";\n\nconst token = process.env.TELEGRAM_BOT_TOKEN;\nif (!token) throw new Error(\"TELEGRAM_BOT_TOKEN is not set. Add it to .env\");\n\nconst bot = new Telegraf(token);\nbot.command(\"start\", (ctx) => ctx.reply(\"Bot is alive. Send /help.\"));\nbot.launch();\nprocess.once(\"SIGINT\", () => bot.stop(\"SIGINT\"));\nprocess.once(\"SIGTERM\", () => bot.stop(\"SIGTERM\"));\n```\n\nRun it: `python bot.py` or `node bot.js`. Open the chat with your bot and send `/start`.\n\n## Command Handling\n\nRegister one handler per command. Keep each handler small and pure where possible so logic is testable without Telegram.\n\n**Python:**\n\n```python\nfrom telegram.ext import CommandHandler\n\nasync def help_command(update, context):\n await update.message.reply_text(\n \"Commands:\\n/start - wake up\\n/echo <text> - repeat\\n/menu - show buttons\"\n )\n\nasync def echo(update, context):\n # context.args holds everything after the command\n text = \" \".join(context.args) or \"nothing to echo\"\n await update.message.reply_text(text)\n\napp.add_handler(CommandHandler(\"help\", help_command))\napp.add_handler(CommandHandler(\"echo\", echo))\n```\n\n**Node:**\n\n```js\nbot.command(\"help\", (ctx) =>\n ctx.reply(\"Commands:\\n/start - wake up\\n/echo <text> - repeat\\n/menu - show buttons\")\n);\n\nbot.command(\"echo\", (ctx) => {\n const text = ctx.message.text.replace(/^\\/echo(@\\S+)?\\s*/, \"\") || \"nothing to echo\";\n return ctx.reply(text);\n});\n```\n\nRules:\n- Command names are lowercase, letters/numbers/underscore only, max 32 chars.\n- Register them with `/setcommands` in BotFather so they autocomplete.\n- Unknown commands should get a friendly fallback, not silence.\n- Use `context.args` (Python) / parse `ctx.message.text` (Node) for parameters; do not assume order.\n\n## Inline Keyboards\n\nInline keyboards put buttons *under* a message. Each button carries a `callback_data` string (1–64 bytes) that comes back to a callback handler — no text is typed by the user.\n\n**Python:**\n\n```python\nfrom telegram import InlineKeyboardButton, InlineKeyboardMarkup\nfrom telegram.ext import CallbackQueryHandler\n\nasync def menu(update, context):\n keyboard = [\n [InlineKeyboardButton(\"Get weather\", callback_data=\"weather\")],\n [InlineKeyboardButton(\"Set city\", callback_data=\"set_city\")],\n [InlineKeyboardButton(\"Open site\", url=\"https://example.com\")],\n ]\n await update.message.reply_text(\n \"Choose:\", reply_markup=InlineKeyboardMarkup(keyboard)\n )\n\nasync def button(update, context):\n query = update.callback_query\n await query.answer() # REQUIRED: clears the loading state on the button\n await query.edit_message_text(f\"You picked: {query.data}\")\n\napp.add_handler(CommandHandler(\"menu\", menu))\napp.add_handler(CallbackQueryHandler(button))\n```\n\n**Node:**\n\n```js\nbot.command(\"menu\", (ctx) =>\n ctx.reply(\"Choose:\", {\n reply_markup: {\n inline_keyboard: [\n [{ text: \"Get weather\", callback_data: \"weather\" }],\n [{ text: \"Set city\", callback_data: \"set_city\" }],\n [{ text: \"Open site\", url: \"https://example.com\" }],\n ],\n },\n })\n);\n\nbot.on(\"callback_query\", async (ctx) => {\n await ctx.answerCbQuery(); // REQUIRED\n await ctx.editMessageText(`You picked: ${ctx.callbackQuery.data}`);\n});\n```\n\nRules:\n- Always call `query.answer()` / `ctx.answerCbQuery()` — without it the button spins forever on the user's screen.\n- Keep `callback_data` short and stable; it is what your router switches on. Encode intent, not state.\n- `url` buttons open a link and do **not** fire a callback.\n- You can edit the message text on click (`edit_message_text`) to reflect the choice instead of sending a new bubble.\n\n## Webhook vs Polling\n\n**Polling** (default, `run_polling` / `bot.launch()` in long-poll mode) is simplest: the bot opens HTTPS long-poll requests to Telegram. No public server, no certificates, no port forwarding. Use it for development and for bots behind a home network or behind a tunnel.\n\n**Webhook** is for production behind a public HTTPS endpoint. Telegram pushes updates to your URL. You must:\n- Serve HTTPS on port 443 (or use a reverse proxy / serverless function that terminates TLS).\n- Set the webhook once: `curl https://api.telegram.org/bot<TOKEN>/setWebhook?url=https://your.domain/bot<TOKEN>`.\n- Match the path to the token, and delete the old webhook (`deleteWebhook`) before switching back to polling — a bot cannot poll and webhook at once.\n\nGuidance:\n- Start with polling. Move to webhook only when you have a stable public HTTPS URL and need push (lower latency, fewer requests, required by some hosts).\n- If deploying serverless (Cloud Functions, Workers, Lambda), webhook is the natural fit — `app.post(\"/webhook\", ...)` + `setWebhook`.\n- Never hardcode the webhook URL; read it from env so staging vs prod differs by config.\n\n## Common Pitfalls\n\n1. **Token in source / committed to git.** Load from env. A leaked token lets anyone drive your bot; revoke via BotFather `/revoke`.\n2. **Forgetting `query.answer()` / `ctx.answerCbQuery()`.** The button shows a perpetual spinner. Every callback handler must answer the query.\n3. **`callback_data` over 64 bytes.** Telegram rejects it silently with an API error. Encode an opaque id, look up the payload server-side.\n4. **Polling and webhook simultaneously.** You cannot do both. Switching modes requires deleting the webhook first or updates will never arrive on the other.\n5. **Not handling `/setcommands`.** Without it, commands don't autocomplete and the bot feels broken even though it works.\n6. **Replying to callback queries with `reply_text` instead of `edit_message_text`.** That sends a *new* message bubble, not an update to the button's message. Use edit for in-place changes.\n7. **Crashing on every update.** A single unhandled exception in a handler can kill polling in older setups; wrap external calls (API/DB) in try/except and send a graceful error. python-telegram-bot v20 uses async error handlers (`app.add_error_handler`).\n8. **Localized command names.** Commands must be ASCII; don't localize the `/command` itself, localize the reply text.\n9. **Long-running work blocking the loop.** Heavy jobs (image gen, API calls) should run in a task/queue, not inline in the handler, or updates back up.\n10. **Testing with the real token in CI.** Use a throwaway test bot or mock the update objects; never poll the production bot from a test run.\n\n## Verification Checklist\n\n- [ ] `TELEGRAM_BOT_TOKEN` is read from env/`.env`, absent from source and git history.\n- [ ] `/start` (and every registered command) gets a reply when sent from a real Telegram chat.\n- [ ] Every inline-keyboard callback handler calls `answer()`/`answerCbQuery()` — no button spins\n indefinitely.\n- [ ] `/setcommands` was sent to BotFather so commands autocomplete in the client.\n- [ ] Only one of polling or webhook is active — `getWebhookInfo` (or equivalent) confirms no\n stale webhook is set if the bot is running in polling mode.\n- [ ] An unhandled exception in one handler does not crash the whole bot process.\n", "readme_content": "# telegram-bot-build\n\nTurn a BotFather token into a bot you can actually talk to — slash commands and inline keyboard menus included. The agent writes the code, registers the handlers, and runs the bot so it answers in chat, not just compiles.\n\n## What it does\n\nBuilds a working Telegram bot in Python (`python-telegram-bot`) or Node (`telegraf`). Covers the full path: getting the token, wiring up the library, registering commands, building inline keyboard buttons, and choosing polling vs webhook. You end up with a runnable bot, not a skeleton with TODOs.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/telegram-bot-build/SKILL.md\n```\n\n## How to use\n\n```\n\"Build a Telegram bot that echoes text and shows a menu of buttons\"\n```\n\nThe agent will:\n- Get you a BotFather token if you don't have one (or load it from `.env`).\n- Scaffold the bot in your language of choice, with commands registered.\n- Add an inline keyboard example so you see buttons working.\n- Run it and confirm it responds to `/start` in your chat.\n\n## What you get\n\nA minimal but real bot:\n\n```python\nasync def start(update, context):\n await update.message.reply_text(\"Bot is alive. Send /help.\")\n\nasync def menu(update, context):\n from telegram import InlineKeyboardButton, InlineKeyboardMarkup\n kb = [[InlineKeyboardButton(\"Weather\", callback_data=\"weather\")]]\n await update.message.reply_text(\"Choose:\", reply_markup=InlineKeyboardMarkup(kb))\n```\n\nplus the polling run loop and a `.env` for the token.\n\n## What it covers\n\n- **Commands** — one handler each; parameters parsed from the message.\n- **Inline keyboards** — buttons under a message, each firing a `callback_data` you route on.\n- **Polling vs webhook** — polling for dev and behind NAT; webhook for production behind HTTPS.\n- **Pitfalls** — token safety, the `query.answer()` spinner trap, callback-data length limits.\n\n## Limitations\n\n- It builds a bot user (Bot API), not a Telegram client account — no reading others' chats, no MTProto automation.\n- Scaling to many groups needs rate-limit handling and a proper deploy; this skill gets you to a correct single-bot baseline.\n- Group admin/moderation tooling is out of scope unless you ask for it specifically.\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/telegram-bot-build/SKILL.md" }, { "name": "ollama-local", "category": "devops", "tier": "featured", "description": "Run local LLMs with Ollama — private, offline, no API costs.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/ollama-local/SKILL.md", "path": "skills/ollama-local", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "ollama-local", "description": "Run local LLMs with Ollama — private, offline, no API costs.", "version": "1.0.0" }, "agent_use": "- The user wants to run an LLM locally without paying for API access.\n- The user wants privacy — no data leaves their machine.\n- The user wants to use a local model with their agent or application.\n- The user says \"set up Ollama\", \"run a local LLM\", or \"I want offline AI\".", "user_use": "The agent installs Ollama, pulls a model that fits your hardware, and shows you how to use it via the command line or REST API. Models run entirely on your machine — nothing leaves your network. You can use the local model for chat, code generation, embeddings, or as a provider for Hermes Agent.", "skillmd_content": "---\nname: ollama-local\ndescription: Use when the user wants to run an LLM locally without cloud API costs or data leaving their machine — installing Ollama, pulling/managing models, calling the REST API (generate, chat, embeddings, streaming), picking a model for their RAM budget, or wiring Ollama into Hermes as a provider.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [ollama, local-llm, self-hosted, embeddings, offline-ai]\n related_skills: [http-api-tester]\n---\n\n# ollama-local\n\n## Overview\n\nSet up and use Ollama for running large language models locally. Ollama runs models on your machine — no API keys, no cloud, no per-token costs. The agent installs Ollama, pulls models, and shows you how to use them via the REST API or command line.\n\n## When to Use\n\n- The user wants to run an LLM locally without paying for API access.\n- The user wants privacy — no data leaves their machine.\n- The user wants to use a local model with their agent or application.\n- The user says \"set up Ollama\", \"run a local LLM\", or \"I want offline AI\".\n\n## Installation\n\n### Linux\n\n```bash\ncurl -fsSL https://ollama.com/install.sh | sh\n```\n\n### macOS\n\n```bash\n# Via Homebrew\nbrew install ollama\n\n# Or download from https://ollama.com/download\n```\n\n### Windows\n\nDownload from https://ollama.com/download and run the installer. Ollama runs as a background service on Windows.\n\n### Verify installation\n\n```bash\nollama --version\n# ollama version is 0.x.x\n```\n\n## Model Management\n\n### Pull a model\n\n```bash\n# Small, fast model (good for testing)\nollama pull llama3.2:3b\n\n# Medium model (good balance of speed and quality)\nollama pull llama3.1:8b\n\n# Large model (best quality, needs 16GB+ RAM)\nollama pull llama3.1:70b\n\n# Coding-focused model\nollama pull qwen2.5-coder:7b\n\n# Embedding model\nollama pull nomic-embed-text\n```\n\n### List installed models\n\n```bash\nollama list\n```\n\n### Run a model (interactive chat)\n\n```bash\nollama run llama3.1:8b\n>>> Tell me about quantum computing\n```\n\n### Remove a model\n\n```bash\nollama rm llama3.2:3b\n```\n\n## API Usage\n\nOllama exposes a REST API at `http://localhost:11434`:\n\n### Generate a response\n\n```bash\ncurl http://localhost:11434/api/generate -d '{\n \"model\": \"llama3.1:8b\",\n \"prompt\": \"Explain recursion in one sentence.\",\n \"stream\": false\n}'\n```\n\n### Chat (multi-turn)\n\n```bash\ncurl http://localhost:11434/api/chat -d '{\n \"model\": \"llama3.1:8b\",\n \"messages\": [\n {\"role\": \"user\", \"content\": \"What is 2+2?\"},\n {\"role\": \"assistant\", \"content\": \"4\"},\n {\"role\": \"user\", \"content\": \"What about 3+5?\"}\n ],\n \"stream\": false\n}'\n```\n\n### Generate embeddings\n\n```bash\ncurl http://localhost:11434/api/embeddings -d '{\n \"model\": \"nomic-embed-text\",\n \"prompt\": \"The quick brown fox jumps over the lazy dog.\"\n}'\n```\n\n### Python client\n\n```python\nimport requests\n\nresponse = requests.post('http://localhost:11434/api/generate', json={\n 'model': 'llama3.1:8b',\n 'prompt': 'Write a haiku about the ocean.',\n 'stream': False\n})\nprint(response.json()['response'])\n```\n\n### Streaming responses\n\n```python\nimport requests\n\nresponse = requests.post('http://localhost:11434/api/generate', json={\n 'model': 'llama3.1:8b',\n 'prompt': 'Tell me a story.',\n 'stream': True\n}, stream=True)\n\nfor line in response.iter_lines():\n if line:\n import json\n chunk = json.loads(line)\n print(chunk.get('response', ''), end='', flush=True)\n```\n\n## Integration with Hermes\n\nConfigure Hermes to use the local Ollama instance as a provider:\n\n```bash\n# Set Ollama as a custom provider\nhermes config set model.provider custom\nhermes config set model.base_url http://localhost:11434/v1\nhermes config set model.api_key ollama # Ollama doesn't require a real key\nhermes config set model.default llama3.1:8b\n```\n\nOr use Ollama for specific tasks (like auxiliary/compression) while keeping a cloud model for main reasoning:\n\n```bash\nhermes config set auxiliary.compression.provider custom\nhermes config set auxiliary.compression.base_url http://localhost:11434/v1\nhermes config set auxiliary.compression.model llama3.2:3b\n```\n\n## Model Selection Guide\n\n| Model | Size | RAM needed | Best for |\n|---|---|---|---|\n| `llama3.2:3b` | 2 GB | 4 GB | Fast responses, simple tasks |\n| `llama3.1:8b` | 5 GB | 8 GB | General purpose, good balance |\n| `qwen2.5-coder:7b` | 5 GB | 8 GB | Code generation, debugging |\n| `llama3.1:70b` | 40 GB | 64 GB | High quality, complex reasoning |\n| `nomic-embed-text` | 0.3 GB | 1 GB | Embeddings for RAG/search |\n\n## Performance Tips\n\n- **Use GPU if available** — Ollama auto-detects NVIDIA/AMD GPUs and Apple Silicon. GPU inference is substantially faster than CPU; measure on your own hardware, since the gap depends on the model, quantisation, and VRAM.\n- **Match model size to your RAM** — A model that doesn't fit in RAM will spill to disk and become extremely slow. Check `ollama ps` to see if the model is fully in memory.\n- **Use smaller models for simple tasks** — Don't use a 70B model for a one-sentence answer. Use 3B or 8B for quick tasks.\n- **Keep models loaded** — Ollama keeps models in memory for 5 minutes after last use by default. Increase this with `OLLAMA_KEEP_ALIVE` env var if you're making frequent requests.\n- **Quantization** — Ollama uses 4-bit quantization by default, which reduces memory usage by ~70% with minimal quality loss. No configuration needed.\n\n## Common Pitfalls\n\n1. **Model larger than available RAM.** Ollama will fall back to disk swap and performance becomes unusable rather than failing outright — check `ollama ps` to confirm the model is fully resident in memory, and use a smaller model or add RAM if not.\n2. **First pull looks \"stuck\".** The first `ollama pull` downloads the full model file (multiple GB); this can take minutes on a slow connection. Subsequent runs use the cached model and start instantly — don't kill the process assuming it's hung.\n3. **Port conflict on 11434.** If another service already binds Ollama's default port, the server fails to start silently in some setups. Set `OLLAMA_HOST=0.0.0.0:11435` before starting and update client URLs to match.\n4. **GPU not detected.** On Linux, missing NVIDIA drivers/CUDA toolkit means Ollama silently falls back to CPU (much slower) instead of erroring. Verify with `nvidia-smi` before assuming GPU is in use.\n5. **Prompts exceed local context comfortably.** Local models advertise large context windows (e.g., 128k for Llama 3.1) but running at full context requires far more RAM than the base model size suggests. Keep prompts under ~8k tokens for 8B-class models in practice.\n6. **Requests queue instead of running in parallel.** Ollama processes requests sequentially by default, so concurrent callers block each other. Set `OLLAMA_NUM_PARALLEL` if concurrency is needed.\n\n## Verification Checklist\n\n- [ ] `ollama --version` succeeds and `ollama list` shows the pulled model\n- [ ] `ollama ps` confirms the model is loaded and shows a reasonable memory footprint (not swapping)\n- [ ] A test `curl http://localhost:11434/api/generate` call returns a non-empty `response` field\n- [ ] If GPU acceleration was expected, `ollama ps` or system GPU monitor (`nvidia-smi`) confirms it's actually being used\n- [ ] If wired into Hermes as a provider, `hermes config get model.base_url` reflects the correct local URL and a real Hermes call round-trips successfully\n", "readme_content": "# ollama-local\n\nRun large language models locally with Ollama — no API keys, no cloud, no per-token costs.\n\n## What it does\n\nThe agent installs Ollama, pulls a model that fits your hardware, and shows you how to use it via the command line or REST API. Models run entirely on your machine — nothing leaves your network. You can use the local model for chat, code generation, embeddings, or as a provider for Hermes Agent.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/ollama-local/SKILL.md\n```\n\n## How to use\n\n```\n\"Set up Ollama with a model that fits my 16GB RAM laptop\"\n```\n\nThe agent:\n1. Installs Ollama (or verifies it's installed)\n2. Recommends a model based on your RAM (llama3.1:8b for 16GB)\n3. Pulls the model: `ollama pull llama3.1:8b`\n4. Tests it: `ollama run llama3.1:8b \"Hello\"`\n5. Shows you the REST API endpoint at http://localhost:11434\n\n## Model selection\n\n| Model | RAM needed | Best for |\n|---|---|---|\n| llama3.2:3b | 4 GB | Fast, simple tasks |\n| llama3.1:8b | 8 GB | General purpose |\n| qwen2.5-coder:7b | 8 GB | Code generation |\n| llama3.1:70b | 64 GB | Complex reasoning |\n\n## Example\n\n```\nUser: \"I want to use a local model for my Hermes agent instead of paying for API calls\"\n\nAgent:\n 1. Checks RAM: 16 GB available\n 2. Recommends: llama3.1:8b (fits in RAM, good quality)\n 3. Pulls: ollama pull llama3.1:8b\n 4. Configures Hermes:\n hermes config set model.provider custom\n hermes config set model.base_url http://localhost:11434/v1\n hermes config set model.default llama3.1:8b\n 5. Returns: \"Hermes is now using your local model. No API costs.\"\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/ollama-local/SKILL.md" }, { "name": "searxng-self-host", "category": "devops", "tier": "featured", "description": "Self-host a private SearXNG meta-search engine without tracking.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/searxng-self-host/SKILL.md", "path": "skills/searxng-self-host", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "searxng-self-host", "description": "Self-host a private SearXNG meta-search engine without tracking.", "version": "1.0.0" }, "agent_use": "- The user wants a private search engine that doesn't track them.\n- The user wants to self-host search for their team or family.\n- The user says \"set up SearXNG\", \"self-host my search\", or \"I want private search\".", "user_use": "The agent deploys SearXNG with Docker Compose, configures the search engines, and exposes a web UI plus a JSON API. Searches are proxied — the search engines see the server's IP, not yours. No tracking, no ads, no query logging.", "skillmd_content": "---\nname: searxng-self-host\ndescription: Use when the user wants to self-host a private, meta-search engine that aggregates results from Google, Bing, DuckDuckGo, Wikipedia, etc. without tracking — triggers include \"set up SearXNG\", \"self-host my search\", or \"I want private search\".\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [searxng, self-hosting, docker, meta-search, privacy, reverse-proxy]\n related_skills: [caddy-reverse-proxy, docker-umbrella, forgejo-self-host, uptime-kuma-self-host]\n---\n\n# searxng-self-host\n\n## Overview\n\nDeploy a self-hosted SearXNG instance with Docker. SearXNG is a privacy-focused meta-search engine that aggregates results from multiple search engines (Google, Bing, DuckDuckGo, Wikipedia, etc.) without tracking users or sharing search queries.\n\n## When to Use\n\n- The user wants a private search engine that doesn't track them.\n- The user wants to self-host search for their team or family.\n- The user says \"set up SearXNG\", \"self-host my search\", or \"I want private search\".\n\n## Prerequisites\n\n- Docker and Docker Compose installed\n- A free port (default: 8080)\n\n## Docker Deployment\n\n### Step 1: Create the deployment\n\n```yaml\n# docker-compose.yml\nversion: \"3\"\nservices:\n searxng:\n image: searxng/searxng:latest\n container_name: searxng\n restart: unless-stopped\n ports:\n - \"8080:8080\"\n volumes:\n - ./searxng:/etc/searxng:rw\n environment:\n - SEARXNG_BASE_URL=http://localhost:8080/\n cap_drop:\n - ALL\n cap_add:\n - CHOWN\n - SETGID\n - SETUID\n```\n\n### Step 2: Create the settings file\n\n```bash\nmkdir -p searxng\ncat > searxng/settings.yml << 'EOF'\nuse_default_settings: true\n\ngeneral:\n instance_name: \"My Search\"\n debug: false\n\nsearch:\n safe_search: 0\n autocomplete: \"google\"\n default_lang: \"en\"\n formats:\n - html\n - json\n\nserver:\n secret_key: \"CHANGE_ME_TO_A_RANDOM_STRING\"\n bind_address: \"0.0.0.0\"\n port: 8080\n\nengines:\n - name: google\n engine: google\n shortcut: g\n disabled: false\n - name: bing\n engine: bing\n shortcut: b\n disabled: false\n - name: duckduckgo\n engine: duckduckgo\n shortcut: ddg\n disabled: false\n - name: wikipedia\n engine: wikipedia\n shortcut: wp\n disabled: false\n - name: github\n engine: github\n shortcut: gh\n disabled: false\n\noutgoing:\n request_timeout: 3.0\n max_request_timeout: 10.0\n useragent_suffix: \"\"\nEOF\n```\n\n### Step 3: Start\n\n```bash\ndocker compose up -d\n```\n\n### Step 4: Verify\n\n```bash\ncurl -s http://localhost:8080/search?q=test\\&format=json | python -m json.tool | head -20\n```\n\nOpen `http://localhost:8080` in a browser to see the search interface.\n\n## Configuration\n\n### Search engines\n\nEnable or disable individual engines in `settings.yml`:\n\n```yaml\nengines:\n - name: google\n engine: google\n shortcut: g\n disabled: false # enabled\n - name: brave\n engine: brave\n shortcut: br\n disabled: false\n - name: yahoo\n engine: yahoo\n shortcut: y\n disabled: true # disabled\n```\n\nPopular engines: Google, Bing, DuckDuckGo, Brave, Yahoo, Wikipedia, GitHub, Stack Overflow, Reddit, YouTube.\n\n### Privacy settings\n\n```yaml\n# In settings.yml\nserver:\n method: \"POST\" # POST instead of GET (hides query from URL/logs)\n image_proxy: true # Proxy images so the search engine doesn't see the user's IP\n \noutgoing:\n request_timeout: 3.0\n # Use a proxy for all outgoing requests\n proxies:\n all://: \"socks5h://127.0.0.1:9050\" # via Tor (optional)\n```\n\n### Reverse proxy setup\n\nFor HTTPS and custom domains, put SearXNG behind Caddy or nginx:\n\n**Caddy (automatic HTTPS):**\n```\nsearch.mydomain.com {\n reverse_proxy localhost:8080\n}\n```\n\n**nginx:**\n```nginx\nserver {\n listen 80;\n server_name search.mydomain.com;\n location / {\n proxy_pass http://localhost:8080;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n }\n}\n```\n\n## API Usage\n\nSearXNG has a JSON API (must be enabled in settings):\n\n```bash\n# Search\ncurl \"http://localhost:8080/search?q=python+async+guide&format=json\"\n\n# Search specific categories\ncurl \"http://localhost:8080/search?q=python+async&categories=it&format=json\"\n\n# Search specific engines\ncurl \"http://localhost:8080/search?q=python+async&engines=google,bing&format=json\"\n```\n\n```python\nimport requests\n\nresults = requests.get('http://localhost:8080/search', params={\n 'q': 'python async guide',\n 'format': 'json',\n 'categories': 'it'\n}).json()\n\nfor result in results['results'][:5]:\n print(f\"{result['title']} — {result['url']}\")\n```\n\n## Common Pitfalls\n\n1. **Default secret key left unchanged.** `secret_key` in settings.yml must be replaced — generate one with `openssl rand -hex 32` before exposing the instance.\n2. **JSON API disabled by default.** The `json` format must be explicitly listed under `search.formats` in settings.yml, or API requests return HTML instead of JSON.\n3. **Google rate limiting.** Searching too frequently from one IP gets noticed and blocked — distribute load across multiple engines rather than hammering one.\n4. **No HTTPS by default.** SearXNG serves plain HTTP — queries are visible on the network until a reverse proxy (Caddy/nginx) terminates TLS in front of it.\n5. **Bot detection / CAPTCHAs.** Aggressive automated querying can trigger CAPTCHAs from upstream engines (Google especially) despite SearXNG's built-in anti-bot measures.\n6. **Missing container capabilities.** The container needs `CHOWN`, `SETGID`, `SETUID` to write to the settings directory — don't reach for `--privileged`; the specific caps in the compose file are sufficient and safer.\n\n## Verification Checklist\n\n- [ ] `secret_key` in settings.yml changed from the placeholder default\n- [ ] `curl http://localhost:8080/search?q=test&format=json` returns valid JSON, not an error page\n- [ ] Web UI loads at `http://localhost:8080` (or the reverse-proxied domain) with search results rendering\n- [ ] At least 2-3 engines enabled and returning results (not all disabled/erroring)\n- [ ] HTTPS confirmed working if a reverse proxy was configured\n- [ ] Container running with only the specific capabilities listed (`CHOWN`, `SETGID`, `SETUID`), not `--privileged`\n", "readme_content": "# searxng-self-host\n\nSelf-host a private SearXNG meta-search engine that queries Google, Bing, and DuckDuckGo without tracking you.\n\n## What it does\n\nThe agent deploys SearXNG with Docker Compose, configures the search engines, and exposes a web UI plus a JSON API. Searches are proxied — the search engines see the server's IP, not yours. No tracking, no ads, no query logging.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/searxng-self-host/SKILL.md\n```\n\n## How to use\n\n```\n\"Set up a private search engine on my server\"\n```\n\nThe agent:\n1. Creates a docker-compose.yml with SearXNG\n2. Generates a settings.yml with Google, Bing, DuckDuckGo, Wikipedia enabled\n3. Starts the container\n4. Verifies the JSON API works\n5. Returns: \"Search UI at http://localhost:8080, API at http://localhost:8080/search?q=...&format=json\"\n\n## Prerequisites\n\n- Docker and Docker Compose\n- A free port (default: 8080)\n\n## What you get\n\n| Component | URL | Notes |\n|---|---|---|\n| Web UI | `http://localhost:8080` | Search interface |\n| JSON API | `http://localhost:8080/search?q=...&format=json` | For programmatic use |\n| Settings | `searxng/settings.yml` | Configure engines, privacy, proxy |\n\n## Example\n\n```\nUser: \"I want my agent to search the web privately\"\n\nAgent:\n 1. Deploys SearXNG with Docker\n 2. Enables JSON API in settings\n 3. Tests: curl http://localhost:8080/search?q=test&format=json → results\n 4. Returns: \"Your agent can now search via http://localhost:8080/search?q=...&format=json\"\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/searxng-self-host/SKILL.md" }, { "name": "uptime-kuma-self-host", "category": "devops", "tier": "featured", "description": "Self-host an uptime monitoring dashboard with alerts.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/uptime-kuma-self-host/SKILL.md", "path": "skills/uptime-kuma-self-host", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "uptime-kuma-self-host", "description": "Self-host an uptime monitoring dashboard with alerts.", "version": "1.0.0" }, "agent_use": "- The user wants to monitor their self-hosted services.\n- The user wants uptime alerts sent to Discord, Slack, email, or webhook.\n- The user wants a public status page for their services.\n- The user says \"set up uptime monitoring\", \"I want to know when my site goes down\", or \"monitor my services\".", "user_use": "The agent deploys Uptime Kuma with Docker, creates an admin account, and helps you add monitors for your services. You get a web UI showing real-time status, historical uptime, and response times. Alerts go to Discord, Slack, email, Telegram, or a webhook when a service goes down or comes back up.", "skillmd_content": "---\nname: uptime-kuma-self-host\ndescription: Use when the user wants to self-host uptime monitoring for their services (HTTP, TCP, DNS, ping) with alerting to Discord/Slack/email/webhook, or wants a public status page, or says \"set up uptime monitoring\" / \"I want to know when my site goes down\".\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [uptime-kuma, uptime-monitoring, docker, status-page, alerting]\n related_skills: [ntfy-notifier, caddy-reverse-proxy, docker-umbrella]\n---\n\n# uptime-kuma-self-host\n\n## Overview\n\nDeploy Uptime Kuma — a self-hosted monitoring tool that tracks the uptime of your services (HTTP, TCP, DNS, ping) and sends alerts when something goes down. It runs as a Docker container with a web UI for managing monitors and viewing status pages.\n\n## When to Use\n\n- The user wants to monitor their self-hosted services.\n- The user wants uptime alerts sent to Discord, Slack, email, or webhook.\n- The user wants a public status page for their services.\n- The user says \"set up uptime monitoring\", \"I want to know when my site goes down\", or \"monitor my services\".\n\n## Prerequisites\n\n- Docker and Docker Compose installed\n- A free port (default: 3001)\n\n## Docker Deployment\n\n### Step 1: Create the deployment\n\n```yaml\n# docker-compose.yml\nversion: \"3\"\nservices:\n uptime-kuma:\n image: louislam/uptime-kuma:latest\n container_name: uptime-kuma\n restart: unless-stopped\n ports:\n - \"3001:3001\"\n volumes:\n - uptime-kuma-data:/app/data\n\nvolumes:\n uptime-kuma-data:\n```\n\n### Step 2: Start\n\n```bash\ndocker compose up -d\n```\n\n### Step 3: Initial setup\n\nOpen `http://localhost:3001` in a browser. Create an admin account (username + password). This is a one-time setup.\n\n### Step 4: Add monitors via the UI\n\n1. Click \"Add New Monitor\"\n2. Choose a monitor type\n3. Enter the target URL/host\n4. Set the check interval\n5. Save\n\n### Step 5: Add notifications\n\n1. Go to Settings → Notifications\n2. Add a notification channel (Discord webhook, Slack webhook, email, etc.)\n3. Test the notification\n4. Assign the notification to monitors\n\n## Monitor Types\n\n| Type | What it checks | Example |\n|---|---|---|\n| HTTP(s) | Is a URL responding with 2xx? | `https://mysite.com` |\n| HTTP(s) - Keyword | Does the response contain a keyword? | `https://mysite.com` looking for \"OK\" |\n| TCP Port | Is a TCP port open? | `mysite.com:5432` |\n| DNS | Does a domain resolve? | `mysite.com` → A record |\n| Ping | Does a host respond to ICMP? | `192.168.1.1` |\n| Push | Wait for a push (passive monitor) | `https://kuma:3001/api/push/STATUS?msg=OK&ping=100` |\n| Docker Container | Is a Docker container running? | Container name via Docker socket |\n\n## Notification Channels\n\n| Channel | Setup |\n|---|---|\n| Discord | Webhook URL from Discord channel settings |\n| Slack | Webhook URL from Slack app config |\n| Email | SMTP server + credentials |\n| Webhook | Custom HTTP POST on status change |\n| Telegram | Bot token + chat ID |\n| ntfy | ntfy.sh topic URL |\n| Signal | via signal-cli-rest-api |\n| Pushover | User key + app key |\n\n## Status Pages\n\nUptime Kuma can generate public status pages showing the uptime of your services:\n\n1. Go to Status Pages → Add Status Page\n2. Name it (e.g., \"My Services\")\n3. Add monitors to display\n4. Set a custom domain (optional)\n5. Share the URL: `http://localhost:3001/status/my-services`\n\n## API (Push Monitors)\n\nPush monitors are passive — they wait for your service to \"check in\":\n\n```bash\n# Your service calls this URL on a schedule\n# If Kuma doesn't hear from it within the interval, it's marked down\ncurl \"http://localhost:3001/api/push/ABCD1234?msg=OK&ping=42\"\n```\n\nUse this for services behind firewalls that Kuma can't reach directly.\n\n## Monitoring a Docker Host\n\nTo monitor Docker containers on the host, bind-mount the Docker socket:\n\n```yaml\nservices:\n uptime-kuma:\n image: louislam/uptime-kuma:latest\n volumes:\n - uptime-kuma-data:/app/data\n - /var/run/docker.sock:/var/run/docker.sock # for container monitoring\n```\n\nThen add a \"Docker Container\" monitor type pointing to the container name.\n\n## Common Pitfalls\n\n1. **Port 3001 collides with another service.** Forgejo and some other self-hosted tools default\n to nearby ports. Check `docker ps` / `netstat` first and remap in `docker-compose.yml` if\n 3001 is taken.\n2. **Treating the data volume as disposable.** All monitor configs, history, and notification\n settings live only in the `uptime-kuma-data` volume — there's no external config file to\n fall back on. Back it up regularly.\n3. **Assuming HTTPS is built in.** Uptime Kuma serves plain HTTP. Put it behind Caddy or another\n reverse proxy if the status page or dashboard needs to be reachable over TLS.\n4. **Not configuring \"Accepted Status Codes.\"** A 3xx redirect is treated as healthy by default;\n only 5xx (and non-matching codes outside the accepted set) count as down. Set this explicitly\n if the monitored service redirects normally.\n5. **Leaving \"Retry\" at its default on a flapping service.** A service that goes up/down rapidly\n sends one notification per transition. Raise the retry count (e.g., 2 failed checks before\n alerting) to suppress flap noise.\n6. **Setting a push-monitor interval shorter than the service's real check-in cadence.** Kuma\n waits the full interval before marking a push monitor down — if the interval doesn't match how\n often the service actually pushes, you get false \"down\" alerts or delayed detection.\n\n## Verification Checklist\n\n- [ ] `docker compose ps` shows `uptime-kuma` running and `http://localhost:3001` loads the\n dashboard.\n- [ ] Admin account created (one-time setup completed, not left on the setup screen).\n- [ ] At least one monitor added and showing a status (up/down/pending), not stuck on \"PENDING\".\n- [ ] Test notification sent successfully from Settings → Notifications before assigning it to\n monitors.\n- [ ] `uptime-kuma-data` volume confirmed present (`docker volume inspect`) and included in the\n user's backup plan.\n", "readme_content": "# uptime-kuma-self-host\n\nSelf-host an uptime monitoring dashboard that tracks your services and alerts you when they go down.\n\n## What it does\n\nThe agent deploys Uptime Kuma with Docker, creates an admin account, and helps you add monitors for your services. You get a web UI showing real-time status, historical uptime, and response times. Alerts go to Discord, Slack, email, Telegram, or a webhook when a service goes down or comes back up.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/uptime-kuma-self-host/SKILL.md\n```\n\n## How to use\n\n```\n\"Set up uptime monitoring for my three web services with Discord alerts\"\n```\n\nThe agent:\n1. Creates a docker-compose.yml with Uptime Kuma\n2. Starts the container on port 3001\n3. Opens the setup page for your admin account\n4. Adds HTTP monitors for your services\n5. Configures Discord webhook notifications\n\n## Prerequisites\n\n- Docker and Docker Compose\n- A free port (default: 3001)\n\n## What you get\n\n| Component | URL | Notes |\n|---|---|---|\n| Dashboard | `http://localhost:3001` | Web UI for all monitors |\n| Status page | `http://localhost:3001/status/<name>` | Public uptime page |\n| Push API | `http://localhost:3001/api/push/<id>` | For passive monitors |\n\n## Monitor types\n\n| Type | Example |\n|---|---|\n| HTTP(s) | `https://mysite.com` — checks for 2xx response |\n| TCP Port | `mysite.com:5432` — checks port is open |\n| Ping | `192.168.1.1` — ICMP echo |\n| DNS | `mysite.com` — checks resolution |\n| Push | Passive — your service calls Kuma's API |\n| Docker | Container running check via Docker socket |\n\n## Example\n\n```\nUser: \"Monitor my blog and API, alert me on Discord if either goes down\"\n\nAgent:\n 1. Deploys Uptime Kuma on port 3001\n 2. Adds HTTP monitor: https://myblog.com (check every 60s)\n 3. Adds HTTP monitor: https://api.myblog.com/health (check every 30s)\n 4. Adds Discord notification webhook\n 5. Returns: \"Monitoring started. Dashboard at http://localhost:3001\"\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/uptime-kuma-self-host/SKILL.md" }, { "name": "ntfy-notifier", "category": "integrations", "tier": "featured", "description": "Send push notifications to your phone and desktop via ntfy.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/ntfy-notifier/SKILL.md", "path": "skills/ntfy-notifier", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "ntfy-notifier", "description": "Send push notifications to your phone and desktop via ntfy.", "version": "1.0.0" }, "agent_use": "- The user wants push notifications for their scripts or agent tasks.\n- The user wants alerts on their phone when a cron job fails or a deploy completes.\n- The user wants a simple notification channel without setting up Firebase, APNS, or email.\n- The user says \"send me a notification\", \"alert my phone\", or \"I want push notifications from my scripts\".", "user_use": "The agent sets up ntfy push notifications for your scripts, cron jobs, or agent tasks. You install the ntfy app on your phone, subscribe to a topic, and then any HTTP POST to that topic URL appears as a push notification. No mobile SDK integration, no certificate management, no per-platform setup.", "skillmd_content": "---\nname: ntfy-notifier\ndescription: Use when the user wants push notifications to their phone or desktop from a script, cron job, or agent task — via ntfy's HTTP pub/sub topics — without setting up Firebase, APNS, or a mobile SDK. Covers the public ntfy.sh server and self-hosting via Docker.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [notifications, ntfy, push, pub-sub, alerting, self-hosted]\n related_skills: [webhook-receiver, uptime-kuma-self-host, cron-task]\n---\n\n# ntfy-notifier\n\n## Overview\n\nSend push notifications to any device using ntfy — a simple HTTP-based pub/sub notification service. No SDK, no mobile app integration, no APNS/FCM setup. Just HTTP POST to a topic URL and the notification appears on any subscribed device.\n\n## When to Use\n\n- The user wants push notifications for their scripts or agent tasks.\n- The user wants alerts on their phone when a cron job fails or a deploy completes.\n- The user wants a simple notification channel without setting up Firebase, APNS, or email.\n- The user says \"send me a notification\", \"alert my phone\", or \"I want push notifications from my scripts\".\n\n## Setup\n\n### Option A: Public server (fastest)\n\nUse the public ntfy server at `https://ntfy.sh`. No setup needed — just pick a topic name and start sending.\n\n1. Install the ntfy app on your phone:\n - **iOS**: App Store → \"ntfy\"\n - **Android**: Play Store → \"ntfy\" or F-Droid\n2. Open the app, tap \"+\", subscribe to a topic (e.g., `my-alerts-abc123`)\n3. Send a notification to that topic from any device\n\n### Option B: Self-hosted (private)\n\n```yaml\n# docker-compose.yml\nversion: \"3\"\nservices:\n ntfy:\n image: binwiederhier/ntfy:latest\n container_name: ntfy\n restart: unless-stopped\n ports:\n - \"8090:80\"\n volumes:\n - ntfy-data:/var/lib/ntfy\n command: serve\n\nvolumes:\n ntfy-data:\n```\n\n```bash\ndocker compose up -d\n```\n\nIn the phone app, set the server URL to `http://your-server:8090` and subscribe to a topic.\n\n## Sending Notifications\n\n### curl (simplest)\n\n```bash\n# Basic notification\ncurl -d \"Deploy complete\" ntfy.sh/my-alerts-abc123\n\n# With title and priority\ncurl \\\n -H \"Title: Server Alert\" \\\n -H \"Priority: high\" \\\n -H \"Tags: warning,server\" \\\n -d \"CPU usage at 95%\" \\\n ntfy.sh/my-alerts-abc123\n```\n\n### Python\n\n```python\nimport requests\n\n# Basic\nrequests.post(\"https://ntfy.sh/my-alerts-abc123\", data=\"Build complete\")\n\n# With options\nrequests.post(\"https://ntfy.sh/my-alerts-abc123\",\n data=\"Disk space critical: 2% remaining\",\n headers={\n \"Title\": \"Disk Alert\",\n \"Priority\": \"urgent\",\n \"Tags\": \"warning,disk\",\n \"Actions\": \"view, Open Dashboard, https://grafana.example.com\"\n }\n)\n```\n\n### With actions (buttons in the notification)\n\n```bash\ncurl \\\n -H \"Title: Deploy Ready\" \\\n -H \"Actions: view, Open PR, https://github.com/me/repo/pull/42; http, Approve, https://api.example.com/approve\" \\\n -d \"PR #42 is ready for review\" \\\n ntfy.sh/my-alerts-abc123\n```\n\nThe notification shows buttons: \"Open PR\" (opens URL) and \"Approve\" (sends HTTP request).\n\n## Topics and Subscriptions\n\nTopics are pub/sub channels identified by a name. No registration — anyone with the topic name can publish or subscribe.\n\n- **Pick a unique topic name** — `my-alerts-abc123` is better than `alerts` (which anyone could read)\n- **Subscribe on your phone** — open the ntfy app, add the topic\n- **Subscribe via CLI** — `ntfy subscribe my-alerts-abc123`\n- **Subscribe via curl** — `curl -s ntfy.sh/my-alerts-abc123/sse` (Server-Sent Events stream)\n\n## Priority Levels\n\n| Priority | Behavior |\n|---|---|\n| `default` | Normal notification, no sound |\n| `high` | Notification sound, may bypass Do Not Disturb |\n| `urgent` | Notification sound, bypasses Do Not Disturb, may repeat |\n\n```bash\ncurl -H \"Priority: urgent\" -d \"Server is DOWN\" ntfy.sh/my-alerts-abc123\n```\n\n## Common Patterns\n\n| Pattern | Command |\n|---|---|\n| Script success | `curl -d \"Backup complete\" ntfy.sh/my-topic` |\n| Script failure | `curl -H \"Priority: high\" -d \"Backup FAILED\" ntfy.sh/my-topic` |\n| Cron job heartbeat | `curl -d \"Job ran OK\" ntfy.sh/my-topic` (send after each run) |\n| Deploy notification | `curl -H \"Title: Deploy\" -d \"v1.2.3 live\" ntfy.sh/my-topic` |\n| With action button | `curl -H \"Actions: view, Logs, https://...\" -d \"Build failed\" ntfy.sh/my-topic` |\n\n## Common Pitfalls\n\n1. **Public topics are public.** Anyone who guesses your topic name can read your notifications. Use a long, random topic name (e.g., `alerts-k7m3x9p2q4`), never a plain word like `alerts`. For sensitive notifications, self-host.\n2. **No authentication on the public server.** The public ntfy.sh server doesn't require auth — anyone can publish to any topic they can guess. If you need auth, self-host and configure access control.\n3. **Rate limiting on the public server.** ntfy.sh rate-limits to roughly 60 requests/hour per IP. For higher volume (e.g., per-line log streaming), self-host instead.\n4. **Topic name collisions.** Two unrelated users on the same topic name (e.g., both picking \"test\") will see each other's notifications. Always use a unique, hard-to-guess topic name.\n5. **No delivery guarantees by default.** ntfy is fire-and-forget — if no client is subscribed when a message is sent, it's lost unless message caching is enabled on a self-hosted server.\n6. **Self-hosted push silently fails on iOS without HTTPS.** iOS requires HTTPS for background push delivery. Put a self-hosted ntfy instance behind Caddy or nginx with TLS, or iOS clients won't receive notifications when the app is backgrounded.\n\n## Verification Checklist\n\n- [ ] A test `curl -d \"test\" ntfy.sh/<topic>` (or self-hosted equivalent) returns HTTP 200\n- [ ] The notification actually arrives on the subscribed device/app, not just a 200 from the API\n- [ ] Topic name is long/random enough that it isn't guessable (not `alerts`, `test`, `notify`)\n- [ ] Priority level matches the alert's urgency (`urgent` for outages, `default` for routine heartbeats)\n- [ ] For self-hosted setups: the server is reachable over HTTPS if iOS clients are involved\n- [ ] Any `Actions` buttons open the correct URL / fire the correct HTTP request when tapped\n", "readme_content": "# ntfy-notifier\n\nSend push notifications to your phone and desktop via simple HTTP requests — no SDK, no Firebase, no APNS.\n\n## What it does\n\nThe agent sets up ntfy push notifications for your scripts, cron jobs, or agent tasks. You install the ntfy app on your phone, subscribe to a topic, and then any HTTP POST to that topic URL appears as a push notification. No mobile SDK integration, no certificate management, no per-platform setup.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/ntfy-notifier/SKILL.md\n```\n\n## How to use\n\n```\n\"Send me a notification when my build finishes\"\n```\n\nThe agent:\n1. Picks a unique topic name (or uses one you specify)\n2. Tells you to install the ntfy app and subscribe to the topic\n3. Adds a curl call to your script: `curl -d \"Build complete\" ntfy.sh/your-topic`\n4. Tests it — you get a notification on your phone\n\n## Example\n\n```\nUser: \"Alert my phone if the backup script fails\"\n\nAgent:\n 1. Topic: backup-alerts-x7k2m9\n 2. Instructs: install ntfy app, subscribe to \"backup-alerts-x7k2m9\"\n 3. Adds to backup script:\n curl -H \"Priority: high\" -H \"Title: Backup Failed\" \\\n -d \"Backup failed at $(date)\" ntfy.sh/backup-alerts-x7k2m9\n 4. Tests: curl -d \"Test alert\" ntfy.sh/backup-alerts-x7k2m9\n 5. Returns: \"You should see a test notification on your phone.\"\n```\n\n## Sending a notification\n\n```bash\n# Basic\ncurl -d \"Hello\" ntfy.sh/your-topic\n\n# With title and priority\ncurl -H \"Title: Alert\" -H \"Priority: high\" -d \"Server down\" ntfy.sh/your-topic\n```\n\nThe notification appears instantly on any device subscribed to the topic.\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/ntfy-notifier/SKILL.md" }, { "name": "git-backup", "category": "utility", "tier": "featured", "description": "Automate git backup of your repos to a remote.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/git-backup/SKILL.md", "path": "skills/git-backup", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "git-backup", "description": "Automate git backup of your repos to a remote.", "version": "1.0.0" }, "agent_use": "- The user says \"back up my repos\", \"I don't want to lose my code\", or \"set up\n automatic repo backups\".\n- The user has local git repos (work, personal, dotfiles) that are not pushed\n anywhere, or are pushed only to one provider and want a second copy.\n- The user wants a 3-2-1-style strategy: 3 copies, 2 media, 1 offsite.\n- Suitable for any OS where `git` runs (Linux, macOS, Windows/WSL, MSYS).\n\nDo **not** use this skill for:\n- Non-git directories — use a file-level tool (rsync, restic, borg).\n- One-off manual pushes the user will do by hand.\n- Large binary/asset stores — consider Git LFS or object storage instead.", "user_use": "- **Three strategies**, matched to your setup:\n - **Mirror clone** — compact bare mirror of every ref, ready to push to a host.\n - **Bundle** — one self-contained `.bundle` file for cold/portable storage.\n - **Bundle + push** — both, for a 3-2-1-grade safety net.\n- **Scheduling** out of the box: `cron` or a `systemd` timer (Windows via Task\n Scheduler / WSL).\n- **Any remote**: GitHub, Forgejo/Gitea, GitLab, or S3-compatible object storage\n (incl. Backblaze B2, Wasabi, MinIO via `rclone`).\n- **Verification** built in: `git bundle verify`, `git fsck`, push exit codes,\n and a weekly spot-restore check plus a heartbeat alert.\n- **Restore** in three lines, from mirror, bundle, or bucket.", "skillmd_content": "---\nname: git-backup\ndescription: Use when a user wants a hands-off, repeatable backup of local git repositories to a remote — mirror clones, bundles, scheduled cron/systemd jobs, or restoring from a mirror/bundle to GitHub, Forgejo, GitLab, or S3.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [git, backup, mirror-clone, cron, disaster-recovery]\n related_skills: [cron-task, forgejo-self-host, dotfiles-manage]\n---\n\n# git-backup\n\n## Overview\n\nGive an agent this skill and it will set up an automated, verifiable backup of\nthe user's git repositories to a remote destination — with no manual `git push`\nrequired and no data lost when a laptop dies.\n\nBackups are only useful if they are (a) automatic, (b) verified, and\n(c) restorable. This skill optimizes for all three.\n\n## When to Use\n\n- The user says \"back up my repos\", \"I don't want to lose my code\", or \"set up\n automatic repo backups\".\n- The user has local git repos (work, personal, dotfiles) that are not pushed\n anywhere, or are pushed only to one provider and want a second copy.\n- The user wants a 3-2-1-style strategy: 3 copies, 2 media, 1 offsite.\n- Suitable for any OS where `git` runs (Linux, macOS, Windows/WSL, MSYS).\n\nDo **not** use this skill for:\n- Non-git directories — use a file-level tool (rsync, restic, borg).\n- One-off manual pushes the user will do by hand.\n- Large binary/asset stores — consider Git LFS or object storage instead.\n\n## Backup Strategies\n\nPick one based on how many repos, how much automation, and how \"portable\" the\nbackup must be.\n\n### 1. Mirror clone (recommended for live remotes)\n\nA bare mirror keeps **all** refs (branches, tags, notes) and is what remote\nhosts expect.\n\n```bash\n# One-time setup\ngit clone --mirror https://github.com/user/repo.git /backups/repo.git\n\n# Each run (idempotent)\ncd /backups/repo.git\ngit remote update --prune\n```\n\nMirror clones are compact (bare, no working tree) and push cleanly to a fresh\nremote later. Good when the destination is another git host.\n\n### 2. Bundle (recommended for cold/portable storage)\n\nA bundle is a **single self-contained file** containing the entire repo — perfect\nfor S3, NAS, USB drives, or emailing to yourself.\n\n```bash\n# Full snapshot into one file\ngit bundle create /backups/repo-$(date +%F).bundle --all\n\n# Incremental after a known base (smaller files over time)\ngit bundle create /backups/repo-inc.bundle --since=1.week --all\n```\n\nVerify a bundle before trusting it:\n\n```bash\ngit bundle verify /backups/repo-2026-07-20.bundle\n```\n\n### 3. Bundle + push (belt and suspenders)\n\nCombine both: keep a live mirror on a git host **and** ship a dated bundle to\nobject storage. This survives both \"host went down\" and \"account locked\".\n\n```bash\ngit clone --mirror /src/repo /backups/repo.git && \\\n (cd /backups/repo.git && git remote update --prune) && \\\n git -C /src/repo bundle create /backups/bundles/repo-$(date +%F).bundle --all && \\\n aws s3 cp /backups/bundles/repo-$(date +%F).bundle s3://my-bucket/git/\n```\n\n## Scheduling\n\n### cron (Linux/macOS/WSL)\n\nEdit the user's crontab; never run backups as root unless the repos require it.\n\n```bash\ncrontab -e\n# Daily at 03:15, log to a file, silence on success\n15 3 * * * /usr/bin/bash /home/user/bin/git-backup.sh >> /var/log/git-backup.log 2>&1\n```\n\nStagger multiple repos so they don't all hit the network at once:\n\n```bash\n15 3 * * * /home/user/bin/git-backup.sh work >> /var/log/gb-work.log 2>&1\n30 3 * * * /home/user/bin/git-backup.sh personal >> /var/log/gb-personal.log 2>&1\n```\n\n### systemd timer (preferred on modern Linux)\n\nTimers survive sleep, log to the journal, and recover missed runs.\n\n```ini\n# ~/.config/systemd/user/git-backup.service\n[Unit]\nDescription=Git backup\n\n[Service]\nType=oneshot\nExecStart=/home/user/bin/git-backup.sh\n```\n\n```ini\n# ~/.config/systemd/user/git-backup.timer\n[Unit]\nDescription=Daily git backup\n\n[Timer]\nOnCalendar=*-*-* 03:15:00\nPersistent=true\nRandomizedDelaySec=300\n\n[Install]\nWantedBy=timers.target\n```\n\n```bash\nsystemctl --user daemon-reload\nsystemctl --user enable --now git-backup.timer\nsystemctl --user status git-backup.timer\n```\n\nOn Windows use Task Scheduler to call a `.bat`/`.ps1` wrapper around the same\nscript, or run the script under WSL.\n\n## Remote Options\n\n### GitHub\nCreate a private repo, then push the mirror:\n```bash\ncd /backups/repo.git\ngit remote add github https://github.com/user/backup-repo.git\ngit push --mirror github\n```\n\n### Forgejo / Gitea\nSame flow, different URL:\n```bash\ngit remote add forge https://forgejo.example.com/user/backup-repo.git\ngit push --mirror forge\n```\n\n### GitLab\n```bash\ngit remote add gitlab https://gitlab.com/user/backup-repo.git\ngit push --mirror gitlab\n```\n\n### S3 (or any S3-compatible bucket)\nBundles are the natural fit here — they're immutable files:\n```bash\naws s3 cp repo-$(date +%F).bundle s3://my-backup-bucket/git/\n# rclone also works for Backblaze B2, Wasabi, MinIO, etc.\nrclone copy repo-$(date +%F).bundle remote:bucket/git/\n```\nSet lifecycle rules to expire very old bundles after N copies exist.\n\n## Backup Verification\n\nA backup you never check is a hope, not a backup. After every run:\n\n1. **Mirror:** `git fsck --connectivity-only` inside the mirror, and confirm the\n remote's ref list matches the source.\n2. **Bundle:** always run `git bundle verify <file>` in the backup script and\n abort on non-zero exit.\n3. **Remote push:** check `git push --mirror` exit code; alert on failure.\n4. **Spot restore (weekly):** clone the mirror or unbundle into a temp dir and\n confirm `git log -1` works.\n\nAdd a heartbeat: a tiny `echo \"$(date) OK\" >> /var/log/gb-heartbeat` line, and\nalert if the file is older than 26h.\n\n## Restore\n\nFrom a mirror:\n```bash\ngit clone /backups/repo.git restored-repo\n```\n\nFrom a bundle:\n```bash\ngit clone /backups/repo-2026-07-20.bundle restored-repo\n```\n\nFrom S3:\n```bash\naws s3 cp s3://my-backup-bucket/git/repo-2026-07-20.bundle ./\ngit clone repo-2026-07-20.bundle restored-repo\n```\n\nThen re-point remotes to the live host and push normally. If the original host\nis gone, the mirror/bundle *is* the source of truth.\n\n## Minimal script skeleton (`git-backup.sh`)\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\nSRC=\"$1\"; NAME=$(basename \"$SRC\"); STAMP=$(date -u +%F)\nBAK=\"/backups\"; mkdir -p \"$BAK/bundles\"\ngit -C \"$SRC\" bundle create \"$BAK/bundles/$NAME-$STAMP.bundle\" --all\ngit bundle verify \"$BAK/bundles/$NAME-$STAMP.bundle\"\necho \"$(date -u) $NAME OK\" >> /var/log/gb-heartbeat\n```\n\n## Common Pitfalls\n\n1. **`git push` (not `--mirror`) loses tags/branches.** Always use `--mirror` for\n full-fidelity copies, or `git push --tags` plus every branch explicitly.\n2. **Non-bare mirrors drift.** Use `git clone --mirror` (bare) or `remote update\n --prune`; a normal clone accumulates conflicts on repeated fetches.\n3. **Credentials expire.** Use SSH keys or a token stored in the OS keychain, not\n inline in scripts. On CI, use scoped deploy keys.\n4. **Unverified bundles rot silently.** A truncated bundle fails only on clone —\n always `git bundle verify` immediately after creation.\n5. **Clock skew / timezones** in dated filenames cause confusing overwrites;\n use UTC (`date -u +%FT%TZ`) for filenames.\n6. **Huge repos + daily full bundles** waste space; switch to incremental\n bundles (`--since`) or keep only the mirror for those.\n7. **Silent failures.** If the script has no alert path, a broken backup looks\n identical to a working one. Always log + heartbeat + alert.\n8. **Symlinks / submodules.** `git bundle` does not follow submodules; back them\n up separately or use `git submodule` foreach.\n\n## Verification Checklist\n\n- [ ] `git bundle verify <file>` (or `git fsck --connectivity-only` for mirrors) returns success after the backup run\n- [ ] The destination remote's ref list matches the source (`git ls-remote` compared, or a clone/unbundle into a temp dir confirms `git log -1` works)\n- [ ] The backup used `--mirror` (not a plain `git push`), so all branches, tags, and notes were captured\n- [ ] The scheduled job (cron/systemd timer) is enabled, and the heartbeat file has a timestamp within the expected window\n- [ ] Credentials are stored via SSH key or OS keychain/token — not hardcoded in the script\n", "readme_content": "# git-backup\n\n> A Hermes skill that gives the agent everything it needs to set up **automatic,\n> verifiable, restorable backups** of your git repositories to an offsite remote.\n\nPart of the [Hermes Skills Portfolio](https://github.com/THEROCKSSS/hermes-skills-portfolio)\nby Alex.\n\n---\n\n## Why\n\nA local repo is one spilled coffee away from gone. `git-backup` turns \"I should\nreally push that\" into a scheduled, hands-off job that you can prove works.\n\nBackups are only real when they are **automatic**, **verified**, and\n**restorable**. This skill is built around all three.\n\n## What it does\n\n- **Three strategies**, matched to your setup:\n - **Mirror clone** — compact bare mirror of every ref, ready to push to a host.\n - **Bundle** — one self-contained `.bundle` file for cold/portable storage.\n - **Bundle + push** — both, for a 3-2-1-grade safety net.\n- **Scheduling** out of the box: `cron` or a `systemd` timer (Windows via Task\n Scheduler / WSL).\n- **Any remote**: GitHub, Forgejo/Gitea, GitLab, or S3-compatible object storage\n (incl. Backblaze B2, Wasabi, MinIO via `rclone`).\n- **Verification** built in: `git bundle verify`, `git fsck`, push exit codes,\n and a weekly spot-restore check plus a heartbeat alert.\n- **Restore** in three lines, from mirror, bundle, or bucket.\n\n## Install\n\nPoint Hermes at the skill file:\n\n```\nhttps://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/git-backup/SKILL.md\n```\n\nOr copy the `git-backup` folder into your Hermes skills directory.\n\n## Quick start\n\nTell the agent:\n\n> \"Back up my `~/code` repos to a private GitHub repo every night, and also drop\n> a weekly bundle in my S3 bucket. Verify each backup and warn me if one fails.\"\n\nThe agent will pick the mirror + bundle strategy, wire up a `systemd` timer (or\n`cron`), add verification + a heartbeat, and hand you the restore commands.\n\n## Example\n\n```bash\n# One self-contained, verified snapshot\ngit bundle create repo-2026-07-20.bundle --all\ngit bundle verify repo-2026-07-20.bundle\n\n# Restore it anywhere\ngit clone repo-2026-07-20.bundle restored-repo\n```\n\n## License\n\nMIT — use it, fork it, ship it.\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/git-backup/SKILL.md" }, { "name": "dotfiles-manage", "category": "utility", "tier": "featured", "description": "Version and sync your dotfiles across machines.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/dotfiles-manage/SKILL.md", "path": "skills/dotfiles-manage", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "dotfiles-manage", "description": "Version and sync your dotfiles across machines.", "version": "1.0.0" }, "agent_use": "- The user wants their config files versioned and backed up.\n- The user is setting up a new machine and wants their configs deployed automatically.\n- The user wants to sync configs across multiple machines.\n- The user says \"set up dotfiles\", \"version my configs\", or \"I want my settings on my new laptop\".", "user_use": "The agent sets up a dotfiles management system using one of four strategies (git bare repo, GNU Stow, chezmoi, or yadm). Your config files get versioned in a git repo, and you can deploy them to any new machine by cloning and running one command. No more manually copying `.bashrc` to a new laptop.", "skillmd_content": "---\nname: dotfiles-manage\ndescription: \"Use when the user wants their config files (shell, editor, git, tmux) versioned in git and deployable to a new machine in one command, or wants to sync dotfiles across multiple machines.\"\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [dotfiles, git-bare-repo, gnu-stow, chezmoi, yadm, config-sync]\n related_skills: [env-config-manager, git-backup]\n---\n\n# dotfiles-manage\n\n## Overview\n\nSet up dotfiles management — version control for your configuration files (shell config, editor config, git config, etc.) with the ability to deploy them to any new machine in one command.\n\n## When to Use\n\n- The user wants their config files versioned and backed up.\n- The user is setting up a new machine and wants their configs deployed automatically.\n- The user wants to sync configs across multiple machines.\n- The user says \"set up dotfiles\", \"version my configs\", or \"I want my settings on my new laptop\".\n\n## Strategies\n\nFour approaches, ranked by simplicity:\n\n| Strategy | How it works | Best for |\n|---|---|---|\n| **Git bare repo** | A bare git repo stores dotfiles; alias manages add/commit | Minimalists, no extra tools |\n| **GNU Stow** | Symlinks from a directory to your home dir | Organized by package, standard tool |\n| **chezmoi** | Templated dotfiles with encryption and machine-specific values | Multiple machines with different needs |\n| **yadm** | Yet Another Dotfiles Manager — git wrapper for home dir | Git-familiar users who want extras |\n\n## Strategy 1: Git Bare Repo (simplest)\n\nNo extra software needed — just git.\n\n### Setup\n\n```bash\n# Create a bare repo\ngit init --bare $HOME/.cfg\n\n# Add an alias to your shell config\necho \"alias config='/usr/bin/git --git-dir=\\$HOME/.cfg/ --work-tree=\\$HOME'\" >> ~/.bashrc\nsource ~/.bashrc\n\n# Ignore untracked files in your home dir\nconfig config --local status.showUntrackedFiles no\n```\n\n### Add files\n\n```bash\nconfig add .bashrc .vimrc .gitconfig .tmux.conf\nconfig commit -m \"Initial dotfiles\"\nconfig remote add origin git@github.com:youruser/dotfiles.git\nconfig push -u origin main\n```\n\n### Deploy on a new machine\n\n```bash\n# Clone as bare repo\ngit clone --bare git@github.com:youruser/dotfiles.git $HOME/.cfg\n\n# Add the alias\necho \"alias config='/usr/bin/git --git-dir=\\$HOME/.cfg/ --work-tree=\\$HOME'\" >> ~/.bashrc\nsource ~/.bashrc\n\n# Checkout the files\nconfig checkout\n\n# If it fails because files already exist, back them up first:\nmkdir -p .config-backup\nconfig checkout 2>&1 | grep \"\\t\" | awk {'print $1'} | xargs -I{} mv {} .config-backup/{}\nconfig checkout\n\n# Set ignore\nconfig config --local status.showUntrackedFiles no\n```\n\n## Strategy 2: GNU Stow\n\nOrganizes dotfiles into \"packages\" and symlinks them into your home directory.\n\n### Setup\n\n```bash\n# Install stow\n# Linux: apt install stow / pacman -S stow\n# macOS: brew install stow\n\n# Create a dotfiles directory\nmkdir ~/dotfiles\ncd ~/dotfiles\n\n# Organize by package (each package is a directory)\nmkdir -p bash vim git tmux\n\n# Move configs into the package directories\nmv ~/.bashrc ~/dotfiles/bash/.bashrc\nmv ~/.vimrc ~/dotfiles/vim/.vimrc\nmv ~/.gitconfig ~/dotfiles/git/.gitconfig\n\n# Stow (create symlinks)\nstow bash vim git\n\n# Verify\nls -la ~/.bashrc # → ~/dotfiles/bash/.bashrc\n```\n\n### Deploy on a new machine\n\n```bash\ngit clone git@github.com:youruser/dotfiles.git ~/dotfiles\ncd ~/dotfiles\nstow bash vim git tmux\n```\n\n### Remove a package\n\n```bash\nstow -D vim # removes the symlinks for the vim package\n```\n\n## Strategy 3: chezmoi (for multiple machines with differences)\n\nchezmoi handles machine-specific values, templating, and secrets.\n\n### Setup\n\n```bash\n# Install\n# Linux: snap install chezmoi --classic / or download\n# macOS: brew install chezmoi\n\n# Initialize\nchezmoi init\n\n# Add a file\nchezmoi add ~/.bashrc\nchezmoi add ~/.gitconfig\n\n# Edit managed files\nchezmoi edit ~/.bashrc\n\n# Apply changes to your home dir\nchezmoi apply\n\n# Push to git\nchezmoi cd\ngit add -A\ngit commit -m \"Initial dotfiles\"\ngit remote add origin git@github.com:youruser/dotfiles.git\ngit push -u origin main\n```\n\n### Deploy on a new machine\n\n```bash\nchezmoi init git@github.com:youruser/dotfiles.git\nchezmoi apply\n```\n\n### Machine-specific values\n\n```bash\n# Template file (chezmoi edit ~/.gitconfig)\n[user]\n name = {{ .name }}\n email = {{ .email }}\n\n# Set values per machine\nchezmoi data --format json # see current values\n# Edit config:\nchezmoi edit-config\n# Add:\n# data:\n# name: \"Your Name\"\n# email: \"your@email.com\"\n```\n\n## Managing Different OSes\n\nFor configs that differ between Linux and macOS:\n\n**chezmoi:**\n```bash\n# OS-specific files are named with .linux_ or .darwin_ prefix\n# chezmoi automatically picks the right one\n```\n\n**Git bare repo / Stow:**\n```bash\n# Use conditionals in your shell config:\nif [ \"$(uname)\" = \"Darwin\" ]; then\n # macOS-specific settings\nelif [ \"$(uname)\" = \"Linux\" ]; then\n # Linux-specific settings\nfi\n```\n\n## Secrets in Dotfiles\n\nNever commit plaintext secrets (API keys, tokens, passwords).\n\n| Method | How |\n|---|---|\n| **chezmoi encryption** | `chezmoi add --encrypt ~/.config/secrets` — encrypts with age or gpg |\n| **Env vars** | Store secrets in a file that's gitignored, load via shell config |\n| **Password manager** | Reference secrets from `pass`, `1password-cli`, or `bitwarden-cli` in config |\n\n```bash\n# Example: load API key from password manager in .bashrc\nexport OPENAI_API_KEY=$(pass openai/api-key)\n```\n\n## Common Pitfalls\n\n1. **Committing secrets** — Before pushing, check for API keys, tokens, and passwords in your dotfiles. Use `git log -p` to review history. If secrets are already pushed, rotate them immediately — git history is forever.\n2. **Symlink loops with Stow** — If you stow a directory that contains a symlink pointing back to your home dir, you'll create a loop. Stow will warn you, but check manually.\n3. **Bare repo checkout fails** — If files already exist in your home dir, `config checkout` fails. Back up the conflicting files first (the deploy script above handles this).\n4. **Different paths on different OSes** — A config that works on Linux (`~/.config/`) may need a different path on macOS (`~/Library/Application Support/`). Use chezmoi for cross-OS setups, or conditional logic in shell configs.\n5. **Forgetting to stow/restow** — After adding a new file to a Stow package, you need to re-run `stow <package>` to create the symlink. It's not automatic.\n6. **Machine-specific values in git** — If you hardcode a machine-specific value (hostname, IP, path) in a dotfile, it breaks on other machines. Use templates (chezmoi) or conditionals.\n\n## Verification Checklist\n\n- [ ] `config status` / `git -C ~/dotfiles status` (or `stow -n`/`chezmoi diff`) shows no unexpected changes after setup\n- [ ] A clean-machine deploy (fresh clone + checkout/`stow`/`chezmoi apply`) reproduces the expected files or symlinks\n- [ ] `git log -p` reviewed for accidentally committed secrets before the first push\n- [ ] `status.showUntrackedFiles` is set to `no` for the bare-repo approach so `git status`/`config status` in `$HOME` stays quiet\n", "readme_content": "# dotfiles-manage\n\nVersion and sync your configuration files across machines with git — deploy to any new machine in one command.\n\n## What it does\n\nThe agent sets up a dotfiles management system using one of four strategies (git bare repo, GNU Stow, chezmoi, or yadm). Your config files get versioned in a git repo, and you can deploy them to any new machine by cloning and running one command. No more manually copying `.bashrc` to a new laptop.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/dotfiles-manage/SKILL.md\n```\n\n## How to use\n\n```\n\"Set up dotfiles management for my shell, vim, and git configs\"\n```\n\nThe agent:\n1. Recommends a strategy based on your needs (bare repo for simplicity, chezmoi for multiple machines)\n2. Sets up the repo structure\n3. Adds your config files\n4. Pushes to a git remote\n5. Shows you the one-command deploy for new machines\n\n## Strategies\n\n| Strategy | Needs | Best for |\n|---|---|---|\n| Git bare repo | Just git | Minimalists |\n| GNU Stow | stow installed | Organized by package |\n| chezmoi | chezmoi installed | Multiple machines, templated configs |\n| yadm | yadm installed | Git-familiar, wants extras |\n\n## Example\n\n```\nUser: \"I just got a new laptop. I want my configs from my old machine.\"\n\nAgent:\n 1. On old machine: sets up git bare repo at ~/.cfg\n 2. Adds: .bashrc, .vimrc, .gitconfig, .tmux.conf\n 3. Pushes to github.com/youruser/dotfiles\n 4. On new machine: git clone --bare ... ~/.cfg\n 5. Runs: config checkout\n 6. Returns: \"Your configs are now on the new machine.\"\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/dotfiles-manage/SKILL.md" }, { "name": "resume-builder", "category": "utility", "tier": "featured", "description": "Generate a professional resume in HTML/PDF from structured data.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/resume-builder/SKILL.md", "path": "skills/resume-builder", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "resume-builder", "description": "Generate a professional resume in HTML/PDF from structured data.", "version": "1.0.0" }, "agent_use": "- The user supplies (or asks the agent to draft) a resume from facts: jobs,\n education, skills, projects, certifications, contact info.\n- The user has a YAML/JSON resume and wants a styled HTML page or a PDF.\n- The user wants to re-theme an existing resume (minimal / modern / classic)\n without re-typing content.\n- The user needs a portable, ATS-friendly document for job applications.\n\nDo **not** use this skill for:\n- Cover letters (single narrative prose) — that is a different artifact.\n- Full CVs with publications/grants requiring a two-column academic layout\n (the classic template can approximate it, but flag the limitation).\n- Free-form un-structured prompts with no data — first collect data (see\n Input Format), then render.", "user_use": "Give the agent your work history, education, skills, and contact details as\nstructured YAML or JSON. `resume-builder` renders a clean, ATS-friendly resume\nin three visual styles and exports it to PDF with a single command.\n\nNo copy-pasting into Word. No fighting with margins. You describe the facts; the\nskill handles the typography, pagination, and print fidelity.", "skillmd_content": "---\nname: resume-builder\ndescription: Use when the user wants to turn structured career data (YAML/JSON work history, education, skills, projects, certifications) into a polished resume — a styled HTML page, a PDF export, or a re-themed (minimal/modern/classic) version of an existing resume — for job applications or an ATS-friendly document.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [resume, pdf-export, html-templates, weasyprint, puppeteer, cv]\n related_skills: [markdown-to-pdf, invoice-generator, color-palette-generator]\n---\n\n# resume-builder\n\n## Overview\n\nTurn structured career data into a clean, professional resume rendered as HTML\nand exported to PDF. The skill owns the data model, three visual templates, and\ntwo PDF backends so the agent produces a consistent artifact regardless of the\nuser's environment.\n\n## When to Use\n\n- The user supplies (or asks the agent to draft) a resume from facts: jobs,\n education, skills, projects, certifications, contact info.\n- The user has a YAML/JSON resume and wants a styled HTML page or a PDF.\n- The user wants to re-theme an existing resume (minimal / modern / classic)\n without re-typing content.\n- The user needs a portable, ATS-friendly document for job applications.\n\nDo **not** use this skill for:\n- Cover letters (single narrative prose) — that is a different artifact.\n- Full CVs with publications/grants requiring a two-column academic layout\n (the classic template can approximate it, but flag the limitation).\n- Free-form un-structured prompts with no data — first collect data (see\n Input Format), then render.\n\n## Input Format\n\nThe skill consumes one structured document. Prefer YAML for hand-authoring;\naccept JSON interchangeably. The canonical schema:\n\n```yaml\nbasics:\n name: \"Alex\"\n label: \"Senior Software Engineer\"\n email: \"monica@example.com\"\n phone: \"+1 555 0100\"\n url: \"https://monica.example.com\"\n location: \"Berlin, Germany\"\n summary: \"Engineer focused on distributed systems and developer tooling.\"\nsections:\n work:\n - company: \"Acme Corp\"\n position: \"Staff Engineer\"\n start: \"2021-03\"\n end: \"2024-06\"\n location: \"Remote\"\n highlights:\n - \"Led migration of the billing service to event-driven architecture.\"\n - \"Cut p99 latency 40% by introducing a read-through cache.\"\n education:\n - institution: \"TU Berlin\"\n area: \"Computer Science\"\n degree: \"M.Sc.\"\n start: \"2016\"\n end: \"2018\"\n skills:\n - \"Go\"\n - \"Kubernetes\"\n - \"Distributed Systems\"\n projects:\n - name: \"openmetrics-cli\"\n description: \"A tiny Prometheus exporter toolkit.\"\n url: \"https://github.com/monica/openmetrics-cli\"\n certifications:\n - name: \"CKA\"\n issuer: \"CNCF\"\n date: \"2022\"\n```\n\nRules the agent must enforce:\n- `basics.name` and at least one `sections.work` or `sections.education` entry\n are required; everything else is optional and omitted if absent.\n- Dates use ISO `YYYY` or `YYYY-MM`. Render ranges as `2021-03 – 2024-06`;\n an open-ended role uses `Present` for a missing `end`.\n- `highlights` are bullet points; keep each to one line, action-verb led.\n- Unknown top-level keys are ignored, not erroring.\n\n## Template Options\n\nThree bundled, dependency-light CSS templates. The agent picks based on the\nuser's stated preference or defaults to `modern`.\n\n- **minimal** — single column, generous whitespace, hairline rules, neutral\n sans-serif. Best for engineering and design roles; fastest to scan.\n- **modern** — accent color header band, two-tone palette, subtle typographic\n scale. Good general-purpose default.\n- **classic** — serif body, ruled section headers, traditional chronology.\n Best for finance, law, academia-adjacent roles.\n\nEach template is a self-contained `<style>` block plus semantic HTML, so the\nrendered page works offline and prints predictably. The agent sets the accent\ncolor and font stack via CSS custom properties (see Customization).\n\n## PDF Generation\n\nTwo backends; pick whichever the user's machine can run.\n\n### weasyprint (recommended, headless-friendly)\n```bash\npip install weasyprint\nweasyprint resume.html resume.pdf\n```\nWeasyPrint renders HTML/CSS directly — no browser needed. It honors `@page`\nsize, margins, and `print-color-adjust: exact`, so colors survive to PDF.\nBest when Chromium is unavailable.\n\n### puppeteer (best fidelity to screen CSS)\n```bash\nnpm install puppeteer\nnode -e \"const p=require('puppeteer');(async()=>{const b=await p.launch();const pg=await b.newPage();await pg.goto('file://'+process.cwd()+'/resume.html',{waitUntil:'networkidle0'});await pg.pdf({path:'resume.pdf',format:'A4',printBackground:true});await b.close();})()\"\n```\nPuppeteer gives pixel-accurate output and supports web fonts. Slower to install\nand needs a Chromium download. Use when the user already has Node tooling.\n\nAlways generate the PDF from the final HTML with `printBackground: true`\n(puppeteer) or `print-color-adjust: exact` (weasyprint) so the template's\nbackground colors and rules appear.\n\n## HTML Output\n\nThe agent writes a single `resume.html`:\n- One root `<article class=\"resume template-<name>\">`.\n- `lang` attribute set from `basics` locale or default `en`.\n- Inline `<style>` in `<head>`; no external requests (fonts fall back to\n system stacks) so the file is portable.\n- Contact links use `mailto:`/`tel:` and `rel=\"noopener\"` on external `url`.\n- Section order: summary → work → education → projects → skills → certifications.\n Sections with no data are dropped; the order is fixed for ATS consistency.\n\n## Customization\n\nDrive appearance through CSS custom properties at `:root` so users restyle\nwithout touching markup:\n\n```css\n:root{\n --accent:#2563eb;\n --ink:#111827;\n --paper:#ffffff;\n --font-sans: \"Inter\", system-ui, sans-serif;\n --font-serif: \"Georgia\", serif;\n --space: 0.75rem;\n}\n```\n\n- Recolor: set `--accent`. For classic, also set `--font-body: var(--font-serif)`.\n- Page size: override `@page { size: A4; margin: 14mm; }` (Letter for US users).\n- Multi-page: templates are fluid; long resumes paginate automatically. Add\n `break-inside: avoid` on `.entry` to keep a role's block together.\n- One-page coercion: if the user demands one page, shrink `--space` and font\n sizes, trim `summary`, and cap `highlights` to 3–4 per role rather than\n dropping sections.\n\n## Common Pitfalls\n\n1. **Missing `end` date rendered as blank.** Always render `Present` for an open-ended role — never leave the date range blank.\n2. **WeasyPrint missing system libs.** On minimal Linux images, Pango/cairo aren't present — install `libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0` first, or fall back to puppeteer.\n3. **Fonts not embedded in PDF.** Web fonts loaded via `<link>` may not print — use system font stacks, or inline `@font-face` only if the user supplies font files.\n4. **Print background stripped.** Without `printBackground:true` (puppeteer) or `print-color-adjust:exact` (weasyprint), accent bands disappear — the #1 \"my PDF looks blank\" bug.\n5. **ATS parsing broken by layout.** Text-in-images or heavy two-column sidebars can confuse ATS parsers — the minimal/modern templates are ATS-safe; flag the risk if the user demands a dense two-column layout.\n6. **Non-ISO dates left unnormalized.** Convert `Mar 2021` → `2021-03` before rendering, or date ranges sort/display incorrectly.\n7. **Long URLs in `highlights` breaking layout.** Wrap or shorten raw URLs in bullet points rather than letting them overflow the line.\n8. **Over-styling to force one page.** Don't shrink below 9pt to fit — trim `summary` and cap highlights to 3-4 per role instead.\n\n## Verification Checklist\n\n- [ ] Required fields present: `basics.name` and at least one `work` or `education` entry\n- [ ] All dates rendered as `YYYY-MM` ranges (or `Present` for open-ended roles), no raw blanks\n- [ ] Rendered HTML opens correctly with no external network requests (fonts/styles are self-contained)\n- [ ] PDF export includes background colors/accent bands (`printBackground`/`print-color-adjust` was set)\n- [ ] PDF page size matches the user's expected paper size (A4 vs Letter)\n- [ ] Resume fits the requested page count without text below 9pt\n", "readme_content": "# resume-builder\n\n> A Hermes skill that turns structured career data into a professional,\n> print-ready resume — HTML and PDF, no design work required.\n\n**Category:** utility · **Tier:** featured · **Version:** 1.0.0\n\n---\n\n## What it does\n\nGive the agent your work history, education, skills, and contact details as\nstructured YAML or JSON. `resume-builder` renders a clean, ATS-friendly resume\nin three visual styles and exports it to PDF with a single command.\n\nNo copy-pasting into Word. No fighting with margins. You describe the facts; the\nskill handles the typography, pagination, and print fidelity.\n\n## Templates\n\n| Template | Look | Best for |\n|-----------|-----------------------------------------|---------------------------|\n| `minimal` | Single column, hairline rules, calm | Engineering, design |\n| `modern` | Accent header band, two-tone palette | General-purpose default |\n| `classic` | Serif body, ruled headers, traditional | Finance, law, academia |\n\n## Quick start\n\n```yaml\n# resume.yaml\nbasics:\n name: \"Alex\"\n label: \"Senior Software Engineer\"\n email: \"monica@example.com\"\n summary: \"Engineer focused on distributed systems.\"\nsections:\n work:\n - company: \"Acme Corp\"\n position: \"Staff Engineer\"\n start: \"2021-03\"\n end: \"2024-06\"\n highlights:\n - \"Led a billing-service migration to event-driven architecture.\"\n education:\n - institution: \"TU Berlin\"\n area: \"Computer Science\"\n degree: \"M.Sc.\"\n end: \"2018\"\n skills: [\"Go\", \"Kubernetes\", \"Distributed Systems\"]\n```\n\nRender and export:\n\n```bash\n# HTML (agent writes resume.html)\n# PDF via WeasyPrint (no browser needed):\npip install weasyprint && weasyprint resume.html resume.pdf\n\n# or PDF via Puppeteer (pixel-accurate):\nnpm install puppeteer && node export-pdf.js\n```\n\n## Why it's reliable\n\n- **Print-safe by default** — background colors and rules survive to PDF via\n `printBackground: true` / `print-color-adjust: exact`.\n- **ATS-friendly** — real text, predictable section order, no image-bound text.\n- **Portable** — single self-contained HTML file, system font stacks, no\n external requests.\n- **Customizable** — recolor and re-space through CSS custom properties; switch\n page size (A4 / Letter) with one `@page` rule.\n\n## Install\n\nInstall this skill into Hermes from the public portfolio:\n\n```\nhttps://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/resume-builder/SKILL.md\n```\n\nThen ask Hermes: *\"Build my resume from resume.yaml using the modern template\nand export a PDF.\"*\n\n## License\n\nMIT — use it, fork it, ship your own portfolio version.\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/resume-builder/SKILL.md" }, { "name": "invoice-generator", "category": "utility", "tier": "featured", "description": "Generate professional invoices in PDF from line items.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/invoice-generator/SKILL.md", "path": "skills/invoice-generator", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "invoice-generator", "description": "Generate professional invoices in PDF from line items.", "version": "1.0.0" }, "agent_use": "- A user asks to \"make an invoice\", \"bill my client\", or \"generate an invoice PDF\".\n- A user provides services/products sold and wants a formal document with totals.\n- A user needs recurring or one-off invoices with consistent branding.\n- A user wants tax (VAT/GST/sales tax) applied and shown as its own line.\n- A user works across currencies and needs symbols + formatting handled correctly.\n\nDo **not** use this skill for: quotes/estimates (no tax committed yet — still fine\nto adapt, but name it clearly), receipts for already-paid cash sales, or timesheets\nwithout a billing step. For those, adjust the document title and clarify status.", "user_use": "", "skillmd_content": "---\nname: invoice-generator\ndescription: Use when a user wants a clean, branded PDF invoice generated from structured line items — client details, itemized line items, tax, totals, and currency — for one-off billing, recurring invoices, or multi-currency clients.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [invoice, pdf, billing, fpdf2, line-items]\n related_skills: [markdown-to-pdf, resume-builder]\n---\n\n# invoice-generator\n\n## Overview\n\nTurn structured line items into a polished, print-ready PDF invoice. The agent\ncollects client and item details, validates the math, and renders a professional\ndocument the user can send to a customer or file for accounting.\n\n## When to Use\n\n- A user asks to \"make an invoice\", \"bill my client\", or \"generate an invoice PDF\".\n- A user provides services/products sold and wants a formal document with totals.\n- A user needs recurring or one-off invoices with consistent branding.\n- A user wants tax (VAT/GST/sales tax) applied and shown as its own line.\n- A user works across currencies and needs symbols + formatting handled correctly.\n\nDo **not** use this skill for: quotes/estimates (no tax committed yet — still fine\nto adapt, but name it clearly), receipts for already-paid cash sales, or timesheets\nwithout a billing step. For those, adjust the document title and clarify status.\n\n## Input Format\n\nCollect the invoice as a single YAML block. Keep field names stable so the\ngenerator script can parse them without guessing.\n\n```yaml\ninvoice:\n number: INV-2026-0042\n issue_date: 2026-07-20\n due_date: 2026-08-19\n currency: USD\n tax_rate: 0.20 # 20% VAT/GST; 0 if tax-exempt\n notes: \"Payment due within 30 days. Bank transfer preferred.\"\n sender:\n name: Alex\n company: Amano Studio LLC\n email: billing@amano.studio\n address: \"12 Birch Lane, Suite 4\\nPortland, OR 97201\"\n tax_id: \"US-EIN 84-1234567\"\n client:\n name: Jordan Reeves\n company: Reeves & Co.\n email: accounts@reeves.co\n address: \"88 Market Street\\nLondon EC2M 1AP, UK\"\n items:\n - description: \"Brand identity design\"\n quantity: 1\n unit_price: 2400.00\n - description: \"Logo suite (primary + secondary)\"\n quantity: 2\n unit_price: 350.00\n - description: \"Business card layout\"\n quantity: 3\n unit_price: 90.00\n```\n\n### Field rules\n\n- `currency`: ISO 4217 code (`USD`, `EUR`, `GBP`, `JPY`, ...). Drives the symbol.\n- `tax_rate`: decimal fraction (0.20 = 20%). Multiply after subtotal.\n- `quantity` × `unit_price` = line total. All money values are decimals.\n- `address` may contain `\\n` for line breaks.\n- Missing `due_date` → default to `issue_date` + 30 days.\n- Missing `number` → fall back to `INV-<YYYY>-<seq>` (see Numbering).\n\n## Template\n\nA clean, professional invoice has a clear visual hierarchy:\n\n1. **Header row** — Sender name/logo on the left, \"INVOICE\" title + number on the right.\n2. **Party block** — \"From\" (sender) and \"Bill To\" (client) side by side.\n3. **Meta row** — Issue date, due date, currency, tax ID.\n4. **Line-item table** — columns: Description | Qty | Unit Price | Line Total.\n5. **Totals block** — Subtotal, Tax, **Total Due** (right-aligned, emphasized).\n6. **Footer** — notes, payment terms, thank-you line.\n\nDesign cues that read as \"professional\":\n- One accent color (deep navy or slate), generous whitespace, 10–11pt body text.\n- Right-align all numeric columns; use thousands separators.\n- Bold the Total Due and separate it with a thin rule.\n- Avoid clip-art, gradients, and decorative fonts.\n\n## PDF Generation\n\nUse `fpdf2` (pure Python, no system libraries) so the skill runs anywhere Python does.\n\nInstall once:\n\n```bash\npip install fpdf2\n```\n\nMinimal generation function (embed/call this from the agent's runtime):\n\n```python\nfrom fpdf import FPDF\nfrom datetime import date, timedelta\n\nSYMBOLS = {\"USD\": \"$\", \"EUR\": \"€\", \"GBP\": \"£\", \"JPY\": \"¥\", \"CAD\": \"C$\", \"AUD\": \"A$\"}\n\ndef money(value, currency):\n sym = SYMBOLS.get(currency, f\"{currency} \")\n # JPY has no decimal places by convention\n dec = 0 if currency == \"JPY\" else 2\n return f\"{sym}{value:,.{dec}f}\"\n\ndef build_invoice(data, out_path=\"invoice.pdf\"):\n inv = data[\"invoice\"]\n cur = inv.get(\"currency\", \"USD\")\n tax_rate = float(inv.get(\"tax_rate\", 0) or 0)\n items = inv.get(\"items\", [])\n\n subtotal = sum(float(i[\"quantity\"]) * float(i[\"unit_price\"]) for i in items)\n tax = subtotal * tax_rate\n total = subtotal + tax\n\n pdf = FPDF(format=\"A4\", unit=\"mm\")\n pdf.add_page()\n pdf.set_auto_page_break(auto=True, margin=15)\n pdf.set_margins(15, 15, 15)\n\n # Header\n pdf.set_font(\"Helvetica\", \"B\", 20)\n pdf.cell(0, 10, inv[\"sender\"][\"name\"], ln=True)\n pdf.set_font(\"Helvetica\", \"\", 10)\n pdf.cell(0, 6, inv[\"sender\"].get(\"company\", \"\"), ln=True)\n pdf.ln(2)\n\n pdf.set_xy(140, 15)\n pdf.set_font(\"Helvetica\", \"B\", 16)\n pdf.cell(0, 10, \"INVOICE\", ln=True)\n pdf.set_font(\"Helvetica\", \"\", 10)\n pdf.set_x(140)\n pdf.cell(0, 6, f\"Number: {inv.get('number', 'N/A')}\", ln=True)\n pdf.set_x(140)\n pdf.cell(0, 6, f\"Issue: {inv.get('issue_date', date.today().isoformat())}\", ln=True)\n\n pdf.ln(6)\n\n # Parties\n y = pdf.get_y()\n pdf.set_font(\"Helvetica\", \"B\", 10)\n pdf.cell(90, 6, \"From\", ln=0)\n pdf.cell(90, 6, \"Bill To\", ln=True)\n pdf.set_font(\"Helvetica\", \"\", 10)\n for key in (\"name\", \"company\", \"email\", \"address\"):\n s = str(inv[\"sender\"].get(key, \"\")).replace(\"\\n\", \" | \")\n c = str(inv[\"client\"].get(key, \"\")).replace(\"\\n\", \" | \")\n pdf.cell(90, 6, s[:48], ln=0)\n pdf.cell(90, 6, c[:48], ln=True)\n\n pdf.ln(4)\n\n # Items table\n pdf.set_font(\"Helvetica\", \"B\", 10)\n pdf.set_fill_color(230, 232, 240)\n pdf.cell(100, 8, \"Description\", border=1, fill=True)\n pdf.cell(20, 8, \"Qty\", border=1, fill=True, align=\"R\")\n pdf.cell(35, 8, \"Unit Price\", border=1, fill=True, align=\"R\")\n pdf.cell(35, 8, \"Total\", border=1, fill=True, align=\"R\", ln=True)\n pdf.set_font(\"Helvetica\", \"\", 10)\n for i in items:\n lt = float(i[\"quantity\"]) * float(i[\"unit_price\"])\n pdf.cell(100, 8, str(i[\"description\"])[:60], border=1)\n pdf.cell(20, 8, str(i[\"quantity\"]), border=1, align=\"R\")\n pdf.cell(35, 8, money(float(i[\"unit_price\"]), cur), border=1, align=\"R\")\n pdf.cell(35, 8, money(lt, cur), border=1, align=\"R\", ln=True)\n\n # Totals\n pdf.ln(2)\n pdf.set_font(\"Helvetica\", \"B\", 11)\n pdf.cell(155, 8, \"Subtotal\", align=\"R\")\n pdf.cell(35, 8, money(subtotal, cur), align=\"R\", ln=True)\n if tax_rate:\n pdf.cell(155, 8, f\"Tax ({int(tax_rate*100)}%)\", align=\"R\")\n pdf.cell(35, 8, money(tax, cur), align=\"R\", ln=True)\n pdf.set_font(\"Helvetica\", \"B\", 13)\n pdf.cell(155, 9, \"Total Due\", align=\"R\")\n pdf.cell(35, 9, money(total, cur), align=\"R\", ln=True)\n\n # Notes\n if inv.get(\"notes\"):\n pdf.ln(6)\n pdf.set_font(\"Helvetica\", \"\", 9)\n pdf.multi_cell(0, 5, inv[\"notes\"])\n\n pdf.output(out_path)\n return out_path\n```\n\nThe agent should:\n1. Parse the YAML the user provided (or assemble it from a conversation).\n2. Call `build_invoice(data, \"invoice.pdf\")`.\n3. Return the saved PDF path to the user.\n\n## Numbering\n\n- Prefer an explicit `number` from the user (e.g. `INV-2026-0042`).\n- If absent, generate `INV-<YYYY>-<seq>` where `<seq>` is a zero-padded sequence\n based on the year (start at 0001, increment per invoice).\n- Keep numbers unique and sequential; do not reuse or skip ranges silently.\n- For drafts, prefix with `DRAFT-` and strip it on finalization.\n\n## Tax Calculation\n\n- `tax = subtotal × tax_rate`. Apply **after** the subtotal, never per line, unless\n the jurisdiction requires line-level tax (then sum line taxes).\n- Show tax as its own line with the rate in parentheses: `Tax (20%)`.\n- `total = subtotal + tax`. Never round the subtotal before computing tax if the\n jurisdiction rounds tax on the gross — when in doubt, compute tax on the exact\n subtotal and round only the displayed value.\n- Tax-exempt? Set `tax_rate: 0` and omit the tax line (or label it `Tax (0% — exempt)`).\n- If the client is in another tax jurisdiction, surface that to the user before\n assuming a rate — reverse-charge rules may apply.\n\n## Multi-currency\n\n- Always store ISO 4217 codes. Map to display symbols via the `SYMBOLS` dict above;\n extend it as needed.\n- **JPY, KRW** conventionally show **no decimals** — handle `dec = 0`.\n- Format with thousands separators (`1,234.56`) and right-align in tables.\n- If the invoice is in a non-sender currency, note the exchange basis in `notes`\n (e.g. \"Converted at 1 EUR = 1.08 USD on 2026-07-20\").\n- Never mix currencies within one invoice; convert first, then bill in one currency.\n\n## Common Pitfalls\n\n1. **Float rounding.** Money math in floats is fine for display, but round only at\n presentation. Accumulate subtotal as a precise sum.\n2. **Missing client address** breaks the layout — default to the email if address\n is absent, and warn the user.\n3. **Long descriptions** overflow the cell — truncate to ~60 chars or use\n `multi_cell` for wrapping.\n4. **Page breaks.** Set `auto_page_break` (done above) so long item lists don't clip.\n5. **Special characters** (€, é, &). `fpdf2` core fonts are latin-1; for full\n Unicode, add a TTF font via `pdf.add_font(...)` or sanitize to ASCII.\n6. **Date formats.** Store ISO `YYYY-MM-DD`; format for display per locale only at\n render time, never in the source data.\n7. **Duplicate numbers.** Check the sequence before assigning a generated number.\n8. **Not saved where the user expects.** Return the absolute output path and confirm\n the filename so the user can find the PDF.\n\n## Verification Checklist\n\n- [ ] `subtotal + tax == total`, recomputed independently and matching what's printed on the PDF\n- [ ] Currency symbol and decimal places match the ISO 4217 code (0 decimals for JPY/KRW, else 2)\n- [ ] Invoice number is unique — checked against prior invoices, not blindly incremented\n- [ ] Client/sender address and long line-item descriptions render without truncation or overflow\n- [ ] The absolute output path of the generated PDF was returned and confirmed to the user\n\n## Install / Source\n\nPublic portfolio (Alex):\nhttps://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/invoice-generator/SKILL.md\n", "readme_content": "# invoice-generator\n\nA public [Hermes](https://github.com/THEROCKSSS/hermes-skills-portfolio) skill that turns\nstructured line items into a clean, professional **PDF invoice** — with client\ndetails, itemized rows, tax, totals, and multi-currency support.\n\n> \"Agent + skill = the user gets a branded invoice PDF without touching a\n> spreadsheet.\"\n\n## Why\n\nInvoicing is repetitive, error-prone, and easy to get wrong on tax and totals.\nThis skill standardizes the input, validates the math, and renders a\nprint-ready document every time.\n\n## Install\n\nPoint Hermes at the skill file:\n\n```\nhttps://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/invoice-generator/SKILL.md\n```\n\nOr copy the `invoice-generator/` folder into your Hermes skills directory.\n\nThe PDF engine uses [`fpdf2`](https://pypi.org/project/fpdf2/) (pure Python):\n\n```bash\npip install fpdf2\n```\n\n## Usage\n\nTell the agent what you sold. Provide the details as a YAML block, or just\ndescribe the job and let the agent assemble it:\n\n```yaml\ninvoice:\n number: INV-2026-0042\n issue_date: 2026-07-20\n currency: USD\n tax_rate: 0.20\n sender:\n name: Alex\n company: Amano Studio LLC\n email: billing@amano.studio\n client:\n name: Jordan Reeves\n company: Reeves & Co.\n email: accounts@reeves.co\n items:\n - description: \"Brand identity design\"\n quantity: 1\n unit_price: 2400.00\n - description: \"Logo suite\"\n quantity: 2\n unit_price: 350.00\n```\n\nThe agent returns a saved `invoice.pdf` path.\n\n## Features\n\n- **Clean template** — header, From/Bill-To blocks, itemized table, totals.\n- **Tax handling** — subtotal × rate, shown as its own line; exempt-aware.\n- **Numbering** — explicit or auto `INV-<YYYY>-<seq>`.\n- **Multi-currency** — ISO 4217 codes, correct symbols, JPY/KRW zero-decimal.\n- **Safe math** — precise accumulation, rounding only at display.\n\n## What it is not\n\nQuotes/estimates, cash receipts, and timesheets without a billing step are\nout of scope — adapt the title and status if you reuse it for those.\n\n## License\n\nMIT — free to use, fork, and ship in your own portfolio.\n\n---\n\nPart of the [hermes-skills-portfolio](https://github.com/THEROCKSSS/hermes-skills-portfolio)\nby Alex.\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/invoice-generator/SKILL.md" }, { "name": "caddy-reverse-proxy", "category": "devops", "tier": "featured", "description": "Set up a Caddy reverse proxy with automatic HTTPS.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/caddy-reverse-proxy/SKILL.md", "path": "skills/caddy-reverse-proxy", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "caddy-reverse-proxy", "description": "Set up a Caddy reverse proxy with automatic HTTPS.", "version": "1.0.0" }, "agent_use": "- The user wants to expose a local service over HTTPS with a real domain.\n- The user wants to proxy multiple services through one server with TLS.\n- The user wants automatic certificate management (no certbot, no manual renewal).\n- The user says \"set up a reverse proxy\", \"I need HTTPS for my service\", or \"proxy my Docker services\".", "user_use": "The agent installs Caddy, writes a Caddyfile for your services, and starts the proxy. Caddy automatically obtains Let's Encrypt TLS certificates, redirects HTTP to HTTPS, and proxies requests to your backend services. You get production-grade HTTPS without managing certificates.", "skillmd_content": "---\nname: caddy-reverse-proxy\ndescription: Use when the user wants to expose a local service over HTTPS with a real domain, proxy multiple services through one server with TLS, get automatic certificate management without certbot or manual renewal, or says \"set up a reverse proxy\", \"I need HTTPS for my service\", or \"proxy my Docker services\".\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [caddy, reverse-proxy, tls, lets-encrypt, caddyfile, docker]\n related_skills: [docker-umbrella]\n---\n\n# caddy-reverse-proxy\n\n## Overview\n\nSet up Caddy as a reverse proxy with automatic HTTPS. Caddy obtains and renews Let's Encrypt certificates automatically, handles HTTP-to-HTTPS redirects, and proxies requests to your backend services. No manual certificate management.\n\n## When to Use\n\n- The user wants to expose a local service over HTTPS with a real domain.\n- The user wants to proxy multiple services through one server with TLS.\n- The user wants automatic certificate management (no certbot, no manual renewal).\n- The user says \"set up a reverse proxy\", \"I need HTTPS for my service\", or \"proxy my Docker services\".\n\n## Prerequisites\n\n- A server with a public IP address\n- A domain name pointing to your server (A record)\n- Ports 80 and 443 open\n\n## Installation\n\n### Linux\n\n```bash\n# Debian/Ubuntu (official repo)\nsudo apt install -y debian-keyring debian-archive-keyring apt-transport-https\ncurl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg\ncurl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list\nsudo apt update\nsudo apt install caddy\n```\n\n### macOS\n\n```bash\nbrew install caddy\n```\n\n### Docker\n\n```bash\ndocker run -d --name caddy \\\n -p 80:80 -p 443:443 \\\n -v caddy_data:/data \\\n -v caddy_config:/config \\\n -v $PWD/Caddyfile:/etc/caddy/Caddyfile \\\n caddy:latest\n```\n\n## Caddyfile Syntax\n\nThe Caddyfile is Caddy's configuration format. It's intentionally simple.\n\n### Single site\n\n```\nexample.com {\n reverse_proxy localhost:8080\n}\n```\n\nThis one block does:\n1. Listens on ports 80 and 443 for `example.com`\n2. Obtains a Let's Encrypt certificate automatically\n3. Redirects HTTP to HTTPS\n4. Proxies all requests to `localhost:8080`\n\n### Multiple sites\n\n```\napp.example.com {\n reverse_proxy localhost:8080\n}\n\napi.example.com {\n reverse_proxy localhost:3000\n}\n\ndocs.example.com {\n root * /var/www/docs\n file_server\n}\n```\n\n### Reverse proxy with header manipulation\n\n```\napi.example.com {\n reverse_proxy localhost:3000 {\n header_up Host {host}\n header_up X-Real-IP {remote_host}\n header_up X-Forwarded-For {remote_host}\n header_up X-Forwarded-Proto {scheme}\n }\n}\n```\n\n### WebSocket support\n\nCaddy supports WebSockets automatically — no special config needed:\n\n```\nchat.example.com {\n reverse_proxy localhost:3000\n}\n```\n\n### Static files + API\n\n```\nexample.com {\n # Serve static files\n handle /assets/* {\n root * /var/www/assets\n file_server\n }\n\n # Proxy API requests\n handle /api/* {\n reverse_proxy localhost:3000\n }\n\n # Everything else → frontend\n handle {\n reverse_proxy localhost:5173\n }\n}\n```\n\n### Load balancing\n\n```\napi.example.com {\n reverse_proxy localhost:3000 localhost:3001 localhost:3002 {\n lb_policy round_robin\n health_uri /health\n health_interval 10s\n }\n}\n```\n\n## Automatic HTTPS\n\nCaddy handles certificates automatically:\n\n| Feature | How it works |\n|---|---|\n| Certificate issuance | Caddy obtains certificates from Let's Encrypt on first request |\n| Renewal | Caddy renews certificates 30 days before expiry |\n| HTTP→HTTPS redirect | Automatic for all sites with a domain name |\n| On-demand TLS | Optional — issue certificates on first request for any domain |\n\n**On-demand TLS** (for wildcard/multi-tenant setups):\n\n```\n{\n on_demand_tls {\n ask https://api.example.com/check-domain\n }\n}\n\nhttps:// {\n tls {\n on_demand\n }\n reverse_proxy localhost:8080\n}\n```\n\nCaddy will ask your API endpoint whether a domain is allowed before issuing a certificate.\n\n## Running Caddy\n\n### As a system service (Linux)\n\n```bash\nsudo systemctl enable caddy\nsudo systemctl start caddy\nsudo systemctl status caddy\n\n# Reload config without downtime\nsudo systemctl reload caddy\n\n# View logs\nsudo journalctl -u caddy -f\n```\n\n### With Docker Compose\n\n```yaml\nversion: \"3\"\nservices:\n caddy:\n image: caddy:latest\n restart: unless-stopped\n ports:\n - \"80:80\"\n - \"443:443\"\n volumes:\n - caddy_data:/data\n - caddy_config:/config\n - ./Caddyfile:/etc/caddy/Caddyfile\n extra_hosts:\n - \"host.docker.internal:host-gateway\"\n\nvolumes:\n caddy_data:\n caddy_config:\n```\n\nUse `host.docker.internal` in the Caddyfile to proxy to services running on the host (not in Docker).\n\n## Common Pitfalls\n\n1. **No domain name.** Caddy's automatic HTTPS requires a domain name pointing to your server. Without a domain, Caddy can't obtain certificates. For local-only HTTPS, use Caddy's internal CA: `localhost:8080 { reverse_proxy localhost:3000 }` (generates a self-signed cert).\n2. **Ports 80/443 not open.** Let's Encrypt uses the HTTP-01 challenge, which requires port 80 to be reachable. If your firewall blocks port 80, certificate issuance fails.\n3. **Hitting Let's Encrypt rate limits.** Let's Encrypt allows 50 certificates per domain per week. Don't repeatedly restart Caddy with new domains in testing — you'll hit the limit and have to wait it out.\n4. **Wrong host resolution in Docker.** If Caddy runs in Docker and your backend runs on the host, use `host.docker.internal` (with `extra_hosts` in compose). If both are in Docker, put them on the same network and use the container name instead.\n5. **Restarting instead of reloading.** Use `caddy reload` (or `systemctl reload caddy`) to apply config changes without dropping connections. `caddy stop` + `caddy start` drops active connections.\n6. **Large uploads silently rejected.** Caddy has a default body size limit. For large file uploads, set `request_body { max_size 100MB }` in the site block.\n\n## Verification Checklist\n\n- [ ] `curl -I https://<domain>` returns a valid certificate (no `-k` needed) and the expected backend response\n- [ ] HTTP requests to the same domain redirect to HTTPS (`curl -I http://<domain>` shows a 301/308)\n- [ ] `caddy validate --config Caddyfile` (or `docker exec caddy caddy validate`) passes before reload\n- [ ] Config changes were applied with `caddy reload`, not a hard restart, if connections needed to stay up\n- [ ] WebSocket-dependent backends were tested with an actual upgrade request, not just a plain GET\n- [ ] Domain's A record resolves to the server's public IP before expecting certificate issuance to succeed\n", "readme_content": "# caddy-reverse-proxy\n\nSet up a Caddy reverse proxy with automatic HTTPS — no certbot, no manual certificate renewal.\n\n## What it does\n\nThe agent installs Caddy, writes a Caddyfile for your services, and starts the proxy. Caddy automatically obtains Let's Encrypt TLS certificates, redirects HTTP to HTTPS, and proxies requests to your backend services. You get production-grade HTTPS without managing certificates.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/caddy-reverse-proxy/SKILL.md\n```\n\n## How to use\n\n```\n\"Proxy my API at api.mysite.com to localhost:3000 with HTTPS\"\n```\n\nThe agent:\n1. Installs Caddy (or uses Docker)\n2. Writes a Caddyfile:\n ```\n api.mysite.com {\n reverse_proxy localhost:3000\n }\n ```\n3. Starts Caddy\n4. Verifies: `curl https://api.mysite.com/health` returns 200\n\n## Prerequisites\n\n- A server with a public IP\n- A domain name pointing to your server (A record)\n- Ports 80 and 443 open\n\n## What you get\n\n| Feature | Automatic? |\n|---|---|\n| TLS certificate issuance | Yes — Let's Encrypt |\n| Certificate renewal | Yes — 30 days before expiry |\n| HTTP→HTTPS redirect | Yes |\n| WebSocket proxying | Yes — no config needed |\n| Multiple sites | Yes — one block per domain |\n\n## Example\n\n```\nUser: \"I have three services: a web app on :5173, an API on :3000, and docs on :8080. I want them all under my domain with HTTPS.\"\n\nAgent:\n 1. Writes Caddyfile:\n mysite.com { reverse_proxy localhost:5173 }\n api.mysite.com { reverse_proxy localhost:3000 }\n docs.mysite.com { reverse_proxy localhost:8080 }\n 2. Starts Caddy via Docker Compose\n 3. Verifies all three domains return 200 over HTTPS\n 4. Returns: \"All three services are live with HTTPS.\"\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/caddy-reverse-proxy/SKILL.md" }, { "name": "sqlite-dashboard", "category": "backend", "tier": "featured", "description": "Browse SQLite databases with a web UI.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/sqlite-dashboard/SKILL.md", "path": "skills/sqlite-dashboard", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "sqlite-dashboard", "description": "Browse SQLite databases with a web UI.", "version": "1.0.0" }, "agent_use": "- The user wants to browse a SQLite database without installing a desktop app.\n- The user wants to inspect an application's database (Hermes state.db, a web app's SQLite, etc.).\n- The user wants to run ad-hoc SQL queries against a database from a browser.\n- The user says \"let me see my database\", \"browse my SQLite db\", or \"what's in this .db file\".", "user_use": "The agent launches a web-based dashboard (sqlite-web) for any SQLite database file. You get a table browser, SQL query editor, schema viewer, and CSV/JSON export — all in a browser. No desktop app needed.", "skillmd_content": "---\nname: sqlite-dashboard\ndescription: Use when the user wants to browse a SQLite database file through a web UI or desktop app, run ad-hoc SQL queries against it, or inspect an application's .db/.sqlite/.sqlite3 file without installing a full database server.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [sqlite, database-browser, sqlite-web, docker, ad-hoc-sql]\n related_skills: [tailscale-deploy, caddy-reverse-proxy, csv-toolkit]\n---\n\n# sqlite-dashboard\n\n## Overview\n\nSet up a web-based UI for browsing SQLite databases. SQLite is everywhere — application databases, agent state stores, config files — but there's no built-in UI for inspecting them. This skill deploys a lightweight web dashboard for any SQLite database file.\n\n## When to Use\n\n- The user wants to browse a SQLite database without installing a desktop app.\n- The user wants to inspect an application's database (Hermes state.db, a web app's SQLite, etc.).\n- The user wants to run ad-hoc SQL queries against a database from a browser.\n- The user says \"let me see my database\", \"browse my SQLite db\", or \"what's in this .db file\".\n\n## Options\n\nThree approaches, depending on the user's needs:\n\n| Tool | Type | Best for |\n|---|---|---|\n| **sqlite-web** | Python web app | Quick browsing, query execution, export |\n| **DB Browser for SQLite** | Desktop app | Local inspection without a server |\n| **LiteQueen / sqlite-explorer** | Docker web UI | Persistent web access, multiple databases |\n\n## Option 1: sqlite-web (recommended)\n\nA Python-based web UI for SQLite databases.\n\n### Install\n\n```bash\npip install sqlite-web\n```\n\n### Run\n\n```bash\n# Start the web UI for a database file\nsqlite-web /path/to/database.db\n\n# With options\nsqlite-web /path/to/database.db \\\n --host 0.0.0.0 \\\n --port 8080 \\\n --read-only # prevent accidental edits\n```\n\nOpen `http://localhost:8080` in a browser.\n\n### Features\n\n- Table browser with pagination\n- SQL query editor with syntax highlighting\n- CSV/JSON export\n- Insert/update/delete rows\n- Foreign key navigation\n- Index and trigger viewer\n\n### With Docker\n\n```yaml\nversion: \"3\"\nservices:\n sqlite-web:\n image: coleifer/sqlite-web:latest\n restart: unless-stopped\n ports:\n - \"8080:8080\"\n volumes:\n - /path/to/your/db:/data\n command: sqlite_web /data/database.db --host 0.0.0.0 --port 8080\n```\n\n## Option 2: DB Browser for SQLite (desktop)\n\nA GUI app for local inspection. No server needed.\n\n- **Linux**: `apt install sqlitebrowser` or download from https://sqlitebrowser.org\n- **macOS**: `brew install --cask db-browser-for-sqlite`\n- **Windows**: download from https://sqlitebrowser.org\n\nOpen the app, then File → Open Database → select your `.db` file.\n\n## Option 3: Docker web UI (persistent)\n\nFor a persistent web dashboard that can browse multiple databases:\n\n```yaml\nversion: \"3\"\nservices:\n sqlite-explorer:\n image: ghcr.io/coleifer/sqlite-web:latest\n restart: unless-stopped\n ports:\n - \"8080:8080\"\n volumes:\n - ./databases:/data:ro\n command: sqlite_web /data --host 0.0.0.0 --port 8080 --read-only\n```\n\nMount a directory of database files and browse any of them from the UI.\n\n## Workflow\n\n### Step 1: Identify the database\n\nFind the SQLite database the user wants to browse:\n\n```bash\n# Find .db files in a project\nfind /path/to/project -name \"*.db\" -o -name \"*.sqlite\" -o -name \"*.sqlite3\"\n\n# Check it's a valid SQLite database\nfile /path/to/database.db\n# → SQLite 3.x database\n```\n\n### Step 2: Start the dashboard\n\n```bash\nsqlite-web /path/to/database.db --host 0.0.0.0 --port 8080 --read-only\n```\n\n### Step 3: Browse\n\nOpen `http://localhost:8080`:\n- **Browse Data** tab → select a table → see rows with pagination\n- **Execute SQL** tab → run ad-hoc queries\n- **Structure** tab → see schema, indexes, triggers\n- **Export** → download as CSV or JSON\n\n### Step 4: Query examples\n\n```sql\n-- List all tables\nSELECT name FROM sqlite_master WHERE type='table';\n\n-- Count rows in each table\nSELECT 'sessions' as tbl, COUNT(*) as rows FROM sessions\nUNION ALL\nSELECT 'logs', COUNT(*) FROM logs;\n\n-- Recent records\nSELECT * FROM sessions ORDER BY rowid DESC LIMIT 10;\n\n-- Search across columns\nSELECT * FROM logs WHERE message LIKE '%error%' ORDER BY timestamp DESC LIMIT 20;\n```\n\n## Export\n\n```bash\n# Export a table to CSV via the command line\nsqlite3 /path/to/database.db \".mode csv\" \".headers on\" \".output export.csv\" \"SELECT * FROM my_table;\" \".quit\"\n\n# Export to JSON\nsqlite3 /path/to/database.db \".mode json\" \".output export.json\" \"SELECT * FROM my_table;\" \".quit\"\n```\n\n## Common Pitfalls\n\n1. **Opening a database that's already open for writing elsewhere.** A second writer hits\n \"database is locked\" errors. Use `--read-only` mode for browsing — it doesn't require write\n locks.\n2. **Scrolling a multi-million-row table instead of querying it.** sqlite-web paginates, but\n rendering that many pages is slow. Use the SQL query tab with `LIMIT` instead.\n3. **Copying only the `.db` file from a WAL-mode database.** Uncommitted data lives in the\n `-wal` file, not the main file. Copy both, or checkpoint first:\n `sqlite3 database.db \"PRAGMA wal_checkpoint(TRUNCATE);\"`.\n4. **Assuming a `.db` extension means valid SQLite.** Run `file database.db` first — if it\n doesn't say \"SQLite 3.x database,\" confirm with `sqlite3 database.db \"PRAGMA integrity_check;\"`\n before trusting the dashboard's output.\n5. **Leaving the dashboard writable for a production database.** Without `--read-only`, a\n misclick in the UI can modify or delete rows. Default to read-only for any database that isn't\n a scratch copy.\n6. **Exposing sqlite-web directly to the public internet.** It has no built-in authentication and\n grants full read/write access to the database. Put it behind Tailscale or a reverse proxy with\n auth instead of publishing the port.\n\n## Verification Checklist\n\n- [ ] `file <database>` confirms \"SQLite 3.x database\" before the dashboard is pointed at it.\n- [ ] Dashboard reachable at `http://<host>:8080` and the target database's tables are visible in\n the Browse Data tab.\n- [ ] Read-only mode confirmed (`--read-only` flag present) unless the user explicitly wants\n write access.\n- [ ] If the source database uses WAL, the `-wal` file was copied alongside `.db` or checkpointed\n first — row counts match the live database.\n- [ ] Dashboard is not reachable from the public internet without authentication (Tailscale/proxy\n auth in front, or bound to localhost only).\n", "readme_content": "# sqlite-dashboard\n\nBrowse SQLite databases with a web UI — inspect tables, run queries, and export data from any browser.\n\n## What it does\n\nThe agent launches a web-based dashboard (sqlite-web) for any SQLite database file. You get a table browser, SQL query editor, schema viewer, and CSV/JSON export — all in a browser. No desktop app needed.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/sqlite-dashboard/SKILL.md\n```\n\n## How to use\n\n```\n\"Let me browse my Hermes state.db\"\n```\n\nThe agent:\n1. Finds the database file\n2. Launches: `sqlite-web /path/to/state.db --port 8080 --read-only`\n3. Opens `http://localhost:8080` in your browser\n4. You see tables, can run queries, export data\n\n## What you get\n\n| Feature | Notes |\n|---|---|\n| Table browser | Paginated, sortable |\n| SQL query editor | With syntax highlighting |\n| Schema viewer | Tables, indexes, triggers |\n| CSV/JSON export | Per-table or per-query |\n| Read-only mode | Prevents accidental edits |\n\n## Example\n\n```\nUser: \"What's in my app's database?\"\n\nAgent:\n 1. Finds: /var/lib/myapp/data.db\n 2. Launches: sqlite-web /var/lib/myapp/data.db --port 8080 --read-only\n 3. Returns: \"Dashboard at http://localhost:8080\"\n 4. User browses tables, runs SELECT queries, exports to CSV\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/sqlite-dashboard/SKILL.md" }, { "name": "markdown-to-slides", "category": "frontend", "tier": "featured", "description": "Create presentation slides from markdown.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/markdown-to-slides/SKILL.md", "path": "skills/markdown-to-slides", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "markdown-to-slides", "description": "Create presentation slides from markdown.", "version": "1.0.0" }, "agent_use": "Reach for this skill whenever the user asks to:\n\n- Build a slide deck, talk, keynote, or lecture from Markdown or notes.\n- Convert an existing Markdown document into slides.\n- Theme, brand, or restyle a presentation quickly.\n- Export slides to PDF (for sharing/handouts) or PPTX (for collaborators).\n- Set up a live-reloading preview while writing a talk.\n\nDo **not** use it for static documents (use a doc/reports skill), posters, or\ninfographics where a single fixed canvas is the deliverable.", "user_use": "", "skillmd_content": "---\nname: markdown-to-slides\ndescription: Use when the user wants to author a talk, deck, or lecture from plain Markdown and export it to PDF/PPTX/HTML — build a slide deck with reveal.js, Marp, or Slidev, theme/brand an existing deck, or set up a live-reloading slide preview.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [markdown, slides, presentations, reveal.js, marp, slidev]\n related_skills: [markdown-to-pdf, markdown-linter]\n---\n\n# markdown-to-slides\n\n## Overview\n\nAuthor beautiful presentations from plain Markdown — no drag-and-drop editors, no\nlocked-in proprietary formats. This skill covers three widely used engines\n(reveal.js, Marp, Slidev), their slide-delimiter syntax, theming, live preview,\nand one-command export to PDF or PowerPoint.\n\n## When to Use\n\nReach for this skill whenever the user asks to:\n\n- Build a slide deck, talk, keynote, or lecture from Markdown or notes.\n- Convert an existing Markdown document into slides.\n- Theme, brand, or restyle a presentation quickly.\n- Export slides to PDF (for sharing/handouts) or PPTX (for collaborators).\n- Set up a live-reloading preview while writing a talk.\n\nDo **not** use it for static documents (use a doc/reports skill), posters, or\ninfographics where a single fixed canvas is the deliverable.\n\n## Tools\n\nPick an engine based on the job. All three consume Markdown; they differ in\nphilosophy and export strength.\n\n### reveal.js\n- **Best for:** rich, interactive, animated decks; speaker notes; embedded code\n execution; vertical/nested slides.\n- **Authoring:** raw HTML + Markdown, or the `reveal-md` CLI which wraps\n Markdown in a reveal.js scaffold.\n- **Install:** `npm install -g reveal-md` (CLI) or `npm create reveal@latest`.\n- **Run:** `reveal-md deck.md` → serves at `http://localhost:5655`.\n- **Strengths:** largest plugin ecosystem (math, charts, audio, PDF export).\n\n### Marp\n- **Best for:** minimal, opinionated Markdown→slides with zero fuss. Great for\n docs teams that already write Markdown.\n- **Authoring:** standard Markdown with a `---` slide separator and YAML front\n matter for theming.\n- **Install:** `npm install -g @marp-team/marp-cli`.\n- **Run:** `marp deck.md -w` (watch mode) or `marp deck.md --pdf`.\n- **Strengths:** fastest path to a clean deck; first-class PDF/PPTX/HTML export;\n official VSCode extension with live preview.\n\n### Slidev\n- **Best for:** developer talks — code highlighting, live demos, Vue components\n inside slides, drawing on slides.\n- **Authoring:** Markdown with `---` separators and `<style>` / `<script>`\n blocks; slide-level `<v-clicks>` for step animations.\n- **Install:** `npm init slidev` then `npm install`.\n- **Run:** `npm run dev` → serves at `http://localhost:3030`.\n- **Strengths:** built on Vite (HMR), draws on slide, records presentations,\n exports to PDF/PPTX via `@slidev/cli` build.\n\n## Markdown Syntax for Slides\n\nAll three use `---` on its own line as the default slide separator.\n\n```markdown\n---\n# Title Slide\nA subtitle here\n\n---\n\n## Slide Two\n- Bullet one\n- Bullet two\n\n> A blockquote for emphasis\n\n---\n\n## Slide Three\n\\`\\`\\`js\nconsole.log(\"code is highlighted\");\n\\`\\`\\`\n```\n\n### reveal.js (via reveal-md)\nUse `---` for horizontal slides and `--` for vertical (nested) slides:\n\n```markdown\n# Horizontal A\n---\n\n# Horizontal B\n--\n\n## Nested under B\n```\n\nAdd speaker notes with HTML comments: `<!-- .slide: data-notes=\"Talk about X\" -->`\nor a `Notes:` block when using `reveal-md` note syntax.\n\n### Marp\nFront matter controls the deck globally:\n\n```markdown\n---\nmarp: true\ntheme: default\npaginate: true\n---\n\n# First slide\n```\n\n- `<!-- _class: lead -->` applies a CSS class to one slide.\n- `<!-- _backgroundColor: #0b3d91 -->` sets a slide background.\n- `<!-- _paginate: false -->` hides the page number on that slide.\n\n### Slidev\n- `---` separates slides; `---layout: center` switches layout for the next slide.\n- `<v-click>` and `<v-clicks>` reveal list items step by step.\n- A slide's first comment block (`<!-- ... -->`) becomes speaker notes.\n\n## Themes\n\n### reveal.js\nThemes are CSS files in `css/theme/` (e.g. `black`, `white`, `league`,\n`beige`, `sky`, `night`). Switch via front matter or the `theme` flag:\n`reveal-md deck.md --theme night`. Custom themes are plain CSS overriding\n`--r-background-color`, `--r-main-font`, etc.\n\n### Marp\nShips with `default`, `gaia`, and `uncover`. Apply via front matter\n`theme: gaia`. Custom themes are defined in a CSS file and registered with\n`@theme mytheme` directives (see Marp core docs). Brand decks by overriding\nCSS custom properties:\n\n```css\n:root {\n --color-background: #0b3d91;\n --color-foreground: #ffffff;\n --color-primary: #ffb000;\n}\n```\n\n### Slidev\nThemes are npm packages (e.g. `@slidev/theme-default`, `@slidev/theme-seriph`,\n`@slidev/theme-apple-basic`). Install and set in front matter:\n`theme: seriph`. Unstyled local `style` blocks override per-deck.\n\n## Export to PDF/PPTX\n\n### Marp (simplest, recommended default)\n```bash\nmarp deck.md --pdf deck.pdf\nmarp deck.md --pptx deck.pptx\nmarp deck.md --html deck.html\n```\nFor pixel-perfect PDF, enable Chromium in Marp: `marp --pdf --allow-local-files`.\n\n### reveal.js\nUse the print-to-PDF query param in a headless browser, or:\n```bash\nreveal-md deck.md --print deck.pdf # chromium required\n```\nPPTX export is not native; convert the PDF, or render HTML and use a\nPDF→PPTX converter (e.g. `unoconv` / LibreOffice headless).\n\n### Slidev\n```bash\nslidev build --pdf # requires playwright/chromium\nslidev build --pptx # exports .pptx\nslidev export deck.md # interactive export wizard\n```\nEnsure `npx playwright install chromium` is available for PDF/PPTX builds.\n\n## Live Preview\n\n- **Marp:** `marp deck.md -w` plus the Marp VSCode extension (side-by-side\n preview). Or `npx @marp-team/marp-cli deck.md -w -s` for a server.\n- **reveal.js:** `reveal-md deck.md` opens a live server; edits hot-reload.\n- **Slidev:** `npm run dev` gives Vite HMR — edits appear instantly, plus a\n presenter mode at `/presenter/0` and a drawing layer.\n\nAlways preview before exporting; fonts, backgrounds, and code blocks often\nshift between preview and print.\n\n## Common Pitfalls\n\n1. **`---` ambiguity.** YAML front matter and slide separators both use `---`.\n Keep front matter at the very top and ensure a blank line around separators\n or the parser reads the first content slide as more front matter.\n2. **Chromium missing.** PDF/PPTX export in Marp and Slidev needs a headless\n browser. Install it (`npx @marp-team/marp-cli --version` then\n `npx playwright install chromium`) or exports silently fail with no output file.\n3. **Absolute image paths break in export.** Use relative paths, and pass\n `--allow-local-files` (Marp) or run the export from the deck's own directory\n so assets resolve.\n4. **Fonts not embedded.** Some PDF converters drop web fonts. Prefer system\n fonts or bundle `@font-face` files alongside the deck.\n5. **Speaker notes leak into public exports.** Notes render in HTML export by\n default; strip them for public PDFs (`marp --pdf` excludes notes; reveal\n needs an explicit flag).\n6. **Engine mismatch.** reveal.js uses `--` for vertical slides; Marp/Slidev\n treat `--` as a thematic break (a visible horizontal rule), not a slide\n boundary. Don't copy `--`-based syntax between engines.\n7. **Watch-mode port clash.** If a preview server won't start, the port is\n likely already bound by a previous run — kill the stray process or pass an\n explicit port flag rather than retrying the same command.\n\n## Verification Checklist\n\n- [ ] Every intended slide boundary (`---`) actually produced a separate slide, not a merged or extra one\n- [ ] Exported PDF/PPTX file exists, opens, and has the same slide count as the live preview\n- [ ] Code blocks in the deck show syntax highlighting in both preview and export\n- [ ] Speaker notes are present in the presenter view but absent from any public-facing export\n- [ ] Chosen theme's colors/fonts are visibly applied in the export, not just the preview\n- [ ] No leftover `--allow-local-files` / chromium warnings in the export command's output\n", "readme_content": "# markdown-to-slides\n\n> Turn plain Markdown into polished presentation slides — then export to PDF or\n> PowerPoint in one command.\n\nPart of the [Hermes Skills Portfolio](https://github.com/THEROCKSSS/hermes-skills-portfolio)\nby **Alex**.\n\n## Why this skill exists\n\nMost people write faster in Markdown than in Keynote or PowerPoint, but turning\nnotes into a real deck is usually a copy-paste chore. This skill lets an agent\n(or a human) author talks, lectures, and investor updates as Markdown and render\nthem with the three best open-source slide engines:\n\n| Engine | Best for | Export strength |\n| --- | --- | --- |\n| **reveal.js** | Rich, interactive, animated decks | PDF (HTML → PPTX via conversion) |\n| **Marp** | Minimal Markdown, docs teams | PDF + PPTX + HTML (native) |\n| **Slidev** | Developer talks, live code | PDF + PPTX (native) |\n\n## Install\n\nIn Hermes, install the skill from the portfolio:\n\n```\nhttps://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/markdown-to-slides/SKILL.md\n```\n\nOr copy `skills/markdown-to-slides/` into your Hermes skills directory.\n\n## Quick start\n\nCreate `deck.md`:\n\n```markdown\n---\nmarp: true\ntheme: default\npaginate: true\n---\n\n# Hello, slides\nAuthored in Markdown\n\n---\n\n## Slide two\n- Write bullets\n- Separate slides with `---`\n- Export when ready\n```\n\nRender and export with Marp:\n\n```bash\nnpm install -g @marp-team/marp-cli\nmarp deck.md -w # live preview\nmarp deck.md --pdf deck.pdf\nmarp deck.md --pptx deck.pptx\n```\n\nPrefer reveal.js or Slidev? The SKILL.md covers their syntax, theming, and\nexports in full.\n\n## What you get\n\n- **One source of truth** — your deck is a diffable text file, not a binary.\n- **Themeable** — swap or brand a deck by overriding CSS variables.\n- **Portable output** — hand out PDFs, hand decks to collaborators as PPTX.\n- **Live preview** — watch mode hot-reloads as you write.\n\n## Links\n\n- Install URL: <https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/markdown-to-slides/SKILL.md>\n- Full reference: see `SKILL.md` in this folder.\n\n---\n\n© Alex — released for the Hermes community.\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/markdown-to-slides/SKILL.md" }, { "name": "github-actions-ci", "category": "backend", "tier": "featured", "description": "Set up GitHub Actions CI/CD for your project.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/github-actions-ci/SKILL.md", "path": "skills/github-actions-ci", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "github-actions-ci", "description": "Set up GitHub Actions CI/CD for your project.", "version": "1.0.0" }, "agent_use": "Invoke this skill when the user:\n\n- Asks for \"CI\", \"continuous integration\", \"GitHub Actions\", \"pipeline\", or\n \"automated tests on push\".\n- Wants every push/PR to run tests or a build before merge.\n- Wants to deploy automatically to a host, container registry, cloud, or\n Pages on merge to `main`.\n- Has flaky, slow, or broken workflows that need fixing.\n- Needs matrix builds across OSes, language versions, or dependency versions.\n- Is setting up a new repo and wants a sane baseline pipeline.\n\nDo **not** use it for other CI providers (GitLab CI, CircleCI, Jenkins,\nTravis). Those have different syntax and deserve their own skills.", "user_use": "`github-actions-ci` turns \"can you add CI to my repo?\" into a working\n`.github/workflows/*.yml` file plus the supporting config to make it green. It\ncovers the full pipeline lifecycle:\n\n- **Test** — run your suite on every push and pull request\n- **Build** — compile, bundle, and upload artifacts\n- **Deploy** — ship to hosts, registries, or Pages on merge to `main`\n- **Matrix** — parallelize across OSes and language versions\n- **Secrets** — OIDC and encrypted secret handling done right\n- **Caching** — slash build times with dependency caches\n- **Pitfalls** — the 10 mistakes that break real pipelines, avoided by default", "skillmd_content": "---\nname: github-actions-ci\ndescription: >-\n Use when a user wants automated testing, builds, deployments, matrix\n builds, caching, or secret handling via GitHub Actions — including\n debugging a flaky or broken workflow, or setting up a baseline CI/CD\n pipeline for a new repo.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [github-actions, ci-cd, yaml, workflows, matrix-builds]\n related_skills: [generate-dockerfile, changelog-generator, api-test-suite]\n---\n\n# GitHub Actions CI/CD\n\n## Overview\n\nTurn a user's project into a repo with a real, working continuous integration\nand continuous deployment pipeline. This skill produces a correct\n`.github/workflows/*.yml` file plus the supporting configuration needed to make\nit pass.\n\n## When to Use\n\nInvoke this skill when the user:\n\n- Asks for \"CI\", \"continuous integration\", \"GitHub Actions\", \"pipeline\", or\n \"automated tests on push\".\n- Wants every push/PR to run tests or a build before merge.\n- Wants to deploy automatically to a host, container registry, cloud, or\n Pages on merge to `main`.\n- Has flaky, slow, or broken workflows that need fixing.\n- Needs matrix builds across OSes, language versions, or dependency versions.\n- Is setting up a new repo and wants a sane baseline pipeline.\n\nDo **not** use it for other CI providers (GitLab CI, CircleCI, Jenkins,\nTravis). Those have different syntax and deserve their own skills.\n\n## Workflow Syntax\n\nA workflow is a YAML file in `.github/workflows/`. The minimal skeleton:\n\n```yaml\nname: CI\n\non:\n push:\n branches: [main]\n pull_request:\n branches: [main]\n\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - name: Run tests\n run: echo \"add your test command here\"\n```\n\nKey top-level keys:\n\n- `name` — human-readable pipeline name shown in the GitHub UI.\n- `on` — trigger. Can be a string, list, or map with `push`/`pull_request`/\n `workflow_dispatch`/scheduled `schedule` (cron) etc.\n- `jobs` — a map of jobs. Each job has `runs-on`, `steps`, and optional\n `needs`, `strategy`, `env`, `services`, `permissions`, `concurrency`.\n- `steps` — a list. Each step is either `uses:` (an action) or `run:`\n (a shell command), plus `name:`, `env:`, `if:`, `with:`, `id:`.\n\nCritical rules:\n\n1. Always pin actions to a major version tag (`actions/checkout@v4`), never a\n branch like `@main` in production — branch refs are mutable and a supply-chain\n risk. Use SHAs for maximum security.\n2. `actions/checkout@v4` is required before any step that reads repo files.\n3. Use `shell:` to force an interpreter (`bash`, `pwsh`, `python`). Default on\n Linux/macOS is `bash`, on Windows is `pwsh`.\n4. Indentation is YAML — two spaces, no tabs. A single stray tab breaks parse.\n\n## Common Patterns\n\n### Test\n\n```yaml\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: 20\n cache: npm\n - run: npm ci\n - run: npm test -- --ci\n```\n\nRule of thumb: `npm ci` (clean install from lockfile) over `npm install` in CI.\nFor Python use `actions/setup-python@v5` + `pip install -r requirements.txt`\nand `pytest`. Fail the job by returning a non-zero exit code — Actions does\nthat for you automatically.\n\n### Build\n\n```yaml\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: 20\n - run: npm ci\n - run: npm run build\n - uses: actions/upload-artifact@v4\n with:\n name: dist\n path: dist/\n```\n\nUpload build outputs with `actions/upload-artifact@v4` so later jobs (or\nhumans) can download them. Note: artifact actions v3 and v4 are NOT\ninteroperable — keep both upload and download on the same major version.\n\n### Deploy\n\n```yaml\njobs:\n deploy:\n needs: build\n runs-on: ubuntu-latest\n if: github.ref == 'refs/heads/main'\n steps:\n - uses: actions/checkout@v4\n - uses: actions/download-artifact@v4\n with:\n name: dist\n path: dist\n - name: Deploy to production\n run: ./scripts/deploy.sh\n env:\n DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}\n```\n\nGate deploys with `if: github.ref == 'refs/heads/main'` so PRs never deploy,\nand `needs:` to ensure the build/test job passed first.\n\n## Matrix Builds\n\nRun the same job across many configurations in parallel via `strategy.matrix`:\n\n```yaml\njobs:\n test:\n runs-on: ${{ matrix.os }}\n strategy:\n fail-fast: false\n matrix:\n os: [ubuntu-latest, macos-latest, windows-latest]\n node: [18, 20, 22]\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: ${{ matrix.node }}\n cache: npm\n - run: npm ci\n - run: npm test\n```\n\n- `fail-fast: false` lets all matrix legs finish even if one fails — better for\n seeing the full picture.\n- `max-parallel` caps concurrency if you hit runner limits.\n- Add `include:` to append extra dimensions and `exclude:` to drop\n combinations you don't care about.\n\n## Secrets Management\n\nNever hardcode credentials. Store them in **Settings → Secrets and variables →\nActions** as repository (or environment) secrets, then reference them as\n`${{ secrets.NAME }}`.\n\n```yaml\nsteps:\n - name: Publish package\n run: npm publish\n env:\n NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n```\n\nRules:\n\n- Secrets are redacted in logs; never `echo` them. If you must debug, log only\n `${{ secrets.TOKEN != '' }}` (prints `true`/`false`) to confirm presence.\n- Prefer **OpenID Connect (OIDC)** over static keys for cloud deploys\n (AWS, GCP, Azure). Use `actions/configure-aws-credentials@v4` with\n `role-to-assume` so no long-lived key is stored.\n- Scope secrets to an `environment:` (e.g. `production`) to require manual\n approval and restrict who can consume them.\n- `GITHUB_TOKEN` is auto-provided; for cross-repo pushes, mint a Personal\n Access Token and store it as a secret.\n\n## Caching\n\nCache dependencies to slash build times:\n\n```yaml\n- uses: actions/setup-node@v4\n with:\n node-version: 20\n cache: npm # auto-caches ~/.npm\n\n# For anything else:\n- uses: actions/cache@v4\n with:\n path: ~/.cache/pip\n key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}\n restore-keys: |\n ${{ runner.os }}-pip-\n```\n\n- The `cache:` shortcut on `setup-*` actions is the cleanest path for Node,\n Python, Ruby, Go.\n- For arbitrary folders use `actions/cache@v4` with a `key` that includes a hash\n of the lockfile so the cache busts when dependencies change.\n- `restore-keys` provides a prefix match fallback when the exact key misses.\n- Cache size limit is ~10 GB and caches are scoped per-branch/PR by default.\n\n## Common Pitfalls\n\n1. **Missing checkout.** Steps read an empty workspace without\n `actions/checkout@v4` first. Always check it out.\n2. **Mutable action refs.** `@main` or `@master` can change under you and is a\n supply-chain risk. Pin to `@v4` or a commit SHA.\n3. **`npm install` instead of `npm ci`.** `install` ignores the lockfile and can\n produce non-reproducible builds; `ci` fails if lockfile is out of sync.\n4. **Artifact version mismatch.** Mixing `upload-artifact@v3` with\n `download-artifact@v4` fails. Keep them aligned.\n5. **Secrets in logs.** Printing a secret (even in a failed step) leaks it into\n build logs. Reference via `env:`, never `run: echo \"$SECRET\"`.\n6. **Wrong trigger.** `on: push` with no `branches:` runs on every branch;\n `pull_request` never fires on direct pushes. Be explicit.\n7. **`continue-on-error` abuse.** It marks a job green even when it fails —\n useful only for experimental matrix legs, never for your real test gate.\n8. **Windows line endings.** `git autocrlf` can break shell scripts on\n `windows-latest`. Set `shell: bash` or normalize line endings.\n9. **No `concurrency` group.** Concurrent pushes can race deploys. Use:\n ```yaml\n concurrency:\n group: deploy-${{ github.ref }}\n cancel-in-progress: true\n ```\n10. **Timeouts.** Long jobs may exceed the default. Add `timeout-minutes:` to\n jobs to fail fast and free runners.\n\n## Verification Checklist\n\n- [ ] Workflow file lives at `.github/workflows/<name>.yml`.\n- [ ] `actions/checkout@v4` is the first step of every job that needs code.\n- [ ] Actions are pinned to major versions.\n- [ ] Test command matches the project's actual test runner.\n- [ ] Secrets are referenced via `${{ secrets.* }}`, never inline.\n- [ ] Deploy jobs are gated by `if: github.ref == 'refs/heads/main'`.\n- [ ] A `concurrency` group protects any deploy/publish job.\n", "readme_content": "# github-actions-ci\n\n> Ship a real GitHub Actions CI/CD pipeline for any project — tests, builds, and\n> deployments that actually pass, generated from a single conversation.\n\nPart of the [Hermes Skills Portfolio](https://github.com/THEROCKSSS/hermes-skills-portfolio)\nby **Alex**.\n\n---\n\n## What it does\n\n`github-actions-ci` turns \"can you add CI to my repo?\" into a working\n`.github/workflows/*.yml` file plus the supporting config to make it green. It\ncovers the full pipeline lifecycle:\n\n- **Test** — run your suite on every push and pull request\n- **Build** — compile, bundle, and upload artifacts\n- **Deploy** — ship to hosts, registries, or Pages on merge to `main`\n- **Matrix** — parallelize across OSes and language versions\n- **Secrets** — OIDC and encrypted secret handling done right\n- **Caching** — slash build times with dependency caches\n- **Pitfalls** — the 10 mistakes that break real pipelines, avoided by default\n\n## Why use it\n\nWriting YAML by hand means re-learning the same footguns every time: forgetting\n`checkout`, pinning to a mutable `@main`, leaking a secret to logs, or watching\n`npm install` produce a non-reproducible build. This skill bakes in the\nbattle-tested defaults so the pipeline works the first time and stays safe.\n\n## Install\n\nInstall the skill into your Hermes agent:\n\n```bash\nhermes skills install \\\n https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/github-actions-ci/SKILL.md\n```\n\nOr add it to your portfolio checkout and point Hermes at the directory.\n\n## Usage\n\nJust ask your agent naturally:\n\n- \"Add CI that runs my pytest suite on Python 3.11 and 3.12\"\n- \"Set up GitHub Actions to build my Next.js app and deploy to Pages\"\n- \"Make my workflow test on Ubuntu, macOS, and Windows with Node 18/20/22\"\n- \"Why is my deploy firing on every PR? Fix it.\"\n\nThe skill inspects your stack, emits the correct workflow, and walks a\ndeliverable checklist (checkout first, pin actions, gate deploys, protect\nsecrets) before calling it done.\n\n## Example output\n\n```yaml\nname: CI\non:\n push:\n branches: [main]\n pull_request:\n branches: [main]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: 20\n cache: npm\n - run: npm ci\n - run: npm test -- --ci\n```\n\n## License\n\nMIT — free to use, fork, and extend.\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/github-actions-ci/SKILL.md" }, { "name": "openapi-generator", "category": "backend", "tier": "featured", "description": "Generate client SDKs and server stubs from an OpenAPI spec.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/openapi-generator/SKILL.md", "path": "skills/openapi-generator", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "openapi-generator", "description": "Generate client SDKs and server stubs from an OpenAPI spec.", "version": "1.0.0" }, "agent_use": "Load this skill when the user:\n\n- Has an OpenAPI 2.0 (Swagger) or 3.0/3.1 spec and wants a **client SDK** in a\n specific language (TypeScript, Python, Go, Java, C#, Ruby, etc.).\n- Wants a **server stub** / skeleton to implement an API described by a spec.\n- Needs **API documentation** (HTML, Markdown, AsciiDoc, Redoc) generated from a\n spec.\n- Asks to scaffold a new project \"from the API contract\".\n- Wants to keep generated clients/stubs in sync with a changing spec.\n- Mentions Swagger Codegen, `openapi-generator`, `openapi-generator-cli`,\n `nswag`, or \"generate a client from this YAML/JSON\".\n\nDo **not** use this for: writing the OpenAPI spec itself (that's design work),\nruntime API mocking (use Prism/httpbin), or contract testing (use Dredd/Schemathesis).\nThis skill is about *code generation from an existing spec*.", "user_use": "Point the skill at an OpenAPI 2.0 / 3.0 / 3.1 specification and it produces\nreal, buildable code — no hand-written boilerplate:\n\n- **Client SDKs** — typed clients for TypeScript, Python, Go, Java, C#, Rust, Swift, and more.\n- **Server stubs** — routing, models, and interfaces to implement on Spring, FastAPI, Express, ASP.NET Core, and others.\n- **Documentation** — HTML, Markdown, AsciiDoc, and Redoc sites generated from the same contract.\n- **Infrastructure** — Kubernetes, Terraform, and Postman collections from the spec.\n\n---", "skillmd_content": "---\nname: openapi-generator\ndescription: >-\n Use when the user has an OpenAPI 2.0 (Swagger) or 3.x spec and wants a\n typed client SDK, a server stub, generated API documentation, or config\n scaffolded from it via openapi-generator-cli — instead of hand-writing\n boilerplate. Covers 50+ language/framework targets, Docker/JAR/npm CLI\n options, and spec validation before generation.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [openapi, swagger, code-generation, client-sdk, server-stub, api-docs]\n related_skills: [mcp-server-build, http-api-tester]\n---\n\n# openapi-generator\n\n## Overview\n\nTurn an OpenAPI specification into working code. This skill wraps\n[OpenAPI Generator](https://openapi-generator.tech) so an agent can produce\nclient SDKs, server stubs, documentation, and supporting config directly from a\nspec file — with the right flags, the right generator, and none of the common\npitfalls.\n\n## When to Use\n\nLoad this skill when the user:\n\n- Has an OpenAPI 2.0 (Swagger) or 3.0/3.1 spec and wants a **client SDK** in a\n specific language (TypeScript, Python, Go, Java, C#, Ruby, etc.).\n- Wants a **server stub** / skeleton to implement an API described by a spec.\n- Needs **API documentation** (HTML, Markdown, AsciiDoc, Redoc) generated from a\n spec.\n- Asks to scaffold a new project \"from the API contract\".\n- Wants to keep generated clients/stubs in sync with a changing spec.\n- Mentions Swagger Codegen, `openapi-generator`, `openapi-generator-cli`,\n `nswag`, or \"generate a client from this YAML/JSON\".\n\nDo **not** use this for: writing the OpenAPI spec itself (that's design work),\nruntime API mocking (use Prism/httpbin), or contract testing (use Dredd/Schemathesis).\nThis skill is about *code generation from an existing spec*.\n\n## openapi-generator-cli\n\nThe CLI is distributed as a Java JAR, Docker image, and native installers. Pick\none and reuse it for every generation in a session.\n\n### Option A — Docker (recommended, no Java install)\n\n```bash\n# List every available generator\ndocker run --rm openapitools/openapi-generator-cli list\n\n# Generate a TypeScript fetch client\ndocker run --rm -v \"${PWD}:/local\" openapitools/openapi-generator-cli generate \\\n -i /local/openapi.yaml \\\n -g typescript-fetch \\\n -o /local/out/typescript\n```\n\nThe `-v \"${PWD}:/local\"` bind mount is mandatory — the container writes output\ninto `/local`, which maps to your working directory.\n\n### Option B — JAR (needs Java 11+)\n\n```bash\ncurl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.6.0/openapi-generator-cli-7.6.0.jar \\\n -o openapi-generator-cli.jar\n\njava -jar openapi-generator-cli.jar list\njava -jar openapi-generator-cli.jar generate -i openapi.yaml -g python -o out/python\n```\n\n### Option C — npm wrapper (single-binary, no Java)\n\n```bash\nnpm install -g @openapitools/openapi-generator-cli\nopenapi-generator-cli version-manager set 7.6.0\nopenapi-generator-cli generate -i openapi.yaml -g go -o out/go\n```\n\nPin a specific version (`openapi-generator-cli version-manager set 7.6.0`) so\ngenerated output is reproducible across machines.\n\n## Generator Types\n\n### Client\n\nGenerates a typed SDK to *call* the API. Most common request.\n\n```bash\nopenapi-generator-cli generate \\\n -i openapi.yaml -g typescript-fetch -o sdk/ts \\\n --additional-properties=supportsES6=true,npmName=my-api-sdk,npmVersion=1.0.0\n```\n\nCommon client generators: `typescript-fetch`, `typescript-axios`, `python`,\n`java`, `go`, `csharp-netcore`, `ruby`, `php`, `rust`, `kotlin`, `swift5`, `dart`.\n\n### Server\n\nGenerates a server stub with routing, models, and (optionally) business-logic\nstubs you fill in.\n\n```bash\nopenapi-generator-cli generate \\\n -i openapi.yaml -g spring -o server/java \\\n --additional-properties=interfaceOnly=true,useSpringBoot3=true\n```\n\nCommon server generators: `spring` (Java), `flask` / `fastapi` (Python),\n`express` (Node), `go-server`, `aspnetcore` (C#), `ruby-on-sinatra`, `php-symfony`.\n\nUse `interfaceOnly=true` to emit just the interfaces/models — ideal when you\nalready have a framework set up and only want the contract types.\n\n### Documentation\n\n```bash\n# Static HTML2 docs\nopenapi-generator-cli generate -i openapi.yaml -g html2 -o docs/html\n\n# Markdown\nopenapi-generator-cli generate -i openapi.yaml -g markdown -o docs/md\n\n# AsciiDoc\nopenapi-generator-cli generate -i openapi.yaml -g asciidoc -o docs/ascii\n```\n\nOther doc-ish generators: `openapi` (normalizes/re-exports the spec),\n`openapi-yaml`, `redoc` (bundles Redoc viewer), `confluencewiki`.\n\n## Language Support\n\nOpenAPI Generator supports **50+ targets**. The agent should verify the exact\ngenerator name before invoking — names are case-sensitive and change between\nversions. Run `list` (Docker/JAR) or `openapi-generator-cli list` (npm) and grep\nfor the language.\n\n```bash\ndocker run --rm openapitools/openapi-generator-cli list | grep -i typescript\n```\n\nHigh-traffic families and their canonical generator names:\n\n| Family | Generator(s) |\n|------------|------------------------------------------------|\n| TypeScript | `typescript-fetch`, `typescript-axios`, `typescript-node` |\n| Python | `python`, `python-fastapi` |\n| Go | `go`, `go-server` |\n| Java | `java`, `spring` |\n| C# | `csharp-netcore`, `aspnetcore` |\n| Rust | `rust`, `rust-server` |\n| Kotlin | `kotlin`, `kotlin-spring` |\n| Swift | `swift5` |\n| PHP | `php`, `php-symfony`, `php-laravel` |\n| Ruby | `ruby`, `ruby-on-sinatra` |\n\nIf a user asks for a language not listed by `list`, tell them it is unsupported\nrather than guessing a generator name — a wrong name fails loudly.\n\n## Customization\n\nGenerators are configured with `--additional-properties` (comma-separated\n`key=value`) and `-c config.json` for many properties.\n\n```bash\nopenapi-generator-cli generate -i openapi.yaml -g python -o out/python \\\n -c config.json\n```\n\n`config.json`:\n\n```json\n{\n \"packageName\": \"my_api_client\",\n \"projectName\": \"my-api-client\",\n \"packageVersion\": \"2.1.0\",\n \"generateSourceCodeOnly\": false,\n \"useOneOfDiscriminatorLookup\": true\n}\n```\n\nUseful properties (generator-specific — always confirm with `config-help`):\n\n```bash\nopenapi-generator-cli config-help -g typescript-fetch\n```\n\n- `useOneOfDiscriminatorLookup`, `useInlineModelResolver` — shape of models.\n- `enumClassPrefix`, `enumNameMappings` — enum naming.\n- `hideGenerationTimestamp` — set `true` to keep diffs clean in version control.\n- `sourceFolder`, `apiPackage`, `modelPackage` — output layout.\n\nFor deeper changes (custom templates, license headers, naming), use the\n`-t templates/` flag to supply a Mustache template directory. Copy the default\ntemplates first:\n\n```bash\nopenapi-generator-cli author template -g typescript-fetch -o my-templates/\n```\n\n## Validation\n\nValidate the spec before generating — most failures trace back to an invalid or\npartially-resolved spec.\n\n```bash\n# Built-in structural validation\nopenapi-generator-cli validate -i openapi.yaml\n\n# Stronger linting with Spectral (separate tool)\nnpm install -g @stoplight/spectral-cli\nspectral lint openapi.yaml\n```\n\nWorkflow the agent should follow:\n\n1. `validate` the spec — fix schema/reference errors first.\n2. `list` / `config-help` to confirm generator name and properties.\n3. `generate` into a clean output directory.\n4. Inspect the output: does `out/<lang>/README.md` / build file exist?\n5. For client/server, optionally compile/build the generated project to confirm\n it is non-trivial and importable.\n\n```bash\n# Example check after generating a Python client\ncd out/python && python -m pip install -e . && python -c \"import openapi_client\"\n```\n\n## Common Pitfalls\n\n1. **Forgetting to mount the spec into Docker.** Omitting `-v \"${PWD}:/local\"` means the container can't see your spec and silently writes nothing where you expect. Always bind the working directory.\n2. **Remote `$ref`s break in air-gapped runs.** Remote (URL) `$ref`s resolve fine online but fail with no network access. Bundle first with `openapi-generator-cli merge` or `swagger-cli bundle` to inline all references.\n3. **Version drift produces different code on different machines.** Pin the CLI version (`version-manager set 7.6.0`) and record it in the generated project's README or a Makefile/CI step.\n4. **Overwriting hand-written code.** Never generate into a directory that contains source you've edited — a regeneration silently clobbers your changes. Use `interfaceOnly=true` or generate into a dedicated `generated/` subtree and import it.\n5. **`hideGenerationTimestamp` left off in committed code.** Leave it off while debugging, but set it `true` before committing so every regeneration doesn't create a noisy timestamp-only diff.\n6. **Case-sensitive generator names.** `TypeScript-Fetch` fails outright; the correct name is `typescript-fetch`. Always copy the exact string from `list` rather than guessing casing.\n7. **JVM runs out of memory on large specs.** The JAR path can OOM on big specs. Raise the heap: `java -Xmx2g -jar openapi-generator-cli.jar generate ...`.\n8. **OpenAPI 3.1 features aren't fully supported by every generator.** Webhooks and some discriminator patterns lag behind on certain generators. If a 3.1 spec fails, try converting to 3.0 first with `openapi-generator-cli merge` or `swagger2openapi`.\n9. **Hand-resolving merge conflicts inside generated folders.** Treat generated code as a build artifact — regenerate rather than resolve Git conflicts inside it.\n10. **Docker writes output owned by root.** Add `--user $(id -u):$(id -g)` to the `docker run` command so generated files aren't root-owned on the host.\n\n## Verification Checklist\n\n- [ ] `openapi-generator-cli validate -i <spec>` passes with no schema/reference errors before generating\n- [ ] The exact generator name was confirmed via `list` (case-sensitive), not guessed\n- [ ] Output directory contains the expected files (`README.md`, build file, model/client sources) after generation\n- [ ] For client SDKs: the generated project actually installs/builds/imports (e.g. `pip install -e .` then `import <package>` succeeds)\n- [ ] Generated output was written to a dedicated directory, not one containing hand-edited source\n- [ ] CLI version used is recorded (README, Makefile, or CI config) so regeneration is reproducible\n", "readme_content": "# openapi-generator\n\n> Generate client SDKs, server stubs, API docs, and config from an OpenAPI spec — in 50+ languages, straight from the contract.\n\nPart of the [Hermes Skills Portfolio](https://github.com/THEROCKSSS/hermes-skills-portfolio) by **Alex**.\n\n---\n\n## What it does\n\nPoint the skill at an OpenAPI 2.0 / 3.0 / 3.1 specification and it produces\nreal, buildable code — no hand-written boilerplate:\n\n- **Client SDKs** — typed clients for TypeScript, Python, Go, Java, C#, Rust, Swift, and more.\n- **Server stubs** — routing, models, and interfaces to implement on Spring, FastAPI, Express, ASP.NET Core, and others.\n- **Documentation** — HTML, Markdown, AsciiDoc, and Redoc sites generated from the same contract.\n- **Infrastructure** — Kubernetes, Terraform, and Postman collections from the spec.\n\n---\n\n## Why you want it\n\n| Without this skill | With this skill |\n|--------------------|-----------------|\n| Hand-write a client per language | One spec → every client in minutes |\n| Drift between docs and code | Docs generated from the same source of truth |\n| Guess generator flags | Correct, version-pinned invocations |\n| Hit Docker/JAR pitfalls blind | Guided around the common traps |\n\n---\n\n## Quick start\n\n```bash\n# Generate a TypeScript fetch client from your spec\ndocker run --rm -v \"${PWD}:/local\" openapitools/openapi-generator-cli generate \\\n -i /local/openapi.yaml \\\n -g typescript-fetch \\\n -o /local/out/typescript\n```\n\nPrefer npm or the JAR? The skill documents all three install paths\n(Docker, JAR, npm wrapper) plus version pinning for reproducible output.\n\n---\n\n## Highlights\n\n- ✅ 50+ generator targets with verified canonical names\n- ✅ Client, server, and documentation generator recipes\n- ✅ `additional-properties` and `config.json` customization patterns\n- ✅ Spec validation with built-in and Spectral linting\n- ✅ A dedicated **Pitfalls** section: Docker mounts, `$ref` resolution, version drift, overwriting hand-written code, JVM memory, 3.1 support\n\n---\n\n## Install\n\nAdd this skill to your Hermes agent:\n\n```\nhttps://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/openapi-generator/SKILL.md\n```\n\nThen ask your agent things like:\n\n- *\"Generate a Python client and a Spring server stub from my openapi.yaml.\"*\n- *\"Build HTML docs from this spec and pin the generator version.\"*\n- *\"Create a TypeScript SDK with ES6 support and a custom package name.\"*\n\n---\n\n## License\n\nMIT — contributions welcome via the portfolio repository.\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/openapi-generator/SKILL.md" }, { "name": "email-send", "category": "integrations", "tier": "featured", "description": "Send email programmatically via SMTP or API providers.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/email-send/SKILL.md", "path": "skills/email-send", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "email-send", "description": "Send email programmatically via SMTP or API providers.", "version": "1.0.0" }, "agent_use": "- The user wants to send email from their app or script.\n- The user wants to send notifications, alerts, or reports via email.\n- The user wants to set up a transactional email provider.\n- The user says \"send email from my app\", \"set up email notifications\", or \"I need to email users\".", "user_use": "The agent configures an email provider (SMTP, Resend, SendGrid, Mailgun, or AWS SES), writes the sending code in your language, and tests the connection. You get a working email function for your app, scripts, or agent tasks — notifications, alerts, reports, or transactional emails.", "skillmd_content": "---\nname: email-send\ndescription: \"Use when the user wants to send email programmatically from an app or script — transactional email, notifications, alerts, or reports — via SMTP or a provider API (Resend, SendGrid, Mailgun, AWS SES, Postmark).\"\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [smtp, transactional-email, resend, sendgrid, aws-ses, nodemailer]\n related_skills: [ntfy-notifier, webhook-receiver, env-config-manager]\n---\n\n# email-send\n\n## Overview\n\nSet up programmatic email sending via SMTP or a transactional email API. The agent configures the provider, writes the sending code, and tests the connection. You get a working email function for your app, scripts, or agent tasks.\n\n## When to Use\n\n- The user wants to send email from their app or script.\n- The user wants to send notifications, alerts, or reports via email.\n- The user wants to set up a transactional email provider.\n- The user says \"send email from my app\", \"set up email notifications\", or \"I need to email users\".\n\n## Provider Comparison\n\n| Provider | Free tier | Setup | Best for |\n|---|---|---|---|\n| **SMTP (Gmail, etc.)** | Limited | SMTP creds | Personal scripts, low volume |\n| **Resend** | 3k/month free | API key | Modern apps, developer-friendly |\n| **SendGrid** | 100/day free | API key | Established apps, high volume |\n| **Mailgun** | 5k/month (3mo) | API key | EU hosting, routing |\n| **AWS SES** | 62k free (from EC2) | IAM creds | AWS-native, cheapest at scale |\n| **Postmark** | 100/month free | API key | High deliverability, transactional |\n\n## Setup: SMTP\n\nSimplest for low-volume personal use. Works with any email provider that supports SMTP.\n\n### Python (smtplib)\n\n```python\nimport smtplib\nfrom email.mime.text import MIMEText\nfrom email.mime.multipart import MIMEMultipart\n\ndef send_email(to: str, subject: str, body: str, html: bool = False):\n msg = MIMEMultipart()\n msg['From'] = 'you@example.com'\n msg['To'] = to\n msg['Subject'] = subject\n msg.attach(MIMEText(body, 'html' if html else 'plain'))\n\n with smtplib.SMTP('smtp.gmail.com', 587) as server:\n server.starttls()\n server.login('you@example.com', 'your-app-password')\n server.send_message(msg)\n return \"Sent\"\n```\n\n### Node.js (nodemailer)\n\n```javascript\nconst nodemailer = require('nodemailer');\n\nconst transporter = nodemailer.createTransport({\n host: 'smtp.gmail.com',\n port: 587,\n secure: false,\n auth: {\n user: 'you@example.com',\n pass: 'your-app-password'\n }\n});\n\nasync function sendEmail(to, subject, body) {\n await transporter.sendMail({\n from: 'you@example.com',\n to,\n subject,\n html: body\n });\n return 'Sent';\n}\n```\n\n### Gmail app password\n\nFor Gmail SMTP, you need an app-specific password (not your regular password):\n1. Enable 2FA on your Google account\n2. Go to https://myaccount.google.com/apppasswords\n3. Generate an app password for \"Mail\"\n4. Use that 16-character password in your SMTP config\n\n## Setup: Resend (API, recommended for apps)\n\n```bash\npip install resend\n# or\nnpm install resend\n```\n\n```python\nimport resend\nresend.api_key = \"re_xxxxxxx\"\n\nparams = {\n \"from\": \"alerts@yourdomain.com\",\n \"to\": [\"user@example.com\"],\n \"subject\": \"Build complete\",\n \"html\": \"<p>Your build finished successfully.</p>\"\n}\nemail = resend.Emails.send(params)\n```\n\n```javascript\nconst Resend = require('resend');\nconst resend = new Resend('re_xxxxxxx');\n\nawait resend.emails.send({\n from: 'alerts@yourdomain.com',\n to: 'user@example.com',\n subject: 'Build complete',\n html: '<p>Your build finished successfully.</p>'\n});\n```\n\n**Note:** Resend requires verifying your sending domain (add DNS records). For testing without a domain, use `onboarding@resend.dev` as the from address.\n\n## Setup: AWS SES\n\n```python\nimport boto3\n\nses = boto3.client('ses', region_name='us-east-1',\n aws_access_key_id='YOUR_KEY',\n aws_secret_access_key='YOUR_SECRET'\n)\n\nses.send_email(\n Source='alerts@yourdomain.com',\n Destination={'ToAddresses': ['user@example.com']},\n Message={\n 'Subject': {'Data': 'Alert'},\n 'Body': {'Html': {'Data': '<p>Server down</p>'}}\n }\n)\n```\n\n**Note:** New SES accounts are in \"sandbox\" mode — you can only send to verified email addresses. Request production access to send to anyone.\n\n## Templating\n\n### Simple HTML template\n\n```python\ndef build_report_email(title: str, metrics: dict) -> str:\n rows = \"\".join(f\"<tr><td>{k}</td><td>{v}</td></tr>\" for k, v in metrics.items())\n return f\"\"\"\n <html><body>\n <h2>{title}</h2>\n <table border=\"1\" cellpadding=\"8\" style=\"border-collapse:collapse;\">\n <tr><th>Metric</th><th>Value</th></tr>\n {rows}\n </table>\n <p style=\"color:#888;font-size:12px;margin-top:20px;\">\n Sent by automated system. Do not reply.\n </p>\n </body></html>\n \"\"\"\n```\n\n### Jinja2 template\n\n```python\nfrom jinja2 import Template\n\ntemplate = Template(\"\"\"\n<h2>{{ title }}</h2>\n<ul>\n{% for item in items %}\n <li>{{ item.name }}: {{ item.value }}</li>\n{% endfor %}\n</ul>\n\"\"\")\n\nhtml = template.render(title=\"Daily Report\", items=data)\n```\n\n## Attachments\n\n```python\nimport smtplib\nfrom email.mime.multipart import MIMEMultipart\nfrom email.mime.base import MIMEBase\nfrom email import encoders\n\nmsg = MIMEMultipart()\nmsg['Subject'] = 'Report with attachment'\n\nwith open('report.pdf', 'rb') as f:\n part = MIMEBase('application', 'octet-stream')\n part.set_payload(f.read())\n encoders.encode_base64(part)\n part.add_header('Content-Disposition', 'attachment; filename=\"report.pdf\"')\n msg.attach(part)\n\nwith smtplib.SMTP('smtp.gmail.com', 587) as server:\n server.starttls()\n server.login('you@example.com', 'password')\n server.send_message(msg)\n```\n\n## Common Pitfalls\n\n1. **Gmail blocks less-secure apps** — Use an app-specific password, not your regular password. Regular password auth is blocked by Google for most accounts.\n2. **Emails going to spam** — If using a custom domain, set up SPF, DKIM, and DMARC DNS records. Without these, recipient servers will likely mark your emails as spam. Transactional providers (Resend, SendGrid) handle this automatically.\n3. **SES sandbox mode** — New AWS SES accounts can only send to verified addresses. You must request production access to send to any address.\n4. **Rate limits** — Gmail SMTP limits ~500 emails/day. Resend's free tier is 3k/month. SendGrid is 100/day. For high volume, use a dedicated provider.\n5. **HTML in plain text clients** — Always include a plain-text alternative alongside HTML. Some email clients and all automated spam filters check for a text part.\n6. **Attachment size** — Most providers limit attachments to 10-25 MB. For larger files, upload to a storage service and send a download link.\n7. **Sending domain not verified** — Resend, SendGrid, and Mailgun require you to verify your sending domain (add DNS records). Without verification, emails will fail or be rejected.\n\n## Verification Checklist\n\n- [ ] Test send lands in the inbox (not spam) for both the plain-text and HTML parts\n- [ ] SPF/DKIM/DMARC records verified for a custom sending domain, or provider-managed domain (e.g. `onboarding@resend.dev`) confirmed for testing\n- [ ] Attachment (if any) downloads and opens correctly from the received message\n- [ ] Provider account confirmed out of sandbox mode (SES) or within the free-tier/rate limit for the expected send volume\n", "readme_content": "# email-send\n\nSend email programmatically via SMTP or a transactional email API.\n\n## What it does\n\nThe agent configures an email provider (SMTP, Resend, SendGrid, Mailgun, or AWS SES), writes the sending code in your language, and tests the connection. You get a working email function for your app, scripts, or agent tasks — notifications, alerts, reports, or transactional emails.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/email-send/SKILL.md\n```\n\n## How to use\n\n```\n\"Set up email notifications for my app using Resend\"\n```\n\nThe agent:\n1. Helps you create a Resend account and get an API key\n2. Writes the sending code in Python or Node.js\n3. Tests by sending a test email to your address\n4. Integrates into your app or script\n\n## Providers\n\n| Provider | Free tier | Best for |\n|---|---|---|\n| SMTP (Gmail) | 500/day | Personal scripts |\n| Resend | 3k/month | Modern apps |\n| SendGrid | 100/day | High volume |\n| AWS SES | 62k (from EC2) | Cheapest at scale |\n| Mailgun | 5k/month (3mo) | EU hosting |\n\n## Example\n\n```\nUser: \"Send me an email when my cron job fails\"\n\nAgent:\n 1. Sets up Resend with your API key\n 2. Writes: send_email(\"you@example.com\", \"Job Failed\", \"<p>Backup failed at 3AM</p>\")\n 3. Tests: sends a test email → you receive it\n 4. Adds the call to your cron job's error handler\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/email-send/SKILL.md" }, { "name": "rss-monitor", "category": "integrations", "tier": "featured", "description": "Monitor RSS and Atom feeds for new entries with notifications.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/rss-monitor/SKILL.md", "path": "skills/rss-monitor", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "rss-monitor", "description": "Monitor RSS and Atom feeds for new entries with notifications.", "version": "1.0.0" }, "agent_use": "- The user wants to know when a blog publishes a new post.\n- The user wants to monitor news feeds for specific topics.\n- The user wants to track updates from multiple sources in one place.\n- The user says \"monitor this RSS feed\", \"notify me when this blog updates\", or \"watch for new entries\".", "user_use": "The agent sets up a feed monitoring script that checks your RSS/Atom feeds on a schedule, detects new entries by tracking entry IDs, and sends you a notification (via ntfy, Discord, Telegram, or email) when new content appears. Silent when there's nothing new — you only hear about it when there's something to read.", "skillmd_content": "---\nname: rss-monitor\ndescription: Use when the user wants to be notified when a blog, news feed, or other RSS/Atom source publishes new content, or wants to track updates from multiple feeds in one place — triggers include \"monitor this RSS feed\", \"notify me when this blog updates\", or \"watch for new entries\".\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [rss, atom, feed-monitoring, notifications, cron, feedparser]\n related_skills: [ntfy-notifier, cron-task, email-send, telegram-bot-build, discord-bot-build]\n---\n\n# rss-monitor\n\n## Overview\n\nMonitor RSS and Atom feeds for new entries. The agent sets up a feed monitoring loop that checks feeds on a schedule, detects new entries, and delivers notifications via your preferred channel (Telegram, Discord, email, ntfy).\n\n## When to Use\n\n- The user wants to know when a blog publishes a new post.\n- The user wants to monitor news feeds for specific topics.\n- The user wants to track updates from multiple sources in one place.\n- The user says \"monitor this RSS feed\", \"notify me when this blog updates\", or \"watch for new entries\".\n\n## Prerequisites\n\n- Python 3.8+ with `feedparser` installed: `pip install feedparser`\n- A notification channel (ntfy, Telegram, Discord, or email)\n\n## Feed Discovery\n\n### Find the RSS/Atom feed for a site\n\n```bash\n# Check for <link rel=\"alternate\"> in the HTML\ncurl -s https://example.com | grep -oP '(?<=href=\")[^\"]*(rss|atom|feed)[^\"]*'\n\n# Common feed paths to try\ncurl -s -o /dev/null -w \"%{http_code}\" https://example.com/feed/\ncurl -s -o /dev/null -w \"%{http_code}\" https://example.com/rss/\ncurl -s -o /dev/null -w \"%{http_code}\" https://example.com/atom.xml\ncurl -s -o /dev/null -w \"%{http_code}\" https://example.com/feed.xml\n```\n\n### Verify a feed is valid\n\n```python\nimport feedparser\n\nfeed = feedparser.parse(\"https://example.com/feed/\")\nprint(f\"Feed: {feed.feed.get('title', 'Unknown')}\")\nprint(f\"Entries: {len(feed.entries)}\")\nfor entry in feed.entries[:3]:\n print(f\" - {entry.title}\")\n```\n\n## Monitoring Loop\n\n### Simple monitor script\n\n```python\n#!/usr/bin/env python3\n\"\"\"Monitor RSS feeds for new entries. Notifies via ntfy on new content.\"\"\"\nimport feedparser\nimport requests\nimport json\nimport os\nimport time\nfrom datetime import datetime, timezone\n\nFEEDS = [\n \"https://blog.example.com/feed/\",\n \"https://news.ycombinator.com/rss\",\n]\n\nSTATE_FILE = os.path.expanduser(\"~/.rss-monitor-state.json\")\nNTFY_TOPIC = \"rss-alerts-abc123\"\n\ndef load_state():\n if os.path.exists(STATE_FILE):\n with open(STATE_FILE) as f:\n return json.load(f)\n return {}\n\ndef save_state(state):\n with open(STATE_FILE, 'w') as f:\n json.dump(state, f, indent=2)\n\ndef notify(title, body):\n requests.post(\n f\"https://ntfy.sh/{NTFY_TOPIC}\",\n data=body.encode('utf-8'),\n headers={\"Title\": title, \"Tags\": \"rss,new\"}\n )\n\ndef check_feeds():\n state = load_state()\n new_entries = []\n\n for feed_url in FEEDS:\n feed = feedparser.parse(feed_url)\n seen_ids = state.get(feed_url, [])\n\n for entry in feed.entries:\n entry_id = entry.get('id', entry.get('link', ''))\n if entry_id not in seen_ids:\n new_entries.append({\n 'feed': feed.feed.get('title', feed_url),\n 'title': entry.title,\n 'link': entry.get('link', ''),\n 'published': entry.get('published', '')\n })\n\n # Update seen IDs (keep last 100)\n all_ids = [e.get('id', e.get('link', '')) for e in feed.entries]\n state[feed_url] = list(set(seen_ids + all_ids))[-100:]\n\n save_state(state)\n\n for entry in new_entries:\n title = f\"New: {entry['title'][:60]}\"\n body = f\"{entry['feed']}\\n{entry['link']}\"\n notify(title, body)\n print(f\"New entry: {entry['feed']} — {entry['title']}\")\n\n if not new_entries:\n print(f\"No new entries ({datetime.now().isoformat()})\")\n\nif __name__ == \"__main__\":\n check_feeds()\n```\n\n### Schedule with cron\n\n```bash\n# Check every hour\ncrontab -e\n# Add:\n0 * * * * /usr/bin/python3 /path/to/rss-monitor.py\n\n# Or with Hermes cron\nhermes cron create \"1h\" --prompt \"Run the RSS monitor script at scripts/rss-monitor.py and report any new entries\"\n```\n\n## Change Detection\n\nThe monitor tracks seen entry IDs to detect what's new. Two approaches:\n\n| Approach | How | Tradeoff |\n|---|---|---|\n| **Entry ID tracking** | Store entry IDs in a JSON state file | Reliable, but misses entries if IDs change |\n| **Timestamp threshold** | Notify on entries newer than last check | Simpler, but misses backdated entries |\n\nThe script above uses entry ID tracking — more reliable for most feeds.\n\n## Filtering\n\n### Keyword filter\n\n```python\nKEYWORDS = [\"python\", \"ai\", \"agents\", \"llm\"]\n\ndef matches_keywords(entry):\n text = (entry.title + \" \" + entry.get('summary', '')).lower()\n return any(kw in text for kw in KEYWORDS)\n\n# In check_feeds, before notifying:\nif matches_keywords(entry):\n notify(title, body)\n```\n\n### Regex filter\n\n```python\nimport re\n\nPATTERNS = [\n re.compile(r'\\b(funding|series [a-c])\\b', re.IGNORECASE),\n re.compile(r'\\b(security|vulnerability|cve)\\b', re.IGNORECASE),\n]\n\ndef matches_patterns(entry):\n text = entry.title + \" \" + entry.get('summary', '')\n return any(p.search(text) for p in PATTERNS)\n```\n\n## Notification Delivery\n\n| Channel | How |\n|---|---|\n| **ntfy** | `requests.post(f\"https://ntfy.sh/{topic}\", data=body, headers={\"Title\": title})` |\n| **Discord** | Webhook URL: `requests.post(discord_webhook_url, json={\"content\": f\"**{title}**\\n{body}\"})` |\n| **Telegram** | Bot API: `requests.post(f\"https://api.telegram.org/bot{token}/sendMessage\", json={\"chat_id\": chat_id, \"text\": f\"{title}\\n{body}\"})` |\n| **Email** | See the `email-send` skill |\n\n## Common Pitfalls\n\n1. **Feed URL changes silently.** Sites sometimes change their feed URL without redirecting — a 404 from a previously-working feed means the site needs a new feed URL found.\n2. **Polling too frequently.** Most publishers don't update more than a few times per day — checking every 15-30 minutes is sufficient; checking every minute risks the monitor's IP getting blocked.\n3. **Entry ID instability causing duplicates.** Some feeds don't provide stable entry IDs — if `id` changes between checks, fall back to using the entry link as the dedup key.\n4. **Partial feed content.** Some feeds only include a summary, not the full article — follow the entry link and scrape the page if full text is needed.\n5. **Inconsistent date fields.** Feeds vary between `published`, `updated`, and `created` — use `feedparser`'s parsed fields (`entry.published_parsed`, a `time.struct_time`) rather than string-parsing.\n6. **State file corruption causing re-notification.** A corrupted state JSON makes the monitor treat every entry as new — handle JSON decode errors gracefully and fall back to a fresh state rather than crashing.\n7. **Authenticated feeds.** Private Substacks and paid newsletters require auth headers — fetch with `requests` using auth, then pass the response text to `feedparser.parse()` instead of the URL.\n\n## Verification Checklist\n\n- [ ] Feed URL verified valid with `feedparser.parse()` returning a non-empty `entries` list\n- [ ] State file (`~/.rss-monitor-state.json` or equivalent) created and persists entry IDs across runs\n- [ ] A test run against a feed with a known new entry actually triggers a notification\n- [ ] Polling interval is 15+ minutes (not aggressive enough to risk IP blocking)\n- [ ] Notification channel (ntfy/Discord/Telegram/email) confirmed to deliver by checking for the message\n- [ ] Keyword/regex filters (if used) tested against both matching and non-matching sample entries\n", "readme_content": "# rss-monitor\n\nMonitor RSS and Atom feeds for new entries — get notified when new content appears on any feed you follow.\n\n## What it does\n\nThe agent sets up a feed monitoring script that checks your RSS/Atom feeds on a schedule, detects new entries by tracking entry IDs, and sends you a notification (via ntfy, Discord, Telegram, or email) when new content appears. Silent when there's nothing new — you only hear about it when there's something to read.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/rss-monitor/SKILL.md\n```\n\n## How to use\n\n```\n\"Monitor the Hacker News RSS feed and notify me on ntfy when new entries appear\"\n```\n\nThe agent:\n1. Verifies the feed URL is valid\n2. Writes a monitoring script with entry ID tracking\n3. Schedules it with cron (every hour)\n4. Tests by running it once — you get a notification for current entries\n5. Future runs only notify on new entries\n\n## Prerequisites\n\n- Python 3.8+ with `feedparser` (`pip install feedparser`)\n- A notification channel (ntfy, Discord, Telegram, or email)\n\n## Example\n\n```\nUser: \"Watch three tech blogs and text me when they post something about AI\"\n\nAgent:\n 1. Adds the three feed URLs\n 2. Adds keyword filter: [\"ai\", \"llm\", \"machine learning\", \"gpt\"]\n 3. Writes monitor script with ntfy notification\n 4. Schedules: crontab 0 * * * * python3 rss-monitor.py\n 5. Returns: \"Monitoring started. You'll get a push only when a matching post appears.\"\n```\n\n## Filtering options\n\n| Filter | Example |\n|---|---|\n| Keywords | Only notify if title/summary contains \"python\" or \"ai\" |\n| Regex | Notify on `\\b(funding|series [a-c])\\b` |\n| No filter | Notify on every new entry |\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/rss-monitor/SKILL.md" }, { "name": "pdf-extract", "category": "utility", "tier": "utility", "description": "Extract text, images, and tables from PDFs with OCR fallback.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/pdf-extract/SKILL.md", "path": "skills/pdf-extract", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "pdf-extract", "description": "Extract text, images, and tables from PDFs with OCR fallback.", "version": "1.0.0" }, "agent_use": "- The user wants to extract text from a PDF.\n- The user has a scanned PDF that needs OCR.\n- The user wants to pull tables or images out of a PDF.\n- The user says \"read this PDF\", \"extract text from PDF\", or \"what's in this PDF\".", "user_use": "The agent reads a PDF and extracts its content as structured text. For normal PDFs, it uses direct text extraction (fast). For scanned PDFs (image-only), it falls back to OCR with tesseract. It can also extract tables as structured rows and pull embedded images.", "skillmd_content": "---\nname: pdf-extract\ndescription: Use when the user wants to extract text, tables, or images from a PDF file — including scanned/image-only PDFs needing OCR — or asks to \"read this PDF\", \"extract text from PDF\", \"what's in this PDF\", or to pull tables/images out of one.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [pdf, ocr, text-extraction, table-extraction, pymupdf, tesseract]\n related_skills: [ocr-documents, markdown-to-pdf, csv-toolkit]\n---\n\n# pdf-extract\n\n## Overview\n\nExtract text, images, and tables from PDF files using open-source Python libraries. The agent handles PDF parsing, OCR fallback for scanned documents, and structured output.\n\n## When to Use\n\n- The user wants to extract text from a PDF.\n- The user has a scanned PDF that needs OCR.\n- The user wants to pull tables or images out of a PDF.\n- The user says \"read this PDF\", \"extract text from PDF\", or \"what's in this PDF\".\n\n## Prerequisites\n\n```bash\npip install pymupdf pdfplumber pillow\n# For OCR fallback:\npip install pytesseract\n# Also install tesseract-ocr system package:\n# Linux: apt install tesseract-ocr\n# macOS: brew install tesseract\n# Windows: download from https://github.com/UB-Mannheim/tesseract/wiki\n```\n\n## Text Extraction\n\n### Basic text extraction (pymupdf)\n\n```python\nimport fitz # pymupdf\n\ndef extract_text(pdf_path: str) -> str:\n doc = fitz.open(pdf_path)\n text = []\n for page in doc:\n text.append(page.get_text())\n return \"\\n\".join(text)\n```\n\n### With page numbers\n\n```python\ndef extract_text_with_pages(pdf_path: str) -> list[dict]:\n doc = fitz.open(pdf_path)\n pages = []\n for i, page in enumerate(doc):\n pages.append({\n \"page\": i + 1,\n \"text\": page.get_text()\n })\n return pages\n```\n\n## Table Extraction\n\n```python\nimport pdfplumber\n\ndef extract_tables(pdf_path: str) -> list:\n tables = []\n with pdfplumber.open(pdf_path) as pdf:\n for i, page in enumerate(pdf.pages):\n page_tables = page.extract_tables()\n for table in page_tables:\n tables.append({\"page\": i + 1, \"rows\": table})\n return tables\n```\n\n## Image Extraction\n\n```python\nimport fitz\nimport os\n\ndef extract_images(pdf_path: str, output_dir: str = \"./extracted_images\"):\n os.makedirs(output_dir, exist_ok=True)\n doc = fitz.open(pdf_path)\n images = []\n for page_num, page in enumerate(doc):\n for img_index, img in enumerate(page.get_images(full=True)):\n xref = img[0]\n base_image = doc.extract_image(xref)\n image_bytes = base_image[\"image\"]\n ext = base_image[\"ext\"]\n filename = f\"{output_dir}/page{page_num+1}_img{img_index+1}.{ext}\"\n with open(filename, \"wb\") as f:\n f.write(image_bytes)\n images.append(filename)\n return images\n```\n\n## OCR Fallback (for scanned PDFs)\n\nIf `get_text()` returns empty or near-empty, the PDF is likely scanned images. Use OCR:\n\n```python\nimport fitz\nimport pytesseract\nfrom PIL import Image\nimport io\n\ndef extract_with_ocr(pdf_path: str) -> str:\n doc = fitz.open(pdf_path)\n text = []\n for page in doc:\n # Render page to image at 300 DPI\n pix = page.get_pixmap(dpi=300)\n img = Image.open(io.BytesIO(pix.tobytes(\"png\")))\n page_text = pytesseract.image_to_string(img)\n text.append(page_text)\n return \"\\n\".join(text)\n```\n\n## Auto-detect: text vs scanned\n\n```python\ndef extract_pdf(pdf_path: str) -> str:\n doc = fitz.open(pdf_path)\n # Try direct text extraction\n total_text = \"\".join(page.get_text() for page in doc)\n # If less than 50 chars per page on average, use OCR\n if len(total_text) / len(doc) < 50:\n return extract_with_ocr(pdf_path)\n return total_text\n```\n\n## Workflow\n\n1. Identify the PDF file path\n2. Try direct text extraction with pymupdf\n3. If text is sparse (< 50 chars/page average), fall back to OCR\n4. If the user needs tables, use pdfplumber\n5. If the user needs images, extract with pymupdf's image API\n6. Return structured output (text, tables, or image paths)\n\n## Common Pitfalls\n\n1. **Scanned PDFs return empty text.** `get_text()` returns `\"\"` for image-only PDFs — always check text length and fall back to OCR.\n2. **OCR is slow.** Rendering at 300 DPI and running tesseract takes 2-5 seconds per page — warn the user before running it on large PDFs.\n3. **Encrypted PDFs fail to open.** `fitz.open()` raises on password-protected PDFs — call `doc.authenticate(\"password\")` first if the password is known.\n4. **Table extraction quality varies.** pdfplumber handles bordered tables well but struggles with borderless ones — check the output before trusting it.\n5. **Large PDFs exhaust memory.** A 500-page PDF loaded whole with pymupdf can use significant RAM — process pages one at a time if memory is constrained.\n6. **Missing Tesseract language packs.** Non-English PDFs need the matching pack (e.g. `tesseract-ocr-fra`) installed and `lang='fra'` passed to `image_to_string`, or OCR silently produces garbage text.\n\n## Verification Checklist\n\n- [ ] Checked average chars/page before deciding text-extraction vs. OCR\n- [ ] Extracted text/table/image count is consistent with the source PDF's page count\n- [ ] OCR output spot-checked for garbled text when a scanned PDF was processed\n- [ ] Correct Tesseract language pack used for non-English documents\n- [ ] Password-protected PDFs authenticated successfully before extraction was attempted\n- [ ] Output files (images, extracted text/tables) saved where the user expects them\n", "readme_content": "# pdf-extract\n\nExtract text, tables, and images from PDF files — with OCR fallback for scanned documents.\n\n## What it does\n\nThe agent reads a PDF and extracts its content as structured text. For normal PDFs, it uses direct text extraction (fast). For scanned PDFs (image-only), it falls back to OCR with tesseract. It can also extract tables as structured rows and pull embedded images.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/pdf-extract/SKILL.md\n```\n\n## How to use\n\n```\n\"Extract the text from report.pdf\"\n```\n\nThe agent:\n1. Opens the PDF with pymupdf\n2. Tries direct text extraction\n3. If text is sparse (scanned PDF), falls back to OCR\n4. Returns the extracted text\n\n## What you get\n\n| Output | Method | Notes |\n|---|---|---|\n| Text | pymupdf `get_text()` | Fast, works on text-based PDFs |\n| Tables | pdfplumber `extract_tables()` | Bordered tables work best |\n| Images | pymupdf `extract_image()` | Saves to disk |\n| OCR text | tesseract via PIL | Fallback for scanned PDFs |\n\n## Example\n\n```\nUser: \"Pull the tables out of financial_report.pdf\"\n\nAgent:\n 1. Uses pdfplumber to extract tables\n 2. Finds 3 tables across 5 pages\n 3. Returns: [{\"page\": 2, \"rows\": [[\"Q1\", \"$1.2M\"], ...]}, ...]\n 4. Optionally exports to CSV\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/pdf-extract/SKILL.md" }, { "name": "ocr-documents", "category": "utility", "tier": "utility", "description": "Extract text from images and scanned documents using OCR.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/ocr-documents/SKILL.md", "path": "skills/ocr-documents", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "ocr-documents", "description": "Extract text from images and scanned documents using OCR.", "version": "1.0.0" }, "agent_use": "- The user has a screenshot or image containing text they want to extract.\n- The user has a scanned document that needs to be converted to editable text.\n- The user says \"read the text in this image\", \"OCR this scan\", or \"extract text from screenshot\".", "user_use": "The agent runs OCR (Tesseract or EasyOCR) on an image or scanned document and returns the extracted text. For poor-quality images, it preprocesses first (grayscale, contrast enhancement, upscaling) to improve accuracy. For multi-language documents, it loads the appropriate language packs.", "skillmd_content": "---\nname: ocr-documents\ndescription: Use when the user has an image, screenshot, or scanned document and wants the text extracted — via Tesseract or EasyOCR — including preprocessing low-quality scans, pulling text with bounding boxes, OCR'ing a PDF page, or handling multi-language/handwritten input.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [ocr, tesseract, easyocr, image-processing, text-extraction, pdf]\n related_skills: [pdf-extract, markdown-to-pdf]\n---\n\n# ocr-documents\n\n## Overview\n\nExtract text from images, screenshots, and scanned documents using Tesseract OCR and EasyOCR. The agent handles image preprocessing, OCR execution, and text cleanup.\n\n## When to Use\n\n- The user has a screenshot or image containing text they want to extract.\n- The user has a scanned document that needs to be converted to editable text.\n- The user says \"read the text in this image\", \"OCR this scan\", or \"extract text from screenshot\".\n\n## Prerequisites\n\n```bash\n# Tesseract (recommended for most use cases)\npip install pytesseract pillow\n# System package:\n# Linux: apt install tesseract-ocr\n# macOS: brew install tesseract\n# Windows: https://github.com/UB-Mannheim/tesseract/wiki\n\n# EasyOCR (alternative, better for handwriting/complex layouts)\npip install easyocr\n```\n\n## Basic OCR\n\n### Tesseract (fast, reliable for printed text)\n\n```python\nimport pytesseract\nfrom PIL import Image\n\ndef ocr_image(image_path: str, lang: str = \"eng\") -> str:\n img = Image.open(image_path)\n return pytesseract.image_to_string(img, lang=lang)\n```\n\n### EasyOCR (better for complex layouts, handwriting)\n\n```python\nimport easyocr\n\nreader = easyocr.Reader(['en'])\n\ndef ocr_image_easyocr(image_path: str) -> str:\n results = reader.readtext(image_path)\n return \"\\n\".join([r[1] for r in results])\n```\n\n## Image Preprocessing\n\nOCR accuracy depends heavily on image quality. Preprocess for better results:\n\n```python\nfrom PIL import Image, ImageEnhance, ImageFilter\nimport pytesseract\n\ndef ocr_with_preprocessing(image_path: str) -> str:\n img = Image.open(image_path)\n\n # Convert to grayscale\n img = img.convert('L')\n\n # Increase contrast\n enhancer = ImageEnhance.Contrast(img)\n img = enhancer.enhance(2.0)\n\n # Sharpen\n img = img.filter(ImageFilter.SHARPEN)\n\n # Upscale small images\n if img.width < 1000:\n ratio = 1000 / img.width\n img = img.resize((int(img.width * ratio), int(img.height * ratio)))\n\n return pytesseract.image_to_string(img)\n```\n\n## OCR with Bounding Boxes\n\n```python\nimport pytesseract\nfrom PIL import Image, ImageDraw\n\ndef ocr_with_boxes(image_path: str, output_path: str = \"annotated.png\"):\n img = Image.open(image_path)\n data = pytesseract.image_to_data(img, output_type=pytesseract.Output.DICT)\n\n draw = ImageDraw.Draw(img)\n for i in range(len(data[\"text\"])):\n if int(data[\"conf\"][i]) > 60: # confidence threshold\n x, y, w, h = data[\"left\"][i], data[\"top\"][i], data[\"width\"][i], data[\"height\"][i]\n draw.rectangle([x, y, x + w, y + h], outline=\"red\", width=2)\n\n img.save(output_path)\n return [data[\"text\"][i] for i in range(len(data[\"text\"])) if int(data[\"conf\"][i]) > 60]\n```\n\n## PDF Page OCR\n\n```python\nimport fitz # pymupdf\nimport pytesseract\nfrom PIL import Image\nimport io\n\ndef ocr_pdf_page(pdf_path: str, page_num: int = 0, dpi: int = 300) -> str:\n doc = fitz.open(pdf_path)\n page = doc[page_num]\n pix = page.get_pixmap(dpi=dpi)\n img = Image.open(io.BytesIO(pix.tobytes(\"png\")))\n return pytesseract.image_to_string(img)\n```\n\n## Multi-language OCR\n\n```python\n# Install language packs:\n# Linux: apt install tesseract-ocr-fra tesseract-ocr-deu tesseract-ocr-spa\n# Then:\ntext = pytesseract.image_to_string(img, lang='eng+fra+deu')\n```\n\n## Workflow\n\n1. Identify the image or document to OCR\n2. Check image quality — if low resolution or poor contrast, preprocess\n3. Run Tesseract for printed text, EasyOCR for handwriting/complex layouts\n4. Clean up the output (remove stray characters, fix common OCR errors)\n5. Return the extracted text\n\n## Common OCR Errors and Fixes\n\n| Error | Cause | Fix |\n|---|---|---|\n| Empty output | Image too small | Upscale to 1000px+ width |\n| Garbled text | Low contrast | Convert to grayscale + enhance contrast |\n| Missing text | Dark background | Invert colors: `ImageOps.invert(img)` |\n| Wrong characters | Similar-looking chars (0/O, 1/l) | Post-process with regex replacements |\n| Slow processing | High DPI | Use 300 DPI (sufficient for most text) |\n\n## Common Pitfalls\n\n1. **Tesseract path not found on Windows.** Unlike Linux/macOS, the `tesseract` binary isn't on PATH by default. Set it explicitly: `pytesseract.pytesseract.tesseract_cmd = r'C:\\Program Files\\Tesseract-OCR\\tesseract.exe'`.\n2. **Handwriting comes back garbled.** Tesseract is trained on printed text and performs poorly on handwriting. Switch to EasyOCR or TrOCR instead of trying to tune Tesseract further.\n3. **Rotated pages produce nonsense text.** Tesseract assumes horizontal text; a sideways or upside-down scan silently returns garbage rather than an error. Detect orientation first with `pytesseract.image_to_osd(img)` and rotate before running OCR.\n4. **Multi-column documents get jumbled.** Tesseract reads left-to-right, top-to-bottom across the whole page, so two-column layouts interleave lines from both columns. Use `image_to_data` with bounding boxes and sort by column (x-position) before reassembling text.\n5. **Trusting low-confidence words.** `image_to_data` returns a per-word confidence score; anything below ~50 is unreliable and should be flagged or dropped rather than trusted verbatim.\n6. **OCR-ing oversized images wastes time for no gain.** Images over 5000px take significant time with no accuracy benefit past ~2000-3000px width — resize down first.\n\n## Verification Checklist\n\n- [ ] Extracted text is non-empty and roughly matches the visible content when spot-checked against the source image\n- [ ] Low-confidence words (below ~50 via `image_to_data`) are flagged or excluded, not silently included\n- [ ] For rotated or scanned pages, orientation was checked/corrected before OCR, not assumed upright\n- [ ] For multi-column layouts, column order in the output matches reading order, not raster scan order\n- [ ] Language pack matches the actual document language (multi-language docs use `lang='eng+fra+...'` as needed)\n", "readme_content": "# ocr-documents\n\nExtract text from images, screenshots, and scanned documents using OCR.\n\n## What it does\n\nThe agent runs OCR (Tesseract or EasyOCR) on an image or scanned document and returns the extracted text. For poor-quality images, it preprocesses first (grayscale, contrast enhancement, upscaling) to improve accuracy. For multi-language documents, it loads the appropriate language packs.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/ocr-documents/SKILL.md\n```\n\n## How to use\n\n```\n\"Extract the text from this screenshot\"\n```\n\nThe agent:\n1. Opens the image\n2. Preprocesses if needed (grayscale, contrast, upscale)\n3. Runs Tesseract OCR\n4. Returns the text\n\n## Prerequisites\n\n- Tesseract OCR installed (system package)\n- Python: `pip install pytesseract pillow`\n\n## Example\n\n```\nUser: \"Read the text in this receipt photo\"\n\nAgent:\n 1. Opens receipt.jpg (800px wide, low contrast)\n 2. Preprocesses: grayscale + contrast x2 + upscale to 1000px\n 3. Runs: pytesseract.image_to_string(img)\n 4. Returns: \"Coffee Shop\\nLatte $4.50\\nMuffin $3.25\\nTotal $7.75\"\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/ocr-documents/SKILL.md" }, { "name": "gif-search", "category": "utility", "tier": "utility", "description": "Search and download GIFs from Tenor by keyword.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/gif-search/SKILL.md", "path": "skills/gif-search", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "gif-search", "description": "Search and download GIFs from Tenor by keyword.", "version": "1.0.0" }, "agent_use": "- The user wants a GIF for a message or reaction.\n- The user wants to find a specific reaction GIF (facepalm, thumbs up, celebration).\n- The user says \"find me a GIF\", \"search for a GIF\", or \"I need a reaction GIF\".", "user_use": "The agent searches Tenor's GIF library by keyword, shows you the top results, and downloads the one you pick. Useful for finding reaction GIFs for messages, social media, or documentation.", "skillmd_content": "---\nname: gif-search\ndescription: \"Use when the user wants a GIF for a message or reaction — including a specific reaction GIF (facepalm, thumbs up, celebration) — found and downloaded via the Tenor API.\"\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [tenor-api, gif-search, reaction-gif, media-download]\n related_skills: [discord-bot-build, telegram-bot-build]\n---\n\n# gif-search\n\n## Overview\n\nSearch and download animated GIFs from Tenor's API. The agent finds GIFs by keyword, previews results, and downloads the selected GIF to a local file.\n\n## When to Use\n\n- The user wants a GIF for a message or reaction.\n- The user wants to find a specific reaction GIF (facepalm, thumbs up, celebration).\n- The user says \"find me a GIF\", \"search for a GIF\", or \"I need a reaction GIF\".\n\n## Prerequisites\n\nA Tenor API key. Get one free at https://developers.google.com/tenor/guides/quickstart\n\n```bash\n# Store the key as an environment variable\nexport TENOR_API_KEY=\"your_key_here\"\n```\n\n## Search for GIFs\n\n```python\nimport requests\nimport os\n\ndef search_gifs(query: str, limit: int = 10, pos: str = \"\") -> list:\n \"\"\"Search Tenor for GIFs matching the query.\"\"\"\n api_key = os.environ.get(\"TENOR_API_KEY\", \"\")\n params = {\n \"q\": query,\n \"key\": api_key,\n \"limit\": limit,\n \"media_filter\": \"gif\",\n \"contentfilter\": \"medium\",\n }\n if pos:\n params[\"pos\"] = pos\n\n resp = requests.get(\"https://tenor.googleapis.com/v2/search\", params=params)\n resp.raise_for_status()\n data = resp.json()\n\n results = []\n for item in data.get(\"results\", []):\n gif_url = None\n for media in item.get(\"media_formats\", []):\n if media.get(\"gif\"):\n gif_url = media[\"gif\"][\"url\"]\n break\n if gif_url:\n results.append({\n \"id\": item.get(\"id\"),\n \"title\": item.get(\"title\", \"\"),\n \"url\": gif_url,\n \"preview\": item.get(\"media_formats\", [{}])[0].get(\"tinygif\", {}).get(\"url\", \"\"),\n })\n return results\n```\n\n## Download a GIF\n\n```python\ndef download_gif(url: str, output_path: str) -> str:\n \"\"\"Download a GIF to a local file.\"\"\"\n resp = requests.get(url, stream=True)\n resp.raise_for_status()\n with open(output_path, \"wb\") as f:\n for chunk in resp.iter_content(chunk_size=8192):\n f.write(chunk)\n return output_path\n```\n\n## Full Workflow\n\n```python\n# 1. Search for GIFs\nresults = search_gifs(\"facepalm\", limit=5)\n\n# 2. Show options to the user\nfor i, r in enumerate(results):\n print(f\"{i}: {r['title']} — {r['url']}\")\n\n# 3. User picks one\nchoice = 2\nselected = results[choice]\n\n# 4. Download\npath = download_gif(selected[\"url\"], \"facepalm.gif\")\nprint(f\"Downloaded to {path}\")\n```\n\n## Trending GIFs\n\n```python\ndef trending_gifs(limit: int = 10) -> list:\n \"\"\"Get currently trending GIFs.\"\"\"\n api_key = os.environ.get(\"TENOR_API_KEY\", \"\")\n params = {\"key\": api_key, \"limit\": limit, \"media_filter\": \"gif\"}\n resp = requests.get(\"https://tenor.googleapis.com/v2/featured\", params=params)\n resp.raise_for_status()\n # Same parsing as search_gifs\n ...\n```\n\n## Categories\n\n```python\ndef categories() -> list:\n \"\"\"Get available GIF categories.\"\"\"\n api_key = os.environ.get(\"TENOR_API_KEY\", \"\")\n params = {\"key\": api_key, \"type\": \"featured\"}\n resp = requests.get(\"https://tenor.googleapis.com/v2/categories\", params=params)\n return resp.json().get(\"tags\", [])\n```\n\n## Workflow\n\n1. Get the search query from the user\n2. Search Tenor with the query\n3. Present top 5-10 results (title + URL) to the user\n4. User picks one\n5. Download the selected GIF to a local file\n6. Return the file path\n\n## Common Pitfalls\n\n1. **No API key** — Without a Tenor API key, all requests fail. Get one at https://developers.google.com/tenor/guides/quickstart (free).\n2. **Content filter** — Tenor returns NSFW content by default if no filter is set. Use `contentfilter=medium` or `contentfilter=high` to keep results safe.\n3. **Rate limits** — Tenor's free tier allows ~100 requests per minute. For normal use this is plenty.\n4. **GIF size** — Full GIFs can be 5-20 MB. If you need smaller files, use the `tinygif` or `nanogif` media format instead of `gif`.\n5. **API version** — Tenor has v1 and v2 APIs. v2 is current. v1 is deprecated but still works. Always use `tenor.googleapis.com/v2/`.\n6. **Attribution** — Tenor doesn't require attribution, but linking back to the Tenor page is good practice.\n\n## Verification Checklist\n\n- [ ] `TENOR_API_KEY` is set and a test search returns results, not an auth error\n- [ ] `contentfilter` set to `medium` or `high` before showing results to the user\n- [ ] Downloaded file is a valid, non-zero-size GIF at the expected path\n- [ ] Media format chosen (`gif` vs `tinygif`/`nanogif`) matches the size constraint the use case needs\n", "readme_content": "# gif-search\n\nSearch and download GIFs from Tenor by keyword.\n\n## What it does\n\nThe agent searches Tenor's GIF library by keyword, shows you the top results, and downloads the one you pick. Useful for finding reaction GIFs for messages, social media, or documentation.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/gif-search/SKILL.md\n```\n\n## How to use\n\n```\n\"Find me a celebration GIF\"\n```\n\nThe agent:\n1. Searches Tenor for \"celebration\"\n2. Shows you 5-10 results with titles and preview URLs\n3. You pick one\n4. Downloads it to a local file\n\n## Prerequisites\n\n- A free Tenor API key from https://developers.google.com/tenor/guides/quickstart\n- Set as `TENOR_API_KEY` environment variable\n\n## Example\n\n```\nUser: \"I need a facepalm GIF\"\n\nAgent:\n 1. Searches Tenor: search_gifs(\"facepalm\", limit=5)\n 2. Returns:\n 0: Facepalm Reaction — https://media.tenor.com/...\n 1: Picard Facepalm — https://media.tenor.com/...\n 2: Animated Facepalm — https://media.tenor.com/...\n 3. User picks: 1\n 4. Downloads to facepalm.gif\n 5. Returns: \"Saved to facepalm.gif\"\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/gif-search/SKILL.md" }, { "name": "youtube-transcript", "category": "utility", "tier": "utility", "description": "Extract transcripts from YouTube videos.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/youtube-transcript/SKILL.md", "path": "skills/youtube-transcript", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "youtube-transcript", "description": "Extract transcripts from YouTube videos.", "version": "1.0.0" }, "agent_use": "- The user wants the text content of a YouTube video.\n- The user wants to search for a specific topic within a video.\n- The user wants to summarize or quote a video.\n- The user says \"get the transcript\", \"what does this video say\", or \"extract the captions\".", "user_use": "The agent fetches the caption track from a YouTube video and returns it as plain text or timestamped segments. Works with manual captions and auto-generated captions. You get the text content of a video without watching it.", "skillmd_content": "---\nname: youtube-transcript\ndescription: Use when the user wants the text content of a YouTube video — the full transcript, timestamped segments, a search for a specific topic within the video, or a summary/quote — from a URL or bare video ID.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [youtube, transcript, captions, youtube-transcript-api, video-text-extraction]\n related_skills: [csv-toolkit]\n---\n\n# youtube-transcript\n\n## Overview\n\nFetch transcripts from YouTube videos. The agent retrieves the video's caption track, cleans the text, and returns it as plain text or structured segments with timestamps.\n\n## When to Use\n\n- The user wants the text content of a YouTube video.\n- The user wants to search for a specific topic within a video.\n- The user wants to summarize or quote a video.\n- The user says \"get the transcript\", \"what does this video say\", or \"extract the captions\".\n\n## Prerequisites\n\n```bash\npip install youtube-transcript-api\n```\n\n## Basic Transcript\n\n```python\nfrom youtube_transcript_api import YouTubeTranscriptApi\n\ndef get_transcript(video_id: str, lang: str = \"en\") -> str:\n \"\"\"Get the full transcript as plain text.\"\"\"\n transcript = YouTubeTranscriptApi.get_transcript(video_id, languages=[lang])\n return \" \".join([snippet[\"text\"] for snippet in transcript])\n```\n\n## With Timestamps\n\n```python\ndef get_transcript_with_timestamps(video_id: str, lang: str = \"en\") -> list:\n \"\"\"Get transcript as a list of {start, duration, text} dicts.\"\"\"\n transcript = YouTubeTranscriptApi.get_transcript(video_id, languages=[lang])\n return [\n {\n \"start\": snippet[\"start\"],\n \"end\": snippet[\"start\"] + snippet[\"duration\"],\n \"text\": snippet[\"text\"]\n }\n for snippet in transcript\n ]\n```\n\n## Extract Video ID from URL\n\n```python\nimport re\n\ndef extract_video_id(url: str) -> str:\n patterns = [\n r\"(?:youtube\\.com/watch\\?v=|youtu\\.be/|youtube\\.com/embed/)([a-zA-Z0-9_-]{11})\",\n r\"youtube\\.com/shorts/([a-zA-Z0-9_-]{11})\",\n ]\n for pattern in patterns:\n match = re.search(pattern, url)\n if match:\n return match.group(1)\n # Maybe it's already just the ID\n if re.match(r\"^[a-zA-Z0-9_-]{11}$\", url):\n return url\n raise ValueError(f\"Could not extract video ID from: {url}\")\n```\n\n## Full Workflow\n\n```python\n# 1. Get video ID from URL\nvideo_id = extract_video_id(\"https://www.youtube.com/watch?v=dQw4w9WgXcQ\")\n\n# 2. Get transcript\ntranscript = get_transcript(video_id)\n\n# 3. Optionally save to file\nwith open(\"transcript.txt\", \"w\") as f:\n f.write(transcript)\n```\n\n## Search Within Transcript\n\n```python\ndef search_transcript(video_id: str, query: str, lang: str = \"en\") -> list:\n \"\"\"Find segments containing a query string.\"\"\"\n transcript = YouTubeTranscriptApi.get_transcript(video_id, languages=[lang])\n query_lower = query.lower()\n matches = []\n for snippet in transcript:\n if query_lower in snippet[\"text\"].lower():\n matches.append({\n \"timestamp\": snippet[\"start\"],\n \"text\": snippet[\"text\"]\n })\n return matches\n```\n\n## Translate Non-English Transcripts\n\n```python\nfrom youtube_transcript_api import YouTubeTranscriptApi\n\ndef get_transcript_any_language(video_id: str) -> str:\n \"\"\"Get transcript in any available language.\"\"\"\n transcript_list = YouTubeTranscriptApi.list_transcripts(video_id)\n for transcript in transcript_list:\n try:\n return \" \".join([s[\"text\"] for s in transcript.fetch()])\n except:\n continue\n raise ValueError(\"No transcript available in any language\")\n```\n\n## Common Pitfalls\n\n1. **Calling `get_transcript` without a fallback.** Not all videos have captions — it raises\n `NoTranscriptFound`. Call `list_transcripts` first to check what's actually available before\n assuming a fixed `lang`.\n2. **Treating auto-generated captions as manual-quality.** YouTube auto-captions are usually\n available but less accurate (misheard words, no punctuation) — don't present them as a\n verbatim transcript without noting the source.\n3. **Fetching many videos back-to-back with no delay.** YouTube rate-limits frequent requests;\n space out multiple fetches or batch jobs will start failing partway through.\n4. **Assuming `VideoUnavailable` means a transient error.** It means the video is private or\n deleted — retrying won't help; report it as unavailable to the user.\n5. **Hardcoding `languages=[\"en\"]` for non-English content.** If the requested language isn't\n present, `get_transcript` raises rather than falling back — use `list_transcripts` to discover\n available (including auto-translated) languages first.\n6. **Returning raw transcript text with HTML entities intact.** Segments can contain `&`,\n `'`, etc. — run `html.unescape()` before presenting the text.\n\n## Verification Checklist\n\n- [ ] `extract_video_id` correctly parses the actual URL format given (watch, youtu.be, embed, or\n shorts) before fetching.\n- [ ] `list_transcripts` was checked when the first `get_transcript` call fails, rather than\n immediately reporting failure to the user.\n- [ ] Returned text has no raw HTML entities (`&`, `'`) left unescaped.\n- [ ] Timestamped output (if requested) has `start`/`end` values that increase monotonically and\n cover the video's actual duration.\n", "readme_content": "# youtube-transcript\n\nExtract the transcript from any YouTube video that has captions.\n\n## What it does\n\nThe agent fetches the caption track from a YouTube video and returns it as plain text or timestamped segments. Works with manual captions and auto-generated captions. You get the text content of a video without watching it.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/youtube-transcript/SKILL.md\n```\n\n## How to use\n\n```\n\"Get the transcript from https://www.youtube.com/watch?v=...\"\n```\n\nThe agent:\n1. Extracts the video ID from the URL\n2. Fetches the transcript via the YouTube Transcript API\n3. Returns the text (optionally with timestamps)\n\n## Example\n\n```\nUser: \"What does this video say about machine learning? https://youtu.be/...\"\n\nAgent:\n 1. Extracts video ID\n 2. Gets transcript with timestamps\n 3. Searches for \"machine learning\" in the text\n 4. Returns: \"At 3:42, the speaker says: 'Machine learning is...'\"\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/youtube-transcript/SKILL.md" }, { "name": "ascii-art", "category": "utility", "tier": "utility", "description": "Generate ASCII art from text and images.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/ascii-art/SKILL.md", "path": "skills/ascii-art", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "ascii-art", "description": "Generate ASCII art from text and images.", "version": "1.0.0" }, "agent_use": "- The user wants a text banner or logo for a CLI tool or README.\n- The user wants to convert an image to ASCII art.\n- The user wants decorative ASCII for a terminal output or code comment.\n- The user says \"make ASCII art\", \"generate a banner\", or \"convert this image to ASCII\".", "user_use": "The agent creates ASCII art in three modes: text banners (pyfiglet, 500+ fonts), image-to-ASCII conversion (photo to text art), and decorative boxes/cowsay for terminal output. Useful for CLI tool headers, README decorations, and fun terminal output.", "skillmd_content": "---\nname: ascii-art\ndescription: Use when the user wants a text banner or logo for a CLI tool/README, wants an image converted to ASCII art, wants decorative ASCII for a terminal output or code comment, or explicitly says \"make ASCII art\", \"generate a banner\", or \"convert this image to ASCII\".\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [ascii-art, text-banner, image-to-ascii, pyfiglet, terminal-art]\n related_skills: [color-palette-generator, qr-code-generator]\n---\n\n# ascii-art\n\n## Overview\n\nGenerate ASCII art from text (banners, logos) and images (photo-to-ASCII conversion). The agent creates text banners, converts images to ASCII, and produces decorative art for terminals, documentation, and code comments.\n\n## When to Use\n\n- The user wants a text banner or logo for a CLI tool or README.\n- The user wants to convert an image to ASCII art.\n- The user wants decorative ASCII for a terminal output or code comment.\n- The user says \"make ASCII art\", \"generate a banner\", or \"convert this image to ASCII\".\n\n## Prerequisites\n\n```bash\npip install pyfiglet cowsay pillow\n```\n\n## Text Banners (pyfiglet)\n\n```python\nimport pyfiglet\n\ndef banner(text: str, font: str = \"standard\") -> str:\n \"\"\"Generate an ASCII art banner from text.\"\"\"\n return pyfiglet.figlet_format(text, font=font)\n```\n\n### Popular fonts\n\n```python\nfonts = [\"standard\", \"big\", \"slant\", \"shadow\", \"doom\", \"small\", \"banner3\", \"colossal\"]\n\nfor font in fonts:\n print(f\"\\n=== {font} ===\")\n print(pyfiglet.figlet_format(\"Hello\", font=font))\n```\n\n### List all available fonts\n\n```python\nimport pyfiglet\nprint(len(pyfiglet.FigletFont.getFonts())) # 500+ fonts\n```\n\n## Image to ASCII\n\n```python\nfrom PIL import Image\n\ndef image_to_ascii(image_path: str, width: int = 80, ramp: str = \" .:-=+*#%@\") -> str:\n \"\"\"Convert an image to ASCII art.\"\"\"\n img = Image.open(image_path)\n img = img.convert('L') # grayscale\n\n # Calculate height maintaining aspect ratio\n aspect = img.height / img.width\n # Terminal characters are taller than wide, so adjust\n height = int(aspect * width * 0.5)\n img = img.resize((width, height))\n\n pixels = img.getdata()\n ascii_str = \"\"\n for i, pixel in enumerate(pixels):\n idx = int(pixel / 255 * (len(ramp) - 1))\n ascii_str += ramp[idx]\n if (i + 1) % width == 0:\n ascii_str += \"\\n\"\n\n return ascii_str\n```\n\n## Cowsay\n\n```python\nimport cowsay\n\ndef say(text: str, character: str = \"cow\") -> str:\n \"\"\"Generate a cowsay message.\"\"\"\n import io, sys\n old = sys.stdout\n sys.stdout = buffer = io.StringIO()\n getattr(cowsay, character)(text)\n sys.stdout = old\n return buffer.getvalue()\n```\n\n## Boxed Text\n\n```python\ndef box_text(text: str, style: str = \"single\") -> str:\n \"\"\"Draw a box around text using Unicode box-drawing characters.\"\"\"\n lines = text.split(\"\\n\")\n max_len = max(len(line) for line in lines)\n\n styles = {\n \"single\": (\"│\", \"─\", \"┌\", \"┐\", \"└\", \"┘\"),\n \"double\": (\"║\", \"═\", \"╔\", \"╗\", \"╚\", \"╝\"),\n \"round\": (\"│\", \"─\", \"╭\", \"╮\", \"╰\", \"╯\"),\n \"ascii\": (\"|\", \"-\", \"+\", \"+\", \"+\", \"+\"),\n }\n\n v, h, tl, tr, bl, br = styles.get(style, styles[\"single\"])\n\n result = f\"{tl}{h * (max_len + 2)}{tr}\\n\"\n for line in lines:\n result += f\"{v} {line.ljust(max_len)} {v}\\n\"\n result += f\"{bl}{h * (max_len + 2)}{br}\"\n return result\n```\n\n## Workflow\n\n1. Determine what the user wants: a text banner, image-to-ASCII, or decorative art\n2. For banners: pick a font that matches the mood (big for impact, small for compact)\n3. For images: resize to terminal width, convert to grayscale, map to ASCII ramp\n4. Return the ASCII art as a string\n\n## Common Pitfalls\n\n1. **Banners too wide.** pyfiglet banners can be 100+ chars wide. Check the terminal width and pick a narrower font (small, mini, thin) for narrow terminals.\n2. **Image ASCII looks squashed or stretched.** Terminal characters are ~2x taller than wide. Adjust the aspect ratio with `height = int(aspect * width * 0.5)` — skipping this factor distorts the output.\n3. **Requested font not installed.** Not all pyfiglet fonts ship by default. Check `pyfiglet.FigletFont.getFonts()` for what's actually available before promising a specific font.\n4. **Color codes leaking into plain-text output.** ANSI color codes (`\\033[91m`) render correctly in a terminal but show as garbage escape sequences in a README or plain text file — only add color when the target is a live terminal.\n5. **Converting an oversized image directly.** A 4000px image produces an unreadable wall of characters. Resize to 80-120 chars wide before mapping to the ASCII ramp, not after.\n\n## Verification Checklist\n\n- [ ] Text banner output was actually printed/reviewed at the target line width (not assumed to fit)\n- [ ] Chosen pyfiglet font was confirmed present via `pyfiglet.FigletFont.getFonts()` before use\n- [ ] Image-to-ASCII output used the aspect-ratio correction (`* 0.5`) so the result isn't vertically stretched\n- [ ] Source image was resized to ≤120 chars wide before conversion\n- [ ] If ANSI color was added, confirmed the output target is a terminal, not a file meant to stay plain text\n", "readme_content": "# ascii-art\n\nGenerate ASCII art from text and images — banners, image conversions, and decorative text for terminals and docs.\n\n## What it does\n\nThe agent creates ASCII art in three modes: text banners (pyfiglet, 500+ fonts), image-to-ASCII conversion (photo to text art), and decorative boxes/cowsay for terminal output. Useful for CLI tool headers, README decorations, and fun terminal output.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/ascii-art/SKILL.md\n```\n\n## How to use\n\n**Text banner:**\n```\n\"Make an ASCII banner that says MY TOOL\"\n```\n\n**Image to ASCII:**\n```\n\"Convert logo.png to ASCII art\"\n```\n\nThe agent generates the ASCII art and returns it as text.\n\n## Example\n\n```\nUser: \"Make a banner for my CLI tool called DEPLOY\"\n\nAgent (using pyfiglet with 'slant' font):\n ___ __ ___ ___\n / | ____/ /________ / | / |\n / /| |/ __ / ___/ __ \\/ /| | / /| |\n / ___ / /_/ / / / /_/ / ___ | / ___ |\n/_/ |_\\__,_/_/ \\____/_/ |_|/_/ |_|\n\nReturns the ASCII banner as text.\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/ascii-art/SKILL.md" }, { "name": "excalidraw-diagram", "category": "frontend", "tier": "utility", "description": "Generate hand-drawn style diagrams as Excalidraw JSON.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/excalidraw-diagram/SKILL.md", "path": "skills/excalidraw-diagram", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "excalidraw-diagram", "description": "Generate hand-drawn style diagrams as Excalidraw JSON.", "version": "1.0.0" }, "agent_use": "- The user wants a diagram for documentation or a presentation.\n- The user wants a hand-drawn style diagram (not polished/corporate).\n- The user wants an architecture, flow, or sequence diagram.\n- The user says \"draw a diagram\", \"make an architecture diagram\", or \"visualize this flow\".", "user_use": "The agent creates Excalidraw-compatible JSON files containing boxes, arrows, circles, and labels arranged as architecture diagrams, flow charts, or sequence diagrams. Open the file in the Excalidraw editor (excalidraw.com) to view, edit, or export as PNG/SVG. The hand-drawn aesthetic makes diagrams look approachable, not corporate.", "skillmd_content": "---\nname: excalidraw-diagram\ndescription: \"Use when the user wants a hand-drawn-style diagram — architecture, flow chart, sequence diagram, or mind map — as an Excalidraw-compatible JSON file, rather than a polished, corporate-looking diagram.\"\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [excalidraw, diagrams, architecture-diagram, flowchart, hand-drawn]\n related_skills: [ascii-art, color-palette-generator]\n---\n\n# excalidraw-diagram\n\n## Overview\n\nGenerate Excalidraw-compatible JSON diagrams in a hand-drawn style. The agent creates architecture diagrams, flow charts, sequence diagrams, and mind maps as Excalidraw files that can be opened in the Excalidraw editor or exported as PNG/SVG.\n\n## When to Use\n\n- The user wants a diagram for documentation or a presentation.\n- The user wants a hand-drawn style diagram (not polished/corporate).\n- The user wants an architecture, flow, or sequence diagram.\n- The user says \"draw a diagram\", \"make an architecture diagram\", or \"visualize this flow\".\n\n## Excalidraw JSON Format\n\nExcalidraw files are JSON with an `elements` array. Each element is a shape (rectangle, ellipse, arrow, line, text).\n\n```json\n{\n \"type\": \"excalidraw\",\n \"version\": 2,\n \"source\": \"hermes\",\n \"elements\": [\n {\n \"type\": \"rectangle\",\n \"x\": 100, \"y\": 100,\n \"width\": 200, \"height\": 80,\n \"strokeColor\": \"#1e88e5\",\n \"backgroundColor\": \"transparent\",\n \"fillStyle\": \"hachure\",\n \"strokeWidth\": 2,\n \"roughness\": 1,\n \"id\": \"rect-1\"\n },\n {\n \"type\": \"text\",\n \"x\": 120, \"y\": 130,\n \"text\": \"API Server\",\n \"fontSize\": 20,\n \"fontFamily\": 1,\n \"id\": \"text-1\"\n }\n ],\n \"appState\": { \"viewBackgroundColor\": \"#ffffff\" }\n}\n```\n\n## Helper Functions\n\n### Rectangle with label\n\n```python\nimport json, uuid\n\ndef box(x, y, w, h, label, color=\"#1e88e5\"):\n \"\"\"Create a labeled rectangle element.\"\"\"\n rect_id = str(uuid.uuid4())\n text_id = str(uuid.uuid4())\n return [\n {\n \"type\": \"rectangle\", \"x\": x, \"y\": y, \"width\": w, \"height\": h,\n \"strokeColor\": color, \"backgroundColor\": \"transparent\",\n \"fillStyle\": \"hachure\", \"strokeWidth\": 2, \"roughness\": 1,\n \"id\": rect_id, \"seed\": 1\n },\n {\n \"type\": \"text\", \"x\": x + 10, \"y\": y + h/2 - 10,\n \"text\": label, \"fontSize\": 20, \"fontFamily\": 1,\n \"textAlign\": \"center\", \"id\": text_id, \"seed\": 2\n }\n ]\n\ndef arrow(x1, y1, x2, y2, label=\"\", color=\"#1e88e5\"):\n \"\"\"Create an arrow element between two points.\"\"\"\n elements = [{\n \"type\": \"arrow\", \"x\": x1, \"y\": y1,\n \"width\": x2 - x1, \"height\": y2 - y1,\n \"points\": [[0, 0], [x2 - x1, y2 - y1]],\n \"strokeColor\": color, \"strokeWidth\": 2, \"roughness\": 1,\n \"id\": str(uuid.uuid4()), \"seed\": 3\n }]\n if label:\n elements.append({\n \"type\": \"text\",\n \"x\": (x1 + x2) / 2, \"y\": (y1 + y2) / 2 - 15,\n \"text\": label, \"fontSize\": 16, \"fontFamily\": 1,\n \"id\": str(uuid.uuid4()), \"seed\": 4\n })\n return elements\n\ndef circle(x, y, r, label, color=\"#e91e63\"):\n \"\"\"Create a labeled circle.\"\"\"\n return [\n {\"type\": \"ellipse\", \"x\": x, \"y\": y, \"width\": r*2, \"height\": r*2,\n \"strokeColor\": color, \"backgroundColor\": \"transparent\",\n \"fillStyle\": \"hachure\", \"strokeWidth\": 2, \"roughness\": 1,\n \"id\": str(uuid.uuid4()), \"seed\": 5},\n {\"type\": \"text\", \"x\": x + r - len(label)*5, \"y\": y + r - 10,\n \"text\": label, \"fontSize\": 18, \"fontFamily\": 1,\n \"id\": str(uuid.uuid4()), \"seed\": 6}\n ]\n\ndef build_diagram(elements_list, filepath=\"diagram.excalidraw\"):\n \"\"\"Assemble elements into an Excalidraw file.\"\"\"\n elements = []\n for el_list in elements_list:\n elements.extend(el_list)\n diagram = {\n \"type\": \"excalidraw\", \"version\": 2, \"source\": \"hermes\",\n \"elements\": elements,\n \"appState\": {\"viewBackgroundColor\": \"#ffffff\"}\n }\n with open(filepath, \"w\") as f:\n json.dump(diagram, f, indent=2)\n return filepath\n```\n\n## Architecture Diagram Example\n\n```python\n# Client → API → Database\nelements = [\n box(100, 100, 200, 80, \"Web Client\"),\n box(400, 100, 200, 80, \"API Server\"),\n box(700, 100, 200, 80, \"PostgreSQL\"),\n arrow(300, 140, 400, 140, \"HTTP\"),\n arrow(600, 140, 700, 140, \"SQL\"),\n]\n\nbuild_diagram(elements, \"architecture.excalidraw\")\n```\n\n## Flow Chart Example\n\n```python\n# Start → Decision → (Yes: Action, No: End)\nelements = [\n circle(200, 50, 40, \"Start\"),\n box(150, 150, 200, 80, \"Process Data\"),\n box(150, 300, 200, 80, \"Valid?\"),\n box(400, 300, 200, 80, \"Save Result\"),\n circle(200, 450, 40, \"End\"),\n arrow(200, 90, 200, 150, \"\"),\n arrow(200, 230, 200, 300, \"\"),\n arrow(350, 340, 400, 340, \"Yes\"),\n arrow(200, 380, 200, 450, \"No\"),\n arrow(500, 300, 500, 200, \"\"), # loop back\n]\n\nbuild_diagram(elements, \"flowchart.excalidraw\")\n```\n\n## Opening the Diagram\n\n- Open `https://excalidraw.com` in a browser\n- File → Open → select the `.excalidraw` file\n- Or drag and drop the file onto the Excalidraw window\n\n## Common Pitfalls\n\n1. **Coordinate system** — Excalidraw uses a top-left origin, y increases downward. Plan your layout before generating elements.\n2. **Text positioning** — Text elements need manual x/y positioning. Center text by offsetting from the box: `x + width/2 - len(label) * fontSize/4`.\n3. **Arrow endpoints** — Arrows use `points` relative to the start position `x, y`. `points: [[0, 0], [dx, dy]]` draws from (x, y) to (x+dx, y+dy).\n4. **Roughness** — `roughness: 0` = clean, `roughness: 1` = hand-drawn, `roughness: 2.5` = very sketchy. Default to 1 for the hand-drawn aesthetic.\n5. **Font family** — `1` = Virgil (hand-drawn), `2` = Helvetica, `3` = Cascadia (mono). Use 1 for the Excalidraw look.\n6. **File size** — Large diagrams with many elements produce large JSON. Keep diagrams to under 50 elements for readability.\n\n## Verification Checklist\n\n- [ ] File opens without errors in https://excalidraw.com (File → Open, or drag-and-drop)\n- [ ] Every arrow visually connects its two intended shapes — no floating or misaligned endpoints\n- [ ] Text labels are fully visible, not clipped by or overlapping their container shape\n- [ ] Element count stays under ~50 for a single diagram, split into multiple files otherwise\n", "readme_content": "# excalidraw-diagram\n\nGenerate hand-drawn style diagrams as Excalidraw JSON — architecture, flow, and sequence diagrams.\n\n## What it does\n\nThe agent creates Excalidraw-compatible JSON files containing boxes, arrows, circles, and labels arranged as architecture diagrams, flow charts, or sequence diagrams. Open the file in the Excalidraw editor (excalidraw.com) to view, edit, or export as PNG/SVG. The hand-drawn aesthetic makes diagrams look approachable, not corporate.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/excalidraw-diagram/SKILL.md\n```\n\n## How to use\n\n```\n\"Draw an architecture diagram: Client → API → Database → Cache\"\n```\n\nThe agent:\n1. Plans the layout (positions, sizes)\n2. Generates Excalidraw JSON with boxes and arrows\n3. Saves as a `.excalidraw` file\n4. You open it at excalidraw.com\n\n## Example\n\n```\nUser: \"Make a flow chart for user registration: Sign up → Validate → Create account → Send email\"\n\nAgent:\n 1. Plans 4 boxes in a vertical flow with arrows\n 2. Generates diagram.excalidraw\n 3. Returns: \"Open diagram.excalidraw at https://excalidraw.com\"\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/excalidraw-diagram/SKILL.md" }, { "name": "markdown-to-pdf", "category": "utility", "tier": "utility", "description": "Convert markdown to styled PDF with syntax highlighting.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/markdown-to-pdf/SKILL.md", "path": "skills/markdown-to-pdf", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "markdown-to-pdf", "description": "Convert markdown to styled PDF with syntax highlighting.", "version": "1.0.0" }, "agent_use": "- The user wants to convert a markdown document to PDF.\n- The user wants a styled PDF report from markdown.\n- The user says \"convert this to PDF\", \"make a PDF from markdown\", or \"export as PDF\".", "user_use": "The agent converts a markdown file to PDF using weasyprint, puppeteer, or pandoc. It applies CSS styling (default, dark, or print-friendly), renders code blocks with syntax highlighting, and handles tables, images, and blockquotes. You get a professional PDF from any markdown document.", "skillmd_content": "---\nname: markdown-to-pdf\ndescription: Use when the user wants to convert a Markdown file into a styled PDF — via weasyprint, pandoc, or puppeteer — including choosing a theme (default, dark, print-friendly), embedding syntax-highlighted code, or producing a shareable report/handout from notes.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [markdown, pdf, weasyprint, pandoc, puppeteer, document-conversion]\n related_skills: [markdown-to-slides, markdown-linter, pdf-extract]\n---\n\n# markdown-to-pdf\n\n## Overview\n\nConvert markdown files to PDF with customizable styling. The agent handles markdown parsing, CSS styling, and PDF generation with options for themes, page size, and syntax highlighting.\n\n## When to Use\n\n- The user wants to convert a markdown document to PDF.\n- The user wants a styled PDF report from markdown.\n- The user says \"convert this to PDF\", \"make a PDF from markdown\", or \"export as PDF\".\n\n## Prerequisites\n\n```bash\n# Option 1: weasyprint (recommended, pure Python)\npip install weasyprint markdown\n\n# Option 2: puppeteer (Node.js, better rendering)\nnpm install puppeteer markdown-it\n\n# Option 3: pandoc (system package)\n# Linux: apt install pandoc\n# macOS: brew install pandoc\n```\n\n## Using weasyprint (Python)\n\n```python\nimport markdown\nfrom weasyprint import HTML\n\ndef md_to_pdf(md_path: str, pdf_path: str, css: str = \"\"):\n \"\"\"Convert markdown to PDF with optional CSS styling.\"\"\"\n with open(md_path, 'r') as f:\n md_content = f.read()\n\n html_body = markdown.markdown(md_content, extensions=['codehilite', 'tables', 'fenced_code'])\n\n default_css = \"\"\"\n body { font-family: 'Inter', sans-serif; max-width: 800px; margin: 40px auto; line-height: 1.6; color: #333; }\n h1 { font-size: 2em; border-bottom: 2px solid #eee; padding-bottom: 0.3em; }\n h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }\n code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: 'Fira Code', monospace; font-size: 0.9em; }\n pre { background: #f8f8f8; padding: 16px; border-radius: 5px; overflow-x: auto; }\n pre code { background: none; padding: 0; }\n table { border-collapse: collapse; width: 100%; }\n th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }\n th { background: #f4f4f4; font-weight: bold; }\n blockquote { border-left: 4px solid #ddd; margin-left: 0; padding-left: 16px; color: #666; }\n \"\"\"\n\n html = f\"\"\"\n <html><head><style>{css or default_css}</style></head>\n <body>{html_body}</body></html>\n \"\"\"\n\n HTML(string=html).write_pdf(pdf_path)\n return pdf_path\n```\n\n## Using pandoc (simplest)\n\n```bash\n# Basic conversion\npandoc input.md -o output.pdf\n\n# With a template and table of contents\npandoc input.md -o output.pdf --toc --template=eisvogel\n\n# With syntax highlighting\npandoc input.md -o output.pdf --highlight-style=tango\n```\n\n## Using puppeteer (best rendering)\n\n```javascript\nconst markdownIt = require('markdown-it');\nconst puppeteer = require('puppeteer');\nconst fs = require('fs');\n\nasync function mdToPdf(mdPath, pdfPath) {\n const md = fs.readFileSync(mdPath, 'utf-8');\n const htmlBody = markdownIt({ html: true, highlight: true }).render(md);\n\n const html = `\n <html><head>\n <style>\n body { font-family: system-ui; max-width: 800px; margin: 40px auto; padding: 20px; }\n code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; }\n pre { background: #f8f8f8; padding: 16px; border-radius: 5px; overflow-x: auto; }\n table { border-collapse: collapse; width: 100%; }\n th, td { border: 1px solid #ddd; padding: 8px; }\n </style>\n </head><body>${htmlBody}</body></html>\n `;\n\n const browser = await puppeteer.launch();\n const page = await browser.newPage();\n await page.setContent(html, { waitUntil: 'networkidle0' });\n await page.pdf({ path: pdfPath, format: 'A4', margin: { top: '1in', bottom: '1in' } });\n await browser.close();\n return pdfPath;\n}\n```\n\n## Custom Themes\n\n### Dark theme\n\n```python\nDARK_CSS = \"\"\"\nbody { background: #1a1a2e; color: #e0e0e0; font-family: 'Inter', sans-serif; max-width: 800px; margin: 40px auto; }\nh1, h2, h3 { color: #fff; border-bottom-color: #333; }\ncode { background: #16213e; color: #e94560; }\npre { background: #16213e; border: 1px solid #333; }\na { color: #e94560; }\ntable th { background: #16213e; }\ntable th, table td { border-color: #333; }\n\"\"\"\n```\n\n### Print-friendly\n\n```python\nPRINT_CSS = \"\"\"\nbody { font-family: 'Georgia', serif; max-width: none; margin: 0; font-size: 12pt; line-height: 1.5; }\nh1 { font-size: 20pt; page-break-before: always; }\nh1:first-of-type { page-break-before: avoid; }\nh2 { font-size: 16pt; }\npre, code { font-family: 'Courier New', monospace; font-size: 10pt; }\ntable { font-size: 10pt; }\n@page { margin: 1in; }\n\"\"\"\n```\n\n## Workflow\n\n1. Read the markdown file\n2. Choose the conversion method (weasyprint for Python, puppeteer for best rendering, pandoc for simplicity)\n3. Apply CSS styling (default, dark, or print-friendly)\n4. Generate the PDF\n5. Return the file path\n\n## Common Pitfalls\n\n1. **Missing system dependencies.** weasyprint needs cairo, pango, and gdk-pixbuf system libraries — it fails at import time, not at write_pdf, if these are absent. Ubuntu: `apt install libpango-1.0-0 libpangoft2-1.0-0`. macOS: `brew install pango`.\n2. **Code blocks render as plain text.** Without the `codehilite` extension (Python) or a highlight.js include (JS), fenced code loses highlighting. Install `pygments` for Python highlighting.\n3. **Images don't appear in the PDF.** Local image paths must be absolute or relative to the HTML file being rendered, not relative to the original markdown file's directory. Use `file://` URLs or embed images as base64.\n4. **Page breaks land mid-section.** Add `page-break-before: always` on `h1` only (not every heading level) or every chapter starts a new page needlessly; add `page-break-inside: avoid` on tables and code blocks so they don't split across pages.\n5. **Emoji show as boxes or blanks.** weasyprint doesn't ship a color emoji font. Install Noto Color Emoji or replace emoji with text before conversion.\n6. **PDF is unexpectedly huge.** Embedded images at original resolution bloat file size. Resize to max 1000px width and use JPEG for photos before embedding.\n\n## Verification Checklist\n\n- [ ] Output PDF file exists at the expected path and is non-zero bytes\n- [ ] Page count is plausible for the source document's length (check with `pdfinfo output.pdf` or open it)\n- [ ] Fenced code blocks show syntax highlighting, not plain monospace text\n- [ ] Every image referenced in the markdown actually renders in the PDF (no broken-image icons)\n- [ ] The chosen theme (default/dark/print) is visibly applied — colors and fonts match what was requested\n", "readme_content": "# markdown-to-pdf\n\nConvert markdown to a styled PDF with syntax highlighting, tables, and custom themes.\n\n## What it does\n\nThe agent converts a markdown file to PDF using weasyprint, puppeteer, or pandoc. It applies CSS styling (default, dark, or print-friendly), renders code blocks with syntax highlighting, and handles tables, images, and blockquotes. You get a professional PDF from any markdown document.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/markdown-to-pdf/SKILL.md\n```\n\n## How to use\n\n```\n\"Convert README.md to PDF\"\n```\n\nThe agent:\n1. Reads the markdown\n2. Renders to HTML with syntax highlighting\n3. Applies CSS styling\n4. Generates the PDF\n5. Returns the file path\n\n## Conversion methods\n\n| Method | Language | Best for |\n|---|---|---|\n| weasyprint | Python | Pure Python, no browser needed |\n| puppeteer | Node.js | Best rendering, handles CSS/JS |\n| pandoc | System | Simplest, many format options |\n\n## Example\n\n```\nUser: \"Convert my documentation to a dark-themed PDF\"\n\nAgent:\n 1. Reads docs.md\n 2. Applies DARK_CSS theme\n 3. Runs: md_to_pdf(\"docs.md\", \"docs.pdf\", css=DARK_CSS)\n 4. Returns: \"PDF saved to docs.pdf\"\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/markdown-to-pdf/SKILL.md" }, { "name": "env-config-manager", "category": "utility", "tier": "utility", "description": "Manage environment variables — create .env.example, validate, document.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/env-config-manager/SKILL.md", "path": "skills/env-config-manager", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "env-config-manager", "description": "Manage environment variables — create .env.example, validate, document.", "version": "1.0.0" }, "agent_use": "- The user is setting up a new project and needs an `.env.example`.\n- The user has a `.env` file and wants to validate it.\n- The user is deploying and wants to check all required env vars are set.\n- The user says \"set up env vars\", \"create .env.example\", or \"check my environment\".", "user_use": "The agent scans your project for environment variable usage, creates a `.env.example` template with values stripped, validates your actual `.env` file against it (reports missing and unused vars), and generates markdown documentation listing all required env vars.", "skillmd_content": "---\nname: env-config-manager\ndescription: \"Use when the user needs an `.env.example` generated for a new project, wants an existing `.env` validated against it, or is deploying and needs to confirm all required environment variables are set.\"\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [environment-variables, dotenv, env-example, config-validation, secrets]\n related_skills: [dotfiles-manage, generate-dockerfile]\n---\n\n# env-config-manager\n\n## Overview\n\nManage environment variables across projects. The agent creates `.env.example` files, validates `.env` files against the example, detects missing or unused variables, and generates documentation.\n\n## When to Use\n\n- The user is setting up a new project and needs an `.env.example`.\n- The user has a `.env` file and wants to validate it.\n- The user is deploying and wants to check all required env vars are set.\n- The user says \"set up env vars\", \"create .env.example\", or \"check my environment\".\n\n## Create .env.example\n\n```python\nimport os\nimport re\n\ndef create_env_example(env_path: str = \".env\", output_path: str = \".env.example\"):\n \"\"\"Generate .env.example from .env with values stripped.\"\"\"\n if not os.path.exists(env_path):\n # Try to find env vars referenced in code\n return create_env_example_from_code(\".\", output_path)\n\n with open(env_path, 'r') as f:\n lines = f.readlines()\n\n example_lines = []\n for line in lines:\n line = line.strip()\n if not line or line.startswith('#'):\n example_lines.append(line)\n continue\n if '=' in line:\n key = line.split('=')[0]\n example_lines.append(f\"{key}= # TODO: set value\")\n else:\n example_lines.append(line)\n\n with open(output_path, 'w') as f:\n f.write('\\n'.join(example_lines))\n return output_path\n```\n\n## Find env vars from code\n\n```python\ndef create_env_example_from_code(src_dir: str, output_path: str = \".env.example\"):\n \"\"\"Scan source code for os.environ/os.getenv references and generate .env.example.\"\"\"\n env_vars = set()\n patterns = [\n r\"os\\.environ\\.get\\(['\\\"](\\w+)['\\\"]\",\n r\"os\\.getenv\\(['\\\"](\\w+)['\\\"]\",\n r\"os\\.environ\\[['\\\"](\\w+)['\\\"]\\]\",\n ]\n\n for root, dirs, files in os.walk(src_dir):\n # Skip common ignore dirs\n dirs[:] = [d for d in dirs if d not in ['.git', 'node_modules', '__pycache__', 'venv', '.venv']]\n for filename in files:\n if filename.endswith(('.py', '.js', '.ts', '.jsx', '.tsx')):\n filepath = os.path.join(root, filename)\n with open(filepath, 'r', errors='ignore') as f:\n content = f.read()\n for pattern in patterns:\n matches = re.findall(pattern, content)\n env_vars.update(matches)\n\n with open(output_path, 'w') as f:\n for var in sorted(env_vars):\n f.write(f\"{var}= # TODO: set value\\n\")\n return output_path\n```\n\n## Validate .env\n\n```python\ndef validate_env(env_path: str = \".env\", example_path: str = \".env.example\") -> dict:\n \"\"\"Check .env against .env.example for missing/unused variables.\"\"\"\n def parse_env(path):\n if not os.path.exists(path):\n return {}\n env = {}\n with open(path, 'r') as f:\n for line in f:\n line = line.strip()\n if line and not line.startswith('#') and '=' in line:\n key = line.split('=')[0].strip()\n env[key] = True\n return env\n\n actual = parse_env(env_path)\n expected = parse_env(example_path)\n\n missing = set(expected.keys()) - set(actual.keys())\n unused = set(actual.keys()) - set(expected.keys())\n\n return {\n \"missing\": sorted(missing),\n \"unused\": sorted(unused),\n \"valid\": len(missing) == 0\n }\n```\n\n## Generate env documentation\n\n```python\ndef generate_env_docs(example_path: str = \".env.example\", output: str = \"docs/env-vars.md\"):\n \"\"\"Generate markdown documentation from .env.example.\"\"\"\n with open(example_path, 'r') as f:\n lines = f.readlines()\n\n doc = \"# Environment Variables\\n\\n\"\n doc += \"| Variable | Description | Required |\\n\"\n doc += \"|---|---|---|\\n\"\n\n for line in lines:\n line = line.strip()\n if not line or line.startswith('#'):\n continue\n if '=' in line:\n key, _, comment = line.partition('#')\n key = key.split('=')[0].strip()\n desc = comment.strip() or \"—\"\n doc += f\"| `{key}` | {desc} | Yes |\\n\"\n\n os.makedirs(os.path.dirname(output), exist_ok=True)\n with open(output, 'w') as f:\n f.write(doc)\n return output\n```\n\n## Workflow\n\n1. If `.env` exists, generate `.env.example` from it (strip values)\n2. If no `.env`, scan source code for `os.getenv` / `os.environ` references\n3. Validate the actual `.env` against `.env.example` — report missing and unused vars\n4. Generate markdown documentation listing all env vars\n\n## Common Pitfalls\n\n1. **Secrets in .env.example** — Never commit real values. The `create_env_example` function strips values, but double-check before committing.\n2. **Comment-based parsing** — Comments after values (`KEY=value # description`) may be split incorrectly. The parser handles this by splitting on `=` first.\n3. **Multi-line values** — Values with `export KEY=\"multi\\nline\"` are not handled. Use single-line values or parse quoted strings.\n4. **Code scanning misses** — `create_env_example_from_code` only finds `os.getenv` and `os.environ` patterns. Values loaded from config files or env-loading libraries (python-dotenv) won't be detected.\n5. **Unused variables** — Variables in `.env` but not in `.env.example` might be intentionally unlisted. Review before removing.\n6. **Deployed environments** — On deploy, check with `validate_env()` before starting the app. Missing required vars cause confusing runtime errors.\n\n## Verification Checklist\n\n- [ ] `.env.example` diffed against `.env` to confirm no real secret values leaked through\n- [ ] `validate_env()` returns `missing: []` before deploy\n- [ ] Generated `docs/env-vars.md` lists every variable the running app actually reads\n- [ ] Code-scan pass (`create_env_example_from_code`) cross-checked against `.env.example` for any variable it missed\n", "readme_content": "# env-config-manager\n\nManage environment variables across projects — create .env.example, validate .env, generate docs.\n\n## What it does\n\nThe agent scans your project for environment variable usage, creates a `.env.example` template with values stripped, validates your actual `.env` file against it (reports missing and unused vars), and generates markdown documentation listing all required env vars.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/env-config-manager/SKILL.md\n```\n\n## How to use\n\n```\n\"Set up environment variables for my project\"\n```\n\nThe agent:\n1. Scans source code for `os.getenv()` / `os.environ` references\n2. Creates `.env.example` with all required vars (values stripped)\n3. Checks your `.env` file for missing or unused variables\n4. Generates `docs/env-vars.md` documenting each variable\n\n## Example\n\n```\nUser: \"I'm deploying my app. Are all env vars set?\"\n\nAgent:\n 1. Scans code: finds DATABASE_URL, SECRET_KEY, API_KEY, DEBUG\n 2. Creates .env.example with those 4 vars\n 3. Validates .env:\n - missing: ['API_KEY']\n - unused: ['OLD_TOKEN']\n 4. Returns: \"Missing API_KEY. OLD_TOKEN is set but not used in code.\"\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/env-config-manager/SKILL.md" }, { "name": "http-api-tester", "category": "backend", "tier": "utility", "description": "Test HTTP APIs from the command line without Postman.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/http-api-tester/SKILL.md", "path": "skills/http-api-tester", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "http-api-tester", "description": "Test HTTP APIs from the command line without Postman.", "version": "1.0.0" }, "agent_use": "- The user wants to test an API endpoint quickly.\n- The user is debugging an API and needs to make requests.\n- The user wants to verify an API is working after deployment.\n- The user says \"test this API\", \"check this endpoint\", or \"is my API working\".", "user_use": "The agent makes HTTP requests to your API endpoints, checks the response status and body, and reports pass/fail with timing. Supports GET, POST, PUT, DELETE with auth headers, JSON bodies, and batch test suites. Useful for quick debugging, post-deploy verification, and API smoke testing.", "skillmd_content": "---\nname: http-api-tester\ndescription: Use when a user wants to test or debug an HTTP API endpoint quickly from the command line or Python, verify an API is working after deployment, or says \"test this API\" / \"check this endpoint\" / \"is my API working\" — without a Postman-style GUI.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [http, api-testing, curl, python-requests]\n related_skills: [api-test-suite, openapi-generator, webhook-receiver]\n---\n\n# http-api-tester\n\n## Overview\n\nTest HTTP APIs quickly from the command line or Python. The agent makes requests, checks responses, and reports results — no Postman or GUI needed.\n\n## When to Use\n\n- The user wants to test an API endpoint quickly.\n- The user is debugging an API and needs to make requests.\n- The user wants to verify an API is working after deployment.\n- The user says \"test this API\", \"check this endpoint\", or \"is my API working\".\n\n## Quick Tests with curl\n\n```bash\n# GET request\ncurl -s -w \"\\n%{http_code}\" https://api.example.com/users\n\n# GET with headers\ncurl -s -H \"Authorization: Bearer TOKEN\" https://api.example.com/users\n\n# POST with JSON body\ncurl -s -X POST https://api.example.com/users \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\": \"Test User\", \"email\": \"test@example.com\"}'\n\n# Check response time\ncurl -s -o /dev/null -w \"%{time_total}s\" https://api.example.com/users\n\n# Check status code only\ncurl -s -o /dev/null -w \"%{http_code}\" https://api.example.com/health\n```\n\n## Python Testing\n\n```python\nimport requests\nimport json\nfrom datetime import datetime\n\ndef test_endpoint(method, url, headers=None, body=None, expected_status=200):\n \"\"\"Test an API endpoint and return a structured result.\"\"\"\n start = datetime.now()\n try:\n resp = requests.request(\n method, url,\n headers=headers or {},\n json=body if body else None,\n timeout=30\n )\n elapsed = (datetime.now() - start).total_seconds()\n\n result = {\n \"url\": url,\n \"method\": method,\n \"status\": resp.status_code,\n \"expected\": expected_status,\n \"passed\": resp.status_code == expected_status,\n \"time_ms\": round(elapsed * 1000, 2),\n \"headers\": dict(resp.headers),\n }\n\n # Parse response body\n try:\n result[\"body\"] = resp.json()\n except:\n result[\"body\"] = resp.text[:500]\n\n return result\n except requests.exceptions.RequestException as e:\n return {\n \"url\": url,\n \"method\": method,\n \"error\": str(e),\n \"passed\": False\n }\n```\n\n## Batch Testing\n\n```python\ndef run_test_suite(tests: list) -> list:\n \"\"\"Run a suite of API tests.\"\"\"\n results = []\n for test in tests:\n result = test_endpoint(\n method=test.get(\"method\", \"GET\"),\n url=test[\"url\"],\n headers=test.get(\"headers\"),\n body=test.get(\"body\"),\n expected_status=test.get(\"expected_status\", 200)\n )\n result[\"name\"] = test.get(\"name\", test[\"url\"])\n results.append(result)\n return results\n\n# Example test suite\ntests = [\n {\"name\": \"Health check\", \"url\": \"https://api.example.com/health\", \"expected_status\": 200},\n {\"name\": \"Get users\", \"url\": \"https://api.example.com/users\", \"expected_status\": 200},\n {\"name\": \"Create user\", \"method\": \"POST\", \"url\": \"https://api.example.com/users\",\n \"body\": {\"name\": \"Test\"}, \"expected_status\": 201},\n {\"name\": \"Unauthorized\", \"url\": \"https://api.example.com/admin\", \"expected_status\": 401},\n]\n\nresults = run_test_suite(tests)\nfor r in results:\n status = \"PASS\" if r[\"passed\"] else \"FAIL\"\n print(f\"{status}: {r['name']} — {r.get('status', 'error')}\")\n```\n\n## Authentication Helpers\n\n```python\ndef with_auth(token: str) -> dict:\n \"\"\"Generate Authorization header for Bearer token.\"\"\"\n return {\"Authorization\": f\"Bearer {token}\"}\n\ndef with_basic_auth(user: str, password: str) -> dict:\n \"\"\"Generate Basic auth header.\"\"\"\n import base64\n cred = base64.b64encode(f\"{user}:{password}\".encode()).decode()\n return {\"Authorization\": f\"Basic {cred}\"}\n\ndef with_api_key(key: str, header: str = \"X-API-Key\") -> dict:\n \"\"\"Generate API key header.\"\"\"\n return {header: key}\n```\n\n## Response Inspection\n\n```python\ndef inspect_response(url: str, method: str = \"GET\", headers=None):\n \"\"\"Detailed response inspection.\"\"\"\n resp = requests.request(method, url, headers=headers or {}, timeout=30)\n\n print(f\"Status: {resp.status_code} {resp.reason}\")\n print(f\"Time: {resp.elapsed.total_seconds() * 1000:.0f}ms\")\n print(f\"Content-Type: {resp.headers.get('content-type', 'unknown')}\")\n print(f\"Content-Length: {len(resp.content)} bytes\")\n print(f\"\\nHeaders:\")\n for k, v in resp.headers.items():\n print(f\" {k}: {v}\")\n\n try:\n json_body = resp.json()\n print(f\"\\nBody (JSON):\")\n print(json.dumps(json_body, indent=2)[:1000])\n except:\n print(f\"\\nBody (text):\")\n print(resp.text[:500])\n```\n\n## Workflow\n\n1. Identify the endpoint to test\n2. Choose method (GET, POST, PUT, DELETE)\n3. Add auth headers if needed\n4. Make the request\n5. Check status code and response body\n6. Report pass/fail with timing\n\n## Common Pitfalls\n\n1. **HTTPS certificate errors.** Self-signed certs fail by default. Use `verify=False` for testing (not production): `requests.get(url, verify=False)`.\n2. **Timeout.** Default timeout is 30s. For slow APIs, increase it. For health checks, use 5s so you know quickly if something is wrong.\n3. **Rate limiting.** Rapid requests may hit rate limits. Add `time.sleep(1)` between requests if testing the same endpoint repeatedly.\n4. **Response parsing.** Not all APIs return JSON. Check `content-type` header before calling `.json()`. Fall back to `.text` for non-JSON responses.\n5. **Following redirects.** `requests` follows redirects by default. Use `allow_redirects=False` if you want to see the 3xx response itself.\n6. **Sensitive headers.** Don't log auth headers in test output. Strip them before printing or storing results.\n\n## Verification Checklist\n\n- [ ] Each test reports both status code and response time, not just pass/fail\n- [ ] Auth headers (Authorization, X-API-Key) are excluded from any logged or printed output\n- [ ] Non-2xx responses are checked against the test's `expected_status`, not assumed to be failures\n- [ ] `content-type` is checked before calling `.json()` on a response\n- [ ] A batch test suite reports pass/fail per named test, not just raw request output\n", "readme_content": "# http-api-tester\n\nTest HTTP APIs from the command line — quick verification without Postman or a GUI.\n\n## What it does\n\nThe agent makes HTTP requests to your API endpoints, checks the response status and body, and reports pass/fail with timing. Supports GET, POST, PUT, DELETE with auth headers, JSON bodies, and batch test suites. Useful for quick debugging, post-deploy verification, and API smoke testing.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/http-api-tester/SKILL.md\n```\n\n## How to use\n\n```\n\"Test my API at https://api.example.com/health\"\n```\n\nThe agent:\n1. Makes a GET request to the endpoint\n2. Checks the status code\n3. Reports: status, response time, body\n\n## Example\n\n```\nUser: \"Run a smoke test on my API after deploy\"\n\nAgent runs a test suite:\n PASS: Health check — 200 (45ms)\n PASS: Get users — 200 (120ms)\n PASS: Create user — 201 (89ms)\n PASS: Unauthorized — 401 (12ms)\n\n 4/4 passed. API is healthy.\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/http-api-tester/SKILL.md" }, { "name": "csv-toolkit", "category": "utility", "tier": "utility", "description": "Process CSV files — filter, transform, merge, and analyze.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/csv-toolkit/SKILL.md", "path": "skills/csv-toolkit", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "csv-toolkit", "description": "Process CSV files — filter, transform, merge, and analyze.", "version": "1.0.0" }, "agent_use": "- The user wants to filter or transform a CSV file.\n- The user wants to merge multiple CSVs.\n- The user wants to compute summary statistics from CSV data.\n- The user says \"process this CSV\", \"filter this data\", or \"merge these CSVs\".", "user_use": "The agent reads CSV files and performs data operations: filtering rows, transforming columns, merging multiple files, computing group-by aggregates, sorting, and deduplicating. Uses pandas for heavy operations and the built-in csv module for simple ones. You get structured data processing without opening Excel.", "skillmd_content": "---\nname: csv-toolkit\ndescription: Use when the user wants to filter or transform a CSV file, merge multiple CSVs, compute summary statistics from CSV data, or says \"process this CSV\", \"filter this data\", or \"merge these CSVs\".\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [csv, pandas, data-transformation, data-merge, aggregation]\n related_skills: [sqlite-dashboard, json-formatter]\n---\n\n# csv-toolkit\n\n## Overview\n\nProcess CSV files with Python. Filter rows, transform columns, merge files, compute aggregates, and export results. The agent handles CSV reading, manipulation, and writing without needing Excel or a database.\n\n## When to Use\n\n- The user wants to filter or transform a CSV file.\n- The user wants to merge multiple CSVs.\n- The user wants to compute summary statistics from CSV data.\n- The user says \"process this CSV\", \"filter this data\", or \"merge these CSVs\".\n\n## Prerequisites\n\n```bash\npip install pandas\n# Or for simple operations, just use the csv module (built-in)\n```\n\n## Read and Inspect\n\n```python\nimport pandas as pd\n\ndef inspect_csv(path: str) -> dict:\n \"\"\"Quick overview of a CSV file.\"\"\"\n df = pd.read_csv(path)\n return {\n \"rows\": len(df),\n \"columns\": list(df.columns),\n \"dtypes\": df.dtypes.to_dict(),\n \"head\": df.head(5).to_dict(\"records\"),\n \"null_counts\": df.isnull().sum().to_dict(),\n }\n```\n\n## Filter Rows\n\n```python\ndef filter_csv(path: str, output: str, condition: str):\n \"\"\"Filter rows using a pandas query expression.\"\"\"\n df = pd.read_csv(path)\n filtered = df.query(condition)\n filtered.to_csv(output, index=False)\n return {\"input_rows\": len(df), \"output_rows\": len(filtered), \"output\": output}\n\n# Examples:\n# filter_csv(\"data.csv\", \"filtered.csv\", \"age > 25\")\n# filter_csv(\"data.csv\", \"filtered.csv\", \"status == 'active' and revenue > 1000\")\n```\n\n## Transform Columns\n\n```python\ndef transform_csv(path: str, output: str, transforms: dict):\n \"\"\"Apply transformations to columns.\n transforms = {\"column_name\": \"new_value_expression\"}\n \"\"\"\n df = pd.read_csv(path)\n for col, expr in transforms.items():\n df[col] = df.eval(expr)\n df.to_csv(output, index=False)\n return output\n\n# Example:\n# transform_csv(\"data.csv\", \"out.csv\", {\n# \"price_usd\": \"price_eur * 1.08\",\n# \"name\": \"name.str.upper()\"\n# })\n```\n\n## Merge CSVs\n\n```python\ndef merge_csvs(files: list, output: str, on: str = None, how: str = \"outer\"):\n \"\"\"Merge multiple CSV files.\n If 'on' is None, concatenate vertically (stack rows).\n If 'on' is a column name, merge on that column (join).\n \"\"\"\n if on is None:\n # Vertical concatenation\n dfs = [pd.read_csv(f) for f in files]\n combined = pd.concat(dfs, ignore_index=True)\n else:\n # Horizontal join\n dfs = [pd.read_csv(f) for f in files]\n combined = dfs[0]\n for df in dfs[1:]:\n combined = combined.merge(df, on=on, how=how)\n combined.to_csv(output, index=False)\n return {\"output\": output, \"rows\": len(combined), \"columns\": len(combined.columns)}\n```\n\n## Aggregate / Group By\n\n```python\ndef aggregate_csv(path: str, output: str, group_by: str, agg: dict):\n \"\"\"Group by a column and compute aggregates.\n agg = {\"column\": \"function\", ...}\n \"\"\"\n df = pd.read_csv(path)\n grouped = df.groupby(group_by).agg(agg).reset_index()\n grouped.to_csv(output, index=False)\n return grouped.to_dict(\"records\")\n\n# Example:\n# aggregate_csv(\"sales.csv\", \"summary.csv\", \"region\", {\"revenue\": \"sum\", \"orders\": \"count\"})\n```\n\n## Sort and Deduplicate\n\n```python\ndef sort_csv(path: str, output: str, by: list, ascending: bool = True):\n df = pd.read_csv(path)\n df = df.sort_values(by=by, ascending=ascending)\n df.to_csv(output, index=False)\n return output\n\ndef deduplicate_csv(path: str, output: str, subset: list = None):\n df = pd.read_csv(path)\n before = len(df)\n df = df.drop_duplicates(subset=subset)\n df.to_csv(output, index=False)\n return {\"before\": before, \"after\": len(df), \"removed\": before - len(df)}\n```\n\n## Using the csv module (no pandas)\n\nFor simple operations without pandas:\n\n```python\nimport csv\n\ndef simple_filter(path: str, output: str, column: str, value: str):\n \"\"\"Filter rows where a column equals a value. No pandas needed.\"\"\"\n with open(path, 'r') as infile, open(output, 'w', newline='') as outfile:\n reader = csv.DictReader(infile)\n writer = csv.DictWriter(outfile, fieldnames=reader.fieldnames)\n writer.writeheader()\n for row in reader:\n if row[column] == value:\n writer.writerow(row)\n```\n\n## Common Pitfalls\n\n1. **UTF-8 read fails on Excel-exported CSVs.** Files saved from Excel are often Windows-1252, not UTF-8. Use `pd.read_csv(path, encoding='latin1')` if the default UTF-8 read raises a `UnicodeDecodeError`.\n2. **Loading a huge file blows up memory.** pandas reads the entire file into memory. For files over ~1GB, use the `chunksize` parameter to stream, or switch to `polars`.\n3. **Wrong delimiter assumed.** Some CSVs use semicolons or tabs instead of commas. Pass `sep=';'` explicitly, or `engine='python'` with `sep=None` for auto-detection — don't assume comma.\n4. **Unquoted commas inside fields break parsing.** pandas handles RFC-4180 quoting automatically, but the plain `csv` module needs `quoting=csv.QUOTE_MINIMAL` (or matching the source file's quoting) or embedded commas will split a field in two.\n5. **Date columns silently stay strings.** `pd.read_csv` does not parse dates by default — a \"date\" column read without `parse_dates=['date_column']` stays a string, and sort/filter operations on it behave lexicographically instead of chronologically.\n6. **NaN and empty string are not the same.** Empty cells become `NaN` in pandas, not `''`. Downstream string operations or JSON export may need `df.fillna('')` first, or `NaN` will show up as `null`/`nan` unexpectedly.\n7. **`df.eval()` transforms silently produce NaN on a typo.** A misspelled column name in a `transforms` expression doesn't always raise — check the output column for unexpected `NaN` after `transform_csv`.\n\n## Verification Checklist\n\n- [ ] `inspect_csv()` (or equivalent) was run on the output file to confirm expected row/column counts\n- [ ] Row counts before/after filtering or deduplication were compared and match expectations (no silent full-table drop)\n- [ ] Encoding was confirmed (UTF-8 succeeded, or `latin1`/other encoding was explicitly used after a decode failure)\n- [ ] Delimiter was verified against the actual file (opened a few raw lines) rather than assumed to be a comma\n- [ ] Date columns intended for sorting/filtering were parsed with `parse_dates`, not left as strings\n- [ ] Output CSV was opened/read back to confirm it's valid and matches the expected schema\n", "readme_content": "# csv-toolkit\n\nProcess CSV files — filter, transform, merge, and analyze without Excel.\n\n## What it does\n\nThe agent reads CSV files and performs data operations: filtering rows, transforming columns, merging multiple files, computing group-by aggregates, sorting, and deduplicating. Uses pandas for heavy operations and the built-in csv module for simple ones. You get structured data processing without opening Excel.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/csv-toolkit/SKILL.md\n```\n\n## How to use\n\n```\n\"Filter sales.csv to only show rows where revenue > 1000\"\n```\n\nThe agent:\n1. Reads the CSV with pandas\n2. Applies the filter: `df.query(\"revenue > 1000\")`\n3. Writes the result to a new CSV\n4. Returns the row counts\n\n## Example\n\n```\nUser: \"Merge customer.csv and orders.csv on customer_id\"\n\nAgent:\n 1. Reads both CSVs\n 2. Merges: pd.merge(customers, orders, on=\"customer_id\")\n 3. Writes merged.csv (2,500 rows, 12 columns)\n 4. Returns: \"Merged to merged.csv — 2,500 rows\"\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/csv-toolkit/SKILL.md" }, { "name": "qr-code-generator", "category": "utility", "tier": "utility", "description": "Generate QR codes for URLs, WiFi, vCards, and text.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/qr-code-generator/SKILL.md", "path": "skills/qr-code-generator", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "qr-code-generator", "description": "Generate QR codes for URLs, WiFi, vCards, and text.", "version": "1.0.0" }, "agent_use": "- The user wants a QR code for a URL.\n- The user wants to share WiFi credentials via QR.\n- The user wants a QR code for contact info (vCard).\n- The user says \"make a QR code\", \"generate a QR\", or \"create a scannable code\".", "user_use": "The agent creates QR codes from any data: URLs, WiFi credentials, vCards, or plain text. Customizable colors, size, error correction, and optional logo overlay. Output as PNG for images or SVG for scalable print.", "skillmd_content": "---\nname: qr-code-generator\ndescription: Use when the user wants a QR code generated for a URL, plain text, WiFi credentials, a vCard contact, or other custom content — triggers include \"make a QR code\", \"generate a QR\", or \"create a scannable code\".\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [qr-code, image-generation, wifi, vcard, barcodes]\n related_skills: [password-generator, color-palette-generator, invoice-generator]\n---\n\n# qr-code-generator\n\n## Overview\n\nGenerate QR codes as PNG or SVG images. The agent creates QR codes for URLs, plain text, WiFi credentials, vCards, and custom content with customizable size, color, and error correction.\n\n## When to Use\n\n- The user wants a QR code for a URL.\n- The user wants to share WiFi credentials via QR.\n- The user wants a QR code for contact info (vCard).\n- The user says \"make a QR code\", \"generate a QR\", or \"create a scannable code\".\n\n## Prerequisites\n\n```bash\npip install qrcode[pil]\n```\n\n## Basic QR Code\n\n```python\nimport qrcode\n\ndef make_qr(data: str, output: str = \"qr.png\", size: int = 10, border: int = 4):\n \"\"\"Generate a QR code PNG from any string data.\"\"\"\n qr = qrcode.QRCode(\n version=None, # auto-detect minimum size\n error_correction=qrcode.constants.ERROR_CORRECT_M,\n box_size=size,\n border=border,\n )\n qr.add_data(data)\n qr.make(fit=True)\n img = qr.make_image(fill_color=\"black\", back_color=\"white\")\n img.save(output)\n return output\n```\n\n## Custom Colors\n\n```python\ndef make_colored_qr(data: str, output: str, fill: str = \"#1a1a2e\", back: str = \"#ffffff\"):\n qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_H)\n qr.add_data(data)\n qr.make(fit=True)\n img = qr.make_image(fill_color=fill, back_color=back)\n img.save(output)\n return output\n```\n\n## SVG Output\n\n```python\nimport qrcode.svg\n\ndef make_svg_qr(data: str, output: str = \"qr.svg\"):\n factory = qrcode.svg.SvgPathImage\n qr = qrcode.QRCode(image_factory=factory)\n qr.add_data(data)\n qr.make(fit=True)\n img = qr.make_image()\n img.save(output)\n return output\n```\n\n## WiFi QR Code\n\n```python\ndef wifi_qr(ssid: str, password: str, security: str = \"WPA\", hidden: bool = False, output: str = \"wifi.png\"):\n \"\"\"Generate a QR code that auto-configures WiFi on phones.\"\"\"\n data = f\"WIFI:T:{security};S:{ssid};P:{password};H:{'true' if hidden else 'false'};;\"\n return make_qr(data, output)\n```\n\n## vCard QR Code\n\n```python\ndef vcard_qr(name: str, phone: str, email: str, org: str = \"\", output: str = \"contact.png\"):\n \"\"\"Generate a QR code with contact info.\"\"\"\n data = f\"BEGIN:VCARD\\nVERSION:3.0\\nFN:{name}\\nTEL:{phone}\\nEMAIL:{email}\\nORG:{org}\\nEND:VCARD\"\n return make_qr(data, output)\n```\n\n## URL QR Code\n\n```python\ndef url_qr(url: str, output: str = \"url.png\"):\n \"\"\"Generate a QR code for a URL.\"\"\"\n return make_qr(url, output)\n```\n\n## Error Correction Levels\n\n| Level | Recovery | Use case |\n|---|---|---|\n| L | 7% | High-density, clean environment |\n| M | 15% | Default, general use |\n| Q | 25% | Some risk of damage |\n| H | 30% | Logos overlay, dirty environments |\n\n```python\n# High error correction (allows logo overlay in center)\nqr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_H)\n```\n\n## With Logo Overlay\n\n```python\nfrom PIL import Image\n\ndef qr_with_logo(data: str, logo_path: str, output: str = \"qr_logo.png\"):\n \"\"\"Generate a QR code with a logo in the center.\"\"\"\n qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_H)\n qr.add_data(data)\n qr.make(fit=True)\n qr_img = qr.make_image(fill_color=\"black\", back_color=\"white\").convert(\"RGBA\")\n\n logo = Image.open(logo_path).convert(\"RGBA\")\n # Scale logo to ~20% of QR size\n qr_size = qr_img.size[0]\n logo_size = int(qr_size * 0.2)\n logo = logo.resize((logo_size, logo_size), Image.LANCZOS)\n\n pos = ((qr_size - logo_size) // 2, (qr_size - logo_size) // 2)\n qr_img.paste(logo, pos, logo)\n qr_img.save(output)\n return output\n```\n\n## Workflow\n\n1. Determine the content type (URL, text, WiFi, vCard)\n2. Choose format (PNG for images, SVG for print/web)\n3. Pick error correction level (M default, H for logos)\n4. Generate the QR code\n5. Return the file path\n\n## Common Pitfalls\n\n1. **Too much data.** QR codes have capacity limits — a v1 QR holds 17 alphanumeric chars, v40 holds 4,296. Long URLs produce large, dense codes that are hard to scan; shorten the URL first.\n2. **Dark on dark.** QR codes need high contrast — dark fill on a dark background won't scan. Always use dark fill on a light background.\n3. **Logo too large.** A logo covering more than ~30% of the QR code makes it unscannable — keep logos to 20% max and use error correction H.\n4. **PNG vs SVG confusion.** PNG is for screen/fixed-size print; SVG is scalable. Use SVG for billboards or large-format displays, not screen previews.\n5. **Malformed WiFi QR format.** The string must be exactly `WIFI:T:WPA;S:SSID;P:PASSWORD;;` — the trailing double semicolon is required; omitting it breaks the QR.\n6. **Unescaped special characters.** Semicolons and colons inside WiFi SSIDs/passwords must be escaped as `\\\\:` and `\\\\;` or the field boundaries break.\n\n## Verification Checklist\n\n- [ ] Generated file opens as a valid image (PNG renders, SVG parses without errors)\n- [ ] Decoded the QR (phone camera or a decoder library) to confirm the payload round-trips exactly\n- [ ] Fill/background contrast is dark-on-light\n- [ ] WiFi QR strings end in the required `;;` and escape any `:`/`;` inside SSID/password\n- [ ] Logo overlay (if used) covers ≤20-30% of the code and error correction is set to H\n- [ ] Output format (PNG vs SVG) matches the stated use case (screen vs print/large display)\n", "readme_content": "# qr-code-generator\n\nGenerate QR codes for URLs, WiFi, contact info, and any text — as PNG or SVG.\n\n## What it does\n\nThe agent creates QR codes from any data: URLs, WiFi credentials, vCards, or plain text. Customizable colors, size, error correction, and optional logo overlay. Output as PNG for images or SVG for scalable print.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/qr-code-generator/SKILL.md\n```\n\n## How to use\n\n```\n\"Make a QR code for https://example.com\"\n```\n\nThe agent generates a PNG QR code and returns the file path.\n\n## QR types\n\n| Type | Example |\n|---|---|\n| URL | `make_qr(\"https://example.com\")` |\n| WiFi | `wifi_qr(ssid=\"MyNet\", password=\"pass123\")` |\n| vCard | `vcard_qr(name=\"Jane\", phone=\"+1234\", email=\"jane@x.com\")` |\n| Text | `make_qr(\"Any text content\")` |\n\n## Example\n\n```\nUser: \"Create a WiFi QR for my guest network\"\n\nAgent:\n 1. Generates: WIFI:T:WPA;S:GuestNet;P:welcome2026;;\n 2. Creates wifi_qr.png\n 3. Returns: \"Scan wifi_qr.png to auto-connect to GuestNet\"\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/qr-code-generator/SKILL.md" }, { "name": "log-analyzer", "category": "utility", "tier": "utility", "description": "Analyze log files for errors, patterns, and anomalies.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/log-analyzer/SKILL.md", "path": "skills/log-analyzer", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "log-analyzer", "description": "Analyze log files for errors, patterns, and anomalies.", "version": "1.0.0" }, "agent_use": "- The user wants to find errors in a log file.\n- The user wants to understand what happened in a service from its logs.\n- The user wants to count error types or find the most common issues.\n- The user says \"check the logs\", \"find errors in this log\", or \"what went wrong\".", "user_use": "The agent reads a log file and produces a structured analysis: total lines, error count, error types categorized (connection failures, timeouts, auth errors, etc.), errors by hour to find spikes, and full stack trace extraction. You get a summary of what went wrong without manually scrolling through thousands of log lines.", "skillmd_content": "---\nname: log-analyzer\ndescription: Use when a user wants to find errors in a log file, understand what happened in a service from its logs, count error types, find time-based error spikes, or says \"check the logs\" / \"what went wrong\".\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [logs, error-analysis, regex, observability]\n related_skills: [ntfy-notifier, regex-tester]\n---\n\n# log-analyzer\n\n## Overview\n\nParse and analyze log files to find errors, warnings, patterns, and anomalies. The agent handles large log files, extracts structured data, counts error types, finds time-based patterns, and summarizes findings.\n\n## When to Use\n\n- The user wants to find errors in a log file.\n- The user wants to understand what happened in a service from its logs.\n- The user wants to count error types or find the most common issues.\n- The user says \"check the logs\", \"find errors in this log\", or \"what went wrong\".\n\n## Basic Error Extraction\n\n```python\nimport re\nfrom collections import Counter\n\ndef find_errors(log_path: str, patterns: list = None) -> dict:\n \"\"\"Find error and warning lines in a log file.\"\"\"\n if patterns is None:\n patterns = [\n r'\\bERROR\\b',\n r'\\bFATAL\\b',\n r'\\bCRITICAL\\b',\n r'\\bException\\b',\n r'\\bTraceback\\b',\n r'\\bpanic\\b',\n ]\n\n errors = []\n with open(log_path, 'r', errors='ignore') as f:\n for line_num, line in enumerate(f, 1):\n for pattern in patterns:\n if re.search(pattern, line, re.IGNORECASE):\n errors.append({\"line\": line_num, \"text\": line.strip(), \"pattern\": pattern})\n break\n\n return {\n \"total_errors\": len(errors),\n \"first_10\": errors[:10],\n \"last_10\": errors[-10:],\n }\n```\n\n## Error Type Counting\n\n```python\ndef count_error_types(log_path: str) -> dict:\n \"\"\"Categorize and count errors by type.\"\"\"\n error_patterns = {\n \"connection_refused\": r\"Connection.*refused|ECONNREFUSED\",\n \"timeout\": r\"timeout|timed out|ETIMEDOUT\",\n \"not_found\": r\"not found|404|ENOENT\",\n \"permission_denied\": r\"permission denied|EACCES|403\",\n \"out_of_memory\": r\"out of memory|ENOMEM|OOM\",\n \"auth_failure\": r\"auth.*fail|unauthorized|401\",\n \"rate_limit\": r\"rate limit|429|throttl\",\n \"ssl_error\": r\"SSL|certificate|TLS\",\n }\n\n counts = Counter()\n with open(log_path, 'r', errors='ignore') as f:\n for line in f:\n for error_type, pattern in error_patterns.items():\n if re.search(pattern, line, re.IGNORECASE):\n counts[error_type] += 1\n\n return dict(counts.most_common())\n```\n\n## Time-based Analysis\n\n```python\nfrom datetime import datetime\nfrom collections import defaultdict\n\ndef errors_by_hour(log_path: str, timestamp_pattern: str = r'\\[(\\d{4}-\\d{2}-\\d{2}T\\d{2}):\\d{2}:\\d{2}'):\n \"\"\"Count errors per hour to find spikes.\"\"\"\n hourly = defaultdict(int)\n with open(log_path, 'r', errors='ignore') as f:\n for line in f:\n if re.search(r'ERROR|FATAL|Exception', line, re.IGNORECASE):\n match = re.search(timestamp_pattern, line)\n if match:\n hourly[match.group(1)] += 1\n\n return dict(sorted(hourly.items()))\n```\n\n## Extract Stack Traces\n\n```python\ndef extract_tracebacks(log_path: str) -> list:\n \"\"\"Extract full stack traces from a log file.\"\"\"\n tracebacks = []\n current_trace = []\n in_trace = False\n\n with open(log_path, 'r', errors='ignore') as f:\n for line in f:\n if 'Traceback' in line or 'panic:' in line:\n if current_trace:\n tracebacks.append('\\n'.join(current_trace))\n current_trace = [line]\n in_trace = True\n elif in_trace:\n if line.strip() == '' or (not line.startswith(' ') and not line.startswith('\\t') and not line.startswith('File') and not line.startswith(' ')):\n tracebacks.append('\\n'.join(current_trace))\n current_trace = []\n in_trace = False\n else:\n current_trace.append(line)\n\n if current_trace:\n tracebacks.append('\\n'.join(current_trace))\n\n return tracebacks\n```\n\n## Tail with Filtering\n\n```python\ndef tail_filter(log_path: str, keyword: str, lines: int = 50):\n \"\"\"Get the last N lines matching a keyword.\"\"\"\n matching = []\n with open(log_path, 'r', errors='ignore') as f:\n for line in f:\n if keyword.lower() in line.lower():\n matching.append(line.strip())\n if len(matching) > lines:\n matching.pop(0)\n return matching\n```\n\n## Summary Report\n\n```python\ndef log_summary(log_path: str) -> dict:\n \"\"\"Generate a comprehensive log summary.\"\"\"\n total_lines = 0\n error_count = 0\n warn_count = 0\n info_count = 0\n\n with open(log_path, 'r', errors='ignore') as f:\n for line in f:\n total_lines += 1\n if re.search(r'\\bERROR\\b|\\bFATAL\\b', line, re.IGNORECASE):\n error_count += 1\n elif re.search(r'\\bWARN', line, re.IGNORECASE):\n warn_count += 1\n elif re.search(r'\\bINFO\\b', line, re.IGNORECASE):\n info_count += 1\n\n return {\n \"file\": log_path,\n \"total_lines\": total_lines,\n \"errors\": error_count,\n \"warnings\": warn_count,\n \"info\": info_count,\n \"error_rate\": f\"{error_count / total_lines * 100:.2f}%\" if total_lines > 0 else \"0%\",\n \"error_types\": count_error_types(log_path),\n }\n```\n\n## Workflow\n\n1. Identify the log file(s) to analyze\n2. Run `log_summary` for a quick overview\n3. Run `count_error_types` to categorize errors\n4. Run `errors_by_hour` to find time-based spikes\n5. Extract specific tracebacks or filtered lines for detail\n6. Report findings: what errors, how many, when, and what types\n\n## Common Pitfalls\n\n1. **Large log files.** Reading a 10GB log file line by line into memory will crash. Use the line-by-line iterators (as shown above) — they're memory-efficient. For extremely large files, use `grep` first to pre-filter.\n2. **Timestamp format varies.** The default regex assumes ISO 8601. Adjust the pattern for your log format (e.g., `r'(\\d{2}:\\d{2}):\\d{2}'` for `HH:MM:SS`).\n3. **Multi-line stack traces.** Error lines span multiple lines. The traceback extractor handles this, but simple line-by-line error counting may miss context.\n4. **Rotated logs.** If logs rotate (`app.log.1`, `app.log.2`), analyze all of them. Use `glob.glob(\"app.log*\")` to find all rotation files.\n5. **Encoding.** Some logs use non-UTF-8 encoding. Use `errors='ignore'` to skip bad bytes, or detect encoding with `chardet`.\n6. **False positives.** The word \"error\" can appear in non-error contexts (e.g., \"error handling module\"). Refine patterns to match your log format.\n\n## Verification Checklist\n\n- [ ] `log_summary`'s error/warning/info counts are consistent with `total_lines`\n- [ ] All log rotation files (`app.log.1`, `app.log.2`, ...) were included, not just the current file\n- [ ] The timestamp regex was checked against the actual log's format before trusting `errors_by_hour` output\n- [ ] A sample of flagged \"error\" lines was reviewed for false positives (e.g. \"error handling module\")\n- [ ] Encoding errors were handled (`errors='ignore'` or detected) rather than the analysis crashing mid-file\n", "readme_content": "# log-analyzer\n\nParse log files to find errors, count error types, detect spikes, and extract stack traces.\n\n## What it does\n\nThe agent reads a log file and produces a structured analysis: total lines, error count, error types categorized (connection failures, timeouts, auth errors, etc.), errors by hour to find spikes, and full stack trace extraction. You get a summary of what went wrong without manually scrolling through thousands of log lines.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/log-analyzer/SKILL.md\n```\n\n## How to use\n\n```\n\"Check app.log for errors and tell me what went wrong\"\n```\n\nThe agent:\n1. Runs a log summary (total lines, errors, warnings)\n2. Categorizes errors by type\n3. Shows errors by hour to find spikes\n4. Extracts the most recent stack traces\n5. Reports: \"23 errors, mostly timeouts between 2-4 AM\"\n\n## Example\n\n```\nUser: \"Why did my service crash last night?\"\n\nAgent:\n 1. Analyzes service.log (450k lines)\n 2. Finds 15 ERROR lines, 3 FATAL\n 3. Error types: 8 timeouts, 4 connection refused, 3 OOM\n 4. Spikes at 3:00-3:15 AM\n 5. Extracts traceback: \"OutOfMemoryError: heap space\"\n 6. Returns: \"Service ran out of memory at 3 AM. 8 timeouts cascaded from the OOM event.\"\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/log-analyzer/SKILL.md" }, { "name": "password-generator", "category": "utility", "tier": "utility", "description": "Generate secure passwords, passphrases, and API keys.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/password-generator/SKILL.md", "path": "skills/password-generator", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "password-generator", "description": "Generate secure passwords, passphrases, and API keys.", "version": "1.0.0" }, "agent_use": "- The user needs a strong password.\n- The user wants a memorable passphrase.\n- The user needs an API key or token.\n- The user says \"generate a password\", \"make me a secure password\", or \"create an API key\".", "user_use": "The agent generates cryptographically secure credentials using Python's `secrets` module. Three modes: random passwords (configurable length and character set), memorable passphrases (random words joined by dashes), and API keys (alphanumeric tokens with optional prefix). Includes a strength checker.", "skillmd_content": "---\nname: password-generator\ndescription: Use when the user needs a strong password, a memorable passphrase, an API key/token, or a hex/URL-safe token generated — cryptographically secure via Python's `secrets` module, with optional strength checking or batch generation.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [password, passphrase, secrets, api-key, token-generation, security]\n related_skills: [env-config-manager]\n---\n\n# password-generator\n\n## Overview\n\nGenerate secure passwords, passphrases, and API keys. The agent creates random passwords with configurable complexity, memorable passphrases from word lists, and API tokens with specific character sets.\n\n## When to Use\n\n- The user needs a strong password.\n- The user wants a memorable passphrase.\n- The user needs an API key or token.\n- The user says \"generate a password\", \"make me a secure password\", or \"create an API key\".\n\n## Secure Password\n\n```python\nimport secrets\nimport string\n\ndef generate_password(length: int = 16, uppercase: bool = True, lowercase: bool = True, digits: bool = True, symbols: bool = True, exclude_similar: bool = False) -> str:\n \"\"\"Generate a cryptographically secure random password.\"\"\"\n chars = \"\"\n if lowercase:\n chars += string.ascii_lowercase\n if uppercase:\n chars += string.ascii_uppercase\n if digits:\n chars += string.digits\n if symbols:\n chars += \"!@#$%^&*()_+-=[]{}|;:,.<>?\"\n\n if exclude_similar:\n chars = chars.replace(\"0\", \"\").replace(\"O\", \"\").replace(\"l\", \"\").replace(\"1\", \"\").replace(\"I\", \"\")\n\n # Ensure at least one of each requested type\n password = []\n required = []\n if lowercase:\n required.append(secrets.choice(string.ascii_lowercase))\n if uppercase:\n required.append(secrets.choice(string.ascii_uppercase))\n if digits:\n required.append(secrets.choice(string.digits))\n if symbols:\n required.append(secrets.choice(\"!@#$%^&*()_+-=[]{}|;:,.<>?\"))\n\n remaining = length - len(required)\n password = required + [secrets.choice(chars) for _ in range(remaining)]\n\n # Shuffle\n secrets.SystemRandom().shuffle(password)\n return ''.join(password)\n```\n\n## Passphrase (memorable)\n\n```python\nimport secrets\n\nWORD_LIST = [\n \"apple\", \"brave\", \"cloud\", \"dance\", \"eagle\", \"flame\", \"grace\", \"heart\",\n \"ivory\", \"jungle\", \"kneel\", \"lemon\", \"maple\", \"noble\", \"ocean\", \"pearl\",\n \"quest\", \"river\", \"storm\", \"trust\", \"unity\", \"vivid\", \"whisper\", \"xenon\",\n \"yacht\", \"zebra\", \"anchor\", \"bloom\", \"creek\", \"dawn\", \"ember\", \"frost\",\n \"globe\", \"haven\", \"ideal\", \"jewel\", \"karma\", \"lotus\", \"mint\", \"north\",\n]\n\ndef generate_passphrase(words: int = 4, separator: str = \"-\", capitalize: bool = True, add_number: bool = True) -> str:\n \"\"\"Generate a memorable passphrase from random words.\"\"\"\n selected = [secrets.choice(WORD_LIST) for _ in range(words)]\n if capitalize:\n selected = [w.capitalize() for w in selected]\n if add_number:\n selected.append(str(secrets.randbelow(100)))\n return separator.join(selected)\n```\n\n## API Key / Token\n\n```python\ndef generate_api_key(length: int = 32, prefix: str = \"\") -> str:\n \"\"\"Generate a random API key.\"\"\"\n chars = string.ascii_letters + string.digits\n key = ''.join(secrets.choice(chars) for _ in range(length))\n if prefix:\n return f\"{prefix}_{key}\"\n return key\n```\n\n## Hex Token\n\n```python\ndef generate_hex_token(bytes_len: int = 32) -> str:\n \"\"\"Generate a hex token (e.g., for OAuth).\"\"\"\n return secrets.token_hex(bytes_len)\n```\n\n## URL-safe Token\n\n```python\ndef generate_urlsafe_token(bytes_len: int = 32) -> str:\n \"\"\"Generate a URL-safe token.\"\"\"\n return secrets.token_urlsafe(bytes_len)\n```\n\n## Password Strength Check\n\n```python\nimport re\n\ndef check_strength(password: str) -> dict:\n \"\"\"Evaluate password strength.\"\"\"\n score = 0\n checks = {\n \"length_12+\": len(password) >= 12,\n \"length_16+\": len(password) >= 16,\n \"has_lowercase\": bool(re.search(r'[a-z]', password)),\n \"has_uppercase\": bool(re.search(r'[A-Z]', password)),\n \"has_digit\": bool(re.search(r'\\d', password)),\n \"has_symbol\": bool(re.search(r'[!@#$%^&*()_+\\-=\\[\\]{}|;:,.<>?]', password)),\n \"no_common_patterns\": not re.search(r'(123|abc|qwe|password|admin)', password, re.IGNORECASE),\n }\n\n for check in checks.values():\n if check:\n score += 1\n\n if score >= 7:\n rating = \"strong\"\n elif score >= 5:\n rating = \"moderate\"\n else:\n rating = \"weak\"\n\n return {\"score\": score, \"rating\": rating, \"checks\": checks}\n```\n\n## Batch Generation\n\n```python\ndef generate_batch(count: int = 10, length: int = 16) -> list:\n \"\"\"Generate multiple passwords at once.\"\"\"\n return [generate_password(length) for _ in range(count)]\n```\n\n## Workflow\n\n1. Determine what the user needs: password, passphrase, or API key\n2. For passwords: confirm length and character requirements\n3. For passphrases: confirm word count and separator\n4. For API keys: confirm length and prefix\n5. Generate using `secrets` module (cryptographically secure)\n6. Optionally check strength\n7. Return the credential(s)\n\n## Common Pitfalls\n\n1. **Using `random` instead of `secrets`.** The `random` module is not cryptographically secure — its output is predictable given enough samples. Always use `secrets` (Python 3.6+) for anything security-sensitive.\n2. **Confusable characters in manually-typed passwords.** Characters like `0/O` and `l/1/I` cause transcription errors when a human has to type the password. Use `exclude_similar=True` for passwords meant to be typed rather than pasted.\n3. **Short passphrases feel secure but aren't.** A 2-word passphrase has less entropy than a 12-character random password despite looking longer. Use at least 4 words from a reasonably large list for adequate entropy.\n4. **Small word list caps entropy regardless of word count.** The built-in `WORD_LIST` here has only 40 words — even a 4-word passphrase from it has far less entropy than 4 words from EFF's 7776-word list. Swap in a larger list for anything beyond casual use.\n5. **Passing generated passwords as CLI arguments.** Command-line arguments are visible to any other process via `ps` or `/proc`. Write the password to a file, stdin, or return it directly instead.\n6. **Logging generated credentials.** Never write a generated password/token to a log file or persistent history — print or return it once and let the user capture it themselves.\n\n## Verification Checklist\n\n- [ ] Generation used `secrets` (not `random`) for every code path that touches password/token output\n- [ ] Generated password satisfies every requested character-class constraint (contains at least one of each requested type)\n- [ ] Passphrase word count and entropy are adequate for the stated use (4+ words for anything beyond a throwaway)\n- [ ] No generated credential appears in a log file, shell history, or process argument list\n- [ ] If strength-checked, `check_strength()` rating matches the complexity actually requested (a \"weak\" result on a supposedly strong password flags a bug in the generation parameters)\n", "readme_content": "# password-generator\n\nGenerate secure passwords, memorable passphrases, and API keys.\n\n## What it does\n\nThe agent generates cryptographically secure credentials using Python's `secrets` module. Three modes: random passwords (configurable length and character set), memorable passphrases (random words joined by dashes), and API keys (alphanumeric tokens with optional prefix). Includes a strength checker.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/password-generator/SKILL.md\n```\n\n## How to use\n\n```\n\"Generate a strong 20-character password\"\n```\n\nThe agent generates a password with mixed case, digits, and symbols, then checks its strength.\n\n## Types\n\n| Type | Example | Best for |\n|---|---|---|\n| Password | `K7#m@xL9!pQ4$vB2` | Accounts, databases |\n| Passphrase | `Ocean-Storm-Vivid-Maple-42` | Master passwords |\n| API key | `sk_aB3dE9fG2hI5jK8` | Service tokens |\n| Hex token | `a3f5b8c1d2e4...` | OAuth, sessions |\n\n## Example\n\n```\nUser: \"I need a memorable master password\"\n\nAgent:\n 1. Generates: generate_passphrase(words=5, separator=\"-\", add_number=True)\n 2. Returns: \"Haven-Globe-Ember-Noble-Creek-73\"\n 3. Strength: strong (7/7 checks passed)\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/password-generator/SKILL.md" }, { "name": "json-formatter", "category": "utility", "tier": "utility", "description": "Format, validate, minify, and transform JSON.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/json-formatter/SKILL.md", "path": "skills/json-formatter", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "json-formatter", "description": "Format, validate, minify, and transform JSON.", "version": "1.0.0" }, "agent_use": "- The user has messy or minified JSON that needs formatting.\n- The user wants to validate a JSON file.\n- The user wants to extract specific fields from a large JSON.\n- The user says \"format this JSON\", \"pretty print this\", or \"validate my JSON\".", "user_use": "The agent takes messy or minified JSON and makes it clean: pretty-prints with indentation, validates and reports errors with line/column numbers, fixes common issues (trailing commas, single quotes, comments), extracts specific fields using dot notation, and converts JSON arrays to CSV.", "skillmd_content": "---\nname: json-formatter\ndescription: Use when a user has messy or minified JSON that needs formatting, wants to validate a JSON file, extract specific fields from nested JSON, or convert a JSON array of objects to CSV.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [json, validation, jsonpath, csv-conversion]\n related_skills: [csv-toolkit, regex-tester]\n---\n\n# json-formatter\n\n## Overview\n\nFormat, validate, minify, and transform JSON. The agent handles malformed JSON, pretty-prints messy output, extracts specific fields with dot-notation paths, and converts between JSON and other formats.\n\n## When to Use\n\n- The user has messy or minified JSON that needs formatting.\n- The user wants to validate a JSON file.\n- The user wants to extract specific fields from a large JSON.\n- The user says \"format this JSON\", \"pretty print this\", or \"validate my JSON\".\n\n## Pretty Print\n\n```python\nimport json\n\ndef pretty_print(json_str: str, indent: int = 2) -> str:\n \"\"\"Format JSON with indentation.\"\"\"\n data = json.loads(json_str)\n return json.dumps(data, indent=indent, ensure_ascii=False)\n\ndef pretty_print_file(input_path: str, output_path: str = None, indent: int = 2):\n \"\"\"Format a JSON file in place or to a new file.\"\"\"\n with open(input_path, 'r') as f:\n data = json.load(f)\n out = output_path or input_path\n with open(out, 'w') as f:\n json.dump(data, f, indent=indent, ensure_ascii=False)\n return out\n```\n\n## Minify\n\n```python\ndef minify_json(json_str: str) -> str:\n \"\"\"Remove all whitespace from JSON.\"\"\"\n data = json.loads(json_str)\n return json.dumps(data, separators=(',', ':'), ensure_ascii=False)\n```\n\n## Validate\n\n```python\ndef validate_json(json_str: str) -> dict:\n \"\"\"Validate JSON and return error details if invalid.\"\"\"\n try:\n json.loads(json_str)\n return {\"valid\": True}\n except json.JSONDecodeError as e:\n return {\n \"valid\": False,\n \"error\": str(e),\n \"line\": e.lineno,\n \"column\": e.colno,\n \"position\": e.pos,\n \"context\": json_str[max(0, e.pos-20):e.pos+20] if e.pos else \"\"\n }\n```\n\n## Extract Fields\n\n```python\ndef extract_fields(data, paths: list):\n \"\"\"Extract specific fields from nested JSON using dot notation.\n paths = [\"user.name\", \"user.email\", \"items.0.title\"]\n \"\"\"\n def get_nested(obj, path):\n keys = path.split('.')\n current = obj\n for key in keys:\n if isinstance(current, list):\n try:\n current = current[int(key)]\n except (ValueError, IndexError):\n return None\n elif isinstance(current, dict):\n current = current.get(key)\n else:\n return None\n if current is None:\n return None\n return current\n\n if isinstance(data, str):\n data = json.loads(data)\n\n return {path: get_nested(data, path) for path in paths}\n```\n\n## JSON to CSV\n\n```python\nimport csv\n\ndef json_to_csv(json_path: str, csv_path: str, record_path: str = None):\n \"\"\"Convert a JSON array of objects to CSV.\"\"\"\n with open(json_path, 'r') as f:\n data = json.load(f)\n\n if record_path:\n # Navigate to the array\n for key in record_path.split('.'):\n data = data[key]\n\n if not isinstance(data, list):\n raise ValueError(\"JSON must be an array of objects\")\n\n # Collect all field names\n fieldnames = set()\n for record in data:\n fieldnames.update(record.keys())\n fieldnames = sorted(fieldnames)\n\n with open(csv_path, 'w', newline='') as f:\n writer = csv.DictWriter(f, fieldnames=fieldnames)\n writer.writeheader()\n for record in data:\n writer.writerow({k: record.get(k, '') for k in fieldnames})\n return csv_path\n```\n\n## Fix Common JSON Errors\n\n```python\ndef fix_json(json_str: str) -> str:\n \"\"\"Attempt to fix common JSON formatting errors.\"\"\"\n # Remove trailing commas\n json_str = re.sub(r',\\s*([}\\]])', r'\\1', json_str)\n # Replace single quotes with double quotes\n json_str = json_str.replace(\"'\", '\"')\n # Remove comments (// and /* */)\n json_str = re.sub(r'//.*?$', '', json_str, flags=re.MULTILINE)\n json_str = re.sub(r'/\\*.*?\\*/', '', json_str, flags=re.DOTALL)\n return json_str\n\nimport re\n```\n\n## Workflow\n\n1. Read the JSON (string or file)\n2. If validation fails, try `fix_json` to fix common errors\n3. Pretty-print with 2-space indent\n4. If extracting fields, use dot-notation paths\n5. If converting to CSV, flatten the array\n6. Return the formatted/validated/extracted result\n\n## Common Pitfalls\n\n1. **Trailing commas.** Standard JSON doesn't allow trailing commas. `fix_json` removes them, but validate first to know if there's an issue.\n2. **Single quotes.** JSON requires double quotes. JSON5 allows single quotes, but standard parsers reject them.\n3. **Comments in JSON.** Standard JSON doesn't allow comments. JSONC and JSON5 do. `fix_json` strips comments for standard compatibility.\n4. **Large JSON files.** `json.load()` loads the entire file into memory. For files over 100MB, use `ijson` for streaming parsing.\n5. **Unicode.** Use `ensure_ascii=False` to keep Unicode characters readable. With `ensure_ascii=True` (default), they become `\\uXXXX` escapes.\n6. **Nested arrays.** `json_to_csv` only flattens one level. Deeply nested objects need manual flattening before CSV conversion.\n\n## Verification Checklist\n\n- [ ] `validate_json` reports valid before any downstream transform (extract/CSV/minify) is trusted\n- [ ] Output uses `ensure_ascii=False` when Unicode readability matters\n- [ ] `fix_json` output is re-validated with `json.loads` before being treated as fixed\n- [ ] CSV conversion confirms the source resolves to a flat array of objects (not nested) before running\n- [ ] Files over 100MB use a streaming parser (`ijson`), not `json.load`\n", "readme_content": "# json-formatter\n\nFormat, validate, minify, and transform JSON — pretty-print, fix errors, extract fields, convert to CSV.\n\n## What it does\n\nThe agent takes messy or minified JSON and makes it clean: pretty-prints with indentation, validates and reports errors with line/column numbers, fixes common issues (trailing commas, single quotes, comments), extracts specific fields using dot notation, and converts JSON arrays to CSV.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/json-formatter/SKILL.md\n```\n\n## How to use\n\n```\n\"Format this JSON file\"\n```\n\nThe agent:\n1. Reads the JSON\n2. Validates it — reports any errors with position\n3. Pretty-prints with 2-space indentation\n4. Writes the formatted output\n\n## Example\n\n```\nUser: \"This API response is minified. Make it readable.\"\n\nAgent:\n 1. Reads the minified JSON\n 2. Pretty-prints: json.dumps(data, indent=2)\n 3. Returns formatted JSON with proper indentation and line breaks\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/json-formatter/SKILL.md" }, { "name": "regex-tester", "category": "utility", "tier": "utility", "description": "Test and debug regular expressions with match highlighting.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/regex-tester/SKILL.md", "path": "skills/regex-tester", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "regex-tester", "description": "Test and debug regular expressions with match highlighting.", "version": "1.0.0" }, "agent_use": "- The user wants to test a regex pattern against text.\n- The user needs help writing a regex for a specific pattern.\n- The user has a regex that isn't matching correctly.\n- The user says \"test this regex\", \"write a regex for\", or \"why isn't my regex working\".", "user_use": "The agent tests regex patterns against sample text and shows exactly what matches (with positions, groups, and highlighted output). Can also build patterns from natural language descriptions, explain what a pattern does component-by-component, and replace text using regex.", "skillmd_content": "---\nname: regex-tester\ndescription: Use when the user wants to test a regex pattern against sample text, needs a regex built from a natural-language description, or has a regex that isn't matching as expected — triggers include \"test this regex\", \"write a regex for\", or \"why isn't my regex working\".\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [regex, pattern-matching, text-processing, debugging]\n related_skills: [json-formatter, log-analyzer, markdown-linter]\n---\n\n# regex-tester\n\n## Overview\n\nTest, debug, and build regular expressions. The agent creates regex patterns from descriptions, tests them against sample text, explains what they match, and helps fix patterns that don't work as expected.\n\n## When to Use\n\n- The user wants to test a regex pattern against text.\n- The user needs help writing a regex for a specific pattern.\n- The user has a regex that isn't matching correctly.\n- The user says \"test this regex\", \"write a regex for\", or \"why isn't my regex working\".\n\n## Test a Regex\n\n```python\nimport re\n\ndef test_regex(pattern: str, text: str, flags: list = None) -> dict:\n \"\"\"Test a regex pattern against text and return detailed results.\"\"\"\n flag = 0\n if flags:\n if 'i' in flags: flag |= re.IGNORECASE\n if 'm' in flags: flag |= re.MULTILINE\n if 's' in flags: flag |= re.DOTALL\n if 'x' in flags: flag |= re.VERBOSE\n\n try:\n compiled = re.compile(pattern, flag)\n except re.error as e:\n return {\"valid\": False, \"error\": str(e), \"pattern\": pattern}\n\n matches = list(compiled.finditer(text))\n\n return {\n \"valid\": True,\n \"pattern\": pattern,\n \"match_count\": len(matches),\n \"matches\": [\n {\n \"match\": m.group(0),\n \"start\": m.start(),\n \"end\": m.end(),\n \"groups\": m.groups(),\n \"named_groups\": m.groupdict(),\n }\n for m in matches\n ],\n \"highlighted\": highlight_matches(text, matches),\n }\n\ndef highlight_matches(text: str, matches) -> str:\n \"\"\"Return text with matches wrapped in markers.\"\"\"\n result = []\n last_end = 0\n for m in matches:\n result.append(text[last_end:m.start()])\n result.append(f\"[{m.group(0)}]\")\n last_end = m.end()\n result.append(text[last_end:])\n return ''.join(result)\n```\n\n## Common Patterns\n\n```python\nPATTERNS = {\n \"email\": r\"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}\",\n \"url\": r\"https?://[^\\s<>\"']+[^\\s<>\"'.]\",\n \"ipv4\": r\"\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b\",\n \"ipv6\": r\"(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}\",\n \"phone_us\": r\"\\+?1?[-.\\s]?\\(?(\\d{3})\\)?[-.\\s]?(\\d{3})[-.\\s]?(\\d{4})\",\n \"date_iso\": r\"\\d{4}-\\d{2}-\\d{2}\",\n \"date_us\": r\"\\d{1,2}/\\d{1,2}/\\d{2,4}\",\n \"time\": r\"\\d{1,2}:\\d{2}(?::\\d{2})?(?:\\s?[AP]M)?\",\n \"uuid\": r\"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\",\n \"hex_color\": r\"#[0-9a-fA-F]{6}\\b\",\n \"credit_card\": r\"\\b(?:\\d[ -]*?){13,16}\\b\",\n \"zipcode_us\": r\"\\b\\d{5}(?:-\\d{4})?\\b\",\n \"semver\": r\"\\d+\\.\\d+\\.\\d+(?:-[a-zA-Z0-9.]+)?(?:\\+[a-zA-Z0-9.]+)?\",\n \"mac_address\": r\"([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})\",\n}\n```\n\n## Build Regex from Description\n\nThe agent can construct patterns from natural language descriptions:\n\n```python\ndef build_regex(description: str) -> str:\n \"\"\"Build a regex from a natural language description.\n This is a guide — the agent constructs the pattern based on understanding.\"\"\"\n # The agent interprets the description and constructs the pattern\n # Examples of what the agent can build:\n examples = {\n \"match email addresses\": PATTERNS[\"email\"],\n \"find all urls\": PATTERNS[\"url\"],\n \"extract dates in YYYY-MM-DD format\": PATTERNS[\"date_iso\"],\n \"match a phone number\": PATTERNS[\"phone_us\"],\n \"find hex color codes\": PATTERNS[\"hex_color\"],\n \"match semver versions\": PATTERNS[\"semver\"],\n }\n return examples # The agent selects or constructs the appropriate pattern\n```\n\n## Explain a Regex\n\n```python\ndef explain_regex(pattern: str) -> str:\n \"\"\"Break down a regex pattern into human-readable components.\"\"\"\n explanations = {\n r'\\d': \"any digit (0-9)\",\n r'\\w': \"any word character (a-z, A-Z, 0-9, _)\",\n r'\\s': \"any whitespace\",\n r'.': \"any character\",\n r'*': \"zero or more of the preceding\",\n r'+': \"one or more of the preceding\",\n r'?': \"zero or one of the preceding\",\n r'{n}': \"exactly n of the preceding\",\n r'{n,m}': \"between n and m of the preceding\",\n r'^': \"start of string/line\",\n r'$': \"end of string/line\",\n r'[]': \"character class\",\n r'()': \"capture group\",\n r'(?:)': \"non-capturing group\",\n r'(?=)': \"lookahead\",\n r'(?!)': \"negative lookahead\",\n r'|': \"alternation (OR)\",\n r'\\\\': \"literal backslash\",\n }\n # The agent walks through the pattern and explains each component\n return \"See pattern breakdown in the response\"\n```\n\n## Replace with Regex\n\n```python\ndef regex_replace(pattern: str, text: str, replacement: str, count: int = 0) -> str:\n \"\"\"Replace matches with a replacement string.\"\"\"\n return re.sub(pattern, replacement, text, count=count or 0)\n```\n\n## Workflow\n\n1. Understand what the user wants to match\n2. Either the user provides a pattern to test, or the agent builds one\n3. Test the pattern against sample text\n4. Show matches with positions and groups\n5. If no matches, debug: explain the pattern and suggest fixes\n6. Return the working pattern and match results\n\n## Common Pitfalls\n\n1. **Greedy vs. lazy confusion.** `.*` is greedy (matches as much as possible); `.*?` is lazy (matches as little as possible) — the most common regex bug.\n2. **Missing anchors.** Without `^` and `$`, the pattern matches anywhere in the string, not the whole thing — add anchors when a full-string match is intended.\n3. **Unescaped special characters.** `.` matches any character; to match a literal dot use `\\.` — a frequent source of over-broad matches.\n4. **Catastrophic backtracking.** Nested quantifiers like `(a+)+` can cause exponential matching time on certain inputs — avoid nesting quantifiers.\n5. **Character class range mistakes.** `[a-z]` is lowercase only; `[A-Za-z]` covers both cases; `[\\w]` also includes digits and underscore.\n6. **Unicode surprises.** `\\w` in Python 3 matches Unicode word characters by default — use `[a-zA-Z0-9_]` when ASCII-only matching is required.\n\n## Verification Checklist\n\n- [ ] Pattern compiles without a `re.error`\n- [ ] Tested against both matching and non-matching sample inputs, not just the happy path\n- [ ] Match count and capture groups match what the user described wanting\n- [ ] Checked for catastrophic backtracking risk if the pattern has nested quantifiers\n- [ ] Anchoring (`^`/`$`) behavior matches whether a full-string or partial match was intended\n", "readme_content": "# regex-tester\n\nTest, build, and debug regular expressions — with match highlighting and pattern explanations.\n\n## What it does\n\nThe agent tests regex patterns against sample text and shows exactly what matches (with positions, groups, and highlighted output). Can also build patterns from natural language descriptions, explain what a pattern does component-by-component, and replace text using regex.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/regex-tester/SKILL.md\n```\n\n## How to use\n\n```\n\"Test the regex \\d{3}-\\d{4} against this text: Call 555-1234 or 987-6543\"\n```\n\nThe agent:\n1. Compiles the pattern\n2. Finds all matches\n3. Returns: \"2 matches: [555-1234] at pos 5, [987-6543] at pos 18\"\n\n## Common patterns included\n\n| Pattern | Matches |\n|---|---|\n| email | Email addresses |\n| url | HTTP/HTTPS URLs |\n| ipv4 | IPv4 addresses |\n| date_iso | YYYY-MM-DD dates |\n| phone_us | US phone numbers |\n| uuid | UUIDs |\n| hex_color | #RRGGBB colors |\n| semver | Semantic versions |\n\n## Example\n\n```\nUser: \"Extract all email addresses from this text\"\n\nAgent:\n 1. Uses pattern: [a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}\n 2. Tests against the text\n 3. Returns: [\"alice@example.com\", \"bob@work.org\"]\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/regex-tester/SKILL.md" }, { "name": "file-organizer", "category": "utility", "tier": "utility", "description": "Organize files by type, date, or content hash.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/file-organizer/SKILL.md", "path": "skills/file-organizer", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "file-organizer", "description": "Organize files by type, date, or content hash.", "version": "1.0.0" }, "agent_use": "- The user has a messy directory (Downloads, Desktop) and wants it organized.\n- The user wants to sort files by type, date, or name pattern.\n- The user wants to find and remove duplicate files.\n- The user says \"organize my downloads\", \"clean up this folder\", or \"sort these files\".", "user_use": "The agent scans a directory and organizes files into subdirectories by category (Images, Videos, Documents, Code, etc.) or by modification date (Year/Month). Includes dry-run mode to preview before moving, and a duplicate finder that identifies files with identical content by hash.", "skillmd_content": "---\nname: file-organizer\ndescription: \"Use when the user has a messy directory (Downloads, Desktop) and wants files organized by type or date, or wants duplicate files found — with a dry-run preview shown before anything actually moves.\"\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [file-management, deduplication, directory-cleanup, dry-run, file-sorting]\n related_skills: [csv-toolkit, pdf-extract]\n---\n\n# file-organizer\n\n## Overview\n\nOrganize files in a directory by type, date, or custom patterns. The agent scans a directory, categorizes files, and moves them into a structured layout. Supports dry-run mode to preview before moving.\n\n## When to Use\n\n- The user has a messy directory (Downloads, Desktop) and wants it organized.\n- The user wants to sort files by type, date, or name pattern.\n- The user wants to find and remove duplicate files.\n- The user says \"organize my downloads\", \"clean up this folder\", or \"sort these files\".\n\n## Organize by Type\n\n```python\nimport os\nimport shutil\nfrom pathlib import Path\n\nCATEGORY_MAP = {\n \"Images\": [\".jpg\", \".jpeg\", \".png\", \".gif\", \".bmp\", \".webp\", \".svg\", \".heic\"],\n \"Videos\": [\".mp4\", \".avi\", \".mov\", \".mkv\", \".wmv\", \".flv\", \".webm\"],\n \"Audio\": [\".mp3\", \".wav\", \".flac\", \".aac\", \".ogg\", \".m4a\"],\n \"Documents\": [\".pdf\", \".doc\", \".docx\", \".txt\", \".md\", \".odt\", \".rtf\", \".pages\"],\n \"Spreadsheets\": [\".xls\", \".xlsx\", \".csv\", \".ods\", \".numbers\"],\n \"Presentations\": [\".ppt\", \".pptx\", \".key\", \".odp\"],\n \"Archives\": [\".zip\", \".tar\", \".gz\", \".rar\", \".7z\", \".bz2\", \".xz\"],\n \"Code\": [\".py\", \".js\", \".ts\", \".jsx\", \".tsx\", \".java\", \".cpp\", \".c\", \".h\", \".go\", \".rs\", \".rb\", \".php\", \".sh\", \".html\", \".css\", \".json\", \".xml\", \".yaml\", \".yml\"],\n \"Executables\": [\".exe\", \".msi\", \".deb\", \".rpm\", \".dmg\", \".app\", \".apk\"],\n \"Fonts\": [\".ttf\", \".otf\", \".woff\", \".woff2\", \".eot\"],\n \"Data\": [\".db\", \".sqlite\", \".sql\", \".json\", \".csv\"],\n}\n\ndef organize_by_type(directory: str, dry_run: bool = True) -> dict:\n \"\"\"Organize files into subdirectories by category.\"\"\"\n moved = {}\n skipped = []\n\n for item in Path(directory).iterdir():\n if item.is_dir():\n continue\n\n ext = item.suffix.lower()\n category = \"Misc\"\n for cat, extensions in CATEGORY_MAP.items():\n if ext in extensions:\n category = cat\n break\n\n target_dir = Path(directory) / category\n target_path = target_dir / item.name\n\n if dry_run:\n moved[str(item)] = str(target_path)\n else:\n target_dir.mkdir(exist_ok=True)\n if target_path.exists():\n # Append number to avoid overwriting\n stem = item.stem\n i = 1\n while target_path.exists():\n target_path = target_dir / f\"{stem}_{i}{item.suffix}\"\n i += 1\n shutil.move(str(item), str(target_path))\n moved[str(item)] = str(target_path)\n\n return {\"moved\": moved, \"count\": len(moved), \"dry_run\": dry_run}\n```\n\n## Organize by Date\n\n```python\nimport datetime\n\ndef organize_by_date(directory: str, dry_run: bool = True) -> dict:\n \"\"\"Organize files into year/month subdirectories by modification date.\"\"\"\n moved = {}\n\n for item in Path(directory).iterdir():\n if item.is_dir():\n continue\n\n mtime = datetime.datetime.fromtimestamp(item.stat().st_mtime)\n year = str(mtime.year)\n month = mtime.strftime(\"%m-%B\")\n\n target_dir = Path(directory) / year / month\n target_path = target_dir / item.name\n\n if dry_run:\n moved[str(item)] = str(target_path)\n else:\n target_dir.mkdir(parents=True, exist_ok=True)\n shutil.move(str(item), str(target_path))\n moved[str(item)] = str(target_path)\n\n return {\"moved\": moved, \"count\": len(moved), \"dry_run\": dry_run}\n```\n\n## Find Duplicates\n\n```python\nimport hashlib\n\ndef find_duplicates(directory: str) -> dict:\n \"\"\"Find duplicate files by content hash.\"\"\"\n hashes = {}\n duplicates = {}\n\n for item in Path(directory).rglob(\"*\"):\n if item.is_file():\n h = hashlib.sha256(item.read_bytes()).hexdigest()\n if h in hashes:\n if h not in duplicates:\n duplicates[h] = [hashes[h]]\n duplicates[h].append(str(item))\n else:\n hashes[h] = str(item)\n\n return {\n \"total_files\": len(hashes) + sum(len(v) - 1 for v in duplicates.values()),\n \"duplicate_groups\": len(duplicates),\n \"duplicates\": duplicates,\n }\n```\n\n## Workflow\n\n1. Identify the directory to organize\n2. Run in dry-run mode first to show what would move where\n3. Show the user the proposed organization\n4. If approved, run without dry-run to actually move files\n5. Report: files moved, categories created, any duplicates found\n\n## Common Pitfalls\n\n1. **Always dry-run first** — Never move files without showing the user what will happen. A wrong category mapping could scatter files unexpectedly.\n2. **Name collisions** — Files with the same name in different categories will collide. The code handles this by appending `_1`, `_2`, etc.\n3. **Symlinks** — `Path.iterdir()` includes symlinks. Moving a symlink moves the link, not the target. Handle symlinks separately if needed.\n4. **Hidden files** — Files starting with `.` (`.gitignore`, `.env`) are included by default. Filter them out if the user doesn't want them moved.\n5. **Permission errors** — Moving files requires write permission on both the source and target. On Windows, files in use can't be moved.\n6. **Large directories** — `find_duplicates` reads every file to hash it. For directories with thousands of large files, this is slow. Consider hashing only files above a size threshold first.\n\n## Verification Checklist\n\n- [ ] Dry-run output was shown to and approved by the user before any real move ran\n- [ ] Post-move file count matches pre-move count — nothing was lost or silently overwritten\n- [ ] Any name-collision case produced a `_1`/`_2` suffixed file instead of overwriting the original\n- [ ] `find_duplicates` groups spot-checked (same hash, same actual content) before deleting anything\n", "readme_content": "# file-organizer\n\nOrganize files by type, date, or content — clean up messy directories automatically.\n\n## What it does\n\nThe agent scans a directory and organizes files into subdirectories by category (Images, Videos, Documents, Code, etc.) or by modification date (Year/Month). Includes dry-run mode to preview before moving, and a duplicate finder that identifies files with identical content by hash.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/file-organizer/SKILL.md\n```\n\n## How to use\n\n```\n\"Organize my Downloads folder by file type\"\n```\n\nThe agent:\n1. Scans the directory\n2. Runs in dry-run mode first — shows you what would move where\n3. If approved, creates category folders and moves files\n4. Reports: \"Moved 47 files into 8 categories\"\n\n## Organize modes\n\n| Mode | How it sorts |\n|---|---|\n| By type | Images/, Videos/, Documents/, Code/, etc. |\n| By date | 2026/07-July/, 2026/08-August/, etc. |\n| Duplicates | Finds files with identical content by SHA-256 |\n\n## Example\n\n```\nUser: \"Clean up my Desktop, but show me what you'll do first\"\n\nAgent:\n 1. Dry run on ~/Desktop\n 2. Shows: \"Would move 23 files:\n screenshot.png → Images/\n report.pdf → Documents/\n app.py → Code/\n vacation.mp4 → Videos/\"\n 3. User approves\n 4. Moves files, creates category folders\n 5. Returns: \"Done. 23 files organized into 6 categories.\"\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/file-organizer/SKILL.md" }, { "name": "changelog-generator", "category": "utility", "tier": "utility", "description": "Generate a changelog from git commit history.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/changelog-generator/SKILL.md", "path": "skills/changelog-generator", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "changelog-generator", "description": "Generate a changelog from git commit history.", "version": "1.0.0" }, "agent_use": "- The user wants a changelog for their project.\n- The user wants to generate release notes from commits.\n- The user says \"make a changelog\", \"generate release notes\", or \"what changed since v1.0\".", "user_use": "The agent reads git commits, categorizes them by conventional commit type (feat → Added, fix → Fixed, refactor → Changed), and produces a Keep a Changelog formatted document. Useful for release notes, project documentation, or seeing what changed between versions.", "skillmd_content": "---\nname: changelog-generator\ndescription: Use when the user wants a changelog for their project, wants release notes generated from git history, or asks \"make a changelog\", \"generate release notes\", or \"what changed since v1.0\" — requires a git repo with conventional commit messages (feat:, fix:, refactor:, docs:).\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [changelog, git-log, conventional-commits, release-notes]\n related_skills: [github-actions-ci, git-backup]\n---\n\n# changelog-generator\n\n## Overview\n\nGenerate a formatted changelog from git commit history. The agent reads commits, categorizes them by type (feat, fix, refactor, docs), and produces a Keep a Changelog-format document.\n\n## When to Use\n\n- The user wants a changelog for their project.\n- The user wants to generate release notes from commits.\n- The user says \"make a changelog\", \"generate release notes\", or \"what changed since v1.0\".\n\n## Prerequisites\n\nThe project must be a git repository with conventional commit messages (feat:, fix:, refactor:, docs:, chore:).\n\n## Generate from Commits\n\n```python\nimport subprocess\nimport re\nfrom collections import defaultdict\n\ndef get_commits(since: str = \"\", repo_path: str = \".\") -> list:\n \"\"\"Get commits with hash, date, and message.\"\"\"\n cmd = [\"git\", \"log\", \"--pretty=format:%H|%ai|%s\"]\n if since:\n cmd.insert(2, f\"{since}..HEAD\")\n\n result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)\n commits = []\n for line in result.stdout.strip().split(\"\\n\"):\n if \"|\" in line:\n parts = line.split(\"|\", 2)\n commits.append({\n \"hash\": parts[0][:8],\n \"date\": parts[1][:10],\n \"message\": parts[2]\n })\n return commits\n\ndef categorize_commits(commits: list) -> dict:\n \"\"\"Categorize commits by conventional commit type.\"\"\"\n categories = defaultdict(list)\n for commit in commits:\n msg = commit[\"message\"]\n match = re.match(r'(\\w+)(\\([^)]+\\))?:\\s*(.+)', msg)\n if match:\n cat = match.group(1)\n scope = match.group(2) or \"\"\n desc = match.group(3)\n categories[cat].append({\n \"hash\": commit[\"hash\"],\n \"scope\": scope.strip(\"()\"),\n \"description\": desc,\n })\n else:\n categories[\"other\"].append({\n \"hash\": commit[\"hash\"],\n \"scope\": \"\",\n \"description\": msg,\n })\n return dict(categories)\n\ndef generate_changelog(since: str = \"\", repo_path: str = \".\") -> str:\n \"\"\"Generate a Keep a Changelog formatted document.\"\"\"\n commits = get_commits(since, repo_path)\n categorized = categorize_commits(commits)\n\n # Category display names and order\n category_map = {\n \"feat\": \"Added\",\n \"fix\": \"Fixed\",\n \"refactor\": \"Changed\",\n \"docs\": \"Documentation\",\n \"chore\": \"Maintenance\",\n \"test\": \"Testing\",\n \"perf\": \"Performance\",\n \"other\": \"Other\",\n }\n\n changelog = \"# Changelog\\n\\n\"\n for cat_key in [\"feat\", \"fix\", \"refactor\", \"perf\", \"docs\", \"test\", \"chore\", \"other\"]:\n if cat_key in categorized:\n display = category_map.get(cat_key, cat_key)\n changelog += f\"## {display}\\n\\n\"\n for item in categorized[cat_key]:\n scope = f\"**{item['scope']}**: \" if item[\"scope\"] else \"\"\n changelog += f\"- {scope}{item['description']} ({item['hash']})\\n\"\n changelog += \"\\n\"\n\n return changelog\n```\n\n## Conventional Commit Types\n\n| Type | Maps to | Description |\n|---|---|---|\n| `feat:` | Added | New features |\n| `fix:` | Fixed | Bug fixes |\n| `refactor:` | Changed | Code restructuring |\n| `perf:` | Performance | Performance improvements |\n| `docs:` | Documentation | Documentation changes |\n| `test:` | Testing | Test additions/changes |\n| `chore:` | Maintenance | Build, deps, config |\n\n## Workflow\n\n1. Determine the range (since last tag, since a date, or all history)\n2. Get commits with `git log`\n3. Categorize by conventional commit prefix\n4. Format as a Keep a Changelog document\n5. Write to `CHANGELOG.md` or return as string\n\n## Common Pitfalls\n\n1. **Non-conventional commits fall into \"Other\".** Commits without `feat:`/`fix:` prefixes get dumped in the catch-all bucket. Encourage the team to use conventional commits for better categorization, or the changelog will be mostly \"Other\".\n2. **Merge commits clutter the output.** Merge commit messages (e.g. \"Merge branch 'main'\") add noise. Filter them with `--no-merges` in the `git log` command before categorizing.\n3. **Squashed commits lose their history.** A squash-merged PR collapses many commits into one message; the changelog reflects only the squash message, not the individual changes.\n4. **`since` must be a valid git ref.** Pass a tag name (`v1.0.0`), a commit hash, or a date — an arbitrary string that isn't a real ref makes `git log` silently return the full history instead of erroring.\n5. **Cherry-picked commits appear twice.** If the same commit is cherry-picked across branches, it has a different hash on each branch and shows up as a duplicate entry. Deduplicate by commit message text, not hash, when merging changelogs across branches.\n\n## Verification Checklist\n\n- [ ] `generate_changelog()` was actually run against the target repo and returned non-empty output (not just defined)\n- [ ] The `since` argument, if provided, is a real tag/commit/date confirmed to exist in the repo\n- [ ] Merge commits were excluded (`--no-merges`) unless the user wants them included\n- [ ] Output was spot-checked against `git log --oneline` for the same range to confirm no commits were silently dropped\n- [ ] Written output file (if any) matches Keep a Changelog section ordering: Added, Fixed, Changed, Performance, Documentation, Testing, Maintenance, Other\n", "readme_content": "# changelog-generator\n\nGenerate a formatted changelog from git commit history.\n\n## What it does\n\nThe agent reads git commits, categorizes them by conventional commit type (feat → Added, fix → Fixed, refactor → Changed), and produces a Keep a Changelog formatted document. Useful for release notes, project documentation, or seeing what changed between versions.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/changelog-generator/SKILL.md\n```\n\n## How to use\n\n```\n\"Generate a changelog from v1.0.0 to now\"\n```\n\nThe agent:\n1. Runs `git log v1.0.0..HEAD`\n2. Categorizes commits: feat → Added, fix → Fixed, etc.\n3. Formats as a changelog document\n4. Writes to CHANGELOG.md\n\n## Example\n\n```\nUser: \"What changed since the last release?\"\n\nAgent:\n 1. Gets commits since last tag: git describe --tags\n 2. Categorizes: 3 feat, 5 fix, 2 refactor\n 3. Generates:\n\n ## Added\n - User profile page (a1b2c3d)\n - Dark mode toggle (e4f5g6h)\n - Export to CSV (i7j8k9l)\n\n ## Fixed\n - Login redirect loop (m1n2o3p)\n - Date picker timezone bug (q4r5s6t)\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/changelog-generator/SKILL.md" }, { "name": "color-palette-generator", "category": "frontend", "tier": "utility", "description": "Generate color palettes from images or base colors.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/color-palette-generator/SKILL.md", "path": "skills/color-palette-generator", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "color-palette-generator", "description": "Generate color palettes from images or base colors.", "version": "1.0.0" }, "agent_use": "- The user wants a color palette for a web project.\n- The user wants to extract colors from an image or screenshot.\n- The user wants a palette based on a mood or keyword.\n- The user says \"generate a color palette\", \"extract colors from this image\", or \"give me a color scheme\".", "user_use": "The agent creates cohesive color palettes in three modes: extract dominant colors from an image, generate a scheme (analogous, complementary, triadic, monochrome) from a base color, or produce a palette from a mood keyword. Output as hex values or ready-to-use CSS custom properties.", "skillmd_content": "---\nname: color-palette-generator\ndescription: Use when the user wants a color palette for a web project, wants to extract colors from an image or screenshot, wants a palette based on a mood or base color, or says \"generate a color palette\", \"extract colors from this image\", or \"give me a color scheme\".\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [color-palette, css-custom-properties, image-color-extraction, design]\n related_skills: [frontend-design-toolkit, hallmark-readme, ascii-art]\n---\n\n# color-palette-generator\n\n## Overview\n\nGenerate color palettes from images, keywords, or base colors. The agent extracts dominant colors from images, generates complementary palettes from a seed color, and produces CSS custom properties ready to use.\n\n## When to Use\n\n- The user wants a color palette for a web project.\n- The user wants to extract colors from an image or screenshot.\n- The user wants a palette based on a mood or keyword.\n- The user says \"generate a color palette\", \"extract colors from this image\", or \"give me a color scheme\".\n\n## Extract Colors from Image\n\n```python\nfrom PIL import Image\nfrom collections import Counter\n\ndef extract_palette(image_path: str, num_colors: int = 6) -> list:\n \"\"\"Extract dominant colors from an image.\"\"\"\n img = Image.open(image_path)\n img = img.convert(\"RGB\")\n img = img.resize((150, 150)) # downsize for speed\n\n pixels = list(img.getdata())\n counter = Counter(pixels)\n\n palette = []\n for color, count in counter.most_common(num_colors * 3):\n # Skip colors too similar to already-selected ones\n too_close = False\n for existing in palette:\n if sum(abs(a - b) for a, b in zip(color, existing)) < 50:\n too_close = True\n break\n if not too_close:\n palette.append(color)\n if len(palette) >= num_colors:\n break\n\n return [{\"hex\": rgb_to_hex(c), \"rgb\": c} for c in palette]\n\ndef rgb_to_hex(rgb: tuple) -> str:\n return f\"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}\"\n```\n\n## Generate from Base Color\n\n```python\nimport colorsys\n\ndef generate_palette(base_hex: str, scheme: str = \"analogous\") -> list:\n \"\"\"Generate a palette from a base color.\"\"\"\n r, g, b = int(base_hex[1:3], 16), int(base_hex[3:5], 16), int(base_hex[5:7], 16)\n h, s, v = colorsys.rgb_to_hsv(r/255, g/255, b/255)\n\n colors = []\n if scheme == \"analogous\":\n offsets = [-30, -15, 0, 15, 30]\n for offset in offsets:\n new_h = (h + offset/360) % 1.0\n rgb = colorsys.hsv_to_rgb(new_h, s, v)\n colors.append(rgb_to_hex((int(rgb[0]*255), int(rgb[1]*255), int(rgb[2]*255))))\n\n elif scheme == \"complementary\":\n new_h = (h + 0.5) % 1.0\n for sat in [s, s*0.7, s*0.5]:\n rgb = colorsys.hsv_to_rgb(h, sat, v)\n colors.append(rgb_to_hex((int(rgb[0]*255), int(rgb[1]*255), int(rgb[2]*255))))\n rgb = colorsys.hsv_to_rgb(new_h, sat, v)\n colors.append(rgb_to_hex((int(rgb[0]*255), int(rgb[1]*255), int(rgb[2]*255))))\n\n elif scheme == \"triadic\":\n for offset in [0, 120, 240]:\n new_h = (h + offset/360) % 1.0\n rgb = colorsys.hsv_to_rgb(new_h, s, v)\n colors.append(rgb_to_hex((int(rgb[0]*255), int(rgb[1]*255), int(rgb[2]*255))))\n\n elif scheme == \"monochrome\":\n for val in [0.3, 0.5, 0.7, 0.85, 1.0]:\n rgb = colorsys.hsv_to_rgb(h, s, val)\n colors.append(rgb_to_hex((int(rgb[0]*255), int(rgb[1]*255), int(rgb[2]*255))))\n\n return colors\n```\n\n## CSS Custom Properties Output\n\n```python\ndef palette_to_css(palette: list, name: str = \"palette\") -> str:\n \"\"\"Generate CSS custom properties from a palette.\"\"\"\n css = \":root {\\n\"\n for i, color in enumerate(palette, 1):\n hex_val = color[\"hex\"] if isinstance(color, dict) else color\n css += f\" --color-{name}-{i}: {hex_val};\\n\"\n css += \"}\"\n return css\n```\n\n## Workflow\n\n1. Determine the source: image, base color, or keyword\n2. For images: extract dominant colors with deduplication\n3. For base colors: generate analogous/complementary/triadic/monochrome scheme\n4. Convert to hex\n5. Optionally output as CSS custom properties\n6. Return the palette\n\n## Common Pitfalls\n\n1. **Near-identical shades pass the dedup filter.** Raw color extraction returns many near-identical pixels. The distance-< 50 threshold filters most, but on low-contrast images it can still let through two colors that read as the same to the eye — or, on high-contrast images, reject colors that should have been kept. Check the returned hexes visually, don't trust the threshold blindly.\n2. **Extracting from a huge image is slow.** A 5000x5000 image has 25M pixels to count. Always downsize first (the code resizes to 150x150) before running `Counter`.\n3. **RGBA images skew the palette.** Images with an alpha channel need `img.convert(\"RGB\")` first, or transparent/semi-transparent pixels get counted as if they were opaque colors.\n4. **Wrong scheme for the mood.** \"Analogous\" is safe for most projects. \"Complementary\" can be visually jarring if applied without restraint. \"Monochrome\" is elegant but risks low contrast for text/background pairs — check contrast ratio, not just hue.\n5. **Hex output isn't perceptually uniform.** This skill generates hex/RGB colors via HSV math, not OKLCH. For a palette that needs consistent perceived lightness across hues, convert to OKLCH afterward (see `hallmark-readme` / `frontend-design-toolkit`).\n6. **Palette is light-mode only.** The generated palette targets light backgrounds. For a dark theme, don't reuse it as-is — invert lightness per color while keeping hue constant, and re-check contrast.\n\n## Verification Checklist\n\n- [ ] Extracted or generated hex values were rendered/previewed, not just returned as strings\n- [ ] Source image was downsized before extraction (large images not passed directly to `Counter`)\n- [ ] RGBA images were converted to RGB before extraction\n- [ ] Chosen scheme (analogous/complementary/triadic/monochrome) matches the stated mood or use case\n- [ ] If used for text-on-background pairs, contrast was checked (not just hue difference)\n- [ ] CSS custom properties output (if requested) was validated as parseable CSS\n", "readme_content": "# color-palette-generator\n\nGenerate color palettes from images, base colors, or keywords — output as hex or CSS custom properties.\n\n## What it does\n\nThe agent creates cohesive color palettes in three modes: extract dominant colors from an image, generate a scheme (analogous, complementary, triadic, monochrome) from a base color, or produce a palette from a mood keyword. Output as hex values or ready-to-use CSS custom properties.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/color-palette-generator/SKILL.md\n```\n\n## How to use\n\n```\n\"Extract a color palette from this screenshot\"\n```\n\nThe agent extracts the 6 most dominant colors and returns them as hex values plus CSS custom properties.\n\n## Palette types\n\n| Type | How it works |\n|---|---|\n| Image extraction | Finds dominant colors in an image |\n| Analogous | Colors adjacent on the color wheel |\n| Complementary | Base color + its opposite |\n| Triadic | Three evenly-spaced colors |\n| Monochrome | Variations of a single hue |\n\n## Example\n\n```\nUser: \"Generate a palette from #3b82f6\"\n\nAgent:\n 1. Generates analogous palette:\n #1d4ed8, #2563eb, #3b82f6, #60a5fa, #93c5fd\n 2. As CSS:\n :root {\n --color-palette-1: #1d4ed8;\n --color-palette-2: #2563eb;\n --color-palette-3: #3b82f6;\n --color-palette-4: #60a5fa;\n --color-palette-5: #93c5fd;\n }\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/color-palette-generator/SKILL.md" }, { "name": "snippet-manager", "category": "utility", "tier": "utility", "description": "Save and retrieve reusable code snippets.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/snippet-manager/SKILL.md", "path": "skills/snippet-manager", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "snippet-manager", "description": "Save and retrieve reusable code snippets.", "version": "1.0.0" }, "agent_use": "- The user wants to save a useful code snippet for reuse.\n- The user wants to find a snippet they saved earlier.\n- The user wants to build a personal snippet library.\n- The user says \"save this snippet\", \"find my snippet for\", or \"I had code for this\".", "user_use": "The agent stores code snippets as markdown files with metadata (language, tags, description) in a snippet directory. You can save snippets by giving the code + a name, search by keyword/tag/language, and retrieve snippets to insert into your code. Snippets persist across sessions.", "skillmd_content": "---\nname: snippet-manager\ndescription: Use when the user wants to save a reusable code snippet for later, find a snippet they saved earlier, or build a searchable personal snippet library keyed by language and tags.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [code-snippets, snippet-library, markdown-frontmatter, search, cli-tooling]\n related_skills: [dotfiles-manage, file-organizer]\n---\n\n# snippet-manager\n\n## Overview\n\nStore, search, and retrieve reusable code snippets. The agent saves snippets with metadata (language, tags, description), searches by keyword or tag, and inserts snippets into files on demand.\n\n## When to Use\n\n- The user wants to save a useful code snippet for reuse.\n- The user wants to find a snippet they saved earlier.\n- The user wants to build a personal snippet library.\n- The user says \"save this snippet\", \"find my snippet for\", or \"I had code for this\".\n\n## Storage\n\nSnippets are stored as individual files in a snippet directory:\n\n```\n~/.snippets/\n├── python_http_get.md\n├── js_debounce.md\n├── bash_find_large_files.md\n└── sql_upsert.md\n```\n\nEach snippet file has YAML frontmatter + the code:\n\n```markdown\n---\nlanguage: python\ntags: [http, requests, api]\ndescription: HTTP GET request with error handling and timeout\ncreated: 2026-07-20\n---\n\n```python\nimport requests\n\ndef http_get(url, timeout=10):\n try:\n resp = requests.get(url, timeout=timeout)\n resp.raise_for_status()\n return resp.json()\n except requests.exceptions.RequestException as e:\n print(f\"Request failed: {e}\")\n return None\n```\n```\n\n## Save a Snippet\n\n```python\nimport os\nfrom datetime import datetime\nfrom pathlib import Path\n\ndef save_snippet(name: str, language: str, code: str, tags: list = None, description: str = \"\", snippet_dir: str = None):\n \"\"\"Save a code snippet to the snippet library.\"\"\"\n if snippet_dir is None:\n snippet_dir = os.path.expanduser(\"~/.snippets\")\n Path(snippet_dir).mkdir(parents=True, exist_ok=True)\n\n filename = f\"{name}.md\"\n filepath = os.path.join(snippet_dir, filename)\n\n frontmatter = f\"\"\"---\nlanguage: {language}\ntags: [{', '.join(tags or [])}]\ndescription: {description}\ncreated: {datetime.now().strftime('%Y-%m-%d')}\n---\n\n```{language}\n{code}\n```\n\"\"\"\n with open(filepath, 'w') as f:\n f.write(frontmatter)\n return filepath\n```\n\n## Search Snippets\n\n```python\nimport re\n\ndef search_snippets(query: str = \"\", tag: str = \"\", language: str = \"\", snippet_dir: str = None) -> list:\n \"\"\"Search snippets by keyword, tag, or language.\"\"\"\n if snippet_dir is None:\n snippet_dir = os.path.expanduser(\"~/.snippets\")\n\n results = []\n for filepath in Path(snippet_dir).glob(\"*.md\"):\n with open(filepath, 'r') as f:\n content = f.read()\n\n # Parse frontmatter\n fm_match = re.match(r'^---\\n(.*?)\\n---\\n(.*)', content, re.DOTALL)\n if not fm_match:\n continue\n\n frontmatter = fm_match.group(1)\n body = fm_match.group(2)\n\n # Check filters\n match = True\n if language and f\"language: {language}\" not in frontmatter:\n match = False\n if tag and f\"tags: [{tag}\" not in frontmatter and f\", {tag}\" not in frontmatter:\n match = False\n if query and query.lower() not in content.lower():\n match = False\n\n if match:\n results.append({\n \"file\": filepath.name,\n \"content\": body.strip(),\n \"frontmatter\": frontmatter,\n })\n\n return results\n```\n\n## List All Snippets\n\n```python\ndef list_snippets(snippet_dir: str = None) -> list:\n \"\"\"List all saved snippets with metadata.\"\"\"\n if snippet_dir is None:\n snippet_dir = os.path.expanduser(\"~/.snippets\")\n\n snippets = []\n for filepath in Path(snippet_dir).glob(\"*.md\"):\n with open(filepath, 'r') as f:\n content = f.read()\n fm_match = re.match(r'^---\\n(.*?)\\n---', content, re.DOTALL)\n if fm_match:\n fm = fm_match.group(1)\n lang = re.search(r'language:\\s*(\\w+)', fm)\n desc = re.search(r'description:\\s*(.+)', fm)\n snippets.append({\n \"name\": filepath.stem,\n \"language\": lang.group(1) if lang else \"unknown\",\n \"description\": desc.group(1).strip() if desc else \"\",\n })\n return snippets\n```\n\n## Workflow\n\n1. **Save**: The user provides code + a name → save to `~/.snippets/name.md`\n2. **Search**: The user asks for a snippet → search by keyword, tag, or language\n3. **Insert**: The user wants to use a snippet → retrieve it and insert into the current file\n4. **List**: Show all saved snippets with metadata\n\n## Common Pitfalls\n\n1. **Assuming `~/.snippets/` without checking.** If the user has a preferred location (e.g.,\n inside a dotfiles repo they already version-control), set `snippet_dir` explicitly rather than\n defaulting silently.\n2. **Overwriting on name collision.** `save_snippet` writes `{name}.md` unconditionally — an\n existing file with the same name is silently clobbered. Check for the file first and warn or\n append a number.\n3. **Trusting stored code without testing it.** Snippets are saved as-is with no syntax\n validation; a broken snippet stays broken until someone runs it.\n4. **Malformed tags breaking search.** `search_snippets` matches tags via literal substrings like\n `tags: [{tag}` or `, {tag}`. Inconsistent spacing (`[tag1,tag2]` vs `[tag1, tag2]`) in the\n frontmatter you write will cause `search_snippets(tag=...)` to miss it later.\n5. **Letting one file grow past a focused snippet.** Files over a few hundred lines make the\n library hard to search and defeat the \"one function or pattern per file\" model — split them.\n\n## Verification Checklist\n\n- [ ] Saved snippet file exists at `<snippet_dir>/<name>.md` with valid YAML frontmatter\n (`language`, `tags`, `description`, `created`) followed by a fenced code block.\n- [ ] `search_snippets` with the snippet's own tag/language/keyword returns the new entry.\n- [ ] No pre-existing snippet was silently overwritten by the save.\n- [ ] `list_snippets` shows the correct `language` and `description` parsed from frontmatter.\n", "readme_content": "# snippet-manager\n\nSave, search, and retrieve reusable code snippets — a personal searchable snippet library.\n\n## What it does\n\nThe agent stores code snippets as markdown files with metadata (language, tags, description) in a snippet directory. You can save snippets by giving the code + a name, search by keyword/tag/language, and retrieve snippets to insert into your code. Snippets persist across sessions.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/snippet-manager/SKILL.md\n```\n\n## How to use\n\n**Save a snippet:**\n```\n\"Save this Python HTTP client as 'http_get'\"\n```\n\n**Find a snippet:**\n```\n\"Find my snippet for debouncing\"\n```\n\nThe agent searches the library and returns the matching snippet.\n\n## Example\n\n```\nUser: \"Save this as a snippet: const debounce = (fn, ms) => { ... }\"\n\nAgent:\n 1. Saves to ~/.snippets/js_debounce.md\n 2. Tags: [javascript, utility, performance]\n 3. Returns: \"Saved. Find it with: search 'debounce'\"\n\nLater:\nUser: \"Find my debounce snippet\"\nAgent: Returns the snippet code from ~/.snippets/js_debounce.md\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/snippet-manager/SKILL.md" }, { "name": "markdown-linter", "category": "utility", "tier": "utility", "description": "Lint markdown files for consistency and common issues.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/markdown-linter/SKILL.md", "path": "skills/markdown-linter", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "markdown-linter", "description": "Lint markdown files for consistency and common issues.", "version": "1.0.0" }, "agent_use": "- The user wants to check their markdown for issues.\n- The user is preparing documentation for publication.\n- The user wants consistent markdown across a project.\n- The user says \"check my markdown\", \"lint this doc\", or \"fix markdown issues\".", "user_use": "The agent scans a markdown file and checks for 8 common issues: heading level skips, multiple h1s, trailing whitespace, missing image alt text, empty link text, mixed list markers, code blocks without language, and lines over 120 chars. Reports a sorted punch list with line numbers. Can auto-fix trailing whitespace and normalize list markers.", "skillmd_content": "---\nname: markdown-linter\ndescription: Use when a user wants to check markdown files for issues (inconsistent heading levels, broken links, missing alt text, formatting inconsistencies) before publishing docs, or says \"check my markdown\" / \"lint this doc\" / \"fix markdown issues\".\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [markdown, linting, documentation-quality]\n related_skills: [markdown-to-pdf, markdown-to-slides, hallmark-readme]\n---\n\n# markdown-linter\n\n## Overview\n\nCheck markdown files for common issues: inconsistent heading levels, broken links, missing alt text, trailing whitespace, and formatting inconsistencies. The agent scans markdown files and reports a fixable punch list.\n\n## When to Use\n\n- The user wants to check their markdown for issues.\n- The user is preparing documentation for publication.\n- The user wants consistent markdown across a project.\n- The user says \"check my markdown\", \"lint this doc\", or \"fix markdown issues\".\n\n## Checks\n\n```python\nimport re\nfrom pathlib import Path\n\ndef lint_markdown(filepath: str) -> list:\n \"\"\"Run all markdown linting checks and return issues.\"\"\"\n with open(filepath, 'r') as f:\n lines = f.readlines()\n content = \"\".join(lines)\n\n issues = []\n\n # 1. Heading levels should not skip (h1 → h3 is bad)\n heading_levels = []\n for i, line in enumerate(lines, 1):\n match = re.match(r'^(#{1,6})\\s', line)\n if match:\n level = len(match.group(1))\n heading_levels.append((i, level))\n\n for j, (line_num, level) in enumerate(heading_levels):\n if j > 0:\n prev_level = heading_levels[j-1][1]\n if level > prev_level + 1:\n issues.append({\n \"line\": line_num,\n \"rule\": \"heading-skip\",\n \"message\": f\"Heading level {level} skips from h{prev_level} — should be h{prev_level+1} max\"\n })\n\n # 2. Multiple h1 headings\n h1_count = sum(1 for _, level in heading_levels if level == 1)\n if h1_count > 1:\n issues.append({\n \"line\": 0,\n \"rule\": \"multiple-h1\",\n \"message\": f\"Found {h1_count} h1 headings — should have only one\"\n })\n\n # 3. Trailing whitespace\n for i, line in enumerate(lines, 1):\n if line.rstrip() != line and line.strip():\n issues.append({\n \"line\": i,\n \"rule\": \"trailing-whitespace\",\n \"message\": \"Trailing whitespace at end of line\"\n })\n\n # 4. Images without alt text\n for i, line in enumerate(lines, 1):\n for match in re.finditer(r'!\\[([^\\]]*)\\]\\([^\\)]+\\)', line):\n alt = match.group(1)\n if not alt.strip():\n issues.append({\n \"line\": i,\n \"rule\": \"missing-alt-text\",\n \"message\": \"Image has no alt text\"\n })\n\n # 5. Links without text\n for i, line in enumerate(lines, 1):\n for match in re.finditer(r'\\[([^\\]]*)\\]\\([^\\)]+\\)', line):\n text = match.group(1)\n if not text.strip() and not line.startswith('!'):\n issues.append({\n \"line\": i,\n \"rule\": \"empty-link-text\",\n \"message\": \"Link has no display text\"\n })\n\n # 6. Inconsistent list markers (mixing - and *)\n dash_lists = sum(1 for line in lines if re.match(r'^\\s*-\\s', line))\n star_lists = sum(1 for line in lines if re.match(r'^\\s*\\*\\s', line))\n if dash_lists > 0 and star_lists > 0:\n issues.append({\n \"line\": 0,\n \"rule\": \"mixed-list-markers\",\n \"message\": f\"Mixed list markers: {dash_lists} dash, {star_lists} asterisk — pick one\"\n })\n\n # 7. Code blocks without language\n in_code_block = False\n for i, line in enumerate(lines, 1):\n stripped = line.strip()\n if stripped.startswith(\"```\"):\n if in_code_block:\n in_code_block = False\n else:\n lang = stripped[3:].strip()\n if not lang:\n issues.append({\n \"line\": i,\n \"rule\": \"missing-code-language\",\n \"message\": \"Code block has no language specified\"\n })\n in_code_block = True\n\n # 8. Lines too long (over 120 chars, excluding URLs and tables)\n for i, line in enumerate(lines, 1):\n if len(line.rstrip()) > 120 and not line.strip().startswith(\"|\") and \"http\" not in line:\n issues.append({\n \"line\": i,\n \"rule\": \"line-too-long\",\n \"message\": f\"Line is {len(line.rstrip())} chars (max 120 recommended)\"\n })\n\n return sorted(issues, key=lambda x: x[\"line\"])\n```\n\n## Auto-fix\n\n```python\ndef fix_markdown(filepath: str) -> dict:\n \"\"\"Auto-fix simple markdown issues.\"\"\"\n with open(filepath, 'r') as f:\n content = f.read()\n\n fixes = 0\n\n # Fix trailing whitespace\n lines = content.split(\"\\n\")\n fixed_lines = []\n for line in lines:\n fixed = line.rstrip()\n if fixed != line:\n fixes += 1\n fixed_lines.append(fixed)\n\n # Normalize list markers (use - consistently)\n for i, line in enumerate(fixed_lines):\n if re.match(r'^(\\s*)\\*\\s', line):\n fixed_lines[i] = re.sub(r'^(\\s*)\\*\\s', r'\\1- ', line)\n fixes += 1\n\n with open(filepath, 'w') as f:\n f.write(\"\\n\".join(fixed_lines))\n\n return {\"fixes\": fixes, \"file\": filepath}\n```\n\n## Workflow\n\n1. Run `lint_markdown` to get all issues\n2. Present issues sorted by line number\n3. Offer to auto-fix trailing whitespace and list markers\n4. For other issues, show the line and suggested fix\n5. Let the user decide which to fix\n\n## Common Pitfalls\n\n1. **Frontmatter.** YAML frontmatter (between `---` lines) is not markdown. Skip it during linting.\n2. **Tables.** Table rows can be long. The line-length check skips lines starting with `|`.\n3. **URLs.** Long URLs make lines long. The check skips lines containing `http`.\n4. **Nested code blocks.** Code blocks inside code blocks (quarto, mdx) can confuse the parser. The `in_code_block` toggle handles simple cases.\n5. **Auto-fix is conservative.** Only fixes whitespace and list markers. Heading levels, alt text, and link text require manual judgment.\n\n## Verification Checklist\n\n- [ ] `lint_markdown` was run and its full issue list — not just a sample — was reported, sorted by line number\n- [ ] YAML frontmatter block was excluded from line-length and heading checks\n- [ ] `fix_markdown` was only applied to whitespace/list-marker issues; heading levels, alt text, and link text were left for manual review\n- [ ] Re-running `lint_markdown` after fixes shows the fixed issue count reduced accordingly\n", "readme_content": "# markdown-linter\n\nCheck markdown files for consistency, broken links, missing alt text, and common formatting issues.\n\n## What it does\n\nThe agent scans a markdown file and checks for 8 common issues: heading level skips, multiple h1s, trailing whitespace, missing image alt text, empty link text, mixed list markers, code blocks without language, and lines over 120 chars. Reports a sorted punch list with line numbers. Can auto-fix trailing whitespace and normalize list markers.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/markdown-linter/SKILL.md\n```\n\n## How to use\n\n```\n\"Lint my README.md\"\n```\n\nThe agent:\n1. Runs all 8 checks\n2. Reports issues sorted by line number\n3. Offers to auto-fix trailing whitespace and list markers\n\n## Checks\n\n| Rule | What it catches |\n|---|---|\n| heading-skip | h1 → h3 (skipping h2) |\n| multiple-h1 | More than one # heading |\n| trailing-whitespace | Spaces at end of lines |\n| missing-alt-text | Images without alt text |\n| empty-link-text | Links with no display text |\n| mixed-list-markers | Mixing - and * for lists |\n| missing-code-language | Code blocks without a language |\n| line-too-long | Lines over 120 characters |\n\n## Example\n\n```\nUser: \"Check my docs for markdown issues\"\n\nAgent:\n 1. Lints docs.md\n 2. Finds:\n Line 12: trailing-whitespace\n Line 34: missing-alt-text (image)\n Line 45: mixed-list-markers (mixing - and *)\n Line 67: missing-code-language\n 3. Offers: \"Auto-fix 2 issues (whitespace, list markers)?\"\n 4. User approves → fixes applied\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/markdown-linter/SKILL.md" }, { "name": "portfolio-upkeep", "category": "meta", "tier": "utility", "description": "Maintain and update a Hermes skills portfolio — sync site files, enrich the index, validate skills, and push updates.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/portfolio-upkeep/SKILL.md", "path": "skills/portfolio-upkeep", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-07-20", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "portfolio-upkeep", "description": "Maintain and update a Hermes skills portfolio.", "version": "1.0.0" }, "agent_use": "- The user added or updated a skill and wants the portfolio site updated.\n- The user wants to sync site/ files to docs/ for GitHub Pages.\n- The user wants to validate all skills have correct frontmatter and index entries.\n- The user says \"update the portfolio\", \"sync the site\", \"refresh the index\", or \"publish changes\".", "user_use": "The agent syncs site files from `site/` to `docs/` for GitHub Pages, enriches `skills-index.json` with `agent_use`, `user_use`, `skillmd_content`, and `readme_content` fields extracted from each skill's files, validates that all skill directories have proper frontmatter and index entries, and pushes updates to both Forgejo and GitHub. Run this after adding, updating, or removing skills.", "skillmd_content": "---\nname: portfolio-upkeep\ndescription: Use when a skill has been added or updated in a Hermes skills portfolio and the site/index need to catch up — syncing site/ to docs/, enriching skills-index.json, validating frontmatter, or pushing to Forgejo and GitHub Pages. Triggers include \"update the portfolio\", \"sync the site\", \"refresh the index\", or \"publish changes\".\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [portfolio, skills-index, github-pages, forgejo, sync, validation]\n related_skills: [skills-portfolio-scaffold, skill-publish, skill-registry-catalog]\n---\n\n# portfolio-upkeep\n\n## Overview\n\nMaintain and update a Hermes skills portfolio. The agent syncs site files to the docs/ directory, enriches skills-index.json with agent_use/user_use/skillmd_content/readme_content fields, validates skill frontmatter, and pushes updates to Forgejo and GitHub Pages.\n\n## When to Use\n\n- The user added or updated a skill and wants the portfolio site updated.\n- The user wants to sync site/ files to docs/ for GitHub Pages.\n- The user wants to validate all skills have correct frontmatter and index entries.\n- The user says \"update the portfolio\", \"sync the site\", \"refresh the index\", or \"publish changes\".\n\n## Prerequisites\n\n- A portfolio repo with the structure described in `skills-portfolio-scaffold`\n- Git remotes for both Forgejo and GitHub\n- GitHub Pages enabled on the GitHub repo (serving from /docs)\n\n## Sync Site Files to docs/\n\nThe site files live in `site/` but GitHub Pages serves from `docs/`. Sync them:\n\n```python\nimport shutil, os\n\nREPO_PATH = \"path/to/hermes-skills-portfolio\"\n\n# Files to sync\nsite_files = [\"index.html\", \"styles.css\", \"app.js\"]\nfor filename in site_files:\n src = os.path.join(REPO_PATH, \"site\", filename)\n dst = os.path.join(REPO_PATH, \"docs\", filename)\n if os.path.exists(src):\n shutil.copy2(src, dst)\n print(f\"Synced {filename}\")\n\n# Also copy skills-index.json to docs/\nshutil.copy2(\n os.path.join(REPO_PATH, \"skills-index.json\"),\n os.path.join(REPO_PATH, \"docs\", \"skills-index.json\")\n)\nprint(\"Synced skills-index.json\")\n```\n\n## Enrich skills-index.json\n\nWhen skills are added or updated, the index needs enrichment fields:\n\n```python\nimport json, os, re\n\nINDEX_PATH = os.path.join(REPO_PATH, \"skills-index.json\")\nSKILLS_DIR = os.path.join(REPO_PATH, \"skills\")\n\nwith open(INDEX_PATH, 'r') as f:\n index = json.load(f)\n\nfor skill in index[\"skills\"]:\n skill_path = os.path.join(SKILLS_DIR, skill[\"name\"], \"SKILL.md\")\n readme_path = os.path.join(SKILLS_DIR, skill[\"name\"], \"README.md\")\n\n # Read SKILL.md\n skillmd_content = \"\"\n if os.path.exists(skill_path):\n with open(skill_path, 'r', encoding='utf-8') as f:\n skillmd_content = f.read()\n\n # Read README.md\n readme_content = \"\"\n if os.path.exists(readme_path):\n with open(readme_path, 'r', encoding='utf-8') as f:\n readme_content = f.read()\n\n # Extract \"When to Use\" from SKILL.md as agent_use\n agent_use = \"\"\n when_match = re.search(r'## When to Use\\s*\\n(.*?)(?=\\n## |\\Z)', skillmd_content, re.DOTALL)\n if when_match:\n agent_use = when_match.group(1).strip()\n\n # Extract \"What it does\" from README.md as user_use\n user_use = \"\"\n what_match = re.search(r'## What it does\\s*\\n(.*?)(?=\\n## |\\Z)', readme_content, re.DOTALL)\n if what_match:\n user_use = what_match.group(1).strip()\n\n # Update fields\n skill[\"agent_use\"] = agent_use\n skill[\"user_use\"] = user_use\n skill[\"skillmd_content\"] = skillmd_content\n skill[\"readme_content\"] = readme_content\n\n# Write enriched index\nwith open(INDEX_PATH, 'w', encoding='utf-8') as f:\n json.dump(index, f, indent=2, ensure_ascii=False)\n\n# Also write to docs/\nshutil.copy2(INDEX_PATH, os.path.join(REPO_PATH, \"docs\", \"skills-index.json\"))\n```\n\n## Validate Skills\n\nCheck that every skill directory has the required files and the index is in sync:\n\n```python\ndef validate_portfolio(repo_path):\n skills_dir = os.path.join(repo_path, \"skills\")\n index_path = os.path.join(repo_path, \"skills-index.json\")\n\n with open(index_path, 'r') as f:\n index = json.load(f)\n\n index_names = {s[\"name\"] for s in index[\"skills\"]}\n dir_names = {d for d in os.listdir(skills_dir) if os.path.isdir(os.path.join(skills_dir, d))}\n\n issues = []\n\n # Skills in directories but not in index\n for name in dir_names - index_names:\n issues.append(f\"Directory skills/{name}/ exists but not in skills-index.json\")\n\n # Skills in index but no directory\n for name in index_names - dir_names:\n issues.append(f\"skills-index.json has '{name}' but no directory exists\")\n\n # Check each skill has SKILL.md and README.md\n for name in dir_names:\n skill_path = os.path.join(skills_dir, name)\n if not os.path.exists(os.path.join(skill_path, \"SKILL.md\")):\n issues.append(f\"skills/{name}/SKILL.md missing\")\n if not os.path.exists(os.path.join(skill_path, \"README.md\")):\n issues.append(f\"skills/{name}/README.md missing\")\n\n # Check frontmatter\n for name in dir_names:\n skill_md = os.path.join(skills_dir, name, \"SKILL.md\")\n if os.path.exists(skill_md):\n with open(skill_md, 'r') as f:\n content = f.read(200)\n if not re.search(r'^name:\\s*' + re.escape(name), content, re.MULTILINE):\n issues.append(f\"skills/{name}/SKILL.md frontmatter name doesn't match directory name\")\n\n return issues\n```\n\n## Full Upkeep Workflow\n\n1. **Validate** — run `validate_portfolio()` to check for missing files, index drift, or frontmatter issues\n2. **Enrich** — run the enrichment script to update agent_use, user_use, skillmd_content, readme_content in the index\n3. **Sync** — copy site files and skills-index.json to docs/\n4. **Commit** — `git add -A && git commit -m \"Upkeep: sync site, enrich index, validate skills\"`\n5. **Push** — push to both Forgejo and GitHub:\n ```bash\n git push forgejo main\n git push origin main\n ```\n6. **Verify** — wait 60-90 seconds for GitHub Pages to rebuild, then check the live URL\n\n## Adding a New Skill\n\nWhen adding a new skill to the portfolio:\n\n1. Create `skills/<skill-name>/SKILL.md` with proper frontmatter (name, description, version)\n2. Create `skills/<skill-name>/README.md` with \"What it does\" section\n3. Add an entry to `skills-index.json` with: name, category, tier, description, install_url, path, source, usage (zeros), recency, source_attribution, frontmatter\n4. Run the enrichment script to add agent_use, user_use, skillmd_content, readme_content\n5. Sync to docs/\n6. Commit and push\n\n## Updating an Existing Skill\n\n1. Edit the SKILL.md and/or README.md in the skill directory\n2. Update the entry in `skills-index.json` if the description, tier, or category changed\n3. Run the enrichment script (it overwrites agent_use, user_use, skillmd_content, readme_content from the files)\n4. Sync to docs/\n5. Commit and push\n\n## Common Pitfalls\n\n1. **docs/ drift.** The most common upkeep issue — you update site/ files but forget to copy them to docs/. GitHub Pages serves from docs/, so the live site shows stale content. Always run the sync step.\n2. **Index bloat.** skills-index.json with embedded content can reach 500KB+ for 50 skills. Fine for a static site (loads once, cached by the browser), but be aware of it when committing.\n3. **Frontmatter mismatch.** The `name` field in SKILL.md frontmatter must match the directory name — easy to forget when renaming a skill; the validation script catches it.\n4. **GitHub Pages build time.** After pushing, GitHub Pages takes 60-90 seconds to rebuild — don't verify the live URL immediately.\n5. **Forgejo and GitHub out of sync.** Always push to both remotes. Pushing only one leaves the review surface (Forgejo) and the public site (GitHub) diverged.\n6. **Missing user_use field.** If a README lacks a \"What it does\" section, user_use stays empty and the site falls back to the raw description — add the section instead.\n\n## Verification Checklist\n\n- [ ] `validate_portfolio()` returns zero issues (no missing files, no index drift, no frontmatter mismatch)\n- [ ] docs/ files match site/ files after the sync step\n- [ ] docs/skills-index.json matches the root skills-index.json\n- [ ] Pushed to both `forgejo main` and `origin main`\n- [ ] Waited 60+ seconds and confirmed the live GitHub Pages URL reflects the change\n- [ ] Every skill directory has both SKILL.md and README.md, and each README has a \"What it does\" section\n", "readme_content": "# portfolio-upkeep\n\nMaintain and update a Hermes skills portfolio — sync site files, enrich the index, validate skills, and push.\n\n## What it does\n\nThe agent syncs site files from `site/` to `docs/` for GitHub Pages, enriches `skills-index.json` with `agent_use`, `user_use`, `skillmd_content`, and `readme_content` fields extracted from each skill's files, validates that all skill directories have proper frontmatter and index entries, and pushes updates to both Forgejo and GitHub. Run this after adding, updating, or removing skills.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/portfolio-upkeep/SKILL.md\n```\n\n## How to use\n\n```\n\"Update the portfolio — I added a new skill\"\n```\n\nThe agent:\n1. Validates all skill directories (SKILL.md, README.md, frontmatter)\n2. Enriches skills-index.json with agent_use, user_use, skillmd_content, readme_content\n3. Syncs site files to docs/\n4. Commits and pushes to Forgejo + GitHub\n5. Waits for GitHub Pages to rebuild\n6. Verifies the live site\n\n## Example\n\n```\nUser: \"I updated the tailscale-deploy skill. Sync the portfolio.\"\n\nAgent:\n 1. Validates: all 50 skills OK\n 2. Enriches: updates tailscale-deploy's skillmd_content + agent_use\n 3. Syncs: copies index.html, styles.css, app.js, skills-index.json to docs/\n 4. Commits: \"Upkeep: sync site, enrich index after tailscale-deploy update\"\n 5. Pushes to Forgejo + GitHub\n 6. Verifies: https://therocksss.github.io/hermes-skills-portfolio/ returns 200\n```\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/portfolio-upkeep/SKILL.md" }, { "name": "movie-catalogue-site", "category": "media", "tier": "featured", "description": "Use when the user wants to build a browsable movie/TV catalogue — a \"Netflix-like\" front-end, a media dashboard, a personal streaming site, a watchlist app with browse/search/detail pages — or says \"build me a movie site\", \"a page to browse films and shows\", \"my own TV catalogue\". Covers the app shell, routes, rails, filter state, empty states, and the deploy shape that owns the CSP. Not for the metadata API itself (tmdb-metadata) or the player (streaming-provider-embeds).", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/movie-catalogue-site/SKILL.md", "path": "skills/movie-catalogue-site", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-08-02", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "movie-catalogue-site", "description": "Use when the user wants to build a browsable movie/TV catalogue — a \"Netflix-like\" front-end, a media dashboard, a personal streaming site, a watchlist app with browse/search/detail pages — or says \"build me a movie site\", \"a page to browse films and shows\", \"my own TV catalogue\". Covers the app shell, routes, rails, filter state, empty states, and the deploy shape that owns the CSP. Not for the metadata API itself (tmdb-metadata) or the player (streaming-provider-embeds).", "version": "1.0.0" }, "agent_use": "- The user wants a browsable catalogue of films/shows with posters, detail pages, and search.\n- An existing catalogue needs a new route, rail, or browse surface.\n- Playback, watchlist, or metadata work needs a place to live and the app shell doesn't exist yet.\n- The user asks for \"a personal Netflix\", \"a media browser\", \"a site for my movie list\".", "user_use": "Build the browsable half of a movie/TV catalogue: an app shell with home rails, search, category browse, a title detail page, and a watchlist route — on top of a metadata API and (optionally) third-party embed providers.", "skillmd_content": "---\nname: movie-catalogue-site\ndescription: Use when the user wants to build a browsable movie/TV catalogue — a \"Netflix-like\" front-end, a media dashboard, a personal streaming site, a watchlist app with browse/search/detail pages — or says \"build me a movie site\", \"a page to browse films and shows\", \"my own TV catalogue\". Covers the app shell, routes, rails, filter state, empty states, and the deploy shape that owns the CSP. Not for the metadata API itself (tmdb-metadata) or the player (streaming-provider-embeds).\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [movie-catalogue, streaming-site, spa, react, vite, watchlist]\n related_skills: [tmdb-metadata, streaming-provider-embeds, watchlist-sync, media-id-mapping, movie-night-calendar]\n---\n\n# movie-catalogue-site\n\n## Overview\n\nBuild the browsable half of a movie/TV catalogue: an app shell with home rails, search, category browse, a title detail page, and a watchlist route — on top of a metadata API and (optionally) third-party embed providers. This skill owns the **application structure**: module boundaries, routing, filter state, loading/empty states, and the deploy shape. The metadata contracts live in `tmdb-metadata`, the player in `streaming-provider-embeds`, persistence in `watchlist-sync`.\n\nThe stack that works and stays debuggable: a plain Vite + React SPA (no TypeScript, no component library, no state manager, no CSS framework), a generated REST layer over Postgres (PostgREST/Supabase) instead of a bespoke API server, and a reverse proxy that owns the security headers. Every layer you remove is one that can't break between you and a rendered page.\n\n## When to Use\n\n- The user wants a browsable catalogue of films/shows with posters, detail pages, and search.\n- An existing catalogue needs a new route, rail, or browse surface.\n- Playback, watchlist, or metadata work needs a place to live and the app shell doesn't exist yet.\n- The user asks for \"a personal Netflix\", \"a media browser\", \"a site for my movie list\".\n\nDo **not** use it for: a media *server* that transcodes and serves your own files (that's Jellyfin/Plex territory), or a plain flat list with no metadata lookups.\n\n## Workflow\n\nBuild in this order. Each step's output is the next step's input; skipping ahead means debugging two unknowns at once.\n\n1. **Scaffold and prove one API call.** `npm create vite@latest -- --template react`, add `react-router-dom`, put the metadata key in `.env` as `VITE_TMDB_API_KEY`, and render one rail of real posters. Nothing else — no player, no database. Done when a live response paints artwork.\n2. **Fail loudly on missing config.** Throw at module load if a required key is absent (see Config below). A missing key otherwise produces 401s that the fetch layer reports as \"no results\", and an empty search that says \"no matches\" is a lie.\n3. **Draw the module boundaries** (see Module Layout). One file per external boundary, so a third-party contract change lands in exactly one place.\n4. **Add the routes** (see Routes). Include a real 404 — not the shell over an empty body.\n5. **Build the title detail page.** One request with `append_to_response` rather than three. Cast, runtime, genres, certification, availability. This is the page every other surface links into, so it is the one worth over-building.\n6. **Add search and category browse**, with every active filter encoded in the query string (see Filter State).\n7. **Add the player** — `streaming-provider-embeds`. Add its origin to the proxy CSP in the *same change*, never a follow-up.\n8. **Add persistence** — `watchlist-sync`. Profiles, statuses, resume points.\n9. **Deploy behind a proxy that owns the CSP** (see Deploy). Bind-mount the build output so a rebuild is live without rebuilding a container.\n10. **Verify in a real browser**, not on a clean build. See Verification Checklist.\n\n## Module Layout\n\n```\nsrc/\n main.jsx mounts App\n App.jsx chrome (nav, footer) + AuthContext + ToastProvider + <Routes>\n lib/\n config.js env reading; throws on a missing required key\n tmdb.js every metadata API call, and nothing else\n omdb.js the secondary ratings source\n supabase.js every persistence call (PostgREST fetches)\n providers.js embed URL construction + the trusted-origin allowlist\n idMapping.js cross-system ID resolution\n watchProviders.js streaming *availability* (not playback — see the naming note)\n context/\n AuthContext.jsx which profile is active, and whether it may be edited\n components/ MovieCard, Rail, FilterBar, EpisodePicker, Player, …\n pages/ Home, Search, Category, TitleDetail, Watchlist, Profiles, NotFound\n```\n\n**Keep the origin allowlist in the same file that builds the embed URLs.** Split them and they drift: you add a provider in one and forget the other, which is the black-box failure in `streaming-provider-embeds`.\n\n**Naming trap worth heading off in a comment.** \"Providers\" means two unrelated things in this domain — *playback embed sources* (where the iframe points) and *watch providers* (which streaming services carry a title, from the metadata API). The reference implementation keeps them in `lib/providers.js` and `lib/watchProviders.js` with a header comment on each pointing at the other. Do the same or you will spend an afternoon reading the wrong file.\n\n## Routes\n\n```\n/ home — hero + rails (trending, popular, top rated, continue watching)\n/search?q=&genre=&year= results; every filter in the query string\n/category/:key browse one rail's full list, paginated\n/title/:mediaType/:id detail + player + episode picker\n/person/:id filmography\n/watchlist the list, with status facets\n/profiles switch / log in\n* a real 404\n```\n\n`:mediaType` is attacker-controlled (it comes straight off the URL). Validate it against `['movie','tv']` at the boundary before it reaches any fetch — see Common Pitfalls.\n\n## Config\n\n```js\n// src/lib/config.js\nfunction required(name) {\n const value = import.meta.env[name];\n if (!value) {\n throw new Error(\n `${name} is not set. Copy .env.example to .env and fill it in, then ` +\n `restart the dev server (Vite only reads .env at startup).`,\n );\n }\n return value;\n}\n\nexport const TMDB_API_KEY = required('VITE_TMDB_API_KEY');\nexport const OMDB_API_KEY = required('VITE_OMDB_API_KEY');\n\n// Same-origin relative path, proxied to the REST layer by the front gate.\n// NOT a hardcoded `http://host:8124` — that breaks under HTTPS (mixed content)\n// and under any access path that only forwards one port.\nexport const API_URL = '/api';\n```\n\nShip a tracked `.env.example` and gitignore `.env`. State plainly in the example file that Vite inlines every `VITE_*` value into the bundle at build time, so those keys are readable by anyone with devtools. That is normal for a browse-only metadata key and is *not* a leak — but a write-scoped or database credential must never go there.\n\n## Filter State Belongs in the URL\n\nEncode every active filter as a query parameter. Shareable and bookmarkable views come free, and the browser's own back/forward restores them with no history code. Three rules that make it behave:\n\n- **Scope sticky persistence per page type.** A filter set on search must not leak into category browse even when the parameter names match.\n- **Persist only deliberate changes.** A filter that merely arrived in a shared URL should not become that visitor's sticky default.\n- **Use `replace`, not `push`, when writing filters.** Otherwise adjusting four selects buries the page you came from under four history entries.\n\n## Loading, Empty, and Error States\n\nThree states look identical if you collapse them into one nullable variable: *haven't asked yet*, *asked and the answer is nothing*, *asked and it failed*. Carry a separate `loaded` flag. Collapsing 1 and 2 is exactly why catalogue pages flash \"nothing available\" on every load before data arrives.\n\n- Skeleton cards while loading, sized like the real card so nothing reflows.\n- \"No listed way to watch this in your region\" is a real answer. An empty row is a bug report waiting to happen.\n- Never render a dead affordance. If a button can't work here, say why in one line next to it rather than greying it out silently.\n\n## Deploy\n\nStatic build behind a reverse proxy, plus the REST layer, plus one shared auth gate in front of both. Neither the static site nor the API publishes a host port of its own — every path goes through the gate.\n\n```nginx\nserver {\n listen 80;\n root /usr/share/nginx/html;\n\n add_header X-Content-Type-Options \"nosniff\" always;\n add_header Referrer-Policy \"strict-origin-when-cross-origin\" always;\n add_header Content-Security-Policy \"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://images.metadata.example.com; connect-src 'self' https://api.metadata.example.com https://images.metadata.example.com https://ratings.example.com; media-src 'self' blob:; worker-src 'self' blob:; frame-src https://player.example.com https://*.player.example.com\" always;\n\n location /app/assets/ { try_files $uri =404; } # real assets 404 properly\n location /app/ { try_files $uri $uri/ /app/index.html; } # SPA fallback\n\n location = / { return 301 $real_scheme://$http_host/app/; }\n}\n```\n\nSubstitute the real hosts for the placeholders. In a TMDB-backed app they are `api.themoviedb.org` (connect), `image.tmdb.org` (img + connect if you read poster pixels), `www.omdbapi.com` (connect), and whichever embed hosts you integrated (frame) — see `tmdb-metadata` and `streaming-provider-embeds`.\n\nFour things in that block are load-bearing and each has cost someone an afternoon:\n\n- **`add_header` is not inherited into a location that declares its own.** The child list replaces the parent list entirely. Add one `add_header` inside a `location` and all the security headers silently vanish *for that location*.\n- **`always`**, or the headers are dropped on 301s and 404s.\n- **Assets location before the SPA fallback**, or a missing favicon silently serves the app shell with a 200.\n- **`return 301 $real_scheme://$http_host/…`**, not a bare `return 301 /app/;`. nginx builds the bare form's `Location` from `$scheme://$host`, which drops the port and ignores the Host the client actually used — so every visitor gets bounced to `http://localhost/...` regardless of the domain they typed. Map `$http_x_forwarded_proto` to `$real_scheme` with a `$scheme` fallback.\n\n**Bind-mount the build output** rather than baking it into an image. A rebuild is then live without rebuilding any container, and only proxy config changes need a restart. It removes an entire class of \"why is my fix not deployed\".\n\n## Common Pitfalls\n\n1. **Route params reaching a query string unvalidated.** `:mediaType` off the URL interpolated into a PostgREST filter is *filter-param injection* — not SQL injection (PostgREST parameterises), but an attacker can inject extra filters, operators, or `select=` columns. Validate against a closed list, and coerce every id with `Number.isInteger(n) && n > 0`.\n2. **Adding a provider without touching the proxy CSP.** Renders a black box with no catchable error, and your own failure detection then reports the *title* as unavailable — a lie that sends you debugging the wrong layer. Code change and CSP change ship together, always.\n3. **Two response shapes from one API.** List endpoints return `genre_ids: [28,12]`; detail endpoints return `genres: [{id,name}]`. Code that reads one silently renders nothing on the other. Normalise at the boundary — see `tmdb-metadata`.\n4. **A hardcoded API origin.** Breaks the moment the site is reached over HTTPS, a tunnel, or a different port. Use a same-origin relative path the gate proxies.\n5. **Cross-origin fetch without `credentials: 'include'`.** The gate's session cookie is silently not sent, and every API call 401s only in production.\n6. **Building stats from a resume-point column.** `last_position_seconds` is overwritten on every progress update — see `watchlist-sync` for why summing it produces a confidently wrong number.\n7. **`fullPage: true` screenshots for scroll-reveal content.** The capture expands the viewport virtually rather than scrolling, so `IntersectionObserver` reveals never fire and working content looks blank. Scroll in increments before asserting.\n\n## Limitations\n\nThis skill does **not**:\n\n- Host, transcode, or serve video files. It builds a catalogue that *links out* or embeds third parties.\n- Supply the metadata API contracts — that is `tmdb-metadata`.\n- Integrate or debug an embed provider — that is `streaming-provider-embeds`.\n- Define the persistence schema or auth — that is `watchlist-sync`.\n- Provide a design system. It states structure and state rules only; pair it with a design skill for the visual layer.\n- Grant you any right to redistribute content. See the note in `streaming-provider-embeds`.\n\n## Verification Checklist\n\n- [ ] Home, search, a category, a title detail, and a bogus URL all render — the last as a real 404, not the shell over an empty body\n- [ ] A deep link (`/title/tv/1396`) works on a hard refresh, not just via in-app navigation\n- [ ] Missing `.env` throws a named error at boot instead of rendering an empty catalogue\n- [ ] Every filter is in the query string and survives back/forward\n- [ ] Loading, empty, and error states are visually distinct on at least one slow/failing route\n- [ ] The deployed CSP is applied in the verification run (re-attach it via route interception — `vite preview` sends none) and the console shows zero CSP violations\n- [ ] Security headers are present on a 404 and on the root 301, not just on 200s\n- [ ] A real browser loaded the page; a `curl` 200 does not prove it renders\n", "readme_content": "# movie-catalogue-site\n\nBuild the browsable half of a movie/TV catalogue — home rails, search, category browse, title detail, watchlist — as a small, debuggable SPA over a metadata API.\n\n## What it does\n\nThe agent scaffolds and structures the app: module boundaries that put each external contract in exactly one file, routes including a real 404, filter state encoded in the URL, honest loading/empty/error states, and a deploy shape where a reverse proxy owns the security headers.\n\nIt deliberately keeps the stack small — Vite + React, plain JS, no component library, no state manager, no CSS framework, no bespoke API server. Every layer removed is one that can't break between you and a rendered page.\n\nThe metadata API, the player, and the watchlist each have their own skill. This one is the shell they plug into.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/movie-catalogue-site/SKILL.md\n```\n\n## What you get\n\n```\nsrc/\n main.jsx / App.jsx chrome + auth context + routes\n lib/\n config.js env reading; throws on a missing required key\n tmdb.js every metadata call, and nothing else\n supabase.js every persistence call\n providers.js embed URLs + trusted-origin allowlist (one file, never two)\n watchProviders.js streaming availability (different thing — see below)\n pages/ Home, Search, Category, TitleDetail, Watchlist, Profiles, NotFound\n components/ MovieCard, Rail, FilterBar, EpisodePicker, Player\ndeploy/\n nginx.conf SPA fallback + CSP (the CSP lives here, not in the app)\n docker-compose.yml\n```\n\n## Routes\n\n```\n/ home — hero + rails\n/search?q=&genre=&year= results; every filter in the query string\n/category/:key full paginated list for one rail\n/title/:mediaType/:id detail + player + episode picker\n/person/:id filmography\n/watchlist the list, with status facets\n/profiles switch / log in\n* a real 404\n```\n\n## Two things called \"providers\"\n\nThis domain overloads the word, and mixing them up costs an afternoon:\n\n| File | Means | Example |\n|---|---|---|\n| `lib/providers.js` | **playback embed sources** — where the iframe points | the player host serving `/embed/movie/550` |\n| `lib/watchProviders.js` | **watch providers** — which services legitimately carry a title | Netflix, Hulu, Disney+ from `/watch/providers` |\n\nPut a header comment on each pointing at the other.\n\n## Fail loudly on missing config\n\n```js\nfunction required(name) {\n const value = import.meta.env[name];\n if (!value) {\n throw new Error(`${name} is not set. Copy .env.example to .env and fill it in, ` +\n `then restart the dev server (Vite only reads .env at startup).`);\n }\n return value;\n}\nexport const TMDB_API_KEY = required('VITE_TMDB_API_KEY');\n```\n\nWithout this, a missing key produces 401s that the fetch layer reports as \"no results\" — and a search that says \"no matches\" when it means \"no credentials\" is a lie that hides for days.\n\nShip a tracked `.env.example`, gitignore `.env`, and say in the example that Vite inlines every `VITE_*` value into the bundle: those keys are public on the deployed site. Fine for a browse-only metadata key, never for a database credential.\n\n## Filter state in the URL\n\nEncode every active filter as a query parameter — shareable, bookmarkable, and back/forward works with no history code. Three rules:\n\n- Scope sticky persistence per page type (a search filter must not leak into category browse).\n- Persist only deliberate changes — a filter that arrived in someone else's shared link is not a preference.\n- Write filters with `replace`, not `push`, or four selects bury the page you came from.\n\n## Deploy: the proxy owns the CSP\n\n```nginx\nlocation /app/assets/ { try_files $uri =404; } # assets 404 properly\nlocation /app/ { try_files $uri $uri/ /app/index.html; } # SPA fallback\nlocation = / { return 301 $real_scheme://$http_host/app/; }\n```\n\nFour load-bearing details:\n\n- **`add_header` is not inherited into a location that declares its own** — the child list replaces the parent list entirely, and your security headers vanish for that location.\n- **`always`**, or headers are dropped on 301s and 404s.\n- **Assets block before the SPA fallback**, or a missing favicon serves the app shell with a 200.\n- **`return 301 $real_scheme://$http_host/…`** — the bare `return 301 /app/;` form builds `Location` from `$scheme://$host`, drops the port, and bounces every visitor to whatever the config says instead of the domain they used.\n\nBind-mount the build output instead of baking it into an image: a rebuild goes live without rebuilding a container.\n\n## Pitfalls\n\n- **Route params reaching a query string unvalidated.** `:mediaType` off the URL in a PostgREST filter is filter-param injection — extra filters, operators, or `select=` columns. Validate against a closed list; coerce ids with `Number.isInteger(n) && n > 0`.\n- **Adding a player without touching the CSP.** Black box, no catchable error, and your own failure detection blames the title. Code and CSP ship together.\n- **Two response shapes.** List endpoints give `genre_ids`, detail endpoints give `genres`. Normalise at the boundary.\n- **Hardcoded API origin.** Breaks under HTTPS (mixed content) and under any tunnel forwarding one port. Use a same-origin path the gate proxies.\n- **Cross-origin fetch without `credentials: 'include'`.** The gate cookie is dropped and every write 401s in production only.\n- **`page.screenshot({ fullPage: true })` for reveal-on-scroll content.** It expands the viewport virtually rather than scrolling, so `IntersectionObserver` never fires and working content photographs blank.\n\n## Honest limitations\n\n- It does not host, transcode, or serve video. It builds a catalogue that links out or embeds third parties.\n- Metadata contracts, player integration, and persistence each live in their own skill (`tmdb-metadata`, `streaming-provider-embeds`, `watchlist-sync`).\n- It provides structure and state rules, not a visual design system.\n- Anything you embed from a third-party host is that host's content under that host's terms. This skill takes no position on, and grants no rights to, what those hosts serve.\n\n## Part of\n\n[Hermes Skills Portfolio](https://github.com/THEROCKSSS/hermes-skills-portfolio) — empowering skills for the Hermes agent.\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/movie-catalogue-site/SKILL.md" }, { "name": "tmdb-metadata", "category": "media", "tier": "featured", "description": "Use when wiring TMDB (and OMDb) into an app — browse rails, search, title detail, posters/backdrops, cast, episodes, certifications, streaming availability, IMDb/Rotten Tomatoes/Metacritic ratings — or when a TMDB-backed feature misbehaves, such as a filter that appears to do nothing, an empty search that should have matched, a genre chip that renders blank, a missing runtime, or a poster that taints a canvas.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/tmdb-metadata/SKILL.md", "path": "skills/tmdb-metadata", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-08-02", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "tmdb-metadata", "description": "Use when wiring TMDB (and OMDb) into an app — browse rails, search, title detail, posters/backdrops, cast, episodes, certifications, streaming availability, IMDb/Rotten Tomatoes/Metacritic ratings — or when a TMDB-backed feature misbehaves, such as a filter that appears to do nothing, an empty search that should have matched, a genre chip that renders blank, a missing runtime, or a poster that taints a canvas.", "version": "1.0.0" }, "agent_use": "- Adding browse rails, search, a detail page, an episode list, or a cast list to an app.\n- Adding ratings from IMDb/RT/Metacritic (that's OMDb, keyed by IMDb id, which comes from TMDB).\n- Adding \"where can I watch this\" availability.\n- Debugging: a filter that changes nothing, a search returning too few results, blank genre chips, runtime: null, a SecurityError from a canvas read of a poster.", "user_use": "Wire TMDB as the metadata spine of a catalogue app — titles, artwork, cast, genres, episodes, certifications, streaming availability — with OMDb as the secondary source for IMDb / Rotten Tomatoes / Metacritic ratings.", "skillmd_content": "---\nname: tmdb-metadata\ndescription: Use when wiring TMDB (and OMDb) into an app — browse rails, search, title detail, posters/backdrops, cast, episodes, certifications, streaming availability, IMDb/Rotten Tomatoes/Metacritic ratings — or when a TMDB-backed feature misbehaves, such as a filter that appears to do nothing, an empty search that should have matched, a genre chip that renders blank, a missing runtime, or a poster that taints a canvas.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [tmdb, omdb, movie-metadata, api-integration, posters, ratings]\n related_skills: [movie-catalogue-site, streaming-provider-embeds, media-id-mapping, http-api-tester]\n---\n\n# tmdb-metadata\n\n## Overview\n\nWire TMDB as the metadata spine of a catalogue app — titles, artwork, cast, genres, episodes, certifications, streaming availability — with OMDb as the secondary source for IMDb / Rotten Tomatoes / Metacritic ratings. A free TMDB v3 key is the only credential the browse half of such an app strictly needs.\n\nTMDB is well-behaved but has three sharp edges that fail *silently*: two different response shapes for the same field, filter parameters that are accepted and ignored on the wrong endpoint, and community-maintained data with real holes. Each one renders a plausible-looking wrong page rather than an error.\n\n## When to Use\n\n- Adding browse rails, search, a detail page, an episode list, or a cast list to an app.\n- Adding ratings from IMDb/RT/Metacritic (that's OMDb, keyed by IMDb id, which comes from TMDB).\n- Adding \"where can I watch this\" availability.\n- Debugging: a filter that changes nothing, a search returning too few results, blank genre chips, `runtime: null`, a `SecurityError` from a canvas read of a poster.\n\nDo not use it for playback (`streaming-provider-embeds`) or for anime/MAL id bridging (`media-id-mapping`).\n\n## Workflow\n\n1. **Get a free v3 API key** at <https://www.themoviedb.org/settings/api>. Put it in `.env` as `VITE_TMDB_API_KEY` (or your framework's equivalent) and throw at module load if it is absent.\n2. **Write one fetch wrapper** and route every call through it (see Fetch Wrapper). Nothing else in the app builds a TMDB URL.\n3. **Build browse rails** from the list endpoints, then **search**, then **detail** — in that order, each proven against a live response before the next.\n4. **Normalise the genre shape at the boundary** the first time you render a genre, not later (see Two Response Shapes).\n5. **Use `/discover` for browsing-by-criteria and `/search` for browsing-by-name.** They are not interchangeable — see Search Ignores Filters.\n6. **Add OMDb only after `external_ids` is landing an `imdb_id`.** OMDb is keyed on IMDb id; without one there is nothing to ask.\n7. **Render every absence deliberately.** Missing data is normal here; substituting a plausible value is the failure mode.\n\n## Endpoint Set\n\n| Purpose | Endpoint | Notes |\n|---|---|---|\n| Rails / browse | `/trending/{all,movie,tv}/{day,week}`, `/movie/popular`, `/movie/top_rated`, `/tv/popular`, `/tv/top_rated`, `/tv/airing_today` | list shape — carries `genre_ids`, not `genres` |\n| Search | `/search/multi` | mixed movie + tv + person; filter to the types you want |\n| Filtered browse | `/discover/{movie,tv}` | the only endpoints that honour filter params |\n| Detail | `/{movie,tv}/{id}` | full shape — `genres`, `runtime`, and `external_ids` when appended |\n| Episodes | `/tv/{id}/season/{n}` | per-season episode list with per-episode runtimes |\n| Cast + crew | `/{movie,tv}/{id}/credits` | |\n| Filmography | `/person/{id}/combined_credits` | |\n| Certification | `/movie/{id}/release_dates`, `/tv/{id}/content_ratings` | region-scoped, different shapes per type |\n| Availability | `/{movie,tv}/{id}/watch/providers` | keyed by region code |\n| Service catalogue | `/watch/providers/{movie,tv}?watch_region=XX` | for a \"filter by service\" picker |\n\nAppend `?append_to_response=external_ids,credits` to a detail request to collapse three round-trips into one.\n\n## Fetch Wrapper\n\n```js\nconst BASE = 'https://api.themoviedb.org/3';\n\nasync function tmdbFetch(path, params = {}) {\n const url = new URL(BASE + path);\n url.searchParams.set('api_key', TMDB_API_KEY);\n url.searchParams.set('language', 'en-US');\n Object.entries(params).forEach(([k, v]) => {\n if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, v);\n });\n const res = await fetch(url);\n if (!res.ok) throw new Error(`TMDB ${path} failed: ${res.status}`);\n return res.json();\n}\n```\n\nDropping empty-string params matters: `with_genres=` is not \"no filter\", it is a malformed filter.\n\n## Two Response Shapes, One Field Difference\n\nList endpoints return `genre_ids: [28, 12]`. Detail endpoints return `genres: [{id, name}]`. Code that reads one silently renders nothing on the other — no error, just an absent row.\n\n```js\nexport function genreNames(item, limit = 3) {\n if (!item) return [];\n const fromDetail = Array.isArray(item.genres) ? item.genres.map((g) => g?.id) : null;\n const ids = fromDetail || (Array.isArray(item.genre_ids) ? item.genre_ids : []);\n const out = [];\n for (const id of ids) {\n const name = GENRE_NAMES[id];\n if (name && !out.includes(name)) out.push(name); // unknown ids dropped, not printed as \"18\"\n if (out.length >= limit) break;\n }\n return out;\n}\n```\n\nGenre ids are stable and few enough to hold as a static map rather than fetching `/genre/*/list` every session. Movie-only and TV-only ids share one namespace and do not collide, so a single lookup covers both — TV adds `10759 Action & Adventure`, `10762 Kids`, `10763 News`, `10764 Reality`, `10765 Sci-Fi & Fantasy`, `10766 Soap`, `10767 Talk`, `10768 War & Politics` alongside the movie set.\n\nDerive any picker options from the same map rather than typing a second literal; two literals drift the day one is edited.\n\n## Search Ignores Filters\n\nThe trap that costs a day: `with_watch_providers`, `with_genres` and friends are **accepted and ignored** by `/search/*`. No error, no warning.\n\nMeasured live against the API (2026-07-30, one reference implementation): `/search/multi?query=Rick and Morty` returned `total_results: 2` both with and without `with_watch_providers`, while the same parameter on `/discover/tv` moved `228,046 → 3,449`.\n\nConsequences:\n\n- **Filter search results client-side.** Fetch, then narrow in the browser.\n- **Use `/discover` when the user browses by criteria** rather than by name. That is what it is for, and it filters server-side.\n- **A page is 20 results.** Offer several filters against a 20-row pool and two selections empty it — which reads as \"nothing matches\" but means \"we didn't ask for enough\". Fetch two or three pages and merge, cap deliberately, treat later pages as best-effort, and de-duplicate on `type:id` (the index shifts between requests and repeats rows).\n\n## Images\n\n```\nhttps://image.tmdb.org/t/p/{size}{path}\n```\n\nPoster sizes `w185 w342 w500`; backdrop sizes `w780 w1280 original`. Request the size you will display — a `w1280` backdrop in a 185px rail is pure waste. Return `null` for a null path so callers can omit the element rather than render a broken image.\n\n`image.tmdb.org` sends `Access-Control-Allow-Origin: *`, so canvas pixel reads work **with** `crossOrigin=\"anonymous\"` and throw `SecurityError` from `getImageData` without it. If you derive an accent colour from the poster, handle the block path: one `SecurityError` should set a session flag that skips the decode for every later title, so a CDN policy change degrades to your default accent rather than a half-applied palette.\n\n## OMDb for IMDb / RT / Metacritic\n\nTMDB does not carry Rotten Tomatoes or Metacritic. OMDb does, keyed by IMDb id — which TMDB gives you in `external_ids`.\n\n```js\nexport async function fetchOmdbByImdbId(imdbId) {\n if (!imdbId) return null;\n const url = new URL('https://www.omdbapi.com/');\n url.searchParams.set('apikey', OMDB_API_KEY);\n url.searchParams.set('i', imdbId);\n url.searchParams.set('plot', 'short');\n const res = await fetch(url);\n const data = await res.json();\n return data.Response === 'True' ? data : null; // OMDb signals failure in the body, not the status\n}\n\nexport function parseRatings(omdb) {\n if (!omdb) return { imdb: null, rt: null, metacritic: null };\n const ratings = omdb.Ratings || [];\n return {\n imdb: omdb.imdbRating && omdb.imdbRating !== 'N/A' ? omdb.imdbRating : null,\n rt: ratings.find((r) => r.Source === 'Rotten Tomatoes')?.Value || null,\n metacritic: (ratings.find((r) => r.Source === 'Metacritic')?.Value || omdb.Metascore) ?? null,\n };\n}\n```\n\nThree OMDb facts worth knowing before you rely on it:\n\n- **It reports failure in the body.** `Response: \"False\"` arrives with HTTP 200. Checking `res.ok` alone treats an error as data.\n- **`\"N/A\"` is a real value it returns.** Filter it explicitly or it renders as a rating.\n- **The free tier is 1,000 requests/day**, and `thewdb` is OMDb's shared public demo key — it works, but it is rate-limited across every project using it, and a 401 silently removes your ratings row. Get your own.\n\n## Availability (`/watch/providers`)\n\nReturns a map keyed by **region code**, each holding tiers: `flatrate` (subscription), `free`, `ads`, `rent`, `buy` — plus a `link` to an aggregator page for that title in that region.\n\n- **Label the region you are reporting.** The same title is subscription in one country and purchase-only in another. An unlabelled availability row is a claim about the wrong country for most visitors.\n- **No prices. Ever.** The payload carries services, not amounts. \"Cheapest\" is not derivable. If you want a primary call-to-action, order by *tier* — free, ads, subscription, rent, buy — and say in the interface that this is a ranking by kind of offer, not a price comparison.\n- **The `link` is not a deep link to the service.** It opens the aggregator's page for the title. Say so rather than implying otherwise.\n- **Filtering a grid by service is client-side** (see Search Ignores Filters), which means one request per row. So: fetch nothing until the filter is engaged; narrow on type/year/genre/rating first (those are free — already in the response you hold); cache per `type:id:region` for the session; bound concurrency to about five at a time; and treat a failed lookup as **unknown, not unavailable** — silently dropping it shrinks the result set for a reason the viewer cannot see.\n- For a service picker, `/watch/providers/{movie,tv}?watch_region=XX` returns the region's catalogue with `display_priorities[region]`. Sort by that rather than hand-writing a per-country list, and cap the picker — the raw list runs to hundreds of entries per region, mostly regional channel add-ons.\n\n## Keys and Exposure\n\nA browse-only metadata key in a client bundle is readable by anyone with devtools. That is the normal arrangement for this API tier and is not a leak — but:\n\n- **Never put a write-scoped or account key in the client.** Read-only only.\n- Rate limits are per key, so a public deployment shares yours.\n- Write access (rating a title as a user) belongs behind your own backend, not in the bundle.\n\n## Missing Data Is Normal\n\nTMDB is community-maintained. Expect routinely: `runtime: null` on individual episodes even when the series carries an average; no `backdrop_path`; a poster and nothing else; no availability for a valid region; an obscure title with zero votes.\n\n**Render the absence deliberately.** Do not substitute the series average for a missing episode runtime — that prints a confident number the data never said. Show the episode without a duration instead.\n\n## Common Pitfalls\n\n1. **Reading `genre_ids` on a detail response** (or `genres` on a list response). Silent blank.\n2. **Passing filters to `/search/*`** and concluding the filter is broken. Wrong endpoint.\n3. **Trusting `res.ok` on OMDb.** Failure is in the body.\n4. **Rendering `\"N/A\"`** from OMDb as a score.\n5. **Canvas reads without `crossOrigin=\"anonymous\"`** — `SecurityError`, and the whole tint feature dies on the first poster.\n6. **Fetching `/genre/*/list` per session** to restate a list that changes about never.\n7. **One availability request per row on page load.** Gate it behind the filter actually being used.\n8. **Assuming a certification exists.** Region-scoped, frequently absent, and shaped differently for movie (`release_dates`) vs TV (`content_ratings`).\n9. **Empty-string params.** `with_genres=` is malformed, not absent — drop empty values in the wrapper.\n\n## Limitations\n\nThis skill does **not**:\n\n- Supply an API key, negotiate rate limits, or cover TMDB's v4 account/write endpoints.\n- Cover playback or embed providers — see `streaming-provider-embeds`.\n- Bridge TMDB ids to MyAnimeList/AniList/Kitsu — see `media-id-mapping`.\n- Cache server-side. Everything here is session-scoped, client-side caching; a Redis/edge layer is a separate design.\n- Guarantee any measured figure quoted above still holds. Those are dated observations against a live third-party API, not contract.\n- Provide legal streaming rights of any kind. `/watch/providers` reports where a title is licensed; it does not license anything to you.\n\n## Verification Checklist\n\n- [ ] A rail, a search, and a detail page all render from live responses\n- [ ] Genre chips render on **both** a list card and a detail page (proves the shape normalisation)\n- [ ] A `/discover` filter visibly changes `total_results`; the same param on `/search` visibly does not (control case in the same run)\n- [ ] A title with no backdrop and an episode with `runtime: null` both render without an invented value\n- [ ] OMDb returns a rating for a title with an `imdb_id`, and a title without one skips the request entirely rather than sending `i=undefined`\n- [ ] Availability is labelled with the region it describes, shows no prices, and says \"no listed way to watch\" rather than rendering an empty row\n- [ ] Missing `TMDB_API_KEY` throws a named error at boot instead of producing an empty catalogue\n", "readme_content": "# tmdb-metadata\n\nWire TMDB into an app as the metadata spine — titles, artwork, cast, genres, episodes, certifications, availability — with OMDb for IMDb / Rotten Tomatoes / Metacritic ratings.\n\n## What it does\n\nThe agent builds the metadata layer: one fetch wrapper, the endpoint set that actually covers a catalogue app, and the boundary normalisation that keeps TMDB's two response shapes from silently rendering nothing.\n\nIt exists mostly because of three sharp edges that fail *quietly*: the same field arrives under two different names depending on the endpoint, filter parameters are accepted and ignored on `/search/*`, and the data is community-maintained with real holes. All three produce a plausible-looking wrong page rather than an error.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/tmdb-metadata/SKILL.md\n```\n\n## Endpoints that cover a catalogue app\n\n| Purpose | Endpoint |\n|---|---|\n| Rails / browse | `/trending/{all,movie,tv}/{day,week}`, `/movie/popular`, `/tv/top_rated`, `/tv/airing_today` |\n| Search | `/search/multi` |\n| Filtered browse | `/discover/{movie,tv}` — the only endpoints that honour filter params |\n| Detail | `/{movie,tv}/{id}?append_to_response=external_ids,credits` |\n| Episodes | `/tv/{id}/season/{n}` |\n| Cast + crew | `/{movie,tv}/{id}/credits` |\n| Certification | `/movie/{id}/release_dates`, `/tv/{id}/content_ratings` |\n| Availability | `/{movie,tv}/{id}/watch/providers` |\n| Service catalogue | `/watch/providers/{movie,tv}?watch_region=XX` |\n\nKeys: free TMDB v3 key from <https://www.themoviedb.org/settings/api>, free OMDb key from <https://www.omdbapi.com/apikey.aspx>. Both go in `.env` as `VITE_TMDB_API_KEY` / `VITE_OMDB_API_KEY` (or your framework's equivalent).\n\n## The three silent failures\n\n### 1. Two response shapes\n\nList endpoints return `genre_ids: [28, 12]`. Detail endpoints return `genres: [{id, name}]`. Read the wrong one and the row renders blank with no error.\n\n```js\nconst ids = Array.isArray(item.genres) ? item.genres.map((g) => g?.id) : item.genre_ids;\n```\n\nNormalise at the boundary the first time you render a genre. Hold the id→name map statically — genre ids are stable and small, and movie/TV ids share one non-colliding namespace.\n\n### 2. `/search/*` accepts and ignores filters\n\nMeasured live (2026-07-30, one reference implementation): `/search/multi?query=Rick and Morty` returned `total_results: 2` with and without `with_watch_providers`, while the same param on `/discover/tv` moved `228,046 → 3,449`.\n\nSo: `/discover` when the user browses **by criteria**, `/search` when they browse **by name**, and any filtering of search results happens client-side. A page is 20 results — offer several filters against that pool and two selections empty it, which reads as \"nothing matches\" but means \"we didn't ask for enough\". Fetch a couple of pages, merge, de-duplicate on `type:id`.\n\n### 3. Missing data is normal\n\n`runtime: null` on individual episodes, no `backdrop_path`, no availability for a valid region, zero votes on obscure titles. Render the absence — do not substitute the series average for a missing episode runtime. That prints a confident number the data never said.\n\n## OMDb notes\n\n```js\nconst res = await fetch(url);\nconst data = await res.json();\nreturn data.Response === 'True' ? data : null; // failure arrives with HTTP 200\n```\n\n- OMDb reports failure **in the body**, not the status. `res.ok` alone treats an error as data.\n- `\"N/A\"` is a real value it returns — filter it or it renders as a score.\n- Free tier is 1,000 requests/day. `thewdb` is OMDb's shared public demo key: it works, it is rate-limited across every project using it, and a 401 silently drops your ratings row. Get your own.\n\n## Images\n\n```\nhttps://image.tmdb.org/t/p/{size}{path}\n```\n\nPosters `w185 w342 w500`, backdrops `w780 w1280 original`. Request the size you display. `image.tmdb.org` sends `Access-Control-Allow-Origin: *`, so canvas pixel reads work with `crossOrigin=\"anonymous\"` — and throw `SecurityError` without it. If you derive a poster accent colour, make one `SecurityError` set a session flag that skips the decode for every later title, so a CDN policy change degrades to a default rather than a half-applied palette.\n\n## Availability, honestly\n\n`/watch/providers` returns tiers per region: `flatrate`, `free`, `ads`, `rent`, `buy`, plus a `link` to an aggregator page.\n\n- **Label the region.** The same title is subscription in one country and purchase-only in another.\n- **No prices.** The payload has services, not amounts — \"cheapest\" is not derivable. Rank by tier and say that's what you're doing.\n- **The `link` is not a deep link into the service.** It opens the aggregator's title page.\n- **Filtering a grid by service is client-side**, so: nothing fetched until the filter is engaged, narrow on free fields first, cache per `type:id:region`, cap concurrency around five, and treat a failed lookup as **unknown, not unavailable**.\n\n## Key exposure\n\nA browse-only key in a client bundle is readable by anyone with devtools. That's normal for this tier and not a leak — but never put a write-scoped or account key there, and remember rate limits are per key, so a public deployment shares yours.\n\n## Honest limitations\n\n- Does not supply keys, raise rate limits, or cover TMDB v4 account/write endpoints.\n- Does not cover playback or embed providers (`streaming-provider-embeds`) or MAL/AniList id bridging (`media-id-mapping`).\n- Caching described here is session-scoped and client-side; a server/edge cache is a separate design.\n- The measured figures quoted above are dated observations against a live third-party API, not a contract — re-measure before relying on them.\n- `/watch/providers` reports where a title is licensed. It licenses nothing to you.\n\n## Part of\n\n[Hermes Skills Portfolio](https://github.com/THEROCKSSS/hermes-skills-portfolio) — empowering skills for the Hermes agent.\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/tmdb-metadata/SKILL.md" }, { "name": "streaming-provider-embeds", "category": "media", "tier": "featured", "description": "Use when integrating, switching, or debugging a third-party video embed in a catalogue app — the iframe shows a black box, plays nothing, hangs on a spinner, refuses to play with a sandbox attribute, or reports no progress; or when onboarding a new embed host and you need its URL pattern, postMessage contract, and failure signals captured rather than guessed.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/streaming-provider-embeds/SKILL.md", "path": "skills/streaming-provider-embeds", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-08-02", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "streaming-provider-embeds", "description": "Use when integrating, switching, or debugging a third-party video embed in a catalogue app — the iframe shows a black box, plays nothing, hangs on a spinner, refuses to play with a sandbox attribute, or reports no progress; or when onboarding a new embed host and you need its URL pattern, postMessage contract, and failure signals captured rather than guessed.", "version": "1.0.0" }, "agent_use": "- Adding a player to a catalogue app, or adding a second/third source with a switcher.\n- An embed shows a black box, an endless spinner, or plays nothing.\n- Progress/resume tracking doesn't fire, or saves undefined/0.\n- Onboarding a host with thin, wrong, or client-rendered documentation.", "user_use": "Integrate third-party embed hosts that serve a player for a title id.", "skillmd_content": "---\nname: streaming-provider-embeds\ndescription: Use when integrating, switching, or debugging a third-party video embed in a catalogue app — the iframe shows a black box, plays nothing, hangs on a spinner, refuses to play with a sandbox attribute, or reports no progress; or when onboarding a new embed host and you need its URL pattern, postMessage contract, and failure signals captured rather than guessed.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [iframe-embed, video-player, csp, postmessage, black-box-debugging]\n related_skills: [movie-catalogue-site, media-id-mapping, tmdb-metadata, watchlist-sync]\n---\n\n# streaming-provider-embeds\n\n## Overview\n\nIntegrate third-party embed hosts that serve a player for a title id. You do not control them, cannot read inside their frame, and they change without notice — so every contract you hold is **captured on a date**, not documentation.\n\nThe whole domain is one problem wearing different hats: **a wrong answer here does not error.** A blocked iframe renders a plain black rectangle. A refusing host returns HTTP 200 with an error page. A wrong id plays a *different show* at full quality with no warning. An unavailable dub plays nothing, identical to the provider being down.\n\nSo the discipline throughout is: **make failure observable, or refuse to act.**\n\n### Before you integrate anything — the honest framing\n\nThird-party embed hosts are not licensed sources and this skill does not pretend otherwise. They aggregate streams whose provenance you cannot verify, they are frequently blocked by DNS/ISP/hosting providers, they serve popup and redirect ads, they change or disappear without notice, and embedding one may breach the terms of your host, your CDN, or your jurisdiction's copyright law. If your goal is \"where can I legally watch this\", the answer is the metadata API's availability endpoint (see `tmdb-metadata`), not an embed. Integrate an embed only with that understood, and never describe such a source in your UI as licensed, official, or authorised.\n\n## When to Use\n\n- Adding a player to a catalogue app, or adding a second/third source with a switcher.\n- An embed shows a black box, an endless spinner, or plays nothing.\n- Progress/resume tracking doesn't fire, or saves `undefined`/`0`.\n- Onboarding a host with thin, wrong, or client-rendered documentation.\n- Deciding whether a documented parameter is actually consumed.\n\n## Workflow\n\n1. **Control probe the host before writing any app code** (see The Control Probe). Establish what a hit and a miss each look like.\n2. **Check the host's own framing policy** — `X-Frame-Options`, its own CSP — and follow redirects, recording every host in the chain.\n3. **Allowlist every host in that chain in your proxy's `frame-src`, in the same change as the code.** Not a follow-up commit.\n4. **Build the embed URL in one module**, next to the trusted-origin allowlist (see URL Construction).\n5. **Capture the postMessage contract live** for a full session before parsing anything (see postMessage Capture).\n6. **Add the origin to the allowlist only if it sends something worth parsing.**\n7. **Add explicit failure detection** — an error event if published, plus a conservative silence watchdog (see Detecting Failure).\n8. **Sweep a sample of real ids to measure coverage** and report the number with its sample size.\n9. **Confirm a real video plays in a real browser.** An iframe whose `src` resolves is not a video that plays.\n\n## The Control Probe\n\nBefore concluding anything about a host, request **two** things: an id you believe is real, and one that certainly is not (`99999999`).\n\nIf both responses look the same, your probe is broken and every measurement after it is meaningless. This catches, in one step: being blocked before you reach the routing layer, an SPA shell that returns 200 for everything, and your own malformed request.\n\n**Worked example from a real integration.** A bare `curl` of one host returned an identical 3,545-byte body for a real id, a second real id, and a bogus one. That looked like a dead provider. It was a `410` error page: the host refuses requests without an embed-shaped `Referer`. With the headers added, real ids returned a real player and the bogus id returned a distinguishable `404`.\n\n```bash\ncurl -sS -o /dev/null -w '%{http_code} %{size_download}\\n' \\\n -H \"Referer: https://localhost/\" \\\n -H \"Sec-Fetch-Dest: iframe\" \\\n -H \"Sec-Fetch-Mode: navigate\" \\\n -H \"Sec-Fetch-Site: cross-site\" \\\n -A \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36\" \\\n \"https://provider.example.com/embed/550\"\n```\n\nA `410`/`403` from a bare curl means **your request was malformed**, not that the title is missing. Do not record it as a coverage miss. Note that hosts typically check only that a plausible cross-site `Referer` is *present*, not which origin it names — so keep it configurable (`PROBE_REFERER`) and default it to something neutral rather than baking your deployment's hostname into a file that may go public.\n\n## The Five Traps\n\n### 1. CSP `frame-src` renders a black box\n\nThe most expensive failure in this domain. Adding a provider to your code without adding its origin to `frame-src` means the browser refuses to load the frame. The result is **a plain black rectangle** — no exception, no `onerror`, nothing your app can observe. Your own failure detection then reports the *title* as unavailable, which is false and sends you debugging the wrong layer entirely.\n\n```\nframe-src https://provider.example.com https://*.provider.example.com;\n```\n\nConsole-only symptom:\n\n```\nRefused to frame 'https://provider.example.com/' because it violates the following\nContent Security Policy directive: \"frame-src ...\"\n```\n\n**Rule: the CSP entry and the provider list are one change, never two.** Put a comment in the proxy config naming the module the list mirrors.\n\n### 2. Direct access is often disabled\n\nSeveral hosts serve embeds only when the request looks like an iframe navigation from a site. A plain fetch gets an error page regardless of whether the title exists. Use the header set from the control probe above.\n\n### 3. The host refuses framing outright\n\nSome hosts set their own `X-Frame-Options: sameorigin`. No CSP change on your side helps — the provider has decided.\n\n```bash\ncurl -sS -D - -o /dev/null \"https://provider.example.com/embed/550\" \\\n | grep -iE \"^(x-frame|content-security|location)\"\n```\n\nVerify against a policy-free page before blaming your own CSP. A real observed case: a host answered its own embed with `X-Frame-Options: sameorigin`, producing `Refused to display '…' in a frame because it set 'X-Frame-Options' to 'sameorigin'` — nothing in the app could fix it.\n\n### 4. CSP re-checks after every redirect\n\nA host may 301 to a different origin, and CSP evaluates the **final** URL. Allowlisting only the URL you wrote still fails.\n\nFound live, not theoretically: an embed URL answered with a 301 to a different host, and listing only the first host blocked playback with `Framing 'https://other-host/' violates … \"frame-src …\"`. Both hosts had to be listed. `curl -L -o /dev/null -w '%{num_redirects} %{url_effective}\\n'` tells you what to allowlist.\n\n### 5. `sandbox` is detectable\n\nAdding a `sandbox` attribute as an ad mitigation is detected by some hosts *by the attribute's mere presence*, regardless of which tokens you grant. They then refuse to play — one observed host showed an explicit \"Iframe Sandbox Detected\" error, another hung on a spinner forever. A never-loading player is worse than the popup you were preventing.\n\nThe workable mitigation is a **click-catch overlay**: a transparent layer over the frame that absorbs the first interaction and shows a \"tap to play\" affordance, so a click-triggered popup fires on your overlay instead of inside the frame. It costs one extra tap, touches nothing about the iframe, and re-arms on every new `src`.\n\n```jsx\n<div className=\"player\">\n <iframe\n key={src}\n src={src}\n title=\"Player\"\n allow=\"autoplay; fullscreen; encrypted-media; picture-in-picture\"\n allowFullScreen\n loading=\"lazy\"\n />\n {overlayActive && (\n <button className=\"player__click-catch\" onClick={() => setOverlayActive(false)}>\n Tap again to play\n </button>\n )}\n</div>\n```\n\n`key={src}` is deliberate: changing the key remounts the frame, which is the only way to make a URL-parameter seek take effect.\n\n## URL Construction and the Origin Allowlist\n\nOne module owns both, so they cannot drift:\n\n```js\nexport const PROVIDERS = [\n { key: 'alpha', label: 'Alpha' },\n { key: 'beta', label: 'Beta' },\n { key: 'gamma', label: 'Gamma', animeOnly: true }, // no plain movie/TV route at all\n];\n\nexport function buildEmbedUrl(provider, { mediaType, tmdbId, season, episode, startAtSeconds }) {\n if (provider === 'gamma') {\n // Anime-only host keyed on a foreign id system. Without a *resolved*\n // mapping there is nothing to build — return null so the caller can show\n // an honest \"no source\" panel instead of mounting a frame that can only 404.\n if (!isResolvedMapping(arguments[1].anime)) return null;\n ...\n }\n return mediaType === 'tv'\n ? `https://beta.example.com/tv/${tmdbId}/${season}/${episode}`\n : `https://beta.example.com/movie/${tmdbId}`;\n}\n\n// Full origins (scheme + host + port) exactly as `event.origin` reports them.\nexport const TRUSTED_EMBED_ORIGINS = Object.freeze([\n 'https://alpha.example.com',\n 'https://beta.example.com',\n]);\n\nexport function isTrustedEmbedOrigin(origin) {\n return typeof origin === 'string' && TRUSTED_EMBED_ORIGINS.includes(origin);\n}\n```\n\n`Array.includes` is exact element matching — correct. A **string** `includes` is not: `origin.includes('beta.example.com')` accepts `https://beta.example.com.attacker.com` and `https://evil.example.com/?beta.example.com`. This has been a real vulnerability in a shipped app; keep the array form.\n\n**Only allowlist an origin you gain something from.** An origin that publishes no player events buys you nothing and widens what you trust.\n\n**Return `null`, never a guessed URL.** A `null` src is what lets the caller render \"no source for this title, here's why, pick another\" instead of an empty black box that looks like a player loading forever.\n\n## Capability Table, Not Boolean Soup\n\nTwo abilities are separate and no host has both by accident. Collapsing them into one `supportsSkipIntro` flag hides which half is missing.\n\n```js\nconst PROVIDER_CAPABILITIES = Object.freeze({\n alpha: { reportsPosition: true, canSeekByUrl: false },\n beta: { reportsPosition: false, canSeekByUrl: true },\n gamma: { reportsPosition: true, canSeekByUrl: true },\n});\n```\n\n- `reportsPosition` — does the embed tell you where playback is? Without it you cannot know whether the viewer is inside the intro, and you must refuse to guess.\n- `canSeekByUrl` — can it be told to *start* at an offset, so remounting the frame is a real skip rather than a restart?\n\nRecord the **evidence** for every entry in a comment. A wrong flag here ships a button that silently does nothing. Derive any UI copy about which sources support a feature from this table, never from a hardcoded string that goes stale the day a host gains a parameter.\n\n## postMessage Capture\n\nPlayer events are how you get real progress and end-of-playback. Never assume a documented contract exists — watch for it:\n\n```js\nwindow.addEventListener('message', (e) => {\n let d = e.data;\n if (typeof d === 'string') { try { d = JSON.parse(d); } catch { /* keep raw */ } }\n console.log(e.origin, d);\n});\n```\n\nLoad a real embed, let it run, and record what actually arrives. **Documentation is a hypothesis.** In one measured comparison, one host published a full lifecycle contract and delivered it; another's docs implied events, and a live capture of 76 messages across a full session found every one to be third-party analytics chatter with no playback field at all — so that origin was deliberately left *out* of the allowlist, and the app degraded to a runtime timer instead.\n\nPayload shapes differ in ways that bite:\n\n- Position may be `time` **or** `currentTime`. Reading the wrong field silently saves a resume point of `undefined` or `0`.\n- The payload may be a JSON *string*, not an object.\n- The real event may be nested (`data.data.event`).\n\nHandle every shape defensively:\n\n```js\nfunction handleMessage(event) {\n if (!isTrustedEmbedOrigin(event.origin)) return;\n sawAnyMessageRef.current = true; // set BEFORE parsing — see below\n let data = event.data;\n if (typeof data === 'string') { try { data = JSON.parse(data); } catch { return; } }\n if (!data || typeof data !== 'object') return;\n const inner = data.data && typeof data.data === 'object' ? data.data : data;\n const evt = inner.event ?? data.event;\n const position = typeof inner.time === 'number' ? inner.time : inner.currentTime ?? data.currentTime;\n ...\n}\n```\n\nThrottle progress writes to roughly once every 15 seconds, and flush unthrottled on `visibilitychange` and `pagehide` — that last save is the one that makes resume feel right.\n\nAlso: **fire \"mark as watching\" on frame load, not on the first postMessage.** Some hosts only message on specific interactions, so a viewer who presses play and walks away would otherwise never register as watching at all.\n\n## Detecting Failure You Cannot See\n\nSame-origin policy means you cannot read the frame, and CORS usually blocks pre-probing the URL from the browser. Two signals remain.\n\n**An explicit error event**, if the host publishes one. Cheap and exact.\n\n**Silence.** A host's error page is typically static HTML with no scripts, so it posts *nothing*. A working player loads scripts that post *something* — even chrome chatter — long before playback starts. Measured on one host: a real id produced 1 message before playback began (a fullscreen-bridge call); a bogus id produced 0. That difference is the entire signal.\n\nSo the watchdog is **any message from the trusted origin proves the player loaded** — set the flag before parsing, because the useful evidence is often a message your parser would discard. Key it on recognised *playback* events instead and it fires whenever playback simply hasn't started (autoplay blocked, buffering, paused), yanking a working video away from the viewer.\n\n```js\nconst SILENT_FAILURE_MS = 20 * 1000; // must cover iframe load + player boot on a slow connection\n\nuseEffect(() => {\n if (!src || !onSourceError) return undefined;\n const timer = setTimeout(() => {\n if (!sawAnyMessageRef.current) onSourceError('silent');\n }, SILENT_FAILURE_MS);\n return () => clearTimeout(timer);\n}, [src, onSourceError]);\n```\n\nBe conservative: fire at most once per source, use a generous window, and escalate to a fallback route rather than straight to an error.\n\n## Manual Switching Beats Auto-Detection\n\nA cross-origin iframe cannot be reliably probed for \"does this host carry this title\", so **let the viewer pick a source and remember the choice** — per title, with a per-profile default underneath it.\n\nPrecedence goes one way only: a source explicitly chosen *on this title* always beats the profile default. The default answers \"where does a title I've never opened start\"; the moment someone answers that for a specific title by hand, that is the more specific statement of intent and outranks the blanket preference forever. Changing the default must never retroactively move a title someone already chose a source for.\n\nExclude anime-only or otherwise partial hosts from being settable as a *global* default — as a starting point one would hand `buildEmbedUrl` a null URL on every ordinary title, and the honest \"no source\" panel would be the first thing anyone saw.\n\nAnnounce an automatic switch only when one genuinely happened. Copy that claims a fallback the URL builder doesn't have is worse than silence.\n\n## Coverage Sweeps\n\nMeasure rather than assume. Sample real ids drawn from your own catalogue or mapping so every row genuinely resolves, make the sample **deterministic** (seeded shuffle) so later runs are comparable, rate-limit and bound concurrency, and keep the sweep script in the repo so the next refresh is re-measurable rather than re-argued.\n\nReport the number as measured, with the sample size: \"103/140 (73.6%)\" is useful and checkable; \"good coverage\" is neither, and \"we carry everything\" is a claim you will be held to.\n\n## Common Pitfalls\n\n1. **Provider added to the app, not to `frame-src`.** Black box. The single most expensive mistake here.\n2. **Substring origin checks.** `origin.includes('alpha.example.com')` accepts `https://alpha.example.com.attacker.net`.\n3. **Watchdog keyed on playback events.** Fires on a paused or buffering player and pulls a working video.\n4. **Reading the wrong position field.** `time` vs `currentTime` — saves `undefined`, silently.\n5. **Assuming a documented parameter is consumed.** Many are accepted and ignored. Confirm by diffing responses with and without it: a changed body size, a changed hash of an encrypted payload, or a different downstream request all count. No change means the parameter is decorative.\n6. **Reading client-rendered docs with `curl`.** You get a \"Loading…\" shell. Open them in a real browser, and read the **raw HTML** — withdrawn parameters are often left commented out, invisible to a screenshot, and a clear signal not to use them.\n7. **Adding `sandbox` to stop ads.** Detected by presence; kills playback.\n8. **Trusting an origin that only emits analytics.** Widens your trust boundary for nothing.\n9. **Shipping a seek/skip button on a host that never reports position.** A dead affordance the viewer can't diagnose.\n10. **Letting the embed's own \"next episode\" control stay enabled** while your app also tracks episode state. The video advances inside the frame, your picker and progress badge don't, and everything downstream disagrees with what's playing. Pass the host's disable parameter explicitly rather than relying on its documented default.\n\n## Limitations\n\nThis skill does **not**:\n\n- Endorse, license, or vouch for any embed host, or grant any right to the content they serve. See the framing note at the top.\n- Name specific hosts. They change, disappear, and get blocked; the *method* — control probe, capture live, allowlist the origin, detect failure explicitly — is what transfers.\n- Make playback verifiable in headless CI. Headless browsers lack the codecs for most commercial streams; you can verify the URL built, the response returned, and the messages posted, but a person has to watch a video play.\n- Bypass a host's `X-Frame-Options`. If they refuse framing, that is their decision.\n- Cover DRM, HLS/DASH, or hosting your own streams — that is a media-server problem, not an embed problem.\n- Handle id mapping to foreign systems (MAL/AniList) — see `media-id-mapping`.\n\n## Verification Checklist\n\n- [ ] Control probe run: a real id and a bogus id produce **visibly different** responses\n- [ ] `X-Frame-Options` and any host-side CSP checked with `curl -D -`\n- [ ] Redirect chain followed; every final host is in `frame-src`\n- [ ] The CSP change shipped in the same commit as the provider code\n- [ ] postMessage captured live for a full session; the parser matches what actually arrived, not the docs\n- [ ] The origin allowlist uses exact array matching, and a deliberately wrong origin is ignored in a test\n- [ ] A bogus/unmapped title renders an honest \"no source\" panel, not an empty frame\n- [ ] The silence watchdog does **not** fire on a paused player (test it paused past the deadline)\n- [ ] Coverage measured on a deterministic sample, reported as `n/N (x%)`\n- [ ] A human confirmed a real video plays in a real browser\n", "readme_content": "# streaming-provider-embeds\n\nIntegrate and debug third-party video embeds in a catalogue app — the URL contract, the CSP that makes or breaks them, the postMessage capture, and the failure modes that render as a black box instead of an error.\n\n## What it does\n\nThe agent onboards an embed host the way you'd onboard any undocumented third party: probe it with a control case, capture its real contract live, allowlist exactly what you need, and build explicit failure detection — because in this domain a wrong answer never throws. It renders a black rectangle, or plays a different show at full quality.\n\nIt also covers the five traps that make a working embed look dead (and a dead one look working), and the capability table that stops you shipping a seek button on a host that never reports position.\n\n## Read this before integrating anything\n\nThird-party embed hosts are **not licensed sources** and this skill does not pretend otherwise. They aggregate streams whose provenance you cannot verify, they're frequently blocked at DNS/ISP/hosting level, they serve popup and redirect ads, they change or vanish without notice, and embedding one may breach your host's terms, your CDN's terms, or your jurisdiction's copyright law.\n\nIf the question is \"where can I legally watch this\", the answer is the metadata API's availability endpoint (see `tmdb-metadata`), not an embed. Integrate one only with that understood — and never label such a source in your UI as licensed, official, or authorised.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/streaming-provider-embeds/SKILL.md\n```\n\n## The control probe — do this first\n\nRequest an id you believe is real **and** one that certainly isn't (`99999999`). If both look the same, your probe is broken and every measurement after it is meaningless.\n\n```bash\ncurl -sS -o /dev/null -w '%{http_code} %{size_download}\\n' \\\n -H \"Referer: https://localhost/\" \\\n -H \"Sec-Fetch-Dest: iframe\" -H \"Sec-Fetch-Mode: navigate\" -H \"Sec-Fetch-Site: cross-site\" \\\n -A \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/131.0.0.0 Safari/537.36\" \\\n \"https://provider.example.com/embed/550\"\n```\n\nReal case: a bare `curl` returned an identical 3,545-byte body for a real id, a second real id, and a bogus one — which looked like a dead provider. It was a `410`: the host refuses requests without an embed-shaped `Referer`. With headers, real ids returned a player and the bogus id returned a distinguishable 404.\n\nA `410`/`403` from a bare curl means **your request was malformed**, not that the title is missing.\n\n## The five traps\n\n| # | Trap | Symptom | Fix |\n|---|---|---|---|\n| 1 | Missing `frame-src` entry | **Plain black box**, console-only CSP error | CSP entry and provider list are one change, never two |\n| 2 | Direct access disabled | Error page for every id, real or not | Send embed-shaped headers when probing |\n| 3 | Host sets `X-Frame-Options` | \"Refused to display … 'sameorigin'\" | Nothing on your side fixes it — check before integrating |\n| 4 | CSP re-checks after redirects | Console names a host you never wrote | Follow redirects; allowlist every host in the chain |\n| 5 | `sandbox` attribute detected | \"Sandbox Detected\" error, or an endless spinner | Use a click-catch overlay instead |\n\nTrap 1 is the expensive one: a blocked frame is indistinguishable from a dead provider, so your own failure detection reports the *title* as unavailable — a lie that sends you debugging the wrong layer.\n\nTrap 5's mitigation, which works:\n\n```jsx\n<iframe key={src} src={src} allow=\"autoplay; fullscreen; encrypted-media; picture-in-picture\" allowFullScreen />\n{overlayActive && (\n <button className=\"player__click-catch\" onClick={() => setOverlayActive(false)}>Tap again to play</button>\n)}\n```\n\nA transparent layer absorbs the first click so a click-triggered popup fires on your overlay, not inside the frame. Re-arm it on every new `src`.\n\n## Origin allowlisting\n\n```js\nexport const TRUSTED_EMBED_ORIGINS = Object.freeze(['https://alpha.example.com']);\nexport const isTrustedEmbedOrigin = (o) => typeof o === 'string' && TRUSTED_EMBED_ORIGINS.includes(o);\n```\n\n`Array.includes` is exact element matching — correct. A **string** `includes` is not: `origin.includes('alpha.example.com')` accepts `https://alpha.example.com.attacker.com`. This has been a real vulnerability in a shipped app.\n\nKeep the allowlist in the same file that builds the embed URLs, or the two drift and you get trap 1. And only allowlist an origin you gain something from — one that emits nothing but analytics widens your trust boundary for free.\n\n## Capture the contract, don't trust the docs\n\n```js\nwindow.addEventListener('message', (e) => {\n let d = e.data;\n if (typeof d === 'string') { try { d = JSON.parse(d); } catch {} }\n console.log(e.origin, d);\n});\n```\n\nIn one measured comparison, one host published a full lifecycle contract and delivered it; another's docs implied events, and a live capture of 76 messages across a full session found every one to be third-party analytics chatter with no playback field at all — so that origin was deliberately left out of the allowlist.\n\nShapes that bite: position arrives as `time` **or** `currentTime` (read the wrong one and you save `undefined`), the payload may be a JSON *string*, and the real event may be nested at `data.data.event`. Handle all three.\n\nAlso: mark a title \"watching\" on **frame load**, not on the first message. Some hosts only message on specific interactions, so a viewer who presses play and walks away would never register at all.\n\n## Detecting failure you cannot see\n\nYou can't read a cross-origin frame and CORS blocks pre-probing. Two signals remain: an explicit error event, and **silence**.\n\nA host's error page is static HTML with no scripts, so it posts nothing. A working player posts *something* — even chrome chatter — long before playback starts. Measured on one host: real id → 1 message before playback, bogus id → 0. That difference is the whole signal.\n\n```js\nsawAnyMessageRef.current = true; // set BEFORE parsing — the useful evidence is often a message your parser discards\n\nconst timer = setTimeout(() => {\n if (!sawAnyMessageRef.current) onSourceError('silent');\n}, 20_000); // must cover iframe load + player boot on a slow connection\n```\n\nKey it on recognised *playback* events instead and it fires whenever playback simply hasn't started — autoplay blocked, buffering, paused — yanking a working video away from the viewer.\n\n## Capability table\n\n```js\nconst PROVIDER_CAPABILITIES = Object.freeze({\n alpha: { reportsPosition: true, canSeekByUrl: false },\n beta: { reportsPosition: false, canSeekByUrl: true },\n});\n```\n\nTwo abilities, kept apart so you can see which half is missing: does it tell you where playback is, and can it be told to start at an offset? Record the evidence for each entry in a comment — a wrong flag ships a button that silently does nothing. Derive UI copy from the table, never from a hardcoded string.\n\n## Manual switching, remembered\n\nA cross-origin iframe can't be probed for \"does this host carry this title\", so let the viewer pick and remember it: per title, with a per-profile default underneath. Precedence goes one way — an explicit per-title choice always outranks the default, and changing the default never retroactively moves a title someone already chose for.\n\n## Coverage sweeps\n\nSample real ids from your own catalogue, make the sample deterministic (seeded shuffle), bound concurrency, keep the script in the repo. Report `103/140 (73.6%)` — useful and checkable. \"Good coverage\" is neither, and \"we carry everything\" is a claim you'll be held to.\n\n## Honest limitations\n\n- Names no specific hosts — they change, disappear, and get blocked. The method transfers; a host list wouldn't.\n- Endorses and licenses nothing. See the framing note above.\n- Cannot make playback verifiable in headless CI: headless browsers lack the codecs. You can verify the URL built, the response returned, and the messages posted — a person has to watch a video play.\n- Cannot bypass a host's `X-Frame-Options`.\n- Does not cover DRM, HLS/DASH, or serving your own streams.\n- Does not handle id mapping to MAL/AniList — see `media-id-mapping`.\n\n## Part of\n\n[Hermes Skills Portfolio](https://github.com/THEROCKSSS/hermes-skills-portfolio) — empowering skills for the Hermes agent.\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/streaming-provider-embeds/SKILL.md" }, { "name": "watchlist-sync", "category": "media", "tier": "utility", "description": "Use when adding per-profile watchlists, watch statuses, continue-watching, or resume-position tracking to a media catalogue app — or when designing the Postgres/PostgREST schema, profile switching, PIN/password gates, or a watch-stats page behind one. Also use when a stats number looks plausible but wrong, when a login endpoint has no brute-force limit, or when a preference needs to be per-device rather than per-account.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/watchlist-sync/SKILL.md", "path": "skills/watchlist-sync", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-08-02", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "watchlist-sync", "description": "Use when adding per-profile watchlists, watch statuses, continue-watching, or resume-position tracking to a media catalogue app — or when designing the Postgres/PostgREST schema, profile switching, PIN/password gates, or a watch-stats page behind one. Also use when a stats number looks plausible but wrong, when a login endpoint has no brute-force limit, or when a preference needs to be per-device rather than per-account.", "version": "1.0.0" }, "agent_use": "- Adding a watchlist, watch statuses, ratings, or notes to a catalogue app.\n- Adding \"Continue Watching\" or resume-where-you-left-off.\n- Designing profiles, profile switching, or a PIN/password gate.\n- Building a watch-stats page.", "user_use": "Per-profile watchlists, statuses, continue-watching, and resume points for a catalogue app — on Postgres plus a REST layer generated from the schema (PostgREST/Supabase), with **no bespoke API server** to write or maintain.", "skillmd_content": "---\nname: watchlist-sync\ndescription: Use when adding per-profile watchlists, watch statuses, continue-watching, or resume-position tracking to a media catalogue app — or when designing the Postgres/PostgREST schema, profile switching, PIN/password gates, or a watch-stats page behind one. Also use when a stats number looks plausible but wrong, when a login endpoint has no brute-force limit, or when a preference needs to be per-device rather than per-account.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [watchlist, postgrest, postgres, profiles, auth, playback-progress]\n related_skills: [movie-catalogue-site, streaming-provider-embeds, movie-night-calendar, tmdb-metadata]\n---\n\n# watchlist-sync\n\n## Overview\n\nPer-profile watchlists, statuses, continue-watching, and resume points for a catalogue app — on Postgres plus a REST layer generated from the schema (PostgREST/Supabase), with **no bespoke API server** to write or maintain.\n\nThe persistence itself is easy. What makes this skill worth having is the three places it goes quietly wrong: a credential table that is one `GRANT` away from being public, an auth function that is an unlimited guessing oracle, and a resume-position column that produces confidently wrong statistics when summed.\n\n## When to Use\n\n- Adding a watchlist, watch statuses, ratings, or notes to a catalogue app.\n- Adding \"Continue Watching\" or resume-where-you-left-off.\n- Designing profiles, profile switching, or a PIN/password gate.\n- Building a watch-stats page.\n- Reviewing an existing PostgREST schema for exposure.\n\nDo not use it for playback event capture itself (`streaming-provider-embeds`) or for the browse/detail UI (`movie-catalogue-site`).\n\n## Workflow\n\n1. **Write migration 001** — profiles, a shared titles cache, watchlist (see Schema). Numbered, forward-only SQL applied in order.\n2. **Expose a view, never the credential table** (see Never Expose the Credential Table).\n3. **Put credential comparison inside the database** as `SECURITY DEFINER` functions (see Auth as Database Functions).\n4. **Rate-limit those functions before shipping them**, not after (see Rate-Limit the Oracle). A grant of EXECUTE to an anonymous role is an unlimited guessing oracle otherwise.\n5. **Validate every value that reaches a PostgREST query string** at the client boundary (see Filter-Param Injection).\n6. **Add progress columns** and be explicit about what they mean (see The Resume-Point Trap).\n7. **Decide per-setting whether it is per-account or per-device**, and say which in the UI (see Per-Device vs Per-Account).\n8. **Ship the app tolerant of an unapplied migration** (see Migrations).\n9. **State the perimeter honestly** in the README (see Be Honest About the Perimeter).\n\n## Schema\n\n```sql\nCREATE TABLE profiles (\n id SERIAL PRIMARY KEY,\n slug TEXT UNIQUE NOT NULL,\n display_name TEXT NOT NULL,\n username TEXT UNIQUE,\n password_hash TEXT,\n pin_hash TEXT,\n avatar_color TEXT DEFAULT '#8a7cff',\n is_default BOOLEAN NOT NULL DEFAULT FALSE,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\n-- Shared cache: anything ever added to any watchlist gets one row here.\nCREATE TABLE titles (\n id SERIAL PRIMARY KEY,\n external_id INTEGER NOT NULL, -- TMDB id\n media_type TEXT NOT NULL DEFAULT 'movie', -- 'movie' | 'tv'\n title TEXT NOT NULL,\n image TEXT DEFAULT '',\n release_date TEXT DEFAULT '',\n rating NUMERIC(3,1) DEFAULT 0,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n UNIQUE (external_id, media_type)\n);\n\nCREATE TABLE watchlist (\n id SERIAL PRIMARY KEY,\n profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,\n title_id INTEGER NOT NULL REFERENCES titles(id) ON DELETE CASCADE,\n status TEXT NOT NULL DEFAULT 'not_watched',\n rating INTEGER NOT NULL DEFAULT 0,\n notes TEXT DEFAULT '',\n sort_order INTEGER, -- NULL = never manually placed\n added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n last_watched_at TIMESTAMPTZ,\n last_season INTEGER,\n last_episode INTEGER,\n last_position_seconds INTEGER,\n last_duration_seconds INTEGER,\n UNIQUE (profile_id, title_id)\n);\n```\n\n`status` is a small closed set: `not_watched`, `planning`, `watching`, `watched`, `dropped`. Validate it client-side against the same list before it is written — a garbage status persists silently and then renders as an unmatched, uncountable filter.\n\n`UNIQUE (external_id, media_type)` matters: TMDB movie 1399 and TV 1399 are different titles.\n\n`sort_order` with `ORDER BY sort_order ASC NULLS LAST, added_at DESC` makes manual ordering **additive** — rows never dragged keep their existing newest-first order as a group, so an untouched list looks exactly as it did before the column existed. Persist a reorder by writing the whole ordered list's indices, not by patching the moved row: positions are only meaningful relative to neighbours, and one-row writes leave gaps and ties that compound with every drag.\n\n## Never Expose the Credential Table\n\nThe raw `profiles` row holds hashes. Expose a **view** with only public columns, grant the anonymous role access to that, and revoke it on the table.\n\n```sql\nCREATE VIEW profiles_public AS\n SELECT id, slug, display_name, avatar_color, is_default, created_at FROM profiles;\n\nGRANT SELECT ON profiles_public TO anon;\nREVOKE SELECT ON profiles FROM anon;\n```\n\nA Postgres view runs with its owner's privileges by default, which is exactly what lets `anon` read the view without ever holding `SELECT` on the table underneath.\n\nEvery later table that grows a sensitive column follows the same pattern. **A new column on an exposed table inherits that table's grants** — convenient, and precisely how a sensitive field gets published by accident. Check what a new column is exposed to before adding it.\n\nThe same split works for write-only feedback tables (e.g. \"report a broken source\"): grant `INSERT` to `anon`, give it no `SELECT` policy at all, and expose only aggregate views that omit `profile_id`. Send `Prefer: return=minimal` on the insert so PostgREST never needs a SELECT grant just to echo the row back.\n\n## Auth as Database Functions\n\nCompare hashes **in the database**, never in the client:\n\n```sql\nCREATE EXTENSION IF NOT EXISTS pgcrypto;\n\nCREATE OR REPLACE FUNCTION verify_login(p_username TEXT, p_password TEXT)\nRETURNS TABLE (id INT, slug TEXT, display_name TEXT, avatar_color TEXT)\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$\nBEGIN\n RETURN QUERY\n SELECT p.id, p.slug, p.display_name, p.avatar_color\n FROM profiles p\n WHERE p.username = lower(trim(p_username))\n AND p.password_hash IS NOT NULL\n AND p.password_hash = crypt(p_password, p.password_hash);\nEND;\n$$;\n\nGRANT EXECUTE ON FUNCTION verify_login(TEXT, TEXT) TO anon;\n```\n\n`SECURITY DEFINER` lets the function read a table the caller cannot; the hash never leaves the database. `SET search_path = public` is not optional — without it a caller-controlled search path can shadow the objects the function references.\n\n## Rate-Limit the Oracle\n\nA four-digit PIN is 10,000 combinations, and profile ids are enumerable through the public view. An unmetered `verify_pin` grant to `anon` is a complete takeover in minutes. Add a failure counter and **evaluate the lock before touching a hash**.\n\n```sql\nCREATE TABLE auth_attempts (\n scope TEXT NOT NULL CHECK (scope IN ('pin','login')),\n identity TEXT NOT NULL, -- truncate to 128 chars at the caller\n failed_count INTEGER NOT NULL DEFAULT 0,\n first_failed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n locked_until TIMESTAMPTZ,\n PRIMARY KEY (scope, identity)\n);\n-- No grants to anon. RLS on. service_role policy only. No view over it.\n```\n\nFive design points, each load-bearing:\n\n1. **The lock is evaluated first and short-circuits.** While locked, the response is a constant function of the submitted credential — a right PIN and a wrong PIN produce byte-identical responses, so a locked request carries zero bits about the secret. Check the credential first and the lock second and the oracle stays fully intact.\n2. **Count failures against the identity *as supplied*, resolved or not.** A username that never existed locks out exactly like a real one, so \"locked\" is not an existence oracle.\n3. **\"Locked\" may safely be distinguishable from \"wrong credential.\"** It tells the attacker only that they just made five failed attempts — a fact they already hold. A silent lock lies to the legitimate user staring at a PIN box that stopped working.\n4. **Run exactly one bcrypt on every path** — the real comparison when the identity resolves, a throwaway hash when it doesn't. Short-circuiting inside the `WHERE` clause means \"no such username\" skips hashing and returns measurably faster than \"wrong password\", leaking existence by timing. Split the lookup from the comparison; adding a dummy hash *after* comparing gives the resolving path two hashes and makes the gap worse.\n5. **Time-based, self-clearing locks.** Account lockout is inherently a denial-of-service lever; a permanent lock hands that lever to anyone. Track PIN and login scopes separately so a locked PIN never locks the owner out of full login.\n\nRaise a distinct SQLSTATE (`PT429`) so PostgREST maps it to HTTP 429 with a JSON `message` the client already renders.\n\n**Do not bolt the same counter onto signup.** There is no secret to guess there, the counter has no stable key (every abusive signup uses a fresh username), and a per-username lock lets anyone pre-lock a name a real person is about to register. Signup abuse is a *volume* problem — fix it with a reverse-proxy rate limit.\n\n## Filter-Param Injection\n\nEvery value interpolated into a PostgREST **query string** needs validation at the client boundary. This is not SQL injection — PostgREST parameterises the SQL it generates — but an unvalidated value injects extra PostgREST filter syntax: additional `&` params, `or=(...)`, a different operator than `eq.`, extra `select=` columns.\n\n```js\nexport const MEDIA_TYPES = Object.freeze(['movie', 'tv']);\n\nfunction safeMediaType(v) {\n if (v !== 'movie' && v !== 'tv') throw new Error(`Invalid media type: ${JSON.stringify(v)}`);\n return v;\n}\n\nfunction safeId(v, label) {\n const n = Number(v);\n if (!Number.isInteger(n) || n <= 0) throw new Error(`Invalid ${label}: ${JSON.stringify(v)}`);\n return n;\n}\n```\n\n`mediaType` comes straight off a `/title/:mediaType/:id` route, so it is fully attacker-controlled via a crafted URL. Impact may be low *today* if `anon` already holds those grants — but the moment real server-enforced auth lands, an injectable filter param silently undermines it. Validate in the data module, not in the page, so every caller inherits it.\n\nCross-origin fetches need `credentials: 'include'` or the browser silently drops your gate's session cookie on every API call.\n\n## The Resume-Point Trap\n\n`last_position_seconds` is a **resume point for the most recent play**, overwritten on every progress update.\n\nIt is *not* a running total, and not even a bound in either direction: seek back to minute 5 after watching 40 and it stores 5; skip to the credits and it stores the credits.\n\nSo a \"hours watched\" statistic summed from that column is **wrong** — not approximate, wrong, and confidently so. It will also look plausible, which is why it survives review.\n\nIf you build a stats page, either:\n\n- derive only what the data honestly supports — titles marked watched, distinct calendar days with recorded playback, longest streak, ratings given — and **label each number with what it actually counts**; or\n- add a per-session playback log (append-only start/stop rows), which is the schema change that makes duration answerable.\n\nDo not quietly ship the sum. State the absence and name the fix; a page that explains what it cannot tell you is more trustworthy than one that guesses.\n\nRelated honesty: a status column records *what* happened, not *who* or *why*. \"On this profile's list\" is not \"this person added it\" when the edit PIN is shareable, and \"dropped\" does not distinguish a deliberate choice from an automatic tidy-up. If you want to show either, store it.\n\n## Continue Watching and Auto-Tidy\n\nScope the Continue Watching rail to `status = 'watching'`, not to \"anything with a `last_watched_at`\". The only rows carrying a timestamp without being `watching` are ones since marked `watched` or `dropped`, and resurfacing a finished or abandoned title as \"continue watching\" is wrong. Rows marked `watching` by hand but never played (`last_watched_at IS NULL`) still belong — sorted to the back:\n\n```\norder=last_watched_at.desc.nullslast,added_at.desc\n```\n\nA title left in `watching` with no activity for a long time can be auto-treated as dropped. Three rules keep that honest:\n\n- **Reading must never write.** Home is a surface anyone can land on while viewing another profile; filter stale rows out of the *display* there, and only write on the page that owns the list.\n- **A failed write is not a success.** Only rows whose PATCH actually resolved may be recoloured and announced. Swallowing a rejection and still showing the row as dropped survives while the change is silent — it does not survive a banner announcing it by name.\n- **\"Undo\" needs a column.** Store an opt-out flag (and a timestamp of when the rule fired), or the row is stale by the clock forever and the rule re-drops it on the very next visit. Both halves are needed to claim \"auto-tidied\": the timestamp alone keeps claiming it after the owner restored and re-dropped by hand; `status = 'dropped'` alone is the manual/automatic ambiguity the feature exists to remove.\n\nDefine derived facets by what the columns actually say. \"In progress\" means the row carries something you could resume from — a position greater than zero, or a stored season/episode — which cuts across statuses. Print the criterion next to the chip so it is never read as a synonym for \"Watching\". A position of `0` does not count: that's what an embed reports before playback has moved at all.\n\n## Per-Device vs Per-Account\n\nSome settings genuinely have nowhere durable to live yet — a playback source default, a theme, a rail arrangement, a stale-watching threshold. `localStorage` is a legitimate home for them **if every surface that offers the setting says so out loud**.\n\nKey by profile inside one storage key holding a map, with `'guest'` for \"no profile selected\", so two people sharing a browser don't overwrite each other and the whole preference set can be inspected or cleared in one operation:\n\n```js\nconst LS_PREFS = 'app_profile_prefs'; // { \"3\": { provider: \"alpha\" }, \"guest\": {...} }\n```\n\nWrap reads and writes in try/catch — a locked-down browser throws on `localStorage` access, and private-mode quota failures must not take the page down; the choice just doesn't survive the reload.\n\nWhere a setting has no admin authentication to protect it, per-device is often the *more honest* option: a server-side threshold with no auth is a setting anyone who can reach the API changes for everybody, and the sentence \"one person tuned this for the whole install\" would be false the moment a second person opened the URL.\n\n## Migrations\n\nNumbered, forward-only SQL files applied in order. Two habits pay off:\n\n**Ship the app tolerant of an unapplied migration.** Probe for the column and degrade — hide the feature, or fall back to local storage and say so in the interface:\n\n```js\n// One tiny probe per session, memoised. A 400/404 means PostgREST parsed the\n// request and there is no such column — a permanent answer worth caching.\n// Anything else (offline, 500, proxy hiccup) is transient: drop the memo so\n// the next caller asks again rather than downgrading the schema for the whole\n// page's life over one bad request.\n```\n\nThen a deploy that lands ahead of its migration is a missing feature, not a broken page.\n\n**`NOTIFY pgrst, 'reload schema';`** at the end of every migration, or PostgREST keeps serving the old schema cache and your new column 404s for no visible reason.\n\n## Be Honest About the Perimeter\n\nIf the anonymous role holds broad write grants — the usual arrangement for a generated REST layer — then profile login is a **UI-level gate, not server-enforced authorisation**. Anyone who can reach the API directly can write.\n\nThat is a legitimate design for a self-hosted personal site, but only if you say so, in the README and in the code comments. The real perimeter is whatever fronts the whole deployment: put a single shared gate in front of the app *and* the API so every access path — local, tunnel, public domain — passes the same check, and neither service publishes a host port of its own.\n\nAn unlinked admin route is obscurity, not a gate. If you ship one, say that on the page itself.\n\n## Common Pitfalls\n\n1. **Granting `SELECT` on the table that holds hashes.** One line, total exposure.\n2. **Comparing credentials in the client.** The hash has to travel to do that.\n3. **Unmetered auth RPCs.** 10,000 PINs fall in minutes.\n4. **Checking the credential before the lock.** Leaves the oracle intact.\n5. **Summing `last_position_seconds`.** Confidently wrong hours-watched.\n6. **Unvalidated route params in a PostgREST filter.** Filter-param injection.\n7. **Forgetting `credentials: 'include'`.** Works locally, 401s behind the gate.\n8. **Forgetting `NOTIFY pgrst`.** New column invisible until a restart.\n9. **A new column on an exposed table.** Inherits the table's grants silently.\n10. **Reading a list mutating it.** Anyone viewing another profile's page triggers writes they didn't ask for.\n\n## Limitations\n\nThis skill does **not**:\n\n- Provide real row-level authorisation. The described arrangement is a UI gate plus a shared front-door; per-profile server-enforced RLS with JWTs is a larger design it deliberately doesn't fake.\n- Sync across devices beyond what the database itself gives you — there is no conflict resolution, no offline queue, no CRDT.\n- Import or export to Trakt, Letterboxd, MAL, or Simkl.\n- Answer \"how many hours have I watched\" from the schema above. That needs the playback-log change it names.\n- Capture playback events — that is `streaming-provider-embeds`.\n- Cover Supabase Auth, OAuth, or magic links; the auth here is deliberately a small in-database password/PIN check.\n\n## Verification Checklist\n\n- [ ] `GET /profiles` as the anon role is refused; `GET /profiles_public` returns no hash columns\n- [ ] A wrong password and a right password are both exercised **in the same test run** — a verify function that accepted everything would also pass the happy path\n- [ ] Six consecutive wrong PINs return 429, and the correct PIN also returns 429 while locked (proves the lock is evaluated first)\n- [ ] A locked PIN does not block username+password login for the same profile\n- [ ] A crafted `mediaType` in the URL throws at the data-module boundary instead of reaching a query string\n- [ ] A status change survives a reload, and a second profile cannot see the first's credentials\n- [ ] Continue Watching excludes titles marked watched or dropped\n- [ ] Any stats number is labelled with what it counts, and nothing is summed from `last_position_seconds`\n- [ ] The app still renders with the newest migration unapplied\n", "readme_content": "# watchlist-sync\n\nPer-profile watchlists, watch statuses, continue-watching, and resume points for a media catalogue app — on Postgres plus a generated REST layer, with no bespoke API server.\n\n## What it does\n\nThe agent designs and ships the persistence half of a catalogue app: schema, profile switching, a PIN/password gate that lives inside the database, and progress tracking.\n\nThe storage itself is the easy part. This skill exists for the three places it goes quietly wrong:\n\n- a credential table one `GRANT` away from being public,\n- an auth function that is an unlimited guessing oracle,\n- a resume-position column that produces confidently wrong statistics when summed.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/watchlist-sync/SKILL.md\n```\n\n## Schema\n\n```sql\nprofiles id, slug, display_name, username, password_hash, pin_hash, avatar_color, is_default\ntitles id, external_id, media_type, title, image, release_date, rating -- UNIQUE(external_id, media_type)\nwatchlist id, profile_id, title_id, status, rating, notes, sort_order, added_at,\n last_watched_at, last_season, last_episode,\n last_position_seconds, last_duration_seconds -- UNIQUE(profile_id, title_id)\nauth_attempts scope, identity, failed_count, first_failed_at, locked_until -- no anon grants, ever\n```\n\n`status` is a closed set: `not_watched`, `planning`, `watching`, `watched`, `dropped`. `UNIQUE(external_id, media_type)` matters — movie 1399 and TV 1399 are different titles.\n\n`sort_order` with `ORDER BY sort_order ASC NULLS LAST, added_at DESC` makes manual ordering additive: rows never dragged keep their newest-first order as a group, so an untouched list looks exactly as it did before the column existed.\n\n## Never expose the credential table\n\n```sql\nCREATE VIEW profiles_public AS\n SELECT id, slug, display_name, avatar_color, is_default, created_at FROM profiles;\nGRANT SELECT ON profiles_public TO anon;\nREVOKE SELECT ON profiles FROM anon;\n```\n\nA Postgres view runs with its owner's privileges, which is exactly what lets `anon` read the view without holding `SELECT` on the table underneath.\n\n**A new column on an exposed table inherits that table's grants.** Convenient — and precisely how a sensitive field gets published by accident.\n\n## Auth inside the database\n\n```sql\nCREATE OR REPLACE FUNCTION verify_login(p_username TEXT, p_password TEXT)\nRETURNS TABLE (id INT, display_name TEXT)\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$\nBEGIN\n RETURN QUERY SELECT p.id, p.display_name FROM profiles p\n WHERE p.username = lower(trim(p_username))\n AND p.password_hash IS NOT NULL\n AND p.password_hash = crypt(p_password, p.password_hash);\nEND; $$;\n```\n\n`SECURITY DEFINER` lets the function read a table the caller cannot — the hash never leaves the database. `SET search_path = public` is not optional.\n\n## Rate-limit it, or it's a guessing oracle\n\nA four-digit PIN is 10,000 combinations and profile ids are enumerable through the public view. Five design points, each load-bearing:\n\n1. **Evaluate the lock first and short-circuit.** While locked, the response is a constant function of the submitted credential — a right PIN and a wrong PIN are byte-identical. Check the credential first and the oracle stays fully intact.\n2. **Count failures against the identity as supplied**, resolved or not, so \"locked\" never reveals whether a profile exists.\n3. **\"Locked\" may safely differ from \"wrong credential\"** — it tells the attacker only that they just failed five times, which they know. A silent lock lies to the legitimate user.\n4. **Run exactly one bcrypt on every path.** Short-circuiting in the `WHERE` clause makes \"no such user\" measurably faster than \"wrong password\" — existence by timing. Split lookup from comparison; adding a dummy hash *after* comparing makes the gap worse.\n5. **Time-based, self-clearing locks, scoped separately** for PIN and login, so a locked PIN never locks the owner out entirely.\n\nRaise SQLSTATE `PT429` so PostgREST returns HTTP 429 with a JSON message the client already renders.\n\nDo **not** bolt the same counter onto signup: there's no secret to guess, the counter has no stable key, and a per-username lock lets anyone pre-lock a name someone is about to register. Signup abuse is a volume problem — fix it at the proxy.\n\n## Filter-param injection\n\nValues interpolated into a PostgREST **query string** need validation at the boundary. Not SQL injection — PostgREST parameterises — but an unvalidated value injects extra filters, operators, or `select=` columns.\n\n```js\nfunction safeId(v, label) {\n const n = Number(v);\n if (!Number.isInteger(n) || n <= 0) throw new Error(`Invalid ${label}: ${JSON.stringify(v)}`);\n return n;\n}\n```\n\n`mediaType` comes straight off `/title/:mediaType/:id`, so it's fully attacker-controlled. Validate in the data module so every caller inherits it. And send `credentials: 'include'` on cross-origin fetches or the gate's session cookie is silently dropped.\n\n## The resume-point trap\n\n`last_position_seconds` is a **resume point for the most recent play**, overwritten on every update. It is not a total, and not even a bound: seek back to minute 5 after watching 40 and it stores 5; skip to the credits and it stores the credits.\n\nA \"hours watched\" statistic summed from it is **wrong** — not approximate, wrong, and confidently so. It also looks plausible, which is why it survives review.\n\nEither derive only what the data supports (titles marked watched, distinct days with recorded playback, longest streak, ratings given) and label each number with what it counts — or add an append-only playback log, which is the schema change that makes duration answerable. Don't quietly ship the sum.\n\n## Continue Watching and auto-tidy\n\nScope the rail to `status = 'watching'`, ordered `last_watched_at.desc.nullslast,added_at.desc`. Anything carrying a timestamp without being `watching` has since been marked watched or dropped, and resurfacing it is wrong.\n\nAuto-tidying stale `watching` rows into `dropped` is fine if:\n\n- **reading never writes** (Home is a page anyone can land on while viewing another profile — filter the display there, write only on the page that owns the list),\n- **a failed PATCH is not reported as a success**,\n- **undo has a column** — an opt-out flag plus a fired-at timestamp, or the row is stale forever and the rule re-drops it next visit.\n\n## Per-device vs per-account\n\nPlayback source defaults, themes, rail arrangement, thresholds — `localStorage` is a legitimate home if every surface offering the setting says so. Key by profile inside one map-shaped key (with `'guest'` for none), and wrap access in try/catch: locked-down browsers throw, and private-mode quota failures must not take the page down.\n\nWhere there's no admin auth, per-device is often the *more honest* option — a server-side setting with no auth is one anyone who reaches the API changes for everybody.\n\n## Migrations\n\nNumbered, forward-only. End every one with `NOTIFY pgrst, 'reload schema';` or PostgREST keeps serving the old cache and your new column 404s for no visible reason.\n\nShip the app tolerant of an unapplied migration: probe for the column once per session, memoise a 400/404 as a permanent answer, drop the memo on anything transient. Then a deploy that lands ahead of its migration is a missing feature, not a broken page.\n\n## Be honest about the perimeter\n\nIf `anon` holds broad write grants — the usual arrangement for a generated REST layer — profile login is a **UI gate, not server-enforced authorisation**. Anyone who can reach the API directly can write.\n\nThat's a legitimate design for a self-hosted personal site, but only if you say so. The real perimeter is a single shared gate in front of the app *and* the API, with neither publishing a host port of its own. An unlinked admin route is obscurity — if you ship one, say that on the page.\n\n## Honest limitations\n\n- No real row-level authorisation. It deliberately doesn't fake per-profile server-enforced RLS with JWTs.\n- No cross-device conflict resolution, offline queue, or CRDT.\n- No Trakt / Letterboxd / MAL / Simkl import or export.\n- Cannot answer \"how many hours have I watched\" from this schema — it names the playback-log change that would.\n- Playback event capture belongs to `streaming-provider-embeds`.\n- Not a guide to Supabase Auth, OAuth, or magic links.\n\n## Part of\n\n[Hermes Skills Portfolio](https://github.com/THEROCKSSS/hermes-skills-portfolio) — empowering skills for the Hermes agent.\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/watchlist-sync/SKILL.md" }, { "name": "media-id-mapping", "category": "media", "tier": "utility", "description": "Use when a feature needs a title's id in a system other than the one you hold — TMDB to IMDb, TVDB, MyAnimeList, AniList or Kitsu — typically because an anime player or a ratings API speaks a different id system. Also use when a mapped title plays the wrong show, when a title \"is supported\" but plays nothing, or when season/episode numbers don't line up between two catalogues.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/media-id-mapping/SKILL.md", "path": "skills/media-id-mapping", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-08-02", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "media-id-mapping", "description": "Use when a feature needs a title's id in a system other than the one you hold — TMDB to IMDb, TVDB, MyAnimeList, AniList or Kitsu — typically because an anime player or a ratings API speaks a different id system. Also use when a mapped title plays the wrong show, when a title \"is supported\" but plays nothing, or when season/episode numbers don't line up between two catalogues.", "version": "1.0.0" }, "agent_use": "- An embed host, tracker, or API needs MAL/AniList/Kitsu/TVDB/IMDb ids and you hold TMDB ids.\n- A mapped title plays the wrong show, or plays nothing while appearing supported.\n- Season/episode numbers disagree between two catalogues.\n- You're choosing a mapping dataset and need to know which ones actually bridge what.", "user_use": "Bridge a title's identity across catalogues: TMDB ↔ IMDb ↔ TVDB ↔ MyAnimeList ↔ AniList ↔ Kitsu.", "skillmd_content": "---\nname: media-id-mapping\ndescription: Use when a feature needs a title's id in a system other than the one you hold — TMDB to IMDb, TVDB, MyAnimeList, AniList or Kitsu — typically because an anime player or a ratings API speaks a different id system. Also use when a mapped title plays the wrong show, when a title \"is supported\" but plays nothing, or when season/episode numbers don't line up between two catalogues.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [id-mapping, tmdb, imdb, myanimelist, anilist, anime]\n related_skills: [streaming-provider-embeds, tmdb-metadata, movie-catalogue-site]\n---\n\n# media-id-mapping\n\n## Overview\n\nBridge a title's identity across catalogues: TMDB ↔ IMDb ↔ TVDB ↔ MyAnimeList ↔ AniList ↔ Kitsu. You need this the moment a consumer speaks a different id system than your metadata spine — an anime embed host keyed on MAL ids, a ratings API keyed on IMDb ids, a tracker keyed on AniList ids.\n\nThis is where **silent failure** does its worst work. A wrong id does not 404. It plays a completely different show, at full quality, with no warning, and the viewer blames your site. The hard part is not even the id — it is the **episode number**, which does not carry over between systems and cannot be computed arithmetically.\n\n## The Invariant\n\n**Never guess an id.** Every ambiguous or partial case resolves to `null`, and the caller falls back to a route that does not need the mapping — or says plainly that it cannot play this title.\n\nA fallback is always available. A wrong show is not recoverable.\n\n## When to Use\n\n- An embed host, tracker, or API needs MAL/AniList/Kitsu/TVDB/IMDb ids and you hold TMDB ids.\n- A mapped title plays the wrong show, or plays nothing while appearing supported.\n- Season/episode numbers disagree between two catalogues.\n- You're choosing a mapping dataset and need to know which ones actually bridge what.\n- You're about to ship a derived/inferred id and need a way to prove it is right.\n\n## Workflow\n\n1. **Get the free ids first.** TMDB's `/{movie,tv}/{id}?append_to_response=external_ids` already returns `imdb_id` and `tvdb_id`. No dataset needed for those — see The Easy Half.\n2. **Pick a dataset that carries *your* spine's ids** (see Where Mapping Data Comes From). An excellent anime database that omits TMDB ids cannot bridge TMDB to anything.\n3. **Snapshot it at build time**, trimmed to the fields you need (see Build-Time, Not Runtime).\n4. **Write the resolver as a pure function** over an already-loaded snapshot, so it can be exercised with no async plumbing.\n5. **Handle the cour/offset problem explicitly** (see Episode Numbering).\n6. **Derive only where the data admits exactly one reading**, never where two are possible (see Derive, Then Verify).\n7. **Verify derived rows against the live consumer** and compare their hit rate to your existing baseline. Ship the verifier alongside the builder.\n8. **Track availability separately from existence** (see Availability Is Not Existence).\n9. **Return `null` loudly** — the caller must render an honest dead end, not an empty frame.\n\n## The Easy Half: IMDb and TVDB\n\nTMDB gives you these directly; there is no dataset, no matching, and no ambiguity:\n\n```js\nconst details = await tmdbFetch(`/${mediaType}/${id}`, {\n append_to_response: 'external_ids,credits',\n});\nconst imdbId = details.external_ids?.imdb_id; // \"tt0137523\"\nconst tvdbId = details.external_ids?.tvdb_id; // 81189\n```\n\nIMDb ids are what OMDb keys on for Rotten Tomatoes / Metacritic ratings (see `tmdb-metadata`). TVDB ids matter here for a second reason: they are a **secondary bridge** into anime datasets, because a meaningful minority of series carry a TVDB id in those datasets but no TMDB id.\n\nMovies have `imdb_id` on the detail response. TV series have it under `external_ids`, and individual *episodes* have their own — don't reuse the series id for an episode lookup.\n\n## Where Mapping Data Comes From\n\nDatasets differ in what they bridge, and picking the wrong one wastes a day:\n\n| Dataset kind | Bridges | Use for |\n|---|---|---|\n| Cross-id list carrying your metadata API's ids | TMDB/TVDB ↔ MAL/AniList/Kitsu, with `season` + `episode_offset` | **the actual bridge** |\n| Anime-only aggregators | MAL ↔ AniList ↔ Kitsu ↔ AniDB | enriching, not bridging |\n| Id caches / existence lists | which ids exist at all | validation only |\n\nThe publicly maintained cross-id lists (e.g. the `Fribb/anime-lists` family, which the reference implementation snapshots from `anime-list-mini.json`) are the first category — they carry `mal_id`, `anilist_id`, `kitsu_id`, `anidb_id`, `themoviedb_id`, `thetvdb_id`, plus **`season`** and **`episode_offset`**, which are the two fields that actually matter.\n\n**Check the dataset carries your spine's ids before adopting it.** This is the single most common wasted afternoon in this domain.\n\n## Episode Numbering Does Not Carry Over\n\nMAL files a long-running series as **one entry per cour** (a quarter-season broadcast block). So \"season 3, episode 13\" of a series may be a *different MAL entry starting again at episode 1* — not entry X at episode 38, and not entry Y at episode 13. Neither conversion is arithmetic; both come from the dataset's `season` and `episode_offset` fields.\n\nGiven rows shaped `[foreignId, season, episodeOffset, type]`:\n\n```js\nfunction pickSeriesRow(rows, season, episode) {\n if (!Array.isArray(rows)) return null;\n let best = null;\n for (const row of rows) {\n if (row[1] !== season) continue;\n // A cour's offset is how many episodes of this season came BEFORE it, so it\n // must be strictly below the episode asked for. Among the cours that\n // qualify, the latest-starting one contains this episode.\n if (row[2] >= episode) continue;\n if (!best || row[2] > best[2]) best = row;\n }\n return best;\n}\n\nconst absoluteEpisode = episode - row[2];\nif (!Number.isInteger(absoluteEpisode) || absoluteEpisode <= 0) return null;\n```\n\n### Verify the model, don't inherit it\n\nConsumers map foreign ids onto **their own** catalogue's grouping, which usually matches the source's cour splits but not always.\n\nA worked case: one series appeared to prove a host merged cours — its season-3 entry served 22 episodes (both cours) while the second cour's entry did not exist at all. That looks like a rule. Tested across 16 multi-cour seasons where the two models disagree, the **per-cour model won 10–0**. The merged case was a genuine per-title exception, not an arithmetic rule.\n\nHad that been \"fixed\" as a rule it would have broken every correctly-mapped multi-cour series. **Test a model against a sample before adopting it**, and leave per-title exceptions to the runtime fallback rather than special-casing them in the resolver.\n\n## Snapshot Strictness Rules\n\nEach of these exists to prevent a silent mis-play. Enforce them in the builder, and log how many rows each one dropped:\n\n1. **Never cross-read numbering systems.** TMDB-keyed rows read `season.tmdb` / `episode_offset.tmdb` only; TVDB-keyed rows read the `tvdb` variants only. These genuinely disagree in real datasets — in one snapshot, 38 rows had differing season numbers and 7 had differing offsets. Cross-reading mis-maps exactly those rows.\n2. **A row with an offset object but no value for your route is dropped, not defaulted to 0.** Defaulting turns \"S3E13 → episode 1\" into \"→ episode 13\".\n3. **A `(series, season, offset)` slot claimed by several different foreign ids is dropped entirely.** There is no non-guessing way to choose.\n4. **A row enters the TVDB index only when it carries no TMDB id of its own**, and the resolver consults that index only when the TMDB route found nothing — so the TMDB route always wins and the two indexes can never contradict each other.\n5. Movies are a separate, simpler index: one entry, episode 1.\n\n## Derive, Then Verify\n\nSource datasets have holes. Some rows carry your spine's series id but omit the season number, and a strict builder drops them — silently.\n\nWorked example: a major long-running series had its two *films* mapped and its **entire 148-episode run unmapped**, because its row omitted a season. The title looked supported and played nothing — the worst failure shape available, because it invites you to debug the player.\n\n**Recovery rule.** Derive a season only where the data admits exactly one reading: the series has *exactly one* non-film row in the whole dataset. One entry with no cour split means season 1, offset 0 is the only arrangement the data can describe. Two or more rows are genuinely ambiguous and stay dropped.\n\nThat is a derivation, not a guess — and the difference is testable:\n\n```\nrecovered rows: 47/63 resolve (74.6%)\nexisting map baseline: 73.6%\n```\n\nMatching the baseline is the evidence. Derived ids that were *wrong* would resolve at a visibly different rate — near zero if the rule were broken, suspiciously high if it were selecting only easy cases. A distribution matching the population says the rule found real rows, not plausible noise.\n\n**Ship a repeatable verifier alongside the builder** so the next dataset refresh is re-checked rather than re-argued:\n\n```\nnode scripts/verify-map.mjs # deterministic sample of N entries\nnode scripts/verify-map.mjs --id 11061 # one specific id\nnode scripts/verify-map.mjs --n 200 # bigger sample\n```\n\nThe verifier probes the **live consumer** — the thing that will actually serve these ids — with the embed-shaped headers that consumer requires (see `streaming-provider-embeds`), and it must use a control probe: a real id and a bogus one, so a total-failure result can't be mistaken for a coverage number. Keep the referer configurable (`PROBE_REFERER`) rather than baking a deployment hostname into a file that may go public.\n\n## Availability Is Not Existence\n\n\"We carry this show\" and \"we carry it dubbed\" are different facts. Conflating them makes a track selector dishonest: it offers every track on every mapped title and plays nothing for the ones that exist in only one.\n\nMeasured on one catalogue: of 8,732 titles, **5,155 were sub-only** — 59% of the titles where a dub button was being offered.\n\nIf the consumer's catalogue publishes per-track counts, harvest them during a walk you are already doing and store `[subCount, dubCount]` per id.\n\n**Unknown is not unavailable.** A title absent from the catalogue may still play via the id route, so missing data must leave every track enabled. Disabling on absent data removes a working option on a guess — the same sin as guessing an id, pointed the other way.\n\n```js\nexport function availabilityFromMap(avail, id) {\n const row = avail?.[id];\n if (!Array.isArray(row)) return null; // null = unknown, NOT unavailable\n const sub = Number(row[0]) || 0;\n const dub = Number(row[1]) || 0;\n return { sub, dub, hasSub: sub > 0, hasDub: dub > 0 };\n}\n```\n\n## Build-Time, Not Runtime\n\nMapping datasets are large (the upstream file in the reference case is ~5.7 MB of JSON) and their hosts rarely send CORS headers, so the browser cannot fetch them directly. Snapshot at build time, trimmed: drop every field the consumer doesn't need, drop every row without a foreign id, and re-shape into per-id buckets. That took ~5.7 MB to roughly 190 kB in the reference implementation — small enough to commit and lazily load.\n\nTwo placements, and the difference matters:\n\n- **Bundled import** (`import('../data/map.json')`) — the build *fails* if the file is missing. Right for data the app cannot work without. Bundlers resolve dynamic imports at build time, so even `import()` inside a `try/catch` hard-fails a missing file.\n- **Fetched from a static path at runtime** (`fetch(\\`${import.meta.env.BASE_URL}map.json\\`)`) — a missing file degrades to \"no mapping\" and the app still runs. Right for anything optional.\n\nGetting this backwards means an optional enrichment file can break your build.\n\nMemoise both the parsed result **and the in-flight promise**, so two components mounting at once share one request. On failure, clear the promise (so a later attempt can retry) and let the caller treat it exactly like an unmapped title.\n\n**Refresh is manual and that is the safe direction.** A stale snapshot means a genuinely new show fails to resolve and the caller falls back — not that it plays the wrong thing. Record a `meta.generated` timestamp in the output so \"why won't this new show resolve\" has a one-line answer.\n\n## Deciding When a Mapping Is Even Wanted\n\nOnly mention an *absent* mapping when it would make sense to have one. TMDB has no \"is this anime\" flag; the working heuristic — genre 16 (Animation) **combined with** Japanese original language — is the same filter `/discover` uses to build an anime rail:\n\n```js\nexport function looksLikeAnime(details) {\n const isAnimation = (details?.genres || []).some((g) => g.id === 16);\n return isAnimation && details?.original_language === 'ja';\n}\n```\n\nGenre alone pulls in every Western animated film. Use both, always, and use this only to decide whether \"no mapping for this title\" is worth saying out loud — a live-action film shouldn't be told it has no anime mapping.\n\n## Common Pitfalls\n\n1. **Guessing an id from a title-string match.** Join on ids alone. Title matching across catalogues produces confident wrong answers on every remake, sequel, and localised title.\n2. **Treating episode numbering as arithmetic.** `episode - 12` is not a cour offset; the dataset's field is.\n3. **Adopting a dataset that doesn't carry your spine's ids.** It cannot bridge, however complete it is.\n4. **Defaulting a missing offset to 0.** Silently shifts an entire season.\n5. **Special-casing a per-title exception as a rule.** Breaks every correctly-mapped series.\n6. **Shipping a derived id without a hit-rate comparison.** The comparison is the only evidence you have.\n7. **Disabling a track on unknown availability.** Removes a working option on a guess.\n8. **Bundling an optional file with a static import.** A missing optional artifact fails the build.\n9. **Probing the consumer without embed headers.** Everything looks like a miss (see `streaming-provider-embeds`).\n10. **No control case in the sweep.** A total failure and a coverage number are indistinguishable without one.\n\n## Limitations\n\nThis skill does **not**:\n\n- Ship or maintain a mapping dataset. It tells you which kind to pick and how to snapshot it; the data is someone else's, with their coverage and their licence.\n- Map anything by title, fuzzy match, or fingerprint. Ids only.\n- Cover per-episode ids beyond what the cour/offset model gives you.\n- Guarantee coverage. Real mappings are partial by nature — roughly 4,100 TV series and 1,300 films resolved in the reference snapshot, and everything else legitimately doesn't.\n- Handle the embed/player side of using these ids — see `streaming-provider-embeds`.\n- Provide MAL/AniList *account* integration (OAuth, list sync). This is id resolution only.\n\n## Verification Checklist\n\n- [ ] The chosen dataset demonstrably carries your metadata API's ids (grep one known title)\n- [ ] The resolver is a pure function over a loaded snapshot and is exercised without network\n- [ ] A multi-cour series resolves to the right entry **and** the right episode number, checked by hand against the catalogue\n- [ ] An ambiguous/absent row returns `null`, and the caller renders an honest dead end\n- [ ] Derived rows verified against the live consumer, with their hit rate compared to the baseline and both numbers recorded\n- [ ] The verifier run includes a control probe (real id vs bogus id) that visibly differ\n- [ ] Unknown availability leaves every track enabled\n- [ ] The build still succeeds with the optional artifact deleted\n- [ ] `meta.generated` is present in the snapshot output\n", "readme_content": "# media-id-mapping\n\nBridge a title's identity across catalogues — TMDB ↔ IMDb ↔ TVDB ↔ MyAnimeList ↔ AniList ↔ Kitsu — without ever guessing an id.\n\n## What it does\n\nYou need this the moment a consumer speaks a different id system than your metadata spine: an anime embed host keyed on MAL ids, a ratings API keyed on IMDb ids, a tracker keyed on AniList ids.\n\nThe agent picks a dataset that actually bridges what you hold, snapshots it at build time under strict rules, writes a pure resolver, and — crucially — verifies derived rows against the live consumer instead of reasoning about them.\n\n## The invariant\n\n**Never guess an id.** Every ambiguous or partial case resolves to `null` and the caller falls back to a route that doesn't need the mapping, or says plainly that it can't play this title.\n\nA wrong id does not 404. It plays a completely different show, at full quality, with no warning. A fallback is always available; a wrong show is not recoverable.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/media-id-mapping/SKILL.md\n```\n\n## The easy half — no dataset needed\n\n```js\nconst details = await tmdbFetch(`/${mediaType}/${id}`, { append_to_response: 'external_ids' });\ndetails.external_ids?.imdb_id // \"tt0137523\" → OMDb ratings\ndetails.external_ids?.tvdb_id // 81189 → secondary bridge into anime datasets\n```\n\nTVDB matters for a second reason: a meaningful minority of series carry a TVDB id in anime datasets but no TMDB id, so it's a real fallback route.\n\n## Picking a dataset\n\n| Kind | Bridges | Use for |\n|---|---|---|\n| Cross-id list carrying your API's ids | TMDB/TVDB ↔ MAL/AniList/Kitsu, with `season` + `episode_offset` | **the actual bridge** |\n| Anime-only aggregators | MAL ↔ AniList ↔ Kitsu ↔ AniDB | enriching, not bridging |\n| Id existence caches | which ids exist | validation only |\n\n**Check the dataset carries your spine's ids before adopting it.** An excellent anime database that omits TMDB ids cannot bridge TMDB to anything, however complete it is otherwise. This is the most common wasted afternoon in this domain.\n\n## The hard part is the episode number\n\nMAL files a long-running series as **one entry per cour**. \"Season 3, episode 13\" may be a different entry starting again at episode 1 — not entry X at episode 38, and not entry Y at episode 13. Neither conversion is arithmetic.\n\n```js\nfunction pickSeriesRow(rows, season, episode) {\n let best = null;\n for (const row of rows || []) {\n if (row[1] !== season) continue;\n if (row[2] >= episode) continue; // offset must be strictly below\n if (!best || row[2] > best[2]) best = row; // latest-starting qualifying cour\n }\n return best;\n}\nconst absoluteEpisode = episode - row[2];\n```\n\n**Verify the model, don't inherit it.** One series looked like proof that a host merged cours — season 3 served 22 episodes while the second cour's entry didn't exist. Tested across 16 multi-cour seasons where the two models disagree, the per-cour model won **10–0**. That one case was a per-title exception, not a rule; \"fixing\" it as a rule would have broken every correctly-mapped series.\n\n## Snapshot strictness rules\n\nEach prevents a silent mis-play. Log how many rows each drops:\n\n1. **Never cross-read numbering systems** — TMDB rows read the `tmdb` season/offset, TVDB rows read the `tvdb` ones. In one real snapshot 38 rows had differing seasons and 7 differing offsets.\n2. **A missing offset is dropped, not defaulted to 0.** Defaulting turns \"S3E13 → ep 1\" into \"→ ep 13\".\n3. **A slot claimed by several foreign ids is dropped entirely.** No non-guessing way to choose.\n4. **TVDB index is consulted only when the TMDB route found nothing**, so the two can never contradict.\n\n## Derive, then verify\n\nDatasets have holes. One real case: a major long-running series had its two *films* mapped and its **entire 148-episode run unmapped** because the row omitted a season. The title looked supported and played nothing — the worst failure shape available, because it invites you to debug the player.\n\nRecovery rule: derive a season only where the data admits exactly one reading — the series has *exactly one* non-film row. Two or more rows are genuinely ambiguous and stay dropped.\n\nThen prove it:\n\n```\nrecovered rows: 47/63 resolve (74.6%)\nexisting map baseline: 73.6%\n```\n\nMatching the baseline is the evidence. Wrong ids would resolve at a visibly different rate — near zero if the rule were broken, suspiciously high if it were cherry-picking easy cases.\n\nShip the verifier next to the builder so the next refresh is re-checked rather than re-argued, and give it a control probe (real id vs bogus id) so a total failure can't be mistaken for a coverage number.\n\n## Availability is not existence\n\n\"We carry this show\" and \"we carry it dubbed\" are different facts. Measured on one catalogue: of 8,732 titles, **5,155 were sub-only** — 59% of the titles where a dub button was being offered.\n\n```js\nif (!Array.isArray(row)) return null; // null = UNKNOWN, not unavailable\n```\n\nUnknown must leave every track enabled. Disabling on absent data removes a working option on a guess — the same sin as guessing an id, pointed the other way.\n\n## Build-time, not runtime\n\nMapping datasets are large (~5.7 MB in the reference case) and their hosts rarely send CORS headers. Snapshot at build time, trimmed to per-id buckets — that got to ~190 kB, small enough to commit and lazily load.\n\n- **Bundled import** → the build *fails* if the file is missing. Right for data the app can't work without. (Bundlers resolve dynamic imports at build time, so even `import()` in a try/catch hard-fails.)\n- **Runtime `fetch` from a static path** → a missing file degrades to \"no mapping\" and the app still runs. Right for anything optional.\n\nGetting this backwards means an optional enrichment file breaks your build. Memoise both the parsed result and the in-flight promise; clear the promise on failure so a retry is possible.\n\nRefresh is manual, and that's the safe direction — a stale snapshot means a new show fails to resolve and falls back, not that it plays the wrong thing. Record `meta.generated` in the output.\n\n## Is it even anime?\n\nTMDB has no flag. The working heuristic is genre 16 (Animation) **combined with** Japanese original language — genre alone pulls in every Western animated film. Use it only to decide whether \"no mapping for this title\" is worth saying out loud.\n\n## Honest limitations\n\n- Ships no dataset. The data is someone else's, with their coverage and their licence.\n- Maps by id only — no title matching, fuzzy matching, or fingerprinting.\n- Coverage is partial by nature. The reference snapshot resolved roughly 4,100 TV series and 1,300 films; everything else legitimately doesn't map.\n- The embed/player side of using these ids belongs to `streaming-provider-embeds`.\n- No MAL/AniList *account* integration (OAuth, list sync) — this is id resolution only.\n- All measured figures here are dated observations from one reference implementation, not guarantees. Re-measure.\n\n## Part of\n\n[Hermes Skills Portfolio](https://github.com/THEROCKSSS/hermes-skills-portfolio) — empowering skills for the Hermes agent.\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/media-id-mapping/SKILL.md" }, { "name": "movie-night-calendar", "category": "media", "tier": "utility", "description": "Use when a media catalogue app needs shared scheduling — a movie-night calendar, watch-party planner, a month grid of events with a host and a title attached, an upcoming-event badge, or a \"who's picking this Friday\" feature. Also use when calendar dates render one day off, when past events need distinguishing from upcoming, or when deciding who may delete an event.", "install_url": "https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/movie-night-calendar/SKILL.md", "path": "skills/movie-night-calendar", "usage": { "hub_installs": 0, "github_clones": 0, "stars": 0, "self_reported_users": 0 }, "recency": "2026-08-02", "source": "new", "source_attribution": { "author": "Owen", "origin_type": "internal", "origin_repo": "", "origin_url": "", "origin_note": "Originally authored for the Hermes Skills Portfolio.", "license": "MIT", "derived": false }, "frontmatter": { "name": "movie-night-calendar", "description": "Use when a media catalogue app needs shared scheduling — a movie-night calendar, watch-party planner, a month grid of events with a host and a title attached, an upcoming-event badge, or a \"who's picking this Friday\" feature. Also use when calendar dates render one day off, when past events need distinguishing from upcoming, or when deciding who may delete an event.", "version": "1.0.0" }, "agent_use": "- A catalogue app needs \"movie night on Friday\" scheduling shared between profiles.\n- A watch-party planner, screening schedule, or club calendar over an existing title catalogue.\n- Debugging a calendar that renders events one day early or late.\n- Deciding who may create, edit, or delete an event.", "user_use": "A shared calendar bolted onto a catalogue app: a month grid, one row per scheduled screening, each carrying a host profile, a real title picked from the metadata API, a date and optional time, a description, and an optional chat/voice link.", "skillmd_content": "---\nname: movie-night-calendar\ndescription: Use when a media catalogue app needs shared scheduling — a movie-night calendar, watch-party planner, a month grid of events with a host and a title attached, an upcoming-event badge, or a \"who's picking this Friday\" feature. Also use when calendar dates render one day off, when past events need distinguishing from upcoming, or when deciding who may delete an event.\nversion: 1.0.0\nauthor: Hermes Agent\nlicense: MIT\nmetadata:\n hermes:\n tags: [calendar, scheduling, movie-night, watch-party, postgrest]\n related_skills: [watchlist-sync, movie-catalogue-site, tmdb-metadata]\n---\n\n# movie-night-calendar\n\n## Overview\n\nA shared calendar bolted onto a catalogue app: a month grid, one row per scheduled screening, each carrying a host profile, a real title picked from the metadata API, a date and optional time, a description, and an optional chat/voice link. Plus the small things that make it feel finished — a day panel, a past/upcoming distinction, and an \"there's something coming up\" badge in the nav.\n\nIt is a small feature with two disproportionate traps: **JavaScript date parsing that silently shifts the day**, and **permissions on a table anyone can reach**.\n\n## When to Use\n\n- A catalogue app needs \"movie night on Friday\" scheduling shared between profiles.\n- A watch-party planner, screening schedule, or club calendar over an existing title catalogue.\n- Debugging a calendar that renders events one day early or late.\n- Deciding who may create, edit, or delete an event.\n\nDo not use it for personal reminders with no shared audience (a `notes` field on the watchlist row is enough), or for release-date calendars sourced from the metadata API — those are a `/discover` query with date filters, not stored events.\n\n## Workflow\n\n1. **Add the table and its grants** (see Schema). One migration, following the same exposed-view discipline as `watchlist-sync`.\n2. **Load the month's events once**, ordered by date, and render the grid from that array — not one request per cell.\n3. **Build the month grid** with the leading blanks maths (see Rendering the Month).\n4. **Parse every date string with an explicit local-midnight suffix** (see The Off-By-One-Day Trap). Do this before anything else, or you will chase a rendering bug that is a parsing bug.\n5. **Add the day panel**: click a cell, see that day's events, create from there with the date pre-filled.\n6. **Attach a real title** by searching the metadata API in the create form and storing the id and poster alongside the free-text title (see Attaching a Title).\n7. **Decide and enforce who can delete** (see Permissions), and say the rule in the UI.\n8. **Add the upcoming badge** as a bounded range query (see Upcoming Badge).\n\n## Schema\n\n```sql\nCREATE TABLE movie_nights (\n id SERIAL PRIMARY KEY,\n host_profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,\n title TEXT NOT NULL, -- denormalised on purpose, see below\n external_id INTEGER, -- TMDB id, nullable: not every night is a catalogued title\n media_type TEXT CHECK (media_type IN ('movie','tv')),\n poster_url TEXT DEFAULT '',\n event_date DATE NOT NULL,\n event_time TEXT DEFAULT '', -- free text; see the timezone note\n description TEXT DEFAULT '',\n chat_url TEXT DEFAULT '', -- Discord/Matrix/Jitsi invite\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\nCREATE INDEX movie_nights_date_idx ON movie_nights (event_date);\nALTER TABLE movie_nights ENABLE ROW LEVEL SECURITY;\nNOTIFY pgrst, 'reload schema';\n```\n\nThree deliberate choices:\n\n- **`title` and `poster_url` are denormalised.** An event must still read correctly when the catalogue row is gone, and a scheduled night is a historical record — it should not silently retitle itself if the metadata changes.\n- **`event_date` is a `DATE`, not a `TIMESTAMPTZ`.** A movie night is a calendar day, not an instant. Storing an instant forces a timezone decision on a value that doesn't have one and reintroduces the off-by-one below at the database layer.\n- **`event_time` is free text.** Storing `\"8pm-ish\"` honestly beats storing `20:00:00+00` and then rendering it in a timezone nobody agreed on. If you need a real instant, add a separate nullable `starts_at TIMESTAMPTZ` and say in the UI which timezone it is displayed in.\n\n## The Off-By-One-Day Trap\n\nThis is the bug this skill exists for.\n\n```js\nnew Date('2026-08-02') // parsed as UTC midnight → renders as Aug 1 west of UTC\nnew Date('2026-08-02T00:00:00') // parsed as LOCAL midnight → correct\n```\n\nA bare `YYYY-MM-DD` string is parsed by the ECMAScript spec as **UTC**, while a date-time string without an offset is parsed as **local**. So every event silently shifts a day for anyone in a negative-offset timezone — and looks perfect on the developer's machine if they happen to be east of UTC.\n\nTwo rules, applied everywhere:\n\n```js\n// Read: always append the time component.\nconst dateObj = new Date(dateStr + 'T00:00:00');\n\n// Write: never use toISOString().slice(0,10) — that converts to UTC first\n// and shifts the day back for anyone in a negative offset.\nfunction fmtDate(d) {\n const y = d.getFullYear();\n const m = String(d.getMonth() + 1).padStart(2, '0');\n const day = String(d.getDate()).padStart(2, '0');\n return `${y}-${m}-${day}`;\n}\n```\n\nCompare against a *normalised* today, or every event dated today counts as past from 00:00:01 onward:\n\n```js\nconst today = new Date();\ntoday.setHours(0, 0, 0, 0);\nconst isPast = new Date(dateStr + 'T00:00:00') < today;\n```\n\n## Rendering the Month\n\nFetch once, filter in memory:\n\n```js\nconst events = await api('/movie_nights?order=event_date.asc');\n```\n\nThen the grid maths, which is three lines and easy to get subtly wrong:\n\n```js\nconst firstDay = new Date(year, month, 1).getDay(); // 0=Sun leading blanks\nconst daysInMonth = new Date(year, month + 1, 0).getDate(); // day 0 of next month = last of this\n```\n\n`new Date(y, m + 1, 0)` is the idiomatic \"days in month\" and handles leap years for free. Emit `firstDay` empty cells before day 1, then one cell per day carrying:\n\n- the day number,\n- up to ~3 event rows (title plus a dot in the host's avatar colour), then a `+N more` line,\n- `today` and `past` classes,\n- a `data-date` attribute and a click handler opening the day panel.\n\nCap the events shown per cell. A day with nine events must not stretch the row and break the grid.\n\n**Escape every interpolated string.** These are user-authored titles and descriptions going into `innerHTML`. The one-liner that actually works:\n\n```js\nfunction escapeHtml(str) {\n const div = document.createElement('div');\n div.textContent = str;\n return div.innerHTML;\n}\n```\n\nAnd render `chat_url` links with `target=\"_blank\" rel=\"noopener\"` — a user-supplied URL opened without `noopener` hands the opened page a reference to your window.\n\n## Attaching a Title\n\nThe create form searches the metadata API (see `tmdb-metadata`) and stores the chosen result's id, display title, and poster path together. Store all three:\n\n- the **id** so the event can deep-link into your own title page,\n- the **title** so the event reads correctly without a lookup,\n- the **poster** so the grid and day panel render without a metadata request per event.\n\nKeep `external_id` nullable. Not every movie night is a catalogued title — \"board games\" or \"whatever we feel like\" is a legitimate entry, and a `NOT NULL` id turns that into a lie or a blocked flow.\n\nPre-fill the date from whichever day cell was clicked, and default it to today when the panel was opened without one.\n\n## Permissions\n\nA generated REST layer over Postgres gives `anon` whatever you grant, so decide this explicitly rather than inheriting it:\n\n- **Host may delete their own event.** Compare `event.host_profile_id` against the active profile.\n- **An admin/editor may delete any.** Whatever your app's edit gate is.\n- **Nobody else sees a delete button.**\n\nBe honest that this is a **UI-level gate** if `anon` holds a broad `DELETE` grant — anyone reaching the API directly can delete anything. That's a legitimate design for a self-hosted site behind a single shared front door (see `watchlist-sync`), but the README has to say so. If you want it enforced, the delete has to move behind a `SECURITY DEFINER` function that checks the caller, or behind real RLS with a JWT.\n\nPast events are worth treating differently: hide \"create\" and edit affordances on a past date, but keep delete available for tidy-up. A calendar you cannot clean up fills with dead weeks.\n\n## Upcoming Badge\n\nA bounded range query, not a full table scan on every page load:\n\n```js\nconst today = new Date(); today.setHours(0, 0, 0, 0);\nconst week = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000);\nconst rows = await api(`/movie_nights?event_date=gte.${fmtDate(today)}&event_date=lte.${fmtDate(week)}`);\nbadge.hidden = !(rows && rows.length);\n```\n\nRefresh it after any create or delete, or the badge disagrees with the calendar until the next reload.\n\nFailure must hide the badge, not break the nav — wrap it and swallow.\n\n## Empty States\n\n- **A month with no events** says so in words (\"No movie nights this month\" — plus \"create one from any day\" only when the viewer can actually create). Never a blank grid area.\n- **A day panel with no events** says \"No movie nights planned\", not an empty list.\n- **A failed load** is distinct from an empty month. Same three-state rule as everywhere else: haven't asked, asked and empty, asked and failed.\n\n## Common Pitfalls\n\n1. **`new Date('2026-08-02')`.** UTC-parsed; shifts a day west of UTC. The single most likely bug in this feature.\n2. **`toISOString().slice(0,10)` to write a date.** Same bug, other direction.\n3. **Comparing against an unnormalised `new Date()`.** Today's events read as past all afternoon.\n4. **`getMonth()` off-by-one.** It is 0-indexed; `getDate()` is not.\n5. **Unbounded events per cell.** One busy day breaks the grid.\n6. **Unescaped titles/descriptions in `innerHTML`.** User-authored strings.\n7. **`target=\"_blank\"` without `rel=\"noopener\"`** on a user-supplied chat URL.\n8. **A stale badge** after create/delete.\n9. **`NOT NULL` on the title id.** Blocks legitimate non-catalogue nights.\n10. **Storing a time as a UTC timestamp** and rendering it without saying which timezone.\n\n## Limitations\n\nThis skill does **not**:\n\n- Send invitations, reminders, emails, or push notifications. It stores a link; delivery is a separate system.\n- Do recurrence (every Friday), RSVPs, attendance tracking, or voting on what to watch. Each is a real feature with its own schema; this is a single-row event.\n- Export to iCal/Google Calendar. `event_date` + `event_time` as stored is not a precise enough instant for an `.ics` without the `starts_at` addition it names.\n- Sync playback between viewers. A watch-*party* with synchronised players is a different problem entirely, and the embed hosts in `streaming-provider-embeds` publish no seek command you can rely on.\n- Enforce permissions server-side by itself. It describes the UI gate honestly and names the two ways to make it real.\n- Handle timezones for a distributed group. It deliberately stores a calendar day and free-text time rather than pretending to.\n\n## Verification Checklist\n\n- [ ] An event created for today renders on today's cell with the machine's timezone set to something **west of UTC** (this is the test that catches the parsing bug)\n- [ ] Month navigation across a year boundary and into February of a leap year both render the right number of days\n- [ ] A day with more than three events shows `+N more` and does not stretch the row\n- [ ] A title containing `<script>` renders as text\n- [ ] The delete button appears only for the host and the editor, and the rule is stated in the UI\n- [ ] Creating and deleting an event both update the upcoming badge without a reload\n- [ ] An empty month and a failed load render differently\n- [ ] A past date offers delete but not create\n", "readme_content": "# movie-night-calendar\n\nA shared scheduling calendar for a media catalogue app — a month grid of screenings, each with a host, a real title from the metadata API, a date, and a chat link.\n\n## What it does\n\nThe agent adds the table, the month grid, the day panel, the create flow that searches your metadata API for a real title, the past/upcoming distinction, and the \"something's coming up\" badge in the nav.\n\nIt is a small feature with two disproportionate traps: **JavaScript date parsing that silently shifts the day**, and **permissions on a table anyone can reach**. Most of this skill is those two.\n\n## Install\n\n```bash\nhermes skills install https://raw.githubusercontent.com/THEROCKSSS/hermes-skills-portfolio/main/skills/movie-night-calendar/SKILL.md\n```\n\n## Schema\n\n```sql\nCREATE TABLE movie_nights (\n id SERIAL PRIMARY KEY,\n host_profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,\n title TEXT NOT NULL, -- denormalised: an event must read correctly forever\n external_id INTEGER, -- nullable: not every night is a catalogued title\n media_type TEXT CHECK (media_type IN ('movie','tv')),\n poster_url TEXT DEFAULT '',\n event_date DATE NOT NULL, -- a calendar day, NOT a timestamptz\n event_time TEXT DEFAULT '', -- free text; \"8pm-ish\" beats a fake instant\n description TEXT DEFAULT '',\n chat_url TEXT DEFAULT '',\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\nCREATE INDEX movie_nights_date_idx ON movie_nights (event_date);\nNOTIFY pgrst, 'reload schema';\n```\n\n`event_date` is a `DATE` on purpose. A movie night is a calendar day, not an instant — storing a timestamp forces a timezone decision on a value that doesn't have one, and drags the off-by-one below into the database layer too.\n\n## The off-by-one-day trap\n\n```js\nnew Date('2026-08-02') // parsed as UTC midnight → renders as Aug 1 west of UTC\nnew Date('2026-08-02T00:00:00') // parsed as LOCAL midnight → correct\n```\n\nPer spec, a bare `YYYY-MM-DD` is UTC and a date-time without an offset is local. So every event silently shifts a day for anyone in a negative-offset timezone — and looks perfect on a developer's machine east of UTC.\n\n```js\n// Read: always append the time component.\nconst d = new Date(dateStr + 'T00:00:00');\n\n// Write: never toISOString().slice(0,10) — it converts to UTC first.\nfunction fmtDate(d) {\n return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;\n}\n\n// Compare against a normalised today, or today's events read as past all afternoon.\nconst today = new Date(); today.setHours(0,0,0,0);\n```\n\n## Month grid maths\n\n```js\nconst firstDay = new Date(year, month, 1).getDay(); // leading blank cells\nconst daysInMonth = new Date(year, month + 1, 0).getDate(); // day 0 of next month\n```\n\n`new Date(y, m+1, 0)` handles leap years for free. Fetch the month's events **once** and filter in memory — never one request per cell.\n\nPer cell: the day number, up to ~3 event rows (title + a dot in the host's avatar colour), a `+N more` line, `today`/`past` classes, and a click handler opening the day panel. Cap the rows or one busy day stretches the grid.\n\nEscape everything — these are user-authored strings going into `innerHTML`:\n\n```js\nfunction escapeHtml(str) {\n const div = document.createElement('div');\n div.textContent = str;\n return div.innerHTML;\n}\n```\n\nAnd render `chat_url` with `target=\"_blank\" rel=\"noopener\"`. A user-supplied URL opened without `noopener` hands the opened page a reference to your window.\n\n## Attaching a title\n\nThe create form searches your metadata API and stores three things together: the **id** (so the event deep-links into your own title page), the **title** (so it reads correctly without a lookup), and the **poster** (so the grid renders without a request per event).\n\nKeep the id nullable. \"Board games\" or \"whatever we feel like\" is a legitimate entry, and `NOT NULL` turns that into a blocked flow.\n\n## Permissions\n\nDecide explicitly rather than inheriting whatever `anon` was granted:\n\n- host may delete their own event,\n- an admin/editor may delete any,\n- nobody else sees a delete button.\n\nIf `anon` holds a broad `DELETE` grant, **say in the README that this is a UI gate**, not enforcement — anyone reaching the API directly can delete anything. Legitimate for a self-hosted site behind one shared front door; not legitimate to leave unstated. To make it real, move the delete behind a `SECURITY DEFINER` function that checks the caller, or behind RLS with a JWT.\n\nPast dates: hide create, keep delete. A calendar you can't clean up fills with dead weeks.\n\n## Upcoming badge\n\n```js\nconst rows = await api(`/movie_nights?event_date=gte.${fmtDate(today)}&event_date=lte.${fmtDate(week)}`);\nbadge.hidden = !(rows && rows.length);\n```\n\nA bounded range query, refreshed after every create and delete — otherwise the badge disagrees with the calendar until the next reload. A failure hides the badge; it must not break the nav.\n\n## Empty states\n\nA month with no events says so in words, and only offers \"create one from any day\" when the viewer can actually create. A day panel with no events says \"No movie nights planned\". A failed load is visually distinct from an empty month — same three-state rule as everywhere: haven't asked, asked and empty, asked and failed.\n\n## Honest limitations\n\n- Sends no invitations, reminders, emails, or push notifications. It stores a link; delivery is a separate system.\n- No recurrence, RSVPs, attendance, or voting on what to watch — each is its own schema.\n- No iCal/Google Calendar export: a calendar day plus free-text time isn't a precise enough instant for an `.ics`.\n- No synchronised playback. Watch-party sync is a different problem, and the embed hosts in `streaming-provider-embeds` publish no seek command you can rely on.\n- Does not enforce permissions server-side by itself — it describes the UI gate honestly and names the two ways to make it real.\n- Does not solve timezones for a distributed group; it stores a calendar day and free text rather than pretending to.\n\n## Part of\n\n[Hermes Skills Portfolio](https://github.com/THEROCKSSS/hermes-skills-portfolio) — empowering skills for the Hermes agent.\n", "source_url": "https://github.com/THEROCKSSS/hermes-skills-portfolio/blob/main/skills/movie-night-calendar/SKILL.md" } ] }