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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Runtime state lives outside the repo in `~/.skillhook` (`SKILLHOOK_HOME`):
- **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, with the cloud link's control commands as the documented exceptions (`skill.put` writes `skills/<name>/SKILL.md`, `secret.generate` / `secret.set` and a token rotation write `.env`, `config.patch` writes `skillhook.json`); its own state is `jobs/.cloud/`, skills it removes go to `jobs/.removed-skills/`. Statuses: `queued running succeeded failed timed_out cancelled interrupted`. The running agent talks to skillhook only through files in its job directory (`src/progress.ts`): no token, no HTTP, so the shell runner and a restart are covered; the queue turns them into events and record fields.
- **State changes are events.** Whatever the server learns (a job changing state, a schedule firing or skipping, a skill file appearing or changing) is emitted on `Events` (`src/events.ts`) at the place it happens, after the record on disk is updated, with the full record in the payload. Consumers (the SSE routes, later the cloud link) subscribe; they never poll job files. A new kind of state change gets a new `EventMap` entry, an emit, a row in `docs/api.md` and a test. Listener errors are logged, never thrown into the publisher.
- **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.
- **Every CLI command supports `--json`** and returns non-zero on failure. Register new commands in `COMMANDS` (with the usage text) and `HELP` in `src/commands/main.ts`, then in the README table. `--help` / `-h` never reaches a command: `main` prints the usage from `COMMANDS` instead, so a command never checks for it; a new subcommand gets a line in the `--help` test of `src/cli.test.ts`.
- **Third-party facts** (Granola, Sentry, GitHub, Tailscale) are stated in `docs/` and the examples with the exact header names; change them only with a source.
- **Tests are hermetic**: `tempHome()` from `src/test-support/helpers.ts`, fake runners, ephemeral ports. Never touch `~/.skillhook`, the real `claude`/`codex`, `launchctl` or `tailscale` from a test. Never reach the real npm registry either: point `SKILLHOOK_NPM_REGISTRY` at a local `node:http` server or set `SKILLHOOK_NO_UPDATE_CHECK=1`.
- **Outbound requests are opt-in and enumerated.** By default the CLI phones home once a day, and only for the update check (`src/update.ts`: the registry's `latest` dist-tag, cached 24 h, never on `--json`, in CI, or when `SKILLHOOK_NO_UPDATE_CHECK` / `update_check: false` say so). The one other outbound connection is the Skillhook Cloud link (`src/cloud/link.ts`), and only after `skillhook cloud connect` wrote `cloud.enabled` and `SKILLHOOK_CLOUD_TOKEN`: it talks to `cloud.url` over HTTPS only, sends only what `docs/cloud.md` lists (redacted, scrubbed of every `.env` value), obeys `cloud.mode` and the local allow/deny lists (which the cloud cannot change), and stops on `cloud.enabled: false`, `SKILLHOOK_NO_CLOUD=1` or `cloud disconnect`. Never enable it by default, from `init` or from a job. Besides the link, the person-invoked cloud commands send one request each, only when run, only to the machine's cloud URL (`cloud.url` or `SKILLHOOK_CLOUD_URL`, HTTPS only): `cloud connect` / `disconnect` (pairing, revocation), `cloud report` and the MCP tool `cloud_report_issue` (`src/cloud/report.ts`: the person's text plus the diagnostics `docs/cloud.md` lists, scrubbed like the link's uploads, with the machine token), and `cloud login|machines|jobs|job` (`src/cloud/api.ts`: reads with the person's organisation API key `SKILLHOOK_CLOUD_API_KEY`, never with the machine token); the last two groups refuse under `SKILLHOOK_NO_CLOUD=1`. Do not add other outbound requests the user did not ask for, and never auto-install anything.
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to skillhook, newest first. The format follows [Keep a Chang

## Unreleased

- `--help` and `-h` never run a command. Most commands used to ignore them and do their work, so
`skillhook jobs prune --help` pruned jobs and `skillhook service install --help` installed the service
(0.6.0 fixed only `skillhook cloud`). Now `skillhook <command> [subcommand …] --help`, or
`skillhook help <command>`, prints that command's usage and exits 0 without touching anything;
`--json` prints `{ "ok": true, "command": …, "usage": … }`. The check sits in front of every command,
so a new one cannot forget it. `serve`, `doctor`, `health`, `runners`, `mcp` and `url` gained a usage
text, and `skillhook job <subcommand> --help` prints the agent's job API or, for the rest, `jobs`.

## 0.6.0 (2026-09-29)

- `skillhook cloud report "<title>"`: a person on a paired machine reports a problem to the Skillhook
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ Agents reading this repository should start with [`AGENTS.md`](AGENTS.md) (layou
| `skillhook schedules [list]` · `schedules next <name> [--count N]` · `schedules run <name> [--wait S]` | Every skill or hook with a `schedule:`, its next and last runs; preview occurrences; fire one now. |
| `skillhook update [--install]` | Check npm for a newer skillhook; `--install` upgrades with the package manager that installed it and restarts the background service when it is idle. |

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

## Project layout of `~/.skillhook`

Expand Down
147 changes: 144 additions & 3 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,56 @@
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { appendFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { createServer } from "node:http";
import { main, nodeVersionProblem } from "./commands/main.js";
import { COMMANDS, main, nodeVersionProblem, usageOf } from "./commands/main.js";
import type { CliIO } from "./commands/shared.js";
import { installService, restartService, uninstallService } from "./service.js";
import { disableExposure, enableExposure } from "./tailscale.js";
import { FAKE_CLAUDE, FAKE_CODEX, tempHome, writeConfigFile, writeEnv, writeSkill } from "./test-support/helpers.js";

// No test installs, removes or restarts this machine's service or changes its Tailscale Funnel/Serve, not even when a
// regression lets `service install --help` or `expose off --help` run: here those only record the call.
vi.mock("./service.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./service.js")>()),
installService: vi.fn(async () => ({ ok: false, file: "", output: "not from a test" })),
uninstallService: vi.fn(async () => ({ ok: false, output: "not from a test" })),
restartService: vi.fn(async () => ({ ok: false, output: "not from a test" })),
}));
vi.mock("./tailscale.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./tailscale.js")>()),
enableExposure: vi.fn(async () => ({ ok: false, output: "not from a test" })),
disableExposure: vi.fn(async () => ({ ok: false, output: "not from a test" })),
}));

