parley-live 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@ Generate images, video clips, YouTube thumbnails and spokesperson product ads fr
5
5
  ```bash
6
6
  npm install -g parley-live
7
7
  parley login # one browser sign-in, stored under ~/.parley
8
- parley account # your credit balance
8
+ parley doctor # everything an agent needs to know before spending: session, credits, engines, fixes
9
9
  parley image "a matte black espresso machine on marble, morning light" --out hero.png
10
10
  parley video "steam rises, slow push in" --image hero.png --look standard --move dolly-in --ratio 9:16
11
11
  parley thumbnail "I tested every AI video tool" --overlay "ALL OF THEM" --variants 3
@@ -16,12 +16,13 @@ parley ugc-ad --product product.jpg --name "Cold Brew Kit" --actor jordan --mark
16
16
 
17
17
  | Command | What it does |
18
18
  |---|---|
19
- | `parley login` | Device-code sign-in. Prints a link, you sign in once in the browser, the CLI receives a session token. |
19
+ | `parley doctor` | Node, server, session, credits, live engines, the `@last` outputs, and the fix for anything missing. Run it first; `--json` for agents. |
20
+ | `parley login [--no-browser]` | Device-code sign-in. Prints a link, you sign in once in the browser, the CLI receives a session token. `--no-browser` prints the link instead of opening it (cloud sandboxes, SSH). |
20
21
  | `parley account` | Credit balance. |
21
22
  | `parley engines` | Which video engines this server can run right now, with credit rates. |
22
23
  | `parley camera-moves` | The named camera-move presets accepted by `video --move`. |
23
- | `parley image "<prompt>"` | Generate an image. `--model`, `--size`, `--quality`, `--image-size`, `--ref <file\|url>` (repeatable), `--out`. |
24
- | `parley video "<motion prompt>"` | Animate a still (`--image`) or text-to-video (`--look photoreal`). `--look`, `--move` (repeatable, up to 3), `--duration`, `--ratio`, `--out`. |
24
+ | `parley image "<prompt>"` | Generate an image. `--ratio 16:9\|9:16\|1:1\|4:3\|3:4` sizes it for the destination; `--model`, `--quality`, `--image-size`, `--ref <file\|url\|@last>` (repeatable), `--out`. |
25
+ | `parley video "<motion prompt>"` | Animate a still (`--image <file\|url\|@last>`) or text-to-video (`--look photoreal`). `--look`, `--move` (repeatable, up to 3), `--duration`, `--ratio`, `--out`. |
25
26
  | `parley thumbnail "<title>"` | 1-3 YouTube thumbnail variants. `--overlay`, `--accent`, `--person`, `--out <dir>`. |
26
27
  | `parley ugc-ad` | Product photo → spokesperson ad with a marketplace CTA end-card. `--product`, `--name`, `--actor` or `--face`, `--angle`, `--marketplace`, `--points`, `--seconds`, `--url`, `--out`. |
27
28
  | `parley youtube` | A complete faceless video from ordered beat stills: animated per beat, cut to `--narration` with crossfades, `--music` ducked, `--captions` burned in. `--beat` (repeatable, hosted https URLs), `--tier fast\|standard\|cinematic`, `--silent`, `--project`, `--out`. |
@@ -41,6 +42,16 @@ parley ugc-ad --product product.jpg --name "Cold Brew Kit" --actor jordan --mark
41
42
 
42
43
  `--move` works with every look. On `camera` the move drives the engine; on the others it becomes precise camera direction in the prompt. A named move beats "the camera moves dramatically" every time.
43
44
 
45
+ ### Chaining with `@last`
46
+
47
+ The CLI remembers the last image and the last clip it made (`~/.parley/last.json`), so a still becomes a clip without re-typing a URL:
48
+
49
+ ```bash
50
+ parley image "matte black espresso machine on marble, morning light" --ratio 9:16 --out hero.png
51
+ parley video "steam rises, a hand lifts the cup" --image @last --move dolly-in --ratio 9:16
52
+ parley image "same machine, top-down on oak" --ref @last --ratio 1:1
53
+ ```
54
+
44
55
  ## For agents
45
56
 
46
57
  Add `--json` to any command and read the last line of stdout:
@@ -51,7 +62,9 @@ Add `--json` to any command and read the last line of stdout:
51
62
 
52
63
  Errors are `{"ok":false,"error":"…","code":N}`. Exit codes: `2` not signed in, `3` cannot reach the server, `4` not enough credits, `5` the model declined the prompt, `64` bad usage, `1` anything else. Progress goes to stderr, results to stdout.
53
64
 
54
- The MIT-licensed agent skills that teach Claude Code, Cursor and Codex how to use this CLI well live in the [`skills`](../skills) directory.
65
+ The MIT-licensed agent skills that teach Claude Code, Cursor, Codex, Gemini CLI, Copilot and 20 other agents how to use this CLI well live at [github.com/onehermes/parley-skills](https://github.com/onehermes/parley-skills): `npx skills add onehermes/parley-skills`.
66
+
67
+ In a cloud sandbox or over SSH: `parley login --no-browser` prints the approval link, `PARLEY_TOKEN` skips login entirely, and `npx -y parley-live …` runs without a global install.
55
68
 
56
69
  ## Configuration
57
70
 
package/dist/client.js CHANGED
@@ -169,6 +169,39 @@ export function fileToDataUri(filePath) {
169
169
  export function isUrl(s) {
170
170
  return /^https?:\/\//i.test(s);
171
171
  }
172
+ function lastPath() {
173
+ return path.join(homeDir(), 'last.json');
174
+ }
175
+ export function loadLast() {
176
+ try {
177
+ const p = lastPath();
178
+ if (!existsSync(p))
179
+ return {};
180
+ const parsed = JSON.parse(readFileSync(p, 'utf8'));
181
+ return parsed && typeof parsed === 'object' ? parsed : {};
182
+ }
183
+ catch {
184
+ return {};
185
+ }
186
+ }
187
+ export function rememberLast(kind, entry) {
188
+ try {
189
+ mkdirSync(homeDir(), { recursive: true });
190
+ const cur = loadLast();
191
+ cur[kind] = { file: path.resolve(entry.file), url: entry.url, at: new Date().toISOString() };
192
+ writeFileSync(lastPath(), JSON.stringify(cur, null, 2) + '\n');
193
+ }
194
+ catch { /* best effort: chaining is a convenience, never a failure */ }
195
+ }
196
+ /** Resolve "@last" (or "@last-image" / "@last-video") to the most recent output of that kind. */
197
+ export function resolveRef(ref, kind) {
198
+ if (ref !== '@last' && ref !== `@last-${kind}`)
199
+ return ref;
200
+ const last = loadLast()[kind];
201
+ if (!last)
202
+ throw new CliError(`No previous ${kind} on this machine yet. Make one first (parley ${kind} …), then pass @last.`);
203
+ return last.url || last.file;
204
+ }
172
205
  export function sleep(ms) {
173
206
  return new Promise((r) => setTimeout(r, ms));
174
207
  }
package/dist/index.js CHANGED
@@ -16,8 +16,8 @@
16
16
  */
17
17
  import { readFileSync, existsSync, mkdirSync } from 'node:fs';
18
18
  import path from 'node:path';
19
- import { api, authWebUrl, backendUrl, clearCredentials, CliError, downloadTo, fileToDataUri, isUrl, loadCredentials, openInBrowser, saveCredentials, sleep, } from './client.js';
20
- const VERSION = '0.1.0';
19
+ import { api, authWebUrl, backendUrl, clearCredentials, CliError, downloadTo, fileToDataUri, isUrl, loadCredentials, loadLast, openInBrowser, rememberLast, resolveRef, saveCredentials, sleep, } from './client.js';
20
+ const VERSION = '0.2.0';
21
21
  function parseArgs(argv) {
22
22
  const out = { command: '', positionals: [], flags: {} };
23
23
  const rest = [...argv];
@@ -72,6 +72,10 @@ async function cmdLogin(p) {
72
72
  const backend = backendUrl(str(p.flags.backend));
73
73
  const started = await api('/api/auth/device/start', { method: 'POST', body: {}, auth: false, backend });
74
74
  const link = `${authWebUrl()}/desktop-auth?code=${encodeURIComponent(started.user_code)}`;
75
+ // --no-browser: print the link only. The right choice inside a cloud
76
+ // sandbox or over SSH, where "open a browser" means someone else's screen.
77
+ if (p.flags['no-browser'] === true)
78
+ process.env.PARLEY_NO_BROWSER = '1';
75
79
  const opened = openInBrowser(link);
76
80
  // Always on stderr, even in --json mode: an agent driving `parley login
