From 614690ec4585868e5d41ac9a9b5b41b1989cee29 Mon Sep 17 00:00:00 2001 From: Francesco Di Lorenzo Date: Fri, 10 Jul 2026 15:47:42 +0100 Subject: [PATCH 1/2] feat(typefully): add LinkedIn first comment support to drafts drafts:create and drafts:update accept --linkedin-first-comment (aliases: --linkedin_first_comment, --first-comment, --first_comment), sent to API v2 as platforms.linkedin.settings.first_comment. On update, a literal null clears the comment and omitting the flag preserves it: the CLI re-sends the existing comment on text-only updates because the API clears LinkedIn settings when omitted. A settings-only update (flag without --text) re-sends the draft's existing LinkedIn posts, mirroring the X metadata-only update path. --- skills/typefully/CHANGELOG.md | 8 +- skills/typefully/SKILL.md | 6 +- .../references/platforms/linkedin.md | 19 ++ skills/typefully/scripts/typefully.js | 127 +++++++++++- tests/drafts.test.js | 191 +++++++++++++++++- 5 files changed, 335 insertions(+), 16 deletions(-) diff --git a/skills/typefully/CHANGELOG.md b/skills/typefully/CHANGELOG.md index f06641d..6d9ff99 100644 --- a/skills/typefully/CHANGELOG.md +++ b/skills/typefully/CHANGELOG.md @@ -4,7 +4,13 @@ All notable user-facing changes to the Typefully skill and its CLI are documente The format is based on Keep a Changelog. -## 2026-07-09 +## 2026-07-10 + +### Added + +- LinkedIn first comment support: `drafts:create`/`drafts:update` accept `--linkedin-first-comment ` to post a plain-text comment right after the LinkedIn post is published (the "link in the first comment" pattern). On update, a literal `null` removes the comment and omitting the flag preserves it — including on text-only updates, where the CLI re-sends the existing comment so the API doesn't clear it. +- Aliases: `--linkedin_first_comment`, `--first-comment`, `--first_comment`. +- Documented in `SKILL.md` (flags table, common actions) and `references/platforms/linkedin.md`. ### Changed diff --git a/skills/typefully/SKILL.md b/skills/typefully/SKILL.md index 77c5357..036c1fb 100644 --- a/skills/typefully/SKILL.md +++ b/skills/typefully/SKILL.md @@ -6,7 +6,7 @@ description: > social media content for Twitter/X, LinkedIn, Threads, Bluesky, or Mastodon, or when the user drops a Typefully draft URL such as https://typefully.com/?a=&d=. -last-updated: 2026-07-09 +last-updated: 2026-07-10 allowed-tools: Bash(./scripts/typefully.js:*) --- @@ -135,6 +135,7 @@ When the user asks to add notes, ideas, or context to a draft, use `--scratchpad | "Check my publishing quota" | `social-sets:get` → `publishing_quota` | | "Draft an X Article" | See [`references/platforms/x-articles.md`](references/platforms/x-articles.md) | | "Mention a company on LinkedIn" | See [`references/platforms/linkedin.md`](references/platforms/linkedin.md) | +| "Put the link in a LinkedIn first comment" | `drafts:create --platform linkedin --text "..." --linkedin-first-comment "..."` | | "Show my X analytics / followers" | See [`references/platforms/x.md`](references/platforms/x.md) | | "Comment on / resolve a comment" | See [`references/comments.md`](references/comments.md) | @@ -183,6 +184,7 @@ Add any of these flags to a `drafts:create` or `drafts:update` command. The **Ap | `--scratchpad ""` | Attach internal notes (see [Scratchpad notes](#scratchpad-notes)) | create, update | | `--share` | Generate a public share URL | create, update | | `--schedule ` | Schedule or reschedule the draft | create, update | +| `--linkedin-first-comment ""` | LinkedIn first comment, posted right after publishing (`null` removes it on update) | create, update | | `--exclude-comment-markers` | Render response without anchors (display only; validation still applies) | update | | `--force-overwrite-comments` | Destructive last resort — see [`comments.md`](references/comments.md) | update | @@ -193,7 +195,7 @@ For example, combine the base command with flags like this: ./scripts/typefully.js drafts:update 456 --text "Revised copy" --media abc-123 --use-default ``` -> X-only draft flags (`--reply-to`, `--quote-post-url`, `--community`, `--paid-partnership`, `--made-with-ai`): see [`platforms/x.md`](references/platforms/x.md). X Article flags (`--content-markdown`, `--cover-media-id`): see [`platforms/x-articles.md`](references/platforms/x-articles.md). +> X-only draft flags (`--reply-to`, `--quote-post-url`, `--community`, `--paid-partnership`, `--made-with-ai`): see [`platforms/x.md`](references/platforms/x.md). LinkedIn-only flags (`--linkedin-first-comment`): see [`platforms/linkedin.md`](references/platforms/linkedin.md). X Article flags (`--content-markdown`, `--cover-media-id`): see [`platforms/x-articles.md`](references/platforms/x-articles.md). ### Scheduling & publishing diff --git a/skills/typefully/references/platforms/linkedin.md b/skills/typefully/references/platforms/linkedin.md index d7f08f9..a75723e 100644 --- a/skills/typefully/references/platforms/linkedin.md +++ b/skills/typefully/references/platforms/linkedin.md @@ -26,3 +26,22 @@ Then include the returned `mention_text` in the draft: `linkedin:organizations:resolve [social_set_id] --organization-url ` returns `mention_text` and `urn`. > When commenting on text that contains a mention, select the entire `@[Name](urn:li:...)` substring or stay fully outside it. + +## First comment + +Post a comment right after the LinkedIn post is published — the common "link in the first comment" pattern: + +```bash +./scripts/typefully.js drafts:create --platform linkedin --text "Big launch today!" --linkedin-first-comment "Full details: https://example.com/launch" +``` + +On `drafts:update`, the flag sets or replaces the comment; a literal `null` removes it; omitting the flag keeps the current comment: + +```bash +./scripts/typefully.js drafts:update 123 d456 --linkedin-first-comment "Link here: https://example.com" +./scripts/typefully.js drafts:update 123 d456 --linkedin-first-comment null +``` + +- Plain text only: newlines are preserved and LinkedIn renders URLs as links, but mention syntax is **not** supported inside comments. +- Aliases: `--linkedin_first_comment`, `--first-comment`, `--first_comment`. +- Draft responses expose it as `platforms.linkedin.settings.first_comment`. diff --git a/skills/typefully/scripts/typefully.js b/skills/typefully/scripts/typefully.js index 9c18cea..6e3e61c 100755 --- a/skills/typefully/scripts/typefully.js +++ b/skills/typefully/scripts/typefully.js @@ -451,6 +451,48 @@ function addQuotePostUrl(posts, quotePostUrl) { return posts.map(post => ({ ...post, quote_post_url: quotePostUrl })); } +// LinkedIn first comment: a plain-text comment Typefully posts right after the +// LinkedIn post is published. Returns { provided, value } where value === null +// means "clear the first comment" (literal null, same convention as +// --cover-media-id). +function getLinkedInFirstCommentFromParsed(parsed) { + const keys = ['linkedin-first-comment', 'linkedin_first_comment', 'first-comment', 'first_comment']; + const found = []; + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(parsed, key)) { + found.push({ key, value: coerceFlagValueToString(parsed[key], `--${key}`, { allowEmpty: true }) }); + } + } + + if (found.length === 0) return { provided: false, value: null }; + + const primary = found[0].value; + for (const entry of found) { + if (entry.value !== primary) { + error('Conflicting LinkedIn first comment values', Object.fromEntries(found.map(e => [`--${e.key}`, e.value]))); + } + } + + if (primary.trim() === '' || primary.trim().toLowerCase() === 'null') { + return { provided: true, value: null }; + } + return { provided: true, value: primary }; +} + +function buildLinkedInSettingsForUpdate(firstComment, existing) { + if (firstComment.provided) { + // Explicit set or clear (literal null). + return { first_comment: firstComment.value }; + } + // Flag omitted: re-send the existing first comment so a text-only update + // doesn't clear it (the API clears LinkedIn settings when omitted). + const existingFirstComment = existing?.platforms?.linkedin?.settings?.first_comment; + if (existingFirstComment) { + return { first_comment: existingFirstComment }; + } + return null; +} + // Draft GET responses include response-only and platform-specific post fields // (e.g. subscribers_only, linkedin_reshare_urn) that the API's request schemas // reject with 422 on other platforms. Before re-sending fetched posts, keep only @@ -512,6 +554,12 @@ function validateXOnlyPostOptions(platformList, { quotePostUrl, disclosures }) { } } +function validateLinkedInOnlyOptions(platformList, { firstComment }) { + if (firstComment.provided && !platformList.includes('linkedin')) { + error('--linkedin-first-comment is only supported for LinkedIn. Include linkedin in --platform or remove the flag.'); + } +} + function hasParsedArg(parsed, key) { return Object.prototype.hasOwnProperty.call(parsed, key); } @@ -1264,6 +1312,7 @@ async function cmdDraftsCreate(args) { const socialSetId = resolveSocialSetIdFromParsed(parsed, parsed._positional[0]); const quotePostUrl = getQuotePostUrlFromParsed(parsed); const xContentDisclosures = getXContentDisclosuresFromParsed(parsed); + const linkedInFirstComment = getLinkedInFirstCommentFromParsed(parsed); // Determine platform(s) let platforms = parsed.platform; @@ -1294,6 +1343,7 @@ async function cmdDraftsCreate(args) { const platformList = parsePlatformList(platforms); validateXArticlePlatformUsage(platformList, parsed); + validateLinkedInOnlyOptions(platformList, { firstComment: linkedInFirstComment }); // Build request body const platformsObj = {}; @@ -1349,6 +1399,11 @@ async function cmdDraftsCreate(args) { } } + // LinkedIn-specific settings + if (platform === 'linkedin' && linkedInFirstComment.value) { + platformConfig.settings = { first_comment: linkedInFirstComment.value }; + } + platformsObj[platform] = platformConfig; } } @@ -1396,6 +1451,7 @@ async function cmdDraftsUpdate(args) { const { socialSetId, draftId } = resolveDraftTargetFromParsed(parsed, 'drafts:update'); const quotePostUrl = getQuotePostUrlFromParsed(parsed); const xContentDisclosures = getXContentDisclosuresFromParsed(parsed); + const linkedInFirstComment = getLinkedInFirstCommentFromParsed(parsed); const body = {}; const explicitPlatformList = parsed.platform @@ -1408,6 +1464,7 @@ async function cmdDraftsUpdate(args) { if (explicitPlatformList) { validateXArticlePlatformUsage(explicitPlatformList, parsed); + validateLinkedInOnlyOptions(explicitPlatformList, { firstComment: linkedInFirstComment }); } const shouldUpdateArticle = Boolean( @@ -1428,7 +1485,9 @@ async function cmdDraftsUpdate(args) { text = getPostTextFromParsed(parsed); } - const shouldUpdatePosts = Boolean(!shouldUpdateArticle && (text || quotePostUrl || xContentDisclosures.hasAny)); + const shouldUpdatePosts = Boolean( + !shouldUpdateArticle && (text || quotePostUrl || xContentDisclosures.hasAny || linkedInFirstComment.provided) + ); if (shouldUpdatePosts) { if (explicitPlatformList) { validateXOnlyPostOptions(explicitPlatformList, { @@ -1469,8 +1528,10 @@ async function cmdDraftsUpdate(args) { quotePostUrl, disclosures: xContentDisclosures, }); + validateLinkedInOnlyOptions(platformList, { firstComment: linkedInFirstComment }); let postsArray; + let metadataOnlyPosts = null; // platform -> existing posts, for metadata-only updates if (text) { if (parsed.append) { @@ -1501,22 +1562,37 @@ async function cmdDraftsUpdate(args) { }); } } else { - // X-only metadata update: preserve existing X posts and add quote/disclosure attrs. - const existingXPosts = existing.platforms?.x?.posts; - if (!Array.isArray(existingXPosts) || existingXPosts.length === 0) { - if (quotePostUrl && !xContentDisclosures.hasAny) { - error('Cannot apply --quote-post-url because this draft has no existing X posts'); + // Metadata-only update: preserve existing posts on the target platforms. + metadataOnlyPosts = {}; + platformList = []; + + if (quotePostUrl || xContentDisclosures.hasAny) { + const existingXPosts = existing.platforms?.x?.posts; + if (!Array.isArray(existingXPosts) || existingXPosts.length === 0) { + if (quotePostUrl && !xContentDisclosures.hasAny) { + error('Cannot apply --quote-post-url because this draft has no existing X posts'); + } + error('Cannot apply X-only post options because this draft has no existing X posts'); + } + metadataOnlyPosts.x = existingXPosts; + platformList.push('x'); + } + + if (linkedInFirstComment.provided) { + const existingLinkedInPosts = existing.platforms?.linkedin?.posts; + if (!Array.isArray(existingLinkedInPosts) || existingLinkedInPosts.length === 0) { + error('Cannot apply --linkedin-first-comment because this draft has no existing LinkedIn posts'); } - error('Cannot apply X-only post options because this draft has no existing X posts'); + metadataOnlyPosts.linkedin = existingLinkedInPosts; + platformList.push('linkedin'); } - postsArray = existingXPosts; - platformList = ['x']; } // Build platforms object const platformsObj = {}; for (const p of platformList) { - const sanitizedPosts = postsArray.map(post => sanitizePostForPlatform(post, p)); + const sourcePosts = metadataOnlyPosts ? metadataOnlyPosts[p] : postsArray; + const sanitizedPosts = sourcePosts.map(post => sanitizePostForPlatform(post, p)); const platformPosts = p === 'x' ? addXContentDisclosures(addQuotePostUrl(sanitizedPosts, quotePostUrl), xContentDisclosures) : sanitizedPosts; @@ -1524,6 +1600,12 @@ async function cmdDraftsUpdate(args) { enabled: true, posts: platformPosts, }; + if (p === 'linkedin') { + const linkedInSettings = buildLinkedInSettingsForUpdate(linkedInFirstComment, existing); + if (linkedInSettings) { + platformsObj[p].settings = linkedInSettings; + } + } } body.platforms = platformsObj; } @@ -1553,7 +1635,7 @@ async function cmdDraftsUpdate(args) { } if (Object.keys(body).length === 0) { - error('At least one of --text, --file, --content-markdown, --cover-media-id, --title, --schedule, --share, --notes, --tags, --quote-post-url, --paid-partnership, --made-with-ai, or --force-overwrite-comments is required'); + error('At least one of --text, --file, --content-markdown, --cover-media-id, --title, --schedule, --share, --notes, --tags, --quote-post-url, --linkedin-first-comment, --paid-partnership, --made-with-ai, or --force-overwrite-comments is required'); } const params = new URLSearchParams(); @@ -1620,6 +1702,10 @@ async function cmdCreateDraftAlias(args) { pushStringFlag(forwarded, parsed, 'community', '--community'); const quotePostUrl = getQuotePostUrlFromParsed(parsed); if (quotePostUrl) forwarded.push('--quote-post-url', quotePostUrl); + const linkedInFirstComment = getLinkedInFirstCommentFromParsed(parsed); + if (linkedInFirstComment.provided) { + forwarded.push('--linkedin-first-comment', linkedInFirstComment.value ?? 'null'); + } if (parsed['paid-partnership'] || parsed.paid_partnership) forwarded.push('--paid-partnership'); if (parsed['made-with-ai'] || parsed.made_with_ai) forwarded.push('--made-with-ai'); if (parsed.share) forwarded.push('--share'); @@ -1668,6 +1754,10 @@ async function cmdUpdateDraftAlias(args) { pushStringFlag(forwarded, parsed, 'tags', '--tags', { allowEmpty: true }); const quotePostUrl = getQuotePostUrlFromParsed(parsed); if (quotePostUrl) forwarded.push('--quote-post-url', quotePostUrl); + const linkedInFirstComment = getLinkedInFirstCommentFromParsed(parsed); + if (linkedInFirstComment.provided) { + forwarded.push('--linkedin-first-comment', linkedInFirstComment.value ?? 'null'); + } if (parsed['paid-partnership'] || parsed.paid_partnership) forwarded.push('--paid-partnership'); if (parsed['made-with-ai'] || parsed.made_with_ai) forwarded.push('--made-with-ai'); if (parsed.share) forwarded.push('--share'); @@ -2152,6 +2242,9 @@ COMMANDS: --reply-to URL of X post to reply to --community X community ID to post to --quote-post-url, --quote-url Quote an X post URL (X only) + --linkedin-first-comment Plain-text comment posted right after the LinkedIn + post is published (LinkedIn only, no mention syntax). + Also accepts: --first-comment --paid-partnership, --paid_partnership Label X posts as paid partnership --made-with-ai, --made_with_ai Label X posts as made with AI --share Generate a public share URL for the draft @@ -2170,6 +2263,9 @@ COMMANDS: --schedule