@autoposting.ai/cli 0.3.4 → 0.3.6

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/dist/cli.cjs CHANGED
@@ -42,11 +42,44 @@ function createError(status, body) {
42
42
  if (status >= 500) return new ServerError(message, status, code);
43
43
  return new AutopostingError(message, status, code);
44
44
  }
45
- var VERSION, AutopostingError, AuthenticationError, ScopeError, NotFoundError, ValidationError, RateLimitError, ServerError, Resource, MediaResource, AgentsResource, BillingResource, BrandsResource, CarouselsResource, ClipsResource, PostsResource, KbResource, IdeasResource, UsageResource, WebhooksResource, WorkspacesResource, IDEMPOTENT_METHODS, RETRYABLE_STATUS, sleep, Autoposting;
45
+ function resolvePartSize(fileSize, requested) {
46
+ let size = requested && requested > 0 ? requested : DEFAULT_PART_SIZE;
47
+ if (Math.ceil(fileSize / size) > MAX_PARTS) size = Math.ceil(fileSize / MAX_PARTS);
48
+ return Math.max(size, MIN_PART_SIZE);
49
+ }
50
+ function guessVideoMime(fileName) {
51
+ const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
52
+ return VIDEO_MIME[ext] ?? "video/mp4";
53
+ }
54
+ async function putPart(url, body) {
55
+ let res;
56
+ try {
57
+ res = await fetch(url, { method: "PUT", body });
58
+ } catch (err) {
59
+ throw new AutopostingError(
60
+ `Failed to upload part: ${err instanceof Error ? err.message : String(err)}`,
61
+ 0,
62
+ "UPLOAD_FAILED"
63
+ );
64
+ }
65
+ if (!res.ok) {
66
+ throw new AutopostingError(
67
+ `Failed to upload part: HTTP ${res.status} ${res.statusText}`,
68
+ res.status,
69
+ "UPLOAD_FAILED"
70
+ );
71
+ }
72
+ const etag = res.headers.get("etag");
73
+ if (!etag) {
74
+ throw new AutopostingError("Upload part response is missing the ETag header.", res.status, "UPLOAD_FAILED");
75
+ }
76
+ return etag;
77
+ }
78
+ var VERSION, AutopostingError, AuthenticationError, ScopeError, NotFoundError, ValidationError, RateLimitError, ServerError, Resource, MediaResource, AgentsResource, BillingResource, BrandsResource, CarouselsResource, MIN_PART_SIZE, DEFAULT_PART_SIZE, MAX_PARTS, VIDEO_MIME, ClipsResource, PostsResource, KbResource, IdeasResource, UsageResource, WebhooksResource, WorkspacesResource, IDEMPOTENT_METHODS, RETRYABLE_STATUS, sleep, Autoposting;
46
79
  var init_dist = __esm({
47
80
  "../sdk/dist/index.mjs"() {
48
81
  "use strict";
49
- VERSION = "0.3.4";
82
+ VERSION = "0.3.6";
50
83
  AutopostingError = class extends Error {
51
84
  status;
52
85
  code;
@@ -180,6 +213,17 @@ var init_dist = __esm({
180
213
  return this.client.request("DELETE", `/carousels/${id}`);
181
214
  }
182
215
  };
216
+ MIN_PART_SIZE = 5 * 1024 * 1024;
217
+ DEFAULT_PART_SIZE = 16 * 1024 * 1024;
218
+ MAX_PARTS = 1e4;
219
+ VIDEO_MIME = {
220
+ mp4: "video/mp4",
221
+ mov: "video/quicktime",
222
+ webm: "video/webm",
223
+ mkv: "video/x-matroska",
224
+ avi: "video/x-msvideo",
225
+ m4v: "video/x-m4v"
226
+ };
183
227
  ClipsResource = class extends Resource {
184
228
  /** GET /clips — backend returns `{ clips, pagination }`, not a bare array. */
185
229
  list() {
@@ -188,24 +232,82 @@ var init_dist = __esm({
188
232
  retrieve(id) {
189
233
  return this.client.request("GET", `/clips/${id}`);
190
234
  }
191
- // ponytail: the backend has no single-request /clips/upload; uploading a file means the
192
- // multipart init/presign/complete flow (upload-initiate → upload-presign → upload-complete).
193
- // That's too much surface for v0.3.1 — fail honestly and point at importUrl. Upgrade path:
194
- // implement the init/presign/complete dance here when direct upload is actually needed.
195
- async upload(_filePath, _options) {
196
- throw new AutopostingError(
197
- "Direct clip file upload is not supported by the API yet \u2014 use `importUrl` with a public video URL instead.",
198
- 0,
199
- "NOT_IMPLEMENTED"
235
+ /**
236
+ * Upload a local video file as a clip by driving the backend multipart flow:
237
+ * `init → presign → PUT each part → complete`. On any failure after init, the
238
+ * multipart upload is aborted so it leaves no dangling clip. Returns the created clip.
239
+ *
240
+ * Node-only: reads the file in chunks (memory-bounded for multi-GB videos). `node:fs`
241
+ * is imported dynamically so the rest of the SDK stays runtime-agnostic.
242
+ */
243
+ async upload(filePath, options) {
244
+ if (!options?.brandId) {
245
+ throw new AutopostingError("brandId is required to upload a clip.", 0, "VALIDATION_ERROR");
246
+ }
247
+ const { open: open2, stat } = await import("fs/promises");
248
+ const { basename } = await import("path");
249
+ const fileName = basename(filePath);
250
+ const title = options.title ?? fileName;
251
+ const mimeType = options.contentType ?? guessVideoMime(fileName);
252
+ const { size: fileSize } = await stat(filePath);
253
+ if (fileSize <= 0) {
254
+ throw new AutopostingError(`File is empty: ${filePath}`, 0, "VALIDATION_ERROR");
255
+ }
256
+ const partSize = resolvePartSize(fileSize, options.partSize);
257
+ const partNumbers = Array.from(
258
+ { length: Math.max(1, Math.ceil(fileSize / partSize)) },
259
+ (_, i) => i + 1
200
260
  );
261
+ const { clipId, uploadId, key } = await this.client.request("POST", "/clips/upload/init", { brandId: options.brandId, title, fileName, fileSize, mimeType });
262
+ let completed;
263
+ const handle = await open2(filePath, "r");
264
+ try {
265
+ const { presignedUrls } = await this.client.request("POST", "/clips/upload/presign", { clipId, uploadId, key, partNumbers });
266
+ const urlByPart = new Map(presignedUrls.map((p) => [p.partNumber, p.url]));
267
+ const parts = [];
268
+ let uploaded = 0;
269
+ for (const partNumber of partNumbers) {
270
+ const url = urlByPart.get(partNumber);
271
+ if (!url) {
272
+ throw new AutopostingError(`Missing presigned URL for part ${partNumber}.`, 0, "UPLOAD_FAILED");
273
+ }
274
+ const offset = (partNumber - 1) * partSize;
275
+ const length = Math.min(partSize, fileSize - offset);
276
+ const buffer = Buffer.allocUnsafe(length);
277
+ await handle.read(buffer, 0, length, offset);
278
+ parts.push({ partNumber, etag: await putPart(url, buffer) });
279
+ uploaded += length;
280
+ options.onProgress?.(Math.round(uploaded / fileSize * 100));
281
+ }
282
+ completed = await this.client.request(
283
+ "POST",
284
+ "/clips/upload/complete",
285
+ { clipId, uploadId, key, parts }
286
+ );
287
+ } catch (err) {
288
+ await this.client.request("POST", "/clips/upload/abort", { clipId, uploadId, key }).catch(() => {
289
+ });
290
+ throw err;
291
+ } finally {
292
+ await handle.close();
293
+ }
294
+ return this.retrieve(completed.clipId);
201
295
  }
202
296
  /** POST /clips/import-url — backend returns `{ clipId }`, not a full Clip. */
203
297
  importUrl(params) {
204
298
  return this.client.request("POST", "/clips/import-url", params);
205
299
  }
206
300
  /** POST /clips/:id/render — backend returns render job ids, not a Clip. */
207
- render(id) {
208
- return this.client.request("POST", `/clips/${id}/render`);
301
+ async render(id, params = {}) {
302
+ const editRevision = params.editRevision ?? (await this.retrieve(id)).editRevision ?? 0;
303
+ return this.client.request("POST", `/clips/${id}/render`, {
304
+ editRevision,
305
+ ...params.candidateId ? { candidateId: params.candidateId } : {}
306
+ });
307
+ }
308
+ /** POST /clips/:id/create-draft — turns a rendered clip into a draft post. */
309
+ createDraft(id, params = {}) {
310
+ return this.client.request("POST", `/clips/${id}/create-draft`, params);
209
311
  }
210
312
  remove(id) {
211
313
  return this.client.request("DELETE", `/clips/${id}`);
@@ -562,10 +664,25 @@ var init_dist = __esm({
562
664
  });
563
665
 
564
666
  // src/mcp/tools.ts
565
- var ALL_TOOLS;
667
+ var MEDIA_ITEMS, ALL_TOOLS;
566
668
  var init_tools = __esm({
567
669
  "src/mcp/tools.ts"() {
568
670
  "use strict";
671
+ MEDIA_ITEMS = {
672
+ type: "array",
673
+ maxItems: 10,
674
+ description: "Images or videos to attach, as public https URLs. To attach a local file, use `ap posts create --media <file>` instead.",
675
+ items: {
676
+ type: "object",
677
+ properties: {
678
+ url: { type: "string", description: "Public https URL of the image or video" },
679
+ type: { type: "string", enum: ["image", "video", "gif"] },
680
+ altText: { type: "string", description: "Optional alt text" }
681
+ },
682
+ required: ["url", "type"],
683
+ additionalProperties: false
684
+ }
685
+ };
569
686
  ALL_TOOLS = [
570
687
  // Posts
571
688
  {
@@ -613,7 +730,8 @@ var init_tools = __esm({
613
730
  scheduledAt: {
614
731
  type: "string",
615
732
  description: "ISO 8601 datetime to schedule the post"
616
- }
733
+ },
734
+ media: MEDIA_ITEMS
617
735
  },
618
736
  required: ["brandSlug", "text", "platforms"],
619
737
  additionalProperties: false
@@ -621,14 +739,15 @@ var init_tools = __esm({
621
739
  },
622
740
  {
623
741
  name: "update-post",
624
- description: "Update the text, platforms, or scheduled time of an existing post.",
742
+ description: "Update the text, platforms, scheduled time, or media of an existing post.",
625
743
  inputSchema: {
626
744
  type: "object",
627
745
  properties: {
628
746
  id: { type: "string", description: "Post ID" },
629
747
  text: { type: "string", description: "New post text" },
630
748
  platforms: { type: "string", description: "Comma-separated new platform list" },
631
- scheduledAt: { type: "string", description: "New ISO 8601 scheduled datetime" }
749
+ scheduledAt: { type: "string", description: "New ISO 8601 scheduled datetime" },
750
+ media: MEDIA_ITEMS
632
751
  },
633
752
  required: ["id"],
634
753
  additionalProperties: false
@@ -1062,24 +1181,41 @@ var init_tools = __esm({
1062
1181
  },
1063
1182
  {
1064
1183
  name: "import-clip",
1065
- description: "Import a video clip from a remote URL.",
1184
+ description: "Start AI clipping: import a long video from a public URL (e.g. a YouTube video). Poll get-clip until status is ready.",
1066
1185
  inputSchema: {
1067
1186
  type: "object",
1068
1187
  properties: {
1069
1188
  url: { type: "string", description: "URL of the video to import" },
1189
+ brandId: { type: "string", description: "Brand ID (the `id` from list-brands)" },
1070
1190
  name: { type: "string", description: "Optional name for the imported clip" }
1071
1191
  },
1072
- required: ["url"],
1192
+ required: ["url", "brandId"],
1073
1193
  additionalProperties: false
1074
1194
  }
1075
1195
  },
1076
1196
  {
1077
1197
  name: "render-clip",
1078
- description: "Trigger rendering for a video clip.",
1198
+ description: "Render a clip (uses credits). Call get-clip first for its editRevision and candidates.",
1199
+ inputSchema: {
1200
+ type: "object",
1201
+ properties: {
1202
+ id: { type: "string", description: "Clip ID to render" },
1203
+ editRevision: { type: "number", description: "Current editRevision from get-clip" },
1204
+ candidateId: { type: "string", description: "Optional: render only this candidate" }
1205
+ },
1206
+ required: ["id", "editRevision"],
1207
+ additionalProperties: false
1208
+ }
1209
+ },
1210
+ {
1211
+ name: "create-clip-draft",
1212
+ description: "Create a draft post from a rendered clip. Publish it with publish-post or schedule it.",
1079
1213
  inputSchema: {
1080
1214
  type: "object",
1081
1215
  properties: {
1082
- id: { type: "string", description: "Clip ID to render" }
1216
+ id: { type: "string", description: "Clip ID" },
1217
+ candidateId: { type: "string", description: "Optional candidate ID (defaults to the first rendered one)" },
1218
+ aspectRatio: { type: "string", enum: ["9:16", "16:9", "1:1", "4:5"], description: "Optional render format" }
1083
1219
  },
1084
1220
  required: ["id"],
1085
1221
  additionalProperties: false
@@ -1330,7 +1466,8 @@ async function dispatchToolCall(name, args, client) {
1330
1466
  brandSlug: args.brandSlug,
1331
1467
  text: args.text,
1332
1468
  platforms: parsePlatforms2(args.platforms),
1333
- ...args.scheduledAt ? { scheduledAt: args.scheduledAt } : {}
1469
+ ...args.scheduledAt ? { scheduledAt: args.scheduledAt } : {},
1470
+ ...args.media ? { media: args.media } : {}
1334
1471
  });
1335
1472
  return ok(result);
1336
1473
  }
@@ -1340,7 +1477,8 @@ async function dispatchToolCall(name, args, client) {
1340
1477
  ...args.platforms ? {
1341
1478
  platforms: parsePlatforms2(args.platforms)
1342
1479
  } : {},
1343
- ...args.scheduledAt ? { scheduledAt: args.scheduledAt } : {}
1480
+ ...args.scheduledAt ? { scheduledAt: args.scheduledAt } : {},
1481
+ ...args.media ? { media: args.media } : {}
1344
1482
  });
1345
1483
  return ok(result);
1346
1484
  }
@@ -1528,12 +1666,23 @@ async function dispatchToolCall(name, args, client) {
1528
1666
  case "import-clip": {
1529
1667
  const result = await client.clips.importUrl({
1530
1668
  url: args.url,
1531
- ...args.name ? { name: args.name } : {}
1669
+ brandId: args.brandId,
1670
+ ...args.name ? { title: args.name } : {}
1532
1671
  });
1533
1672
  return ok(result);
1534
1673
  }
1535
1674
  case "render-clip": {
1536
- const result = await client.clips.render(args.id);
1675
+ const result = await client.clips.render(args.id, {
1676
+ editRevision: args.editRevision,
1677
+ ...args.candidateId ? { candidateId: args.candidateId } : {}
1678
+ });
1679
+ return ok(result);
1680
+ }
1681
+ case "create-clip-draft": {
1682
+ const result = await client.clips.createDraft(args.id, {
1683
+ ...args.candidateId ? { candidateId: args.candidateId } : {},
1684
+ ...args.aspectRatio ? { aspectRatio: args.aspectRatio } : {}
1685
+ });
1537
1686
  return ok(result);
1538
1687
  }
1539
1688
  case "delete-clip": {
@@ -2950,6 +3099,17 @@ function resolveExitCode4(err) {
2950
3099
  if (typeof attached === "number") return attached;
2951
3100
  return exitCodeFromError(err);
2952
3101
  }
3102
+ async function resolveBrandId(client, explicit) {
3103
+ if (explicit) return explicit;
3104
+ const brands = await client.brands.list();
3105
+ if (brands.length === 1) return brands[0].id;
3106
+ if (brands.length === 0) {
3107
+ throw new Error("No brands found. Create a brand first, then pass --brand <id>.");
3108
+ }
3109
+ throw new Error(
3110
+ `Multiple brands found \u2014 pass --brand <id>. Available: ${brands.map((b) => `${b.name} (${b.id})`).join(", ")}`
3111
+ );
3112
+ }
2953
3113
  function createClipsCommand() {
2954
3114
  const clips = new import_commander6.Command("clips").description("Manage video clips");
2955
3115
  clips.command("list").description("List all clips").action(async (_opts, cmd) => {
@@ -2990,15 +3150,17 @@ function createClipsCommand() {
2990
3150
  process.exit(resolveExitCode4(err));
2991
3151
  }
2992
3152
  });
2993
- clips.command("upload <file>").description("Upload a video file as a clip").option("--name <name>", "Override the clip name (defaults to filename)").action(async (file, opts, cmd) => {
3153
+ clips.command("upload <file>").description("Upload a video file as a clip").option("--brand <id>", "Brand to attach the clip to (defaults to your only brand)").option("--name <name>", "Override the clip title (defaults to filename)").action(async (file, opts, cmd) => {
2994
3154
  const globals = cmd.optsWithGlobals();
2995
3155
  const printer = createPrinter(globals);
2996
3156
  const spinner = printer.spinner("Uploading clip\u2026");
2997
3157
  try {
2998
3158
  const cred = resolveAuth({ apiKey: globals.apiKey });
2999
3159
  const client = new Autoposting({ apiKey: cred.apiKey });
3160
+ const brandId = await resolveBrandId(client, opts.brand);
3000
3161
  const clip = await client.clips.upload(file, {
3001
- name: opts.name,
3162
+ brandId,
3163
+ ...opts.name ? { title: opts.name } : {},
3002
3164
  onProgress: (pct) => {
3003
3165
  if (pct > 0 && pct < 100) spinner.stop();
3004
3166
  }
@@ -3011,16 +3173,18 @@ function createClipsCommand() {
3011
3173
  process.exit(resolveExitCode4(err));
3012
3174
  }
3013
3175
  });
3014
- clips.command("import").description("Import a clip from a URL").requiredOption("--url <url>", "URL of the video to import").option("--name <name>", "Name for the imported clip").action(async (opts, cmd) => {
3176
+ clips.command("import").description("Start AI clipping from a video URL (e.g. a YouTube video)").requiredOption("--url <url>", "URL of the video to import").option("--brand <id>", "Brand ID (auto-picked when the workspace has exactly one brand)").option("--name <name>", "Name for the imported clip").action(async (opts, cmd) => {
3015
3177
  const globals = cmd.optsWithGlobals();
3016
3178
  const printer = createPrinter(globals);
3017
3179
  const spinner = printer.spinner("Importing clip\u2026");
3018
3180
  try {
3019
3181
  const cred = resolveAuth({ apiKey: globals.apiKey });
3020
3182
  const client = new Autoposting({ apiKey: cred.apiKey });
3183
+ const brandId = await resolveBrandId(client, opts.brand);
3021
3184
  const { clipId } = await client.clips.importUrl({
3022
3185
  url: opts.url,
3023
- ...opts.name ? { name: opts.name } : {}
3186
+ brandId,
3187
+ ...opts.name ? { title: opts.name } : {}
3024
3188
  });
3025
3189
  spinner.stop();
3026
3190
  printer.log({ clipId });
@@ -3030,14 +3194,17 @@ function createClipsCommand() {
3030
3194
  process.exit(resolveExitCode4(err));
3031
3195
  }
3032
3196
  });
3033
- clips.command("render <id>").description("Trigger rendering for a clip").action(async (id, _opts, cmd) => {
3197
+ clips.command("render <id>").description("Render a clip (uses credits)").option("--candidate <id>", "Render only this candidate").option("--revision <n>", "Clip editRevision (read from the clip when omitted)", (v) => Number(v)).action(async (id, opts, cmd) => {
3034
3198
  const globals = cmd.optsWithGlobals();
3035
3199
  const printer = createPrinter(globals);
3036
3200
  const spinner = printer.spinner(`Rendering clip "${id}"\u2026`);
3037
3201
  try {
3038
3202
  const cred = resolveAuth({ apiKey: globals.apiKey });
3039
3203
  const client = new Autoposting({ apiKey: cred.apiKey });
3040
- const { jobIds, activeJobIds, reusedJobIds } = await client.clips.render(id);
3204
+ const { jobIds, activeJobIds, reusedJobIds } = await client.clips.render(id, {
3205
+ ...opts.revision !== void 0 ? { editRevision: opts.revision } : {},
3206
+ ...opts.candidate ? { candidateId: opts.candidate } : {}
3207
+ });
3041
3208
  spinner.stop();
3042
3209
  printer.log({ jobIds, activeJobIds, reusedJobIds });
3043
3210
  } catch (err) {
@@ -3046,6 +3213,25 @@ function createClipsCommand() {
3046
3213
  process.exit(resolveExitCode4(err));
3047
3214
  }
3048
3215
  });
3216
+ clips.command("draft <id>").description("Create a draft post from a rendered clip").option("--candidate <id>", "Candidate to use (defaults to the first rendered one)").option("--aspect-ratio <ratio>", "Render format: 9:16, 16:9, 1:1 or 4:5").action(async (id, opts, cmd) => {
3217
+ const globals = cmd.optsWithGlobals();
3218
+ const printer = createPrinter(globals);
3219
+ const spinner = printer.spinner(`Creating draft from clip "${id}"\u2026`);
3220
+ try {
3221
+ const cred = resolveAuth({ apiKey: globals.apiKey });
3222
+ const client = new Autoposting({ apiKey: cred.apiKey });
3223
+ const { postId } = await client.clips.createDraft(id, {
3224
+ ...opts.candidate ? { candidateId: opts.candidate } : {},
3225
+ ...opts.aspectRatio ? { aspectRatio: opts.aspectRatio } : {}
3226
+ });
3227
+ spinner.stop();
3228
+ printer.log({ postId });
3229
+ } catch (err) {
3230
+ spinner.fail();
3231
+ printer.error(err);
3232
+ process.exit(resolveExitCode4(err));
3233
+ }
3234
+ });
3049
3235
  clips.command("delete <id>").description("Delete a clip (requires --force)").option("--force", "Confirm deletion without interactive prompt").action(async (id, opts, cmd) => {
3050
3236
  const globals = cmd.optsWithGlobals();
3051
3237
  const printer = createPrinter(globals);