@sprid/cli 0.1.18 → 1.0.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/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
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.0 - 2026-09-30
7
+
8
+ - Breaking: Sprid record IDs are UUIDs. Post and review references remain accepted. Incremental IDs are refused.
9
+
10
+ ## 0.1.19 - 2026-09-30
11
+
12
+ - `sprid docs chat` uses the consolidated post and slide tools. Asset creation
13
+ retains retry protection and finished-artwork safeguards; one or many slides
14
+ use the same editing and appending operations.
15
+
6
16
  ## 0.1.18 - 2026-09-28
7
17
 
8
18
  - `sprid post video <ref>` reads a post's own video: its revision, version
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.18",
3
+ "version": "1.0.0",
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'],
@@ -1604,16 +1604,16 @@ export const CONTENT_GUIDES = [
1604
1604
  "title": "Use Sprid in chat",
1605
1605
  "summary": "Take an app from setup to a reviewed post and confirmed delivery using only your chat app and a browser. No CLI or installed skills needed.",
1606
1606
  "url": "https://sprid.studio/docs/chat",
1607
- "markdown": "# Use Sprid in chat\n\n**What this guide does:** Take an app from setup to a reviewed post and confirmed delivery using only your chat app and a browser. No CLI or installed skills needed.\n\nYou tell the agent what you want; it handles the tool calls below. The tool names are here so the agent knows the order. Setup should only ask for the connection the current task needs.\n\n## Connect Sprid\n\nChoose your chat at `https://sprid.studio/start#chat` and follow its steps. Your chat may call this an app, connector or integration. The address is `https://api.sprid.studio/api/mcp?surface=core`. Sign in to Sprid and authorize the right workspace.\n\nAgent: when available, start with `get_marketing_plan` and reuse its prepared work and next step rather than starting over. Discovery can start from a website or store URL, with no social account.\n\n## Pick the app and account\n\nAgent: call `get_capabilities`, then `list_apps` and `list_accounts`, and reuse what exists. If the app is missing, get its description or website/store URL, confirm it and create it with `upsert_app_profile`. A content account owns voice and media; a channel is one exact social destination. Never pick a handle just because it is first in a list. A missing account is created in the dashboard.\n\n## Connect a destination or data source\n\n- **Social channel:** `get_documentation` with the platform name, then `connect_channel`. The person opens the returned link and approves in the browser; `channel_status` confirms the saved result.\n- **Store or analytics:** the person enters identifiers and the key in the secure form at `https://app.sprid.studio/settings/app-profiles`. Credentials never go in chat, tool arguments or reports. A saved-key indicator only proves configuration; `get_marketing_review` does a live read.\n\n## Bring images or a finished video\n\nDraft copy in the chat, in the account’s voice. No extra model key needed.\n\nIf the chat exposes the attached files, pass them to `import_assets`: each file has `download_url` and `file_id`, optionally `mime_type` and `file_name`. Public HTTPS URLs and existing `{kind, id}` library references also work. Limits: PNG, JPEG, WebP or finished MP4, up to 32 MiB each. Each position reports on its own; retry the missing ones before building the post. Files are copied to the account and deduplicated by bytes; temporary download URLs are not kept.\n\nA `sandbox:` URL or local path can’t be fetched. For an image the chat generated, use the existing file if the host exposes it (not verified for every host); otherwise download it and use the browser upload. Never ask the agent to recreate an image or transcribe binary data.\n\n**Fallback: browser upload.** Call `create_upload_session` with the account and a new UUID `requestId`. The person opens the link and picks the files. Then read `get_studio_job` with the returned ID; its result keys are zero-based positions. The same session and asset IDs work from any other chat. No image-generation charge.\n\n## Create and review the post\n\nCall `create_post_from_assets` with a new UUID `requestId`, the account, ordered `{kind, id}` assets, captions and aspect ratio. Reuse the same ID and inputs if a response is lost.\n\n- `presentation: \"finished\"` keeps artwork as-is: no text overlay, no added music. Images fit inside the canvas, which can add margins.\n- `presentation: \"editable\"` allows `text` and `subtitle` on image slides.\n- One finished video becomes one reel. Never mix a video with image slides.\n\nEdit with `update_post` (captions) and `update_slide` or `batch_update_slides` (slides). Call `preview_post` and check every slide or the video: captions, order, crop, legibility. It returns a small image of each slide for the agent to look at and an embedded preview for the person; a chat without embedded apps still shows the images. A preview is not approval to publish.\n\nAgent: show the person the preview, and link every post you mention with the `links` the tools return, as a Markdown link on its ref or title: `links.review` when the ask is to approve or schedule, `links.editor` when it is to change something. Never draw the slides in your own widget or artifact: those sandboxes block Sprid's media host and the images show broken.\n\n## Make a reel from photos or text\n\nCheck `get_capabilities` for hosted creation first. `create_reel` takes ordered scenes (`imageId`, `text`, `durationMs`), up to 120 seconds, and makes a silent MP4. It costs 3 Sprid credits per started render minute, or uses the plan allowance: check `spend_status`, tell the person, and get their go-ahead before sending `confirmed: true`. Save the job ID and read `get_studio_job`. Retrying with the same ID never starts a second render; a stopped job reports `needs_attention` instead of charging again.\n\nTo turn an existing post's slides into a video instead, use its own video where your tool list offers it (the reviewed connector does not list these tools yet; Sprid's own MCP server does): `get_video_capabilities`, then `get_post_video` and `edit_post_video` for transitions and motion, and `update_post {outputs}` for which destinations get it. Rendering it is metered the same way: `quote_post_video_render`, the person's go-ahead, then `render_post_video` with `confirmed: true`. Book a video destination with the render's `videoRenderId`.\n\nApp captures, simulator recordings and your own build scripts run outside remote MCP. Upload their finished files.\n\n## Approve and confirm delivery\n\nThe person opens the returned `/p/<postId>` link, reviews the real media and captions, and picks the channels, time and platform options. TikTok privacy and interaction choices have no defaults and are made on that screen. An agent-supplied confirmation flag is not a human click.\n\nTikTok works two ways from chat. A draft, `options.tiktok = { \"postMode\": \"MEDIA_UPLOAD\" }`, goes to the creator's TikTok inbox without anyone approving it in Sprid; they finish it in the TikTok app. A post straight to their profile is refused from chat and comes back with `approvalUrl`: send the person that link. It opens the posting screen on TikTok at the time asked for, and once they answer and confirm, the booking publishes on its own.\n\nAfter approval, read `get_publication_status`. Keep draft, scheduled, failed and delivered apart, and report delivery only with the platform receipt. Read a failure before retrying. Never repeat a publish call just because a response was lost.\n\nFor results, read `get_documentation` for `marketing-review`. Keep the same app, account and post IDs across clients. `next_actions` gives the next step; an empty `verb` means follow its browser link.\n\n## Limits and troubleshooting\n\n- **Attachment can’t be read:** use the browser upload, not the CLI.\n- **Missing account or service:** set it up in the dashboard. Never paste credentials into chat.\n- **Hosted creation:** check `get_capabilities` before promising it. Store screenshots and store releases are local unless it reports otherwise.\n- **File transfer:** whether a host exposes uploaded or generated files has to be checked per host. A supported schema doesn’t prove it.\n",
1608
- "revision": "ed1ece7b8bb7113d"
1607
+ "markdown": "# Use Sprid in chat\n\n**What this guide does:** Take an app from setup to a reviewed post and confirmed delivery using only your chat app and a browser. No CLI or installed skills needed.\n\nYou tell the agent what you want; it handles the tool calls below. The tool names are here so the agent knows the order. Setup should only ask for the connection the current task needs.\n\n## Connect Sprid\n\nChoose your chat at `https://sprid.studio/start#chat` and follow its steps. Your chat may call this an app, connector or integration. The address is `https://api.sprid.studio/api/mcp?surface=core`. Sign in to Sprid and authorize the right workspace.\n\nAgent: when available, start with `get_marketing_plan` and reuse its prepared work and next step rather than starting over. Discovery can start from a website or store URL, with no social account.\n\n## Pick the app and account\n\nAgent: call `get_capabilities`, then `list_apps` and `list_accounts`, and reuse what exists. If the app is missing, get its description or website/store URL, confirm it and create it with `upsert_app_profile`. A content account owns voice and media; a channel is one exact social destination. Never pick a handle just because it is first in a list. A missing account is created in the dashboard.\n\n## Connect a destination or data source\n\n- **Social channel:** `get_documentation` with the platform name, then `connect_channel`. The person opens the returned link and approves in the browser; `channel_status` confirms the saved result.\n- **Store or analytics:** the person enters identifiers and the key in the secure form at `https://app.sprid.studio/settings/app-profiles`. Credentials never go in chat, tool arguments or reports. A saved-key indicator only proves configuration; `get_marketing_review` does a live read.\n\n## Bring images or a finished video\n\nDraft copy in the chat, in the account’s voice. No extra model key needed.\n\nIf the chat exposes the attached files, pass them to `import_assets`: each file has `download_url` and `file_id`, optionally `mime_type` and `file_name`. Public HTTPS URLs and existing `{kind, id}` library references also work. Limits: PNG, JPEG, WebP or finished MP4, up to 32 MiB each. Each position reports on its own; retry the missing ones before building the post. Files are copied to the account and deduplicated by bytes; temporary download URLs are not kept.\n\nA `sandbox:` URL or local path can’t be fetched. For an image the chat generated, use the existing file if the host exposes it (not verified for every host); otherwise download it and use the browser upload. Never ask the agent to recreate an image or transcribe binary data.\n\n**Fallback: browser upload.** Call `create_upload_session` with the account and a new UUID `requestId`. The person opens the link and picks the files. Then read `get_studio_job` with the returned ID; its result keys are zero-based positions. The same session and asset IDs work from any other chat. No image-generation charge.\n\n## Create and review the post\n\nCall `create_post` with a new UUID `requestId`, the account, ordered `{kind, id}` assets, captions and aspect ratio. Reuse the same ID and inputs if a response is lost.\n\n- `presentation: \"finished\"` keeps artwork as-is: no text overlay, no added music. Images fit inside the canvas, which can add margins.\n- `presentation: \"editable\"` allows `text` and `subtitle` on image slides.\n- One finished video becomes one reel. Never mix a video with image slides.\n\nEdit with `update_post` (captions) and `update_slides` (slides). Call `preview_post` and check every slide or the video: captions, order, crop, legibility. It returns a small image of each slide for the agent to look at and an embedded preview for the person; a chat without embedded apps still shows the images. A preview is not approval to publish.\n\nAgent: show the person the preview, and link every post you mention with the `links` the tools return, as a Markdown link on its ref or title: `links.review` when the ask is to approve or schedule, `links.editor` when it is to change something. Never draw the slides in your own widget or artifact: those sandboxes block Sprid's media host and the images show broken.\n\n## Make a reel from photos or text\n\nCheck `get_capabilities` for hosted creation first. `create_reel` takes ordered scenes (`imageId`, `text`, `durationMs`), up to 120 seconds, and makes a silent MP4. It costs 3 Sprid credits per started render minute, or uses the plan allowance: check `spend_status`, tell the person, and get their go-ahead before sending `confirmed: true`. Save the job ID and read `get_studio_job`. Retrying with the same ID never starts a second render; a stopped job reports `needs_attention` instead of charging again.\n\nTo turn an existing post's slides into a video instead, use its own video: `get_video_capabilities`, then `get_post_video` and `edit_post_video` for transitions and motion, and `update_post {outputs}` for which destinations get it. Rendering it is metered the same way: `quote_post_video_render`, the person's go-ahead, then `render_post_video` with `confirmed: true`. Book a video destination with the render's `videoRenderId`.\n\nApp captures, simulator recordings and your own build scripts run outside remote MCP. Upload their finished files.\n\n## Approve and confirm delivery\n\nThe person opens the returned `/p/<postId>` link, reviews the real media and captions, and picks the channels, time and platform options. TikTok privacy and interaction choices have no defaults and are made on that screen. An agent-supplied confirmation flag is not a human click.\n\nTikTok works two ways from chat. A draft, `options.tiktok = { \"postMode\": \"MEDIA_UPLOAD\" }`, goes to the creator's TikTok inbox without anyone approving it in Sprid; they finish it in the TikTok app. A post straight to their profile is refused from chat and comes back with `approvalUrl`: send the person that link. It opens the posting screen on TikTok at the time asked for, and once they answer and confirm, the booking publishes on its own.\n\nAfter approval, read `post_queue`. Keep draft, scheduled, failed and delivered apart, and report delivery only with the platform receipt. Read a failure before retrying. Never repeat a publish call just because a response was lost.\n\nFor results, read `get_documentation` for `marketing-review`. Keep the same app, account and post IDs across clients. `next_actions` gives the next step; an empty `verb` means follow its browser link.\n\n## Limits and troubleshooting\n\n- **Attachment can’t be read:** use the browser upload, not the CLI.\n- **Missing account or service:** set it up in the dashboard. Never paste credentials into chat.\n- **Hosted creation:** check `get_capabilities` before promising it. Store screenshots and store releases are local unless it reports otherwise.\n- **File transfer:** whether a host exposes uploaded or generated files has to be checked per host. A supported schema doesn’t prove it.\n",
1608
+ "revision": "203ffeb3b12a34b7"
1609
1609
  },
