Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions skills/typefully/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +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-27

### Added

- Link-preview cards are now documented for LinkedIn, Threads, and Bluesky: when a post contains a URL, Typefully automatically fetches Open Graph metadata and publishes a rich preview card — no flag needed.
- New `--hide-link-preview` flag (also `--hide_link_preview`) on `drafts:create` and `drafts:update` publishes the URL as plain text with no card. Supported on LinkedIn and Threads only, matching the Typefully editor; the flag errors when neither platform is targeted. On `drafts:update` it works without `--text` to hide the card on an existing draft.

## 2026-07-09

### Changed
Expand Down
12 changes: 12 additions & 0 deletions skills/typefully/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@ When the user asks to add notes, ideas, or context to a draft, use `--scratchpad
./scripts/typefully.js drafts:create --text "My post" --scratchpad "Ideas: 1) Add stats 2) Include quote"
```

### Link previews

When a post contains a URL, Typefully automatically fetches Open Graph metadata for the last URL in the text and publishes a rich link-preview card on **LinkedIn, Threads, and Bluesky**. No flag is needed — it just works. X and Mastodon unfurl links themselves after publishing.

To publish the URL as plain text with no card, pass `--hide-link-preview`. Suppression is supported on **LinkedIn and Threads only** (matching the Typefully editor); the flag errors if neither platform is targeted, and is ignored for other platforms in a mixed-platform draft:

```bash
./scripts/typefully.js drafts:create --platform linkedin,threads --text "Read this https://example.com" --hide-link-preview
./scripts/typefully.js drafts:update draft-123 --hide-link-preview --use-default # hide the card on an existing draft
```

---

## 3. Schedule & publish
Expand Down Expand Up @@ -183,6 +194,7 @@ Add any of these flags to a `drafts:create` or `drafts:update` command. The **Ap
| `--scratchpad "<notes>"` | Attach internal notes (see [Scratchpad notes](#scratchpad-notes)) | create, update |
| `--share` | Generate a public share URL | create, update |
| `--schedule <iso\|next-free-slot\|now>` | Schedule or reschedule the draft | create, update |
| `--hide-link-preview` | Suppress the link-preview card (LinkedIn/Threads only — see [Link previews](#link-previews)) | 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 |

Expand Down
73 changes: 64 additions & 9 deletions skills/typefully/scripts/typefully.js
Original file line number Diff line number Diff line change
Expand Up @@ -471,9 +471,9 @@ function sanitizePostForPlatform(post, platform) {
if (reshareTarget) {
clean.linkedin_reshare_target = reshareTarget;
}
if (post.hide_link_preview) {
clean.hide_link_preview = true;
}
}
if (HIDE_LINK_PREVIEW_PLATFORMS.includes(platform) && post.hide_link_preview) {
clean.hide_link_preview = true;
}
return clean;
}
Expand Down Expand Up @@ -512,6 +512,24 @@ function validateXOnlyPostOptions(platformList, { quotePostUrl, disclosures }) {
}
}

// Platforms where Typefully lets you suppress the link-preview card (matches the web editor).
const HIDE_LINK_PREVIEW_PLATFORMS = ['linkedin', 'threads'];

function getHideLinkPreviewFromParsed(parsed) {
return Boolean(parsed['hide-link-preview'] || parsed.hide_link_preview);
}

function addHideLinkPreview(posts, hideLinkPreview) {
if (!hideLinkPreview) return posts;
return posts.map(post => ({ ...post, hide_link_preview: true }));
}

function validateHideLinkPreviewOption(platformList, hideLinkPreview) {
if (hideLinkPreview && !platformList.some(p => HIDE_LINK_PREVIEW_PLATFORMS.includes(p))) {
error('--hide-link-preview is only supported for LinkedIn and Threads posts. Include linkedin or threads in --platform or remove the flag.');
}
}

function hasParsedArg(parsed, key) {
return Object.prototype.hasOwnProperty.call(parsed, key);
}
Expand Down Expand Up @@ -1260,10 +1278,13 @@ async function cmdDraftsCreate(args) {
paid_partnership: 'boolean',
'made-with-ai': 'boolean',
made_with_ai: 'boolean',
'hide-link-preview': 'boolean',
hide_link_preview: 'boolean',
});
const socialSetId = resolveSocialSetIdFromParsed(parsed, parsed._positional[0]);
const quotePostUrl = getQuotePostUrlFromParsed(parsed);
const xContentDisclosures = getXContentDisclosuresFromParsed(parsed);
const hideLinkPreview = getHideLinkPreviewFromParsed(parsed);

// Determine platform(s)
let platforms = parsed.platform;
Expand Down Expand Up @@ -1312,6 +1333,7 @@ async function cmdDraftsCreate(args) {
quotePostUrl,
disclosures: xContentDisclosures,
});
validateHideLinkPreviewOption(platformList, hideLinkPreview);

