@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.
- package/dist/cmo.mjs +443 -9
- 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 = [
|
|
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:
|
|
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.
|
|
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(
|
|
22999
|
-
|
|
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(
|
|
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(
|
|
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
|
+
"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",
|