From 79ee06a6815f695e09b3f615349b1d29f3f20e78 Mon Sep 17 00:00:00 2001 From: Francesco Di Lorenzo Date: Tue, 28 Apr 2026 09:18:27 +0100 Subject: [PATCH] feat(typefully): support LinkedIn reshares --- skills/typefully/CHANGELOG.md | 3 + skills/typefully/SKILL.md | 41 +++++++- skills/typefully/scripts/typefully.js | 133 +++++++++++++++++++++++--- tests/drafts.test.js | 127 +++++++++++++++++++++++- 4 files changed, 286 insertions(+), 18 deletions(-) diff --git a/skills/typefully/CHANGELOG.md b/skills/typefully/CHANGELOG.md index b79c20b..11ff5c9 100644 --- a/skills/typefully/CHANGELOG.md +++ b/skills/typefully/CHANGELOG.md @@ -8,6 +8,9 @@ The format is based on Keep a Changelog. ### Added +- `drafts:create` and `drafts:update` now support LinkedIn reshares/reposts with `--linkedin-reshare-target `. +- LinkedIn reshare aliases: `--linkedin_reshare_target`, `--linkedin-reshare-url`, `--linkedin-repost-url`, `--reshare-url`, and `--repost-url`. +- Skill docs now explain LinkedIn reshare/repost workflows, including plain reshares without commentary. - `analytics:posts:list` now supports `--include-replies` (alias: `--include_replies`) to opt in to X reply posts. ### Changed diff --git a/skills/typefully/SKILL.md b/skills/typefully/SKILL.md index 253d994..f2b307d 100644 --- a/skills/typefully/SKILL.md +++ b/skills/typefully/SKILL.md @@ -4,7 +4,7 @@ description: > Create, schedule, and manage social media posts via Typefully. ALWAYS use this skill when asked to draft, schedule, post, or check tweets, posts, threads, or social media content for Twitter/X, LinkedIn, Threads, Bluesky, or Mastodon. -last-updated: 2026-03-17 +last-updated: 2026-04-28 allowed-tools: Bash(./scripts/typefully.js:*) --- @@ -110,6 +110,7 @@ When determining which social set to use: | "Draft a tweet about X" | `drafts:create --text "..."` (uses default social set) | | "Post this to LinkedIn" | `drafts:create --platform linkedin --text "..."` | | "Mention a company on LinkedIn" | `linkedin:organizations:resolve --organization-url ""` then use returned `mention_text` in `drafts:create` | +| "Reshare/repost this LinkedIn post" | `drafts:create --platform linkedin --linkedin-reshare-target ""` | | "Post to X and LinkedIn" (same content) | `drafts:create --platform x,linkedin --text "..."` | | "X thread + LinkedIn post" (different content) | Create one draft, then `drafts:update` to add platform (see [Publishing to Multiple Platforms](#publishing-to-multiple-platforms)) | | "What's scheduled?" | `drafts:list --status scheduled` | @@ -225,6 +226,27 @@ Then include that `mention_text` in your LinkedIn draft text: ./scripts/typefully.js drafts:create --platform linkedin --text "Thanks @[Typefully](urn:li:organization:86779668) for the support." ``` +## LinkedIn Reshares/Reposts + +LinkedIn reshares are supported by adding a LinkedIn post URL or canonical parent URN to the LinkedIn post payload: + +```bash +# Reshare/repost a LinkedIn post with commentary +./scripts/typefully.js drafts:create --platform linkedin --text "Worth reading" --linkedin-reshare-target "https://www.linkedin.com/posts/typefullycom_linkedin-mentions-just-got-a-lot-better-in-activity-7437089188157554688-wSxN" + +# Plain reshare/repost with no commentary +./scripts/typefully.js drafts:create --platform linkedin --linkedin-reshare-target "urn:li:share:7437089188157554688" +``` + +The API also accepts `urn:li:ugcPost:` and `urn:li:groupPost:` values. The CLI sends this as `linkedin_reshare_target`; draft responses return the canonical `linkedin_reshare_urn`. + +Useful aliases: +- `--linkedin_reshare_target` +- `--linkedin-reshare-url` +- `--linkedin-repost-url` +- `--reshare-url` +- `--repost-url` + ## Commands Reference ### User & Social Sets @@ -274,10 +296,12 @@ All drafts commands support an optional `[social_set_id]` - if omitted, the conf | `drafts:create ... --reply-to ` | Reply to an existing X post | | `drafts:create ... --community ` | Post to an X community | | `drafts:create ... --quote-post-url ` | Quote an existing X post URL | +| `drafts:create ... --linkedin-reshare-target ` | Reshare/repost an existing LinkedIn post | | `drafts:create ... --share` | Generate a public share URL for the draft | | `drafts:create ... --scratchpad "..."` | Add internal notes/scratchpad to the draft | | `drafts:update [social_set_id] --text "..."` | Update an existing draft (single-arg requires `--use-default` if a default is configured) | | `drafts:update ... --quote-post-url ` | Update X post(s) in a draft to quote an existing post URL | +| `drafts:update ... --linkedin-reshare-target ` | Update LinkedIn post(s) in a draft to reshare/repost an existing LinkedIn post | | `drafts:update [social_set_id] --tags "tag1,tag2"` | Update tags on an existing draft (content unchanged) | | `drafts:update ... --share` | Generate a public share URL for the draft | | `drafts:update ... --scratchpad "..."` | Update internal notes/scratchpad | @@ -379,6 +403,21 @@ Use `queue:get` when the user asks what is already scheduled (or free) for a giv ./scripts/typefully.js drafts:create --platform linkedin --text "Thanks @[Typefully](urn:li:organization:86779668) for the support." ``` +### Create a LinkedIn reshare/repost +```bash +./scripts/typefully.js drafts:create --platform linkedin --text "Worth reading" --linkedin-reshare-target "https://www.linkedin.com/posts/typefullycom_linkedin-mentions-just-got-a-lot-better-in-activity-7437089188157554688-wSxN" +``` + +### Create a plain LinkedIn reshare/repost +```bash +./scripts/typefully.js drafts:create --platform linkedin --linkedin-reshare-target "urn:li:share:7437089188157554688" +``` + +### Update a LinkedIn draft to reshare/repost a post +```bash +./scripts/typefully.js drafts:update 456 --platform linkedin --linkedin-reshare-target "urn:li:ugcPost:7346543409643380737" --use-default +``` + ### Create a post on all connected platforms ```bash ./scripts/typefully.js drafts:create --all --text "Posting everywhere!" diff --git a/skills/typefully/scripts/typefully.js b/skills/typefully/scripts/typefully.js index 0c3fbe4..0081cf2 100755 --- a/skills/typefully/scripts/typefully.js +++ b/skills/typefully/scripts/typefully.js @@ -389,6 +389,53 @@ function addQuotePostUrl(posts, quotePostUrl) { return posts.map(post => ({ ...post, quote_post_url: quotePostUrl })); } +function getLinkedInReshareTargetFromParsed(parsed) { + const keys = [ + 'linkedin-reshare-target', + 'linkedin_reshare_target', + 'linkedin-reshare-url', + 'linkedin_reshare_url', + 'linkedin-repost-url', + 'linkedin_repost_url', + 'reshare-url', + 'reshare_url', + 'repost-url', + 'repost_url', + ]; + const values = []; + + for (const key of keys) { + if (!Object.prototype.hasOwnProperty.call(parsed, key)) continue; + values.push({ + key, + value: coerceFlagValueToString(parsed[key], `--${key}`), + }); + } + + if (values.length === 0) return null; + + const first = values[0].value; + const conflicting = values.find(item => item.value !== first); + if (conflicting) { + error('Conflicting LinkedIn reshare target values', Object.fromEntries(values.map(item => [`--${item.key}`, item.value]))); + } + + return first; +} + +function addLinkedInReshareTarget(posts, linkedinReshareTarget) { + if (!linkedinReshareTarget) return posts; + return posts.map(post => ({ ...post, linkedin_reshare_target: linkedinReshareTarget })); +} + +function splitDraftText(text, { allowEmpty = false } = {}) { + const posts = splitThreadText(text || ''); + if (posts.length === 0 && allowEmpty) { + return ['']; + } + return posts; +} + function parseCsvArg(value, flagName) { // parseArgs sets missing values to true (e.g. `--tags --other-flag`) if (value === true) { @@ -992,6 +1039,7 @@ async function cmdDraftsCreate(args) { const parsed = parseArgs(args, { share: 'boolean', all: 'boolean' }); const socialSetId = resolveSocialSetIdFromParsed(parsed, parsed._positional[0]); const quotePostUrl = getQuotePostUrlFromParsed(parsed); + const linkedinReshareTarget = getLinkedInReshareTargetFromParsed(parsed); // Get text content let text = parsed.text; @@ -1002,8 +1050,8 @@ async function cmdDraftsCreate(args) { text = fs.readFileSync(parsed.file, 'utf-8'); } - if (!text) { - error('--text or --file is required'); + if (!text && !linkedinReshareTarget) { + error('--text, --file, or --linkedin-reshare-target is required'); } // Determine platform(s) @@ -1020,6 +1068,8 @@ async function cmdDraftsCreate(args) { error('No connected platforms found. Connect a platform at typefully.com'); } platforms = allPlatforms.join(','); + } else if (!platforms && linkedinReshareTarget) { + platforms = 'linkedin'; } else if (!platforms) { // Smart default: get first connected platform const defaultPlatform = await getFirstConnectedPlatform(socialSetId); @@ -1033,9 +1083,15 @@ async function cmdDraftsCreate(args) { if (quotePostUrl && !platformList.includes('x')) { error('--quote-post-url is only supported for X posts. Include x in --platform or remove the quote flag.'); } + if (linkedinReshareTarget && !platformList.includes('linkedin')) { + error('--linkedin-reshare-target is only supported for LinkedIn posts. Include linkedin in --platform or remove the reshare flag.'); + } + if (linkedinReshareTarget && !text && platformList.some(platform => platform !== 'linkedin')) { + error('A text value is required when creating non-LinkedIn posts alongside a LinkedIn reshare.'); + } // Split text into posts (thread support) - const posts = splitThreadText(text); + const posts = splitDraftText(text, { allowEmpty: Boolean(linkedinReshareTarget) }); // Parse media IDs const mediaIds = parsed.media ? parsed.media.split(',').map(m => m.trim()) : []; @@ -1053,9 +1109,13 @@ async function cmdDraftsCreate(args) { // Build platforms object const platformsObj = {}; for (const platform of platformList) { - const postsArray = platform === 'x' - ? addQuotePostUrl(basePostsArray, quotePostUrl) - : basePostsArray; + let postsArray = basePostsArray; + if (platform === 'x') { + postsArray = addQuotePostUrl(postsArray, quotePostUrl); + } + if (platform === 'linkedin') { + postsArray = addLinkedInReshareTarget(postsArray, linkedinReshareTarget); + } const platformConfig = { enabled: true, posts: postsArray, @@ -1106,6 +1166,7 @@ async function cmdDraftsUpdate(args) { const parsed = parseArgs(args, { append: 'boolean', share: 'boolean', 'use-default': 'boolean' }); const { socialSetId, draftId } = resolveDraftTargetFromParsed(parsed, 'drafts:update'); const quotePostUrl = getQuotePostUrlFromParsed(parsed); + const linkedinReshareTarget = getLinkedInReshareTargetFromParsed(parsed); // Get text content let text = parsed.text; @@ -1118,7 +1179,7 @@ async function cmdDraftsUpdate(args) { const body = {}; - const shouldUpdatePosts = Boolean(text || quotePostUrl); + const shouldUpdatePosts = Boolean(text || quotePostUrl || linkedinReshareTarget); if (shouldUpdatePosts) { const explicitPlatformList = parsed.platform ? parsed.platform.split(',').map(p => p.trim()) @@ -1126,6 +1187,9 @@ async function cmdDraftsUpdate(args) { if (quotePostUrl && explicitPlatformList && !explicitPlatformList.includes('x')) { error('--quote-post-url is only supported for X posts. Include x in --platform or remove the quote flag.'); } + if (linkedinReshareTarget && explicitPlatformList && !explicitPlatformList.includes('linkedin')) { + error('--linkedin-reshare-target is only supported for LinkedIn posts. Include linkedin in --platform or remove the reshare flag.'); + } // Parse media IDs const mediaIds = parsed.media ? parsed.media.split(',').map(m => m.trim()) : []; @@ -1157,6 +1221,9 @@ async function cmdDraftsUpdate(args) { if (quotePostUrl && !platformList.includes('x')) { error('--quote-post-url is only supported for X posts. Include x in --platform or remove the quote flag.'); } + if (linkedinReshareTarget && !platformList.includes('linkedin')) { + error('--linkedin-reshare-target is only supported for LinkedIn posts. Include linkedin in --platform or remove the reshare flag.'); + } let postsArray; @@ -1179,7 +1246,7 @@ async function cmdDraftsUpdate(args) { postsArray = [...existingPosts, newPost]; } else { // Replace with new posts - const posts = splitThreadText(text); + const posts = splitDraftText(text); postsArray = posts.map((postText, index) => { const post = { text: postText }; if (index === 0 && mediaIds.length > 0) { @@ -1188,7 +1255,7 @@ async function cmdDraftsUpdate(args) { return post; }); } - } else { + } else if (quotePostUrl) { // Quote-only update: preserve existing X posts and add quote URL. const existingXPosts = existing.platforms?.x?.posts; if (!Array.isArray(existingXPosts) || existingXPosts.length === 0) { @@ -1196,14 +1263,27 @@ async function cmdDraftsUpdate(args) { } postsArray = existingXPosts; platformList = ['x']; + } else { + // Reshare-only update: preserve existing LinkedIn posts and add the reshare target. + const existingLinkedInPosts = existing.platforms?.linkedin?.posts; + if (!Array.isArray(existingLinkedInPosts) || existingLinkedInPosts.length === 0) { + postsArray = [{ text: '' }]; + } else { + postsArray = existingLinkedInPosts; + } + platformList = ['linkedin']; } // Build platforms object const platformsObj = {}; for (const p of platformList) { - const platformPosts = p === 'x' - ? addQuotePostUrl(postsArray, quotePostUrl) - : postsArray; + let platformPosts = postsArray; + if (p === 'x') { + platformPosts = addQuotePostUrl(platformPosts, quotePostUrl); + } + if (p === 'linkedin') { + platformPosts = addLinkedInReshareTarget(platformPosts, linkedinReshareTarget); + } platformsObj[p] = { enabled: true, posts: platformPosts, @@ -1233,7 +1313,7 @@ async function cmdDraftsUpdate(args) { } if (Object.keys(body).length === 0) { - error('At least one of --text, --file, --title, --schedule, --share, --notes, --tags, or --quote-post-url is required'); + error('At least one of --text, --file, --title, --schedule, --share, --notes, --tags, --quote-post-url, or --linkedin-reshare-target is required'); } const data = await apiRequest('PATCH', `/social-sets/${socialSetId}/drafts/${draftId}`, body); @@ -1247,6 +1327,7 @@ async function cmdDraftsUpdate(args) { async function cmdCreateDraftAlias(args) { const parsed = parseArgs(args, { share: 'boolean', all: 'boolean' }); const socialSetId = requireSocialSetId(getSocialSetIdFromParsed(parsed)); + const linkedinReshareTarget = getLinkedInReshareTargetFromParsed(parsed); const forwarded = [String(socialSetId)]; @@ -1258,12 +1339,14 @@ async function cmdCreateDraftAlias(args) { if (Object.prototype.hasOwnProperty.call(parsed, 'text')) { text = coerceFlagValueToString(parsed.text, '--text'); } else { - if (parsed._positional.length === 0) { + if (parsed._positional.length === 0 && !linkedinReshareTarget) { error('Draft text is required (provide it as the first argument, or use --text/--file)'); } text = parsed._positional.join(' '); } - forwarded.push('--text', text); + if (text || !linkedinReshareTarget) { + forwarded.push('--text', text); + } } pushStringFlag(forwarded, parsed, 'platform', '--platform'); @@ -1276,6 +1359,7 @@ async function cmdCreateDraftAlias(args) { pushStringFlag(forwarded, parsed, 'community', '--community'); const quotePostUrl = getQuotePostUrlFromParsed(parsed); if (quotePostUrl) forwarded.push('--quote-post-url', quotePostUrl); + if (linkedinReshareTarget) forwarded.push('--linkedin-reshare-target', linkedinReshareTarget); if (parsed.share) forwarded.push('--share'); pushStringFlag(forwarded, parsed, 'notes', '--notes'); @@ -1313,6 +1397,8 @@ async function cmdUpdateDraftAlias(args) { pushStringFlag(forwarded, parsed, 'tags', '--tags', { allowEmpty: true }); const quotePostUrl = getQuotePostUrlFromParsed(parsed); if (quotePostUrl) forwarded.push('--quote-post-url', quotePostUrl); + const linkedinReshareTarget = getLinkedInReshareTargetFromParsed(parsed); + if (linkedinReshareTarget) forwarded.push('--linkedin-reshare-target', linkedinReshareTarget); if (parsed.share) forwarded.push('--share'); pushStringFlag(forwarded, parsed, 'notes', '--notes'); @@ -1609,6 +1695,10 @@ 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-reshare-target Reshare/repost a LinkedIn post (LinkedIn only) + Also accepts: --linkedin_reshare_target, + --linkedin-reshare-url, --linkedin-repost-url, + --reshare-url, --repost-url --share Generate a public share URL for the draft --notes, --scratchpad Internal notes/scratchpad for the draft @@ -1623,6 +1713,10 @@ COMMANDS: --schedule