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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions __tests__/fixtures/published-guide/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Published command fixture

`cli.mdx` is the complete, unmodified Marketing guide at the immutable source in
`provenance.json`. The test extracts its email/song examples and chat-link flag,
then runs the built CLI with synthetic credentials and intercepted native fetch.
No endpoint is contacted. Request records omit headers.

Run `pnpm build` then `pnpm exec vitest run __tests__/publishedCommands.test.ts`.
Documentation owners can set `RECOUP_CLI_REFERENCE_PATH` to an absolute candidate
Markdown path to run the same parser/request assertions before publishing.

When these examples intentionally change, copy the complete guide from its owning
commit, update provenance and SHA256, and review the expected requests together.
The default check protects this pinned guide; it does not monitor future live docs
or establish installed npm package behavior. Coverage is limited to email/song
examples and the retired email flag, not every CLI command or authorization rule.
192 changes: 192 additions & 0 deletions __tests__/fixtures/published-guide/cli.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
---
title: "CLI"
description: "Install the Recoup CLI and interact with the platform from your terminal."
---

The Recoup CLI (`@recoupable/cli`) wraps the Recoup API for terminal-first workflows. It's available as a global npm package and comes pre-installed in sandbox environments.

<Note>
**The CLI is in beta.** The reference below follows the v0.1.15 source command set. Check `recoup --version` and `--help` for the package installed in your environment. For research workflows and most content operations, call the [REST API](/api-reference) directly — the CLI is gradually catching up.

**Agents:** if a command isn't listed below, don't retry — fall back to the corresponding REST endpoint linked under each command.
</Note>

## Install

```bash
npm install -g @recoupable/cli
```

## Authenticate

Set your API key as an environment variable:

```bash
export RECOUP_API_KEY=your-api-key
```

Verify it works:

```bash
recoup whoami
```

