@sprid/cli 0.1.19 → 1.0.2

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/CHANGELOG.md CHANGED
@@ -3,6 +3,32 @@
3
3
  sprid follows semver: a breaking change to a command's arguments, its output
4
4
  shape or its exit codes is a major release.
5
5
 
6
+ ## 1.0.2 - 2026-10-01
7
+
8
+ - Local video previews include a safe-zone toggle using the editor's approximate
9
+ bottom and right interface bands. Guides follow the actual picture through
10
+ letterboxing, resizing and feed view. They never appear in exported videos.
11
+ - Screen-reel captions stay above the bottom band and left of the action rail,
12
+ including their entrance animation. Oversized caption cards fail with a
13
+ readable error instead of drawing under the platform interface.
14
+
15
+ ## 1.0.1 - 2026-10-01
16
+
17
+ - Local post previews use a compact artwork grid and a caption comparison list.
18
+ Clicking a post opens its media beside the caption, with icon controls and
19
+ file details on demand. Search and lane filters also scope post navigation.
20
+ - The preview follows the system theme, with a light/dark switch. Needs attention
21
+ includes missing files and captions, failed checks, and changed files or captions.
22
+ Carousel controls, keyboard navigation and the feed view remain available offline.
23
+ - A sound toggle above the grid also controls hover playback. The sun icon’s
24
+ diagonal rays render correctly.
25
+ - Feed previews preload every visible post plus one beyond each edge, and
26
+ keep outgoing video frames until scrolling stops.
27
+
28
+ ## 1.0.0 - 2026-09-30
29
+
30
+ - Breaking: Sprid record IDs are UUIDs. Post and review references remain accepted. Incremental IDs are refused.
31
+
6
32
  ## 0.1.19 - 2026-09-30
7
33
 
8
34
  - `sprid docs chat` uses the consolidated post and slide tools. Asset creation
package/README-post.md CHANGED
@@ -405,21 +405,27 @@ bunx @sprid/cli post preview --open # file://, fine for watching, cannot s
405
405
  bunx @sprid/cli post preview --lane collage --copy --out ~/Desktop/reels
406
406
  ```
407
407
 
408
- It reads the registry and nothing else, so it is the same command in every repo,
409
- and it writes one disposable HTML file with **two views**, because a batch and a
410
- post are two different questions:
411
-
412
- - **Grid.** Everything at once, small. Hover plays a reel and walks a deck, the filter matches slug and
413
- caption, and a card carries the caption itself and nothing derived from it -
414
- the length, the loudness, the check result and the Sprid state ride along as
415
- badges. This is where you see repetition, an outlier, a caption that ran long.
416
- - **Feed.** One post at reading size with its caption under it, arrow keys
417
- between them, `m` for sound, `esc` back. A carousel advances by itself at
418
- reading pace (`space` stops it, `[` `]` step it, the dots say how many are
419
- left), because the question a batch review asks of a deck is whether it holds
420
- together as one set, and that is a thing you watch rather than click through. It is the only view that answers
421
- *would I stop on this*, because it is the shape a reader meets. Each reel has
422
- its own `#slug` address, so a position is a link.
408
+ The video viewer has a **Safe zones** toggle: approximate bottom 20% and right 14% from 38% down to the bottom band. Hatching follows the video picture, including letterboxing; keep captions and important graphics outside it. This guide is not a guarantee of platform UI placement. Screen-reel captions use the same margins when rendered.
409
+
410
+ It reads the local registry and writes one self-contained HTML file. The preview
411
+ uses Sprid’s paper colours, follows the system theme, and works offline.
412
+
413
+ - **Grid.** A compact picture grid. Hover plays a reel or walks through a deck.
414
+ The sound button above the grid, or `m`, unmutes hover playback.
415
+ Click opens the post with its full caption beside the media, copy controls,
416
+ and file details. Missing files remain visible.
417
+ - **List.** One row per post, with the caption’s opening line, lane, length and
418
+ check state. Use it to compare captions across a batch.
419
+ - **Feed.** One post at reading size, with its caption underneath. Swipe sideways
420
+ or use arrow keys between posts, `m` for sound, and `esc` to return. Carousels
421
+ advance automatically; `space` pauses, `[` and `]` step slides manually.
422
+ Visible media and one extra post beyond each edge preload. Outgoing videos
423
+ stay loaded until scrolling stops.
424
+
425
+ Search matches the local ID, lane and caption. The lane selector and
426
+ **Needs attention** filter also apply to navigation between posts. Needs attention
427
+ includes failed checks, missing media or captions, and files or captions changed
428
+ since their saved record. Every post has a `#slug` address.
423
429
 
424
430
  **`--serve` watches and reloads.** The registry, the lanes' media directories
425
431
  and this package's own source are all watched; a change rebuilds the page and
package/README.md CHANGED
@@ -153,7 +153,7 @@ Use `sprid use <slug>` to save a choice; it also works with an environment token
153
153
 