function io(env: NodeJS.ProcessEnv = {}) {
const out: string[] = [];
const err: string[] = [];
const cli: CliIO = { stdout: (t) => out.push(t), stderr: (t) => err.push(t), env, isTTY: false };
return { cli, out: () => out.join(""), err: () => err.join(""), json: () => JSON.parse(out.join("")) as Record<string, unknown> };
}

/** Every entry under `dir` with its mtime and each file's content: an equal snapshot means nothing was written, created or removed. */
function snapshot(dir: string): Record<string, string> {
const entries: Record<string, string> = {};
for (const name of readdirSync(dir, { recursive: true, encoding: "utf8" }).sort()) {
const file = path.join(dir, name);
const stat = lstatSync(file);
entries[name] = `${stat.mtimeMs} ${stat.isFile() ? readFileSync(file, "utf8") : stat.isDirectory() ? "(directory)" : "(other)"}`;
}
return entries;
}

/** What main() returned, or "still running" after `ms` (a line that started a server, say). */
async function settle(running: Promise<number>, ms = 5_000): Promise<number | "still running"> {
let timer: NodeJS.Timeout | undefined;
const late = new Promise<"still running">((resolve) => (timer = setTimeout(() => resolve("still running"), ms)));
try {
return await Promise.race([running, late]);
} finally {
clearTimeout(timer);
}
}

