Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ jobs:
node dist/cli.js --version
node dist/cli.js init --dir "$RUNNER_TEMP/skillhook" --json
node dist/cli.js skills validate --dir "$RUNNER_TEMP/skillhook" --json
node dist/cli.js link . --dir "$RUNNER_TEMP/skillhook" --json
node dist/cli.js projects --dir "$RUNNER_TEMP/skillhook" --json
node dist/cli.js run pull-after-merge --dir "$RUNNER_TEMP/skillhook" --payload '{}' --dry-run --json > /dev/null

- name: Audit production dependencies
run: npm audit --omit=dev
Expand Down Expand Up @@ -92,7 +95,7 @@ jobs:
const fs = require("node:fs");
const [info] = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const files = new Set(info.files.map((f) => f.path));
const required = ["package.json", "README.md", "CHANGELOG.md", "LICENSE", "dist/cli.js", "dist/index.js", "dist/index.d.ts", "dist/update.js", "schema/skillhook.schema.json", "examples/skills/hello/SKILL.md", "examples/skills/sentry-triage/SKILL.md"];
const required = ["package.json", "README.md", "CHANGELOG.md", "LICENSE", "dist/cli.js", "dist/index.js", "dist/index.d.ts", "dist/update.js", "schema/skillhook.schema.json", "schema/skillhook.yaml.schema.json", "examples/skills/hello/SKILL.md", "examples/skills/sentry-triage/SKILL.md"];
const missing = required.filter((f) => !files.has(f));
const unwanted = [...files].filter((f) => /^(src|test|scripts|docs|skills|\.github)\//.test(f) || /\.test\.|\.env|\.tgz$|\.map$/.test(f));
if (missing.length || unwanted.length) {
Expand All @@ -113,6 +116,9 @@ jobs:
skillhook skills add sentry-triage --dir "$home" --json
skillhook skills validate --dir "$home" --json
skillhook run hello --dir "$home" --payload '{"name":"ci"}' --dry-run --json > /dev/null
skillhook projects init "$RUNNER_TEMP/repo" --dir "$home" --json > /dev/null
skillhook projects --dir "$home" --json > /dev/null
skillhook skills validate --dir "$home" --json
skillhook mcp --print-config --dir "$home" --json > /dev/null
skillhook doctor --dir "$home" --json > "$RUNNER_TEMP/doctor.json" || true
node -e 'const r = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); if (!Array.isArray(r.checks)) process.exit(1);' "$RUNNER_TEMP/doctor.json"
Expand Down
13 changes: 8 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ is `skillhook`. User docs: `README.md`, `docs/`, `llms.txt`.
| `src/cli.ts` → `src/commands/main.ts` | CLI entry; `HELP` there is the command reference. One file per command in `src/commands/`. |
| `src/server.ts` | `node:http` server: webhook, health and admin routes. No framework. |
| `src/auth.ts` | Signature/token verification and the matching `signRequest` (used by `send`, MCP and tests). |
| `src/skills.ts` | SKILL.md parsing (zod), auth normalization, `SkillRegistry` (mtime cache). |
| `src/skills.ts` | SKILL.md parsing (zod), auth normalization, `loadSkills` for one directory. |
| `src/projects.ts` | `skillhook.yaml` (a repository's hooks): the zod schema (`HookSchema` = the `skillhook:` block + `run`/`skill`/`prompt`), loading, compiling hooks into `Skill`s, the starter template. |
| `src/registry.ts` | `SkillRegistry`: `<home>/skills` first, then linked projects from `projects` in `skillhook.json`; mtime caches for SKILL.md, skillhook.yaml and the config file, so nothing needs a restart. |
| `src/prompt.ts` | Placeholders, event block, unattended-run guardrails. |
| `src/jobs.ts`, `src/queue.ts`, `src/run.ts` | Job directories on disk, the concurrency queue, invocation preparation. |
| `src/runners/` | `claude.ts`, `codex.ts`, `shell.ts`: build argv, parse output; `env.ts` is the env allow-list. |
Expand All @@ -33,7 +35,8 @@ is `skillhook`. User docs: `README.md`, `docs/`, `llms.txt`.
| `.github/workflows/` | `ci.yml` (PRs and main: checks + packed-tarball install), `release.yml` (tags merged version bumps), `publish.yml` (npm publish with provenance, GitHub release, verification). |
| `examples/skills/` | Bundled webhook skills; `skillhook skills add <name>` copies them. Shipped in the npm package. |
| `skills/` | Agent-facing plugin skills (setup, authoring). This repo is itself a Claude Code / Codex / Cursor plugin via the manifests at the root. |
| `schema/skillhook.schema.json` | Generated from `src/config.ts` by `npm run schema`. Never edit by hand. |
| `schema/skillhook.schema.json`, `schema/skillhook.yaml.schema.json` | Generated from `src/config.ts` and `src/projects.ts` by `npm run schema`. Never edit by hand. |
| `skillhook.yaml` | This repository's own hooks (a `pull-after-merge` shell hook); the dogfood example of `docs/projects.md`. Not shipped in the package. |
| `test/fixtures/` | `fake-claude.mjs` / `fake-codex.mjs` emulate the real CLIs' output formats. |

Runtime state lives outside the repo in `~/.skillhook` (`SKILLHOOK_HOME`):
Expand All @@ -44,8 +47,8 @@ Runtime state lives outside the repo in `~/.skillhook` (`SKILLHOOK_HOME`):
- **Runtime dependencies stay at three**: `@modelcontextprotocol/server`, `yaml`, `zod`. Everything else is `node:` built-ins. Node >= 22, ESM, TypeScript strict, imports end in `.js`.
- **Security is not optional.** The server binds `127.0.0.1` by default; TLS and public exposure are Tailscale's job. Every webhook goes through `verifyRequest`; every admin route through `requireAdmin`. Compare secrets only with `safeEqual`. A skill without `auth` gets a bearer token (`SKILLHOOK_SECRET_<NAME>`); `auth: none` must be explicit and is warned about. Secret values are never logged, never returned by an API/tool except once at generation, and never written into job files (`redactHeaders`). The agent's environment is an allow-list (`src/runners/env.ts`); `SKILLHOOK_ADMIN_TOKEN` and `SKILLHOOK_SECRET_*` are never forwarded implicitly.
- **Payloads are data.** Anything that reaches the prompt from a webhook is wrapped in `<webhook_payload>` and the guardrails say so. Never build a prompt by concatenating payload text outside those blocks.
- **Skills are Agent Skills.** Standard frontmatter (`name`, `description`, `license`, `compatibility`, `metadata`, `allowed-tools`) plus a `skillhook:` block. `name` must equal the directory name. New fields: add to the zod schema in `src/skills.ts`, to `docs/skills.md`, to `skills/skillhook-authoring/SKILL.md`, and cover them in `src/skills.test.ts` — in the same PR.
- **Config changes** go in `src/config.ts` (zod, `.prefault({})` for nested objects so defaults apply), then `npm run schema`, then `docs/operations.md`.
- **Skills are Agent Skills.** Standard frontmatter (`name`, `description`, `license`, `compatibility`, `metadata`, `allowed-tools`) plus a `skillhook:` block. `name` must equal the directory name. New fields: add to the zod schema in `src/skills.ts`, to `docs/skills.md`, to `skills/skillhook-authoring/SKILL.md`, and cover them in `src/skills.test.ts` — in the same PR. A hook in `skillhook.yaml` is the same block plus exactly one of `run` / `skill` / `prompt` (`HookSchema` in `src/projects.ts` extends `SkillhookBlockSchema`, so new block fields reach hooks automatically); hook-only fields go in `src/projects.ts`, `docs/projects.md`, `npm run schema` and `src/projects.test.ts`. A compiled hook is an ordinary `Skill` (with `source.type === "project"`); never special-case hooks in the server, queue or runners.
- **Config changes** go in `src/config.ts` (zod, `.prefault({})` for nested objects so defaults apply), then `npm run schema`, then `docs/operations.md`. `projects` is the one key the server re-reads without a restart (`configProjects` in `src/registry.ts`); keep it that way.
- **Runners never shell-interpolate.** Argv arrays only; the prompt travels on stdin; parse the CLI's structured output (`stream-json`, JSONL). When Claude Code or Codex change flags, update the runner, `test/fixtures/`, `docs/runners.md` and the version note in `README.md` together.
- **Jobs are directories.** `job.json` is the record; artifacts sit next to it; nothing outside `~/.skillhook/jobs` is written by the server. Statuses: `queued running succeeded failed timed_out cancelled interrupted`.
- **Every CLI command supports `--json`** and returns non-zero on failure. Register new commands in `COMMANDS` and `HELP` in `src/commands/main.ts`, then in the README table.
Expand All @@ -59,7 +62,7 @@ Runtime state lives outside the repo in `~/.skillhook` (`SKILLHOOK_HOME`):
npm run typecheck # tsc --noEmit (strict)
npm test # vitest: unit + HTTP integration (src/server.test.ts) + CLI (src/cli.test.ts)
npm run build # tsc -p tsconfig.build.json → dist/
npm run schema -- --check # schema/skillhook.schema.json is current
npm run schema -- --check # schema/skillhook.schema.json and schema/skillhook.yaml.schema.json are current
npm run release -- --check # package.json, package-lock.json, plugin manifests and CHANGELOG.md agree on the version
npm run check # all of the above
```
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,27 @@ All notable changes to skillhook, newest first. The format follows [Keep a Chang

## Unreleased

- Version-controlled hooks: a repository can declare its webhooks in a `skillhook.yaml` at its root.
Each hook maps a webhook name to what runs: `run:` (a shell command, executed in the repository
with the payload on stdin), `skill:` (a `SKILL.md` directory in the repository, served under the
hook's name) or `prompt:` (inline instructions for the agent), plus any field of the `skillhook:`
block (`auth`, `when`, `model`, `cwd`, `env`, …). Secrets are named, never stored, in the file.
- `skillhook link [dir]` registers a repository (the new `projects` key in `skillhook.json`),
`skillhook unlink <dir>` removes it, `skillhook projects` lists linked repositories with their
hooks and URLs, and `skillhook projects init [dir]` writes a starter file (a `git pull --ff-only`
hook for merged GitHub pull requests) and links it. The server re-reads `projects`, every
`skillhook.yaml` and every referenced `SKILL.md` on change, so `link` and `git pull` need no
restart. Names in `~/.skillhook/skills` win over repositories; a name defined twice is reported by
`skills list`, `skills validate`, `doctor` and the server log instead of being served.
- `skills list` gained a `source` column, `skills show` a `source:` line, `GET /skills` and the MCP
skill tools a `source` field (`{type: "home"}` or `{type: "project", dir, file, kind}`), and
`doctor` a `project <dir>` check per linked repository. New MCP tools: `list_projects`,
`link_project` (with `init`), `unlink_project`.
- `schema/skillhook.yaml.schema.json` (generated by `npm run schema`) gives editors validation and
completion for `skillhook.yaml`; the starter file references it on its first line.
- This repository now carries its own `skillhook.yaml`: linking a checkout serves a
`pull-after-merge` hook that fast-forwards it when a pull request merges.

## 0.1.1 (2026-09-16)

- The npm package is now `@meterapp/skillhook`; the command is still `skillhook`. Install with
Expand Down
47 changes: 46 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Keep schedules for digests and clean-ups; give everything that has a trigger a w
```

- A skill is a directory `~/.skillhook/skills/<name>/SKILL.md`: standard Agent Skills frontmatter plus a `skillhook:` block that sets the runner, model, authentication, filters and working directory. Edits apply to the next delivery without a restart.
- A repository can carry its own hooks in a version-controlled `skillhook.yaml` (webhook name → a shell command, a `SKILL.md` in the repository, or inline instructions); `skillhook link <dir>` serves them. See [Version-controlled hooks](#version-controlled-hooks-in-a-repository).
- The runner is the real `claude` or `codex` CLI on the machine, so subscriptions, MCP servers, `CLAUDE.md`/`AGENTS.md` files and tool permissions apply as usual.
- Responses are immediate (`202` with a job id) or synchronous with `?wait=N` (or `Prefer: wait=N`); the agent's final message becomes the job result.
- Developed against Claude Code 2.1.270, Codex CLI 0.153.4 and Tailscale 1.102.3. skillhook drives the CLIs through their headless flags (`claude -p --output-format stream-json …`, `codex exec --json …`); `skillhook run <skill> --dry-run` shows the exact command line.
Expand Down Expand Up @@ -144,6 +145,46 @@ The complete webhook payload (event metadata, counts, first/last seen) is at `{{

Full field reference, filters, dedupe and placeholders: [docs/skills.md](docs/skills.md).

## Version-controlled hooks in a repository

The skills above live in `~/.skillhook`, per machine. A team usually wants the opposite: the mapping from webhook to action checked into the repository it acts on, reviewed in pull requests, identical on every machine that serves it. That is `skillhook.yaml` at the repository root:

```yaml
hooks:
pull-after-merge: # POST <public_url>/hooks/pull-after-merge
description: Fast-forward this checkout when a pull request merges.
run: git pull --ff-only # a shell command; no agent involved
auth: { type: github, secret_env: GITHUB_WEBHOOK_SECRET }
when:
- { header: x-github-event, equals: pull_request }
- { path: action, equals: closed }
- { path: pull_request.merged, equals: true }

release-notes:
skill: .claude/skills/release-notes # an Agent Skill in this repository; keys here override its skillhook: block
model: sonnet
auth: { type: github, secret_env: GITHUB_WEBHOOK_SECRET }
when:
- { header: x-github-event, equals: release }
- { path: action, equals: published }
```

Each hook is one of `run:` (a command run in the repository with the payload on stdin), `skill:` (a `SKILL.md` directory in the repository, served under the hook's name) or `prompt:` (inline instructions for the agent), plus any field of the `skillhook:` block: `auth`, `when`, `model`, `cwd` (defaults to the repository), `env`, `timeout_seconds`, … Secrets are named, never stored, in the file.

```bash
skillhook projects init # in the repository: writes a starter skillhook.yaml and links it
```

```bash
skillhook link ~/dev/your-repo # on any machine that should serve the hooks; live without a restart
```

```bash
skillhook projects # which hook runs what, from which repository, at which URL
```

`skillhook skills list` shows repository hooks next to local skills with their source, `skillhook unlink <dir>` stops serving them, and a `git pull` that changes the file is enough to deploy the change. Names in `~/.skillhook/skills` win over repositories, and a name defined twice is reported instead of guessed. Details: [docs/projects.md](docs/projects.md).

## Choosing runner and model

| | `claude` | `codex` | `shell` |
Expand Down Expand Up @@ -253,6 +294,8 @@ Agents reading this repository should start with [`AGENTS.md`](AGENTS.md) (layou
| `skillhook jobs list [--skill S] [--status ST] [--limit N]` · `jobs show <id> [--result] [--prompt] [--stdout] [--stderr]` · `jobs logs <id> [-f] [--stderr]` · `jobs cancel <id>` · `jobs resume <id> [--exec]` · `jobs path <id>` · `jobs prune [--keep N]` | Inspect and manage jobs. |
| `skillhook mcp [--print-config]` | MCP server over stdio; `--print-config` prints client configuration. |
| `skillhook config show\|get <key>\|set <key> <value>\|unset <key>\|path` | Read and edit `skillhook.json`. |
| `skillhook link [dir] [--no-secret]` / `skillhook unlink <dir>` | Serve the hooks a repository declares in its `skillhook.yaml` (default `.`); stop serving them. |
| `skillhook projects [list]` / `skillhook projects init [dir] [--force]` | List linked repositories and their hooks; write a starter `skillhook.yaml` and link it. |
| `skillhook update [--install]` | Check npm for a newer skillhook; `--install` upgrades with the package manager that installed it and restarts the background service when it is idle. |

Global options: `--dir <path>` (default `$SKILLHOOK_HOME` or `~/.skillhook`), `--json` (machine-readable output for every command), `--help`, `--version`. Exit codes: 0 success, 1 failure, 2 usage error. Environment: `SKILLHOOK_HOME`, `SKILLHOOK_NO_UPDATE_CHECK=1` (or `CI`) to silence the daily update check, `SKILLHOOK_NPM_REGISTRY` for a mirror, `SKILLHOOK_DEBUG=1` for stack traces. HTTP API: [docs/api.md](docs/api.md). Service, logs, jobs, config and troubleshooting: [docs/operations.md](docs/operations.md).
Expand All @@ -261,7 +304,7 @@ Global options: `--dir <path>` (default `$SKILLHOOK_HOME` or `~/.skillhook`), `-

```text
~/.skillhook/
├── skillhook.json server config (JSON Schema: schema/skillhook.schema.json)
├── skillhook.json server config (JSON Schema: schema/skillhook.schema.json); `projects` lists linked repositories
├── .env secrets, mode 600: SKILLHOOK_ADMIN_TOKEN, SKILLHOOK_SECRET_<NAME>, provider secrets, API keys
├── server.json pid/host/port while `serve` runs; removed on shutdown
├── skills/<name>/SKILL.md one directory per skill (plus any files the skill needs)
Expand All @@ -283,6 +326,8 @@ Override the location with `SKILLHOOK_HOME=<path>` or `--dir <path>`.

**How do I stop everything?** `skillhook service uninstall` removes the service and `skillhook expose off` removes the Funnel mapping. Delete `~/.skillhook` if you also want to drop the configuration, secrets and job history.

**Can I run a plain script instead of an agent?** Yes. In a repository's `skillhook.yaml`, `run: ./scripts/deploy.sh` (or any command) runs it in the repository with the payload on stdin and the `SKILLHOOK_*` variables set; in a `SKILL.md`, `runner: shell` with `shell.command` does the same. Exit code 0 is success, stdout is the result. See [docs/projects.md](docs/projects.md) and [docs/runners.md](docs/runners.md#shell-runner).

**Can several skills run at once?** Two jobs globally by default (`concurrency` in `skillhook.json`) and one per skill (`skillhook.concurrency` in `SKILL.md`); the rest wait in a FIFO queue that survives restarts. The same webhook firing twice with the same payload while the first run is still queued or running does not start a second job; the sender gets the first job's id (`duplicate: true, in_flight: true`).

**How do I update?** skillhook asks npm once a day (in the background, cached in `~/.skillhook/update-check.json`) and mentions a newer version after a command, in `skillhook doctor` and in the server log. `skillhook update` checks right now; `skillhook update --install` upgrades with whatever installed it (npm, pnpm, bun, yarn) and restarts the background service if no job is running. Opt out with `SKILLHOOK_NO_UPDATE_CHECK=1` or `"update_check": false` in `skillhook.json`. Releases and notes: [GitHub releases](https://github.com/MeterApp/skillhook/releases).
Expand Down
Loading