From 636b7e001e65766cf6080af5c46c24be4d300dbf Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 29 Sep 2026 12:29:13 -0400 Subject: [PATCH 1/2] --help never runs a command Most subcommands ignored --help / -h and ran anyway: `skillhook jobs prune --help` pruned jobs, `service install --help` installed the service, `config set ... --help` wrote the config. 0.6.0 fixed this only for `skillhook cloud`. main.ts now handles --help / -h (and `skillhook help `) before any command code runs, printing the usage that COMMANDS pairs with every command, so a new command cannot forget it; --json prints { ok, command, usage }. The per-command checks in cloud.ts and update.ts are gone. serve, doctor, health, runners, mcp and url gained a usage text, and `job --help` follows jobCommand's dispatch (the agent's job API, or `jobs` for the rest). Each command's USAGE is now an exported _USAGE, like INIT_USAGE and UPDATE_USAGE already were. src/cli.test.ts runs every COMMANDS entry and every subcommand with --help, -h --json and `help ` against a tempHome() and asserts exit 0, the usage on stdout, nothing on stderr, no stdin read and an unchanged home (every file with its mtime and content). The service and Tailscale mutators are stubbed for the file, so a regression can never reach the real launchd/systemd or Funnel. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- CHANGELOG.md | 8 ++ README.md | 2 +- src/cli.test.ts | 147 ++++++++++++++++++++++++++++++++++++- src/commands/cloud.ts | 41 +++++------ src/commands/config.ts | 10 +-- src/commands/deliveries.ts | 12 +-- src/commands/doctor.ts | 5 ++ src/commands/expose.ts | 6 +- src/commands/health.ts | 9 +++ src/commands/init.ts | 2 +- src/commands/job.ts | 30 +++++--- src/commands/jobs.ts | 20 ++--- src/commands/main.ts | 140 ++++++++++++++++++++--------------- src/commands/mcp.ts | 7 ++ src/commands/projects.ts | 6 +- src/commands/run.ts | 12 +-- src/commands/runners.ts | 6 ++ src/commands/schedules.ts | 6 +- src/commands/secret.ts | 10 +-- src/commands/send.ts | 4 +- src/commands/serve.ts | 5 ++ src/commands/service.ts | 4 +- src/commands/skills.ts | 10 +-- src/commands/stats.ts | 6 +- src/commands/update.ts | 4 - 26 files changed, 357 insertions(+), 157 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fb95633..2574369 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//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. diff --git a/CHANGELOG.md b/CHANGELOG.md index c5b1c37..827b17b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 [subcommand …] --help`, or + `skillhook help `, 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 --help` prints the agent's job API or, for the rest, `jobs`. + ## 0.6.0 (2026-09-29) - `skillhook cloud report ""`: a person on a paired machine reports a problem to the Skillhook diff --git a/README.md b/README.md index 958e242..427371b 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/src/cli.test.ts b/src/cli.test.ts index 9f21305..84ac1e7 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,11 +1,27 @@ -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[] = []; @@ -13,6 +29,28 @@ function io(env: NodeJS.ProcessEnv = {}) { 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]; @@ -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); + }); }); diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index 318a85f..b9a6968 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -10,7 +10,7 @@ import { machineKeyPair, publicKeyOf, SealError } from "../cloud/seal.js"; import { ensureSecretFileMode, readEnvFile, removeEnvVar, upsertEnvVar } from "../env.js"; import { bool, CommandError, formatDuration, num, relativeTime, str, table, UsageError, type Ctx } from "./shared.js"; -const USAGE = `Usage: +export const CLOUD_USAGE = `Usage: skillhook cloud connect --code XXXX-XXXX [--url URL] [--control|--observe] [--force] pair this machine with Skillhook Cloud (the dashboard shows the code) skillhook cloud connect --token TOKEN [--url URL] [--control|--observe] [--force] pair with a machine token instead skillhook cloud disconnect [--keep-token] stop the link, forget the pairing, revoke the token @@ -43,20 +43,15 @@ export async function cloudCommand(ctx: Ctx): Promise<number> { async function cloudSubcommand(ctx: Ctx): Promise<number> { const [sub = "status"] = ctx.args; - // Only the usage: `cloud report … --help` must not send a report, nor `cloud logout --help` forget the key. - if (bool(ctx.flags, "help", "h")) { - ctx.print(USAGE, { usage: USAGE }); - return 0; - } const config = ctx.config(); const env = ctx.io.env; switch (sub) { case "connect": { const code = str(ctx.flags, "code"); const token = str(ctx.flags, "token"); - if (!code && !token) throw new UsageError("Give --code (from the dashboard's pairing page) or --token", USAGE); - if (code && token) throw new UsageError("Give either --code or --token, not both", USAGE); - if (bool(ctx.flags, "control") && bool(ctx.flags, "observe")) throw new UsageError("--control and --observe exclude each other", USAGE); + if (!code && !token) throw new UsageError("Give --code (from the dashboard's pairing page) or --token", CLOUD_USAGE); + if (code && token) throw new UsageError("Give either --code or --token, not both", CLOUD_USAGE); + if (bool(ctx.flags, "control") && bool(ctx.flags, "observe")) throw new UsageError("--control and --observe exclude each other", CLOUD_USAGE); const url = resolveCloudUrl(env, config.cloud, str(ctx.flags, "url")); try { assertSecureCloudUrl(url, env); @@ -130,9 +125,9 @@ async function cloudSubcommand(ctx: Ctx): Promise<number> { } case "login": { const given = str(ctx.flags, "key"); - if (!given) throw new UsageError("Give the organisation API key: --key shc_…, or --key - to read it from stdin (Settings → API keys on the dashboard)", USAGE); + if (!given) throw new UsageError("Give the organisation API key: --key shc_…, or --key - to read it from stdin (Settings → API keys on the dashboard)", CLOUD_USAGE); const key = (given === "-" ? await readStdin(ctx) : given).trim(); - if (!API_KEY_RE.test(key)) throw new UsageError("That is not an organisation API key: those are shc_ followed by letters, digits, - and _ (Settings → API keys on the dashboard)", USAGE); + if (!API_KEY_RE.test(key)) throw new UsageError("That is not an organisation API key: those are shc_ followed by letters, digits, - and _ (Settings → API keys on the dashboard)", CLOUD_USAGE); const client = fleetClient(env, config.cloud, key); const { data: me } = await client.get("/me", MeSchema); upsertEnvVar(ctx.paths.envFile, CLOUD_API_KEY_ENV, key); @@ -160,11 +155,11 @@ async function cloudSubcommand(ctx: Ctx): Promise<number> { } case "jobs": { const status = str(ctx.flags, "status"); - if (status && !(JOB_STATUSES as readonly string[]).includes(status)) throw new UsageError(`--status must be one of ${JOB_STATUSES.join(", ")}`, USAGE); + if (status && !(JOB_STATUSES as readonly string[]).includes(status)) throw new UsageError(`--status must be one of ${JOB_STATUSES.join(", ")}`, CLOUD_USAGE); const outcome = str(ctx.flags, "outcome"); - if (outcome && !(JOB_OUTCOMES as readonly string[]).includes(outcome)) throw new UsageError(`--outcome must be one of ${JOB_OUTCOMES.join(", ")}`, USAGE); + if (outcome && !(JOB_OUTCOMES as readonly string[]).includes(outcome)) throw new UsageError(`--outcome must be one of ${JOB_OUTCOMES.join(", ")}`, CLOUD_USAGE); const limit = num(ctx.flags, "limit"); - if (limit !== undefined && !(Number.isInteger(limit) && limit >= 1 && limit <= 100)) throw new UsageError("--limit must be a whole number from 1 to 100", USAGE); + if (limit !== undefined && !(Number.isInteger(limit) && limit >= 1 && limit <= 100)) throw new UsageError("--limit must be a whole number from 1 to 100", CLOUD_USAGE); const waiting = bool(ctx.flags, "waiting"); const query = new URLSearchParams(); for (const [key, value] of Object.entries({ machine: str(ctx.flags, "machine"), skill: str(ctx.flags, "skill"), status, outcome, waiting: waiting ? "1" : undefined, limit: limit?.toString(), before: str(ctx.flags, "before") })) if (value) query.set(key, value); @@ -182,7 +177,7 @@ async function cloudSubcommand(ctx: Ctx): Promise<number> { } case "job": { const id = ctx.args[1]; - if (!id) throw new UsageError("Missing the job id (the machine's own, or the cloud's)", USAGE); + if (!id) throw new UsageError("Missing the job id (the machine's own, or the cloud's)", CLOUD_USAGE); const client = fleetClient(env, config.cloud, storedApiKey(ctx.paths, env)); const { data, raw } = await client.get(`/jobs/${encodeURIComponent(id)}`, JobDetailSchema); if (ctx.json) { @@ -193,7 +188,7 @@ async function cloudSubcommand(ctx: Ctx): Promise<number> { return 0; } default: - throw new UsageError(`Unknown cloud subcommand "${sub}"`, USAGE); + throw new UsageError(`Unknown cloud subcommand "${sub}"`, CLOUD_USAGE); } } @@ -205,18 +200,18 @@ async function readStdin(ctx: Ctx): Promise<string> { async function reportInput(ctx: Ctx): Promise<IssueReportInput> { const argument = ctx.args.slice(1).join(" ").trim(); const flagged = str(ctx.flags, "title")?.trim(); - if (argument && flagged) throw new UsageError("Give the title once: as the argument or with --title", USAGE); + if (argument && flagged) throw new UsageError("Give the title once: as the argument or with --title", CLOUD_USAGE); const title = flagged || argument; - if (!title) throw new UsageError('Missing the title: skillhook cloud report "what went wrong"', USAGE); + if (!title) throw new UsageError('Missing the title: skillhook cloud report "what went wrong"', CLOUD_USAGE); const kind = str(ctx.flags, "kind"); - if (kind && !(ISSUE_KINDS as readonly string[]).includes(kind)) throw new UsageError(`--kind must be one of ${ISSUE_KINDS.join(", ")}`, USAGE); + if (kind && !(ISSUE_KINDS as readonly string[]).includes(kind)) throw new UsageError(`--kind must be one of ${ISSUE_KINDS.join(", ")}`, CLOUD_USAGE); const severity = str(ctx.flags, "severity"); - if (severity && !(ISSUE_SEVERITIES as readonly string[]).includes(severity)) throw new UsageError(`--severity must be one of ${ISSUE_SEVERITIES.join(", ")}`, USAGE); - if (ctx.flags.body === true) throw new UsageError("--body needs the text, or - to read it from stdin", USAGE); - if (ctx.flags["body-file"] === true) throw new UsageError("--body-file needs a path", USAGE); + if (severity && !(ISSUE_SEVERITIES as readonly string[]).includes(severity)) throw new UsageError(`--severity must be one of ${ISSUE_SEVERITIES.join(", ")}`, CLOUD_USAGE); + if (ctx.flags.body === true) throw new UsageError("--body needs the text, or - to read it from stdin", CLOUD_USAGE); + if (ctx.flags["body-file"] === true) throw new UsageError("--body-file needs a path", CLOUD_USAGE); const inline = str(ctx.flags, "body"); const file = str(ctx.flags, "body-file"); - if (inline !== undefined && file !== undefined) throw new UsageError("Give --body or --body-file, not both", USAGE); + if (inline !== undefined && file !== undefined) throw new UsageError("Give --body or --body-file, not both", CLOUD_USAGE); let body = inline === "-" ? await readStdin(ctx) : inline; if (file !== undefined) { try { diff --git a/src/commands/config.ts b/src/commands/config.ts index 0485f8d..9e1fead 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -3,7 +3,7 @@ import { coerceConfigValue, HOT_CONFIG_KEYS, readRawConfig, RESTART_CONFIG_KEYS, import { getPath } from "../util.js"; import { CommandError, UsageError, type Ctx } from "./shared.js"; -const USAGE = `Usage: +export const CONFIG_USAGE = `Usage: skillhook config show effective config (defaults applied) skillhook config get <key> e.g. defaults.model skillhook config set <key> <value> @@ -52,13 +52,13 @@ export async function configCommand(ctx: Ctx): Promise<number> { return 0; } case "get": { - if (!key) throw new UsageError("Missing key", USAGE); + if (!key) throw new UsageError("Missing key", CONFIG_USAGE); const value = getPath(ctx.config(), key); ctx.print(typeof value === "string" ? value : JSON.stringify(value, null, 2), { key, value }); return 0; } case "set": { - if (!key || rest.length === 0) throw new UsageError("Usage: skillhook config set <key> <value>", USAGE); + if (!key || rest.length === 0) throw new UsageError("Usage: skillhook config set <key> <value>", CONFIG_USAGE); const value = coerceConfigValue(rest.join(" ")); const raw = setConfigValue(ctx.paths, key, value); const server = await notifyServer(ctx); @@ -66,7 +66,7 @@ export async function configCommand(ctx: Ctx): Promise<number> { return 0; } case "unset": { - if (!key) throw new UsageError("Missing key", USAGE); + if (!key) throw new UsageError("Missing key", CONFIG_USAGE); const raw = setConfigValue(ctx.paths, key, undefined); const server = await notifyServer(ctx); ctx.print(`Removed ${key} from ${ctx.paths.configFile}${describeReload(server)}`, { ok: true, key, config: raw, server: server ?? null }); @@ -83,6 +83,6 @@ export async function configCommand(ctx: Ctx): Promise<number> { ctx.print(ctx.paths.configFile, { file: ctx.paths.configFile, home: ctx.paths.home, raw: readRawConfig(ctx.paths), hot_keys: HOT_CONFIG_KEYS, restart_keys: RESTART_CONFIG_KEYS }); return 0; default: - throw new UsageError(`Unknown config subcommand "${sub}"`, USAGE); + throw new UsageError(`Unknown config subcommand "${sub}"`, CONFIG_USAGE); } } diff --git a/src/commands/deliveries.ts b/src/commands/deliveries.ts index ad36787..39b8eb8 100644 --- a/src/commands/deliveries.ts +++ b/src/commands/deliveries.ts @@ -2,7 +2,7 @@ import { DELIVERY_OUTCOMES, readDeliveryBody, type DeliveryOutcome } from "../de import { replayCommand } from "./replay.js"; import { bool, CommandError, num, relativeTime, str, table, UsageError, type Ctx } from "./shared.js"; -const USAGE = `Usage: +export const DELIVERIES_USAGE = `Usage: skillhook deliveries list [--skill NAME] [--outcome ${DELIVERY_OUTCOMES.join("|")}] [--since ISO] [--after ID] [--limit N] skillhook deliveries show <id> [--body] skillhook deliveries replay <id> [--force] [--skip-filters] [--runner R] [--model M] [--effort E] [--wait S] @@ -18,9 +18,9 @@ export async function deliveriesCommand(ctx: Ctx): Promise<number> { case "list": case "ls": { const outcome = str(ctx.flags, "outcome") as DeliveryOutcome | undefined; - if (outcome && !DELIVERY_OUTCOMES.includes(outcome)) throw new UsageError(`--outcome must be one of ${DELIVERY_OUTCOMES.join(", ")}`, USAGE); + if (outcome && !DELIVERY_OUTCOMES.includes(outcome)) throw new UsageError(`--outcome must be one of ${DELIVERY_OUTCOMES.join(", ")}`, DELIVERIES_USAGE); const since = str(ctx.flags, "since"); - if (since && Number.isNaN(Date.parse(since))) throw new UsageError("--since must be an ISO-8601 instant", USAGE); + if (since && Number.isNaN(Date.parse(since))) throw new UsageError("--since must be an ISO-8601 instant", DELIVERIES_USAGE); const page = log.list({ skill: str(ctx.flags, "skill"), outcome, since, after: str(ctx.flags, "after"), limit: num(ctx.flags, "limit") ?? 30 }); const rows = page.deliveries.map((d) => { const code = d.code && d.code !== d.outcome ? d.code : ""; @@ -33,7 +33,7 @@ export async function deliveriesCommand(ctx: Ctx): Promise<number> { } case "show": case "get": { - if (!id) throw new UsageError("Missing delivery id", USAGE); + if (!id) throw new UsageError("Missing delivery id", DELIVERIES_USAGE); const delivery = log.get(id); if (!delivery) throw new CommandError(`Unknown delivery ${id}`); const wantBody = bool(ctx.flags, "body"); @@ -57,8 +57,8 @@ export async function deliveriesCommand(ctx: Ctx): Promise<number> { } case "replay": case "rerun": - return replayCommand(ctx, "delivery", id, USAGE); + return replayCommand(ctx, "delivery", id, DELIVERIES_USAGE); default: - throw new UsageError(`Unknown deliveries subcommand "${sub}"`, USAGE); + throw new UsageError(`Unknown deliveries subcommand "${sub}"`, DELIVERIES_USAGE); } } diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index c4fdbc8..a2066f8 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,6 +1,11 @@ import { formatDoctor, runDoctor } from "../doctor.js"; import type { Ctx } from "./shared.js"; +export const DOCTOR_USAGE = `Usage: skillhook doctor + +Checks Node, disk, the config, secrets, skills, the claude/codex logins, Tailscale, the public URL, the server and the +service, with what to do about each problem. Exits 1 when a check fails; skillhook health adds the deep probes.`; + export async function doctorCommand(ctx: Ctx): Promise<number> { const report = await runDoctor(ctx.paths, { env: ctx.io.env }); ctx.print(formatDoctor(report), report); diff --git a/src/commands/expose.ts b/src/commands/expose.ts index 309e66d..1f37984 100644 --- a/src/commands/expose.ts +++ b/src/commands/expose.ts @@ -4,13 +4,15 @@ import { createOps, resolveBaseUrl, webhookUrl } from "../ops.js"; import { currentExposures, disableExposure, enableExposure, findTailscale, tailscaleStatus, type ExposureMode } from "../tailscale.js"; import { bool, CommandError, num, table, UsageError, type Ctx } from "./shared.js"; -const USAGE = `Usage: +export const EXPOSE_USAGE = `Usage: skillhook expose tailscale [--serve] [--port N] Funnel (public HTTPS, default) or Serve (tailnet only) skillhook expose status skillhook expose off skillhook expose cloudflare | ngrok print the recipe for other tunnels skillhook url [skill] print webhook URLs`; +export const URL_USAGE = "Usage: skillhook url [skill] [--public|--local] the webhook URLs of every skill (or one): public when exposed, else local"; + export async function exposeCommand(ctx: Ctx): Promise<number> { const [sub = "status"] = ctx.args; switch (sub) { @@ -29,7 +31,7 @@ export async function exposeCommand(ctx: Ctx): Promise<number> { case "ngrok": return recipe(ctx, "ngrok"); default: - throw new UsageError(`Unknown expose subcommand "${sub}"`, USAGE); + throw new UsageError(`Unknown expose subcommand "${sub}"`, EXPOSE_USAGE); } } diff --git a/src/commands/health.ts b/src/commands/health.ts index a4a1d71..46aa4f9 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -2,6 +2,15 @@ import { adminRequest, findRunningServer } from "../client.js"; import { formatHealth, runHealth, type HealthReport } from "../health.js"; import { bool, CommandError, type Ctx } from "./shared.js"; +export const HEALTH_USAGE = `Usage: skillhook health [--quick] [--refresh] [--no-network] [--local] + +The doctor's checks plus the MCP servers Claude Code and Codex know, plugins, codex doctor, disk and each skill's last +run, grouped. Exits 1 when a check fails. + --quick skip the deep probes (MCP servers, plugins, codex doctor) + --refresh probe again instead of the running server's cached report + --no-network skip the npm update check and the public URL probe + --local check in this process even when a server is running`; + /** * `skillhook health`: doctor plus the deep probes (MCP servers, plugins, `codex doctor`, disk, last runs), grouped. * Through the running server when there is one (its cached report, `--refresh` for a fresh one), otherwise in-process. diff --git a/src/commands/init.ts b/src/commands/init.ts index 2010f65..46743d4 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -7,7 +7,7 @@ import { generateSecret } from "../ids.js"; import { ensureDir } from "../util.js"; import { bool, num, str, UsageError, type Ctx } from "./shared.js"; -export const INIT_USAGE = "Usage: skillhook init [--dir PATH] [--runner claude|codex] [--model MODEL] [--port N] [--force]"; +export const INIT_USAGE = "Usage: skillhook init [--dir PATH] [--runner claude|codex|shell] [--model MODEL] [--port N] [--force]"; export async function initCommand(ctx: Ctx): Promise<number> { const { paths } = ctx; diff --git a/src/commands/job.ts b/src/commands/job.ts index f3526aa..0339e70 100644 --- a/src/commands/job.ts +++ b/src/commands/job.ts @@ -7,35 +7,43 @@ import path from "node:path"; import { addNote, askQuestion, DEFAULT_HUMAN_WAIT_SECONDS, MAX_HUMAN_WAIT_SECONDS, readProgress, recordOutcome, reportProgress, waitForAnswer } from "../progress.js"; import { REPORTABLE_OUTCOMES, RESPONSE_FILE, type JobOutcome, type JobResponse } from "../response.js"; import { readJsonFileOr, writeJsonFile } from "../util.js"; -import { jobsCommand } from "./jobs.js"; +import { jobsCommand, JOBS_USAGE } from "./jobs.js"; import { CommandError, list, num, str, UsageError, type Ctx } from "./shared.js"; -const USAGE = `Usage (inside a run; the job comes from $SKILLHOOK_JOB_ID and $SKILLHOOK_JOB_DIR, or --job <id>): +export const JOB_USAGE = `Usage (inside a run; the job comes from $SKILLHOOK_JOB_ID and $SKILLHOOK_JOB_DIR, or --job <id>): skillhook job progress "<what you are doing>" [--state working|blocked] [--percent N] [--step NAME] skillhook job ask "<question>" [--option A]... [--context TEXT] [--wait SECONDS] wait for a person's answer; prints JSON, exit 3 when none came skillhook job outcome <${REPORTABLE_OUTCOMES.join("|")}> [--summary TEXT] [--link URL]... [--data JSON] skillhook job note "<text>" - skillhook job context`; + skillhook job context + +Any other subcommand, or none, is the operator's skillhook jobs (skillhook jobs --help).`; const AGENT_SUBCOMMANDS = new Set(["progress", "ask", "outcome", "note", "context"]); /** `skillhook job ask` exits with this when the wait ends without an answer. */ export const NO_ANSWER_EXIT_CODE = 3; +/** What `skillhook job … --help` prints: this usage for the agent's subcommands (or none), the `jobs` usage for the rest. */ +export function jobUsage(args: string[]): string { + const [sub] = args; + return sub && !AGENT_SUBCOMMANDS.has(sub) ? JOBS_USAGE : JOB_USAGE; +} + export async function jobCommand(ctx: Ctx): Promise<number> { const [sub, first] = ctx.args; if (!sub || !AGENT_SUBCOMMANDS.has(sub)) return jobsCommand(ctx); const { id, dir } = resolveJob(ctx); switch (sub) { case "progress": { - if (!first) throw new UsageError("Missing the progress message", USAGE); + if (!first) throw new UsageError("Missing the progress message", JOB_USAGE); const state = str(ctx.flags, "state"); - if (state && state !== "working" && state !== "blocked") throw new UsageError("--state must be working or blocked", USAGE); + if (state && state !== "working" && state !== "blocked") throw new UsageError("--state must be working or blocked", JOB_USAGE); const progress = reportProgress(dir, { message: first, state: state as "working" | "blocked" | undefined, percent: num(ctx.flags, "percent"), step: str(ctx.flags, "step") }); ctx.print(`${progress.state}: ${progress.message}`, { ok: true, job_id: id, progress }); return 0; } case "ask": { - if (!first) throw new UsageError("Missing the question", USAGE); + if (!first) throw new UsageError("Missing the question", JOB_USAGE); const envWait = Number(ctx.io.env.SKILLHOOK_HUMAN_WAIT_SECONDS); const wait = Math.min(MAX_HUMAN_WAIT_SECONDS, Math.max(0, num(ctx.flags, "wait") ?? (Number.isFinite(envWait) && envWait > 0 ? envWait : DEFAULT_HUMAN_WAIT_SECONDS))); const question = askQuestion(dir, { text: first, options: list(ctx.flags, "option"), context: str(ctx.flags, "context"), waitSeconds: wait }); @@ -49,7 +57,7 @@ export async function jobCommand(ctx: Ctx): Promise<number> { } case "outcome": { const outcome = first as JobOutcome | undefined; - if (!outcome || !REPORTABLE_OUTCOMES.includes(outcome)) throw new UsageError(`The outcome must be one of ${REPORTABLE_OUTCOMES.join(", ")}`, USAGE); + if (!outcome || !REPORTABLE_OUTCOMES.includes(outcome)) throw new UsageError(`The outcome must be one of ${REPORTABLE_OUTCOMES.join(", ")}`, JOB_USAGE); const summary = str(ctx.flags, "summary") ?? ctx.args[2] ?? ""; const links = list(ctx.flags, "link"); let data: unknown; @@ -58,7 +66,7 @@ export async function jobCommand(ctx: Ctx): Promise<number> { try { data = JSON.parse(rawData) as unknown; } catch { - throw new UsageError("--data must be JSON", USAGE); + throw new UsageError("--data must be JSON", JOB_USAGE); } } const response: JobResponse = { outcome, summary, ...(links.length ? { links } : {}), ...(data !== undefined ? { data } : {}) }; @@ -69,7 +77,7 @@ export async function jobCommand(ctx: Ctx): Promise<number> { return 0; } case "note": { - if (!first) throw new UsageError("Missing the note", USAGE); + if (!first) throw new UsageError("Missing the note", JOB_USAGE); const entry = addNote(dir, first); ctx.print(`Noted: ${first}`, { ok: true, job_id: id, entry }); return 0; @@ -97,7 +105,7 @@ export async function jobCommand(ctx: Ctx): Promise<number> { return 0; } default: - throw new UsageError(`Unknown job subcommand "${sub}"`, USAGE); + throw new UsageError(`Unknown job subcommand "${sub}"`, JOB_USAGE); } } @@ -108,7 +116,7 @@ function resolveJob(ctx: Ctx): { id: string; dir: string } { const envDir = ctx.io.env.SKILLHOOK_JOB_DIR; if (!flagId && envId && envDir && existsSync(envDir)) return { id: envId, dir: envDir }; const id = flagId ?? envId; - if (!id) throw new UsageError("Not inside a skillhook run: SKILLHOOK_JOB_ID and SKILLHOOK_JOB_DIR are not set. Pass --job <id> to address a job by id.", USAGE); + if (!id) throw new UsageError("Not inside a skillhook run: SKILLHOOK_JOB_ID and SKILLHOOK_JOB_DIR are not set. Pass --job <id> to address a job by id.", JOB_USAGE); const dir = ctx.store().pathsFor(id).dir; if (!existsSync(dir)) throw new CommandError(`Unknown job ${id}`); return { id, dir }; diff --git a/src/commands/jobs.ts b/src/commands/jobs.ts index 16480fb..f117008 100644 --- a/src/commands/jobs.ts +++ b/src/commands/jobs.ts @@ -14,7 +14,7 @@ import { sleep } from "../util.js"; import { replayCommand } from "./replay.js"; import { bool, CommandError, formatDuration, num, relativeTime, str, table, UsageError, type Ctx } from "./shared.js"; -const USAGE = `Usage: +export const JOBS_USAGE = `Usage: skillhook jobs list [--skill NAME] [--status ${JOB_STATUSES.join("|")}] [--outcome ${JOB_OUTCOMES.join("|")}] [--failure ${FAILURE_KINDS.join("|")}] [--trigger ${TRIGGERS.join("|")}] [--waiting] [--since ISO] [--after ID] [--limit N] skillhook jobs show <id> [--result] [--response] [--prompt] [--stdout] [--stderr] skillhook jobs logs <id> [--follow|-f] [--stderr] @@ -32,15 +32,15 @@ export async function jobsCommand(ctx: Ctx): Promise<number> { case "list": case "ls": { const status = str(ctx.flags, "status") as JobStatus | undefined; - if (status && !JOB_STATUSES.includes(status)) throw new UsageError(`--status must be one of ${JOB_STATUSES.join(", ")}`, USAGE); + if (status && !JOB_STATUSES.includes(status)) throw new UsageError(`--status must be one of ${JOB_STATUSES.join(", ")}`, JOBS_USAGE); const trigger = str(ctx.flags, "trigger") as Trigger | undefined; - if (trigger && !TRIGGERS.includes(trigger)) throw new UsageError(`--trigger must be one of ${TRIGGERS.join(", ")}`, USAGE); + if (trigger && !TRIGGERS.includes(trigger)) throw new UsageError(`--trigger must be one of ${TRIGGERS.join(", ")}`, JOBS_USAGE); const outcome = str(ctx.flags, "outcome") as JobOutcome | undefined; - if (outcome && !JOB_OUTCOMES.includes(outcome)) throw new UsageError(`--outcome must be one of ${JOB_OUTCOMES.join(", ")}`, USAGE); + if (outcome && !JOB_OUTCOMES.includes(outcome)) throw new UsageError(`--outcome must be one of ${JOB_OUTCOMES.join(", ")}`, JOBS_USAGE); const since = str(ctx.flags, "since"); - if (since && Number.isNaN(Date.parse(since))) throw new UsageError("--since must be an ISO-8601 instant", USAGE); + if (since && Number.isNaN(Date.parse(since))) throw new UsageError("--since must be an ISO-8601 instant", JOBS_USAGE); const failure = str(ctx.flags, "failure") as FailureKind | undefined; - if (failure && !FAILURE_KINDS.includes(failure)) throw new UsageError(`--failure must be one of ${FAILURE_KINDS.join(", ")}`, USAGE); + if (failure && !FAILURE_KINDS.includes(failure)) throw new UsageError(`--failure must be one of ${FAILURE_KINDS.join(", ")}`, JOBS_USAGE); const waiting = bool(ctx.flags, "waiting") || undefined; const page = store.listPage({ skill: str(ctx.flags, "skill"), status, trigger, outcome, failure, waiting, since, after: str(ctx.flags, "after"), limit: num(ctx.flags, "limit") ?? 30 }); const jobs = page.jobs; @@ -89,7 +89,7 @@ export async function jobsCommand(ctx: Ctx): Promise<number> { const job = store.get(requireId(id)); if (!job) throw new CommandError(`Unknown job ${id}`); const text = ctx.args[2]; - if (!text?.trim()) throw new UsageError("Missing the answer text", USAGE); + if (!text?.trim()) throw new UsageError("Missing the answer text", JOBS_USAGE); const option = str(ctx.flags, "option"); const by = str(ctx.flags, "by") ?? ctx.io.env.USER; const resume: "auto" | "never" = ctx.flags.resume === false ? "never" : "auto"; @@ -186,7 +186,7 @@ export async function jobsCommand(ctx: Ctx): Promise<number> { } case "replay": case "rerun": - return replayCommand(ctx, "job", id, USAGE); + return replayCommand(ctx, "job", id, JOBS_USAGE); case "resume": { const job = store.get(requireId(id)); if (!job) throw new CommandError(`Unknown job ${id}`); @@ -211,12 +211,12 @@ export async function jobsCommand(ctx: Ctx): Promise<number> { return 0; } default: - throw new UsageError(`Unknown jobs subcommand "${sub}"`, USAGE); + throw new UsageError(`Unknown jobs subcommand "${sub}"`, JOBS_USAGE); } } function requireId(id: string | undefined): string { - if (!id) throw new UsageError("Missing job id", USAGE); + if (!id) throw new UsageError("Missing job id", JOBS_USAGE); return id; } diff --git a/src/commands/main.ts b/src/commands/main.ts index 2de61da..6d3487f 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -2,28 +2,28 @@ import { ConfigError } from "../config.js"; import { SkillError } from "../skills.js"; import { errorMessage } from "../util.js"; import { VERSION } from "../version.js"; -import { CommandError, createCtx, parseArgs, UsageError, type CliIO, type Ctx } from "./shared.js"; -import { initCommand } from "./init.js"; -import { serveCommand } from "./serve.js"; -import { skillsCommand } from "./skills.js"; -import { secretCommand } from "./secret.js"; -import { runCommand } from "./run.js"; -import { sendCommand } from "./send.js"; -import { healthCommand } from "./health.js"; -import { jobCommand } from "./job.js"; -import { jobsCommand } from "./jobs.js"; -import { runnersCommand } from "./runners.js"; -import { statsCommand } from "./stats.js"; -import { deliveriesCommand } from "./deliveries.js"; -import { exposeCommand, urlCommand } from "./expose.js"; -import { serviceCommand } from "./service.js"; -import { doctorCommand } from "./doctor.js"; -import { cloudCommand } from "./cloud.js"; -import { configCommand } from "./config.js"; -import { mcpCommand } from "./mcp.js"; -import { updateCommand } from "./update.js"; -import { linkCommand, projectsCommand, unlinkCommand } from "./projects.js"; -import { schedulesCommand } from "./schedules.js"; +import { bool, CommandError, createCtx, parseArgs, UsageError, type CliIO, type Ctx } from "./shared.js"; +import { initCommand, INIT_USAGE } from "./init.js"; +import { serveCommand, SERVE_USAGE } from "./serve.js"; +import { skillsCommand, SKILLS_USAGE } from "./skills.js"; +import { secretCommand, SECRET_USAGE } from "./secret.js"; +import { runCommand, RUN_USAGE } from "./run.js"; +import { sendCommand, SEND_USAGE } from "./send.js"; +import { healthCommand, HEALTH_USAGE } from "./health.js"; +import { jobCommand, jobUsage } from "./job.js"; +import { jobsCommand, JOBS_USAGE } from "./jobs.js"; +import { runnersCommand, RUNNERS_USAGE } from "./runners.js"; +import { statsCommand, STATS_USAGE } from "./stats.js"; +import { deliveriesCommand, DELIVERIES_USAGE } from "./deliveries.js"; +import { exposeCommand, EXPOSE_USAGE, urlCommand, URL_USAGE } from "./expose.js"; +import { serviceCommand, SERVICE_USAGE } from "./service.js"; +import { doctorCommand, DOCTOR_USAGE } from "./doctor.js"; +import { cloudCommand, CLOUD_USAGE } from "./cloud.js"; +import { configCommand, CONFIG_USAGE } from "./config.js"; +import { mcpCommand, MCP_USAGE } from "./mcp.js"; +import { updateCommand, UPDATE_USAGE } from "./update.js"; +import { linkCommand, projectsCommand, PROJECTS_USAGE, unlinkCommand } from "./projects.js"; +import { schedulesCommand, SCHEDULES_USAGE } from "./schedules.js"; import { planUpdateNotice, spawnBackgroundRefresh } from "../update.js"; import { readJsonFileOr } from "../util.js"; @@ -76,45 +76,55 @@ Agents Read the organisation's machines and jobs with an organisation API key (never the machine token) Global options: --dir <path> (default $SKILLHOOK_HOME or ~/.skillhook), --json, --help, --version -Each subcommand prints its own usage on a mistake. Docs: https://github.com/MeterApp/skillhook +skillhook <command> --help (or skillhook help <command>) prints its usage and runs nothing; a mistake prints it too. +Docs: https://github.com/MeterApp/skillhook `; -type Command = (ctx: Ctx) => Promise<number | void> | number | void; - -const COMMANDS: Record<string, Command> = { - init: initCommand, - serve: serveCommand, - skills: skillsCommand, - skill: skillsCommand, - secret: secretCommand, - secrets: secretCommand, - run: runCommand, - send: sendCommand, - jobs: jobsCommand, - job: jobCommand, - deliveries: deliveriesCommand, - delivery: deliveriesCommand, - expose: exposeCommand, - url: urlCommand, - urls: urlCommand, - service: serviceCommand, - doctor: doctorCommand, - health: healthCommand, - runners: runnersCommand, - stats: statsCommand, - config: configCommand, - cloud: cloudCommand, - mcp: mcpCommand, - update: updateCommand, - upgrade: updateCommand, - link: linkCommand, - unlink: unlinkCommand, - projects: projectsCommand, - project: projectsCommand, - schedules: schedulesCommand, - schedule: schedulesCommand, +export interface Command { + run: (ctx: Ctx) => Promise<number | void> | number | void; + /** What `skillhook <command> … --help` prints instead of running it; a function of the subcommand when that changes it. */ + usage: string | ((args: string[]) => string); +} + +export const COMMANDS: Record<string, Command> = { + init: { run: initCommand, usage: INIT_USAGE }, + serve: { run: serveCommand, usage: SERVE_USAGE }, + skills: { run: skillsCommand, usage: SKILLS_USAGE }, + skill: { run: skillsCommand, usage: SKILLS_USAGE }, + secret: { run: secretCommand, usage: SECRET_USAGE }, + secrets: { run: secretCommand, usage: SECRET_USAGE }, + run: { run: runCommand, usage: RUN_USAGE }, + send: { run: sendCommand, usage: SEND_USAGE }, + jobs: { run: jobsCommand, usage: JOBS_USAGE }, + job: { run: jobCommand, usage: jobUsage }, + deliveries: { run: deliveriesCommand, usage: DELIVERIES_USAGE }, + delivery: { run: deliveriesCommand, usage: DELIVERIES_USAGE }, + expose: { run: exposeCommand, usage: EXPOSE_USAGE }, + url: { run: urlCommand, usage: URL_USAGE }, + urls: { run: urlCommand, usage: URL_USAGE }, + service: { run: serviceCommand, usage: SERVICE_USAGE }, + doctor: { run: doctorCommand, usage: DOCTOR_USAGE }, + health: { run: healthCommand, usage: HEALTH_USAGE }, + runners: { run: runnersCommand, usage: RUNNERS_USAGE }, + stats: { run: statsCommand, usage: STATS_USAGE }, + config: { run: configCommand, usage: CONFIG_USAGE }, + cloud: { run: cloudCommand, usage: CLOUD_USAGE }, + mcp: { run: mcpCommand, usage: MCP_USAGE }, + update: { run: updateCommand, usage: UPDATE_USAGE }, + upgrade: { run: updateCommand, usage: UPDATE_USAGE }, + link: { run: linkCommand, usage: PROJECTS_USAGE }, + unlink: { run: unlinkCommand, usage: PROJECTS_USAGE }, + projects: { run: projectsCommand, usage: PROJECTS_USAGE }, + project: { run: projectsCommand, usage: PROJECTS_USAGE }, + schedules: { run: schedulesCommand, usage: SCHEDULES_USAGE }, + schedule: { run: schedulesCommand, usage: SCHEDULES_USAGE }, }; +/** The usage `skillhook <name> [args…] --help` prints. */ +export function usageOf(command: Command, args: string[]): string { + return typeof command.usage === "function" ? command.usage(args) : command.usage; +} + /** Commands whose output must stay clean, or that handle update checks themselves. */ const NO_UPDATE_NOTICE = new Set(["serve", "mcp", "update", "upgrade", "version", "help"]); @@ -156,14 +166,16 @@ export async function main(argv: string[], io: CliIO = defaultIO()): Promise<num return 1; } const { flags, positionals } = parseArgs(argv); - const [name, ...rest] = positionals; + // `skillhook help jobs` is `skillhook jobs --help`. + const help = bool(flags, "help", "h") || positionals[0] === "help"; + const [name, ...rest] = positionals[0] === "help" ? positionals.slice(1) : positionals; if (flags.version === true || flags.v === true || name === "version") { io.stdout(`${VERSION}\n`); return 0; } - if (!name || name === "help" || ((flags.help === true || flags.h === true) && !name)) { + if (!name || name === "help") { io.stdout(HELP); - return name || flags.help === true || flags.h === true ? 0 : 1; + return help ? 0 : 1; } const command = COMMANDS[name]; if (!command) { @@ -171,8 +183,14 @@ export async function main(argv: string[], io: CliIO = defaultIO()): Promise<num return 1; } const ctx = createCtx(flags, rest, io); + if (help) { + // Checked here, before any command code runs, so no command can forget it: `jobs prune --help` must not prune. + const usage = usageOf(command, rest); + ctx.print(usage, { ok: true, command: name, usage }); + return 0; + } try { - const code = await command(ctx); + const code = await command.run(ctx); noticeUpdate(ctx, name); return typeof code === "number" ? code : 0; } catch (error) { diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 5abae93..c4d3340 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -6,6 +6,13 @@ import { which } from "../tailscale.js"; import { PACKAGE } from "../version.js"; import { bool, CommandError, str, type Ctx } from "./shared.js"; +export const MCP_USAGE = `Usage: skillhook mcp [--print-config] + skillhook mcp --job [ID] + +Serves skillhook's tools over stdio to Claude Code, Codex, Cursor and other MCP clients; --print-config prints the +commands and the JSON that register it. --job serves one run's job API instead (progress, questions, the outcome): +the runners start it with $SKILLHOOK_JOB_ID and $SKILLHOOK_JOB_DIR set.`; + export async function mcpCommand(ctx: Ctx): Promise<number> { if (ctx.flags.job !== undefined) { // The runners start this for every run (`--mcp-config` / `mcp_servers.skillhook_job`) with the job in the environment. diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 4ffd61d..1a26b62 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -4,7 +4,7 @@ import { skillSummary } from "../server.js"; import { displayPath } from "../util.js"; import { bool, CommandError, UsageError, type Ctx } from "./shared.js"; -const USAGE = `Usage: +export const PROJECTS_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 <dir> stop serving its hooks (the repository is not touched) skillhook projects [list] linked projects and their hooks @@ -31,7 +31,7 @@ export async function projectsCommand(ctx: Ctx): Promise<number> { case "init": return init(ctx, target); default: - throw new UsageError(`Unknown projects subcommand "${sub}"`, USAGE); + throw new UsageError(`Unknown projects subcommand "${sub}"`, PROJECTS_USAGE); } } @@ -123,7 +123,7 @@ async function link(ctx: Ctx, target: string | undefined): Promise<number> { } async function unlink(ctx: Ctx, target: string | undefined): Promise<number> { - if (!target) throw new UsageError("Missing project directory", USAGE); + if (!target) throw new UsageError("Missing project directory", PROJECTS_USAGE); const ops = createOps(ctx.paths, { env: ctx.io.env }); const { unlinkProject } = await import("../ops.js"); const result = unlinkProject(ops, target); diff --git a/src/commands/run.ts b/src/commands/run.ts index 53965da..84c9c8f 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -10,7 +10,7 @@ import { formatCommand } from "../runners/types.js"; import type { Skill } from "../skills.js"; import { bool, CommandError, formatDuration, list, num, parseHeaderFlags, readPayloadArg, str, UsageError, type Ctx } from "./shared.js"; -const USAGE = `Usage: skillhook run <skill> [--payload JSON|@file|-] [--header "Name: value"]... [--runner claude|codex|shell] +export const RUN_USAGE = `Usage: skillhook run <skill> [--payload JSON|@file|-] [--header "Name: value"]... [--runner claude|codex|shell] [--model M] [--effort E] [--cwd DIR] [--wait S] [--dry-run] [--json] skillhook run --file SKILL.md | --stdin [same options] @@ -22,11 +22,11 @@ export async function runCommand(ctx: Ctx): Promise<number> { const [name] = ctx.args; const file = str(ctx.flags, "file"); const fromStdin = bool(ctx.flags, "stdin"); - if (!name && !file && !fromStdin) throw new UsageError("Missing skill name (or --file SKILL.md / --stdin)", USAGE); - if (name && (file || fromStdin)) throw new UsageError("Give a skill name or --file/--stdin, not both", USAGE); - if (file && fromStdin) throw new UsageError("--file and --stdin exclude each other", USAGE); + if (!name && !file && !fromStdin) throw new UsageError("Missing skill name (or --file SKILL.md / --stdin)", RUN_USAGE); + if (name && (file || fromStdin)) throw new UsageError("Give a skill name or --file/--stdin, not both", RUN_USAGE); + if (file && fromStdin) throw new UsageError("--file and --stdin exclude each other", RUN_USAGE); const runner = str(ctx.flags, "runner"); - if (runner && !RunnerNameSchema.safeParse(runner).success) throw new UsageError("--runner must be claude, codex or shell", USAGE); + if (runner && !RunnerNameSchema.safeParse(runner).success) throw new UsageError("--runner must be claude, codex or shell", RUN_USAGE); const ops = createOps(ctx.paths, { env: ctx.io.env, logger: ctx.json ? undefined : createLogger({ format: "pretty", level: "warn" }) }); let skillMd: string | undefined; if (file) { @@ -41,7 +41,7 @@ export async function runCommand(ctx: Ctx): Promise<number> { const installed = skillMd === undefined ? ops.registry.get(name as string) : undefined; if (skillMd === undefined && !installed) throw new CommandError(`No skill named "${name}" in ${ctx.paths.skillsDir}`); const payloadArg = str(ctx.flags, "payload", "p"); - if (fromStdin && payloadArg === "-") throw new UsageError("--payload - cannot be combined with --stdin (both read standard input)", USAGE); + if (fromStdin && payloadArg === "-") throw new UsageError("--payload - cannot be combined with --stdin (both read standard input)", RUN_USAGE); const { payload } = await readPayloadArg(ctx, payloadArg); const headers = parseHeaderFlags(list(ctx.flags, "header", "H")); const overrides = { runner: runner as RunnerName | undefined, model: str(ctx.flags, "model"), effort: str(ctx.flags, "effort"), cwd: str(ctx.flags, "cwd") }; diff --git a/src/commands/runners.ts b/src/commands/runners.ts index 5ce23f7..c06b874 100644 --- a/src/commands/runners.ts +++ b/src/commands/runners.ts @@ -4,6 +4,12 @@ import { checkReadiness, RUNNER_NAMES, type RunnerReadiness } from "../readiness import { baseRunEnv } from "../runners/env.js"; import { bool, CommandError, table, type Ctx } from "./shared.js"; +export const RUNNERS_USAGE = `Usage: skillhook runners [--refresh] [--local] + +Whether claude, codex and shell are installed and logged in: what every job checks before it starts. Asks the running +server when there is one (--refresh probes again, --local checks in this process). Exits 1 when the default runner is +not ready.`; + /** `skillhook runners`: is each runner installed and logged in, as a job checks before it starts. */ export async function runnersCommand(ctx: Ctx): Promise<number> { const refresh = bool(ctx.flags, "refresh"); diff --git a/src/commands/schedules.ts b/src/commands/schedules.ts index cd09297..23c3f25 100644 --- a/src/commands/schedules.ts +++ b/src/commands/schedules.ts @@ -4,7 +4,7 @@ import { nextRuns } from "../schedule.js"; import { buildSchedulePayload, listSchedules, type ScheduleStatus } from "../scheduler.js"; import { CommandError, num, relativeTime, table, UsageError, type Ctx } from "./shared.js"; -const USAGE = `Usage: +export const SCHEDULES_USAGE = `Usage: skillhook schedules list every skill or hook with a schedule: cron, zone, next and last run skillhook schedules next <name> [--count N] the next N occurrences (default 5) skillhook schedules run <name> [--wait S] fire a scheduled skill now, with the payload a scheduled run gets`; @@ -21,12 +21,12 @@ export async function schedulesCommand(ctx: Ctx): Promise<number> { case "fire": return runCommand(ctx, requireName(name)); default: - throw new UsageError(`Unknown schedules subcommand "${sub}"`, USAGE); + throw new UsageError(`Unknown schedules subcommand "${sub}"`, SCHEDULES_USAGE); } } function requireName(name: string | undefined): string { - if (!name) throw new UsageError("Missing skill name", USAGE); + if (!name) throw new UsageError("Missing skill name", SCHEDULES_USAGE); return name; } diff --git a/src/commands/secret.ts b/src/commands/secret.ts index d7a40e7..f48b51b 100644 --- a/src/commands/secret.ts +++ b/src/commands/secret.ts @@ -2,7 +2,7 @@ import { readEnvFile, redactValue, removeEnvVar } from "../env.js"; import { createOps, generateSecretFor, resolveSecretName, setSecret } from "../ops.js"; import { bool, CommandError, num, str, table, UsageError, type Ctx } from "./shared.js"; -const USAGE = `Usage: +export const SECRET_USAGE = `Usage: skillhook secret set <NAME|skill|admin> [--value VALUE | --stdin] store a provider's signing secret skillhook secret generate <NAME|skill|admin> [--force] [--bytes N] create a random secret (printed once) skillhook secret list @@ -22,7 +22,7 @@ export async function secretCommand(ctx: Ctx): Promise<number> { return 0; } case "set": { - if (!name) throw new UsageError("Missing name", USAGE); + if (!name) throw new UsageError("Missing name", SECRET_USAGE); let value = str(ctx.flags, "value"); if (value === undefined) { if (bool(ctx.flags, "stdin") || !ctx.io.isTTY) value = (ctx.io.stdin ? await ctx.io.stdin() : (await import("node:fs")).readFileSync(0, "utf8")).replace(/\r?\n$/, ""); @@ -36,7 +36,7 @@ export async function secretCommand(ctx: Ctx): Promise<number> { case "generate": case "gen": case "rotate": { - if (!name) throw new UsageError("Missing name", USAGE); + if (!name) throw new UsageError("Missing name", SECRET_USAGE); const result = generateSecretFor(ops, name, { force: bool(ctx.flags, "force") || sub === "rotate", bytes: num(ctx.flags, "bytes") }); if (!result.generated) { ctx.print(`${result.env} already exists; pass --force to replace it.`, { ok: true, env: result.env, existed: true }); @@ -48,14 +48,14 @@ export async function secretCommand(ctx: Ctx): Promise<number> { case "unset": case "rm": case "remove": { - if (!name) throw new UsageError("Missing name", USAGE); + if (!name) throw new UsageError("Missing name", SECRET_USAGE); const { env } = resolveSecretName(ops, name); const removed = removeEnvVar(ctx.paths.envFile, env); ctx.print(removed ? `Removed ${env}` : `${env} was not set`, { ok: true, env, removed }); return 0; } default: - throw new UsageError(`Unknown secret subcommand "${sub}"`, USAGE); + throw new UsageError(`Unknown secret subcommand "${sub}"`, SECRET_USAGE); } } diff --git a/src/commands/send.ts b/src/commands/send.ts index 0ea9900..0ae137e 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -1,7 +1,7 @@ import { createOps, resolveBaseUrl, sendSignedWebhook } from "../ops.js"; import { bool, CommandError, list, num, parseHeaderFlags, readPayloadArg, str, UsageError, type Ctx } from "./shared.js"; -const USAGE = `Usage: skillhook send <skill> [--payload JSON|@file|-] [--wait SECONDS] [--url BASE_URL | --public | --local] +export const SEND_USAGE = `Usage: skillhook send <skill> [--payload JSON|@file|-] [--wait SECONDS] [--url BASE_URL | --public | --local] [--header "Name: value"]... [--json] Signs the payload the way the skill's auth expects (bearer, HMAC, Standard Webhooks, …) and POSTs it to @@ -9,7 +9,7 @@ Signs the payload the way the skill's auth expects (bearer, HMAC, Standard Webho export async function sendCommand(ctx: Ctx): Promise<number> { const [name] = ctx.args; - if (!name) throw new UsageError("Missing skill name", USAGE); + if (!name) throw new UsageError("Missing skill name", SEND_USAGE); const ops = createOps(ctx.paths, { env: ctx.io.env }); const skill = ops.registry.get(name); if (!skill) throw new CommandError(`No skill named "${name}"`); diff --git a/src/commands/serve.ts b/src/commands/serve.ts index 9384472..090fe05 100644 --- a/src/commands/serve.ts +++ b/src/commands/serve.ts @@ -18,6 +18,11 @@ import { checkForUpdate, detectInstall, releaseNotesUrl, UPDATE_CHECK_INTERVAL_M import { VERSION } from "../version.js"; import { bool, num, str, type Ctx } from "./shared.js"; +export const SERVE_USAGE = `Usage: skillhook serve [--port N] [--host H] [--log-level debug|info|warn|error] [--pretty] + +Runs the webhook server in the foreground until it is stopped (skillhook service install runs it at login instead). +--port and --host override skillhook.json (default 127.0.0.1:8787); logs are JSON lines, readable with --pretty or at a terminal.`; + export async function serveCommand(ctx: Ctx): Promise<number> { const events = new Events(); // One live config object for everything in this process; reloads patch it in place (see ConfigRef). diff --git a/src/commands/service.ts b/src/commands/service.ts index e6fea0f..c97ff24 100644 --- a/src/commands/service.ts +++ b/src/commands/service.ts @@ -2,7 +2,7 @@ import { installService, readServiceLog, restartService, serviceStatus, uninstal import { sleep } from "../util.js"; import { bool, num, UsageError, type Ctx } from "./shared.js"; -const USAGE = `Usage: +export const SERVICE_USAGE = `Usage: skillhook service install run \`skillhook serve\` at login and keep it alive (launchd on macOS, systemd --user on Linux) skillhook service uninstall skillhook service status @@ -59,6 +59,6 @@ export async function serviceCommand(ctx: Ctx): Promise<number> { return 0; } default: - throw new UsageError(`Unknown service subcommand "${sub}"`, USAGE); + throw new UsageError(`Unknown service subcommand "${sub}"`, SERVICE_USAGE); } } diff --git a/src/commands/skills.ts b/src/commands/skills.ts index 9fe33ad..d3f10de 100644 --- a/src/commands/skills.ts +++ b/src/commands/skills.ts @@ -7,7 +7,7 @@ import { AUTH_TYPES, describeAuth, type AuthType, type Skill } from "../skills.j import { displayPath } from "../util.js"; import { bool, CommandError, list, num, str, table, UsageError, type Ctx } from "./shared.js"; -const USAGE = `Usage: +export const SKILLS_USAGE = `Usage: skillhook skills list skillhook skills show <name> skillhook skills new <name> [--description TEXT] [--runner claude|codex|shell] [--model M] [--effort E] @@ -41,12 +41,12 @@ export async function skillsCommand(ctx: Ctx): Promise<number> { case "dir": return skillPath(ctx, requireName(name)); default: - throw new UsageError(`Unknown skills subcommand "${sub}"`, USAGE); + throw new UsageError(`Unknown skills subcommand "${sub}"`, SKILLS_USAGE); } } function requireName(name: string | undefined): string { - if (!name) throw new UsageError("Missing skill name", USAGE); + if (!name) throw new UsageError("Missing skill name", SKILLS_USAGE); return name; } @@ -102,9 +102,9 @@ async function showSkill(ctx: Ctx, name: string): Promise<number> { async function newSkill(ctx: Ctx, name: string): Promise<number> { const ops = createOps(ctx.paths, { env: ctx.io.env }); const runner = str(ctx.flags, "runner"); - if (runner && !RunnerNameSchema.safeParse(runner).success) throw new UsageError("--runner must be claude, codex or shell", USAGE); + if (runner && !RunnerNameSchema.safeParse(runner).success) throw new UsageError("--runner must be claude, codex or shell", SKILLS_USAGE); const authType = str(ctx.flags, "auth") as AuthType | undefined; - if (authType && !AUTH_TYPES.includes(authType)) throw new UsageError(`--auth must be one of ${AUTH_TYPES.join(", ")}`, USAGE); + if (authType && !AUTH_TYPES.includes(authType)) throw new UsageError(`--auth must be one of ${AUTH_TYPES.join(", ")}`, SKILLS_USAGE); const result = createSkill(ops, { name, description: str(ctx.flags, "description", "d") ?? `${name} skill (edit the description in SKILL.md)`, diff --git a/src/commands/stats.ts b/src/commands/stats.ts index 292dd19..603159a 100644 --- a/src/commands/stats.ts +++ b/src/commands/stats.ts @@ -1,17 +1,17 @@ import { collectStats, formatStats, parseSince } from "../stats.js"; import { str, UsageError, type Ctx } from "./shared.js"; -const USAGE = `Usage: +export const STATS_USAGE = `Usage: skillhook stats [--since 24h|7d|2w|ISO] [--until ISO] [--skill NAME] jobs by status, outcome, runner and failure kind; durations, cost, tokens; deliveries by outcome; per skill`; /** `skillhook stats`: numbers over the job directories and the delivery log on this machine. */ export async function statsCommand(ctx: Ctx): Promise<number> { const sinceRaw = str(ctx.flags, "since"); const since = parseSince(sinceRaw); - if (sinceRaw && !since) throw new UsageError("--since must be like 24h, 7d, 2w or an ISO-8601 instant", USAGE); + if (sinceRaw && !since) throw new UsageError("--since must be like 24h, 7d, 2w or an ISO-8601 instant", STATS_USAGE); const untilRaw = str(ctx.flags, "until"); const until = parseSince(untilRaw); - if (untilRaw && !until) throw new UsageError("--until must be an ISO-8601 instant", USAGE); + if (untilRaw && !until) throw new UsageError("--until must be an ISO-8601 instant", STATS_USAGE); const report = collectStats(ctx.store(), ctx.deliveryLog(), { since, until, skill: str(ctx.flags, "skill") }); ctx.print(formatStats(report), report); return 0; diff --git a/src/commands/update.ts b/src/commands/update.ts index bef5c17..3d83d69 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -13,10 +13,6 @@ SKILLHOOK_NO_UPDATE_CHECK=1, CI=1, or "update_check": false in skillhook.json; p SKILLHOOK_NPM_REGISTRY.`; export async function updateCommand(ctx: Ctx): Promise<number> { - if (bool(ctx.flags, "help", "h")) { - ctx.io.stdout(`${UPDATE_USAGE}\n`); - return 0; - } if (bool(ctx.flags, "refresh")) { // Spawned in the background by other commands; only refreshes the cache. await checkForUpdate(ctx.paths, { env: ctx.io.env, force: true, timeoutMs: 15_000 }); From d75a63964725b3aeadb5d7d8d76453ff0a78ebc1 Mon Sep 17 00:00:00 2001 From: Jonathan <jonathan.osacky@gmail.com> Date: Tue, 29 Sep 2026 12:30:54 -0400 Subject: [PATCH 2/2] Say exactly what health --quick skips in its usage Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/commands/health.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/health.ts b/src/commands/health.ts index 46aa4f9..de9ca2e 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -4,9 +4,9 @@ import { bool, CommandError, type Ctx } from "./shared.js"; export const HEALTH_USAGE = `Usage: skillhook health [--quick] [--refresh] [--no-network] [--local] -The doctor's checks plus the MCP servers Claude Code and Codex know, plugins, codex doctor, disk and each skill's last -run, grouped. Exits 1 when a check fails. - --quick skip the deep probes (MCP servers, plugins, codex doctor) +The doctor's checks plus the MCP servers Claude Code and Codex know, plugins, codex doctor and each skill's last run, +grouped. Exits 1 when a check fails. + --quick skip those deep checks (MCP servers, plugins, codex doctor, last runs) --refresh probe again instead of the running server's cached report --no-network skip the npm update check and the public URL probe --local check in this process even when a server is running`;