<Tip>
Get an API key from the [API Keys page](https://chat.recoupable.dev/keys) or use the [agent signup](/agents) for instant key generation.
</Tip>

## Configuration

| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `RECOUP_API_KEY` | Yes | — | Your Recoup API key |
| `RECOUP_API_URL` | No | `https://recoup-api.vercel.app` | API base URL override; set `https://api.recoupable.dev` to use the canonical API domain |

All commands support `--json` for machine-readable output and `--help` for usage info.

---

## Commands

### whoami

Show the authenticated account. See [`GET /api/accounts/id`](/api-reference/accounts/id).

```bash
recoup whoami
recoup whoami --json
```

### orgs

List organizations. See [`GET /api/organizations`](/api-reference/organizations/list).

```bash
recoup orgs list
recoup orgs list --account <accountId>
```

### artists

List artists. See [`GET /api/artists`](/api-reference/artists/list).

```bash
recoup artists list
recoup artists list --org <orgId>
recoup artists list --account <accountId>
```

### emails

Send an email to the authenticated account. See [`POST /api/emails`](/api-reference/emails/create).

```bash
recoup emails --subject "Pulse Report" --text "Here's your weekly summary."
recoup emails --subject "Update" --html "<h1>Report</h1><p>Details here.</p>"
```

| Flag | Required | Description |
|------|----------|-------------|
| `--subject <text>` | No | Email subject line; defaults from the body when omitted |
| `--text <body>` | No | Plain text or Markdown body |
| `--html <body>` | No | Raw HTML body (takes precedence over `--text`) |
| `--cc <email>` | No | CC recipient (repeatable) |
| `--chat-id <id>` | No | Chat ID for a chat link in the email footer |
| `--account <accountId>` | No | Send to a specific account (org keys only) |

### chats

Manage chats. See [`GET /api/chats`](/api-reference/chat/chats) and [`POST /api/chats`](/api-reference/chat/create).

```bash
recoup chats list
recoup chats create --name "My Topic"
recoup chats create --name "My Topic" --artist <artistId>
```

### sandboxes

Manage sandboxes. See [`GET /api/sandboxes`](/api-reference/sandboxes/list) and [`POST /api/sandboxes`](/api-reference/sandboxes/create).

```bash
recoup sandboxes list
recoup sandboxes create
recoup sandboxes create --command "ls -la"
```

### tasks

Check background task status. See [`GET /api/tasks/runs`](/api-reference/tasks/runs).

```bash
recoup tasks status --run <runId>
```

### songs

Run AI music analysis. See [`POST /api/songs/analyze`](/api-reference/songs/analyze) and [`GET /api/songs/analyze/presets`](/api-reference/songs/analyze-presets).

```bash
recoup songs presets
recoup songs analyze --preset catalog_metadata --audio https://example.com/track.mp3
recoup songs analyze --prompt "Describe the production style" --audio https://example.com/track.mp3
```

Exactly one of `--preset` or `--prompt` is required, together with `--audio`. The API rejects both selectors together and verifies the audio URL before inference. The coordinated CLI validation fix reports these errors before sending a request; older installed packages may send them to the API.

| Flag | Required | Description |
|------|----------|-------------|
| `--preset <name>` | Conditional | Curated analysis preset. Required when `--prompt` is omitted. |
| `--prompt <text>` | Conditional | Custom text prompt. Required when `--preset` is omitted. |
| `--audio <url>` | Yes | Public URL to an audio file (MP3, WAV, FLAC), required for every preset and custom prompt. Metadata-only analysis is not supported. |
| `--max-tokens <n>` | No | Max tokens to generate (default 512). |

---

## content

Content creation pipeline — generate AI-powered social videos for artists.

### List templates

```bash
recoup content templates
```

### Validate an artist

Check that an artist has the required assets before creating content. See [`GET /api/content/validate`](/api-reference/content/validate).

```bash
recoup content validate --artist <artistAccountId>
```

### Estimate cost

Preview estimated cost and duration before kicking off the pipeline. See [`GET /api/content/estimate`](/api-reference/content/estimate).

```bash
recoup content estimate
recoup content estimate --lipsync --batch 2 --compare
```

### Create content

Run the full content-creation pipeline for an artist. See [`POST /api/content/create`](/api-reference/content/create).

```bash
recoup content create --artist <artistAccountId>
recoup content create --artist <artistAccountId> --template <name> --lipsync --upscale
```

| Flag | Required | Description |
|------|----------|-------------|
| `--artist <id>` | Yes | Artist account ID |
| `--template <name>` | No | Template name (default: random) |
| `--lipsync` | No | Enable lipsync mode |
| `--upscale` | No | Enable upscaling |
| `--caption-length <n>` | No | Max caption length in characters |

<Tip>
For finer-grained control (individual image, video, caption, transcription, edit, upscale, or analyze operations), call the [content REST endpoints](/api-reference/content/generate-image) directly. Those primitives aren't yet exposed as individual CLI subcommands.
</Tip>
19 changes: 19 additions & 0 deletions __tests__/fixtures/published-guide/intercept.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const { appendFileSync } = require("node:fs");

globalThis.fetch = async (input, options = {}) => {
const url = new URL(String(input));
if (url.origin !== "https://cli-fixture.invalid") {
throw new Error("Unexpected fixture origin");
}
appendFileSync(process.env.CLI_FIXTURE_REQUEST_LOG, JSON.stringify({
method: options.method,
path: url.pathname,
body: options.body ? JSON.parse(options.body) : null,
}) + "\n");
return new Response(JSON.stringify({
message: "Synthetic email response",
response: "Synthetic analysis response",
elapsed_seconds: 0,
presets: [],
}), { status: 200, headers: { "Content-Type": "application/json" } });
};
6 changes: 6 additions & 0 deletions __tests__/fixtures/published-guide/provenance.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"repository": "https://github.com/recoupable/marketing",
"commit": "e1cc8eaf09c19a037e5615bffde76b3e8cc573c3",
"path": "content/docs/source/cli.mdx",
"sha256": "960556fcd300c5216fa708db8f1ca9379ae4592788b4df57b7cfcda2f08c3eae"
}
109 changes: 109 additions & 0 deletions __tests__/publishedCommands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import provenance from "./fixtures/published-guide/provenance.json";

