# @sleeperhit/cli

Command-line client for the **Sleeper Hit Studio B2B Story API** (`/api/v1`). It maps 1:1 onto the documented endpoints — create projects, build a Series Bible, map and run a podcast season, generate gated episodes, publish an RSS feed, and open the live table-read theater.

The endpoints, auth, error envelope, and flow mirror the [Story API docs](https://docs.sleeperhit.studio/quickstart) (quickstart, concepts, API reference). Nothing here contradicts those docs — the CLI is a thin, scriptable wrapper around the same HTTP surface.

## Requirements

- Node.js **>= 20** (uses the built-in global `fetch` and `node:crypto.randomUUID`).
- A Sleeper Hit Studio customer API key. Create one at <https://sleeperhit.studio/dashboard/api>.
- Or sign in from the browser with `sleeperhit login`. A plain login asks for every scope (the approval page lists each one); `--scopes story:read,story:write` asks for exactly that list instead. The key it saves lasts 90 days; logging in again renews it and revokes the key the previous login saved, and `sleeperhit logout` revokes it on the server. `sleeperhit whoami` prints the key's scopes and expiry. The saved file is shared with the stdio MCP server's `login` tool.

## Install

```bash
npm install -g @sleeperhit/cli
sleeperhit --help
```

Or run it once without installing: `npx @sleeperhit/cli --help`. The package is one bundled file with no runtime dependencies.

### From the monorepo

The package lives at `packages/cli` and is picked up by the `packages/*` workspace glob:

```bash
pnpm install
pnpm --filter @sleeperhit/cli build      # bundles src (and @sleeperhit/shared) -> dist/index.js
cd packages/cli
pnpm link --global                       # now `sleeperhit` is on your PATH
```

### Run without building (dev)

```bash
# from packages/cli
pnpm dev -- projects list
# or directly with a TS-aware runner:
pnpm exec tsx src/index.ts projects list
```

## Configuration

| Env var | Required | Default | Purpose |
|---|---|---|---|
| `SLEEPERHIT_API_KEY` | yes | — | Bearer key, `sh_<prefix>_<secret>`. Sent as `Authorization: Bearer <key>`. |
| `SLEEPERHIT_BASE_URL` | no | `https://sleeperhit.studio` | Origin to target. `/api/v1` is appended automatically. Point at `https://stage.sleeperhit.studio` or a local dev origin when testing. |

```bash
export SLEEPERHIT_API_KEY="sh_xxxxxxxxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# optional
export SLEEPERHIT_BASE_URL="https://sleeperhit.studio"
```

The CLI auto-generates an `Idempotency-Key` (UUID) for every POST. Override it with `--idempotency-key <key>` when you want a stable key tied to your own job record for safe retries.

Errors are printed in the standard `{ error: { code, message, requestId } }` form. On `rate_limited` the exact `Retry-After` is surfaced; `jobs watch` honors it automatically. The CLI exits non-zero on any error (`1` API/runtime, `64` usage, `2` when a requested operation is unavailable in the current environment).

## Commands

```
login [--scopes <list>] [--no-browser] [--base-url <url>]   # every scope unless --scopes narrows it; 90-day key; revokes the previous login's key
logout                                                      # revokes the saved key on the server, then removes it
whoami [--api-key <key>]                                    # the key's scopes and expiry
capabilities
guidance
credits

projects create --name <name> [--description <text>] [--metadata <json>]
projects from-script <scriptId> | --script <scriptUploadId>
projects list [--limit N] [--cursor C]
projects get <projectId>        # prints the project JSON, then its workspace gate and table-read cast readiness on stderr
projects episodes <projectId> [--season N]          # seasons, episodes (made in the app included), versions, videos
projects seasons add <projectId> [--name <text>]     # name a new season (free)
projects episodes start <projectId> <episodeId>      # a Series Bible episode as an empty draft
projects update <projectId> [--name <text>] [--description <text>]
        [--metadata <json>] [--archived true|false]

bible get <projectId>
bible save <projectId> --json '<object>' | --file <json> [--title <text>]
bible generate <projectId> [--instructions <text>]
bible coverage <projectId> [--generate] [--focus <text>]
outline coverage <projectId> [--generate] [--focus <text>]

seasons create <projectId> [--title <text>] [--season-number <n>]
        [--publishing-series-id <id>] [--coverage-policy <json>] [--run-key <key>]
seasons list <projectId>
seasons get <runId>
seasons map <runId> <episodeId> --expected-map-version <n>
        [--patch <json>] [--title <text>] [--summary <text>] [--objective <text>]
        [--audience <text|null>] [--tone <text|null>] [--notes <text|null>]
        [--duration-minutes <n>] [--episode-number <n>]
        [--confirmed true|false] [--confirm]
seasons approve <runId> --expected-map-version <n> --confirm
seasons advance <runId> [--episode-id <id>]
seasons replan <runId> [--episode-id <id>] [--instruction <text>] --confirm
seasons retry-coverage <runId> [--episode-id <id>] --confirm
seasons revise <runId> [--episode-id <id>] --instruction <text> --confirm
seasons publish <runId> [--episode-id <id>] --confirm

sources add <projectId> --type text|markdown|url|pdf
        [--content <text> | --file <path>]   (text / markdown)
        [--uri <http(s) url>]                (url / pdf)
        [--label <text>] [--retention EXTRACTED_ONLY|STORE_ORIGINAL|METADATA_ONLY]
        [--metadata <json>]                  use {"sourceContextMode":"full"} for complete planner + writer context
sources list <projectId> [--limit N] [--cursor C]
sources get <projectId> <sourceId>

capture record <tour.json> --out <dir> [--storage-state <file>] [--audio-from <url|file> [--offset <seconds>]]
        [--browser chrome|chromium] [--app-version <tag>] [--upload --project <projectId> [--label <text>]]
        record one take of an app tour on this machine (needs the optional recorder package,
        @sleeperhit/capture: Playwright, Google Chrome, ffmpeg); --upload then imports the take. Free.
capture import <file|take-dir> --project <projectId> [--label <text>] [--origin-url <url>] [--route <path>] [--app-version <text>]
        a real screen recording of an app (MP4: H.264, constant 30 fps, at most 1920x1080, bt709), or a
        capture record take's directory with its events.json, stills and tour, uploaded straight to storage,
        then checked: ready, or rejected with the ffmpeg command that fixes it. Free.
capture list --project <projectId>
capture get|finalize|delete <captureId> --project <projectId>

plans create <projectId>
        --target audience=...,objective=...,outcome=...   (repeatable)
        --artifact type=table_read,mode=podcast|documentary|drama[,narrationPolicy=auto|include|suppress][,durationSeconds=N]
        [--brief genre="..." --brief audience="..." --brief pageTarget=4]
        [--source <sourceId> ...] [--title <text>] [--auto-approve]  (non-table artifacts only)
        [--json '<full payload>']    power-user escape hatch
plans list <projectId> [--limit N] [--cursor C]
plans get <planId>
plans approve <planId> --confirm
plans reject <planId> [--reason <text>]

jobs create <planId> [--project <projectId>] [--artifact type=...,mode=...]
        [--voice-map '{"GARY":{"voiceId":"...","provider":"hume"}}'] [--script <scriptUploadId>]
jobs list [--project <id>] [--plan <id>] [--limit N] [--cursor C]
jobs get <jobId>
jobs watch <jobId> [--interval <ms>]   Poll until terminal; print manifest URLs on READY
jobs cancel <jobId>
jobs artifacts <jobId>

artifacts get <artifactId> [--revision N]
artifacts render-video <artifactId>

script get <artifactId> [--scope all|page|scene|character|range] [--page N] [--scene N]
        [--character <name>] [--start N --end N] [--limit N]
script page <artifactId> <page>
script scene <artifactId> <sceneIndex>
script character <artifactId> <character...>
script replace <artifactId> --file <path> [--instruction <text>] [--narration-policy include|suppress]
script upload --title <title> --file <path> [--format text|fountain|fdx]   # a new script in no project
script show <scriptId>                    # the script's id, its Writers Lab URL id, or any version's id
script update <scriptId> [--title <t>] [--slug <s>] [--logline <text>] [--media-type film|tv]
        [--genre <text>] [--season N] [--clear logline|genre|media-type]...
script versions add <scriptId> --file <path> [--format text|fountain|fdx]
script write <scriptUploadId> --file <path> [--expected-file <path>]   # a draft's or version's whole text
script finish <scriptUploadId> [--coverage adopt|fresh]                # the recommendation, then finish
script versions delete <scriptId> <scriptUploadId> --confirm
script export <scriptUploadId> --format fountain|fdx|pdf [--out <path>] [--no-title-page]
        [--from-page N] [--to-page N] [--font screenplay-sim|courier-prime|courier-new|final-draft]
script overrides get <scriptUploadId>
script overrides set <scriptUploadId> [--avatar <text>] [--theater <text>] [--soundscape <text>]
        [--pitch-deck <text>] [--locations <text>] [--clear <override>]...
script table-read prepare <scriptUploadId> [--confirm]
        Prepare a cast episode's table read (the Writers Lab's Continue to Table Read): prints the price;
        --confirm charges it once for this screenplay version and adds its sound effects and music.

cast <artifactId>
cast update <artifactId> --json '<entries-or-{entries,avatarStyle}>'
cast restyle <artifactId> --style <s>
avatar set <artifactId> --character <c> [--style <s>] [--refine <r>] [--reference-image-url <u>]
voice set <artifactId> --character <c> --voice-id <id> --voice-name <name> [--gender <g>] [--provider <p>]
modify-voice <artifactId> --start <n> --end <n>
        [--effect autotune] [--key C..B] [--scale major|minor|majpent|minpent|chromatic]
        [--strength <0..1>] [--smooth <int 1+>] [--reverb none|light|chapel]

refine <artifactId> <instruction...> [--scope auto|screenplay|plan]
finalize <artifactId> [--mode audio|video]

coverage generate <artifactId> [--focus <text>]
coverage get <artifactId> [--report <id>]
coverage watch <artifactId> [--interval <ms>]

music generate <artifactId> [--coverage <0..1|0..100>]
music update <artifactId> [--scope screenplay|scene|entry] [--scene <n>] [--entry <n>]
        [--mode merge|replace] [--prompt <text>] [--summary <text>] [--weight <0..2>]
        [--mute | --enable]
music get <artifactId>
music watch <artifactId> [--interval <ms>]

sfx add <artifactId> --entry <n> --label <text> --prompt <text>
        [--volume <0..1>] [--trigger before|with|after] [--offset-ms <n>] [--duration <s>] [--disable]
sfx update <artifactId> --id <cueId>
        [--entry <n>] [--label <text>] [--prompt <text>] [--volume <0..1>]
        [--trigger before|with|after] [--offset-ms <n>] [--duration <s>] [--regenerate]
        [--enable | --disable]
sfx list <artifactId>
sfx remove <artifactId> --id <cueId>
```

Run `sleeperhit --help` for the same reference at the terminal.

### Post-creation artifact operations

`refine`, `finalize`, `cast`, `avatar`, `voice`, `modify-voice`, `coverage`, `music`, and `sfx` are wired against the live `/api/v1/artifacts/{artifactId}` operation families. They mutate or analyze the same table read artifact while keeping the artifact id and share URLs stable. Commands that queue work return JSON immediately; use the matching `watch` command or poll `artifacts get` until the status is terminal.

## End-to-end example

Copy-pasteable. Replace the captured IDs as you go (or pipe through `jq`).

```bash
export SLEEPERHIT_API_KEY="sh_..."

# 1. Confirm the table read is available and you have credits
sleeperhit capabilities | jq '.artifacts.table_read.availability'
sleeperhit credits      | jq '.credits.balance'

# 2. Create a project
PROJECT_ID=$(sleeperhit projects create --name "Q3 Launch Story" \
  --description "Product launch table read" | jq -r '.project.id')

# 3. Attach an inline text source (instant READY)
SOURCE_ID=$(sleeperhit sources add "$PROJECT_ID" \
  --type text --label press-release \
  --content "Acme Corp launches its next-gen platform in Q3 2026, cutting onboarding time 60%." \
  | jq -r '.source.id')

# 4. Generate the plan
PLAN_ID=$(sleeperhit plans create "$PROJECT_ID" \
  --target audience="enterprise buyers" \
  --target objective="introduce the Q3 platform launch" \
  --target outcome="book a demo within 7 days" \
  --target tone="confident, specific" \
  --artifact type=table_read,mode=documentary \
  --source "$SOURCE_ID" | jq -r '.plan.id')

# 5. Poll until REQUIRES_APPROVAL, review the exact plan, then cross the human hold
sleeperhit plans get "$PLAN_ID" | jq '{status:.plan.status, plan:.plan.plan, total:.plan.quote.total}'
sleeperhit plans approve "$PLAN_ID" --confirm

# 6. Create the job (reserves credits) and watch it to completion
JOB_ID=$(sleeperhit jobs create "$PLAN_ID" | jq -r '.job.id')
sleeperhit jobs watch "$JOB_ID"
# On READY, watch prints:
#   Theater:        https://sleeperhit.studio/share/table-read/<token>
#   Theater (full): https://sleeperhit.studio/share/table-read/<token>?view=fullscreen
#   Audio (live):   https://sleeperhit.studio/share/table-read-audio/<token>
#   Video render URL: /api/v1/artifacts/<artifactId>/render-video

# 7. (Optional) opt into a durable MP4
ARTIFACT_ID=$(sleeperhit jobs get "$JOB_ID" \
  | jq -r '.job.artifacts[] | select(.type=="table_read") | .id')
sleeperhit artifacts render-video "$ARTIFACT_ID"
```

The `shareToken` baked into the theater/audio URLs authorizes them — not your API key — so you can hand those URLs directly to end users.

A table read performs the project's cast; it never makes one. From a plan, it
starts only when the project's cast canon covers every character — a voice for
everyone and, unless the project is audio-only, a headshot, body shot and
character sheet. `sleeperhit projects get <projectId>` prints who still needs
what (`project.tableReadReadiness`); a job that would refuse is a 409
`cast_precondition_failed` before anything is created or charged.

For voices the cast canon does not carry, first use `sleeperhit voices list`,
then provide a character-to-voice map on an explicit table-read artifact
request:

```bash
sleeperhit jobs create "$PLAN_ID" \
  --artifact type=table_read,mode=documentary \
  --voice-map '{"GARY":{"voiceId":"voice_gary","provider":"hume"},"MAEVE":{"voiceId":"voice_maeve","provider":"hume"}}'
```

`--voice-map` requires exactly one `--artifact type=table_read` request. Each
entry requires `voiceId`; `voiceName`, `gender`, and `provider` are optional.

To perform a finalized, cast-ready screenplay as written (no new pages, no
coverage rewrite), adopt its version id:

```bash
sleeperhit jobs create "$PLAN_ID" --script "$SCRIPT_UPLOAD_ID"
```

`--script` uses the single table-read request (adding one when none is given)
and cannot be combined with `--voice-map`.

## Use it in your own agent workflow

The CLI emits JSON on stdout and human progress on stderr, so it composes cleanly inside scripts or an agent's tool layer:

```bash
#!/usr/bin/env bash
set -euo pipefail
export SLEEPERHIT_API_KEY="${SLEEPERHIT_API_KEY:?set your key}"

ARTICLE_URL="$1"

PROJECT_ID=$(sleeperhit projects create --name "Agent run $(date +%s)" | jq -r '.project.id')

# Async URL source — poll to READY before planning
SOURCE_ID=$(sleeperhit sources add "$PROJECT_ID" --type url --uri "$ARTICLE_URL" | jq -r '.source.id')
until [ "$(sleeperhit sources get "$PROJECT_ID" "$SOURCE_ID" | jq -r '.source.status')" = "READY" ]; do
  sleep 3
done

PLAN_ID=$(sleeperhit plans create "$PROJECT_ID" \
  --target audience="general" --target objective="summarize the article" --target outcome="share a 90s table read" \
  --artifact type=table_read,mode=podcast,narrationPolicy=include,durationSeconds=90 \
  --source "$SOURCE_ID" | jq -r '.plan.id')

# Wait for the async planner, then stop for exact human review.
until [ "$(sleeperhit plans get "$PLAN_ID" | jq -r '.plan.status')" = "REQUIRES_APPROVAL" ]; do sleep 4; done
sleeperhit plans get "$PLAN_ID" | jq '.plan | {title, plan, quote}'
read -r -p "Approve this exact plan? [y/N] " APPROVE
case "$APPROVE" in y|Y|yes|YES) ;; *) exit 1 ;; esac
sleeperhit plans approve "$PLAN_ID" --confirm

JOB_ID=$(sleeperhit jobs create "$PLAN_ID" | jq -r '.job.id')
sleeperhit jobs watch "$JOB_ID"   # blocks, streams progress, prints share URLs on READY
```

For a power-user payload that exactly matches the API schema, bypass the flag helpers with `--json`:

```bash
PLAN_ID=$(sleeperhit plans create "$PROJECT_ID" --json '{
  "title": "Launch narrative",
  "target": { "audience": "enterprise buyers", "objective": "introduce the launch", "outcome": "book a demo" },
  "artifactRequests": [{ "type": "table_read", "mode": "drama", "narrationPolicy": "suppress", "durationSeconds": 120 }],
  "styleConstraints": { "voicePreference": "authoritative, not salesy" },
  "sourceIds": ["'"$SOURCE_ID"'"]
}' | jq -r '.plan.id')

# After it reaches REQUIRES_APPROVAL, review the exact plan with the user.
sleeperhit plans get "$PLAN_ID" | jq '.plan | {title, plan, quote}'
sleeperhit plans approve "$PLAN_ID" --confirm
```

## See also

- [Quickstart](https://docs.sleeperhit.studio/quickstart) — the same flow as raw HTTP / Node.
- [API reference](https://docs.sleeperhit.studio/api-reference) — every endpoint, field, and error code.
- [Concepts](https://docs.sleeperhit.studio/concepts) — the plan/job/artifact model and live post-creation operations.
- `GET /api/v1/openapi.json` — the live OpenAPI 3.1 spec.

## License

MIT. See LICENSE.