1610
1610
  {
1611
1611
  "id": "local",
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({
package/src/tools.mjs CHANGED
@@ -71,11 +71,18 @@ export function listFlag(value) {
71
71
  return items.length ? items : undefined;
72
72
  }
73
73
 
74
- /** A positive integer positional, or a usage error naming the command. */
75
- export function needInt(arg, usage) {
76
- const n = Number(arg);
77
- if (!Number.isInteger(n) || n <= 0) throw new UsageError(usage);
78
- return n;
74
+ /** Sprid identifiers are UUIDv4; a post or review may also use its readable reference. */
75
+ export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
76
+ export function needId(arg, usage) {
77
+ if (typeof arg !== "string" || !UUID.test(arg)) throw new UsageError(usage);
78
+ return arg.toLowerCase();
79
+ }
80
+ export function needRef(arg, usage, review = false) {
81
+ if (typeof arg !== "string") throw new UsageError(usage);
82
+ if (UUID.test(arg)) return arg.toLowerCase();
83
+ const pattern = review ? /^[A-Z][A-Z0-9]{1,4}-R[1-9]\d*$/i : /^[A-Z][A-Z0-9]{1,4}-[1-9]\d*$/i;
84
+ if (pattern.test(arg)) return arg.toUpperCase();
85
+ throw new UsageError(usage);
79
86
  }
80
87
 
81
88
  /** Human output for results with no bespoke renderer. */