77
81
  // --json` must be able to relay the link to the person who has to click it.
@@ -140,12 +144,22 @@ function outPathFor(p, defaultName) {
140
144
  async function cmdImage(p) {
141
145
  const prompt = p.positionals.join(' ').trim();
142
146
  if (!prompt)
143
- throw new CliError('Usage: parley image "<prompt>" [--model gpt-image-2|gemini-3-pro-image-preview] [--size 1024x1024|1024x1536|1536x1024] [--quality low|medium|high] [--image-size 1K|2K|4K] [--ref <file|url>]... [--out file.png]');
144
- const refs = list(p.flags.ref).map((r) => (isUrl(r) ? r : fileToDataUri(r)));
147
+ throw new CliError(`Usage: parley image "<prompt>" [--ratio ${Object.keys(RATIO_TO_SIZE).join('|')}] [--model gpt-image-2|gemini-3-pro-image-preview] [--size 1024x1024|1024x1536|1536x1024] [--quality low|medium|high] [--image-size 1K|2K|4K] [--ref <file|url|@last>]... [--out file.png]`);
148
+ const refs = list(p.flags.ref).map((r) => resolveRef(r, 'image')).map((r) => (isUrl(r) ? r : fileToDataUri(r)));
145
149
  const body = { prompt };
146
150
  const model = str(p.flags.model);
147
151
  if (model)
148
152
  body.model = model;
153
+ // --ratio is the destination-first way to size an image: it sets the exact
154
+ // gpt-image-2 size AND the aspect ratio the other models take, so the same
155
+ // flag works whichever model serves the request. --size still wins if given.
156
+ const ratio = str(p.flags.ratio);
157
+ if (ratio) {
158
+ if (!(ratio in RATIO_TO_SIZE))
159
+ throw new CliError(`--ratio must be one of ${Object.keys(RATIO_TO_SIZE).join(', ')}`);
160
+ body.aspectRatio = ratio;
161
+ body.gptImageSize = RATIO_TO_SIZE[ratio];
162
+ }
149
163
  const size = str(p.flags.size);
150
164
  if (size)
151
165
  body.gptImageSize = size;
@@ -175,8 +189,13 @@ async function cmdImage(p) {
175
189
  }
176
190
  else
177
191
  throw new CliError('The server returned no image.');
192
+ rememberLast('image', { file: out, url: r.imageUrl ?? null });
178
193
  emit({ ok: true, file: out, bytes, model: r.model, imageUrl: r.imageUrl ?? null, revisedPrompt: r.revisedPrompt ?? null }, `Saved ${out} (${Math.round(bytes / 1024)} KB, ${r.model})${r.imageUrl ? `\nHosted: ${r.imageUrl}` : ''}`);
179
194
  }
195
+ /** Destination ratio → the exact gpt-image-2 size (the other models take the ratio itself). */
196
+ const RATIO_TO_SIZE = {
197
+ '16:9': '1536x1024', '9:16': '1024x1536', '1:1': '1024x1024', '4:3': '1536x1024', '3:4': '1024x1536',
198
+ };
180
199
  const LOOKS = ['fast', 'standard', 'cinematic', 'photoreal', 'transform', 'camera'];
181
200
  const LOOK_TO_PROVIDER = {
182
201
  fast: 'hailuo', standard: 'kling', illustrated: 'kling', cinematic: 'veo', photoreal: 'seedance-2.0', transform: 'gemini-omni', camera: 'higgsfield-dop',
@@ -188,7 +207,8 @@ async function cmdVideo(p) {
188
207
  const look = str(p.flags.look, 'standard');
189
208
  if (!(look in LOOK_TO_PROVIDER))
190
209
  throw new CliError(`Unknown --look "${look}". Use one of: ${LOOKS.join(', ')}.`);
191
- const image = str(p.flags.image);
210
+ const imageFlag = str(p.flags.image);
211
+ const image = imageFlag ? resolveRef(imageFlag, 'image') : undefined;
192
212
  const imageFiles = image ? [isUrl(image) ? image : fileToDataUri(image)] : [];
193
213
  if (!image && look !== 'photoreal') {
194
214
  throw new CliError('Every look except "photoreal" animates a still: pass --image <file|url> (make one with `parley image` first), or use --look photoreal for text-to-video.');
@@ -226,6 +246,7 @@ async function cmdVideo(p) {
226
246
  const out = outPathFor(p, `parley-video-${jobId}.mp4`);
227
247
  const src = r.videoUrl || `${backendUrl()}/video/jobs/${jobId}/result?download=1`;
228
248
  const bytes = await downloadTo(src, out);
249
+ rememberLast('video', { file: out, url: r.videoUrl ?? null });
229
250
  emit({ ok: true, file: out, bytes, jobId, videoUrl: r.videoUrl ?? null, model: r.model ?? null, duration: r.duration ?? null, width: r.width ?? null, height: r.height ?? null, deduped: created.deduped === true }, `Saved ${out} (${Math.round(bytes / 1024)} KB, ${r.model || look}, ${r.duration ?? '?'}s)${r.videoUrl ? `\nHosted: ${r.videoUrl}` : ''}`);
230
251
  }
231
252
  async function cmdThumbnail(p) {
@@ -413,11 +434,79 @@ async function cmdApi(p) {
413
434
  else
414
435
  console.log(JSON.stringify(r, null, 2));
415
436
  }
437
+ /**
438
+ * parley doctor [--json]
439
+ * One call that tells an agent (or a person) exactly what state this machine
440
+ * is in and what to do next: Node version, whether the API is reachable,
441
+ * whether a session exists, the credit balance, which engines are live, and
442
+ * the last outputs available as @last. Exit 0 always; `ready` says whether a
443
+ * generation would succeed right now.
444
+ */
445
+ async function cmdDoctor() {
446
+ const report = { cli: VERSION, node: process.version, backendUrl: backendUrl() };
447
+ const fixes = [];
448
+ const major = Number(process.version.replace(/^v/, '').split('.')[0]);
449
+ report.nodeOk = major >= 20;
450
+ if (!report.nodeOk)
451
+ fixes.push(`Node ${process.version} is too old: install Node 20 or newer.`);
452
+ try {
453
+ const e = await api('/video/engines', { auth: false });
454
+ report.reachable = true;
455
+ report.engines = Object.fromEntries(Object.entries(e.engines).map(([k, v]) => [k, { ready: v.ready, creditsPerSec: v.creditsPerSec ?? null, fallback: v.fallback ?? null }]));
456
+ report.cameraMoves = Array.isArray(e.cameraMoves) ? e.cameraMoves.length : 0;
457
+ }
458
+ catch (e) {
459
+ report.reachable = false;
460
+ fixes.push(`Cannot reach ${backendUrl()} (${e?.message || e}). Check the network, or set PARLEY_BACKEND_URL if you use a different server.`);
461
+ }
462
+ const creds = loadCredentials();
463
+ report.signedIn = false;
464
+ if (!creds && !process.env.PARLEY_TOKEN) {
465
+ fixes.push('Not signed in: run `parley login` (or `parley login --no-browser` and open the printed link). In CI or a cloud sandbox, set PARLEY_TOKEN instead.');
466
+ }
467
+ else if (report.reachable) {
468
+ try {
469
+ const bal = await api('/api/credits/balance');
470
+ report.signedIn = true;
471
+ report.email = creds?.email ?? null;
472
+ report.credits = bal.credits_balance;
473
+ if (bal.credits_balance <= 0)
474
+ fixes.push('Credit balance is 0: top up at https://labs.parley.live before generating.');
475
+ }
476
+ catch (e) {
477
+ if (e instanceof CliError && e.exitCode === 2)
478
+ fixes.push('The stored session is no longer valid: run `parley login` again.');
479
+ else
480
+ fixes.push(`Could not read the balance: ${e?.message || e}`);
481
+ }
482
+ }
483
+ const last = loadLast();
484
+ report.last = { image: last.image?.url ?? last.image?.file ?? null, video: last.video?.url ?? last.video?.file ?? null };
485
+ report.ready = report.nodeOk === true && report.reachable === true && report.signedIn === true && (typeof report.credits !== 'number' || report.credits > 0);
486
+ report.fixes = fixes;
487
+ if (JSON_MODE) {
488
+ console.log(JSON.stringify({ ok: true, ...report }));
489
+ return;
490
+ }
491
+ console.log(`parley ${VERSION} on Node ${process.version}${report.nodeOk ? '' : ' (too old)'}`);
492
+ console.log(`server ${report.backendUrl} ${report.reachable ? 'reachable' : 'UNREACHABLE'}`);
493
+ console.log(`session ${report.signedIn ? `signed in as ${report.email || 'user'}, ${report.credits} credits` : 'not signed in'}`);
494
+ if (report.engines) {
495
+ const live = Object.entries(report.engines).filter(([, v]) => v.ready).map(([k]) => k);
496
+ console.log(`engines ${live.join(', ')} (${report.cameraMoves} camera moves)`);
497
+ }
498
+ if (report.last && (report.last.image || report.last.video))
499
+ console.log(`last image=${report.last.image || '-'} video=${report.last.video || '-'}`);
500
+ console.log(`ready ${report.ready ? 'yes' : 'no'}`);
501
+ for (const f of fixes)
502
+ console.log(` fix: ${f}`);
503
+ }
416
504
  function help(topic) {
417
505
  const lines = {
418
- login: 'parley login [--backend URL]\n Sign in once in your browser. Stores a session token under ~/.parley (or $PARLEY_HOME).',
419
- image: 'parley image "<prompt>" [--model gpt-image-2|gemini-3-pro-image-preview] [--size 1024x1024|1024x1536|1536x1024] [--quality low|medium|high] [--image-size 1K|2K|4K] [--ref <file|url>]... [--out file]\n Generate an image. --ref passes reference images (identity / style locks).',
420
- video: `parley video "<motion prompt>" --image <file|url> [--look ${LOOKS.join('|')}] [--move <slug>]... [--duration 5] [--ratio 16:9|9:16|1:1] [--out clip.mp4]\n Animate a still (or --look photoreal for text-to-video). --move adds named camera moves; see \`parley camera-moves\`.`,
506
+ doctor: 'parley doctor [--json]\n Node, server, session, credits, live engines and the @last outputs, with the fix for anything missing. Run this first.',
507
+ login: 'parley login [--no-browser] [--backend URL]\n Sign in once in your browser. --no-browser prints the link instead of opening it (cloud sandboxes, SSH). Stores a session token under ~/.parley (or $PARLEY_HOME).',
508
+ image: `parley image "<prompt>" [--ratio ${Object.keys(RATIO_TO_SIZE).join('|')}] [--model gpt-image-2|gemini-3-pro-image-preview] [--quality low|medium|high] [--image-size 1K|2K|4K] [--ref <file|url|@last>]... [--out file]\n Generate an image. --ratio sizes it for the destination; --ref passes reference images (identity / style locks).`,
509
+ video: `parley video "<motion prompt>" --image <file|url|@last> [--look ${LOOKS.join('|')}] [--move <slug>]... [--duration 5] [--ratio 16:9|9:16|1:1] [--out clip.mp4]\n Animate a still (@last = the image you just made) or --look photoreal for text-to-video. --move adds named camera moves; see \`parley camera-moves\`.`,
421
510
  thumbnail: 'parley thumbnail "<video title>" [--variants 1-3] [--overlay "2-4 WORDS"] [--accent WORD] [--person true|false] [--out dir]',
422
511
  'ugc-ad': `parley ugc-ad --product <image> --name "Product" [--actor maya|jordan|sofia|marcus | --face <image>] [--angle ${ANGLES.join('|')}] [--marketplace ${MARKETPLACES.join('|')}] [--points "a;b;c"] [--seconds 3-16] [--url https://...] [--out ad.mp4]`,
423
512
  youtube: `parley youtube --beat <https still>... [--narration <https audio>] [--music <https audio>] [--tier ${YT_TIERS.join('|')}] [--captions] [--silent] [--project <workspace id>] [--out video.mp4]\n Animate ordered beat stills and cut them to the narration into one finished video.`,
@@ -444,6 +533,10 @@ async function main(argv) {
444
533
  case 'login':
445
534
  await cmdLogin(p);
446
535
  break;
536
+ case 'doctor':
537
+ case 'status':
538
+ await cmdDoctor();
539
+ break;
447
540
  case 'logout':
448
541
  await cmdLogout();
449
542
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parley-live",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Generate images, video clips, YouTube thumbnails and product ads from the terminal with Parley. Built for coding agents: every command has a --json mode.",
5
5
  "license": "MIT",
6
6
  "type": "module",