// Split text into posts (thread support)
const posts = splitThreadText(text);
Expand All @@ -1330,9 +1352,12 @@ async function cmdDraftsCreate(args) {
});

for (const platform of platformList) {
const postsArray = platform === 'x'
let postsArray = platform === 'x'
? addXContentDisclosures(addQuotePostUrl(basePostsArray, quotePostUrl), xContentDisclosures)
: basePostsArray;
if (HIDE_LINK_PREVIEW_PLATFORMS.includes(platform)) {
postsArray = addHideLinkPreview(postsArray, hideLinkPreview);
}
const platformConfig = {
enabled: true,
posts: postsArray,
Expand Down Expand Up @@ -1392,10 +1417,13 @@ async function cmdDraftsUpdate(args) {
exclude_comment_markers: 'boolean',
'force-overwrite-comments': 'boolean',
force_overwrite_comments: 'boolean',
'hide-link-preview': 'boolean',
hide_link_preview: 'boolean',
});
const { socialSetId, draftId } = resolveDraftTargetFromParsed(parsed, 'drafts:update');
const quotePostUrl = getQuotePostUrlFromParsed(parsed);
const xContentDisclosures = getXContentDisclosuresFromParsed(parsed);
const hideLinkPreview = getHideLinkPreviewFromParsed(parsed);

const body = {};
const explicitPlatformList = parsed.platform
Expand Down Expand Up @@ -1428,13 +1456,16 @@ async function cmdDraftsUpdate(args) {
text = getPostTextFromParsed(parsed);
}

const shouldUpdatePosts = Boolean(!shouldUpdateArticle && (text || quotePostUrl || xContentDisclosures.hasAny));
const shouldUpdatePosts = Boolean(
!shouldUpdateArticle && (text || quotePostUrl || xContentDisclosures.hasAny || hideLinkPreview)
);
if (shouldUpdatePosts) {
if (explicitPlatformList) {
validateXOnlyPostOptions(explicitPlatformList, {
quotePostUrl,
disclosures: xContentDisclosures,
});
validateHideLinkPreviewOption(explicitPlatformList, hideLinkPreview);
}

// Parse media IDs
Expand Down Expand Up @@ -1469,6 +1500,7 @@ async function cmdDraftsUpdate(args) {
quotePostUrl,
disclosures: xContentDisclosures,
});
validateHideLinkPreviewOption(platformList, hideLinkPreview);

let postsArray;

Expand Down Expand Up @@ -1500,8 +1532,11 @@ async function cmdDraftsUpdate(args) {
return post;
});
}
} else {
} else if (quotePostUrl || xContentDisclosures.hasAny) {
// X-only metadata update: preserve existing X posts and add quote/disclosure attrs.
if (hideLinkPreview) {
error('Cannot combine --hide-link-preview with X-only flags unless --text is provided');
}
const existingXPosts = existing.platforms?.x?.posts;
if (!Array.isArray(existingXPosts) || existingXPosts.length === 0) {
if (quotePostUrl && !xContentDisclosures.hasAny) {
Expand All @@ -1511,15 +1546,31 @@ async function cmdDraftsUpdate(args) {
}
postsArray = existingXPosts;
platformList = ['x'];
} else {
// --hide-link-preview only: preserve existing posts on platforms that support suppression.
const targets = platformList.filter(p =>
HIDE_LINK_PREVIEW_PLATFORMS.includes(p) &&
Array.isArray(existing.platforms?.[p]?.posts) &&
existing.platforms[p].posts.length > 0
);
if (targets.length === 0) {
error('Cannot apply --hide-link-preview because this draft has no existing LinkedIn or Threads posts');
}
postsArray = null;
platformList = targets;
}

// Build platforms object
const platformsObj = {};
for (const p of platformList) {
const sanitizedPosts = postsArray.map(post => sanitizePostForPlatform(post, p));
const platformPosts = p === 'x'
const sourcePosts = postsArray ?? existing.platforms?.[p]?.posts ?? [];
const sanitizedPosts = sourcePosts.map(post => sanitizePostForPlatform(post, p));
let platformPosts = p === 'x'
? addXContentDisclosures(addQuotePostUrl(sanitizedPosts, quotePostUrl), xContentDisclosures)
: sanitizedPosts;
if (HIDE_LINK_PREVIEW_PLATFORMS.includes(p)) {
platformPosts = addHideLinkPreview(platformPosts, hideLinkPreview);
}
platformsObj[p] = {
enabled: true,
posts: platformPosts,
Expand Down Expand Up @@ -1553,7 +1604,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, --paid-partnership, --made-with-ai, --hide-link-preview, or --force-overwrite-comments is required');
}

const params = new URLSearchParams();
Expand Down Expand Up @@ -2154,6 +2205,8 @@ COMMANDS:
--quote-post-url, --quote-url <url> Quote an X post URL (X only)
--paid-partnership, --paid_partnership Label X posts as paid partnership
--made-with-ai, --made_with_ai Label X posts as made with AI
--hide-link-preview Suppress the link-preview card (LinkedIn/Threads only).
Also accepts: --hide_link_preview
--share Generate a public share URL for the draft
--notes, --scratchpad <text> Internal notes/scratchpad for the draft

Expand All @@ -2172,6 +2225,8 @@ COMMANDS:
--quote-post-url, --quote-url <url> Quote an X post URL (X only)
--paid-partnership, --paid_partnership Label X posts as paid partnership
--made-with-ai, --made_with_ai Label X posts as made with AI
--hide-link-preview Suppress the link-preview card (LinkedIn/Threads only).
Also accepts: --hide_link_preview
--share Generate a public share URL for the draft
--notes, --scratchpad <text> Internal notes/scratchpad for the draft
--exclude-comment-markers Render response posts[*].text without
Expand Down
125 changes: 124 additions & 1 deletion tests/drafts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ describe('drafts', () => {
);
assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), {
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, --paid-partnership, --made-with-ai, --hide-link-preview, or --force-overwrite-comments is required',
});
assert.equal(server.requests.length, 0);
}));
Expand Down Expand Up @@ -732,6 +732,54 @@ describe('drafts', () => {
assert.equal(server.requests.length, 0);
}));

