@cmo-so/cli 0.3.0 → 0.5.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.
Files changed (2) hide show
  1. package/dist/cmo.mjs +443 -9
  2. package/package.json +1 -1
package/dist/cmo.mjs CHANGED
@@ -7473,7 +7473,16 @@ function dataUrl(type, bytes) {
7473
7473
  }
7474
7474
 
7475
7475
  // ../../packages/api-client/src/domains.ts
7476
- var MEMORY_CHANNELS = ["general", "blog", "linkedin", "facebook", "instagram", "google_business"];
7476
+ var MEMORY_CHANNELS = [
7477
+ "general",
7478
+ "blog",
7479
+ "linkedin",
7480
+ "facebook",
7481
+ "instagram",
7482
+ "google_business",
7483
+ "youtube",
7484
+ "tiktok"
7485
+ ];
7477
7486
  function addWebsite(api2, url) {
7478
7487
  return api2.request("/v1/domains", {
7479
7488
  method: "POST",
@@ -7706,7 +7715,38 @@ function domainApi(api2, domain) {
7706
7715
  `${base}/social/google_business/location`,
7707
7716
  { connection_id: connectionId }
7708
7717
  ),
7718
+ /** How the Google Business Profile is doing: the last 28 days and the 28 before (Google lags 2-3 days). */
7719
+ googleBusinessPerformance: (refresh = false) => api2.request(`${base}/social/google_business/performance${refresh ? "?refresh=true" : ""}`),
7709
7720
  disconnectGoogleBusiness: (connectionId) => api2.request(`${base}/social/google_business/${connectionId}`, { method: "DELETE" }),
7721
+ /** A Google sign-in link for this website's YouTube channel (docs/prd/youtube.md §7); back with ?youtube=connected
7722
+ * (or none when the Google account has no channel, error&message=…). */
7723
+ connectYouTube: (returnTo = "close") => post(`${base}/social/youtube/connect`, { return_to: returnTo }),
7724
+ /** Your YouTube channels here (name, @handle, picture, subscribers), and the one this website posts to. */
7725
+ youtubeChannels: () => api2.request(
7726
+ `${base}/social/youtube/channels`
7727
+ ),
7728
+ chooseYouTubeChannel: (connectionId) => put(
7729
+ `${base}/social/youtube/channel`,
7730
+ {
7731
+ connection_id: connectionId
7732
+ }
7733
+ ),
7734
+ /** Disconnect: the token is deleted and Google's access revoked (YouTube's policy). */
7735
+ disconnectYouTube: (connectionId) => api2.request(`${base}/social/youtube/${connectionId}`, { method: "DELETE" }),
7736
+ /** A TikTok sign-in link for this website (docs/prd/tiktok.md §7); back with ?tiktok=connected (error&message=…). */
7737
+ connectTikTok: (returnTo = "close") => post(`${base}/social/tiktok/connect`, { return_to: returnTo }),
7738
+ /** What the TikTok account allows right now: show it in the TikTok post editor (TikTok's guidelines). */
7739
+ tiktokCreator: () => api2.request(`${base}/social/tiktok/creator`),
7740
+ /** Disconnect: TikTok's access is revoked and the token deleted. */
7741
+ disconnectTikTok: (connectionId) => api2.request(`${base}/social/tiktok/${connectionId}`, { method: "DELETE" }),
7742
+ /** A TikTok post's settings: who can view it, comments / Duet / Stitch, content disclosure, AI label. */
7743
+ setSocialTikTok: (id, answers) => api2.request(`${base}/social/${id}`, {
7744
+ method: "PATCH",
7745
+ headers: { "Content-Type": "application/json" },
7746
+ body: JSON.stringify({ tiktok: answers })
7747
+ }),
7748
+ /** Ask to join the beta of these channels: an email says what we need (reply or WhatsApp); the team is told. */
7749
+ requestBeta: (req) => post(`${base}/social/beta-request`, req),
7710
7750
  /** One channel's own pause switch (pauseSocial / resumeSocial hold every channel). */
7711
7751
  setChannelPaused: (channel, paused) => post(`${base}/social/channels/${channel}/${paused ? "pause" : "resume"}`),
7712
7752
  /** How often one channel posts. */
@@ -7871,7 +7911,35 @@ function domainApi(api2, domain) {
7871
7911
  headers: { "Content-Type": "application/json" },
7872
7912
  body: JSON.stringify({ cta_type: ctaType })
7873
7913
  }),
7914
+ /** Make a Google Business post an Event or Offer (or back to an update with { type: "STANDARD" }). */
7915
+ setSocialGooglePost: (id, googlePost) => api2.request(`${base}/social/${id}`, {
7916
+ method: "PATCH",
7917
+ headers: { "Content-Type": "application/json" },
7918
+ body: JSON.stringify({ google_post: googlePost })
7919
+ }),
7874
7920
  resumeSocial: () => post(`${base}/social/resume`),
7921
+ /** Start a video upload: PUT each part_size slice of the file to its url, keep the ETags, then completeVideoUpload. */
7922
+ startVideoUpload: (file) => post(
7923
+ `${base}/social/video/upload`,
7924
+ file
7925
+ ),
7926
+ completeVideoUpload: (videoId, parts, edit) => post(`${base}/social/video/${videoId}/complete`, { parts, edit }),
7927
+ /** A direct link to a video file (not a YouTube / TikTok page). */
7928
+ videoFromLink: (url, edit) => post(`${base}/social/video/link`, { url, edit }),
7929
+ video: (videoId) => api2.request(`${base}/social/video/${videoId}`),
7930
+ /** Another part or framing; prepared again in the background. */
7931
+ editVideo: (videoId, edit) => post(`${base}/social/video/${videoId}/edit`, { edit }),
7932
+ /** A post on one video channel (YouTube by default, or TikTok) from a ready video, waiting in Review; Maggie
7933
+ * writes the details (YouTube) or the caption (TikTok) unless write is false. */
7934
+ postFromVideo: (videoId, opts = {}) => post(`${base}/social/video/${videoId}/post`, opts),
7935
+ youtubeSettings: () => api2.request(`${base}/social/youtube/settings`),
7936
+ setYouTubeSettings: (s) => put(`${base}/social/youtube/settings`, s),
7937
+ /** A YouTube post's title, tags, visibility, made-for-kids or altered-content answer. */
7938
+ setSocialYouTube: (id, answers) => api2.request(`${base}/social/${id}`, {
7939
+ method: "PATCH",
7940
+ headers: { "Content-Type": "application/json" },
7941
+ body: JSON.stringify({ youtube: answers })
7942
+ }),
7875
7943
  pauseAutopilot: () => post(`${base}/autopilot/pause`),
7876
7944
  resumeAutopilot: () => post(`${base}/autopilot/resume`),
7877
7945
  // Reports (docs/18 §6): report:read with the user's own key.
@@ -8111,6 +8179,9 @@ var list = (xs, n = 5) => {
8111
8179
  };
8112
8180
  var ids = (a) => (Array.isArray(a.ids) && a.ids.length ? a.ids : a.id != null ? [a.id] : []).map(Number);
8113
8181
  var keys = (a) => Object.keys(a ?? {}).filter((k) => a[k] !== void 0);
8182
+ var videoChannels = (a) => (Array.isArray(a.channels) && a.channels.length ? a.channels : ["youtube"]).map(
8183
+ (c) => c === "tiktok" ? "TikTok" : "YouTube"
8184
+ );
8114
8185
  var confirmCopy = {
8115
8186
  // --------------------------------------------------------- account / setup
8116
8187
  website_add: {
@@ -8292,6 +8363,36 @@ var confirmCopy = {
8292
8363
  en: (a) => `Bring back LinkedIn post #${a.id}.`,
8293
8364
  "zh-Hant": (a) => `\u6062\u5FA9 LinkedIn \u8CBC\u6587 #${a.id}\u3002`
8294
8365
  },
8366
+ youtube_connect_link: {
8367
+ en: () => "Make a link to connect your YouTube channel.",
8368
+ "zh-Hant": () => "\u7522\u751F\u9023\u63A5\u4F60 YouTube \u983B\u9053\u7684\u9023\u7D50\u3002"
8369
+ },
8370
+ tiktok_connect_link: {
8371
+ en: () => "Make a link to connect your TikTok account.",
8372
+ "zh-Hant": () => "\u7522\u751F\u9023\u63A5\u4F60 TikTok \u5E33\u865F\u7684\u9023\u7D50\u3002"
8373
+ },
8374
+ social_video_add: {
8375
+ en: (a) => {
8376
+ const names = videoChannels(a).join(" and ");
8377
+ return `Make a ${names} post from ${a.url}${a.write === false ? "" : ` (Maggie writes it: one AI generation per channel)`}; it waits for your OK.`;
8378
+ },
8379
+ "zh-Hant": (a) => {
8380
+ const names = videoChannels(a).join(" \u548C ");
8381
+ return `\u7528 ${a.url} \u505A ${names} \u5F71\u7247\u8CBC\u6587${a.write === false ? "" : "\uFF08Maggie \u4F86\u5BEB\uFF1A\u6BCF\u500B\u6E20\u9053\u7528\u4E00\u6B21 AI \u751F\u6210\uFF09"}\uFF0C\u7B49\u4F60\u78BA\u8A8D\u5F8C\u624D\u767C\u3002`;
8382
+ }
8383
+ },
8384
+ social_tiktok_set: {
8385
+ en: (a) => `Set TikTok post #${a.id}: ${keys(a).filter((k) => k !== "id").map((k) => `${k} ${String(a[k])}`).join(", ") || "its settings"}.`,
8386
+ "zh-Hant": (a) => `\u8A2D\u5B9A TikTok \u8CBC\u6587 #${a.id}\uFF1A${keys(a).filter((k) => k !== "id").map((k) => `${k} ${String(a[k])}`).join("\u3001") || "\u8A2D\u5B9A"}\u3002`
8387
+ },
8388
+ social_youtube_set: {
8389
+ en: (a) => `Change YouTube post #${a.id}: ${keys(a).filter((k) => k !== "id").join(", ") || "its details"}.`,
8390
+ "zh-Hant": (a) => `\u4FEE\u6539 YouTube \u8CBC\u6587 #${a.id}\uFF1A${keys(a).filter((k) => k !== "id").join("\u3001") || "\u7D30\u7BC0"}\u3002`
8391
+ },
8392
+ youtube_settings_set: {
8393
+ en: (a) => `Change your YouTube defaults: ${keys(a).join(", ") || "nothing"}.`,
8394
+ "zh-Hant": (a) => `\u4FEE\u6539 YouTube \u9810\u8A2D\uFF1A${keys(a).join("\u3001") || "\u7121"}\u3002`
8395
+ },
8295
8396
  social_image_new: {
8296
8397
  en: (a) => `Make a new picture for LinkedIn post #${a.id}${a.instruction ? `: ${String(a.instruction).slice(0, 300)}` : ""} (1 AI generation, in the background)${a.replace ? ", stopping the one being made for another post" : ""}.`,
8297
8398
  "zh-Hant": (a) => `\u70BA LinkedIn \u8CBC\u6587 #${a.id} \u751F\u6210\u65B0\u5716\u7247${a.instruction ? `\uFF1A${String(a.instruction).slice(0, 300)}` : ""}\uFF08\u7528 1 \u6B21 AI \u751F\u6210\uFF0C\u5728\u80CC\u666F\u9032\u884C\uFF09${a.replace ? "\uFF0C\u4E26\u505C\u6B62\u6B63\u5728\u70BA\u53E6\u4E00\u7BC7\u751F\u6210\u7684\u5716\u7247" : ""}\u3002`
@@ -10305,10 +10406,13 @@ var read5 = { resource: "autopilot", level: "read" };
10305
10406
  var write5 = { resource: "autopilot", level: "write" };
10306
10407
  var moves2 = ["up", "down", "top"];
10307
10408
  var CHANNEL_KEYS = ["linkedin", "facebook", "instagram", "google_business"];
10409
+ var ALL_CHANNEL_KEYS = [...CHANNEL_KEYS, "youtube", "tiktok"];
10410
+ var VIDEO_KEYS = ["youtube", "tiktok"];
10411
+ var TIKTOK_PRIVACY = ["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"];
10308
10412
  var channelsParam = {
10309
10413
  type: "array",
10310
10414
  items: { type: "string", enum: CHANNEL_KEYS },
10311
- description: "Which channels to write for: linkedin, facebook. Leave out to write for every connected channel (one post each, one credit each); give one only when the user names it."
10415
+ description: "Which channels to write for: linkedin, facebook, instagram, google_business. Leave out to write for every connected channel (one post each, one credit each); give one only when the user names it. YouTube and TikTok take a video: use social_video_add."
10312
10416
  };
10313
10417
  var channelsOf = (value) => {
10314
10418
  const list2 = Array.isArray(value) ? value.map((v) => String(v).toLowerCase()).filter((v) => CHANNEL_KEYS.includes(v)) : [];
@@ -10331,7 +10435,15 @@ var item = (i) => ({
10331
10435
  campaign: i.campaign ?? null,
10332
10436
  failure: i.failure_message ?? null,
10333
10437
  link: i.provider_permalink ?? null,
10334
- text: clip(i.social_copy, 300)
10438
+ text: clip(i.social_copy, 300),
10439
+ ...i.provider === "youtube" ? {
10440
+ youtube: i.youtube ?? null,
10441
+ video: i.video ? { status: i.video.status, seconds: i.video.duration, shape: i.video.shape, error: i.video.error } : null
10442
+ } : {},
10443
+ ...i.provider === "tiktok" ? {
10444
+ tiktok: i.tiktok ?? null,
10445
+ video: i.video ? { status: i.video.status, seconds: i.video.duration, shape: i.video.shape, error: i.video.error } : null
10446
+ } : {}
10335
10447
  });
10336
10448
  var socialTools = [
10337
10449
  tool({
@@ -10740,7 +10852,7 @@ var socialTools = [
10740
10852
  description: "Pause or resume the user's social posting: one channel (linkedin or facebook), or every channel when no channel is given.",
10741
10853
  parameters: {
10742
10854
  type: "object",
10743
- properties: { paused: { type: "boolean" }, channel: { type: "string", enum: CHANNEL_KEYS } },
10855
+ properties: { paused: { type: "boolean" }, channel: { type: "string", enum: ALL_CHANNEL_KEYS } },
10744
10856
  required: ["paused"]
10745
10857
  },
10746
10858
  group: "social",
@@ -11296,6 +11408,250 @@ var socialTools = [
11296
11408
  return { id: out.id, button: out.cta_type ?? "LEARN_MORE", link: out.cta_url ?? null };
11297
11409
  }
11298
11410
  }),
11411
+ tool({
11412
+ name: "youtube_connect_link",
11413
+ description: "A link for the user to connect their YouTube channel (or reconnect): they sign in with the Google account that owns the channel and allow Maggie to upload videos and see the channel. Give them the link to open; never ask for a password. YouTube posts are short videos (5-30 seconds now) that the user approves one by one.",
11414
+ parameters: none,
11415
+ group: "social",
11416
+ need: write5,
11417
+ risk: "W1",
11418
+ gates: ["VER"],
11419
+ run: async (ctx) => {
11420
+ const out = await ws(ctx).connectYouTube("close");
11421
+ return { url: out.url, expires_in_seconds: out.expires_in_seconds };
11422
+ }
11423
+ }),
11424
+ tool({
11425
+ name: "tiktok_connect_link",
11426
+ description: "A link for the user to connect their TikTok account (or reconnect): they sign in to TikTok and allow Maggie to see their profile and post videos. Give them the link to open; never ask for a password. TikTok is in beta (testers only; until TikTok reviews our app, videos post as 'Only me' and the account must be private). TikTok posts are short videos (5-30 seconds) that the user approves one by one.",
11427
+ parameters: none,
11428
+ group: "social",
11429
+ need: write5,
11430
+ risk: "W1",
11431
+ gates: ["VER"],
11432
+ run: async (ctx) => {
11433
+ const out = await ws(ctx).connectTikTok("close");
11434
+ return { url: out.url, expires_in_seconds: out.expires_in_seconds };
11435
+ }
11436
+ }),
11437
+ tool({
11438
+ name: "tiktok_creator",
11439
+ description: "What the user's TikTok account allows right now (TikTok asks to check before every post): its name and @username, who a video can be shown to (privacy_options), whether comments / Duet / Stitch are turned off on the account, the longest video it can post, and whether it can post now (problem). Read it before asking the user the TikTok settings of a post.",
11440
+ parameters: none,
11441
+ group: "social",
11442
+ need: read5,
11443
+ risk: "R0",
11444
+ run: async (ctx) => ws(ctx).tiktokCreator()
11445
+ }),
11446
+ tool({
11447
+ name: "social_tiktok_set",
11448
+ description: "Set a TikTok post's settings, ONLY as the user chose them (TikTok's rules: never pick for them, nothing is preset): privacy (one of the account's privacy_options from tiktok_creator: PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY), allow_comment / allow_duet / allow_stitch (off unless the user turns them on; not possible when the account turned them off), disclose (the video promotes a brand, product or service) with your_brand (their own: labelled 'Promotional content') and / or branded (a third party: labelled 'Paid partnership'; can't be SELF_ONLY), is_aigc (AI-generated content label). The caption is the post's text (social_update_copy). Before the user approves, tell them: 'By posting, you agree to TikTok's Music Usage Confirmation' (with branded content: 'TikTok's Branded Content Policy and Music Usage Confirmation'). Preview first.",
11449
+ parameters: {
11450
+ type: "object",
11451
+ properties: {
11452
+ id: { type: "integer", description: "A TikTok post (from social_list)" },
11453
+ privacy: { type: "string", enum: TIKTOK_PRIVACY },
11454
+ allow_comment: { type: "boolean" },
11455
+ allow_duet: { type: "boolean" },
11456
+ allow_stitch: { type: "boolean" },
11457
+ disclose: { type: "boolean" },
11458
+ your_brand: { type: "boolean" },
11459
+ branded: { type: "boolean" },
11460
+ is_aigc: { type: "boolean" }
11461
+ },
11462
+ required: ["id"]
11463
+ },
11464
+ group: "social",
11465
+ need: write5,
11466
+ risk: "W1",
11467
+ gates: ["VER"],
11468
+ run: async (ctx, { id, privacy, ...rest }) => {
11469
+ const answers = Object.fromEntries(
11470
+ Object.entries(rest).filter(([, v]) => v !== void 0)
11471
+ );
11472
+ if (privacy) answers.privacy = privacy;
11473
+ const out = await ws(ctx).setSocialTikTok(Number(id), answers);
11474
+ return item(out);
11475
+ }
11476
+ }),
11477
+ tool({
11478
+ name: "social_video_add",
11479
+ description: "Make a YouTube and / or TikTok post (channels; default youtube) from a video: a DIRECT link to a video file (an .mp4 on their website, a public Dropbox link), never a YouTube / TikTok / Instagram page (we never download those; tell the user to upload the video in the app instead). The video must be 5-30 seconds: give start/end (seconds) to use a part of a longer one. vertical: make a wide video vertical (a Short) cropped at left, middle or right. Maggie writes YouTube's title, description and tags, or TikTok's caption, from the video and the note (one AI generation per channel) unless write is false. Each post waits in Review: video posts are always approved by the user. YouTube asks whether it's made for kids; TikTok asks who can view it and the other TikTok settings (social_tiktok_set, as the user chooses). is_aigc: TikTok's AI-generated label, only when the user says the video was made with AI. Preview first.",
11480
+ parameters: {
11481
+ type: "object",
11482
+ properties: {
11483
+ url: { type: "string", description: "Direct https link to the video file" },
11484
+ channels: {
11485
+ type: "array",
11486
+ items: { type: "string", enum: [...VIDEO_KEYS] },
11487
+ description: "youtube, tiktok or both (one post each); default youtube"
11488
+ },
11489
+ is_aigc: { type: "boolean", description: "TikTok: the video is AI-generated (the user said so)" },
11490
+ note: { type: "string", description: "What the video is about, in the user's words" },
11491
+ write: { type: "boolean", description: "Let Maggie write the details (default true)" },
11492
+ start: { type: "number", description: "Start of the part to use, seconds" },
11493
+ end: { type: "number", description: "End of the part to use, seconds" },
11494
+ vertical: { type: "string", enum: ["left", "middle", "right"] }
11495
+ },
11496
+ required: ["url"]
11497
+ },
11498
+ group: "social",
11499
+ need: write5,
11500
+ risk: "W2",
11501
+ gates: ["VER", "LLM"],
11502
+ run: async (ctx, a) => {
11503
+ const d = ws(ctx);
11504
+ const edit = {
11505
+ ...a.start != null ? { start: Number(a.start) } : {},
11506
+ ...a.end != null ? { end: Number(a.end) } : {},
11507
+ ...a.vertical ? { aspect: "9:16", crop_x: a.vertical === "left" ? 0 : a.vertical === "right" ? 1 : 0.5 } : {}
11508
+ };
11509
+ let v = await d.videoFromLink(String(a.url), Object.keys(edit).length ? edit : void 0);
11510
+ const until = Date.now() + 15e4;
11511
+ while ((v.status === "checking" || v.status === "uploading") && Date.now() < until) {
11512
+ await new Promise((r) => setTimeout(r, 3e3));
11513
+ v = await d.video(v.id);
11514
+ }
11515
+ if (v.status !== "ready")
11516
+ return { ok: false, video: { status: v.status, error: v.error, seconds: v.source_duration } };
11517
+ const wanted = (a.channels ?? ["youtube"]).filter(
11518
+ (c) => VIDEO_KEYS.includes(c)
11519
+ );
11520
+ const posts2 = [];
11521
+ for (const channel of wanted.length ? wanted : ["youtube"]) {
11522
+ posts2.push(
11523
+ await d.postFromVideo(v.id, {
11524
+ channel,
11525
+ note: a.note ?? "",
11526
+ write: a.write !== false,
11527
+ ...channel === "tiktok" && a.is_aigc != null ? { is_aigc: a.is_aigc } : {}
11528
+ })
11529
+ );
11530
+ }
11531
+ return {
11532
+ ok: true,
11533
+ ...posts2.length === 1 ? { post: item(posts2[0]) } : { posts: posts2.map(item) },
11534
+ next: [
11535
+ posts2.some((p) => p.provider === "youtube") ? "YouTube: show the title and description; ask whether it's made for kids and which visibility, then social_youtube_set." : "",
11536
+ posts2.some((p) => p.provider === "tiktok") ? "TikTok: show the caption; read tiktok_creator and ask who can view it, whether to allow comments / Duet / Stitch and whether it promotes a brand, then social_tiktok_set exactly as they say; tell them the Music Usage Confirmation line." : "",
11537
+ "The user approves each post."
11538
+ ].filter(Boolean).join(" ")
11539
+ };
11540
+ }
11541
+ }),
11542
+ tool({
11543
+ name: "social_youtube_set",
11544
+ description: "Change a YouTube post that hasn't gone out: title (at most 100 characters, aim 40-60), tags, visibility (public, unlisted, private), made_for_kids (YouTube asks for every video: ask the user, never guess) or synthetic (realistic AI-made or altered content). The description is the post's text (social_update_copy). Preview first.",
11545
+ parameters: {
11546
+ type: "object",
11547
+ properties: {
11548
+ id: { type: "integer", description: "A YouTube post (from social_list)" },
11549
+ title: { type: "string" },
11550
+ tags: { type: "array", items: { type: "string" } },
11551
+ privacy: { type: "string", enum: ["public", "unlisted", "private"] },
11552
+ made_for_kids: { type: "boolean" },
11553
+ synthetic: { type: "boolean" }
11554
+ },
11555
+ required: ["id"]
11556
+ },
11557
+ group: "social",
11558
+ need: write5,
11559
+ risk: "W1",
11560
+ gates: ["VER"],
11561
+ run: async (ctx, { id, ...rest }) => {
11562
+ const answers = Object.fromEntries(
11563
+ Object.entries(rest).filter(([, v]) => v !== void 0)
11564
+ );
11565
+ const out = await ws(ctx).setSocialYouTube(Number(id), answers);
11566
+ return item(out);
11567
+ }
11568
+ }),
11569
+ tool({
11570
+ name: "youtube_settings",
11571
+ description: "The user's YouTube defaults for new posts on this website: visibility, made_for_kids (null = asked each time), category and altered content.",
11572
+ parameters: none,
11573
+ group: "social",
11574
+ need: read5,
11575
+ risk: "R0",
11576
+ run: async (ctx) => ws(ctx).youtubeSettings()
11577
+ }),
11578
+ tool({
11579
+ name: "youtube_settings_set",
11580
+ description: "Change the YouTube defaults for new posts: privacy (public, unlisted, private), made_for_kids (yes / no for every video, or ask per video) and synthetic. Only what the user says. Preview first.",
11581
+ parameters: {
11582
+ type: "object",
11583
+ properties: {
11584
+ privacy: { type: "string", enum: ["public", "unlisted", "private"] },
11585
+ made_for_kids: {
11586
+ type: "string",
11587
+ enum: ["yes", "no", "ask"],
11588
+ description: "yes / no for every new video, or ask (answer per video)"
11589
+ },
11590
+ synthetic: { type: "boolean" }
11591
+ }
11592
+ },
11593
+ group: "social",
11594
+ need: write5,
11595
+ risk: "W1",
11596
+ gates: ["VER"],
11597
+ run: async (ctx, a) => ws(ctx).setYouTubeSettings({
11598
+ ...a.privacy ? { privacy: a.privacy } : {},
11599
+ ...a.made_for_kids ? { made_for_kids: a.made_for_kids === "ask" ? null : a.made_for_kids === "yes" } : {},
11600
+ ...a.synthetic != null ? { synthetic: a.synthetic } : {}
11601
+ })
11602
+ }),
11603
+ tool({
11604
+ name: "google_business_performance",
11605
+ description: "How the website's Google Business Profile is doing: views on Google Search and Maps, website clicks, calls and direction requests for the last 28 days next to the 28 before (Google's numbers lag 2-3 days). Use it when the user asks how their Google listing or local presence is doing, or to show what Google posts are worth. Compare with the previous 28 days; don't invent reasons for changes.",
11606
+ parameters: {
11607
+ type: "object",
11608
+ properties: { refresh: { type: "boolean", description: "Read Google again now (else up to 6 hours old)" } }
11609
+ },
11610
+ group: "social",
11611
+ need: read5,
11612
+ risk: "R0",
11613
+ run: async (ctx, { refresh }) => ws(ctx).googleBusinessPerformance(refresh === true)
11614
+ }),
11615
+ tool({
11616
+ name: "social_google_post_type",
11617
+ description: "Make a Google Business post that hasn't gone out an EVENT (an open day, a workshop: title up to 58 characters, start and end dates YYYY-MM-DD, optional times HH:MM) or an OFFER (title, dates, optional coupon code, terms and redeem link; Google adds a View offer button), or back to STANDARD (an update). Offers are refused for regulated categories (finance, health, alcohol, gambling) and hotels: use an update or an event. Only real dates and offers the user gave. Preview first.",
11618
+ parameters: {
11619
+ type: "object",
11620
+ properties: {
11621
+ id: { type: "integer", description: "A Google Business post (from social_list)" },
11622
+ type: { type: "string", enum: ["STANDARD", "EVENT", "OFFER"] },
11623
+ title: { type: "string", description: "Event or offer title, up to 58 characters" },
11624
+ start: { type: "string", description: "YYYY-MM-DD" },
11625
+ end: { type: "string", description: "YYYY-MM-DD (defaults to start)" },
11626
+ start_time: { type: "string", description: "HH:MM (optional)" },
11627
+ end_time: { type: "string", description: "HH:MM (optional)" },
11628
+ coupon: { type: "string", description: "Offer code (optional)" },
11629
+ terms: { type: "string", description: "Offer terms (optional)" },
11630
+ redeem_url: { type: "string", description: "Where to redeem the offer online (optional)" }
11631
+ },
11632
+ required: ["id", "type"]
11633
+ },
11634
+ group: "social",
11635
+ need: write5,
11636
+ risk: "W1",
11637
+ gates: ["VER"],
11638
+ run: async (ctx, a) => {
11639
+ const type = String(a.type).toUpperCase();
11640
+ const body = type === "STANDARD" ? { type: "STANDARD" } : {
11641
+ type,
11642
+ title: String(a.title ?? ""),
11643
+ start: String(a.start ?? ""),
11644
+ ...a.end ? { end: a.end } : {},
11645
+ ...a.start_time ? { start_time: a.start_time } : {},
11646
+ ...a.end_time ? { end_time: a.end_time } : {},
11647
+ ...a.coupon ? { coupon: a.coupon } : {},
11648
+ ...a.terms ? { terms: a.terms } : {},
11649
+ ...a.redeem_url ? { redeem_url: a.redeem_url } : {}
11650
+ };
11651
+ const out = await ws(ctx).setSocialGooglePost(Number(a.id), body);
11652
+ return { id: out.id, google_post: out.google_post ?? null };
11653
+ }
11654
+ }),
11299
11655
  tool({
11300
11656
  name: "instagram_connect_link",
11301
11657
  description: "A link for the user to connect Instagram (or reconnect). Instagram connects through the Facebook sign-in: only an Instagram professional account (Business or Creator) linked to one of their Facebook Pages can be posted to, so tell them that first. Give them the link to open; never ask for a password. Every Instagram post goes out with its picture (no links in it).",
@@ -14853,7 +15209,7 @@ var program = new Command();
14853
15209
  // package.json
14854
15210
  var package_default = {
14855
15211
  name: "@cmo-so/cli",
14856
- version: "0.3.0",
15212
+ version: "0.5.0",
14857
15213
  description: "CMO.so on the command line: your websites, ideas, blog posts, LinkedIn queue and results, plus a local MCP server for Claude Desktop, Cursor and other MCP clients.",
14858
15214
  type: "module",
14859
15215
  license: "MIT",
@@ -22995,10 +23351,14 @@ posts.command("ls", { isDefault: true }).description("planned, draft or publishe
22995
23351
  posts.command("show <id>").description("one post in full").action(act(async (opts, id) => tool2(opts, "post_get", { id })));
22996
23352
  posts.command("publish <id>").description("publish a draft now (uses a post credit)").action(act(async (opts, id) => tool2(opts, "post_publish_now", { id })));
22997
23353
  posts.command("delete <id>").description("delete a draft").action(act(async (opts, id) => tool2(opts, "draft_delete", { id })));
22998
- var social = program2.command("social").description("your social posts: LinkedIn, your Facebook Page, Instagram and your Google Business Profile");
22999
- var CHANNELS = ["linkedin", "facebook", "instagram", "google_business"];
23354
+ var social = program2.command("social").description(
23355
+ "your social posts: LinkedIn, your Facebook Page, Instagram, your Google Business Profile, YouTube and TikTok"
23356
+ );
23357
+ var CHANNELS = ["linkedin", "facebook", "instagram", "google_business", "youtube", "tiktok"];
23000
23358
  var channelsOpt = (value) => {
23001
- const list2 = (value ?? "").split(",").map((v) => v.trim().toLowerCase()).map((v) => v === "google" || v === "gbp" ? "google_business" : v).filter((v) => CHANNELS.includes(v));
23359
+ const list2 = (value ?? "").split(",").map((v) => v.trim().toLowerCase()).map(
23360
+ (v) => v === "google" || v === "gbp" ? "google_business" : v === "yt" || v === "shorts" ? "youtube" : v === "tt" ? "tiktok" : v
23361
+ ).filter((v) => CHANNELS.includes(v));
23002
23362
  return list2.length ? list2 : void 0;
23003
23363
  };
23004
23364
  social.command("ls", { isDefault: true }).description("drafts to review and queued posts").action(
@@ -23028,7 +23388,9 @@ for (const [cmd, paused] of [
23028
23388
  ["pause", true],
23029
23389
  ["resume", false]
23030
23390
  ])
23031
- social.command(cmd).description(`${cmd} posting (every channel, or --channel linkedin | facebook | instagram | google_business)`).option("--channel <name>", "only this channel").action(
23391
+ social.command(cmd).description(
23392
+ `${cmd} posting (every channel, or --channel linkedin | facebook | instagram | google_business | youtube | tiktok)`
23393
+ ).option("--channel <name>", "only this channel").action(
23032
23394
  act(
23033
23395
  async (opts) => tool2(opts, "social_set_paused", {
23034
23396
  paused,
@@ -23036,6 +23398,78 @@ for (const [cmd, paused] of [
23036
23398
  })
23037
23399
  )
23038
23400
  );
23401
+ var youtube = social.command("youtube").description("your YouTube channel: short videos you approve one by one");
23402
+ youtube.command("connect").description("a link to connect your YouTube channel").action(act(async (opts) => tool2(opts, "youtube_connect_link")));
23403
+ youtube.command("add <url>").description("a YouTube post from a direct link to a video file (5-30 s): Maggie writes the title and description").option("--note <text>", "what the video is about").option("--start <seconds>", "use the part from here").option("--end <seconds>", "use the part up to here").option("--vertical <where>", "make a wide video vertical: left, middle or right").option("--no-write", "write the title and description yourself").action(
23404
+ act(
23405
+ async (opts, url) => tool2(opts, "social_video_add", {
23406
+ url,
23407
+ ...opts.note ? { note: opts.note } : {},
23408
+ ...opts.start ? { start: Number(opts.start) } : {},
23409
+ ...opts.end ? { end: Number(opts.end) } : {},
23410
+ ...opts.vertical ? { vertical: opts.vertical } : {},
23411
+ write: opts.write !== false
23412
+ })
23413
+ )
23414
+ );
23415
+ youtube.command("set <id>").description("a YouTube post's title, tags, visibility, made-for-kids or altered-content answer").option("--title <text>").option("--tags <list>", "comma-separated").option("--privacy <p>", "public, unlisted or private").option("--kids <yes|no>", "made for kids").option("--synthetic <yes|no>", "realistic AI-made or altered content").action(
23416
+ act(
23417
+ async (opts, id) => tool2(opts, "social_youtube_set", {
23418
+ id: ids2([id])[0],
23419
+ ...opts.title ? { title: opts.title } : {},
23420
+ ...opts.tags ? {
23421
+ tags: opts.tags.split(",").map((t) => t.trim()).filter(Boolean)
23422
+ } : {},
23423
+ ...opts.privacy ? { privacy: opts.privacy } : {},
23424
+ ...opts.kids ? { made_for_kids: opts.kids === "yes" } : {},
23425
+ ...opts.synthetic ? { synthetic: opts.synthetic === "yes" } : {}
23426
+ })
23427
+ )
23428
+ );
23429
+ youtube.command("settings").description("your YouTube defaults (visibility, made for kids)").action(act(async (opts) => tool2(opts, "youtube_settings")));
23430
+ var tiktok = social.command("tiktok").description("your TikTok account (beta): short videos you approve one by one");
23431
+ tiktok.command("connect").description("a link to connect your TikTok account").action(act(async (opts) => tool2(opts, "tiktok_connect_link")));
23432
+ tiktok.command("account").description("what your TikTok account allows now: who can view, comments / Duet / Stitch, longest video").action(act(async (opts) => tool2(opts, "tiktok_creator")));
23433
+ tiktok.command("add <url>").description("a TikTok post from a direct link to a video file (5-30 s): Maggie writes the caption").option("--note <text>", "what the video is about").option("--start <seconds>", "use the part from here").option("--end <seconds>", "use the part up to here").option("--vertical <where>", "make a wide video vertical: left, middle or right").option("--ai", "the video is AI-generated (TikTok labels it)").option("--no-write", "write the caption yourself").action(
23434
+ act(
23435
+ async (opts, url) => tool2(opts, "social_video_add", {
23436
+ url,
23437
+ channels: ["tiktok"],
23438
+ ...opts.note ? { note: opts.note } : {},
23439
+ ...opts.start ? { start: Number(opts.start) } : {},
23440
+ ...opts.end ? { end: Number(opts.end) } : {},
23441
+ ...opts.vertical ? { vertical: opts.vertical } : {},
23442
+ ...opts.ai ? { is_aigc: true } : {},
23443
+ write: opts.write !== false
23444
+ })
23445
+ )
23446
+ );
23447
+ tiktok.command("set <id>").description("a TikTok post's settings, as you choose them (nothing is preset)").option("--privacy <p>", "everyone, friends, followers or only-me (what your account offers)").option("--comment <yes|no>").option("--duet <yes|no>").option("--stitch <yes|no>").option("--promotes <what>", "none, your-brand, branded or both (content disclosure)").option("--ai <yes|no>", "AI-generated content label").action(
23448
+ act(
23449
+ async (opts, id) => {
23450
+ const privacy = {
23451
+ everyone: "PUBLIC_TO_EVERYONE",
23452
+ friends: "MUTUAL_FOLLOW_FRIENDS",
23453
+ followers: "FOLLOWER_OF_CREATOR",
23454
+ "only-me": "SELF_ONLY"
23455
+ }[String(opts.privacy ?? "").toLowerCase()];
23456
+ const p = opts.promotes?.toLowerCase();
23457
+ return tool2(opts, "social_tiktok_set", {
23458
+ id: ids2([id])[0],
23459
+ ...privacy ? { privacy } : {},
23460
+ ...opts.comment ? { allow_comment: opts.comment === "yes" } : {},
23461
+ ...opts.duet ? { allow_duet: opts.duet === "yes" } : {},
23462
+ ...opts.stitch ? { allow_stitch: opts.stitch === "yes" } : {},
23463
+ ...opts.ai ? { is_aigc: opts.ai === "yes" } : {},
23464
+ ...p ? {
23465
+ disclose: p !== "none",
23466
+ your_brand: p === "your-brand" || p === "both",
23467
+ branded: p === "branded" || p === "both"
23468
+ } : {}
23469
+ });
23470
+ }
23471
+ )
23472
+ );
23039
23473
  var autopilot = program2.command("autopilot").description("the blog autopilot");
23040
23474
  autopilot.command("status", { isDefault: true }).description("on or paused, posts per day, where it publishes").action(act(async (opts) => tool2(opts, "publish_settings_get")));
23041
23475
  for (const [cmd, paused] of [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmo-so/cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "CMO.so on the command line: your websites, ideas, blog posts, LinkedIn queue and results, plus a local MCP server for Claude Desktop, Cursor and other MCP clients.",
5
5
  "type": "module",
6
6
  "license": "MIT",