diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14d9ac3..ca82195 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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) { @@ -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" diff --git a/AGENTS.md b/AGENTS.md index 87596d9..3448d8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`: `/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. | @@ -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 ` 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`): @@ -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_`); `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 `` 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. @@ -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 ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index dd0374e..b788b14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 ` 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 diff --git a/README.md b/README.md index ed90f24..d9d81b1 100644 --- a/README.md +++ b/README.md @@ -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//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 ` 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 --dry-run` shows the exact command line. @@ -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 /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 ` 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` | @@ -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 [--result] [--prompt] [--stdout] [--stderr]` · `jobs logs [-f] [--stderr]` · `jobs cancel ` · `jobs resume [--exec]` · `jobs path ` · `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 \|set \|unset \|path` | Read and edit `skillhook.json`. | +| `skillhook link [dir] [--no-secret]` / `skillhook unlink ` | 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 ` (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). @@ -261,7 +304,7 @@ Global options: `--dir ` (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_, provider secrets, API keys ├── server.json pid/host/port while `serve` runs; removed on shutdown ├── skills//SKILL.md one directory per skill (plus any files the skill needs) @@ -283,6 +326,8 @@ Override the location with `SKILLHOOK_HOME=` or `--dir `. **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). diff --git a/docs/api.md b/docs/api.md index 7d1f4fb..760fcff 100644 --- a/docs/api.md +++ b/docs/api.md @@ -177,7 +177,25 @@ Public: `{"ok": true, "version": "0.1.0"}`. Admin or direct local: adds `"uptime "how": "Authorization: Bearer <$SKILLHOOK_SECRET_HELLO>" }, "when": ["payload.action equals \"created\""], - "dir": "/Users/me/.skillhook/skills/hello" + "dir": "/Users/me/.skillhook/skills/hello", + "file": "/Users/me/.skillhook/skills/hello/SKILL.md", + "source": { "type": "home" } + }, + { + "name": "pull-after-merge", + "description": "Fast-forward this checkout when a pull request merges.", + "enabled": true, + "runner": "shell", + "model": null, + "effort": null, + "cwd": "/Users/me/dev/api", + "timeout_seconds": 900, + "path": "/hooks/pull-after-merge", + "auth": { "type": "hmac", "secret_env": "GITHUB_WEBHOOK_SECRET", "configured": true, "how": "github HMAC-SHA256 of the body in x-hub-signature-256 (prefix sha256=), secret $GITHUB_WEBHOOK_SECRET" }, + "when": ["header x-github-event equals \"pull_request\"", "payload.action equals \"closed\"", "payload.pull_request.merged equals true"], + "dir": "/Users/me/dev/api", + "file": "/Users/me/dev/api/skillhook.yaml", + "source": { "type": "project", "dir": "/Users/me/dev/api", "file": "/Users/me/dev/api/skillhook.yaml", "kind": "run" } } ], "errors": [ @@ -186,7 +204,7 @@ Public: `{"ok": true, "version": "0.1.0"}`. Admin or direct local: adds `"uptime } ``` -`runner`, `model`, `effort`, `cwd` and `timeout_seconds` are effective values after `defaults`. `auth.type` is the normalized type: `github`, `sentry` and `linear` appear as `hmac`, `granola` and `svix` as `standard-webhooks`; `auth.how` spells out the preset. `auth.configured` says whether the secret is present. This call rescans the skills directory, so new directories appear immediately. +`runner`, `model`, `effort`, `cwd` and `timeout_seconds` are effective values after `defaults`. `auth.type` is the normalized type: `github`, `sentry` and `linear` appear as `hmac`, `granola` and `svix` as `standard-webhooks`; `auth.how` spells out the preset. `auth.configured` says whether the secret is present. `source` says where the skill is defined: `{"type": "home"}` for `/skills/`, or `{"type": "project", "dir", "file", "kind"}` for a hook of a linked repository's `skillhook.yaml` (`kind` is `run`, `skill` or `prompt`; see [projects.md](projects.md)). This call rescans the skills directory and every linked repository, so new directories and hooks appear immediately. ## `POST /skills//run` diff --git a/docs/mcp.md b/docs/mcp.md index be0288c..277c2f7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -51,7 +51,7 @@ Global options apply to the `mcp` command like any other (`--dir`); the server w The server announces itself as `skillhook` with these instructions: -> skillhook turns this machine into a webhook endpoint that runs Agent Skills (SKILL.md files) with Claude Code or Codex. Typical flow: skillhook_status → create_skill (or add_example) → set_secret/generate_secret → run_skill to test locally → get_webhook_urls to hand the URL to the sender (Granola, Sentry, GitHub, Zapier…). Skills live in `/skills//SKILL.md`; the `skillhook:` frontmatter block sets runner, model, auth and filters. Secrets live in `/.env` and are never returned by tools except right after generation. Jobs are directories under `/jobs/` with payload.json, prompt.md, stdout.log and result.md. +> skillhook turns this machine into a webhook endpoint that runs Agent Skills (SKILL.md files) with Claude Code or Codex. Typical flow: skillhook_status → create_skill (or add_example) → set_secret/generate_secret → run_skill to test locally → get_webhook_urls to hand the URL to the sender (Granola, Sentry, GitHub, Zapier…). Skills live in `/skills//SKILL.md`; the `skillhook:` frontmatter block sets runner, model, auth and filters. Secrets live in `/.env` and are never returned by tools except right after generation. A repository can declare its own hooks in a version-controlled skillhook.yaml (webhook name → run: shell command | skill: SKILL.md directory | prompt: inline instructions); link_project registers it so the hooks are served, list_projects shows what runs from which webhook. Jobs are directories under `/jobs/` with payload.json, prompt.md, stdout.log and result.md. Every tool returns a text block (a one-line summary followed by JSON) and the same JSON as `structuredContent`. Failures come back as `isError: true` with `Error: `; nothing throws. @@ -61,8 +61,8 @@ Every tool returns a text block (a one-line summary followed by JSON) and the sa | Tool | Input | Use it to | |---|---|---| -| `skillhook_status` | none | Get the lay of the land first: version and whether a newer one is on npm (`update`, from the daily check's cache), home, config file, whether a server is running (base URL, queue), public base URL and its source, every skill (runner, model, auth, URL), skill load errors, the 10 most recent jobs, and `defaults`. | -| `list_skills` | none | List every skill with effective runner/model/effort/cwd/timeout, auth type, whether its secret is configured, `when` conditions and webhook URL. | +| `skillhook_status` | none | Get the lay of the land first: version and whether a newer one is on npm (`update`, from the daily check's cache), home, config file, whether a server is running (base URL, queue), public base URL and its source, every skill (runner, model, auth, source, URL), skill load errors, linked `projects`, the 10 most recent jobs, and `defaults`. | +| `list_skills` | none | List every skill and repository hook with effective runner/model/effort/cwd/timeout, auth type, whether its secret is configured, `when` conditions, `source` and webhook URL. | | `get_skill` | `name` | Read one skill: the same summary plus the full `SKILL.md` text. | | `create_skill` | `name`, `description`, `instructions`; optional `runner`, `model`, `effort`, `auth_type`, `secret_env`, `cwd`, `timeout_seconds`, `when`, `env`, `overwrite` | Write `/skills//SKILL.md` from structured input. `instructions` is the Markdown body (use `{{payload}}`, `{{payload.some.path}}` or let skillhook append the event block). For `bearer`, `basic` and `hmac` a secret is generated and returned once in `secret`; for provider-signed types the response's `auth_note` says to call `set_secret` with the provider's secret. Returns the webhook URL and whether it is public. | | `update_skill_file` | `name`, `content` | Replace a skill's `SKILL.md` after validating the frontmatter (invalid content is rejected and nothing is written). | @@ -70,6 +70,16 @@ Every tool returns a text block (a one-line summary followed by JSON) and the sa | `list_examples` | none | List the bundled example skills with description, runner and auth type. | | `add_example` | `name`, optional `as` | Copy a bundled example into the home (optionally under another name); generates its secret when skillhook manages it. | +### Repositories with a `skillhook.yaml` + +| Tool | Input | Use it to | +|---|---|---| +| `list_projects` | none | Every linked repository with its hooks (runner, `source.kind` of `run`/`skill`/`prompt`, auth, cwd, URL) and errors: the version-controlled answer to "which skill runs from which webhook". | +| `link_project` | `dir`; optional `init`, `no_secret` | Register a repository's `skillhook.yaml` (or the file's path) so its hooks are served; the running server needs no restart. `init: true` writes a starter file first when none exists. Returns the hooks with URLs, generated secrets (once) and any hook errors. | +| `unlink_project` | `dir` | Stop serving a repository's hooks (`404` at once); nothing in the repository is touched. | + +`update_skill_file` works for `skill:` hooks (it edits the SKILL.md in the repository) and refuses `run:`/`prompt:` hooks, which live in `skillhook.yaml` itself. See [projects.md](projects.md). + ### Running and testing | Tool | Input | Use it to | diff --git a/docs/operations.md b/docs/operations.md index 41a5362..6c9a9ea 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -141,7 +141,7 @@ skillhook jobs prune [--keep N] ## Configuration -`skillhook.json` is validated strictly: unknown keys and wrong types are errors, and `config set` refuses to write an invalid file. `skillhook config show` prints the effective configuration with defaults applied; `config get `; `config set ` (values that look like JSON, such as `4`, `true`, `["a","b"]`, `{"k":1}`, are parsed, everything else is a string); `config unset `; `config path`. Restart the server after changing it. +`skillhook.json` is validated strictly: unknown keys and wrong types are errors, and `config set` refuses to write an invalid file. `skillhook config show` prints the effective configuration with defaults applied; `config get `; `config set ` (values that look like JSON, such as `4`, `true`, `["a","b"]`, `{"k":1}`, are parsed, everything else is a string); `config unset `; `config path`. Restart the server after changing it, except for `projects`, which the server re-reads on its own. | Key | Default | Meaning | |---|---|---| @@ -173,6 +173,7 @@ skillhook jobs prune [--keep N] | `jobs.dedupe_in_flight` | `true` | Fold a delivery identical to a queued or running job of the same skill into that job; skills override with `dedupe.in_flight`. | | `jobs.inline_payload_max_bytes` | `200000` | Payload size inlined in prompts. | | `env_passthrough` | `[]` | Extra env var names copied into every run. | +| `projects` | `[]` | Linked repositories (absolute paths, `~` allowed; a directory holding `skillhook.yaml`, or the file itself). Written by `skillhook link` / `unlink`; re-read without a restart. See [projects.md](projects.md). | | `log_level` | `"info"` | `debug`, `info`, `warn`, `error`. | | `update_check` | `true` | Daily check of the npm registry for a newer skillhook (`SKILLHOOK_NO_UPDATE_CHECK=1` and `CI` disable it as well). | @@ -202,7 +203,8 @@ skillhook config set defaults.model sonnet | `secrets` | `.env` has mode 600 | `.env` missing or another mode | | | `admin token` | `SKILLHOOK_ADMIN_TOKEN` set | unset (admin API localhost-only) | | | `skills` | all `SKILL.md` files parse | no skills yet | one or more invalid | -| `skill ` | runner, model, auth type and cwd | `auth: none` | secret missing (`webhooks will get 503`); cwd does not exist | +| `skill ` | runner, model, auth type and cwd (and the `skillhook.yaml` it comes from) | `auth: none` | secret missing (`webhooks will get 503`); cwd does not exist | +| `project ` | the linked repository's `skillhook.yaml` parses; hooks listed | | file missing or invalid; a hook that does not compile (`skip` when nothing is linked) | | `claude` / `codex` | CLI found and logged in, or `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` present | | not on PATH; not logged in (checked only for runners a skill or the default uses) | | `tailscale` | the configured port is exposed via Funnel or Serve (URL shown) | CLI missing; not running; port not exposed | | | `public url` | `/health` answers | did not answer (certificate still provisioning, or the server is down) | | @@ -259,7 +261,7 @@ The env var named by the skill's `secret_env` (default `SKILLHOOK_SECRET_` ### `404 unknown_skill` -The directory name and `name:` differ, the name has uppercase letters or underscores, the directory starts with `.` or `_`, the skill has `enabled: false`, or `SKILL.md` is invalid (then the server also logs `skill failed to load` and answers `500 invalid_skill`). `skillhook skills validate` shows the reason. +The directory name and `name:` differ, the name has uppercase letters or underscores, the directory starts with `.` or `_`, the skill has `enabled: false`, or `SKILL.md` is invalid (then the server also logs `skill failed to load` and answers `500 invalid_skill`). For a repository hook: the repository is not linked on this machine, the hook was removed from `skillhook.yaml`, or its name is shadowed by an earlier definition. `skillhook skills validate` and `skillhook projects` show the reason. ### Job ends as `interrupted` after a restart diff --git a/docs/projects.md b/docs/projects.md new file mode 100644 index 0000000..299739d --- /dev/null +++ b/docs/projects.md @@ -0,0 +1,180 @@ +# Version-controlled hooks: `skillhook.yaml` + +A repository can declare its own webhooks in a `skillhook.yaml` at its root. The file maps each webhook name to what runs (a shell command, a `SKILL.md` in the repository, or inline instructions for an agent) and lives in git next to the code it acts on. Anyone on the team can change which skill answers which webhook in a pull request, `git log` shows who changed it and when, and every machine that links the repository serves the same hooks. The question "which skill is running from which webhook" has one answer: the file in the repository. + +Related: [skills.md](skills.md) (every field a hook accepts, filters, placeholders), [runners.md](runners.md) (the shell runner), [operations.md](operations.md) (the `projects` config key), [mcp.md](mcp.md) (`link_project`, `list_projects`). + +## Quick start + +In the repository: + +```bash +skillhook projects init # writes skillhook.yaml with a starter hook and links this directory +``` + +Edit the file, commit it. On each machine that should serve the hooks: + +```bash +skillhook link ~/dev/your-repo # registers the repository in ~/.skillhook/skillhook.json +``` + +```bash +skillhook secret set GITHUB_WEBHOOK_SECRET # secrets stay on the machine, never in the repository +``` + +```bash +skillhook url pull-after-merge # the URL to configure at the sender +``` + +The hooks are live on the running server as soon as the file is linked or edited; no restart. `skillhook projects` lists every linked repository with its hooks, `skillhook skills list` shows them next to the skills in `~/.skillhook/skills` with a `source` column. + +## The file + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/MeterApp/skillhook/main/schema/skillhook.yaml.schema.json +hooks: + # A shell command: no agent involved. Runs in the repository with the payload on stdin. + pull-after-merge: + description: Fast-forward this checkout when a pull request merges. + run: git pull --ff-only + 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 } + + # An Agent Skill that lives in the repository. Its skillhook: block applies; keys set here win. + release-notes: + skill: .claude/skills/release-notes + model: sonnet + auth: { type: github, secret_env: GITHUB_WEBHOOK_SECRET } + when: + - { header: x-github-event, equals: release } + - { path: action, equals: published } + + # Inline instructions for Claude Code or Codex, when a whole SKILL.md would be too much. + summarize: + prompt: Summarize the payload in three bullet points and write them to {{job_dir}}/summary.md. + model: haiku +``` + +`hooks` is a map from webhook name to hook. The name follows the skill naming rule (1-64 lowercase letters, digits and single hyphens) and becomes the URL: `POST /hooks/`. `skillhook.yml` is accepted too. Unknown keys are rejected, so a typo cannot silently disable a filter. + +## What a hook accepts + +Exactly one of these says what runs: + +| Key | Type | What runs | +|---|---|---| +| `run` | string or list of strings | A shell command through the [shell runner](runners.md#shell-runner): a string is executed with `/bin/sh -c`, a list directly (first element is the executable). Implies `runner: shell`. | +| `skill` | path | A directory containing a `SKILL.md` (or the path of the file), relative to the repository. The SKILL.md's frontmatter, body, `allowed-tools` and reference files are used exactly as for a skill in `~/.skillhook/skills`. | +| `prompt` | string | The body of an agent run, with the same `{{placeholders}}` as a SKILL.md body (`{{payload.x}}`, `{{job_dir}}`, …). | + +Everything else is the [`skillhook:` block](skills.md#the-skillhook-block) of a SKILL.md, key for key: `description`, `runner`, `model`, `effort`, `cwd`, `timeout_seconds`, `auth`, `when`, `env`, `concurrency`, `dedupe`, `claude`, `codex`, `shell`, `enabled`. Two defaults differ from a skill directory: + +- `cwd` defaults to the repository (the directory containing `skillhook.yaml`), not the skill directory. A relative `cwd` is resolved against the repository; `~` and absolute paths work as usual. `defaults.cwd` from `skillhook.json` does not apply to hooks. +- The default secret variable is derived from the **hook** name: `SKILLHOOK_SECRET_`. + +`description` defaults to the SKILL.md's description for `skill` hooks and to a line about the command otherwise. Without `auth` a hook expects `Authorization: Bearer $SKILLHOOK_SECRET_`, like a skill. + +### `run` hooks + +The command runs in the repository (or the hook's `cwd`) with: + +- the payload on stdin, pretty-printed JSON (or the raw text body); +- the [job environment](runners.md#environment): `SKILLHOOK_PAYLOAD_PATH`, `SKILLHOOK_EVENT_PATH`, `SKILLHOOK_JOB_DIR`, `SKILLHOOK_PROMPT_PATH`, `SKILLHOOK_SKILL` (the hook name), `SKILLHOOK_SKILL_DIR` (the repository), `SKILLHOOK_TRIGGER`, plus the names listed in `env:`; +- the merged `PATH` that also finds tools in `~/.local/bin`, `/opt/homebrew/bin` and friends when the server runs as a service. + +Exit code 0 makes the job `succeeded` with stdout as the result; anything else `failed` with the last stderr lines as the error; `timeout_seconds` (default 900) still applies. Payload data never reaches the command line: read it from stdin or `$SKILLHOOK_PAYLOAD_PATH` (`jq -r .pull_request.number "$SKILLHOOK_PAYLOAD_PATH"`). `run` cannot be combined with `runner` (other than `shell`) or `shell.command`. + +```yaml +hooks: + deploy: + run: ./scripts/deploy.sh # a script in the repository + auth: { type: bearer } # skillhook generates SKILLHOOK_SECRET_DEPLOY on link + when: + - { path: environment, equals: production } + notify: + run: ["python3", "scripts/notify.py", "--channel", "ops"] + env: [SLACK_BOT_TOKEN] + timeout_seconds: 60 +``` + +### `skill` hooks + +The SKILL.md keeps its own name (which must match its directory, as always), while the webhook gets the hook's name. This is how one skill can answer several webhooks with different filters, and how a skill that also serves Claude Code interactively (`.claude/skills//SKILL.md`) becomes a webhook without duplicating it: + +```yaml +hooks: + issue-opened: + skill: .claude/skills/issue-triage + when: [{ header: x-github-event, equals: issues }, { path: action, equals: opened }] + auth: { type: github, secret_env: GITHUB_WEBHOOK_SECRET } + issue-labelled: + skill: .claude/skills/issue-triage + when: [{ header: x-github-event, equals: issues }, { path: action, equals: labeled }, { path: label.name, equals: agent }] + auth: { type: github, secret_env: GITHUB_WEBHOOK_SECRET } +``` + +Keys set on the hook replace the same keys of the SKILL.md's `skillhook:` block (a whole `claude:` or `when:` block is replaced, not merged). The skill directory stays the one passed as `--add-dir` and `{{skill_dir}}`, so `references/*.md` next to the SKILL.md keep working; the agent's working directory is the repository unless `cwd` says otherwise. + +### `prompt` hooks + +`prompt` is the whole body: skillhook prepends `# Skill: `, appends the event block when the prompt does not reference the payload, and adds the unattended-run guardrails, exactly as for a SKILL.md. Use it for one-paragraph jobs; move anything longer into a `SKILL.md` and point `skill:` at it. + +## Linking + +```bash +skillhook link [dir] # default: the current directory; also accepts the path of the YAML file +``` + +`link` validates the file, appends the absolute path to `projects` in `~/.skillhook/skillhook.json`, generates the secrets it manages (bearer, basic, generic hmac; skip with `--no-secret`), names the provider secrets you still have to paste, and prints every hook with its URL. Linking the same repository again is a no-op that re-prints the hooks. `skillhook unlink ` removes the entry; the hooks answer `404` at once and the repository is not touched. `skillhook projects` lists what is linked; `skillhook projects init [dir]` writes a starter file and links it. The MCP tools are `link_project` (with `init: true` to scaffold), `unlink_project` and `list_projects`. + +Precedence and reloads: + +- Routing order is `~/.skillhook/skills` first, then linked repositories in the order of `projects`. A name that is already taken is reported (`hook "deploy" in …/skillhook.yaml is shadowed by …`) by `skills list`, `skills validate`, `doctor` and the server log, and that later definition is not served. Rename one of them. +- The server re-reads `projects` when `skillhook.json` changes, and a repository's hooks when its `skillhook.yaml` or a referenced `SKILL.md` changes. `git pull` on the repository is enough to deploy a hook change. +- A repository that has moved or lost its file shows as an error in the same places; `unlink` it or restore the file. + +Several machines can link the same repository; each has its own URL, secrets and job history, and all serve the same hooks. A hook that must run on only one machine can be disabled elsewhere with a machine-local skill of the same name in `~/.skillhook/skills` (`enabled: false`), which shadows it. + +## Secrets + +`skillhook.yaml` names secrets (`secret_env`) and never contains them. Values live in `~/.skillhook/.env` on each machine: `skillhook secret generate ` for a bearer token, `skillhook secret set NAME` for a provider's signing secret or an API key listed in `env:`. `skillhook doctor` reports a hook whose secret is missing (its webhook answers `503 skill_not_configured` until it is set). + +## Seeing what runs where + +| Where | What it shows | +|---|---| +| `skillhook.yaml` in the repository, and its git history | The intended mapping, reviewable in pull requests. | +| `skillhook projects` | Each linked repository with its hooks: runner, kind (`run`, `skill`, `prompt`), auth, URL, and any error. | +| `skillhook skills list` | Every routable name with a `source` column (the skills directory or the repository). | +| `skillhook skills show ` | The effective settings, the source file, and for `skill` hooks the SKILL.md path. | +| `GET /skills`, MCP `list_skills` / `list_projects` | The same as data: each skill carries `source: {type, dir, file, kind}`. | +| `skillhook jobs list` | Runs by hook name; `jobs show ` prints the working directory and the exact command. | + +## Editor support + +The first line of the starter file points editors at the JSON Schema: `# yaml-language-server: $schema=https://raw.githubusercontent.com/MeterApp/skillhook/main/schema/skillhook.yaml.schema.json` (VS Code with the YAML extension, JetBrains IDEs). The schema ships in the npm package as `schema/skillhook.yaml.schema.json`. + +## Troubleshooting + +### `No skillhook.yaml or skillhook.yml in …` + +`link` needs the file to exist. Create it with `skillhook projects init ` or write it by hand. + +### `hook "x" in … is shadowed by …` + +Two sources define the same name. Skills in `~/.skillhook/skills` win over repositories, earlier repositories over later ones. Rename the hook, or remove the other definition. + +### `Hook "x": skill directory … does not exist` + +`skill:` is resolved relative to the repository (the directory containing `skillhook.yaml`). Check the path and that the directory holds a `SKILL.md` whose `name` equals the directory name. + +### The webhook answers `404` after `git pull` + +The file was renamed or the hook removed, or the repository was unlinked on this machine. `skillhook projects` shows what is served; `skillhook skills validate` shows why something is not. + +### A `run` hook fails with `command not found` + +The service's `PATH` is fixed at install time plus the standard tool directories. Use an absolute path in `run`, or a script in the repository that sets up its own environment. diff --git a/docs/runners.md b/docs/runners.md index 288a8d2..b075987 100644 --- a/docs/runners.md +++ b/docs/runners.md @@ -128,6 +128,8 @@ skillhook: The rendered prompt is still written to `prompt.md`, so a shell command can hand it to another LLM tool. +In a repository's `skillhook.yaml` a shell hook is written as `run: ` (string or array) and runs in the repository by default; see [projects.md](projects.md#run-hooks). + ## Environment Every runner gets a freshly built environment: diff --git a/docs/skills.md b/docs/skills.md index 696ba41..33c156b 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -13,6 +13,8 @@ Related: [security.md](security.md) (auth types in depth), [runners.md](runners. - The default secret variable is `SKILLHOOK_SECRET_`: the name upper-cased with every run of non-alphanumerics replaced by `_` (`granola-meeting-actions` becomes `SKILLHOOK_SECRET_GRANOLA_MEETING_ACTIONS`). - Other files in the directory (scripts, reference docs, templates) are available to the agent: the directory is passed as `--add-dir` and exposed as `{{skill_dir}}` and `$SKILLHOOK_SKILL_DIR`. +A repository can also declare hooks in a version-controlled `skillhook.yaml` (a shell command, a `SKILL.md` in the repository, or an inline prompt per webhook name); `skillhook link ` serves them next to the skills here. Every field below applies to those hooks as well. See [projects.md](projects.md). + Edits take effect without a restart. The server re-reads a `SKILL.md` whose modification time changed before the next delivery, and rescans the directory on every `GET /skills`. Changes to `skillhook.json` do require a restart; changes to `.env` do not (secrets are re-read on every request). ## Anatomy @@ -112,7 +114,7 @@ The Codex approval policy is server-wide: `runners.codex.approval_policy` (defau | `command` | string | Run through `/bin/sh -c ""`. | | `command` | string[] | Executed directly; the first element is the executable. | -The command receives the payload JSON on stdin and the `SKILLHOOK_*` variables in its environment; its stdout becomes the job result and a non-zero exit code fails the job. See [runners.md](runners.md#shell-runner). +The command receives the payload JSON on stdin and the `SKILLHOOK_*` variables in its environment; its stdout becomes the job result and a non-zero exit code fails the job. See [runners.md](runners.md#shell-runner). In a repository's `skillhook.yaml` the same thing is spelled `run: ` ([projects.md](projects.md#run-hooks)). ## Authentication diff --git a/llms.txt b/llms.txt index c4a8a9f..dc48ecb 100644 --- a/llms.txt +++ b/llms.txt @@ -6,6 +6,7 @@ - [README](README.md): pitch (reactive skills instead of scheduled tasks), how it works, five-minute quickstart, writing a skill, choosing runner and model, security summary, permanent URLs, examples, agent setup, CLI reference, FAQ - [Writing skills](docs/skills.md): SKILL.md format, every `skillhook:` field with type and default, auth types, `when` filters, deduplication, template placeholders, the prompt and guardrails, what the agent receives, scaffolding and testing, examples +- [Version-controlled hooks](docs/projects.md): `skillhook.yaml` in a repository (webhook name → `run:` shell command, `skill:` SKILL.md directory, or `prompt:`), `skillhook link` / `unlink` / `projects`, precedence and shadowing, live reload, secrets, seeing which skill runs from which webhook - [Security](docs/security.md): threat model, per-auth-type header formats and sender setup (bearer, basic, hmac, github, sentry, linear, standard-webhooks, granola, svix, stripe, slack), IP allow-lists, secrets and file modes, environment isolation, prompt-injection guardrails, limits, admin API, checklist - [Getting a permanent URL](docs/exposure.md): Tailscale Funnel and Serve, one-time approval, Cloudflare Tunnel and ngrok recipes, `public_url`, client IPs behind proxies, verification, troubleshooting - [Runners](docs/runners.md): exact `claude -p` and `codex exec` command lines, subscription vs API key, shell runner, environment allow-list, working directories, timeouts, sessions and resume @@ -19,6 +20,7 @@ - Home directory: `~/.skillhook` (override with `SKILLHOOK_HOME` or `--dir `): `skillhook.json`, `.env` (mode 600), `skills//SKILL.md`, `jobs//`, `logs/service.log`, `server.json` while a server runs. - A skill is `skills//SKILL.md`: YAML frontmatter with `name` (must equal the directory name, `^[a-z0-9]+(?:-[a-z0-9]+)*$`), `description`, optional `license`, `compatibility`, `metadata`, `allowed-tools`, and a `skillhook:` block with `runner`, `model`, `effort`, `cwd`, `timeout_seconds`, `auth`, `when`, `env`, `concurrency`, `dedupe`, `claude`, `codex`, `shell`, `enabled`; then Markdown instructions. +- A repository can declare hooks in a version-controlled `skillhook.yaml` at its root: `hooks: { : { run: | skill: | prompt: , ...any skillhook: field } }`. `cwd` defaults to the repository; the default secret is `SKILLHOOK_SECRET_`. `skillhook link ` registers it in `projects` of `skillhook.json` (re-read without a restart), `skillhook unlink ` removes it, `skillhook projects` lists linked repositories and hooks, `skillhook projects init [dir]` writes a starter file. Names in `/skills` win over repositories; duplicates are reported, not served. JSON Schema: `schema/skillhook.yaml.schema.json`. MCP: `link_project`, `unlink_project`, `list_projects`. - Default auth is `bearer`: the sender sets `Authorization: Bearer $SKILLHOOK_SECRET_` (name upper-cased, non-alphanumerics to `_`). Provider presets: `github`, `sentry`, `linear`, `standard-webhooks`, `granola`, `svix`, `stripe`, `slack`; also `basic`, `hmac`, `none`. Secrets live in `.env`; missing secret means `503 skill_not_configured`. - Webhook URL: `POST /hooks/` answers `202 {job_id, status_url}`; `?wait=N` (max 120 s by default) returns `200` with `result`. Duplicates return `200 {duplicate: true}`, filtered deliveries `200 {skipped: true}`. - Identical deliveries in flight: a webhook whose payload and query string match a job of the same skill that is still queued or running answers `200 {duplicate: true, in_flight: true, job_id}` instead of starting a second run (`jobs.dedupe_in_flight`, default true; per skill `dedupe.in_flight: false`). Once that job finishes, the same payload runs again. @@ -29,7 +31,7 @@ - Environment given to the agent: `SKILLHOOK_JOB_ID`, `SKILLHOOK_JOB_DIR`, `SKILLHOOK_SKILL`, `SKILLHOOK_SKILL_DIR`, `SKILLHOOK_PAYLOAD_PATH`, `SKILLHOOK_EVENT_PATH`, `SKILLHOOK_PROMPT_PATH`, `SKILLHOOK_TRIGGER`, `SKILLHOOK_RUNNER`, `ANTHROPIC_*`/`CLAUDE_*`/`OPENAI_*`/`CODEX_*`, and the names listed in the skill's `env:`. `SKILLHOOK_SECRET_*` and `SKILLHOOK_ADMIN_TOKEN` are never forwarded implicitly. - Job statuses: `queued`, `running`, `succeeded`, `failed`, `timed_out`, `cancelled`, `interrupted`. Job ids look like `20260916T025442Z-r1wn6g`. `skillhook jobs list|show|logs|cancel|resume|path|prune`. - Admin API (`/skills`, `/skills//run`, `/jobs`, `/jobs/`, `/jobs//cancel`): `Authorization: Bearer $SKILLHOOK_ADMIN_TOKEN`, or token-less from direct loopback connections without proxy headers. -- MCP: `skillhook mcp` (stdio). Register with `claude mcp add skillhook -- skillhook mcp` or `codex mcp add skillhook -- skillhook mcp`; `skillhook mcp --print-config` prints the exact lines and an mcp.json snippet. Tools: skillhook_status, list_skills, get_skill, create_skill, update_skill_file, validate_skills, run_skill, send_test_webhook, list_jobs, get_job, cancel_job, set_secret, generate_secret, list_secrets, get_webhook_urls, expose, service, doctor, list_examples, add_example. +- MCP: `skillhook mcp` (stdio). Register with `claude mcp add skillhook -- skillhook mcp` or `codex mcp add skillhook -- skillhook mcp`; `skillhook mcp --print-config` prints the exact lines and an mcp.json snippet. Tools: skillhook_status, list_skills, get_skill, create_skill, update_skill_file, validate_skills, run_skill, send_test_webhook, list_jobs, get_job, cancel_job, set_secret, generate_secret, list_secrets, get_webhook_urls, expose, service, doctor, list_examples, add_example, list_projects, link_project, unlink_project. - Config: `skillhook.json` (schema in `schema/skillhook.schema.json`); `skillhook config show|get|set|unset|path`. Every CLI command accepts `--json` and `--dir`; exit code 0 ok, 1 error, 2 usage. - Updates: `skillhook update` asks npm for the newest version, `skillhook update --install` upgrades (npm/pnpm/bun/yarn) and restarts the service when idle. A daily background check (cached in `/update-check.json`) mentions newer versions after interactive commands, in `doctor` and in the server log; disable with `SKILLHOOK_NO_UPDATE_CHECK=1`, `CI`, or `"update_check": false`. Releases: https://github.com/MeterApp/skillhook/releases. - Install: `npm install -g @meterapp/skillhook` (the command is `skillhook`; `npx @meterapp/skillhook ` for one-off use). The unscoped `skillhook` package is the old 0.1.0 name: `npm uninstall -g skillhook` before installing, then `skillhook service install` again if the service ran from it. diff --git a/schema/skillhook.schema.json b/schema/skillhook.schema.json index 8a1269d..a415031 100644 --- a/schema/skillhook.schema.json +++ b/schema/skillhook.schema.json @@ -228,6 +228,14 @@ "type": "string" } }, + "projects": { + "default": [], + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "log_level": { "default": "info", "type": "string", diff --git a/schema/skillhook.yaml.schema.json b/schema/skillhook.yaml.schema.json new file mode 100644 index 0000000..8fb36e5 --- /dev/null +++ b/schema/skillhook.yaml.schema.json @@ -0,0 +1,588 @@ +{ + "$id": "https://raw.githubusercontent.com/MeterApp/skillhook/main/schema/skillhook.yaml.schema.json", + "title": "skillhook.yaml", + "description": "Hooks a repository declares for skillhook (https://github.com/MeterApp/skillhook/blob/main/docs/projects.md): webhook name → shell command, SKILL.md or prompt.", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "hooks": { + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "runner": { + "type": "string", + "enum": [ + "claude", + "codex", + "shell" + ] + }, + "model": { + "type": "string" + }, + "effort": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "timeout_seconds": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "auth": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "none" + }, + "allow_ips": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "bearer" + }, + "secret_env": { + "type": "string", + "pattern": "^[A-Z_][A-Z0-9_]*$" + }, + "header": { + "type": "string" + }, + "allow_query_token": { + "type": "boolean" + }, + "allow_ips": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "basic" + }, + "secret_env": { + "type": "string", + "pattern": "^[A-Z_][A-Z0-9_]*$" + }, + "allow_ips": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "hmac" + }, + "secret_env": { + "type": "string", + "pattern": "^[A-Z_][A-Z0-9_]*$" + }, + "header": { + "type": "string" + }, + "prefix": { + "type": "string" + }, + "encoding": { + "type": "string", + "enum": [ + "hex", + "base64" + ] + }, + "algorithm": { + "type": "string", + "enum": [ + "sha256", + "sha1", + "sha512" + ] + }, + "delivery_id_header": { + "type": "string" + }, + "timestamp_header": { + "type": "string" + }, + "tolerance_seconds": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "allow_ips": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "github" + }, + "secret_env": { + "type": "string", + "pattern": "^[A-Z_][A-Z0-9_]*$" + }, + "allow_ips": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sentry" + }, + "secret_env": { + "type": "string", + "pattern": "^[A-Z_][A-Z0-9_]*$" + }, + "allow_ips": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "linear" + }, + "secret_env": { + "type": "string", + "pattern": "^[A-Z_][A-Z0-9_]*$" + }, + "allow_ips": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "standard-webhooks" + }, + "secret_env": { + "type": "string", + "pattern": "^[A-Z_][A-Z0-9_]*$" + }, + "tolerance_seconds": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "allow_ips": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "granola" + }, + "secret_env": { + "type": "string", + "pattern": "^[A-Z_][A-Z0-9_]*$" + }, + "tolerance_seconds": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "allow_ips": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "svix" + }, + "secret_env": { + "type": "string", + "pattern": "^[A-Z_][A-Z0-9_]*$" + }, + "tolerance_seconds": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "allow_ips": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "stripe" + }, + "secret_env": { + "type": "string", + "pattern": "^[A-Z_][A-Z0-9_]*$" + }, + "tolerance_seconds": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "allow_ips": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "slack" + }, + "secret_env": { + "type": "string", + "pattern": "^[A-Z_][A-Z0-9_]*$" + }, + "tolerance_seconds": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "allow_ips": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "when": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "header": { + "type": "string" + }, + "query": { + "type": "string" + }, + "equals": {}, + "not_equals": {}, + "in": { + "type": "array", + "items": {} + }, + "matches": { + "type": "string" + }, + "exists": { + "type": "boolean" + }, + "contains": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "env": { + "type": "array", + "items": { + "type": "string" + } + }, + "concurrency": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "dedupe": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "header": { + "type": "string" + }, + "in_flight": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "claude": { + "type": "object", + "properties": { + "permission_mode": { + "type": "string", + "enum": [ + "acceptEdits", + "auto", + "bypassPermissions", + "manual", + "dontAsk", + "plan" + ] + }, + "allowed_tools": { + "type": "array", + "items": { + "type": "string" + } + }, + "disallowed_tools": { + "type": "array", + "items": { + "type": "string" + } + }, + "add_dirs": { + "type": "array", + "items": { + "type": "string" + } + }, + "max_budget_usd": { + "type": "number", + "exclusiveMinimum": 0 + }, + "append_system_prompt": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "codex": { + "type": "object", + "properties": { + "sandbox": { + "type": "string", + "enum": [ + "read-only", + "workspace-write", + "danger-full-access" + ] + }, + "network_access": { + "type": "boolean" + }, + "profile": { + "type": "string" + }, + "add_dirs": { + "type": "array", + "items": { + "type": "string" + } + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "shell": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + ] + } + }, + "required": [ + "command" + ], + "additionalProperties": false + }, + "enabled": { + "type": "boolean" + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "skill": { + "type": "string", + "minLength": 1 + }, + "run": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + ] + }, + "prompt": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + } + }, + "required": [ + "hooks" + ], + "additionalProperties": false +} diff --git a/scripts/generate-schema.ts b/scripts/generate-schema.ts index 9520edc..1a894a5 100644 --- a/scripts/generate-schema.ts +++ b/scripts/generate-schema.ts @@ -1,29 +1,55 @@ -// Writes schema/skillhook.schema.json from the zod config schema. +// Writes schema/skillhook.schema.json (server config) and schema/skillhook.yaml.schema.json (a project's hooks) +// from the zod schemas. // npm run schema regenerate -// npm run schema -- --check fail when the committed file is stale (CI) +// npm run schema -- --check fail when a committed file is stale (CI) import { existsSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { z } from "zod"; import { ConfigSchema } from "../src/config.js"; +import { ProjectFileSchema } from "../src/projects.js"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const schema = { - $id: "https://raw.githubusercontent.com/MeterApp/skillhook/main/schema/skillhook.schema.json", - title: "skillhook.json", - description: "Server configuration for skillhook (https://github.com/MeterApp/skillhook).", - ...z.toJSONSchema(ConfigSchema, { target: "draft-7", io: "input" }), -}; -const file = path.join(root, "schema", "skillhook.schema.json"); -const next = `${JSON.stringify(schema, null, 2)}\n`; -if (process.argv.includes("--check")) { - const current = existsSync(file) ? readFileSync(file, "utf8") : ""; - if (current !== next) { - console.error(`${file} is stale. Run: npm run schema`); - process.exit(1); +const base = "https://raw.githubusercontent.com/MeterApp/skillhook/main/schema"; + +const schemas = [ + { + file: "skillhook.schema.json", + schema: { + $id: `${base}/skillhook.schema.json`, + title: "skillhook.json", + description: "Server configuration for skillhook (https://github.com/MeterApp/skillhook).", + ...z.toJSONSchema(ConfigSchema, { target: "draft-7", io: "input" }), + }, + }, + { + file: "skillhook.yaml.schema.json", + schema: { + $id: `${base}/skillhook.yaml.schema.json`, + title: "skillhook.yaml", + description: "Hooks a repository declares for skillhook (https://github.com/MeterApp/skillhook/blob/main/docs/projects.md): webhook name → shell command, SKILL.md or prompt.", + ...z.toJSONSchema(ProjectFileSchema, { target: "draft-7", io: "input", unrepresentable: "any" }), + }, + }, +]; + +const check = process.argv.includes("--check"); +let stale = false; +for (const { file, schema } of schemas) { + const target = path.join(root, "schema", file); + const next = `${JSON.stringify(schema, null, 2)}\n`; + if (check) { + const current = existsSync(target) ? readFileSync(target, "utf8") : ""; + if (current !== next) { + console.error(`${target} is stale. Run: npm run schema`); + stale = true; + } + } else { + writeFileSync(target, next); + console.log(`wrote ${target}`); } - console.log("schema up to date"); -} else { - writeFileSync(file, next); - console.log(`wrote ${file}`); +} +if (check) { + if (stale) process.exit(1); + console.log("schemas up to date"); } diff --git a/skillhook.yaml b/skillhook.yaml new file mode 100644 index 0000000..b6202c2 --- /dev/null +++ b/skillhook.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/MeterApp/skillhook/main/schema/skillhook.yaml.schema.json +# The webhooks of this repository, version-controlled with it. Serve them from a checkout with: +# skillhook link . (then: skillhook secret set GITHUB_WEBHOOK_SECRET; skillhook url pull-after-merge) +# Reference: docs/projects.md + +hooks: + # GitHub → Settings → Webhooks: payload URL from `skillhook url pull-after-merge`, content type + # application/json, event "Pull requests". Fast-forwards the branch this checkout is on; a checkout + # with local changes or a diverged branch makes the job fail instead of touching anything. + pull-after-merge: + description: Fast-forward this checkout when a pull request merges into main. + run: git pull --ff-only + timeout_seconds: 120 + 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 } + - { path: pull_request.base.ref, equals: main } diff --git a/skills/skillhook-authoring/SKILL.md b/skills/skillhook-authoring/SKILL.md index 94a5b81..6c97774 100644 --- a/skills/skillhook-authoring/SKILL.md +++ b/skills/skillhook-authoring/SKILL.md @@ -47,6 +47,27 @@ Start from an example when one is close — `skillhook skills examples`, then `s Unknown keys fail validation and the skill stops routing — `skillhook skills validate ` tells you. +## Hooks in a repository (`skillhook.yaml`) + +When the webhook belongs to a repository (pull the checkout after a merge, run a script on deploy, triage that repository's issues), put the mapping in a `skillhook.yaml` at its root instead of `~/.skillhook/skills`, so it is reviewed and versioned with the code and identical on every machine that runs `skillhook link `: + +```yaml +hooks: + pull-after-merge: # POST /hooks/pull-after-merge + run: git pull --ff-only # a shell command in the repository, payload on stdin; no agent + 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 }] + issue-triage: + skill: .claude/skills/issue-triage # a SKILL.md directory in the repository, served under this hook's name + when: [{ header: x-github-event, equals: issues }, { path: action, equals: opened }] + auth: { type: github, secret_env: GITHUB_WEBHOOK_SECRET } + summarize: + prompt: Summarize the payload into {{job_dir}}/summary.md. # inline instructions for the agent + model: haiku +``` + +Each hook is exactly one of `run` / `skill` / `prompt` plus any key of the table above. Differences from a skill directory: `cwd` defaults to the repository (a relative `cwd` is resolved against it), the default secret is `SKILLHOOK_SECRET_`, and keys on a `skill:` hook replace the same keys of the SKILL.md's block (so one SKILL.md can back several hooks with different filters). `run` commands read the payload from stdin or `$SKILLHOOK_PAYLOAD_PATH`; never build the command line from payload data. Scaffold with `skillhook projects init` (MCP `link_project` with `init: true`), check with `skillhook skills validate`, test with `skillhook run --payload … --dry-run` like any skill. Secrets are named in the file and set per machine with `skillhook secret set`. Reference: docs/projects.md. + ## Auth presets and what each verifies | `type` | Header(s) checked | Secret | diff --git a/skills/skillhook-setup/SKILL.md b/skills/skillhook-setup/SKILL.md index d63d2f0..6915b98 100644 --- a/skills/skillhook-setup/SKILL.md +++ b/skills/skillhook-setup/SKILL.md @@ -101,6 +101,18 @@ skillhook secret set GRANOLA_WEBHOOK_SECRET # provider-signed skills (github, Give the user the URL and the header to configure (bearer: `Authorization: Bearer `); never paste a secret into chat, a commit or a screenshot. Senders that want the answer in the HTTP response add `?wait=` (up to `max_wait_seconds`, default 120). The bundled examples carry provider-specific setup steps in their SKILL.md: `skillhook skills examples`, then `skillhook skills add `. +## 9. Serve a repository's own hooks + +A team keeps the webhook → action mapping in the repository instead of on one machine: a `skillhook.yaml` at the root with `hooks: { : { run: | skill: | prompt: , auth, when, model, … } }`. On each machine that should serve it: + +```bash +skillhook link ~/dev/the-repo # or, in the repository: skillhook projects init (writes a starter file, then links) +skillhook projects # every linked repository with its hooks and URLs +skillhook secret set GITHUB_WEBHOOK_SECRET # the file names secrets; values stay in .env +``` + +Hooks are live without a restart, `git pull` deploys changes, and `skillhook unlink ` stops serving them. Names in `~/.skillhook/skills` win over repositories; a duplicate is reported by `skillhook skills list` and `doctor`. MCP: `link_project` (`init: true` to scaffold), `list_projects`, `unlink_project`. Writing the file itself is covered by skillhook-authoring. + ## The same through MCP Install the plugin (`/plugin marketplace add MeterApp/skillhook`, then `/plugin install skillhook@meterapp-skillhook`) or add the server directly — `skillhook mcp --print-config` prints the command for Claude Code, Codex and mcp.json hosts. Tools map onto the CLI: @@ -114,6 +126,7 @@ Install the plugin (`/plugin marketplace add MeterApp/skillhook`, then `/plugin | Expose | `skillhook expose tailscale [--serve]`, `skillhook url` | `expose` (mode `funnel` / `serve` / `status` / `off`), `get_webhook_urls` | | Service | `skillhook service …` | `service` (action `install` / `status` / `restart` / `logs` / `uninstall`) | | Jobs | `skillhook jobs show / logs / cancel` | `get_job`, `list_jobs`, `cancel_job` | +| Repository hooks | `skillhook link`, `unlink`, `projects [init]` | `link_project`, `unlink_project`, `list_projects` | `skillhook_status` reports the home directory, whether the server runs, the public URL and every skill with its auth type. The MCP server never returns secret values except right after `generate_secret`. `run_skill` uses the running server when there is one, otherwise runs in-process. diff --git a/src/cli.test.ts b/src/cli.test.ts index 3f838d6..7e6a4cd 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { createServer } from "node:http"; @@ -132,6 +132,73 @@ describe("cli", () => { expect(String(resume.json().resume_command)).toContain("claude --resume"); }); + it("links a repository's skillhook.yaml, lists and runs its hooks, and unlinks it", async () => { + const repo = path.join(paths.home, "repo"); + const bare = path.join(paths.home, "bare"); + mkdirSync(bare, { recursive: true }); + const nothing = io(); + expect(await main(["link", bare, ...dir, "--json"], nothing.cli)).toBe(1); + expect(String(nothing.json().error)).toContain("skillhook.yaml"); + + const init = io(); + expect(await main(["projects", "init", repo, ...dir, "--json"], init.cli)).toBe(0); + const initialized = init.json(); + expect(initialized.written).toBe(true); + expect(existsSync(path.join(repo, "skillhook.yaml"))).toBe(true); + expect(((initialized.project as { hooks: { name: string }[] }).hooks).map((h) => h.name)).toEqual(["pull-after-merge"]); + expect(JSON.parse(readFileSync(paths.configFile, "utf8")).projects).toEqual([repo]); + + writeFileSync(path.join(repo, "skillhook.yaml"), ["hooks:", " where:", " run: pwd", " greet:", " prompt: Say hi to {{payload.name}}.", " model: haiku", ""].join("\n")); + const again = io(); + expect(await main(["link", repo, ...dir, "--json"], again.cli)).toBe(0); + const linked = again.json(); + expect(linked.added).toBe(false); + expect((linked.secrets as { env: string; secret: string | null }[]).map((s) => s.env).sort()).toEqual(["SKILLHOOK_SECRET_GREET", "SKILLHOOK_SECRET_WHERE"]); + expect(readFileSync(paths.envFile, "utf8")).toContain("SKILLHOOK_SECRET_WHERE="); + + const list = io(); + expect(await main(["projects", ...dir, "--json"], list.cli)).toBe(0); + const projects = list.json().projects as { dir: string; hooks: { name: string; runner: string; source: { kind: string } }[] }[]; + expect(projects).toHaveLength(1); + expect(projects[0]?.hooks.map((h) => [h.name, h.runner, h.source.kind])).toEqual([["where", "shell", "run"], ["greet", "claude", "prompt"]]); + const human = io(); + expect(await main(["projects", ...dir], human.cli)).toBe(0); + expect(human.out()).toContain("where"); + expect(human.out()).toContain("/hooks/greet"); + + const skills = io(); + expect(await main(["skills", "list", ...dir, "--json"], skills.cli)).toBe(0); + const where = (skills.json().skills as { name: string; source: { type: string; dir?: string } }[]).find((s) => s.name === "where"); + expect(where?.source).toMatchObject({ type: "project", dir: repo }); + const show = io(); + expect(await main(["skills", "show", "where", ...dir], show.cli)).toBe(0); + expect(show.out()).toContain("shell command"); + + const run = io(); + expect(await main(["run", "where", ...dir, "--payload", "{}", "--json"], run.cli)).toBe(0); + expect((run.json().job as { result: string; runner: string }).result).toBe(repo); + const dry = io(); + expect(await main(["run", "greet", ...dir, "--payload", '{"name":"Ada"}', "--dry-run", "--json"], dry.cli)).toBe(0); + expect(String(dry.json().prompt)).toContain("Say hi to Ada."); + expect(dry.json().cwd).toBe(repo); + + const validate = io(); + expect(await main(["skills", "validate", ...dir, "--json"], validate.cli)).toBe(0); + expect(validate.json().valid).toContain("where"); + const doctor = io({ SKILLHOOK_NO_UPDATE_CHECK: "1" }); + await main(["doctor", ...dir, "--json"], doctor.cli); + expect((doctor.json().checks as { name: string; status: string; detail: string }[]).find((c) => c.name.startsWith("project "))).toMatchObject({ status: "ok", detail: expect.stringContaining("where") }); + + const unlink = io(); + expect(await main(["unlink", repo, ...dir, "--json"], unlink.cli)).toBe(0); + expect(unlink.json().removed).toBe(true); + expect(JSON.parse(readFileSync(paths.configFile, "utf8")).projects).toBeUndefined(); + const gone = io(); + expect(await main(["run", "where", ...dir, "--json"], gone.cli)).toBe(1); + const twice = io(); + expect(await main(["unlink", repo, ...dir, "--json"], twice.cli)).toBe(1); + }); + it("reports failures with a non-zero exit code", async () => { const missing = io(); expect(await main(["run", "nope", ...dir, "--json"], missing.cli)).toBe(1); diff --git a/src/commands/init.ts b/src/commands/init.ts index dc9589c..2010f65 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -69,6 +69,7 @@ export async function initCommand(ctx: Ctx): Promise { "skillhook expose tailscale # permanent public HTTPS URL via Tailscale Funnel", "skillhook send hello --wait 60 # POST a signed test webhook to the running server", "skillhook skills new my-skill # add your own skill", + "skillhook link ~/dev/your-repo # serve the hooks a repository declares in its skillhook.yaml", ]; const human = [ `Initialized skillhook in ${paths.home}`, diff --git a/src/commands/main.ts b/src/commands/main.ts index d70d213..8590270 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -16,6 +16,7 @@ import { doctorCommand } from "./doctor.js"; import { configCommand } from "./config.js"; import { mcpCommand } from "./mcp.js"; import { updateCommand } from "./update.js"; +import { linkCommand, projectsCommand, unlinkCommand } from "./projects.js"; import { planUpdateNotice, spawnBackgroundRefresh } from "../update.js"; import { readJsonFileOr } from "../util.js"; @@ -37,6 +38,10 @@ Skills (SKILL.md files in ~/.skillhook/skills//) skills list | show | new [options] | add [--as NAME] | examples | validate [name] | path secret set [--value V|--stdin] | generate [--force] | list | unset +Projects (a repository's skillhook.yaml: webhook name → shell command, SKILL.md or prompt, version-controlled with the code) + link [dir] [--no-secret] | unlink Serve the hooks a repository declares (default dir: .); stop serving them + projects [list] | init [dir] [--force] List linked projects and their hooks; write a starter skillhook.yaml and link it + Running run [--payload JSON|@file|-] [--header "K: v"]... [--runner R] [--model M] [--effort E] [--cwd DIR] [--wait S] [--dry-run] send [--payload …] [--wait S] [--url BASE|--public|--local] [--header "K: v"]... POST a signed test webhook @@ -73,6 +78,10 @@ const COMMANDS: Record = { mcp: mcpCommand, update: updateCommand, upgrade: updateCommand, + link: linkCommand, + unlink: unlinkCommand, + projects: projectsCommand, + project: projectsCommand, }; /** Commands whose output must stay clean, or that handle update checks themselves. */ diff --git a/src/commands/projects.ts b/src/commands/projects.ts new file mode 100644 index 0000000..4ffd61d --- /dev/null +++ b/src/commands/projects.ts @@ -0,0 +1,144 @@ +import { PROJECT_FILE_NAMES, type LoadedProject } from "../projects.js"; +import { createOps, describeProject, initProject, linkProject, listProjects, resolveBaseUrl, webhookUrl, type LinkResult, type Ops } from "../ops.js"; +import { skillSummary } from "../server.js"; +import { displayPath } from "../util.js"; +import { bool, CommandError, UsageError, type Ctx } from "./shared.js"; + +const USAGE = `Usage: + skillhook link [dir] [--no-secret] register a repository's ${PROJECT_FILE_NAMES[0]} (or the file itself) with this server; default: . + skillhook unlink stop serving its hooks (the repository is not touched) + skillhook projects [list] linked projects and their hooks + skillhook projects init [dir] [--force] write a starter ${PROJECT_FILE_NAMES[0]} into dir (default: .) and link it + skillhook projects add|remove the same as link / unlink + +A project's ${PROJECT_FILE_NAMES[0]} maps webhook names to what runs: a shell command (run:), a SKILL.md in the +repository (skill:) or inline instructions (prompt:). It is version-controlled with the repository; the hooks +go live on the running server without a restart. Docs: docs/projects.md`; + +export async function projectsCommand(ctx: Ctx): Promise { + const [sub = "list", target] = ctx.args; + switch (sub) { + case "list": + case "ls": + return listCommand(ctx); + case "add": + case "link": + return link(ctx, target); + case "remove": + case "rm": + case "unlink": + return unlink(ctx, target); + case "init": + return init(ctx, target); + default: + throw new UsageError(`Unknown projects subcommand "${sub}"`, USAGE); + } +} + +/** `skillhook link [dir]` */ +export async function linkCommand(ctx: Ctx): Promise { + return link(ctx, ctx.args[0]); +} + +/** `skillhook unlink ` */ +export async function unlinkCommand(ctx: Ctx): Promise { + return unlink(ctx, ctx.args[0]); +} + +function projectJson(ops: Ops, project: LoadedProject, baseUrl: string): Record { + const secrets = ops.secrets(); + return { + dir: project.dir, + file: project.file, + error: project.error ?? null, + hooks: project.hooks.map((hook) => ({ ...skillSummary(hook, ops.config, secrets), url: webhookUrl(baseUrl, hook.name) })), + errors: project.errors, + }; +} + +function hookLines(ops: Ops, project: LoadedProject, baseUrl: string): string[] { + const secrets = ops.secrets(); + const lines = project.hooks.map((hook) => { + const summary = skillSummary(hook, ops.config, secrets); + const auth = summary.auth as { type: string; configured: boolean; how: string }; + const kind = hook.source.type === "project" ? hook.source.kind : "skill"; + return ` ${hook.name.padEnd(24)} ${String(summary.runner).padEnd(6)} ${kind.padEnd(6)} ${`${auth.type}${auth.configured ? "" : " (secret missing)"}`.padEnd(22)} ${webhookUrl(baseUrl, hook.name)}`; + }); + for (const error of project.errors) lines.push(` ✗ ${error.name}: ${error.error.split("\n")[0]}`); + return lines; +} + +async function listCommand(ctx: Ctx): Promise { + const ops = createOps(ctx.paths, { env: ctx.io.env }); + const projects = listProjects(ops); + const { baseUrl, source } = await resolveBaseUrl(ops); + const lines: string[] = []; + if (!projects.length) lines.push(`No linked projects. Register a repository's ${PROJECT_FILE_NAMES[0]} with: skillhook link (or create one: skillhook projects init )`); + for (const project of projects) { + if (lines.length) lines.push(""); + lines.push(`${describeProject(project)}${project.error ? ` ✗ ${project.error}` : ` ${project.hooks.length} hook(s)${project.errors.length ? `, ${project.errors.length} invalid` : ""}`}`); + lines.push(...hookLines(ops, project, baseUrl)); + } + if (projects.length) lines.push("", `URLs use the ${source} base URL ${baseUrl}`); + ctx.print(lines.join("\n"), { projects: projects.map((p) => projectJson(ops, p, baseUrl)), base_url: baseUrl, base_url_source: source }); + return projects.some((p) => p.error || p.errors.length) ? 1 : 0; +} + +async function printLinked(ctx: Ctx, ops: Ops, result: LinkResult, intro: string[], extra: Record = {}): Promise { + const { baseUrl } = await resolveBaseUrl(ops); + const lines = [...intro, `${result.added ? "Linked" : "Already linked:"} ${describeProject(result.project)} → ${displayPath(ops.paths.configFile)}`, "", ...hookLines(ops, result.project, baseUrl)]; + for (const error of result.errors.filter((e) => !result.project.errors.some((p) => p.name === e.name))) lines.push(` ✗ ${error.name}: ${error.error.split("\n")[0]}`); + const generated = result.secrets.filter((s) => s.generated); + if (generated.length) lines.push("", "Secrets generated (shown once; stored in .env):", ...generated.map((s) => ` ${s.env}=${s.generated}`)); + const secrets = ops.secrets(); + const provider: { name: string; env: string }[] = []; + for (const hook of result.project.hooks) { + const auth = hook.auth; + if (auth.type !== "none" && !result.secrets.some((s) => s.hook === hook.name) && !secrets[auth.secret_env]) provider.push({ name: hook.name, env: auth.secret_env }); + } + if (provider.length) lines.push("", "Provider secrets to paste:", ...provider.map((p) => ` skillhook secret set ${p.env} # ${p.name}`)); + const first = result.project.hooks[0]; + lines.push("", "Hooks are live on the running server without a restart. Commit the file with the repository.", ...(first ? [`Test one: skillhook run ${first.name} --payload '{}' --dry-run`] : [])); + ctx.print(lines.join("\n"), { + ok: result.errors.length === 0, + ...extra, + entry: result.entry, + added: result.added, + project: projectJson(ops, result.project, baseUrl), + secrets: result.secrets.map((s) => ({ hook: s.hook, env: s.env, secret: s.generated ?? null, existed: s.existed })), + errors: result.errors, + }); + return result.errors.length ? 1 : 0; +} + +async function link(ctx: Ctx, target: string | undefined): Promise { + const ops = createOps(ctx.paths, { env: ctx.io.env }); + let result: LinkResult; + try { + result = linkProject(ops, target ?? ".", { noSecret: bool(ctx.flags, "no-secret") }); + } catch (error) { + throw new CommandError((error as Error).message); + } + return printLinked(ctx, ops, result, []); +} + +async function unlink(ctx: Ctx, target: string | undefined): Promise { + if (!target) throw new UsageError("Missing project directory", USAGE); + const ops = createOps(ctx.paths, { env: ctx.io.env }); + const { unlinkProject } = await import("../ops.js"); + const result = unlinkProject(ops, target); + ctx.print(result.removed ? `Unlinked ${displayPath(result.entry)}; its hooks now answer 404. The repository was not touched.` : `${displayPath(result.entry)} was not linked`, { ok: result.removed, entry: result.entry, removed: result.removed }); + return result.removed ? 0 : 1; +} + +async function init(ctx: Ctx, target: string | undefined): Promise { + const ops = createOps(ctx.paths, { env: ctx.io.env }); + let result: ReturnType; + try { + result = initProject(ops, target ?? ".", { force: bool(ctx.flags, "force"), noSecret: bool(ctx.flags, "no-secret") }); + } catch (error) { + throw new CommandError((error as Error).message); + } + const intro = result.written ? [`Wrote ${displayPath(result.file)} with a starter hook; edit it, then commit it with the repository.`] : [`${displayPath(result.file)} already exists (pass --force to overwrite it).`]; + return printLinked(ctx, ops, result.link, intro, { file: result.file, written: result.written }); +} diff --git a/src/commands/serve.ts b/src/commands/serve.ts index fe71de2..d48d0fd 100644 --- a/src/commands/serve.ts +++ b/src/commands/serve.ts @@ -19,6 +19,7 @@ export async function serveCommand(ctx: Ctx): Promise { const loaded = registry.list(); for (const error of loaded.errors) logger.error("skill failed to load", { skill: error.name, error: error.error }); + for (const project of loaded.projects) if (!project.error) logger.info("project linked", { dir: project.dir, file: project.file, hooks: project.hooks.map((h) => h.name) }); const current = secrets(); for (const skill of loaded.skills) { if (skill.auth.type === "none") logger.warn("skill has no authentication", { skill: skill.name }); diff --git a/src/commands/shared.ts b/src/commands/shared.ts index b37f1fb..c0f60cd 100644 --- a/src/commands/shared.ts +++ b/src/commands/shared.ts @@ -2,7 +2,7 @@ import { loadConfig, type Config } from "../config.js"; import { loadSecrets, type Secrets } from "../env.js"; import { JobStore } from "../jobs.js"; import { resolvePaths, type Paths } from "../paths.js"; -import { SkillRegistry } from "../skills.js"; +import { configProjects, SkillRegistry } from "../registry.js"; export type FlagValue = string | boolean | string[]; export type Flags = Record; @@ -159,7 +159,7 @@ export function createCtx(flags: Flags, args: string[], io: CliIO): Ctx { return loadSecrets(paths, io.env); }, registry() { - registry ??= new SkillRegistry(paths.skillsDir); + registry ??= new SkillRegistry(paths.skillsDir, { projects: configProjects(paths) }); return registry; }, store() { diff --git a/src/commands/skills.ts b/src/commands/skills.ts index 97e3270..d0e53df 100644 --- a/src/commands/skills.ts +++ b/src/commands/skills.ts @@ -3,7 +3,8 @@ import { RunnerNameSchema } from "../config.js"; import { listExamples } from "../examples.js"; import { addExampleSkill, createOps, createSkill, resolveBaseUrl, webhookUrl } from "../ops.js"; import { skillSummary } from "../server.js"; -import { AUTH_TYPES, describeAuth, loadSkills, type AuthType } from "../skills.js"; +import { AUTH_TYPES, describeAuth, type AuthType, type Skill } from "../skills.js"; +import { displayPath } from "../util.js"; import { bool, CommandError, list, num, str, table, UsageError, type Ctx } from "./shared.js"; const USAGE = `Usage: @@ -49,19 +50,25 @@ function requireName(name: string | undefined): string { return name; } +/** Where a skill is defined, for tables: the skills directory or the linked project directory. */ +export function sourceLabel(skill: Skill, skillsDir: string): string { + return displayPath(skill.source.type === "project" ? skill.source.dir : skillsDir); +} + async function listSkills(ctx: Ctx): Promise { const ops = createOps(ctx.paths, { env: ctx.io.env }); - const loaded = loadSkills(ctx.paths.skillsDir); + const loaded = ops.registry.list(); const secrets = ops.secrets(); const summaries = loaded.skills.map((s) => skillSummary(s, ops.config, secrets)); const { baseUrl, source } = await resolveBaseUrl(ops); - const rows = summaries.map((s) => { + const rows = loaded.skills.map((skill, i) => { + const s = summaries[i] as Record; const auth = s.auth as { type: string; configured: boolean }; - return [String(s.name), s.enabled ? String(s.runner) : "(disabled)", String(s.model ?? "default"), `${auth.type}${auth.configured ? "" : " (secret missing)"}`, webhookUrl(baseUrl, String(s.name))]; + return [skill.name, skill.enabled ? String(s.runner) : "(disabled)", String(s.model ?? "default"), `${auth.type}${auth.configured ? "" : " (secret missing)"}`, sourceLabel(skill, ctx.paths.skillsDir), webhookUrl(baseUrl, skill.name)]; }); const lines: string[] = []; - if (rows.length) lines.push(table(rows, ["skill", "runner", "model", "auth", `url (${source})`])); - else lines.push(`No skills in ${ctx.paths.skillsDir}. Create one with: skillhook skills new `); + if (rows.length) lines.push(table(rows, ["skill", "runner", "model", "auth", "source", `url (${source})`])); + else lines.push(`No skills in ${ctx.paths.skillsDir}. Create one with: skillhook skills new , or link a repository's skillhook.yaml with: skillhook link `); for (const error of loaded.errors) lines.push(`✗ ${error.name}: ${error.error.split("\n")[0]}`); ctx.print(lines.join("\n"), { skills: summaries.map((s) => ({ ...s, url: webhookUrl(baseUrl, String(s.name)) })), errors: loaded.errors, base_url: baseUrl, base_url_source: source }); return loaded.errors.length ? 1 : 0; @@ -82,6 +89,7 @@ async function showSkill(ctx: Ctx, name: string): Promise { ` cwd: ${summary.cwd}`, ` auth: ${describeAuth(skill.auth)}${(summary.auth as { configured: boolean }).configured ? "" : " ← secret missing"}`, ...(skill.config.when?.length ? [` when: ${(summary.when as string[]).join("; ")}`] : []), + ...(skill.source.type === "project" ? [` source: ${displayPath(skill.source.file)} (hook ${skill.name}, ${skill.source.kind === "run" ? "shell command" : skill.source.kind === "prompt" ? "inline prompt" : `SKILL.md at ${displayPath(skill.dir)}`})`] : []), "", content, ].join("\n"); @@ -136,7 +144,7 @@ function examples(ctx: Ctx): number { } function validate(ctx: Ctx, name?: string): number { - const loaded = loadSkills(ctx.paths.skillsDir); + const loaded = ctx.registry().list(); const skills = name ? loaded.skills.filter((s) => s.name === name) : loaded.skills; const errors = name ? loaded.errors.filter((e) => e.name === name) : loaded.errors; if (name && !skills.length && !errors.length) throw new CommandError(`No skill named "${name}"`); diff --git a/src/config.ts b/src/config.ts index 2b66a94..7ccfe1e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -87,6 +87,8 @@ export const ConfigSchema = z .prefault({}), /** Extra env var names copied into every agent run (on top of the runner auth vars). */ env_passthrough: z.array(z.string()).default([]), + /** Linked projects: directories whose `skillhook.yaml` (or the file itself) contributes hooks. Managed by `skillhook link` / `unlink`; re-read without a restart. */ + projects: z.array(z.string().min(1)).default([]), log_level: z.enum(["debug", "info", "warn", "error"]).default("info"), /** Ask the npm registry once a day whether a newer skillhook exists and say so in CLI output, `doctor` and the server log. `SKILLHOOK_NO_UPDATE_CHECK=1` and `CI` disable it too. */ update_check: z.boolean().default(true), diff --git a/src/doctor.ts b/src/doctor.ts index 8364f1d..6450299 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -6,10 +6,11 @@ import type { Paths } from "./paths.js"; import { resolveRunSettings } from "./run.js"; import { commandParts } from "./runners/types.js"; import { serviceStatus } from "./service.js"; -import { loadSkills, type Skill } from "./skills.js"; +import { configProjects, SkillRegistry } from "./registry.js"; +import type { Skill } from "./skills.js"; import { currentExposures, findTailscale, run, tailscaleStatus, which } from "./tailscale.js"; import { checkForUpdate, registryUrl, releaseNotesUrl, updateChecksDisabled } from "./update.js"; -import { errorMessage, isDirectory } from "./util.js"; +import { displayPath, errorMessage, isDirectory } from "./util.js"; import { VERSION } from "./version.js"; export type CheckStatus = "ok" | "warn" | "fail" | "skip"; @@ -95,9 +96,14 @@ export async function runDoctor(paths: Paths, options: DoctorOptions = {}): Prom const secrets: Secrets = loadSecrets(paths); checks.push(check("admin token", secrets[ADMIN_TOKEN_ENV] ? "ok" : "warn", secrets[ADMIN_TOKEN_ENV] ? `${ADMIN_TOKEN_ENV} set` : `${ADMIN_TOKEN_ENV} not set (admin API only reachable from localhost)`, secrets[ADMIN_TOKEN_ENV] ? undefined : "run: skillhook secret generate admin")); - const loaded = loadSkills(paths.skillsDir); + const loaded = new SkillRegistry(paths.skillsDir, { projects: configProjects(paths), base: paths.home }).list(); if (loaded.errors.length) checks.push(check("skills", "fail", `${loaded.errors.length} invalid skill(s): ${loaded.errors.map((e) => `${e.name} (${e.error.split("\n")[0]})`).join("; ")}`, "run: skillhook skills validate")); else checks.push(check("skills", loaded.skills.length ? "ok" : "warn", loaded.skills.length ? `${loaded.skills.length} skill(s): ${loaded.skills.map((s) => s.name).join(", ")}` : "no skills yet", loaded.skills.length ? undefined : "run: skillhook skills new ")); + if (!loaded.projects.length) checks.push(check("projects", "skip", "no linked projects", "skillhook link serves the hooks a repository declares in skillhook.yaml")); + for (const project of loaded.projects) { + const broken = project.error ? 1 : project.errors.length; + checks.push(check(`project ${displayPath(project.dir)}`, broken ? "fail" : "ok", project.error ?? `${project.hooks.length} hook(s): ${project.hooks.map((h) => h.name).join(", ") || "none"}${project.errors.length ? `; ${project.errors.length} invalid: ${project.errors.map((e) => e.name).join(", ")}` : ""}`, broken ? "run: skillhook skills validate" : undefined)); + } const runnersNeeded = new Set(); if (config) { @@ -107,7 +113,7 @@ export async function runDoctor(paths: Paths, options: DoctorOptions = {}): Prom const auth = skill.auth; if (auth.type === "none") checks.push(check(`skill ${skill.name}`, "warn", "auth: none — anyone with the URL can trigger it", "set skillhook.auth.type in SKILL.md")); else if (!secrets[auth.secret_env]) checks.push(check(`skill ${skill.name}`, "fail", `secret ${auth.secret_env} not set (webhooks will get 503)`, `run: skillhook secret generate ${skill.name} (or: skillhook secret set ${auth.secret_env})`)); - else checks.push(check(`skill ${skill.name}`, isDirectory(settings.cwd) ? "ok" : "fail", `${settings.runner}${settings.model ? ` ${settings.model}` : ""}, auth ${auth.type}, cwd ${settings.cwd}`, isDirectory(settings.cwd) ? undefined : "cwd does not exist")); + else checks.push(check(`skill ${skill.name}`, isDirectory(settings.cwd) ? "ok" : "fail", `${settings.runner}${settings.model ? ` ${settings.model}` : ""}, auth ${auth.type}, cwd ${settings.cwd}${skill.source.type === "project" ? `, from ${displayPath(skill.source.file)}` : ""}`, isDirectory(settings.cwd) ? undefined : "cwd does not exist")); } } diff --git a/src/index.ts b/src/index.ts index 857d38d..14134c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,8 @@ export * from "./config.js"; export * from "./env.js"; export * from "./frontmatter.js"; export * from "./skills.js"; +export * from "./projects.js"; +export * from "./registry.js"; export * from "./auth.js"; export * from "./filters.js"; export * from "./payload.js"; diff --git a/src/mcp.ts b/src/mcp.ts index ae92fa5..fedf242 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -6,11 +6,11 @@ import { setConfigValue } from "./config.js"; import { formatDoctor, runDoctor } from "./doctor.js"; import { listExamples } from "./examples.js"; import { JOB_ARTIFACTS, JOB_STATUSES, type JobArtifact, type JobStatus } from "./jobs.js"; -import { addExampleSkill, createOps, createSkill, generateSecretFor, publicJob, resolveBaseUrl, runSkillLocally, sendSignedWebhook, setSecret, triggerViaServer, webhookUrl, type Ops } from "./ops.js"; +import { addExampleSkill, createOps, createSkill, generateSecretFor, initProject, linkProject, listProjects, publicJob, resolveBaseUrl, runSkillLocally, sendSignedWebhook, setSecret, triggerViaServer, unlinkProject, webhookUrl, type LinkResult, type Ops } from "./ops.js"; import type { Paths } from "./paths.js"; import { skillSummary } from "./server.js"; import { installService, readServiceLog, restartService, serviceStatus, uninstallService } from "./service.js"; -import { AUTH_TYPES, loadSkills, parseSkillDocument, type AuthType } from "./skills.js"; +import { AUTH_TYPES, parseSkillDocument, type AuthType } from "./skills.js"; import { currentExposures, disableExposure, enableExposure, tailscaleStatus } from "./tailscale.js"; import { findRunningServer } from "./client.js"; import { updateStatusFromCache } from "./update.js"; @@ -20,6 +20,7 @@ import { VERSION } from "./version.js"; export const MCP_INSTRUCTIONS = `skillhook turns this machine into a webhook endpoint that runs Agent Skills (SKILL.md files) with Claude Code or Codex. Typical flow: skillhook_status → create_skill (or add_example) → set_secret/generate_secret → run_skill to test locally → get_webhook_urls to hand the URL to the sender (Granola, Sentry, GitHub, Zapier…). Skills live in /skills//SKILL.md; the \`skillhook:\` frontmatter block sets runner, model, auth and filters. Secrets live in /.env and are never returned by tools except right after generation. +A repository can declare its own hooks in a version-controlled skillhook.yaml (webhook name → run: shell command | skill: SKILL.md directory | prompt: inline instructions); link_project registers it so the hooks are served, list_projects shows what runs from which webhook. Jobs are directories under /jobs/ with payload.json, prompt.md, stdout.log and result.md.`; type ToolResult = { content: { type: "text"; text: string }[]; structuredContent?: Record; isError?: boolean }; @@ -58,7 +59,7 @@ export function buildMcpServer(paths: Paths, env: NodeJS.ProcessEnv = process.en wrap(async () => { const o = ops(); const running = await findRunningServer(paths); - const loaded = loadSkills(paths.skillsDir); + const loaded = o.registry.list(); const { baseUrl, source } = await resolveBaseUrl(o); const jobs = o.store.list({ limit: 10 }); const update = updateStatusFromCache(paths); @@ -71,12 +72,13 @@ export function buildMcpServer(paths: Paths, env: NodeJS.ProcessEnv = process.en server: running ? { running: true, base_url: running.baseUrl, version: running.health.version, queue: running.health.queue } : { running: false, hint: "skillhook serve (or: skillhook service install)" }, public_base_url: source === "local" ? null : baseUrl, public_url_source: source, - skills: loaded.skills.map((s) => ({ name: s.name, runner: s.config.runner ?? o.config.defaults.runner, model: s.config.model ?? o.config.defaults.model ?? null, auth: s.auth.type, url: webhookUrl(baseUrl, s.name) })), + skills: loaded.skills.map((s) => ({ name: s.name, runner: s.config.runner ?? o.config.defaults.runner, model: s.config.model ?? o.config.defaults.model ?? null, auth: s.auth.type, source: s.source, url: webhookUrl(baseUrl, s.name) })), skill_errors: loaded.errors, + projects: loaded.projects.map((p) => ({ dir: p.dir, file: p.file, hooks: p.hooks.map((h) => h.name), error: p.error ?? null, errors: p.errors })), recent_jobs: jobs.map((j) => ({ id: j.id, skill: j.skill, status: j.status, created_at: j.created_at, error: j.error ?? null })), defaults: o.config.defaults, }, - `skillhook ${VERSION} at ${paths.home}; server ${running ? "running" : "not running"}; ${loaded.skills.length} skill(s).${update.available ? ` Update ${update.latest} is available (skillhook update --install).` : ""}`, + `skillhook ${VERSION} at ${paths.home}; server ${running ? "running" : "not running"}; ${loaded.skills.length} skill(s), ${loaded.projects.length} linked project(s).${update.available ? ` Update ${update.latest} is available (skillhook update --install).` : ""}`, ); }), ); @@ -86,7 +88,7 @@ export function buildMcpServer(paths: Paths, env: NodeJS.ProcessEnv = process.en { title: "List skills", description: "Lists every skill with runner, model, auth type, whether its secret is configured, and its webhook URL.", inputSchema: z.object({}) }, wrap(async () => { const o = ops(); - const loaded = loadSkills(paths.skillsDir); + const loaded = o.registry.list(); const secrets = o.secrets(); const { baseUrl } = await resolveBaseUrl(o); return ok({ skills: loaded.skills.map((s) => ({ ...skillSummary(s, o.config, secrets), url: webhookUrl(baseUrl, s.name) })), errors: loaded.errors }); @@ -139,6 +141,7 @@ export function buildMcpServer(paths: Paths, env: NodeJS.ProcessEnv = process.en wrap(async ({ name, content }) => { const o = ops(); const skill = skillOf(o, name); + if (skill.source.type === "project" && skill.source.kind !== "skill") throw new Error(`"${name}" is a ${skill.source.kind === "run" ? "shell command" : "prompt"} hook defined in ${skill.source.file}; edit that file (it is version-controlled with the project)`); parseSkillDocument(content, skill.dir); writeFileSync(skill.file, content); return ok({ ok: true, file: skill.file, ...skillSummary(o.registry.get(name) ?? skill, o.config, o.secrets()) }); @@ -150,7 +153,7 @@ export function buildMcpServer(paths: Paths, env: NodeJS.ProcessEnv = process.en { title: "Validate skills", description: "Parses every SKILL.md (or one) and reports errors and missing secrets.", inputSchema: z.object({ name: z.string().optional() }) }, wrap(async ({ name }) => { const o = ops(); - const loaded = loadSkills(paths.skillsDir); + const loaded = o.registry.list(); const secrets = o.secrets(); const skills = name ? loaded.skills.filter((s) => s.name === name) : loaded.skills; const errors = name ? loaded.errors.filter((e) => e.name === name) : loaded.errors; @@ -267,7 +270,7 @@ export function buildMcpServer(paths: Paths, env: NodeJS.ProcessEnv = process.en wrap(async ({ skill }) => { const o = ops(); const { baseUrl, source } = await resolveBaseUrl(o); - const names = skill ? [skillOf(o, skill).name] : loadSkills(paths.skillsDir).skills.map((s) => s.name); + const names = skill ? [skillOf(o, skill).name] : o.registry.list().skills.map((s) => s.name); return ok({ base_url: baseUrl, source, public: source !== "local", urls: Object.fromEntries(names.map((n) => [n, webhookUrl(baseUrl, n)])) }, source === "local" ? "No public URL yet: call expose with mode funnel." : undefined); }), ); @@ -286,7 +289,7 @@ export function buildMcpServer(paths: Paths, env: NodeJS.ProcessEnv = process.en } const result = await enableExposure(mode, o.config.port); if (result.ok && result.url) setConfigValue(paths, "public_url", result.url); - const skills = loadSkills(paths.skillsDir).skills.map((s) => s.name); + const skills = o.registry.list().skills.map((s) => s.name); return ok({ ok: result.ok, mode, url: result.url ?? null, approval_url: result.approvalUrl ?? null, output: result.output, webhooks: result.url ? Object.fromEntries(skills.map((n) => [n, webhookUrl(result.url as string, n)])) : {} }, result.ok ? `Exposed at ${result.url}` : result.approvalUrl ? `Funnel needs approval: ${result.approvalUrl}` : "Failed"); }), ); @@ -319,6 +322,54 @@ export function buildMcpServer(paths: Paths, env: NodeJS.ProcessEnv = process.en }), ); + const linkResult = (o: Ops, result: LinkResult, baseUrl: string) => ({ + ok: result.errors.length === 0, + entry: result.entry, + added: result.added, + project: { dir: result.project.dir, file: result.project.file, hooks: result.project.hooks.map((h) => ({ ...skillSummary(h, o.config, o.secrets()), url: webhookUrl(baseUrl, h.name) })), errors: result.project.errors }, + secrets: result.secrets.map((s) => ({ hook: s.hook, env: s.env, secret: s.generated ?? null, existed: s.existed })), + errors: result.errors, + }); + + server.registerTool( + "list_projects", + { title: "List linked projects", description: "Repositories whose skillhook.yaml is served by this machine: directory, file, and each hook with its runner, kind (run/skill/prompt), auth, cwd and webhook URL. This is the version-controlled answer to 'which skill runs from which webhook'.", inputSchema: z.object({}) }, + wrap(async () => { + const o = ops(); + const { baseUrl } = await resolveBaseUrl(o); + const secrets = o.secrets(); + return ok({ projects: listProjects(o).map((p) => ({ dir: p.dir, file: p.file, error: p.error ?? null, hooks: p.hooks.map((h) => ({ ...skillSummary(h, o.config, secrets), url: webhookUrl(baseUrl, h.name) })), errors: p.errors })) }); + }), + ); + + server.registerTool( + "link_project", + { + title: "Link project", + description: "Registers a repository's skillhook.yaml (dir, or the YAML file itself) in skillhook.json so its hooks are served at /hooks/; the running server picks them up without a restart. With init=true a starter skillhook.yaml is written first when none exists (a `run: git pull --ff-only` hook for merged GitHub pull requests). Secrets skillhook manages (bearer/basic/hmac) are generated and returned once; provider-signed hooks need set_secret afterwards.", + inputSchema: z.object({ dir: z.string().describe("repository directory (or path to its skillhook.yaml)"), init: z.boolean().optional().describe("write a starter skillhook.yaml when the directory has none"), no_secret: z.boolean().optional() }), + }, + wrap(async ({ dir, init, no_secret }) => { + const o = ops(); + const { baseUrl } = await resolveBaseUrl(o); + if (init) { + const result = initProject(o, dir, { noSecret: no_secret }); + return ok({ file: result.file, written: result.written, ...linkResult(o, result.link, baseUrl) }, `${result.written ? `Wrote ${result.file} and linked` : "Linked"} ${result.link.project.dir} (${result.link.project.hooks.length} hook(s)).`); + } + const result = linkProject(o, dir, { noSecret: no_secret }); + return ok(linkResult(o, result, baseUrl), `${result.added ? "Linked" : "Already linked"} ${result.project.dir}: ${result.project.hooks.map((h) => h.name).join(", ") || "no hooks"}.`); + }), + ); + + server.registerTool( + "unlink_project", + { title: "Unlink project", description: "Stops serving a repository's hooks (they answer 404 at once). The repository and its skillhook.yaml are not touched.", inputSchema: z.object({ dir: z.string() }) }, + wrap(async ({ dir }) => { + const result = unlinkProject(ops(), dir); + return ok({ ok: result.removed, ...result }, result.removed ? `Unlinked ${result.entry}.` : `${result.entry} was not linked.`); + }), + ); + server.registerTool( "list_examples", { title: "List example skills", description: "Bundled example skills (hello, granola-meeting-actions, sentry-triage, …) that add_example can copy.", inputSchema: z.object({}) }, diff --git a/src/ops.ts b/src/ops.ts index 42bc6b8..d79c871 100644 --- a/src/ops.ts +++ b/src/ops.ts @@ -2,7 +2,7 @@ import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node import path from "node:path"; import { signRequest } from "./auth.js"; import { adminRequest, findRunningServer, localBaseUrl } from "./client.js"; -import { loadConfig, type Config, type RunnerName } from "./config.js"; +import { loadConfig, readRawConfig, setConfigValue, type Config, type RunnerName } from "./config.js"; import { ADMIN_TOKEN_ENV, defaultSecretEnvFor, loadSecrets, readEnvFile, upsertEnvVar, type Secrets } from "./env.js"; import { findExample } from "./examples.js"; import { parseFrontmatter, stringifyFrontmatter } from "./frontmatter.js"; @@ -14,9 +14,11 @@ import { redactHeaders, type Trigger, type WebhookEvent } from "./payload.js"; import { JobQueue } from "./queue.js"; import { resolveRunSettings } from "./run.js"; import { publicJob } from "./server.js"; -import { AUTH_TYPES, parseSkillDocument, renderSkillTemplate, SkillRegistry, skillFile, type AuthType, type NormalizedAuth, type Skill } from "./skills.js"; +import { loadProject, PROJECT_FILE_NAMES, renderProjectTemplate, resolveProject, type LoadedProject } from "./projects.js"; +import { configProjects, SkillRegistry } from "./registry.js"; +import { AUTH_TYPES, parseSkillDocument, renderSkillTemplate, skillFile, type AuthType, type NormalizedAuth, type Skill } from "./skills.js"; import { currentExposures, findTailscale } from "./tailscale.js"; -import { errorMessage, isValidSkillName } from "./util.js"; +import { displayPath, errorMessage, expandTilde, isValidSkillName } from "./util.js"; export interface Ops { paths: Paths; @@ -36,7 +38,7 @@ export function createOps(paths: Paths, options: { env?: NodeJS.ProcessEnv; logg config, secrets: () => loadSecrets(paths, options.env ?? process.env), fileSecrets: () => readEnvFile(paths.envFile), - registry: new SkillRegistry(paths.skillsDir), + registry: new SkillRegistry(paths.skillsDir, { projects: configProjects(paths) }), store: new JobStore(paths.jobsDir, { maxJobs: config.jobs.max_jobs, dedupeWindowSeconds: config.jobs.dedupe_window_seconds }), logger: options.logger ?? silentLogger, }; @@ -180,6 +182,97 @@ export function addExampleSkill(ops: Ops, exampleName: string, asName = exampleN return { skill, file: skillFile(dir), created: true, secret, authNote: authNoteFor(skill, secret) }; } +// --------------------------------------------------------------------------- +// Projects: repositories whose skillhook.yaml contributes hooks +// --------------------------------------------------------------------------- + +/** The `projects` entries of skillhook.json as written (no defaults, no resolution). */ +export function linkedProjectEntries(ops: Ops): string[] { + const raw = readRawConfig(ops.paths).projects; + return Array.isArray(raw) ? raw.filter((entry): entry is string => typeof entry === "string") : []; +} + +function sameProject(a: string, b: string): boolean { + return path.resolve(expandTilde(a)) === path.resolve(expandTilde(b)); +} + +/** Every linked project, loaded. */ +export function listProjects(ops: Ops): LoadedProject[] { + return ops.registry.projects(); +} + +export interface LinkResult { + /** The absolute path written to skillhook.json (a directory, or the YAML file when one was named). */ + entry: string; + project: LoadedProject; + /** False when the project was linked before (the entry is left as it was). */ + added: boolean; + /** Secrets generated for hooks whose auth skillhook manages (bearer, basic, generic hmac). */ + secrets: (SecretResult & { hook: string })[]; + /** Hooks whose name is already taken by another source, or that failed to compile. */ + errors: { name: string; error: string }[]; +} + +/** + * Registers a directory's skillhook.yaml (or the file itself) in `projects` of skillhook.json. The file must + * exist and parse; individual hook problems are reported, not fatal. The running server picks the hooks up + * without a restart. + */ +export function linkProject(ops: Ops, dirOrFile: string, options: { noSecret?: boolean } = {}): LinkResult { + const ref = resolveProject(dirOrFile); + const entry = ref.file === resolveProject(ref.dir).file ? ref.dir : ref.file; + const project = loadProject(entry); + if (project.error) throw new Error(project.error); + const current = linkedProjectEntries(ops); + const added = !current.some((existing) => sameProject(existing, entry)); + if (added) setConfigValue(ops.paths, "projects", [...current, entry]); + const secrets: LinkResult["secrets"] = []; + if (!options.noSecret) { + for (const hook of project.hooks) { + const secret = ensureSkillSecret(ops, hook); + if (secret) secrets.push({ ...secret, hook: hook.name }); + } + } + const listed = ops.registry.list(); + const errors = listed.errors.filter((e) => e.dir === project.dir).map((e) => ({ name: e.name, error: e.error })); + return { entry, project, added, secrets, errors }; +} + +/** Removes a project from `projects`; the hooks stop routing (404) at once. Nothing in the project is touched. */ +export function unlinkProject(ops: Ops, dirOrFile: string): { entry: string; removed: boolean } { + const ref = resolveProject(dirOrFile); + const current = linkedProjectEntries(ops); + const kept = current.filter((existing) => !sameProject(existing, ref.dir) && !sameProject(existing, ref.file)); + const removed = kept.length !== current.length; + if (removed) setConfigValue(ops.paths, "projects", kept.length ? kept : undefined); + return { entry: ref.dir, removed }; +} + +export interface InitProjectResult { + file: string; + /** False when the file already existed and `force` was not set (nothing written). */ + written: boolean; + link: LinkResult; +} + +/** Writes a starter skillhook.yaml into a directory (creating it if needed) and links the project. */ +export function initProject(ops: Ops, dir: string, options: { force?: boolean; noSecret?: boolean } = {}): InitProjectResult { + const ref = resolveProject(dir); + const existing = PROJECT_FILE_NAMES.map((name) => path.join(ref.dir, name)).find((candidate) => existsSync(candidate)); + const file = existing ?? path.join(ref.dir, PROJECT_FILE_NAMES[0]); + const written = !existing || options.force === true; + if (written) { + mkdirSync(ref.dir, { recursive: true }); + writeFileSync(file, renderProjectTemplate()); + } + return { file, written, link: linkProject(ops, ref.dir, { noSecret: options.noSecret }) }; +} + +/** `~/dev/api (skillhook.yaml)` for humans. */ +export function describeProject(project: LoadedProject): string { + return `${displayPath(project.dir)} (${path.basename(project.file)})`; +} + // --------------------------------------------------------------------------- // Running skills // --------------------------------------------------------------------------- diff --git a/src/projects.test.ts b/src/projects.test.ts new file mode 100644 index 0000000..78fffe1 --- /dev/null +++ b/src/projects.test.ts @@ -0,0 +1,118 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { compileHook, loadProject, parseProjectFile, PROJECT_FILE_NAMES, renderProjectTemplate, resolveProject } from "./projects.js"; +import { tempHome } from "./test-support/helpers.js"; + +function project(yaml: string, extra: (dir: string) => void = () => {}): string { + const dir = path.join(tempHome("skillhook-project-").home, "repo"); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, "skillhook.yaml"), yaml); + extra(dir); + return dir; +} + +describe("skillhook.yaml", () => { + it("compiles a run hook into a shell skill that works in the project directory", () => { + const dir = project(`hooks:\n pull:\n run: git pull --ff-only\n auth: { type: github, secret_env: GH_SECRET }\n when:\n - { header: x-github-event, equals: pull_request }\n`); + const loaded = loadProject(dir); + expect(loaded.error).toBeUndefined(); + expect(loaded.errors).toEqual([]); + const [hook] = loaded.hooks; + expect(hook).toMatchObject({ name: "pull", dir, file: path.join(dir, "skillhook.yaml"), enabled: true, source: { type: "project", dir, kind: "run" } }); + expect(hook?.config).toMatchObject({ runner: "shell", shell: { command: "git pull --ff-only" }, cwd: dir }); + expect(hook?.auth).toMatchObject({ type: "hmac", preset: "github", secret_env: "GH_SECRET" }); + expect(hook?.description).toContain("git pull --ff-only"); + expect(hook?.config.when).toHaveLength(1); + }); + + it("wires a SKILL.md from the repository under the hook's name, letting the hook override its block", () => { + const dir = project(`hooks:\n on-release:\n skill: .claude/skills/release-notes\n model: sonnet\n cwd: packages/app\n auth: { type: bearer }\n`, (d) => { + mkdirSync(path.join(d, ".claude", "skills", "release-notes"), { recursive: true }); + mkdirSync(path.join(d, "packages", "app"), { recursive: true }); + writeFileSync(path.join(d, ".claude", "skills", "release-notes", "SKILL.md"), `---\nname: release-notes\ndescription: Writes release notes.\nallowed-tools: Read Bash(gh:*)\nskillhook:\n model: opus\n timeout_seconds: 60\n claude:\n permission_mode: acceptEdits\n---\n\nWrite notes for {{payload.release.tag_name}}.\n`); + }); + const loaded = loadProject(dir); + expect(loaded.errors).toEqual([]); + const [hook] = loaded.hooks; + expect(hook?.name).toBe("on-release"); + expect(hook?.description).toBe("Writes release notes."); + expect(hook?.body).toContain("{{payload.release.tag_name}}"); + expect(hook?.allowedTools).toEqual(["Read", "Bash(gh:*)"]); + expect(hook?.dir).toBe(path.join(dir, ".claude", "skills", "release-notes")); + expect(hook?.file).toBe(path.join(dir, ".claude", "skills", "release-notes", "SKILL.md")); + expect(hook?.config).toMatchObject({ model: "sonnet", timeout_seconds: 60, cwd: path.join(dir, "packages", "app"), claude: { permission_mode: "acceptEdits" } }); + expect(hook?.auth).toMatchObject({ type: "bearer", secret_env: "SKILLHOOK_SECRET_ON_RELEASE" }); + expect(hook?.source).toMatchObject({ type: "project", kind: "skill" }); + expect(Object.keys(loaded.stamps)).toEqual([path.join(dir, "skillhook.yaml"), path.join(dir, ".claude", "skills", "release-notes", "SKILL.md")]); + }); + + it("accepts inline prompts and the SKILL.md file path itself", () => { + const dir = project(`hooks:\n summarize:\n prompt: Summarize {{payload}} into {{job_dir}}/summary.md\n model: haiku\n by-file:\n skill: skills/thing/SKILL.md\n`, (d) => { + mkdirSync(path.join(d, "skills", "thing"), { recursive: true }); + writeFileSync(path.join(d, "skills", "thing", "SKILL.md"), `---\nname: thing\ndescription: Thing.\n---\nDo the thing.\n`); + }); + const loaded = loadProject(dir); + expect(loaded.errors).toEqual([]); + const summarize = loaded.hooks.find((h) => h.name === "summarize"); + expect(summarize).toMatchObject({ body: "Summarize {{payload}} into {{job_dir}}/summary.md", dir, source: { kind: "prompt" } }); + expect(summarize?.config).toMatchObject({ model: "haiku", cwd: dir }); + expect(summarize?.config.runner).toBeUndefined(); + expect(loaded.hooks.find((h) => h.name === "by-file")?.dir).toBe(path.join(dir, "skills", "thing")); + }); + + it("reports per-hook problems without losing the other hooks", () => { + const dir = project(`hooks:\n fine:\n run: "true"\n missing:\n skill: nowhere\n`); + const loaded = loadProject(dir); + expect(loaded.hooks.map((h) => h.name)).toEqual(["fine"]); + expect(loaded.errors).toHaveLength(1); + expect(loaded.errors[0]).toMatchObject({ name: "missing" }); + expect(loaded.errors[0]?.error).toContain("does not exist"); + }); + + it("rejects hooks that do not say what runs, or say it twice", () => { + const file = "/repo/skillhook.yaml"; + expect(() => parseProjectFile(`hooks:\n x:\n model: opus\n`, file)).toThrow(/exactly one of/); + expect(() => parseProjectFile(`hooks:\n x:\n run: ls\n prompt: hi\n`, file)).toThrow(/exactly one of/); + expect(() => parseProjectFile(`hooks:\n x:\n run: ls\n runner: claude\n`, file)).toThrow(/shell runner/); + expect(() => parseProjectFile(`hooks:\n x:\n run: ls\n shell: { command: ls }\n`, file)).toThrow(/keep one/); + expect(() => parseProjectFile(`hooks:\n Bad_Name:\n run: ls\n`, file)).toThrow(/Invalid hook name "Bad_Name"/); + expect(() => parseProjectFile(`hooks:\n x:\n run: ls\n unknown: 1\n`, file)).toThrow(/Unrecognized key/); + expect(() => parseProjectFile(`hooks:\n x:\n run: ls\nextra: true\n`, file)).toThrow(/Unrecognized key/); + expect(() => parseProjectFile(`- a\n- b\n`, file)).toThrow(/YAML mapping/); + expect(() => parseProjectFile(`hooks: [\n`, file)).toThrow(/Cannot parse/); + expect(parseProjectFile(`hooks: {}\n`, file).hooks).toEqual({}); + }); + + it("resolves directories, yaml files and tilde paths", () => { + const dir = project(`hooks: {}\n`); + expect(resolveProject(dir)).toEqual({ entry: dir, dir, file: path.join(dir, "skillhook.yaml") }); + expect(resolveProject(path.join(dir, "skillhook.yaml"))).toEqual({ entry: path.join(dir, "skillhook.yaml"), dir, file: path.join(dir, "skillhook.yaml") }); + expect(resolveProject("repo", path.dirname(dir)).dir).toBe(dir); + expect(resolveProject("~/x").dir.startsWith("/")).toBe(true); + const missing = loadProject(path.join(dir, "nope")); + expect(missing.error).toContain("not a directory"); + const empty = tempHome("skillhook-empty-").home; + expect(loadProject(empty).error).toContain(`No ${PROJECT_FILE_NAMES.join(" or ")}`); + writeFileSync(path.join(empty, "skillhook.yml"), "hooks:\n a:\n run: ls\n"); + expect(loadProject(empty).hooks.map((h) => h.name)).toEqual(["a"]); + }); + + it("ships a starter file that parses and compiles", () => { + const dir = project(renderProjectTemplate()); + const loaded = loadProject(dir); + expect(loaded.error).toBeUndefined(); + expect(loaded.errors).toEqual([]); + expect(loaded.hooks.map((h) => h.name)).toEqual(["pull-after-merge"]); + expect(loaded.hooks[0]?.config).toMatchObject({ runner: "shell", shell: { command: "git pull --ff-only" } }); + expect(loaded.hooks[0]?.auth).toMatchObject({ preset: "github", secret_env: "GITHUB_WEBHOOK_SECRET" }); + expect(renderProjectTemplate().startsWith("# yaml-language-server: $schema=")).toBe(true); + }); + + it("compileHook validates names", () => { + const ref = { entry: "/repo", dir: "/repo", file: "/repo/skillhook.yaml" }; + expect(() => compileHook(ref, "Nope", { run: "ls" })).toThrow(/Invalid hook name/); + expect(compileHook(ref, "ok", { run: ["python3", "handle.py"], cwd: "~/elsewhere" }).config.cwd?.startsWith("/")).toBe(true); + expect(compileHook(ref, "ok", { run: ["python3", "handle.py"] }).description).toContain("python3 handle.py"); + }); +}); diff --git a/src/projects.ts b/src/projects.ts new file mode 100644 index 0000000..e17da11 --- /dev/null +++ b/src/projects.ts @@ -0,0 +1,242 @@ +import { readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import YAML from "yaml"; +import { z } from "zod"; +import { CommandSpecSchema } from "./config.js"; +import { loadSkill, normalizeAuth, SkillError, SkillhookBlockSchema, type Skill, type SkillhookBlock } from "./skills.js"; +import { displayPath, exists, expandTilde, isDirectory, isValidSkillName, SKILL_NAME_RE } from "./util.js"; + +// --------------------------------------------------------------------------- +// skillhook.yaml: hooks a repository declares, version-controlled with the code they act on. +// --------------------------------------------------------------------------- + +/** File names looked up in a linked directory, in order. */ +export const PROJECT_FILE_NAMES = ["skillhook.yaml", "skillhook.yml"] as const; + +export const PROJECT_SCHEMA_URL = "https://raw.githubusercontent.com/MeterApp/skillhook/main/schema/skillhook.yaml.schema.json"; + +/** + * One hook = the `skillhook:` block of a SKILL.md plus what runs: exactly one of `run` (a shell command), + * `skill` (a SKILL.md directory in the project) or `prompt` (inline instructions for the agent runner). + */ +export const HookSchema = SkillhookBlockSchema.extend({ + /** What the hook does; shown by `skills list`, the MCP tools and `GET /skills`. Defaults to the SKILL.md description or a line about the command. */ + description: z.string().min(1).max(1024).optional(), + /** A SKILL.md directory (or the file itself) relative to the project directory; its `skillhook:` block applies, keys set here win. */ + skill: z.string().min(1).optional(), + /** A shell command run in the project directory: a string for `/bin/sh -c`, an array to execute directly. Implies `runner: shell`. */ + run: CommandSpecSchema.optional(), + /** Inline instructions for Claude Code or Codex, with the same `{{placeholders}}` as a SKILL.md body. */ + prompt: z.string().min(1).optional(), +}).superRefine((hook, ctx) => { + const kinds = [hook.run, hook.skill, hook.prompt].filter((v) => v !== undefined).length; + if (kinds !== 1) ctx.addIssue({ code: "custom", message: "A hook needs exactly one of `run` (a shell command), `skill` (a SKILL.md directory) or `prompt` (inline instructions)" }); + if (hook.run !== undefined && hook.runner !== undefined && hook.runner !== "shell") ctx.addIssue({ code: "custom", message: "`run` always uses the shell runner; drop `runner` or use `skill`/`prompt` for an agent" }); + if (hook.run !== undefined && hook.shell !== undefined) ctx.addIssue({ code: "custom", message: "`run` and `shell.command` are the same thing; keep one" }); +}); +export type Hook = z.infer; + +const hookName = z.string().min(1).max(64).regex(SKILL_NAME_RE, "hook names use 1-64 lowercase letters, digits and single hyphens"); + +export const ProjectFileSchema = z + .object({ + $schema: z.string().optional(), + /** Webhook name → hook. Each is served at `POST /hooks/`. */ + hooks: z.record(hookName, HookSchema), + }) + .strict(); +export type ProjectFile = z.infer; + +// --------------------------------------------------------------------------- +// Resolving and loading +// --------------------------------------------------------------------------- + +export interface ProjectRef { + /** What the config or the user named: a directory, or the YAML file itself. */ + entry: string; + /** The project (repository) directory. */ + dir: string; + /** The skillhook.yaml (the first of PROJECT_FILE_NAMES that exists, else the default name). */ + file: string; +} + +/** A directory entry points at `skillhook.yaml` inside it; an entry ending in `.yaml`/`.yml` is the file itself. */ +export function resolveProject(entry: string, base = process.cwd()): ProjectRef { + const resolved = path.resolve(base, expandTilde(entry)); + if (/\.ya?ml$/i.test(resolved) && !isDirectory(resolved)) return { entry, dir: path.dirname(resolved), file: resolved }; + const file = PROJECT_FILE_NAMES.map((name) => path.join(resolved, name)).find((candidate) => exists(candidate)) ?? path.join(resolved, PROJECT_FILE_NAMES[0]); + return { entry, dir: resolved, file }; +} + +export interface LoadedProject extends ProjectRef { + /** Set when the file is missing or invalid as a whole; `hooks` is then empty. */ + error?: string; + /** Hooks that compiled, as routable skills. */ + hooks: Skill[]; + /** Hooks that did not compile (bad name, missing SKILL.md, …); the name is not routable. */ + errors: { name: string; error: string }[]; + /** mtime of every file the project was compiled from; `SkillRegistry` reloads when one changes. */ + stamps: Record; +} + +function mtimeOf(file: string): number { + try { + return statSync(file).mtimeMs; + } catch { + return 0; + } +} + +export function parseProjectFile(text: string, file: string): ProjectFile { + let raw: unknown; + try { + raw = YAML.parse(text) ?? {}; + } catch (error) { + throw new SkillError(`Cannot parse ${file}: ${(error as Error).message}`, path.dirname(file)); + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new SkillError(`${file} must be a YAML mapping with a \`hooks:\` key`, path.dirname(file)); + const hooks = (raw as { hooks?: unknown }).hooks; + if (hooks && typeof hooks === "object" && !Array.isArray(hooks)) { + for (const name of Object.keys(hooks)) if (!isValidSkillName(name)) throw new SkillError(`Invalid hook name "${name}" in ${file}: use 1-64 lowercase letters, digits and single hyphens`, path.dirname(file)); + } + const parsed = ProjectFileSchema.safeParse(raw); + if (!parsed.success) throw new SkillError(`Invalid ${file}:\n${z.prettifyError(parsed.error)}`, path.dirname(file)); + return parsed.data; +} + +/** Reads and compiles a project; never throws (problems land in `error` / `errors`). */ +export function loadProject(entry: string, base = process.cwd()): LoadedProject { + const ref = resolveProject(entry, base); + const project: LoadedProject = { ...ref, hooks: [], errors: [], stamps: { [ref.file]: mtimeOf(ref.file) } }; + if (!isDirectory(ref.dir)) { + project.error = `${ref.dir} is not a directory`; + return project; + } + let text: string; + try { + text = readFileSync(ref.file, "utf8"); + } catch { + project.error = `No ${PROJECT_FILE_NAMES.join(" or ")} in ${ref.dir} (create one with: skillhook projects init ${displayPath(ref.dir)})`; + return project; + } + let parsed: ProjectFile; + try { + parsed = parseProjectFile(text, ref.file); + } catch (error) { + project.error = (error as Error).message; + return project; + } + for (const [name, hook] of Object.entries(parsed.hooks)) { + if (hook.skill !== undefined) { + const skillDir = resolveSkillDir(ref.dir, hook.skill); + project.stamps[path.join(skillDir, "SKILL.md")] = mtimeOf(path.join(skillDir, "SKILL.md")); + } + try { + project.hooks.push(compileHook(ref, name, hook)); + } catch (error) { + project.errors.push({ name, error: (error as Error).message }); + } + } + return project; +} + +/** True while none of the files a project was compiled from has changed. */ +export function projectIsFresh(project: LoadedProject): boolean { + return Object.entries(project.stamps).every(([file, stamp]) => mtimeOf(file) === stamp); +} + +function resolveSkillDir(projectDir: string, skillPath: string): string { + const resolved = path.resolve(projectDir, expandTilde(skillPath)); + return path.basename(resolved) === "SKILL.md" ? path.dirname(resolved) : resolved; +} + +function stripUndefined(value: T): Partial { + return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as Partial; +} + +function describeCommand(command: string | string[]): string { + return Array.isArray(command) ? command.join(" ") : command; +} + +/** + * Turns a hook into the same `Skill` shape a SKILL.md produces, so the server, runners and CLI need no special case. + * `cwd` defaults to the project directory (a relative `cwd` is resolved against it) rather than the skill directory. + */ +export function compileHook(project: ProjectRef, name: string, hook: Hook): Skill { + if (!isValidSkillName(name)) throw new SkillError(`Invalid hook name "${name}": use 1-64 lowercase letters, digits and single hyphens`, project.dir); + const { skill: skillPath, run, prompt, description, ...overrides } = hook; + const block = stripUndefined(overrides) as SkillhookBlock; + + if (skillPath !== undefined) { + const skillDir = resolveSkillDir(project.dir, skillPath); + if (!isDirectory(skillDir)) throw new SkillError(`Hook "${name}": skill directory ${skillDir} does not exist (paths are relative to ${project.dir})`, project.dir); + const doc = loadSkill(skillDir); + const config: SkillhookBlock = { ...doc.config, ...block }; + config.cwd = path.resolve(project.dir, expandTilde(block.cwd ?? doc.config.cwd ?? ".")); + return { + ...doc, + name, + description: description ?? doc.description, + config, + auth: normalizeAuth(name, config.auth), + enabled: config.enabled !== false, + source: { type: "project", dir: project.dir, file: project.file, kind: "skill" }, + }; + } + + const config: SkillhookBlock = run !== undefined ? { ...block, runner: "shell", shell: { command: run } } : block; + config.cwd = path.resolve(project.dir, expandTilde(block.cwd ?? ".")); + return { + name, + description: description ?? (run !== undefined ? `Runs \`${describeCommand(run)}\` in ${path.basename(project.dir)} when the webhook fires.` : `Runs the instructions in ${path.basename(project.file)} when the webhook fires.`), + dir: project.dir, + file: project.file, + body: prompt?.trim() ?? "", + frontmatter: { name, description, ...hook }, + config, + auth: normalizeAuth(name, config.auth), + allowedTools: [], + enabled: config.enabled !== false, + mtimeMs: mtimeOf(project.file), + source: { type: "project", dir: project.dir, file: project.file, kind: run !== undefined ? "run" : "prompt" }, + }; +} + +/** Starter `skillhook.yaml` for `skillhook projects init`. */ +export function renderProjectTemplate(): string { + return `# yaml-language-server: $schema=${PROJECT_SCHEMA_URL} +# skillhook.yaml — the webhooks of this repository, version-controlled with the code they act on. +# +# On a machine that runs skillhook: skillhook link . (then: skillhook skills list) +# Every hook is served at POST /hooks/. Secrets never live here; they are named by +# secret_env and stored on each machine with \`skillhook secret set NAME\`. +# Reference: https://github.com/MeterApp/skillhook/blob/main/docs/projects.md + +hooks: + # A shell command, run in this directory with the payload on stdin (never on the command line). + # GitHub → Settings → Webhooks: content type application/json, event "Pull requests", secret from + # \`skillhook secret generate GITHUB_WEBHOOK_SECRET\`, payload URL from \`skillhook url pull-after-merge\`. + pull-after-merge: + description: Fast-forward this checkout when a pull request merges. + run: git pull --ff-only + 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 } + + # An Agent Skill from this repository (a directory with a SKILL.md). Keys set here override its skillhook: block. + # release-notes: + # skill: .claude/skills/release-notes + # model: sonnet + # auth: { type: github, secret_env: GITHUB_WEBHOOK_SECRET } + # when: + # - { header: x-github-event, equals: release } + # - { path: action, equals: published } + + # Inline instructions for the agent runner, without a SKILL.md. + # summarize: + # prompt: Summarize the payload in three bullet points and write them to {{job_dir}}/summary.md. + # model: haiku +`; +} diff --git a/src/queue.ts b/src/queue.ts index a1871f2..b2c0c8f 100644 --- a/src/queue.ts +++ b/src/queue.ts @@ -7,7 +7,8 @@ import { isTerminal, type JobRecord, type JobStore } from "./jobs.js"; import type { Logger } from "./logger.js"; import { prepareRun } from "./run.js"; import type { RunnerOutcome, StreamState } from "./runners/index.js"; -import type { Skill, SkillRegistry } from "./skills.js"; +import type { SkillRegistry } from "./registry.js"; +import type { Skill } from "./skills.js"; import { errorMessage, nowIso, tail } from "./util.js"; export interface QueueDeps { diff --git a/src/registry.test.ts b/src/registry.test.ts new file mode 100644 index 0000000..a74c235 --- /dev/null +++ b/src/registry.test.ts @@ -0,0 +1,108 @@ +import { mkdirSync, utimesSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { configProjects, SkillRegistry } from "./registry.js"; +import { tempHome, writeConfigFile, writeSkill } from "./test-support/helpers.js"; + +function touchLater(file: string, seconds: number): void { + const future = new Date(Date.now() + seconds * 1000); + utimesSync(file, future, future); +} + +function makeProject(root: string, name: string, yaml: string): string { + const dir = path.join(root, name); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, "skillhook.yaml"), yaml); + return dir; +} + +describe("SkillRegistry", () => { + it("reloads a home skill when its SKILL.md changes", () => { + const paths = tempHome(); + const dir = writeSkill(paths, "live", "description: v1"); + const registry = new SkillRegistry(paths.skillsDir); + expect(registry.get("live")?.description).toBe("v1"); + writeFileSync(path.join(dir, "SKILL.md"), "---\nname: live\ndescription: v2\n---\nbody"); + touchLater(path.join(dir, "SKILL.md"), 5); + expect(registry.get("live")?.description).toBe("v2"); + expect(registry.get("../etc")).toBeUndefined(); + expect(registry.get("missing")).toBeUndefined(); + expect(registry.list().projects).toEqual([]); + }); + + it("merges linked projects after home skills and reports shadowed names", () => { + const paths = tempHome(); + writeSkill(paths, "hello", "description: home hello"); + const api = makeProject(paths.home, "api", "hooks:\n deploy:\n run: ./deploy.sh\n hello:\n run: echo shadowed\n"); + const web = makeProject(paths.home, "web", "hooks:\n deploy:\n run: npm run deploy\n build:\n prompt: Build it.\n"); + const registry = new SkillRegistry(paths.skillsDir, { projects: () => [api, web] }); + const listed = registry.list(); + expect(listed.skills.map((s) => s.name)).toEqual(["hello", "deploy", "build"]); + expect(listed.skills.find((s) => s.name === "hello")?.source).toEqual({ type: "home" }); + expect(listed.skills.find((s) => s.name === "deploy")?.dir).toBe(api); + expect(listed.errors.map((e) => e.name).sort()).toEqual(["deploy", "hello"]); + expect(listed.errors.find((e) => e.name === "deploy")?.error).toContain("shadowed by"); + expect(listed.projects.map((p) => p.dir)).toEqual([api, web]); + // get() follows the same precedence. + expect(registry.get("hello")?.description).toBe("home hello"); + expect(registry.get("deploy")?.config.shell).toEqual({ command: "./deploy.sh" }); + expect(registry.get("build")?.body).toBe("Build it."); + expect(registry.get("nope")).toBeUndefined(); + }); + + it("reloads a project when its skillhook.yaml or a referenced SKILL.md changes, and drops unlinked ones", () => { + const paths = tempHome(); + const repo = makeProject(paths.home, "repo", "hooks:\n a:\n run: echo one\n"); + let entries = [repo]; + const registry = new SkillRegistry(paths.skillsDir, { projects: () => entries }); + expect(registry.get("a")?.config.shell).toEqual({ command: "echo one" }); + writeFileSync(path.join(repo, "skillhook.yaml"), "hooks:\n a:\n run: echo two\n b:\n skill: skills/b\n"); + touchLater(path.join(repo, "skillhook.yaml"), 5); + mkdirSync(path.join(repo, "skills", "b"), { recursive: true }); + writeFileSync(path.join(repo, "skills", "b", "SKILL.md"), "---\nname: b\ndescription: b1\n---\nB one\n"); + expect(registry.get("a")?.config.shell).toEqual({ command: "echo two" }); + expect(registry.get("b")?.description).toBe("b1"); + writeFileSync(path.join(repo, "skills", "b", "SKILL.md"), "---\nname: b\ndescription: b2\n---\nB two\n"); + touchLater(path.join(repo, "skills", "b", "SKILL.md"), 10); + expect(registry.get("b")?.description).toBe("b2"); + entries = []; + expect(registry.get("a")).toBeUndefined(); + expect(registry.list().projects).toEqual([]); + }); + + it("throws for a hook whose definition exists but is broken, like an invalid SKILL.md", () => { + const paths = tempHome(); + const repo = makeProject(paths.home, "repo", "hooks:\n broken:\n skill: skills/broken\n fine:\n run: ls\n"); + mkdirSync(path.join(repo, "skills", "broken"), { recursive: true }); + writeFileSync(path.join(repo, "skills", "broken", "SKILL.md"), "---\nname: broken\n---\nno description\n"); + const registry = new SkillRegistry(paths.skillsDir, { projects: () => [repo] }); + expect(() => registry.get("broken")).toThrow(/Invalid SKILL.md frontmatter/); + expect(registry.get("fine")?.name).toBe("fine"); + const listed = registry.list(); + expect(listed.errors.map((e) => e.name)).toEqual(["broken"]); + const missingDir = new SkillRegistry(paths.skillsDir, { projects: () => [path.join(paths.home, "gone")] }).list(); + expect(missingDir.errors[0]?.error).toContain("not a directory"); + expect(missingDir.projects[0]?.error).toContain("not a directory"); + }); + + it("reads the project list from skillhook.json and notices edits", () => { + const paths = tempHome(); + const repo = makeProject(paths.home, "repo", "hooks:\n a:\n run: ls\n"); + const projects = configProjects(paths); + expect(projects()).toEqual([]); + writeConfigFile(paths, { projects: [repo] }); + touchLater(paths.configFile, 5); + expect(projects()).toEqual([repo]); + writeConfigFile(paths, { projects: [repo, 42, ""] }); + touchLater(paths.configFile, 10); + expect(projects()).toEqual([repo]); + writeFileSync(paths.configFile, "{ not json"); + touchLater(paths.configFile, 15); + expect(projects()).toEqual([]); + const registry = new SkillRegistry(paths.skillsDir, { projects: configProjects(paths) }); + writeConfigFile(paths, { projects: ["repo"] }); + touchLater(paths.configFile, 20); + expect(new SkillRegistry(paths.skillsDir, { projects: configProjects(paths), base: paths.home }).get("a")?.dir).toBe(repo); + expect(registry.list().projects).toHaveLength(1); + }); +}); diff --git a/src/registry.ts b/src/registry.ts new file mode 100644 index 0000000..2f8aeb7 --- /dev/null +++ b/src/registry.ts @@ -0,0 +1,123 @@ +import { statSync } from "node:fs"; +import path from "node:path"; +import type { Paths } from "./paths.js"; +import { loadProject, projectIsFresh, type LoadedProject } from "./projects.js"; +import { loadSkill, loadSkills, SkillError, skillFile, type Skill, type SkillLoadResult } from "./skills.js"; +import { isValidSkillName, readJsonFileOr } from "./util.js"; + +export interface RegistryOptions { + /** Linked project entries (directories or skillhook.yaml paths), re-read on every lookup so `skillhook link` needs no restart. */ + projects?: () => string[]; + /** Base for relative project entries (default: the process cwd). */ + base?: string; +} + +export interface RegistryListResult extends SkillLoadResult { + projects: LoadedProject[]; +} + +/** The `projects` array of `skillhook.json`, cached until the file's mtime changes; tolerant of a missing or invalid file. */ +export function configProjects(paths: Paths): () => string[] { + let stamp = -1; + let cached: string[] = []; + return () => { + let mtime = 0; + try { + mtime = statSync(paths.configFile).mtimeMs; + } catch { + mtime = 0; + } + if (mtime !== stamp) { + stamp = mtime; + const raw = readJsonFileOr<{ projects?: unknown }>(paths.configFile, {}); + cached = Array.isArray(raw.projects) ? raw.projects.filter((entry): entry is string => typeof entry === "string" && entry.length > 0) : []; + } + return cached; + }; +} + +/** + * Every routable skill: the directories under `/skills` first, then the hooks of each linked project in + * config order. `get()` re-reads a skill whose SKILL.md changed and a project whose skillhook.yaml (or a + * referenced SKILL.md) changed; `list()` rescans everything. Edits apply to the next webhook without a restart. + * A name defined twice belongs to the earlier source; the later definition is reported as an error. + */ +export class SkillRegistry { + private cache = new Map(); + private projectCache = new Map(); + + constructor( + public readonly skillsDir: string, + private readonly options: RegistryOptions = {}, + ) {} + + private entries(): string[] { + return this.options.projects?.() ?? []; + } + + private project(entry: string): LoadedProject { + const cached = this.projectCache.get(entry); + if (cached && projectIsFresh(cached)) return cached; + const loaded = loadProject(entry, this.options.base); + this.projectCache.set(entry, loaded); + return loaded; + } + + /** Every linked project, loaded (with errors, if any). */ + projects(): LoadedProject[] { + const entries = this.entries(); + for (const key of [...this.projectCache.keys()]) if (!entries.includes(key)) this.projectCache.delete(key); + return entries.map((entry) => this.project(entry)); + } + + list(): RegistryListResult { + const loaded = loadSkills(this.skillsDir); + this.cache = new Map(loaded.skills.map((s) => [s.name, s])); + const owner = new Map(loaded.skills.map((s) => [s.name, s.file])); + const projects = this.projects(); + for (const project of projects) { + if (project.error) { + loaded.errors.push({ dir: project.dir, name: path.basename(project.dir), error: project.error }); + continue; + } + for (const hook of project.hooks) { + const existing = owner.get(hook.name); + if (existing) { + loaded.errors.push({ dir: project.dir, name: hook.name, error: `hook "${hook.name}" in ${project.file} is shadowed by ${existing}; rename one of them` }); + continue; + } + owner.set(hook.name, project.file); + loaded.skills.push(hook); + } + for (const error of project.errors) loaded.errors.push({ dir: project.dir, name: error.name, error: error.error }); + } + return { ...loaded, projects }; + } + + /** The skill or hook named `name`, or undefined. Throws `SkillError` when its definition exists but is invalid. */ + get(name: string): Skill | undefined { + if (!isValidSkillName(name)) return undefined; + const dir = path.join(this.skillsDir, name); + const file = skillFile(dir); + let mtimeMs: number | undefined; + try { + mtimeMs = statSync(file).mtimeMs; + } catch { + this.cache.delete(name); + } + if (mtimeMs !== undefined) { + const cached = this.cache.get(name); + if (cached && cached.mtimeMs === mtimeMs) return cached; + const skill = loadSkill(dir); // throws SkillError for an invalid file + this.cache.set(name, skill); + return skill; + } + for (const project of this.projects()) { + const hook = project.hooks.find((h) => h.name === name); + if (hook) return hook; + const broken = project.errors.find((e) => e.name === name); + if (broken) throw new SkillError(broken.error, project.dir); + } + return undefined; + } +} diff --git a/src/server.test.ts b/src/server.test.ts index cf64981..68f3fc1 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { signRequest } from "./auth.js"; @@ -7,7 +7,7 @@ import { JobStore } from "./jobs.js"; import { silentLogger } from "./logger.js"; import { JobQueue } from "./queue.js"; import { createServer } from "./server.js"; -import { SkillRegistry } from "./skills.js"; +import { SkillRegistry } from "./registry.js"; import { FAKE_CLAUDE, FAKE_CODEX, tempHome, writeConfigFile, writeEnv, writeSkill } from "./test-support/helpers.js"; import type { Server } from "node:http"; @@ -17,6 +17,7 @@ let base = ""; let queue: JobQueue; let store: JobStore; const recordFile = path.join(paths.home, "record.json"); +const projectDir = path.join(paths.home, "repo"); const ADMIN = "admin-token-123"; function sleep(ms: number) { @@ -73,8 +74,12 @@ beforeAll(async () => { writeSkill(paths, "twinoff", "description: two\nskillhook:\n dedupe:\n in_flight: false\n env: [FAKE_CLAUDE_SLEEP_MS]"); writeSkill(paths, "slacky", "description: sl\nskillhook:\n auth:\n type: slack\n secret_env: SLACK_SECRET"); writeSkill(paths, "unconfigured", "description: u"); + mkdirSync(projectDir, { recursive: true }); + writeFileSync(path.join(projectDir, "skillhook.yaml"), ["hooks:", " where-am-i:", " description: Prints the working directory and the payload it got on stdin.", ' run: printf "%s\\n" "$PWD" && cat', " auth: { type: bearer, secret_env: SKILLHOOK_SECRET_HELLO }", " when:", " - { path: action, equals: closed }", " by-skill:", " skill: skills/greeter", " model: haiku", " auth: { type: bearer, secret_env: SKILLHOOK_SECRET_HELLO }", ""].join("\n")); + mkdirSync(path.join(projectDir, "skills", "greeter"), { recursive: true }); + writeFileSync(path.join(projectDir, "skills", "greeter", "SKILL.md"), "---\nname: greeter\ndescription: Greets.\nskillhook:\n model: opus\n env: [FAKE_CLAUDE_RECORD]\n---\n\nGreet {{payload.name}} from the project.\n"); const config = loadConfig(paths); - const registry = new SkillRegistry(paths.skillsDir); + const registry = new SkillRegistry(paths.skillsDir, { projects: () => [projectDir] }); store = new JobStore(paths.jobsDir, { maxJobs: 100, dedupeWindowSeconds: 3600 }); const { loadSecrets } = await import("./env.js"); const secrets = () => loadSecrets(paths, {}); @@ -284,6 +289,35 @@ describe("HTTP surface", () => { for (const id of [other.job_id, otherQuery.job_id, again.job_id]) await waitForJob(String(id), 25_000); }); + it("serves the hooks of a linked project: a shell command in the project directory, and a SKILL.md under the hook's name", async () => { + const skipped = await fetch(`${base}/hooks/where-am-i`, { method: "POST", body: JSON.stringify({ action: "opened" }), headers: { authorization: "Bearer hello-secret", "content-type": "application/json" } }); + expect((await json(skipped)).skipped).toBe(true); + const res = await fetch(`${base}/hooks/where-am-i?wait=20`, { method: "POST", body: JSON.stringify({ action: "closed", pr: 7 }), headers: { authorization: "Bearer hello-secret", "content-type": "application/json" } }); + const body = await json(res); + expect(body.status).toBe("succeeded"); + expect(String(body.result).split("\n")[0]).toBe(projectDir); + expect(String(body.result)).toContain('"pr": 7'); + const job = store.get(String(body.job_id)); + expect(job).toMatchObject({ runner: "shell", cwd: projectDir }); + expect(job?.command?.slice(0, 2)).toEqual(["/bin/sh", "-c"]); + + const viaSkill = await fetch(`${base}/hooks/by-skill?wait=20`, { method: "POST", body: JSON.stringify({ name: "Grace" }), headers: { authorization: "Bearer hello-secret", "content-type": "application/json" } }); + const skillBody = await json(viaSkill); + expect(skillBody.status).toBe("succeeded"); + expect(String(skillBody.result)).toContain("model=haiku"); + const record = JSON.parse(readFileSync(recordFile, "utf8")) as { prompt: string; cwd: string; env: Record; args: string[] }; + expect(record.prompt).toContain("Greet Grace from the project."); + expect(record.cwd).toBe(projectDir); + expect(record.env.SKILLHOOK_SKILL).toBe("by-skill"); + expect(record.env.SKILLHOOK_SKILL_DIR).toBe(path.join(projectDir, "skills", "greeter")); + expect(record.args).toContain(path.join(projectDir, "skills", "greeter")); + + const skills = await json(await fetch(`${base}/skills`, { headers: { authorization: `Bearer ${ADMIN}` } })); + const hook = (skills.skills as { name: string; source: { type: string; kind?: string; dir?: string }; cwd: string }[]).find((s) => s.name === "where-am-i"); + expect(hook).toMatchObject({ source: { type: "project", kind: "run", dir: projectDir }, cwd: projectDir }); + expect((skills.skills as { name: string; source: { type: string } }[]).find((s) => s.name === "hello")?.source).toEqual({ type: "home" }); + }); + it("runs identical deliveries when in-flight de-duplication is off for the skill", async () => { const post = () => fetch(`${base}/hooks/twinoff`, { method: "POST", body: '{"same": true}', headers: { authorization: "Bearer two" } }); const a = await json(await post()); diff --git a/src/server.ts b/src/server.ts index cb34a91..b50a86c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,7 +10,8 @@ import type { Logger } from "./logger.js"; import { deliveryFingerprint, parseBody, redactHeaders, type Trigger, type WebhookEvent } from "./payload.js"; import type { JobQueue } from "./queue.js"; import { resolveRunSettings } from "./run.js"; -import { describeAuth, SkillError, type Skill, type SkillRegistry } from "./skills.js"; +import type { SkillRegistry } from "./registry.js"; +import { describeAuth, SkillError, type Skill } from "./skills.js"; import { errorMessage, getPath, isPlainObject, isValidSkillName, nowIso, writeJsonFile } from "./util.js"; import { VERSION } from "./version.js"; import type { Paths } from "./paths.js"; @@ -157,6 +158,8 @@ export function skillSummary(skill: Skill, config: Config, secrets: Secrets): Re auth: { type: skill.auth.type, secret_env: secretEnv ?? null, configured: secretEnv ? Boolean(secrets[secretEnv]) : true, how: describeAuth(skill.auth) }, when: skill.config.when?.map(describeCondition) ?? [], dir: skill.dir, + file: skill.file, + source: skill.source, }; } diff --git a/src/skills.test.ts b/src/skills.test.ts index 8f00236..c7b12b6 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -1,7 +1,7 @@ -import { mkdirSync, utimesSync, writeFileSync } from "node:fs"; +import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { loadSkills, parseSkillDocument, renderSkillTemplate, SkillRegistry, describeAuth } from "./skills.js"; +import { loadSkills, parseSkillDocument, renderSkillTemplate, describeAuth } from "./skills.js"; import { tempHome, writeSkill } from "./test-support/helpers.js"; describe("parseSkillDocument", () => { @@ -86,17 +86,4 @@ describe("loadSkills / SkillRegistry", () => { expect(result.errors).toHaveLength(1); expect(result.errors[0]?.name).toBe("broken"); }); - - it("reloads a skill when its SKILL.md changes", () => { - const paths = tempHome(); - const dir = writeSkill(paths, "live", "description: v1"); - const registry = new SkillRegistry(paths.skillsDir); - expect(registry.get("live")?.description).toBe("v1"); - writeFileSync(path.join(dir, "SKILL.md"), "---\nname: live\ndescription: v2\n---\nbody"); - const future = new Date(Date.now() + 5000); - utimesSync(path.join(dir, "SKILL.md"), future, future); - expect(registry.get("live")?.description).toBe("v2"); - expect(registry.get("../etc")).toBeUndefined(); - expect(registry.get("missing")).toBeUndefined(); - }); }); diff --git a/src/skills.ts b/src/skills.ts index 85ff5a1..fce7513 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -243,10 +243,25 @@ export function describeAuth(auth: NormalizedAuth): string { // Skill loading // --------------------------------------------------------------------------- +/** Where a skill came from: its own directory under `/skills`, or a hook in a linked project's `skillhook.yaml`. */ +export type SkillSource = + | { type: "home" } + | { + type: "project"; + /** The project (repository) directory. */ + dir: string; + /** The `skillhook.yaml` that defines the hook. */ + file: string; + /** How the hook is implemented: a `SKILL.md` in the project, an inline `prompt`, or a shell command (`run`). */ + kind: "skill" | "prompt" | "run"; + }; + export interface Skill { name: string; description: string; + /** Directory whose files the agent may use (`--add-dir`, `{{skill_dir}}`): the skill directory, or the project directory for `run`/`prompt` hooks. */ dir: string; + /** The file that defines the skill: `SKILL.md`, or the project's `skillhook.yaml` for `run`/`prompt` hooks. */ file: string; /** Markdown instructions (frontmatter removed). */ body: string; @@ -259,6 +274,7 @@ export interface Skill { /** Set when the file exists but is invalid; the skill is then not routable. */ error?: string; mtimeMs: number; + source: SkillSource; } export class SkillError extends Error { @@ -305,6 +321,7 @@ export function parseSkillDocument(text: string, dir: string): Skill { allowedTools, enabled: config.enabled !== false, mtimeMs: 0, + source: { type: "home" }, }; } @@ -347,40 +364,6 @@ export function loadSkills(skillsDir: string): SkillLoadResult { return result; } -/** - * Cached view of the skills directory. `get()` re-reads a skill whose SKILL.md changed, and - * `list()` rescans the directory, so edits apply to the next webhook without a restart. - */ -export class SkillRegistry { - private cache = new Map(); - - constructor(public readonly skillsDir: string) {} - - list(): SkillLoadResult { - const loaded = loadSkills(this.skillsDir); - this.cache = new Map(loaded.skills.map((s) => [s.name, s])); - return loaded; - } - - get(name: string): Skill | undefined { - if (!isValidSkillName(name)) return undefined; - const dir = path.join(this.skillsDir, name); - const file = skillFile(dir); - let mtimeMs: number; - try { - mtimeMs = statSync(file).mtimeMs; - } catch { - this.cache.delete(name); - return undefined; - } - const cached = this.cache.get(name); - if (cached && cached.mtimeMs === mtimeMs) return cached; - const skill = loadSkill(dir); // throws SkillError for an invalid file - this.cache.set(name, skill); - return skill; - } -} - /** Frontmatter + body for a freshly scaffolded skill. */ export function renderSkillTemplate(options: { name: string; diff --git a/src/util.ts b/src/util.ts index ee1f3aa..28c9ad8 100644 --- a/src/util.ts +++ b/src/util.ts @@ -8,6 +8,12 @@ export function expandTilde(p: string): string { return p; } +/** Shortens a path under the home directory to `~/…` for display. */ +export function displayPath(p: string, home = homedir()): string { + if (p === home) return "~"; + return p.startsWith(`${home}${path.sep}`) ? `~${p.slice(home.length)}` : p; +} + export function ensureDir(dir: string): void { mkdirSync(dir, { recursive: true }); }