it('drafts:create applies --hide-link-preview only to LinkedIn and Threads posts', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('POST', '/v2/social-sets/9/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
platforms: {
linkedin: {
enabled: true,
posts: [{ text: 'Read this https://example.com', hide_link_preview: true }],
},
threads: {
enabled: true,
posts: [{ text: 'Read this https://example.com', hide_link_preview: true }],
},
bluesky: {
enabled: true,
posts: [{ text: 'Read this https://example.com' }],
},
},
});
},
json: { id: 'd1' },
});
const result = await runCli(
['drafts:create', '9', '--platform', 'linkedin,threads,bluesky', '--text', 'Read this https://example.com', '--hide-link-preview'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
server.assertNoPendingExpectations();
}));

it('drafts:create errors when --hide-link-preview is used without LinkedIn or Threads', withCliHarness(async ({
sandbox, server
}) => {
const result = await runCli(
['drafts:create', '9', '--platform', 'bluesky', '--text', 'Read this https://example.com', '--hide-link-preview'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
);
assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), {
error: '--hide-link-preview is only supported for LinkedIn and Threads posts. Include linkedin or threads in --platform or remove the flag.',
});
assert.equal(server.requests.length, 0);
}));

it('drafts:create surfaces API VALIDATION_ERROR messages for quote URLs', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
Expand Down Expand Up @@ -1305,6 +1353,81 @@ describe('drafts', () => {
server.assertNoPendingExpectations();
}));

it('drafts:update can hide the link preview without changing text', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
assert: authAssertFactory(apiKey),
json: {
id: 'd1',
platforms: {
linkedin: {
enabled: true,
posts: [{ text: 'Read this https://example.com', hide_link_preview: false }],
},
threads: {
enabled: true,
posts: [{ text: 'Read this https://example.com', hide_link_preview: false }],
},
bluesky: {
enabled: true,
posts: [{ text: 'Read this https://example.com', hide_link_preview: false }],
},
},
},
});

server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
platforms: {
linkedin: {
enabled: true,
posts: [{ text: 'Read this https://example.com', hide_link_preview: true }],
},
threads: {
enabled: true,
posts: [{ text: 'Read this https://example.com', hide_link_preview: true }],
},
},
});
},
json: { id: 'd1', ok: true },
});
const result = await runCli(
['drafts:update', '9', 'd1', '--hide-link-preview'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
server.assertNoPendingExpectations();
}));

it('drafts:update errors on --hide-link-preview when the draft has no LinkedIn or Threads posts', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
assert: authAssertFactory(apiKey),
json: {
id: 'd1',
platforms: {
bluesky: { enabled: true, posts: [{ text: 'Read this https://example.com' }] },
},
},
});
const result = await runCli(
['drafts:update', '9', 'd1', '--hide-link-preview'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), {
error: '--hide-link-preview is only supported for LinkedIn and Threads posts. Include linkedin or threads in --platform or remove the flag.',
});
assert.equal(server.requests.length, 1);
server.assertNoPendingExpectations();
}));

it('drafts:update can set X content disclosures without changing text', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
Expand Down
Loading