npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@getvhs/vhscli

v0.1.21

Published

generate images, videos, and audio with ai

Readme

vhscli

vhscli is a small CLI for sending image, video, audio, and chat jobs to VHS.

The client handles auth, input uploads, task creation, polling, and writing output files. Model execution stays on the VHS service, so users do not need provider API keys on their machine.

Quick Start

Run without installing:

npx @getvhs/vhscli@latest login
npx @getvhs/vhscli@latest models
npx @getvhs/vhscli@latest generate seedream-5 "a corgi astronaut riding a bicycle on mars" -o corgi.jpg

The installed binary is vhscli:

vhscli login
vhscli models
vhscli generate gpt-image-2 "a clean app icon for a video tool" -o icon.png

Use - when the prompt should come from stdin:

cat prompt.txt | vhscli generate gpt-image-2 - -o art.png
cat question.txt | vhscli chat - -f paper.pdf

Requirements

  • Node.js 24 or newer
  • ffmpeg on PATH when the requested output format differs from the source (images, video and audio all convert through it)
  • ffprobe on PATH whenever a local file is uploaded as a reference: every reference image and video is probed for its dimensions before upload (-i, -v, --first-frame, --last-frame, and chat's -i/-v), and seed-audio-1 reference clips are probed for duration (≤30s)

vhscli bundles no binaries: both tools are resolved from PATH. (VHS Studio, which embeds this CLI, puts its own copies first on the PATH of the process it spawns, so a Studio install needs nothing installed separately.)

Auth

vhscli login
vhscli whoami
vhscli logout

login opens the browser, listens for the OAuth callback locally, writes the session to ~/.vhs/session.json, and initializes the user's billing account on first login.

whoami prints the session email, falling back to the user id. It reads the saved session locally and decodes it — no server round trip, no token refresh — so it answers in milliseconds and is safe to call at startup. That also means it reports the identity in a saved token that may since have expired; whether a request will go through is what running the request tells you.

logout deletes the local session file.

Commands that need auth will start login if no valid session exists.

Where a task lives

Nowhere on your machine. A generation is a row in the VHS backend, and the task id is the only handle to it — the CLI writes no database, no sidecar files, and no config beyond the session token.

That is what makes a task portable: submit on one machine, resume on another, days later, with only the id passed between them. It also means the CLI has no notion of a "project" or working directory. Every path you give it means what it would mean to any other command run from the same place.

Two consequences worth knowing:

  • Keep the id submit prints. Losing it strands a task you have paid for. Use --task-id to choose the id yourself when you need to record it before submitting (see below).
  • resume needs -o as well as the id. The task knows which model ran and what was asked; only you know where the file should go. (Same for save, which is resume without the waiting.)

Commands

vhscli models

Prints the model aliases known to the CLI.

vhscli generate

generate is a command group. Use help to list model subcommands:

vhscli generate --help

Ask a model subcommand for its exact flags:

vhscli generate gpt-image-2 --help

generate submits the task, waits for it, and saves the output to -o (required). It prints the task id first, so an interrupted run can be finished later with vhscli resume <task_id> -o <path>.

vhscli submit <model> [options]

Same models and options as generate, but it prints the task id (task_id: <uuid>) and exits without waiting. -o is validated but not written — nothing lands until you resume.

vhscli submit seedance-2 "a robot dancing in tokyo at night" -o clip.mp4
# prints "task_id: <uuid>" and exits

vhscli resume <task_id> -o clip.mp4
# waits for the task to finish and writes clip.mp4

--task-id <uuid> submits under an id you choose instead of a fresh one, so you can record it before the submit returns. If the command dies before printing anything, re-run it unchanged: the backend insert and the submit are both idempotent by id, so the re-run joins the same task rather than paying for a second one.

vhscli resume <task_id> -o <path>

Finishes a submitted generation. It waits if the task is still running, then writes the output to -o. Which model ran and what was asked are read back from the task itself; the id is the only thing you have to keep.

vhscli resume 7d3c1b2a-... -o clip.mp4

Safe to re-run: a finished task is re-read from the server, never resubmitted.

The server job keeps running after the local process exits. resume attaches the CLI back to that task and writes the result when it is ready.

resume is poll + save, and it is the right command when you are driving one generation by hand. To run several at once, use those two directly.

vhscli poll -

Reads task ids from stdin, one per line, and prints exactly one — the first to finish — then exits. It writes no files and takes no -o.

# watch a batch: poll, save what it gives you, poll again with the rest
left=("${ids[@]}")
while [ ${#left[@]} -gt 0 ]; do
  id="$(printf '%s\n' "${left[@]}" | vhscli poll -)"
  vhscli save "$id" -o "$id.png"
  left=($(printf '%s\n' "${left[@]}" | grep -vFx "$id"))
done

stdout is that one id and nothing else, so it pipes straight into the next command; the human-readable state goes to stderr. With --json it is a single {"event":"task","task_id":"...","state":"done"|"error"|"missing"} line instead.

Ids may keep arriving while poll runs, and each starts being watched as it lands. That is the point of the interface: a long-running program writes a line each time it submits something, so a task fired a second ago is watched without waiting for the batch already in flight.

One result ends the process. So a driver keeps its own list of what is outstanding, drops the id it was just handed, and polls again with the rest — re-sending anything it never saw reported, because an id written to a poll that is already exiting goes into a pipe nobody will read. The driver's list is the record; this process is only a conduit. It also means the driver runs zero or one poll at a time, never one parked on nothing.

Polling again immediately is cheap. Each id is checked against its task before any waiting, so one that finished while the last poll was on its way out — or that names no task at all — is answered off that check with no waiting. missing means there is no such task (a typo, or an id that was never submitted); it counts as finished, so a bad id cannot hang the watch.

A poll that dies of a network failure exits without printing anything, which is the same situation: the tasks are still running server-side, and the driver polls again from its list. Nothing is lost by that, which is why there is no retry in here.

vhscli save <task_id> -o <path>

Writes an already-finished task's output to -o. It never waits: a task still running is an error here, and poll is what waits for it.

vhscli save 7d3c1b2a-... -o cat.png

Safe to re-run — the outcome is read back off the task, never regenerated.

save is what turns a task id into a correct file, and is worth preferring over fetching the result URL yourself: the result envelope differs per model, the download lands atomically (a partial download is never left at -o), and the file is converted when the provider's format differs from your extension. A task that failed is an error, with the task's error as the message.

vhscli chat <prompt>

Runs one chat request against seed-2.0 and writes the answer to stdout. It does not create an output file.

vhscli chat "explain how to make sourdough in 5 steps"
vhscli chat "describe this image as json" -i photo.jpg
vhscli chat "summarize this paper in 5 bullets; include page numbers" -f paper.pdf
vhscli chat "list key events with HH:mm:ss timestamps" -v clip.mp4 --fps 2

Options:

  • -i <path> attaches an image. Repeat for multiple images.
  • -f <path> attaches a PDF. Repeat for multiple files.
  • -v <path> attaches one video.
  • --fps <n> samples video from 0.2 to 5 frames per second. Default is 1.

Image Models

The output format is the provider's choice, so writing to any supported extension (.png, .jpg/.jpeg, .webp) works — the CLI converts the download locally when it has to. The -o extension selects the local format: png, jpg, jpeg, or webp.

--size

Every image model takes one flag for the frame: --size <width>x<height> in pixels. Default is 1920x1080.

vhscli generate gpt-image-2 "..." -o wide.png     --size 3840x2160
vhscli generate nano-banana-2 "..." -o phone.png  --size 1080x1920
vhscli generate seedream-5 "..." -o square.png    --size 1920x1920

One value carries the aspect ratio and the resolution together, and it means the same frame on every model — which is the point, because the providers underneath agree on nothing. OpenAI takes exact pixels inside one window; Seedream takes exact pixels inside a much higher one; Nano Banana takes no pixel count at all, only an aspect ratio and a 1K/2K/4K tier.

The backend fits your size to whichever provider runs it, so a frame is never refused for being unrenderable — but what comes back may differ:

| model | what it does with a size | |---|---| | gpt-image-2 | snaps both edges to a multiple of 16, inside 655360–8294400 pixels, max edge 3840, ratio at most 3:1 | | seedream-5, seedream-5-pro | scales into 3686400–10404496 pixels — its floor is above 2K, so a small frame is scaled up | | nano-banana-2, nano-banana-pro | rounds to its nearest aspect ratio and nearest 1K/2K/4K tier |

The shape is preserved in every case (a ratio past a model's limit is the one exception). The CLI itself only checks the form and that each edge is between 64 and 4096; the fitting happens server-side.

seedream-5

Seedream 5.0 Lite image generation and editing (BytePlus).

vhscli generate seedream-5 "a girl in a yellow raincoat walking under a parasol, monet oil painting style" -o girl.png
vhscli generate seedream-5 "remove her hat, keep everything else" -i photo.jpg -o edit.png

Options:

  • -o, --output <path> sets the output path.
  • -i <path> adds a reference image. Maximum 14; repeat for more.
  • --size <size> as above. Renders nothing below ~2K: 960x540 comes back at roughly 2560x1440.

seedream-5-pro

Seedream 5.0 Pro image generation and editing (BytePlus). The pro tier is slower than seedream-5 — roughly 2 minutes per image — but stronger on prompt adherence and fine detail. Same frame limits.

vhscli generate seedream-5-pro "a lone lighthouse on a cliff at dusk, long exposure, crashing surf" -o lighthouse.png
vhscli generate seedream-5-pro "add a flock of birds across the sky, keep the style" -i scene.png -o birds.png

Options:

  • -o, --output <path> sets the output path.
  • -i <path> adds a reference image. Maximum 14; repeat for more.
  • --size <size> as above.

nano-banana-2

Nano Banana 2 image generation and editing (Google, via token360).

vhscli generate nano-banana-2 "remove the man from the photo, keep everything else" -i photo.jpg -o clean.png
vhscli generate nano-banana-2 "a glossy candle in a bell jar on a marble counter, soft light" -o candle.png

Options:

  • -o, --output <path> sets the output path.
  • -i <path> adds a reference image. Maximum 14; repeat for more.
  • --size <size> as above. Served as the nearest of 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16 at the nearest of 1K/2K/4K.

nano-banana-pro

Nano Banana Pro image generation and editing (Google, via token360). Slower than nano-banana-2, stronger on fine detail and rendered text. Same ratios and tiers.

vhscli generate nano-banana-pro "a glossy face moisturizer jar on warm studio backdrop" -o jar.png
vhscli generate nano-banana-pro "a sun-drenched minimalist living room with a 3d armchair from this sketch" -i sketch.jpg -o room.png

Options:

  • -o, --output <path> sets the output path.
  • -i <path> adds a reference image. Maximum 14; repeat for more.
  • --size <size> as above.

gpt-image-2

OpenAI gpt-image-2 image generation and editing (via token360).

vhscli generate gpt-image-2 "a children's book drawing of a veterinarian examining a cat" -o vet.png
vhscli generate gpt-image-2 "replace the background with a starry night" -i photo.jpg -o night.png

Options:

  • -o, --output <path> sets the output path.
  • -i <path> adds a reference image. Repeat for more.
  • --size <size> as above.

Video Models

seedance-2

Seedance 2.0 video generation.

vhscli generate seedance-2 "a woman in a red dress walks through a rainy neon-lit alley, slow tracking shot" -o alley.mp4
vhscli generate seedance-2 "animate this photo: gentle pan to the right" --first-frame photo.jpg -o pan.mp4
vhscli generate seedance-2 "match the camera move from this clip in a cyberpunk street" -v ref.mp4 -o cyber.mp4

Options:

  • -o, --output <path> sets the output path (.mp4, .webm, or .mov; prefer .mp4).
  • --first-frame <image> uses an image as the first frame.
  • --last-frame <image> uses an image as the last frame. It requires --first-frame.
  • -i <path> adds a reference image. Maximum 9. Conflicts with --first-frame.
  • -v <path> adds a reference video. Maximum 3; repeat for more. Conflicts with --first-frame.
  • -a <path> adds a reference audio file. Maximum 3. Requires at least one -i or -v; conflicts with --first-frame.
  • --ratio <ratio> accepts 21:9, 16:9, 4:3, 1:1, 3:4, or 9:16. Default is 16:9. There is no "adaptive" — the output frame is always one you picked.
  • --resolution <res> accepts 480p, 720p, 1080p, or 4k. Default is 720p. Lowercase only — 4K and 1080P are rejected.
  • --duration <n> accepts 4 to 15. Default is 5.
  • --audio / --no-audio toggles the audio track. Default is --audio (audio on); pass --no-audio for a silent video.

The command polls until the result is ready and prints progress. To avoid blocking, use vhscli submit seedance-2 ... (same flags) to detach immediately, then vhscli resume <task_id> -o clip.mp4 later. An interrupted generate is finished the same way, with the id it printed at submit.

seedance-2.5

Seedance 2.5 video generation — the same model as seedance-2, except resolution tops out at 1080p, duration stretches to 30 seconds, and reference caps rise to 30 images / 10 videos / 10 audios. For 4k, use seedance-2 instead.

vhscli generate seedance-2.5 "a woman in a red dress walks through a rainy neon-lit alley, slow tracking shot" -o alley.mp4

Same options as seedance-2, with these differences:

  • --resolution <res> accepts 480p, 720p, or 1080p — no 4k. Default is 720p.
  • --duration <n> accepts 4 to 30. Default is 5.
  • -i <path> maximum 30 (vs 9 on seedance-2).
  • -v <path> maximum 10 (vs 3 on seedance-2).
  • -a <path> maximum 10 (vs 3 on seedance-2).

minimax-h3

MiniMax H3 video generation (via the fal.ai aggregator). The output always carries an audio track — H3 renders its own; there is no --no-audio. Cite references in the prompt by modality and list order: "Image 1", "Video 1", "Audio 1".

vhscli generate minimax-h3 "a woman in a red dress walks through a rainy neon-lit alley, slow tracking shot" -o alley.mp4
vhscli generate minimax-h3 "animate this photo: gentle pan to the right" --first-frame photo.jpg -o pan.mp4
vhscli generate minimax-h3 "Image 1 is the singer. She sings along to Audio 1 on a rooftop at dusk" -i singer.jpg -a song.mp3 -o rooftop.mp4

Options:

  • -o, --output <path> sets the output path (.mp4, .webm, or .mov; prefer .mp4).
  • --first-frame <image> uses an image as the first frame. The output frame follows this image.
  • --last-frame <image> uses an image as the last frame. It requires --first-frame.
  • -i <path> adds a reference image. Maximum 9. Conflicts with --first-frame.
  • -v <path> adds a reference video (2-15 seconds each, at most 15 seconds combined). Maximum 3. Conflicts with --first-frame.
  • -a <path> adds a reference audio file (2-15 seconds each, at most 15 seconds combined). Maximum 3. Requires at least one -i or -v; conflicts with --first-frame.
  • Reference files are capped at 12 in total across -i/-v/-a.
  • --ratio <ratio> accepts 21:9, 16:9, 4:3, 1:1, 3:4, or 9:16. Default is 16:9. Conflicts with --first-frame (the output follows the image).
  • --resolution <res> accepts 768p or 2k. Default is 768p. Lowercase only.
  • --duration <n> accepts 5 to 15. Default is 5.

Audio Model

seed-audio-1

Seed Audio 1.0 text-to-speech (BytePlus). Output is always .mp3.

vhscli generate seed-audio-1 "Welcome to VHS." -o welcome.mp3
vhscli generate seed-audio-1 "Read this in the reference voice." -i voice.mp3 -o out.mp3
vhscli generate seed-audio-1 "Blend these voices." -i v1.mp3 -i v2.mp3 -o blend.mp3

Options:

  • -o, --output <path> sets the output path (must be .mp3).
  • -i <path> adds a reference voice clip for cloning or blending. Maximum 3; repeat for more. Each clip should be ≤30s and ≤10MB.

Files And Output

-o is required; it sets the output path, interpreted relative to your cwd. The CLI writes exactly there — it does not re-home or de-conflict. A caller that must not overwrite should check the path (or pick a free name) before calling.

Downloads are written through a temporary file and renamed into place. If the requested extension does not match the downloaded media type, conversion happens locally through ffmpeg — images included (-update 1 -frames:v 1 takes the first frame of a multi-frame source).

Local inputs are uploaded via the s3tmpfile edge function: it answers with a presigned PUT URL, the bytes go straight to the bucket, and the resulting public URL is what the provider fetches. There is no dedupe: the same file passed twice is uploaded twice.

The CLI never deletes an upload. It can't know when the file has served its purpose — the provider fetches the URL on its own schedule, vhscli submit exits while it is still doing so, and the seedance privacy fallback re-reads the same URLs after a rejection. Cleanup is the server's job: the bucket carries a 1-day lifecycle expiry. This is why uploading is a plain fire-and-forget call with no lifecycle to track.

Image type is detected from file content. JPEG and PNG are uploaded as-is; other image formats are converted to JPEG first.

Oversized inputs are shrunk with ffmpeg before upload — providers reject very large uploads and downsample high-res inputs server-side anyway. Reference images over 2048px on an edge or 10MB are scaled to fit 2048px (PNG stays PNG, so transparency survives). Reference videos over 1920px on an edge or 50MB are re-encoded to fit 1920px (h264/aac). Audio and PDF inputs are uploaded as-is.

Smoke Tests

smoke/ holds end-to-end tests that hit the live backend with a real session (they cost money and take minutes — the seedance tests render video). Each script generates an output, asserts the file is non-empty, and asks chat to describe it. Run them all with:

npm run smoke    # logs + summary under smoke/out/logs/<timestamp>/

Project Structure

src/
  main.ts         # command tree, login/logout/whoami/models, top-level error handler
  task.ts         # submit/generate/resume/poll/save orchestration + failure-tolerant polling
  version.ts
  models/
    index.ts      # names the models, registers them, dispatches save by name
    types.ts      # Mode / Opts / Payload — the shared vocabulary, nothing more
    image.ts      # one function per image model (seedream, nano banana, gpt-image)
                  # + the shared --size (WxH) parser they all use
    video.ts      # seedance-2, seedance-2.5, and their shared real-face rejection check + byteplus asset rewrite
    audio.ts      # seed-audio-1, and its reference-clip rules
  cmd/
    chat.ts
    resume.ts
  lib/
    auth.ts       # fresh_session — a refreshed access token per request
    backend.ts    # generic invoke (rpc over http) + named helpers for shared endpoints + task2 rows
    error.ts      # Fail + fail() (thrown; main.ts is the only exit point), message_of
    flags.ts      # shared commander parsers (collect) + the --task-id handoff
    media.ts      # save_media (streaming download + convert), detect_mime, Kind, output resolve + stat/hash
    process.ts    # run a local tool (ffmpeg/ffprobe) to completion
    prompt.ts     # the prompt argument, or stdin when the user passed `-`
    session.ts    # interactive login (ensure_login, login)
    storage.ts    # uploads via s3tmpfile presign + direct s3 PUT (upload_images, upload_videos, upload_files)

A model is a plain function. There is no model config anywhere. Each one — seedream_5, seedance_2, seed_audio_1 — writes its own command end to end: its description, its -o hint, its flags, its --size parser, its reference limits, its help text, its endpoint, and its run body. Every limit is a literal in the code that enforces it (if (images.length > 14) fail(...)), not a field some generic builder reads. You read a model by reading its function, top to bottom; nothing else has to be consulted.

Every model's action has the same three sections, in this order:

  1. check — the output path and the flags, everything knowable without a network call. This runs before login, so a typo costs nothing. Then log in and read the prompt.
  2. upload — send the local inputs, assemble the provider payload.
  3. generate — hand off to run_generation: submit → wait → save.

They are grouped by output kind (image.ts, video.ts, audio.ts). Models of the same kind share only real code, taking values rather than settings — every image provider sends the same request and answers with one image_url, so image.ts has one image_request(prompt, size, images) and one save_image(). All five image models share one --size parser too, because --size is now one form (WxH) for all of them: the per-provider frame limits live on the server, where the frame is fitted to whichever model actually runs.

models/index.ts names the models and dispatches by name, with a switch:

| | | |---|---| | register_models | calls each model's function under generate and submit | | save_result | the provider result → the output file |

Save is reached by name rather than by closure because vhscli resume picks a task up in a new process, holding only a task id — the model name is recovered from the task's endpoint via from_endpoint. save_result is exhaustive over the name union, so a new model does not compile until it is handled.

Adding a model is one function, one name, one call in register_models — and the compiler then points at save_result.

lib/backend.ts owns all server communication: named, validated helpers for the endpoints every command shares (submit2, poll2, the task2 rows), plus a generic invoke for model-specific endpoints (the privacy fallback hits kyc/create_asset this way). Callers never reach raw fetch or PostgREST URLs.

Core Flow

Generation and chat share the same server handoff:

  1. Parse and validate CLI inputs (each model's action checks flags before login).
  2. Upload local media via upload_images / upload_videos / upload_files (s3tmpfile presign + direct s3 PUT), which returns a public URL per file. Oversized images and videos are shrunk with ffmpeg first (see Media Handling).
  3. Assemble the provider payload inline in the model's action.
  4. Call create_and_submit(endpoint, payload, ...), which inserts a task2 row (id, user_id, endpoint, payload) and kicks off provider work via backend.submit2() (POST to /functions/v1/main2/submit2); the call returns immediately.
  5. Long-poll for the result via wait_for_task, which calls backend.poll2() — the server holds the request open until a realtime broadcast fires, then returns result and err inline. This is the single poll path for every endpoint; how the server completes an async task (a byteplus callback, or driving an upstream poller in the background) is its business, not the CLI's.
  6. Hand the result to save_result(name, result, output), which dispatches by model name to check the response fields and write the output.

The CLI never calls model providers directly. The task2 row is the durable server-side job record; backend.submit2 is the only server entry point for model execution.

The task2 row is the only record of a task, so vhscli submit can stop the moment the backend has it. vhscli resume <task_id> -o <path> re-attaches via the same wait_for_task helper, reads the row through backend.get_task — which also tells it which model ran, via from_endpoint(row.endpoint) — and saves the output through save_result.

Design Decisions

The runtime is stock Node and TypeScript. There is no Bun dependency and no local workspace package assumption.

The package is ESM-only and targets modern Node. That keeps the runtime model simple and matches current npm behavior.

commander owns command parsing, help text, and option validation.

There is no schema library. commander already validates flags at the edge, and each save helper checks the few response fields it actually reads (save_result fails with a precise message if one is missing), so a validation layer over both would only restate what those two already enforce. Payloads are plain objects; fields we don't set are omitted rather than sent as null, since 3rd-party providers treat a missing key and a null key differently. HTTP is plain fetch with AbortSignal.timeout (downloads keep a headers-only timer, so a long stream is never cut off mid-body).

The CLI is intentionally thin: upload bytes, create a task row, call /submit, wait, and save the result. Provider-specific execution remains server-side.

The CLI is driven by AI agents (Claude Code, Codex) and by VHS Studio. There are no automatic retries: a retry decision is the agent's to make, and it makes a better one when handed a precise error than the CLI could make blindly. So every failure exits 1 with a message that names the operation, the endpoint or file, the http status, and the response body — enough for an agent to distinguish "retry this" from "fix the input" from "give up".

What the CLI does own is correctness under restart:

  • Every request fetches a fresh access token (auth.fresh_session), so a token expiring mid-run never kills a long generation.
  • The task2 row is inserted before submit2, so an aborted run is always resumable with vhscli resume <task_id> -o <path> — including a wait that died from a network blip; the server-side job keeps running. Pass --task-id if you need the id recorded before the submit round-trips.
  • Re-runs converge instead of erroring: task2 inserts are keyed by task id and treat a duplicate-key 409 as success.
  • Downloads stream to a unique temp file next to the destination and rename into place; partial downloads and failed conversions are cleaned up, never left as the output. That temp file is dot-prefixed (.cat.png.4821-0.tmp.png), so a destination directory watched by a file indexer never sees a half-written download as content.

Errors are not hidden. Expected failures throw Fail, which prints a short lowercase message and exits with status 1. Unexpected errors keep their stack — that is the bug report. main.ts is the single exit point (it sets process.exitCode rather than calling process.exit, so piped stdout always flushes), and library code never exits the process.

Auth state stays in ~/.vhs/session.json so this package can share existing VHS sessions.