const fixtures = join(__dirname, "fixtures", "published-guide");
const snapshot = readFileSync(join(fixtures, "cli.mdx"), "utf8");
// Documentation owners can run the same guard against a candidate guide.
const guide = process.env.RECOUP_CLI_REFERENCE_PATH
? readFileSync(process.env.RECOUP_CLI_REFERENCE_PATH, "utf8")
: snapshot;

function section(name: string) {
const heading = `### ${name}\n`;
expect(guide.split(heading)).toHaveLength(2);
return guide.split(heading)[1].split(/^#{1,3} /m)[0];
}

function examples(name: string) {
return [...section(name).matchAll(/```bash\n([\s\S]*?)```/g)]
.flatMap((match) => match[1].trim().split("\n"));
}

function run(command: string) {
// These published examples use double-quoted arguments, without shell expansion.
// Reject unsupported syntax rather than accidentally executing a shell.
const tokens = command.match(/"[^"\n]*"|[^\s"]+/g) ?? [];
expect(tokens[0]).toBe("recoup");
expect(tokens.join(" ")).toBe(command);
const args = tokens.slice(1).map((token) => token.replace(/^"|"$/g, ""));
const directory = mkdtempSync(join(tmpdir(), "recoup-cli-guide-"));
const log = join(directory, "requests.jsonl");
try {
const result = spawnSync(process.execPath, [
"--require", join(fixtures, "intercept.cjs"),
join(__dirname, "..", "dist", "bin.cjs"), ...args,
], {
encoding: "utf8",
timeout: 10000,
env: {
RECOUP_API_KEY: "fixture-not-a-real-key",
RECOUP_API_URL: "https://cli-fixture.invalid",
CLI_FIXTURE_REQUEST_LOG: log,
},
});
expect(result.error).toBeUndefined();
const requests = existsSync(log)
? readFileSync(log, "utf8").trim().split("\n").map((line) => JSON.parse(line))
: [];
return { ...result, requests };
} finally {
rmSync(directory, { recursive: true, force: true });
}
}

describe("published command contract (built CLI, intercepted requests)", () => {
it("pins the unmodified maintained guide to its recorded source", () => {
expect(createHash("sha256").update(snapshot).digest("hex")).toBe(provenance.sha256);
});

it("runs every email example from the guide", () => {
const commands = examples("emails");
expect(commands).toHaveLength(2);
const expected = [
{ subject: "Pulse Report", text: "Here's your weekly summary." },
{ subject: "Update", html: "<h1>Report</h1><p>Details here.</p>" },
];
commands.forEach((command, index) => {
const result = run(command);
expect(result.status).toBe(0);
expect(result.requests).toEqual([{ method: "POST", path: "/api/emails", body: expected[index] }]);
});
});

it("maps the published chat link option to chat_id", () => {
const option = section("emails").match(/\| `(--[^`]+)` \| No \| Chat ID for a chat link/);
expect(option).not.toBeNull();
const result = run(`${examples("emails")[0]} ${option![1].replace("<id>", "fixture-chat")}`);
expect(result.status).toBe(0);
expect(result.requests).toEqual([{ method: "POST", path: "/api/emails", body: {
subject: "Pulse Report", text: "Here's your weekly summary.", chat_id: "fixture-chat",
} }]);
});

it("rejects retired --room-id before any request", () => {
const result = run(`${examples("emails")[0]} --room-id fixture-chat`);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("unknown option '--room-id'");
expect(result.requests).toEqual([]);
});

it("runs every song example from the guide", () => {
const commands = examples("songs");
expect(commands).toHaveLength(3);
const expected = [
{ method: "GET", path: "/api/songs/analyze/presets", body: null },
{ method: "POST", path: "/api/songs/analyze", body: { preset: "catalog_metadata", audio_url: "https://example.com/track.mp3" } },
{ method: "POST", path: "/api/songs/analyze", body: { prompt: "Describe the production style", audio_url: "https://example.com/track.mp3" } },
];
commands.forEach((command, index) => {
const result = run(command);
expect(result.status).toBe(0);
expect(result.requests).toEqual([expected[index]]);
});
});
});
Loading