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..de9ca2e 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 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`; + /** * `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 });