describe("cli", () => {
const paths = tempHome();
const dir = ["--dir", paths.home];
Expand Down Expand Up @@ -841,4 +879,107 @@ describe("cli", () => {
expect(await main(["update", ...emptyHome, "--json"], fresh.cli)).toBe(1);
expect(fresh.json().ok).toBe(false);
});

it("prints the usage for --help, -h and help <command> and runs nothing, for every command and subcommand", async () => {
const home = tempHome("skillhook-cli-help-");
const at = ["--dir", home.home];
// A home where every line below has something to act on: a job to prune or answer, a delivery to replay, secrets and
// config to change, a linked project to unlink, a schedule to fire, an API key to forget.
expect(await main(["init", ...at, "--json"], io().cli)).toBe(0);
writeConfigFile(home, { runners: { claude: { command: FAKE_CLAUDE }, codex: { command: FAKE_CODEX } } });
const run = io();
expect(await main(["run", "hello", ...at, "--payload", '{"name":"help"}', "--json"], run.cli)).toBe(0);
const job = (run.json().job as { id: string }).id;
const { DeliveryLog } = await import("./delivery-log.js");
const delivery = new DeliveryLog(home.jobsDir, () => ({ max: 100, store_bodies: true, body_max_bytes: 1000 })).record({ skill: "hello", received_at: "2026-09-29T12:00:00.000Z", outcome: "rejected", http_status: 401, code: "missing_token", reason: "no bearer token", ip: "203.0.113.9", method: "POST", path: "/hooks/hello", query: {}, headers: {}, content_type: "application/json", bytes: 2, duration_ms: 1, rawBody: Buffer.from("{}") }).id;
const linked = path.join(home.home, "linked");
expect(await main(["projects", "init", linked, ...at, "--json"], io().cli)).toBe(0);
const unlinked = path.join(home.home, "unlinked");
mkdirSync(unlinked);
writeFileSync(path.join(unlinked, "skillhook.yaml"), "hooks:\n where:\n run: pwd\n");
writeSkill(home, "tick", 'description: Ticks.\nskillhook:\n runner: shell\n shell:\n command: ["sh", "-c", "echo tick"]\n webhook: false\n schedule: "*/5 * * * *"');
appendFileSync(home.envFile, "SKILLHOOK_CLOUD_API_KEY=shc_placeholder-help-key\n");
// Should a line run after all, nothing leaves the machine: no registry, no cloud, fake runners (and the stubs above).
const env = { SKILLHOOK_NO_UPDATE_CHECK: "1", SKILLHOOK_NPM_REGISTRY: "http://127.0.0.1:1", SKILLHOOK_CLOUD_URL: "http://127.0.0.1:1", SKILLHOOK_NO_CLOUD: "1", SKILLHOOK_JOB_ID: job, SKILLHOOK_JOB_DIR: path.join(home.jobsDir, job) };

// Each command with each subcommand, and arguments that would change the home if it ran; an alias gets its command's.
const lines: Record<string, string[][]> = {
init: [[], ["--force", "--runner", "codex"]],
serve: [[], ["--port", "0"]],
skills: [[], ["list"], ["show", "hello"], ["new", "fresh"], ["add", "hello", "--as", "copy"], ["examples"], ["validate"], ["path", "hello"]],
secret: [[], ["list"], ["set", "hello", "--value", "changed"], ["set", "hello", "--stdin"], ["generate", "admin", "--force"], ["rotate", "hello"], ["unset", "hello"]],
run: [["hello", "--payload", "{}"], ["hello", "--dry-run"], ["--file", path.join(home.skillsDir, "hello", "SKILL.md")], ["--stdin"]],
send: [["hello", "--wait", "5"]],
jobs: [[], ["list"], ["show", job], ["logs", job, "--follow"], ["answer", job, "yes", "--no-resume"], ["cancel", job], ["replay", job], ["resume", job], ["path", job], ["prune", "--keep", "0"]],
job: [[], ["progress", "halfway", "--percent", "50"], ["ask", "Go?", "--wait", "0"], ["outcome", "completed", "--summary", "done"], ["note", "noted"], ["context"], ["prune", "--keep", "0"]],
deliveries: [[], ["list"], ["show", delivery, "--body"], ["replay", delivery, "--force"]],
expose: [[], ["tailscale"], ["serve"], ["status"], ["off"], ["cloudflare"], ["ngrok"]],
url: [[], ["hello", "--local"]],
service: [[], ["install"], ["uninstall"], ["status"], ["restart"], ["logs", "--lines", "5"]],
doctor: [[]],
health: [[], ["--quick", "--local"]],
runners: [[], ["--refresh", "--local"]],
stats: [[], ["--since", "7d"]],
config: [[], ["show"], ["get", "port"], ["set", "port", "9999"], ["unset", "runners"], ["reload"], ["path"]],
cloud: [[], ["connect", "--code", "ABCD-EFGH", "--control"], ["disconnect"], ["status"], ["report", "Broken", "--body", "details"], ["login", "--key", "shc_placeholder-other-key"], ["logout"], ["machines"], ["jobs", "--waiting"], ["job", job]],
mcp: [[], ["--print-config"], ["--job", job]],
update: [[], ["--install"], ["--refresh"]],
link: [[], [unlinked]],
unlink: [[linked]],
projects: [[], ["list"], ["init", path.join(home.home, "fresh")], ["add", unlinked], ["remove", linked]],
schedules: [[], ["list"], ["next", "tick"], ["run", "tick"]],
};
for (const name of Object.keys(lines)) expect(COMMANDS, name).toHaveProperty(name);

const before = snapshot(home.home);
const prune = io(env);
expect(await main(["jobs", "prune", "--keep", "0", "--help", ...at], prune.cli)).toBe(0);
expect(prune.out()).toContain("skillhook jobs prune [--keep N]");
const agent = io(env);
expect(await main(["job", "progress", "halfway", "-h", ...at], agent.cli)).toBe(0);
expect(agent.out()).toContain('skillhook job progress "<what you are doing>"');
const operator = io(env);
expect(await main(["help", "job", "prune", ...at, "--json"], operator.cli)).toBe(0);
expect(operator.json()).toMatchObject({ ok: true, command: "job", usage: expect.stringContaining("skillhook jobs prune [--keep N]") });

const documented: string[] = [];
let stdinReads = 0;
for (const [name, command] of Object.entries(COMMANDS)) {
const own = lines[name] ?? Object.entries(lines).find(([other]) => COMMANDS[other]?.run === command.run)?.[1];
expect(own, `no --help lines for skillhook ${name}`).toBeDefined();
expect(usageOf(command, []), name).toMatch(/^Usage/);
// Every subcommand the usage documents (`skillhook jobs prune …`, `skillhook projects add|remove …`) has a line.
const words = usageOf(command, []).split(/\s+/);
for (const sub of words.flatMap((word, i) => (words[i - 2] === "skillhook" && words[i - 1] === name && /^[a-z]/.test(word) ? word.split("|") : []))) {
documented.push(`${name} ${sub}`);
expect(own?.map((args) => args[0]), `skillhook ${name} ${sub} --help`).toContain(sub);
}

for (const args of own ?? []) {
const usage = usageOf(command, args);
for (const argv of [[name, ...args, "--help", ...at], [name, "-h", ...args, ...at, "--json"], ["help", name, ...args, ...at]]) {
const line = `skillhook ${argv.join(" ")}`;
const h = io(env);
h.cli.stdin = async () => {
stdinReads++;
return "";
};
expect(await settle(main(argv, h.cli)), line).toBe(0);
if (argv.includes("--json")) expect(h.json(), line).toEqual({ ok: true, command: name, usage });
else expect(h.out(), line).toBe(`${usage}\n`);
expect(h.err(), line).toBe("");
expect(snapshot(home.home), line).toEqual(before);
}
}
}
expect(documented).toEqual(expect.arrayContaining(["jobs prune", "job progress", "service install", "config set", "cloud report", "projects remove"]));
expect(stdinReads).toBe(0);
for (const stub of [installService, uninstallService, restartService, enableExposure, disableExposure]) expect(stub).not.toHaveBeenCalled();

// The same line without --help does what it says, and the snapshot sees it.
const pruned = io(env);
expect(await main(["jobs", "prune", "--keep", "0", ...at, "--json"], pruned.cli)).toBe(0);
expect(pruned.json().removed).toBeGreaterThan(0);
expect(snapshot(home.home)).not.toEqual(before);
});
});
Loading
Loading