154
154
  ```json
155
155
  { "apiUrl": "https://api.sprid.studio", "token": "sprd_…", "tokenId": 7,
156
- "workspace": { "id": 1, "slug": "example-team", "name": "Example Team", "tier": "starter" },
156
+ "workspace": { "id": "9cc2a060-e371-4df3-8432-88dcaa207628", "slug": "example-team", "name": "Example Team", "tier": "starter" },
157
157
  "email": "you@example.com" }
158
158
  ```
159
159
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprid/cli",
3
- "version": "0.1.19",
3
+ "version": "1.0.2",
4
4
  "description": "The Sprid command line: connect your app, review results, prepare posts and store screenshots, and release mobile apps.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -38,8 +38,8 @@
38
38
  "node": ">=18"
39
39
  },
40
40
  "dependencies": {
41
- "@sprid/shots": "0.1.2",
42
- "@sprid/release": "0.1.2"
41
+ "@sprid/shots": "0.1.3",
42
+ "@sprid/release": "1.0.0"
43
43
  },
44
44
  "publishConfig": {
45
45
  "access": "public"
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { UsageError } from "../args.mjs";
7
7
  import { C, OK } from "../format.mjs";
8
- import { callTool, needInt, readJson, show } from "../tools.mjs";
8
+ import { callTool, needId, readJson, show } from "../tools.mjs";
9
9
  import { confirm } from "./reviews.mjs";
10
10
 
11
11
  const USAGE = [
@@ -37,7 +37,7 @@ export async function ads(ctx) {
37
37
  return 0;
38
38
  }
39
39
  if (READS[sub]) {
40
- const adSetId = needInt(arg, `sprid ads ${sub} <adSetId> --account <slug>`);
40
+ const adSetId = needId(arg, `sprid ads ${sub} <adSetId> --account <slug>`);
41
41
  const days = ctx.flags.days && sub !== "status" ? { days: Number(ctx.flags.days) } : {};
42
42
  show(ctx, await callTool(ctx, READS[sub], { account, adSetId, ...days }));
43
43
  return 0;
@@ -48,11 +48,11 @@ export async function ads(ctx) {
48
48
  show(ctx, await callTool(ctx, tool, { ...(await readJson(ctx, ctx.flags.file)), account }));
49
49
  return 0;
50
50
  }
51
- if (sub === "launch") return launch(ctx, account, needInt(arg, "sprid ads launch <adSetId> --account <slug> [--yes]"));
52
- if (sub === "budget") return budget(ctx, account, needInt(arg, "sprid ads budget <adSetId> --daily-cents N --account <slug> [--yes]"));
53
- if (sub === "pause") return pause(ctx, account, needInt(arg, "sprid ads pause <adSetId> --account <slug> [--yes]"));
51
+ if (sub === "launch") return launch(ctx, account, needId(arg, "sprid ads launch <adSetId> --account <slug> [--yes]"));
52
+ if (sub === "budget") return budget(ctx, account, needId(arg, "sprid ads budget <adSetId> --daily-cents N --account <slug> [--yes]"));
53
+ if (sub === "pause") return pause(ctx, account, needId(arg, "sprid ads pause <adSetId> --account <slug> [--yes]"));
54
54
  if (sub === "close") {
55
- show(ctx, await callTool(ctx, "close_ad_set", { account, adSetId: needInt(arg, "sprid ads close <adSetId> --account <slug>") }));
55
+ show(ctx, await callTool(ctx, "close_ad_set", { account, adSetId: needId(arg, "sprid ads close <adSetId> --account <slug>") }));
56
56
  return 0;
57
57
  }
58
58
  throw new UsageError(USAGE);
@@ -1,3 +1,4 @@
1
+ import { needId } from "../tools.mjs";
1
2
  import { readFileSync } from "node:fs";
2
3
  import { extname, resolve } from "node:path";
3
4
  import { UsageError } from "../args.mjs";
@@ -15,12 +16,7 @@ function objectFile(ctx) {
15
16
  throw new UsageError("Expected a JSON object.");
16
17
  return data;
17
18
  }
18
- function positiveId(raw) {
19
- const id = Number(raw);
20
- if (!Number.isInteger(id) || id <= 0)
21
- throw new UsageError("Pass a positive account ID.");
22
- return id;
23
- }
19
+ const positiveId = (raw) => needId(raw, "Pass an account UUID.");
24
20
  export async function apps(ctx) {
25
21
  ctx.out(await ctx.api().get("/api/app-profiles/hierarchy"));
26
22
  return 0;
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { UsageError } from "../args.mjs";
7
7
  import { C, OK } from "../format.mjs";
8
- import { callTool, listFlag } from "../tools.mjs";
8
+ import { callTool, listFlag, needId } from "../tools.mjs";
9
9
  import { confirm, stars, truncate } from "./reviews.mjs";
10
10
 
11
11
  const USAGE = "sprid inbox [--view needs_you|all|done] [--kind all|comment|review] [--accounts a,b] [--app <slug>] [--limit N] | draft <commentId> | reply <commentId> --text \"…\" [--yes] | done|flag|reopen <c:id|r:id>";
@@ -58,11 +58,11 @@ function render(item) {
58
58
  return lines.join("\n");
59
59
  }
60
60
 
61
- /** `c:12` or a bare `12` is a comment; `r:41` is a store review. */
61
+ /** `c:UUID` or a bare UUID is a comment; `r:UUID` is a store review. */
62
62
  function parseRef(ref, usage) {
63
- const m = String(ref ?? "").match(/^(?:(c|r):)?([1-9]\d*)$/i);
63
+ const m = String(ref ?? "").match(/^(?:(c|r):)?([0-9a-f-]+)$/i);
64
64
  if (!m) throw new UsageError(usage);
65
- return { kind: (m[1] ?? "c").toLowerCase() === "r" ? "review" : "comment", id: Number(m[2]) };
65
+ return { kind: (m[1] ?? "c").toLowerCase() === "r" ? "review" : "comment", id: needId(m[2], usage) };
66
66
  }
67
67
 
68
68
  async function draft(ctx, ref) {
@@ -102,12 +102,12 @@ export async function init(ctx) {
102
102
  }
103
103
 
104
104
  const wid = await ctx.workspaceId();
105
- if (local?.data.workspaceId && Number(local.data.workspaceId) !== Number(wid))
105
+ if (local?.data.workspaceId && local.data.workspaceId !== wid)
106
106
  throw new UsageError(`This repo belongs to workspace ${local.data.workspaceId}. Use --workspace ${local.data.workspaceId}; do not register it in a different workspace.`);
107
107
  const profiles = await listProfiles(ctx, wid);
108
108
  const pinnedId = local?.data.appId;
109
109
  const existing = pinnedId
110
- ? profiles.find(p => Number(p.id) === Number(pinnedId))
110
+ ? profiles.find(p => p.id === pinnedId)
111
111
  : profiles.find(p => input.slug && p.slug === input.slug);
112
112
  if (pinnedId && !existing) throw new UsageError(`App ${pinnedId} is unavailable in workspace ${wid}. Restore access before registering again.`);
113
113
  // A server rename does not change the app this repo is attached to.
@@ -1,3 +1,4 @@
1
+ import { needRef } from "../tools.mjs";
1
2
  // open / invite / upgrade: deep links and the two workspace verbs.
2
3
 
3
4
  import { UsageError } from "../args.mjs";
@@ -13,8 +14,7 @@ export async function open(ctx) {
13
14
  path = "/";
14
15
  break;
15
16
  case "post": {
16
- const id = Number(arg);
17
- if (!Number.isInteger(id) || id <= 0) throw new UsageError("sprid open post <id>");
17
+ const id = needRef(arg, "sprid open post <UUID|ref>");
18
18
  path = `/p/${id}`;
19
19
  break;
20
20
  }
@@ -92,7 +92,7 @@ export async function pinterest(ctx) {
92
92
  if ((value.description?.length ?? 0) > 800) throw new UsageError("Pinterest description exceeds 800 characters.");
93
93
  if ((value.altText?.length ?? 0) > 500) throw new UsageError("Pinterest alt text exceeds 500 characters.");
94
94
  const updated = await ctx.api().patch(`/api/posts/${ref}`, { pinterestOptions: value });
95
- if (ctx.json) ctx.out({ ok: true, postId: Number(ref), pinterestOptions: updated.pinterestOptions });
95
+ if (ctx.json) ctx.out({ ok: true, postId: updated.id, pinterestOptions: updated.pinterestOptions });
96
96
  else ctx.print(` ${C.ok("✓")} Pinterest details saved on post ${ref}. Review the visual and schedule in Sprid.`);
97
97
  return 0;
98
98
  }
@@ -139,7 +139,7 @@ export async function plan(ctx) {
139
139
  const original = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
140
140
  if (input.artifact?.body !== original) throw new UsageError('The proposed artifact must contain the original saved text exactly. Revise the shared artifact after importing.');
141
141
  const current = await ctx.api().get(`${base}?${scoped}`);
142
- const wid = Number(new URLSearchParams(scoped).get('workspaceId'));
142
+ const wid = new URLSearchParams(scoped).get('workspaceId');
143
143
  if ((state.appId && state.appId !== current.appProfile.id) || (state.workspaceId && state.workspaceId !== wid) || (state.apiUrl && state.apiUrl !== ctx.auth().apiUrl)) throw new UsageError('The local task belongs to another app, workspace or server.');
144
144
  const response = await ctx.api().raw('POST', `${base}/actions/prepare?${scoped}`, { ...input, idempotencyKey: `local-task:${state.key}` });
145
145
  if (response.status < 200 || response.status >= 300) { ctx.out(response.data); return 1; }
@@ -149,7 +149,7 @@ export async function plan(ctx) {
149
149
  }
150
150
  if (verb === 'link-local') {
151
151
  const result = await ctx.api().get(`${base}?${scoped}`);
152
- ctx.out(linkLocalTask(ctx.cwd, result, decodeURIComponent(id(ctx)), ctx.auth().apiUrl, Number(new URLSearchParams(scoped).get('workspaceId'))));
152
+ ctx.out(linkLocalTask(ctx.cwd, result, decodeURIComponent(id(ctx)), ctx.auth().apiUrl, new URLSearchParams(scoped).get('workspaceId')));
153
153
  return 0;
154
154
  }
155
155
  if (verb === 'get' || verb === 'export') {
@@ -1,3 +1,4 @@
1
+ import { needRef } from "../tools.mjs";
1
2
  // sprid post video <postId> read the post's own video
2
3
  // sprid post video render <postId> ... render it to MP4 (spends credits)
3
4
  //
@@ -13,7 +14,6 @@ import { UsageError } from "../args.mjs";
13
14
  import { C, OK } from "../format.mjs";
14
15
  import { callTool } from "../tools.mjs";
15
16
 
16
- const POST_REF = /^([1-9]\d*|[A-Za-z][A-Za-z0-9]{1,4}-\d+)$/;
17
17
  const READ_USAGE = "sprid post video <postId>";
18
18
  const RENDER_USAGE = "sprid post video render <postId> [--confirm] [--ceiling N] [--final]";
19
19
 
@@ -24,8 +24,7 @@ export async function postVideo(ctx) {
24
24
  }
25
25
 
26
26
  function postRef(ref, usage) {
27
- if (!POST_REF.test(String(ref ?? ""))) throw new UsageError(usage);
28
- return /^\d+$/.test(ref) ? Number(ref) : String(ref).toUpperCase();
27
+ return needRef(ref, usage);
29
28
  }
30
29
 
31
30
  /** A refused tool call answers `{ok: false, error}`; print it and fail. */
@@ -1,3 +1,4 @@
1
+ import { needRef } from "../tools.mjs";
1
2
  // sprid post schedule | publish | schedule-batch: putting a post in front of
2
3
  // an audience, through the same tools an agent calls. Publishing now is public
3
4
  // and cannot be taken back, so it asks unless --yes. A booking can be taken
@@ -14,7 +15,6 @@ import { C, OK } from "../format.mjs";
14
15
  import { callTool, listFlag, readJson, show } from "../tools.mjs";
15
16
  import { confirm } from "./reviews.mjs";
16
17
 
17
- const POST_REF = /^([1-9]\d*|[A-Za-z][A-Za-z0-9]{1,4}-\d+)$/;
18
18
 
19
19
  export const PUBLISHING_VERBS = ["schedule", "publish", "schedule-batch"];
20
20
 
@@ -24,8 +24,7 @@ export async function publishing(ctx) {
24
24
  const usage = verb === "publish"
25
25
  ? "sprid post publish <postId> [--platforms a,b] [--options <options.json>] [--video-render <renderId>] [--yes]"
26
26
  : "sprid post schedule <postId> (--at ISO|--next|--queue-end) [--platforms a,b] [--time HH:MM] [--shift-later] [--options <options.json>] [--video-render <renderId>]";
27
- if (!POST_REF.test(String(ref ?? ""))) throw new UsageError(usage);
28
- const id = /^\d+$/.test(ref) ? Number(ref) : String(ref).toUpperCase();
27
+ const id = needRef(ref, usage);
29
28
  const base = { id, ...(await destination(ctx)) };
30
29
  return verb === "publish" ? publish(ctx, base) : schedule(ctx, base, usage);
31
30
  }
@@ -105,7 +104,7 @@ async function scheduleBatch(ctx) {
105
104
  if (typeof ctx.flags.account !== "string" || !ids) throw new UsageError(usage);
106
105
  const args = {
107
106
  account: ctx.flags.account,
108
- postIds: ids.map((v) => (/^\d+$/.test(v) ? Number(v) : v.toUpperCase())),
107
+ postIds: ids.map((v) => needRef(v, "Use post UUIDs or references in --ids.")),
109
108
  ...(await destination(ctx)),
110
109
  ...(typeof ctx.flags.cadence === "string" ? { cadence: ctx.flags.cadence } : {}),
111
110
  ...(listFlag(ctx.flags.times) ? { times: listFlag(ctx.flags.times) } : {}),
@@ -1,3 +1,4 @@
1
+ import { needRef } from "../tools.mjs";
1
2
  // reviews: what users wrote in the stores, the reply the workspace's model
2
3
  // drafted, and the one call that sends it. Sending is public and cannot be
3
4
  // undone on Play, so `reply` is the single interactive prompt in the CLI:
@@ -57,7 +58,7 @@ export function truncate(s, n = 200) {
57
58
 
58
59
  export function renderReview(r) {
59
60
  const date = r.reviewedAt ? String(r.reviewedAt).slice(0, 10) : "";
60
- const head = ` ${stars(r.rating)} ${r.app?.slug ?? r.appProfileId} ${STORE_LABEL[r.source] ?? r.source} ${date} ${C.dim(`#${r.id}`)}`;
61
+ const head = ` ${stars(r.rating)} ${r.app?.slug ?? r.appProfileId} ${STORE_LABEL[r.source] ?? r.source} ${date} ${C.dim(r.ref ?? r.id)}`;
61
62
  const lines = [head];
62
63
  const text = [r.title, r.body].filter(Boolean).join(" · ");
63
64
  if (text) lines.push(` ${truncate(text)}`);
@@ -70,17 +71,8 @@ export function renderReview(r) {
70
71
  return lines.join("\n");
71
72
  }
72
73
 
73
- // A review is named by its row id (41) or its ref (BND-R41); the server
74
- // resolves either, so the CLI only checks the shape.
75
- function needId(arg, usage) {
76
- const id = String(arg ?? "").trim();
77
- if (/^[1-9]\d*$/.test(id)) return Number(id); // --json keeps printing a number
78
- if (/^[A-Za-z][A-Za-z0-9]{1,4}-[Rr]\d+$/.test(id)) return id.toUpperCase();
79
- throw new UsageError(usage);
80
- }
81
-
82
- /** `BND-R41` as is; a bare row id as `#41`. */
83
- const label = (id) => (/^\d+$/.test(String(id)) ? `#${id}` : String(id));
74
+ const needId = (arg, usage) => needRef(arg, usage, true);
75
+ const label = (id) => String(id);
84
76
 
85
77
  async function draft(ctx, arg) {
86
78
  const id = needId(arg, "sprid reviews draft <id|ref>");
@@ -1,3 +1,4 @@
1
+ import { needId } from "../tools.mjs";
1
2
  import { readLocalApp } from "../profiles.mjs";
2
3
  // status / next / queue. Every line here is one field of the StatusView the
3
4
  // server computes; the web Home card and the Monday mail print the same list.
@@ -187,16 +188,14 @@ export async function next(ctx) {
187
188
  export async function queue(ctx) {
188
189
  const [sub, arg] = ctx.positionals;
189
190
  if (sub === "retry") {
190
- const id = Number(arg);
191
- if (!Number.isInteger(id) || id <= 0) throw usage("sprid queue retry <publishId>");
191
+ const id = needId(arg, "sprid queue retry <publishId>");
192
192
  const row = await ctx.api().post(`/api/publishes/${id}/retry`);
193
193
  if (ctx.json) ctx.out({ ok: true, publish: row });
194
194
  else ctx.print(` ${C.ok(OK)} Publish ${id} is back on the queue${row?.scheduledFor ? ` for ${dayTime(row.scheduledFor)}` : ""}.`);
195
195
  return 0;
196
196
  }
197
197
  if (sub === "cancel") {
198
- const id = Number(arg);
199
- if (!Number.isInteger(id) || id <= 0) throw usage("sprid queue cancel <publishId>");
198
+ const id = needId(arg, "sprid queue cancel <publishId>");
200
199
  await ctx.api().del(`/api/publishes/${id}`);
201
200
  if (ctx.json) ctx.out({ ok: true, publishId: id });
202
201
  else ctx.print(` ${C.ok(OK)} Booking ${id} cancelled. The post stays as it is.`);
@@ -1,3 +1,4 @@
1
+ import { needRef } from "../tools.mjs";
1
2
  import { readFile, stat } from 'node:fs/promises';
2
3
  import { basename, resolve, extname } from 'node:path';
3
4
  import { randomUUID, createHash } from 'node:crypto';
@@ -13,10 +14,8 @@ export async function remotePost(ctx) {
13
14
  const [verb, ref] = ctx.positionals;
14
15
  let id = ctx.flags.id ?? ref;
15
16
  if (!['create', 'get', 'update', 'deliveries', 'handoff'].includes(verb) && !(verb === 'preview' && ctx.flags.id)) return false;
16
- // The server post id (271) or its ref (BND-78); the server resolves either.
17
- if (verb !== 'create' && !/^([1-9]\d*|[A-Za-z][A-Za-z0-9]{1,4}-\d+)$/.test(String(id))) throw new UsageError('Supply the server post ID or its ref, such as 271 or BND-78. Local preview slugs remain unchanged.');
17
+ if (verb !== 'create') id = encodeURIComponent(needRef(String(id ?? ''), 'Supply the post UUID or its reference, such as SPR-9.'));
18
18
  const api = ctx.api();
19
- if (verb !== 'create') id = encodeURIComponent(String(id).toUpperCase());
20
19
  const result = verb === 'create' ? await api.post('/api/studio/posts', await payload(ctx))
21
20
  : verb === 'update' ? await api.patch(`/api/posts/${id}`, await payload(ctx))
22
21
  : verb === 'preview' ? await api.get(`/api/studio/posts/${id}/preview`)
@@ -29,7 +29,7 @@ export const COMMAND_GROUPS = [
29
29
  ['media', 'sprid media job <UUID>', 'Read saved upload results or creation progress without repeating the operation.'],
30
30
  ['media', 'sprid media init|build|register|check|push|preview …', 'Run the existing local media pipeline. Historical sprid post invocations remain supported.'],
31
31
  ['post', 'sprid post create --file <draft.json>', 'Create a shared draft from ordered asset references and a stable requestId. With "status": "idea" it saves an idea instead: notes, a sourceUrl and any gathered assets, before layout. Never publishes.'],
32
- ['post', 'sprid post get <postId>', 'Read the shared post and its slides. Every <postId> here takes the number or the post\'s ref, such as BND-142.'],
32
+ ['post', 'sprid post get <postId>', 'Read the shared post and its slides. Every <postId> here takes the UUID or the post\'s ref, such as BND-142.'],
33
33
  ['post', 'sprid post update <postId> --file <changes.json>', 'Apply the existing post-update contract to a shared draft.'],
34
34
  ['post', 'sprid post preview --id <postId>', 'Preview a shared post. Without --id, historical local preview arguments are unchanged.'],
35
35
  ['post', 'sprid post deliveries <postId>', 'Read scheduled, failed and delivered publications with platform receipts.'],
@@ -104,7 +104,7 @@ export const COMMAND_GROUPS = [
104
104
  ['queue', 'sprid queue retry <publishId>', 'Retry a failed or missed publish.'],
105
105
  ['queue', 'sprid queue cancel <publishId>', 'Cancel a booking. The post itself is unchanged and can be scheduled again.'],
106
106
  ['post', 'sprid post schedule <postId> (--at ISO|--next|--queue-end) [--platforms a,b] [--time HH:MM] [--shift-later] [--options <options.json>] [--video-render <renderId>]', 'Book a post: at a time, the next free slot, or after everything already booked. --shift-later moves later bookings down a slot. A destination that goes out as the post\'s video needs --video-render, a final render of the video as it is now. TikTok goes as a draft to the TikTok inbox: {"tiktok": {"postMode": "MEDIA_UPLOAD"}} in --options. A direct TikTok post is answered and confirmed by the person on the review link the refusal prints. A Pinterest Pin needs its board and destination link saved first with sprid pinterest set.'],
107
- ['post', 'sprid post schedule-batch --account <slug> --ids 1,2,3 [--cadence daily|every-other-day|weekdays] [--times 09:00] [--start YYYY-MM-DD] [--platforms a,b] [--skip-busy] [--dry-run]', 'Place a list of posts on a cadence in one call. --dry-run returns the plan without booking anything.'],
107
+ ['post', 'sprid post schedule-batch --account <slug> --ids SPR-9,SPR-10 [--cadence daily|every-other-day|weekdays] [--times 09:00] [--start YYYY-MM-DD] [--platforms a,b] [--skip-busy] [--dry-run]', 'Place a list of posts on a cadence in one call. --dry-run returns the plan without booking anything.'],
108
108
  ['post', 'sprid post publish <postId> [--platforms a,b] [--options <options.json>] [--video-render <renderId>] [--yes]', 'Publish now. Public and not reversible, so it asks unless --yes. Read the receipts with sprid post deliveries.'],
109
109
  ['inbox', 'sprid inbox [--view needs_you|all|done] [--kind all|comment|review] [--accounts a,b] [--app <slug>] [--intent <intent>] [--limit N]', 'Comments on posts and ads plus store reviews, as one ranked queue.'],
110
110
  ['inbox', 'sprid inbox draft <commentId> [--count N]', 'Draft replies in the account’s reply voice. Nothing is sent.'],
@@ -168,7 +168,7 @@ export const COMMAND_EXAMPLES = {
168
168
  status: ['sprid status --workspace my-team --json'],
169
169
  docs: ['sprid docs search posthog permissions', 'sprid docs posthog --offline'],
170
170
  connect: ['sprid docs posthog', 'sprid connect instagram --account myapp'],
171
- post: ['sprid post create --file draft.json', 'sprid post get 123 --json', 'sprid post preview --id 123', 'sprid post check launch --deep'],
171
+ post: ['sprid post create --file draft.json', 'sprid post get SPR-9 --json', 'sprid post preview --id SPR-9', 'sprid post check launch --deep'],
172
172
  media: ['sprid media upload cover.jpg detail.jpg --account myapp', 'sprid media build launch'],
173
173
  'marketing-review': ['sprid marketing-review --app myapp --days 7 --json', 'sprid marketing-review capabilities --app myapp --source gsc --json'],
174
174
  ads: ['sprid ads --account myapp', 'sprid ads launch 12 --account myapp'],
@@ -1612,8 +1612,8 @@ export const CONTENT_GUIDES = [
1612
1612
  "title": "Build locally and send to Sprid",
1613
1613
  "summary": "Build content with your own tools, upload the finished files to Sprid, and keep editing, reviewing and measuring the same posts in chat or the dashboard.",
1614
1614
  "url": "https://sprid.studio/docs/local",
1615
- "markdown": "# Build locally and send to Sprid\n\n**What this guide does:** Build content with your own tools, upload the finished files to Sprid, and keep editing, reviewing and measuring the same posts in chat or the dashboard.\n\nTwo command families: `sprid media` is local production and file transfer (`init`, `build`, `check`, `register`, `push`, `preview`, `upload`). `sprid post` works on shared posts on the server. If sign-in fails, the local result stays usable. Keep the same post and plan IDs when you move work into Sprid.\n\n## Build\n\nUse your app’s own renderer or design tools, or a built-in recipe:\n\n```sh\nsprid media init # set up the stills or simulator-screen recipe\nsprid media build <slug>\nsprid media check <slug>\n```\n\nA TS/JS config runs local code, so only use one you trust. Native captures and binary builds need your app’s own environment. A local slug stays local; a shared preview is always `sprid post preview --id <postId>`.\n\n## Upload\n\n```sh\nsprid login\nsprid media upload slide-01.png slide-02.png --account my-account --json\n```\n\nFiles go straight to storage and come back as ordered asset IDs. PNG, JPEG, WebP and MP4, up to 32 MiB each; larger videos go through `sprid media push`. The command prints a request ID first. To resume, rerun with the same files in the same order and `--request-id <UUID>`, or read the saved result with `sprid media job <UUID>`. Don’t reuse a request ID for a different selection.\n\n## Create and review the post\n\nWrite `draft.json` with the returned IDs and a new UUID as `requestId` (keep it for retries):\n\n```json\n{\n \"account\": \"my-account\",\n \"requestId\": \"5e253644-d8a5-4b18-9cc9-d4d921874138\",\n \"title\": \"A concrete moment\",\n \"captionInstagram\": \"The caption, with hashtags at the end\",\n \"aspectRatio\": \"4:5\",\n \"presentation\": \"finished\",\n \"assets\": [{ \"kind\": \"image\", \"id\": 101 }, { \"kind\": \"image\", \"id\": 102 }]\n}\n```\n\nFor a finished reel, use one asset with `kind: \"video\"`. `finished` artwork gets no overlay or music; images fit inside the canvas, so check the preview for margins.\n\n```sh\nsprid post create --file draft.json --json\nsprid post get 123 --json\nsprid post update 123 --file changes.json --json # e.g. {\"captionInstagram\":\"Revised caption\"}\nsprid post preview --id 123 --json\nsprid post deliveries 123 --json\n```\n\nThe review link opens the same dashboard screen as from chat. To continue in chat, give it post ID `123` instead of creating another draft. Approval and platform choices stay with a person. Check delivery receipts before reporting success.\n\n## Hosted options and results\n\n- `sprid capabilities --json`: server limits and what stays local.\n- `sprid media import --file sources.json`: the same account/files/urls/assets payload as MCP `import_assets`.\n- `sprid media reel --file recipe.json`: the metered hosted reel. Payload and approval rules are in `sprid docs chat`. Poll with `sprid media job <UUID>`.\n- `sprid marketing-review`: connected evidence. `sprid docs marketing-review` covers coverage and attribution.\n\nKeys go in through CLI key-file commands or the secure Sprid forms, never a conversation.\n",
1616
- "revision": "9fca88d8e62aa719"
1615
+ "markdown": "# Build locally and send to Sprid\n\n**What this guide does:** Build content with your own tools, upload the finished files to Sprid, and keep editing, reviewing and measuring the same posts in chat or the dashboard.\n\nTwo command families: `sprid media` is local production and file transfer (`init`, `build`, `check`, `register`, `push`, `preview`, `upload`). `sprid post` works on shared posts on the server. If sign-in fails, the local result stays usable. Keep the same post and plan IDs when you move work into Sprid.\n\n## Build\n\nUse your app’s own renderer or design tools, or a built-in recipe:\n\n```sh\nsprid media init # set up the stills or simulator-screen recipe\nsprid media build <slug>\nsprid media check <slug>\n```\n\nA TS/JS config runs local code, so only use one you trust. Native captures and binary builds need your app’s own environment. A local slug stays local; a shared preview is always `sprid post preview --id <postId>`.\n\n## Upload\n\n```sh\nsprid login\nsprid media upload slide-01.png slide-02.png --account my-account --json\n```\n\nFiles go straight to storage and come back as ordered asset IDs. PNG, JPEG, WebP and MP4, up to 32 MiB each; larger videos go through `sprid media push`. The command prints a request ID first. To resume, rerun with the same files in the same order and `--request-id <UUID>`, or read the saved result with `sprid media job <UUID>`. Don’t reuse a request ID for a different selection.\n\n## Create and review the post\n\nSprid record IDs are UUIDs. Posts also have readable references such as `SPR-9`; either identifies the same post.\n\nWrite `draft.json` with the returned IDs and a new UUID as `requestId` (keep it for retries):\n\n```json\n{\n \"account\": \"my-account\",\n \"requestId\": \"5e253644-d8a5-4b18-9cc9-d4d921874138\",\n \"title\": \"A concrete moment\",\n \"captionInstagram\": \"The caption, with hashtags at the end\",\n \"aspectRatio\": \"4:5\",\n \"presentation\": \"finished\",\n \"assets\": [{ \"kind\": \"image\", \"id\": \"28dc0981-a925-44b0-8ec5-70279f8fef41\" }, { \"kind\": \"image\", \"id\": \"b7a4e0bc-92de-451d-9b3e-d384a2ddcc83\" }]\n}\n```\n\nFor a finished reel, use one asset with `kind: \"video\"`. `finished` artwork gets no overlay or music; images fit inside the canvas, so check the preview for margins.\n\n```sh\nsprid post create --file draft.json --json\nsprid post get SPR-9 --json\nsprid post update SPR-9 --file changes.json --json # e.g. {\"captionInstagram\":\"Revised caption\"}\nsprid post preview --id SPR-9 --json\nsprid post deliveries SPR-9 --json\n```\n\nThe review link opens the same dashboard screen as from chat. To continue in chat, give it post reference `SPR-9` instead of creating another draft. Approval and platform choices stay with a person. Check delivery receipts before reporting success.\n\n## Hosted options and results\n\n- `sprid capabilities --json`: server limits and what stays local.\n- `sprid media import --file sources.json`: the same account/files/urls/assets payload as MCP `import_assets`.\n- `sprid media reel --file recipe.json`: the metered hosted reel. Payload and approval rules are in `sprid docs chat`. Poll with `sprid media job <UUID>`.\n- `sprid marketing-review`: connected evidence. `sprid docs marketing-review` covers coverage and attribution.\n\nKeys go in through CLI key-file commands or the secure Sprid forms, never a conversation.\n",
1616
+ "revision": "ca02de2e5e236255"
1617
1617
  },
1618
1618
  {
1619
1619
  "id": "marketing-review",
@@ -71,7 +71,7 @@ for (const source of Object.keys(QUERY_SOURCES).filter(s => QUERY_SOURCES[s].sto
71
71
  op(source, 'posts', source === 'pinterest'
72
72
  ? 'Filter stored Pin publishing outcomes with collected daily Pin metrics summed from publication to the exclusive end: impressions, saves, Pin clicks, outbound clicks and video views, each with its observed-day coverage.'
73
73
  : 'Filter stored publishing outcomes with the latest metric snapshot before the exclusive end.', { ...dates, status: choice('Publishing status.', ['published', 'failed', 'missed', 'scheduled', 'pending', 'rendering', 'publishing']), contentType: choice('Post type.', ['carousel', 'reel']), limit, offset: integer('Zero-based offset.', 100000, 0) }, ['start', 'end'], { start: '2026-08-01', end: '2026-09-01', status: 'published', limit: 100 });
74
- if (QUERY_SOURCES[source].comments) op(source, 'comments', 'Read feedback already collected into Sprid’s Inbox, with optional post and reply filters.', { ...dates, postId: integer('Sprid post ID.', 1000000000), replied: { type: 'boolean', description: 'Filter comments by reply state.' }, limit, offset: integer('Zero-based offset.', 100000, 0) }, ['start', 'end'], { start: '2026-08-01', end: '2026-09-01', replied: false, limit: 100 });
74
+ if (QUERY_SOURCES[source].comments) op(source, 'comments', 'Read feedback already collected into Sprid’s Inbox, with optional post and reply filters.', { ...dates, postId: str('Sprid post UUID.', { format: 'uuid' }), replied: { type: 'boolean', description: 'Filter comments by reply state.' }, limit, offset: integer('Zero-based offset.', 100000, 0) }, ['start', 'end'], { start: '2026-08-01', end: '2026-09-01', replied: false, limit: 100 });
75
75
  }
76
76
 
77
77
  export function renderQueryReference() {
package/src/post/cli.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { UUID } from "../tools.mjs";
1
2
  // sprid post — the local half of Sprid.
2
3
  //
3
4
  // **It never renders anything.** How a file was made is the consuming repo's
@@ -1208,8 +1209,8 @@ async function cmdRegister(cfg, argv) {
1208
1209
 
1209
1210
  const prev = existsSync(manifestPath(cfg, slug)) ? readManifest(cfg, slug) : {};
1210
1211
  const authored = existsSync(specPath(cfg, lane, slug)) ? readSpec(cfg, lane, slug) : {};
1211
- if (authored.remotePostId !== undefined && (!Number.isInteger(authored.remotePostId) || authored.remotePostId <= 0))
1212
- throw new Error(`${slug}: remotePostId must be a positive integer`);
1212
+ if (authored.remotePostId !== undefined && (typeof authored.remotePostId !== "string" || !UUID.test(authored.remotePostId)))
1213
+ throw new Error(`${slug}: remotePostId must be a UUID`);
1213
1214
  const platforms = checkedPlatforms(flag(argv, "platforms")?.split(",") ?? authored.platforms ?? prev.post?.platforms);
1214
1215
  const title = authored.title ?? prev.post?.title;
1215
1216
  const pinterestOptions = composePinterestOptions({