diff --git a/CLAUDE.md b/CLAUDE.md index f5f3a65..cfb526f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,8 @@ This repository contains AI agent skills for Typefully - markdown files that giv ## Repository Structure -- `skills/typefully/SKILL.md` - The main skill definition file with frontmatter metadata and usage instructions +- `skills/typefully/SKILL.md` - The main skill definition file with frontmatter metadata, the every-session workflow, and the full command reference. Keep it focused; move occasional or platform-specific detail into `references/`. +- `skills/typefully/references/` - On-demand reference guides linked from SKILL.md: `setup.md`, `comments.md`, `local-development.md`, and `platforms/` (`x.md`, `linkedin.md`, `x-articles.md`). Put setup, comment-workflow, local-dev, and platform-specific instructions here so they load only when needed. - `skills/typefully/CHANGELOG.md` - User-facing changelog for the Typefully skill/CLI - `skills/typefully/scripts/typefully.js` - JavaScript CLI for the Typefully API v2 (zero dependencies, Node.js 18+) - `.claude-plugin/marketplace.json` - Claude Code plugin marketplace configuration diff --git a/skills/typefully/CHANGELOG.md b/skills/typefully/CHANGELOG.md index cdde431..4afa973 100644 --- a/skills/typefully/CHANGELOG.md +++ b/skills/typefully/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on Keep a Changelog. ### Added +- `--api-base-url ` global CLI option to override the API base URL for one command; `/v2` is appended when omitted. - Per-draft comment-thread CRUD: - `comments:list ` — list threads with `--platform`, `--status` (`unresolved` / `resolved` / `all`), `--limit`, `--offset` filters. - `comments:create --post-index --selected-text "..." --text "..."` — create a thread anchored on a span. Optional `--platform`, `--occurrence`. @@ -28,6 +29,20 @@ The format is based on Keep a Changelog. - `analytics:posts:list` now matches the backend analytics default: replies are excluded unless you explicitly pass `--include-replies`. - Analytics docs and examples now explain X post analytics, X follower analytics, and the explicit reply-inclusion workflow. +## [2026-07-07] + +### Added + +- X Article draft support via `drafts:create` and `drafts:update` with `--platform x_article`. +- `--content-markdown ` for X Article content. +- `--cover-media-id ` for X Article cover images; pass the literal `null` on update to remove an existing cover. +- `X_ARTICLES.md` guide with detailed X Article payload examples, supported markdown blocks, embeds, covers, and comment workflows. +- `comments:create --platform x_article --selected-text "..." --text "..."` for comments anchored on visible X Article text without `--post-index`. + +### Changed + +- `--all` remains limited to connected post platforms and does not include standalone X Articles. + ## [2026-03-17] ### Added diff --git a/skills/typefully/SKILL.md b/skills/typefully/SKILL.md index a0bd016..0d4531b 100644 --- a/skills/typefully/SKILL.md +++ b/skills/typefully/SKILL.md @@ -4,379 +4,203 @@ 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-05-05 +last-updated: 2026-07-07 allowed-tools: Bash(./scripts/typefully.js:*) --- # Typefully Skill -Create, schedule, and publish social media content across multiple platforms using [Typefully](https://typefully.com). +Create, schedule, and publish social media content across X, LinkedIn, Threads, Bluesky, and Mastodon using [Typefully](https://typefully.com). Run everything through `./scripts/typefully.js` (Node.js 18+, no dependencies). All commands output JSON. -> **Freshness check**: If more than 30 days have passed since the `last-updated` date above, inform the user that this skill may be outdated and point them to the update options below. +> **Script paths** below are relative to this skill's directory. Resolve them based on where the skill is installed. +> +> **Freshness check**: If more than 30 days have passed since the `last-updated` date above, tell the user the skill may be outdated and point them to the update methods in [`references/setup.md`](references/setup.md). +> +> **Missing API key**: If the CLI returns **"API key not found"**, tell the user to run `./scripts/typefully.js setup` themselves and stop — do not hunt for credentials. See [`references/setup.md`](references/setup.md). -## Keeping This Skill Updated +## Reference guides -**Source**: [github.com/typefully/agent-skills](https://github.com/typefully/agent-skills) -**API docs**: [typefully.com/docs/api](https://typefully.com/docs/api) +Load these only when the task needs them: -Update methods by installation type: +| Guide | Use when you need to... | +|-------|-------------------------| +| [`references/setup.md`](references/setup.md) | Configure the API key, fix an "API key not found" error, set up CI, or check whether the skill is up to date | +| [`references/comments.md`](references/comments.md) | Add, reply to, resolve, or delete comments on a draft, or edit a draft that already has comments | +| [`references/platforms/x.md`](references/platforms/x.md) | Pull X (formerly Twitter) analytics, quote or reply to a post, post to a community, or add disclosure labels | +| [`references/platforms/linkedin.md`](references/platforms/linkedin.md) | Mention a company or person on LinkedIn | +| [`references/platforms/x-articles.md`](references/platforms/x-articles.md) | Write or edit a long-form X Article (standalone platform) | -| Installation | How to update | -|--------------|---------------| -| CLI (`npx skills`) | `npx skills update` | -| Claude Code plugin | `/plugin update typefully@typefully-skills` | -| Cursor | Remote rules auto-sync from GitHub | -| Manual | Pull latest from repo or re-copy `skills/typefully/` | - -API changes ship independently—updating the skill ensures you have the latest commands and workflows. - -## Setup - -Before using this skill, ensure: - -1. **API Key**: Run the setup command to configure your API key securely - - Get your key at https://typefully.com/?settings=api - - Run: `/scripts/typefully.js setup` (where `` is the directory containing this SKILL.md) - - Or set environment variable: `export TYPEFULLY_API_KEY=your_key` - -2. **Requirements**: Node.js 18+ (for built-in fetch API). No other dependencies needed. - -**Config priority** (highest to lowest): -1. `TYPEFULLY_API_KEY` environment variable -2. `./.typefully/config.json` (project-local, in user's working directory) -3. `~/.config/typefully/config.json` (user-global) - -### Handling "API key not found" errors - -**CRITICAL**: When you receive an "API key not found" error from the CLI: - -1. **Tell the user to run the setup command** - The setup is interactive and requires user input, so you cannot run it on their behalf. Recommend they run it themselves, using the correct path based on where this skill was loaded: - ```bash - /scripts/typefully.js setup - ``` - -2. **Stop and wait** - After telling the user to run setup, **do not continue with the task**. You cannot create drafts, upload media, or perform any API operations without a valid API key. Wait for the user to complete setup and confirm before proceeding. - -3. **DO NOT** attempt any of the following: - - Searching for API keys in macOS Keychain, `.env` files, or other locations - - Grepping through config files or directories - - Looking in the user's Trash or other system folders - - Constructing complex shell commands to find credentials - - Drafting content or preparing posts before setup is complete - -The setup command will interactively guide the user through configuration. Trust the CLI's error messages and follow their instructions. - -> **Note for agents**: All script paths in this document (e.g., `./scripts/typefully.js`) are relative to the skill directory where this SKILL.md file is located. Resolve them accordingly based on where the skill is installed. - -## Social Sets - -The Typefully API uses the term "social set" to refer to what users commonly call an "account". A social set contains the connected social media platforms (X, LinkedIn, Threads, etc.) for a single identity. - -**The CLI supports a default social set** - once configured, most commands work without specifying the social_set_id. - -**You can pass the social set either way**: -- Positional: `drafts:list 123` -- Flag: `drafts:list --social-set-id 123` (also supports `--social_set_id`) - -When determining which social set to use: - -1. **Check for a configured default first** - Run `config:show` to see if a default is already set: - ```bash - ./scripts/typefully.js config:show - ``` - If `default_social_set` is configured, the CLI uses it automatically when you omit the social_set_id. - -2. **Check project context** - Look for configuration in project files like `CLAUDE.md` or `AGENTS.md`: - - ```markdown - ## Typefully - Default social set ID: 12345 - ``` - -3. **Single social set shortcut** - If the user only has one social set and no default is configured, use it automatically - -4. **Multiple social sets, no default** - Ask the user which to use, then **offer to save their choice as the default**: - ```bash - ./scripts/typefully.js config:set-default - ``` - This command lists available social sets and saves the choice to the config file. - -5. **Reuse previously resolved social set** - If determined earlier in the session, use it without asking again - -## Common Actions - -| User says... | Action | -|--------------|--------| -| "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` | -| "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` | -| "Show my recent posts" | `drafts:list --status published` | -| "Schedule this for tomorrow" | `drafts:create ... --schedule "2025-01-21T09:00:00Z"` | -| "Post this now" | `drafts:create ... --schedule now` or `drafts:publish --use-default` | -| "Add notes/ideas to the draft" | `drafts:create ... --scratchpad "Your notes here"` | -| "Check available tags" | `tags:list` | -| "Check my publishing quota" | `social-sets:get` and inspect `publishing_quota` | -| "Show my X post analytics for last week" | `analytics:posts:list --start-date YYYY-MM-DD --end-date YYYY-MM-DD` | -| "Show my X post analytics including replies" | `analytics:posts:list --start-date YYYY-MM-DD --end-date YYYY-MM-DD --include-replies` | -| "Show my X follower growth" | `analytics:followers:get --start-date YYYY-MM-DD --end-date YYYY-MM-DD` | -| "Show my queue for next week" | `queue:get --start-date YYYY-MM-DD --end-date YYYY-MM-DD` | -| "List comments on this draft" | `comments:list ` | -| "Comment on the phrase 'exciting news'" | `comments:create --post-index 0 --selected-text "exciting news" --text "..."` | -| "Reply to that comment thread" | `comments:reply --text "..."` | -| "Resolve / delete a comment thread" | `comments:resolve ` / `comments:delete ` | -| "Get the draft without comment annotations" | `drafts:get --exclude-comment-markers` | - -## Workflow - -Follow this workflow when creating posts: - -1. **Check if a default social set is configured**: - ```bash - ./scripts/typefully.js config:show - ``` - If `default_social_set` shows an ID, skip to step 3. - -2. **If no default, list social sets** to find available options: - ```bash - ./scripts/typefully.js social-sets:list - ``` - If multiple exist, ask the user which to use and offer to set it as default: - ```bash - ./scripts/typefully.js config:set-default - ``` - -3. **Create drafts** (social_set_id is optional if default is configured): - ```bash - ./scripts/typefully.js drafts:create --text "Your post" - ``` - Note: If `--platform` is omitted, the first connected platform is auto-selected. - - **For multi-platform posts**: See [Publishing to Multiple Platforms](#publishing-to-multiple-platforms) — always use a single draft, even when content differs per platform. +--- -4. **Schedule or publish** as needed +## 1. Choose a social set -## Working with Tags +A "social set" is what users call an "account" — the connected platforms for one identity. Most commands work without a `social_set_id` once a default is configured. Pass it positionally (`drafts:list 123`) or as `--social-set-id 123`. -Tags help organize drafts within Typefully. **Always check existing tags before creating new ones**: +To decide which social set to use: -1. **List existing tags first**: - ```bash - ./scripts/typefully.js tags:list - ``` +1. Run `config:show`. If `default_social_set` is set, the CLI uses it automatically — proceed. +2. Otherwise run `social-sets:list`. If only one exists, use it. +3. If multiple exist with no default, ask the user, then offer to save it: `config:set-default`. +4. Reuse a social set already resolved earlier in the session without asking again. -2. **Use existing tags when available** - if a tag with the desired name already exists, use it directly when creating drafts: - ```bash - ./scripts/typefully.js drafts:create --text "..." --tags existing-tag-name - ``` +--- -3. **Only create new tags if needed** - if the tag doesn't exist, create it: - ```bash - ./scripts/typefully.js tags:create --name "New Tag" - ``` +## 2. Create drafts -**Important**: Tags are scoped to each social set. A tag created for one social set won't appear in another. +```bash +./scripts/typefully.js drafts:create --text "Your post" +``` -## Publishing to Multiple Platforms +- If `--platform` is omitted, the first connected platform is auto-selected. Named platforms: `x`, `linkedin`, `threads`, `bluesky`, `mastodon`, `x_article`. +- Split a thread with `---` on its own line. +- Attach media with `--media`, tags with `--tags`, internal notes with `--scratchpad`, an internal name with `--title`. +- Read content from a file with `--file ./post.txt` instead of `--text`. -If a single draft needs to be created for different platforms, you need to make sure to create **a single draft** and not multiple drafts. +### One draft per post — always -When the content is the same across platforms, create a single draft with multiple platforms: +For the same content on several platforms, pass multiple platforms to a **single** draft: ```bash -# Specific platforms ./scripts/typefully.js drafts:create --platform x,linkedin --text "Big announcement!" - -# All connected platforms -./scripts/typefully.js drafts:create --all --text "Posting everywhere!" +./scripts/typefully.js drafts:create --all --text "Posting everywhere!" # all connected platforms ``` -**IMPORTANT**: When content should be tailored (e.g., X thread with a LinkedIn post version), **still use a single draft** — create with one platform first, then update to add the other: +When content should differ per platform (e.g. an X thread plus a tailored LinkedIn post), **still use one draft** — create with the first platform, then `drafts:update` to add another with different content: ```bash -# 1. Create draft with the primary platform first -./scripts/typefully.js drafts:create --platform linkedin --text "Excited to share our new feature..." -# Returns: { "id": "draft-123", ... } - -# 2. Update the same draft to add another platform with different content -./scripts/typefully.js drafts:update draft-123 --platform x --text "🧵 Thread time! - ---- - -Here's what we shipped and why it matters..." --use-default +./scripts/typefully.js drafts:create --platform linkedin --text "Excited to share..." # -> id draft-123 +./scripts/typefully.js drafts:update draft-123 --platform x --text "🧵 Thread time!" --use-default ``` -So make sure to NEVER create multiple drafts unless the user explicitly wants separate drafts for each platform. - -## LinkedIn Mentions +Never create multiple drafts unless the user explicitly wants separate drafts per platform. -LinkedIn mentions are supported via text syntax inside post content: +> `--all` excludes `x_article`. X Articles are standalone and cannot be combined with any other platform — see [`references/platforms/x-articles.md`](references/platforms/x-articles.md). -```text -@[Company Name](urn:li:organization:123456) -``` +### Scratchpad notes -Use the resolver command to convert a public LinkedIn organization URL into ready-to-paste mention syntax: +When the user asks to add notes, ideas, or context to a draft, use `--scratchpad` — **do NOT write to local files.** Scratchpad notes attach to the draft in Typefully, are visible in the UI, stay private, and are never published. ```bash -# Resolve a LinkedIn URL into mention metadata -./scripts/typefully.js linkedin:organizations:resolve --organization-url "https://www.linkedin.com/company/typefullycom/" -# Returns mention_text like: @[Typefully](urn:li:organization:86779668) +./scripts/typefully.js drafts:create --text "My post" --scratchpad "Ideas: 1) Add stats 2) Include quote" ``` -Then include that `mention_text` in your LinkedIn draft text: +--- + +## 3. Schedule & publish ```bash -./scripts/typefully.js drafts:create --platform linkedin --text "Thanks @[Typefully](urn:li:organization:86779668) for the support." +./scripts/typefully.js drafts:create --text "..." --schedule next-free-slot # or an ISO time, or "now" +./scripts/typefully.js drafts:schedule --time next-free-slot --use-default +./scripts/typefully.js drafts:publish --use-default ``` -## Comments on Drafts +- `next-free-slot` lets Typefully pick the optimal time. +- **Publishing is irreversible and public** — unless the user says "publish now" / "post immediately", confirm first. Creating a draft is safe. +- Single-arg commands require `--use-default` when a default social set is configured (see the [safety note](#commands) below). -Drafts can have comment threads anchored to a selected span or to a whole paragraph. `drafts:get` returns `posts[*].text` with inline `` anchors by default so agents can preserve those anchors during edits. - -**Core rules for agents:** - -1. **Preserve every comment anchor when patching draft text.** Span anchors wrap selected text; self-closing anchors at the start of a paragraph are live comments on the whole following paragraph, not empty/resolved comments. When rewriting, move each anchor to the most semantically equivalent span or paragraph. Do not silently strip or drop anchors. -2. **Do not resolve or delete comments without explicit user instruction.** After editing text that may address feedback, ask whether to resolve the specific thread(s). Never infer that "clean up", "tidy", or "looks addressed" means resolve/delete. -3. **Talk to users about comments, not marker syntax.** Describe anchors in plain English, such as "comment on the word 'constraint'" or "comment on the paragraph starting with ...". Only mention marker syntax when explaining marker-related API errors or when the user asks how anchors work. - -Default edit flow for drafts with comments: get the draft with anchors enabled, rewrite while preserving/repositioning every anchor, patch with `drafts:update --text ...` without force flags, then ask whether to resolve any addressed comments. Use `comments:list --status all` when you need thread ids, comment bodies, authors, or resolved status. - -Use `drafts:get --exclude-comment-markers` only for display, LLM context, export, or preview text that will not be patched back. +--- -### Force Overwrite And Accepting Comments +## Common actions -`--force-overwrite-comments` is destructive: every unresolved thread whose anchor is missing from the submitted text is resolved server-side and stripped, including unrelated threads. Default: do not use it. If a PATCH fails because anchors do not match, fetch the draft with anchors, preserve every `` anchor, and patch again without force. +| User says... | Action | +|--------------|--------| +| "Draft a tweet about X" | `drafts:create --text "..."` | +| "Post this to LinkedIn" | `drafts:create --platform linkedin --text "..."` | +| "Post to X and LinkedIn" (same content) | `drafts:create --platform x,linkedin --text "..."` | +| "X thread + tailored LinkedIn post" | One draft, then `drafts:update` to add the platform | +| "What's scheduled?" / "Recent posts?" | `drafts:list --status scheduled` / `--status published` | +| "Schedule this for tomorrow" | `drafts:create --text "..." --schedule ""` | +| "Post this now" | `drafts:create --text "..." --schedule now` or `drafts:publish --use-default` | +| "Check available tags" | `tags:list` | +| "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) | +| "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) | -Only use `--force-overwrite-comments` when anchors truly cannot be preserved, such as a user-requested wholesale rewrite or an anchor with no reasonable new location. Before using it, run `comments:list --status unresolved`, tell the user which threads will be resolved and stripped (selected text + top comment), state that this cannot be undone via the API, and wait for an explicit "yes, proceed". Do not confirm and PATCH in the same turn. +--- -When the user asks to accept/apply/address a comment, fetch without `--exclude-comment-markers`, identify the target thread, edit only that anchor's text (or the paragraph after a self-closing paragraph anchor), preserve every other anchor and unrelated text, then `drafts:update` without force flags. Ask before resolving the thread. If feedback is open-ended, propose wording or ask instead of inventing silently. For "accept all comments", batch only changes whose anchors can all be preserved. +## Commands -| Command | Purpose | -|---------|---------| -| `comments:list ` | List comment threads. Filters: `--platform`, `--status` (`unresolved` default / `resolved` / `all`), `--limit`, `--offset` | -| `comments:create --post-index --selected-text "..." --text "..."` | Create a comment thread anchored on exact selected text. Optional: `--platform`, `--occurrence` | -| `comments:reply --text "..."` | Add a reply to a thread | -| `comments:resolve ` | Resolve a thread, only after explicit user confirmation | -| `comments:update --text "..."` | Edit a comment's text; comment-author only | -| `comments:delete [comment_id]` | Delete a thread or one comment, only after explicit user instruction | +All commands output JSON. Every `[social_set_id]` is optional and falls back to the configured default. -`comments:create` requires `selected_text` to exactly match the post text. If it repeats, pass zero-based `--occurrence`; for LinkedIn mentions, select the entire `@[Name](urn:li:...)` substring or stay outside it. Pass `--platform` only when the draft has multiple commentable platforms. +> **Safety note**: `drafts:get`, `drafts:update`, `drafts:delete`, `drafts:schedule`, and `drafts:publish` require `--use-default` when you pass a single argument (the draft_id) while a default social set is configured. -## Commands Reference +Platform- and workflow-specific commands live in their guides: [`platforms/x.md`](references/platforms/x.md) (analytics, quotes, replies, communities, disclosures), [`platforms/linkedin.md`](references/platforms/linkedin.md) (mentions), [`platforms/x-articles.md`](references/platforms/x-articles.md), [`comments.md`](references/comments.md), and [`setup.md`](references/setup.md). -### User & Social Sets +### User & social sets | Command | Description | |---------|-------------| | `me:get` | Get authenticated user info | | `social-sets:list` | List all social sets you can access | -| `social-sets:get ` | Get social set details including connected platforms and `publishing_quota` | -| `linkedin:organizations:resolve [social_set_id] --organization-url ` | Resolve LinkedIn company/school URL into mention metadata (`mention_text`, `urn`) | - -`social-sets:get` returns a `publishing_quota` object when available: -- `used` - published drafts already counted in the current quota window -- `remaining` - remaining publish slots, or `"unlimited"` -- `resets_at` - when the current quota window resets +| `social-sets:get ` | Social set details including connected platforms and `publishing_quota` | -Use it before publishing/scheduling when the user asks about remaining posting capacity or when a publish/schedule request fails with quota copy. +`social-sets:get` returns a `publishing_quota` object when available: `used`, `remaining` (or `"unlimited"`), and `resets_at`. Check it before publishing/scheduling when the user asks about capacity or when a publish/schedule fails with quota copy. -### Analytics +### Drafts -All analytics commands support an optional `[social_set_id]` - if omitted, the configured default is used. +Every draft command accepts an optional leading `[social_set_id]` that falls back to the configured default. The four base commands: -The public API currently supports **X analytics only** on these endpoints. The CLI defaults `--platform` to `x`, so you can usually omit it. +| Command | Description | +|---------|-------------| +| `drafts:list [social_set_id]` | List drafts. Filter with `--status scheduled\|published\|...`, order with `--sort` | +| `drafts:get [social_set_id] ` | Get a draft with full content. Add `--exclude-comment-markers` to render `posts[*].text` without comment anchors (display only) | +| `drafts:create [social_set_id] --text ""` | Create a draft (auto-selects platform if `--platform` omitted) | +| `drafts:update [social_set_id] --text ""` | Replace a draft's content | -Replies are now **excluded by default** so the result set matches the main published-post view more closely. Add `--include-replies` when you explicitly want reply posts included. +Add any of these flags to a `drafts:create` or `drafts:update` command. The **Applies to** column shows where each is valid: -Analytics responses return post-level metrics for the requested inclusive date range, including: -- `impressions` -- engagement totals and breakdowns like `likes`, `comments`, `shares`, `quotes`, `saves`, `profile_clicks`, and `link_clicks` +| Flag | Effect | Applies to | +|------|--------|-----------| +| `--platform x,linkedin` | Target specific platform(s), comma-separated | create, update | +| `--all` | All connected platforms (excludes `x_article`) | create | +| `--file ` | Read content from a file instead of `--text` | create, update | +| `--append --text ""` | Append to an existing thread | update | +| `--media ` | Attach media (comma-separated) | create, update | +| `--tags "tag1,tag2"` | Set tags (content stays unchanged if this is the only change on update) | create, update | +| `--title ""` | Internal draft title (not posted) | create, update | +| `--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 | +| `--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 | -Follower analytics returns `current_followers_count` plus daily `data` points with `date` and `followers_count`. If you omit dates, the API returns the default recent range. +For example, combine the base command with flags like this: -| Command | Description | -|---------|-------------| -| `analytics:posts:list [social_set_id] --start-date --end-date ` | List X posts with normalized analytics metrics for an inclusive date range | -| `analytics:posts:list ... --start_date --end_date ` | Snake case aliases for date flags (copied from API docs) | -| `analytics:posts:list ... --include-replies` | Include X replies in the results (excluded by default) | -| `analytics:posts:list ... --include_replies` | Snake case alias for the include-replies flag | -| `analytics:posts:list ... --limit 100 --offset 25` | Paginate through results | -| `analytics:posts:list ... --platform x` | Explicitly request X analytics (currently the only supported platform) | -| `analytics:followers:get [social_set_id]` | Get X follower counts for the API default date range | -| `analytics:followers:get ... --start-date --end-date ` | Get X follower counts for an inclusive date range | -| `analytics:followers:get ... --start_date --end_date ` | Snake case aliases for date flags | -| `analytics:followers:get ... --platform x` | Explicitly request X followers analytics (currently the only supported platform) | - -### Drafts +```bash +./scripts/typefully.js drafts:create --text "Launch day!" --platform x,linkedin --tags product --schedule next-free-slot +./scripts/typefully.js drafts:update 456 --text "Revised copy" --media abc-123 --use-default +``` -All drafts commands support an optional `[social_set_id]` - if omitted, the configured default is used. -**Safety note**: For commands that take `[social_set_id] `, if you pass only a single argument (the draft_id) while a default social set is configured, you must add `--use-default` to confirm intent. +> 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). -When updating a draft that has comments, preserve anchors from `drafts:get` in the text sent to `drafts:update`. `--exclude-comment-markers` is display-only. `--force-overwrite-comments` is a destructive last resort that requires explicit user confirmation; see [Comments on Drafts](#comments-on-drafts). +### Scheduling & publishing -| Command | Description | -|---------|-------------| -| `drafts:list [social_set_id]` | List drafts (add `--status scheduled` to filter, `--sort` to order) | -| `drafts:get [social_set_id] ` | Get a specific draft with full content (single-arg requires `--use-default` if a default is configured) | -| `drafts:get ... --exclude-comment-markers` | Render `posts[*].text` without comment anchors (read-only / display use only) | -| `drafts:create [social_set_id] --text "..."` | Create a new draft (auto-selects platform) | -| `drafts:create [social_set_id] --platform x --text "..."` | Create a draft for specific platform(s) | -| `drafts:create [social_set_id] --all --text "..."` | Create a draft for all connected platforms | -| `drafts:create [social_set_id] --file ` | Create draft from file content | -| `drafts:create ... --media ` | Create draft with attached media | -| `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 ... --paid-partnership` | Label X post(s) as paid partnership | -| `drafts:create ... --made-with-ai` | Label X post(s) as made with AI | -| `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 ... --paid-partnership` | Label existing or updated X post(s) as paid partnership | -| `drafts:update ... --made-with-ai` | Label existing or updated X post(s) as made with AI | -| `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 | -| `drafts:update [social_set_id] --append --text "..."` | Append to existing thread | -| `drafts:update ... --exclude-comment-markers` | Render the response without comment anchors (display only; request validation still applies) | -| `drafts:update ... --force-overwrite-comments` | Destructive last resort; auto-resolves missing-anchor threads and requires explicit user confirmation first | - -### Scheduling & Publishing - -**Safety note**: These commands require `--use-default` when using the default social set with a single argument (to prevent accidental operations from ambiguous syntax). +Single-arg forms require `--use-default` when a default social set is configured. | Command | Description | |---------|-------------| -| `drafts:delete ` | Delete a draft (explicit IDs) | -| `drafts:delete --use-default` | Delete using default social set | -| `drafts:schedule --time next-free-slot` | Schedule to next available slot | -| `drafts:schedule --time next-free-slot --use-default` | Schedule using default social set | +| `drafts:schedule --time ` | Schedule to a time or the next available slot | | `drafts:publish ` | Publish immediately | -| `drafts:publish --use-default` | Publish using default social set | +| `drafts:delete ` | Delete a draft | ### Queue -All queue commands support an optional `[social_set_id]` - if omitted, the configured default is used. - -The queue is a **social-set-specific timeline** made of: -- Queue slots generated from that social set's queue schedule -- Scheduled drafts/posts that belong to that same social set - -Use `queue:get` when the user asks what is already scheduled (or free) for a given account in a date range. +The queue is a **social-set-specific timeline**: free queue slots (from the social set's queue schedule) plus scheduled drafts/posts for that same social set. Use `queue:get` when the user asks what is scheduled or free for an account in a date range. | Command | Description | |---------|-------------| -| `queue:get [social_set_id] --start-date --end-date ` | Get the queue timeline for one social set: free queue slots plus scheduled drafts/posts in a date range | -| `queue:get ... --start_date --end_date ` | Snake case aliases for date flags (copied from API docs) | +| `queue:get [social_set_id] --start-date --end-date ` | Queue timeline: free slots + scheduled drafts/posts in a date range | | `queue:schedule:get [social_set_id]` | Get queue schedule rules | | `queue:schedule:put [social_set_id] --rules '[{"h":9,"m":30,"days":["mon","wed","fri"]}]'` | Replace queue schedule rules (full replacement) | +Snake-case date aliases (`--start_date`, `--end_date`) are accepted. + ### Tags +Tags are scoped per social set — a tag in one social set doesn't appear in another. Check existing tags before creating. + | Command | Description | |---------|-------------| | `tags:list [social_set_id]` | List all tags | @@ -386,274 +210,69 @@ Use `queue:get` when the user asks what is already scheduled (or free) for a giv | Command | Description | |---------|-------------| -| `media:upload [social_set_id] ` | Upload media, wait for processing, return ready media_id | -| `media:upload ... --no-wait` | Upload and return immediately (use media:status to poll) | -| `media:upload ... --timeout ` | Set custom timeout (default: 60) | -| `media:status [social_set_id] ` | Check media upload status | - -### Setup & Configuration - -| Command | Description | -|---------|-------------| -| `setup` | Interactive setup - prompts for API key, storage location, and default social set | -| `setup --key --location ` | Non-interactive setup for scripts/CI (auto-selects default if only one social set) | -| `setup --key --default-social-set ` | Non-interactive setup with explicit default social set | -| `setup --key --no-default` | Non-interactive setup, skip default social set selection | -| `config:show` | Show current config, API key source, and default social set | -| `config:set-default [social_set_id]` | Set default social set (interactive if ID omitted) | +| `media:upload [social_set_id] ` | Upload media, wait for processing, return ready `media_id` | +| `media:upload ... --no-wait` | Upload and return immediately (poll with `media:status`) | +| `media:upload ... --timeout ` | Custom processing timeout (default 60) | +| `media:status [social_set_id] ` | Check upload status | -## Examples +### Examples -### Set up default social set -```bash -# Check current config -./scripts/typefully.js config:show - -# Set default (interactive - lists available social sets) -./scripts/typefully.js config:set-default - -# Set default (non-interactive) -./scripts/typefully.js config:set-default 123 --location global -``` - -### Create a tweet (using default social set) ```bash +# Create a tweet (default social set) ./scripts/typefully.js drafts:create --text "Hello, world!" -``` -### Create a tweet with explicit social_set_id -```bash +# Explicit social_set_id ./scripts/typefully.js drafts:create 123 --text "Hello, world!" -``` -### Create a cross-platform post (specific platforms) -```bash +# Cross-platform, same content ./scripts/typefully.js drafts:create --platform x,linkedin,threads --text "Big announcement!" -``` - -### Resolve LinkedIn mention syntax from a company URL -```bash -./scripts/typefully.js linkedin:organizations:resolve --organization-url "https://www.linkedin.com/company/typefullycom/" -``` - -### Create a LinkedIn draft with a mention -```bash -./scripts/typefully.js drafts:create --platform linkedin --text "Thanks @[Typefully](urn:li:organization:86779668) for the support." -``` - -### Create a post on all connected platforms -```bash ./scripts/typefully.js drafts:create --all --text "Posting everywhere!" -``` -### Create and schedule for next slot -```bash +# Create and schedule for the next slot ./scripts/typefully.js drafts:create --text "Scheduled post" --schedule next-free-slot -``` -### Create with tags -```bash +# Create with tags ./scripts/typefully.js drafts:create --text "Marketing post" --tags marketing,product -``` -### List scheduled posts sorted by date -```bash +# List scheduled posts, newest scheduled first ./scripts/typefully.js drafts:list --status scheduled --sort scheduled_date -``` -### Get queue view for a date range -```bash +# Queue view for a date range ./scripts/typefully.js queue:get --start-date 2026-02-01 --end-date 2026-02-29 -``` -### Get X post analytics for a date range -```bash -./scripts/typefully.js analytics:posts:list --start-date 2026-03-01 --end-date 2026-03-07 -``` - -### Get X post analytics including replies -```bash -./scripts/typefully.js analytics:posts:list --start-date 2026-03-01 --end-date 2026-03-07 --include-replies -``` - -### Paginate through X analytics results -```bash -./scripts/typefully.js analytics:posts:list --start-date 2026-03-01 --end-date 2026-03-31 --limit 100 --offset 100 -``` - -### Get X followers analytics for the default range -```bash -./scripts/typefully.js analytics:followers:get -``` - -### Get X followers analytics for a date range -```bash -./scripts/typefully.js analytics:followers:get --start-date 2026-03-01 --end-date 2026-03-31 -``` - -### Get queue schedule -```bash -./scripts/typefully.js queue:schedule:get -``` - -### Replace queue schedule rules -```bash +# Replace queue schedule rules ./scripts/typefully.js queue:schedule:put --rules '[{"h":9,"m":30,"days":["mon","wed","fri"]}]' -``` -### Reply to a tweet -```bash -./scripts/typefully.js drafts:create --platform x --text "Great thread!" --reply-to "https://x.com/user/status/123456" -``` +# Draft with scratchpad notes +./scripts/typefully.js drafts:create --text "Launching next week!" --scratchpad "Coordinate with marketing before publishing." -### Post to an X community -```bash -./scripts/typefully.js drafts:create --platform x --text "Community update" --community 1493446837214187523 -``` +# Upload media, then attach it +./scripts/typefully.js media:upload ./image.jpg # -> {"media_id": "abc-123", "status": "ready"} +./scripts/typefully.js drafts:create --text "Check out this image!" --media abc-123 -### Create an X quote post -```bash -./scripts/typefully.js drafts:create --platform x --text "My take on this" --quote-post-url "https://x.com/user/status/1234567890123456789" -``` - -### Create an X post with content disclosure labels -```bash -./scripts/typefully.js drafts:create --platform x --text "Sponsored AI-assisted update" --paid-partnership --made-with-ai -``` - -### Update a draft to quote an X post -```bash -./scripts/typefully.js drafts:update 456 --platform x --quote-post-url "https://x.com/user/status/1234567890123456789" --use-default -``` - -### Add an X content disclosure label to an existing draft -```bash -./scripts/typefully.js drafts:update 456 --made-with-ai --use-default -``` - -### Create draft with share URL -```bash -./scripts/typefully.js drafts:create --text "Check this out" --share -``` - -### Create draft with scratchpad notes -```bash -./scripts/typefully.js drafts:create --text "Launching next week!" --scratchpad "Draft for product launch. Coordinate with marketing team before publishing." -``` - -### Upload media and create post with it -```bash -# Single command handles upload + polling - returns when ready! -./scripts/typefully.js media:upload ./image.jpg -# Returns: {"media_id": "abc-123-def", "status": "ready", "message": "Media uploaded and ready to use"} - -# Create post with the media attached -./scripts/typefully.js drafts:create --text "Check out this image!" --media abc-123-def -``` - -### Upload multiple media files -```bash -# Upload each file (each waits for processing) -./scripts/typefully.js media:upload ./photo1.jpg # Returns media_id: id1 -./scripts/typefully.js media:upload ./photo2.jpg # Returns media_id: id2 - -# Create post with multiple media (comma-separated) -./scripts/typefully.js drafts:create --text "Photo dump!" --media id1,id2 -``` - -### Add media to an existing draft -```bash -# Upload media -./scripts/typefully.js media:upload ./new-image.jpg # Returns media_id: xyz - -# Update draft with media (456 is the draft_id) +# Add media to an existing draft ./scripts/typefully.js drafts:update 456 --text "Updated post with image" --media xyz --use-default ``` -### Setup (interactive) -```bash -./scripts/typefully.js setup -``` - -### Setup (non-interactive, for scripts/CI) -```bash -# Auto-selects default social set if only one exists -./scripts/typefully.js setup --key typ_xxx --location global - -# With explicit default social set -./scripts/typefully.js setup --key typ_xxx --location global --default-social-set 123 - -# Skip default social set selection entirely -./scripts/typefully.js setup --key typ_xxx --no-default -``` - -## Platform Names - -Use these exact names for the `--platform` option: -- `x` - X (formerly Twitter) -- `linkedin` - LinkedIn -- `threads` - Threads -- `bluesky` - Bluesky -- `mastodon` - Mastodon - -## Draft URLs - -Typefully draft URLs contain the social set and draft IDs: -``` -https://typefully.com/?a=&d= -``` +--- -Example: `https://typefully.com/?a=12345&d=67890` -- `a=12345` → social_set_id -- `d=67890` → draft_id +## Reference -## Draft Scratchpad +### Character limits -**When the user explictly asked to add notes, ideas, or anything else in the draft scratchpad, use the `--scratchpad` flag—do NOT write to local files!** +X 280 · LinkedIn 3000 · Threads 500 · Bluesky 300 · Mastodon 500. -The `--scratchpad` option attaches internal notes directly to the Typefully draft. These notes: -- Are visible in the Typefully UI alongside the draft -- Stay attached to the draft permanently -- Are private and never published to social media -- Are perfect for storing thread expansion ideas, research notes, context, etc. +### Draft URLs -```bash -# CORRECT: Notes attached to the draft in Typefully -./scripts/typefully.js drafts:create 123 --text "My post" --scratchpad "Ideas for expanding: 1) Add stats 2) Include quote" +Typefully draft URLs encode the social set and draft IDs: `https://typefully.com/?a=&d=` (e.g. `a=12345` → social_set_id, `d=67890` → draft_id). -# WRONG: Do NOT write notes to local files when the user wants them in Typefully -# Writing to /tmp/scratchpad/ or any local file is NOT the same thing -``` +### Automation guidelines -## Automation Guidelines +To keep accounts in good standing, especially on X: -When automating posts, especially on X, follow these rules to keep accounts in good standing: - -- **No duplicate content** across multiple accounts -- **No unsolicited automated replies** - only reply when explicitly requested by the user -- **No trending manipulation** - don't mass-post about trending topics -- **No fake engagement** - don't automate likes, reposts, or follows -- **Respect rate limits** - the API has rate limits, don't spam requests -- **Drafts are private** - content stays private until published or explicitly shared +- No duplicate content across accounts. +- No unsolicited automated replies — only reply when the user explicitly requests it. +- No trending manipulation, no fake engagement (likes/reposts/follows). +- Respect rate limits; drafts stay private until published or explicitly shared. When in doubt, create drafts for user review rather than publishing directly. - -**Publishing confirmation**: Unless the user explicitly asks to "publish now" or "post immediately", always confirm before publishing. Creating a draft is safe; publishing is irreversible and goes public instantly. - -## Tips - -- **Smart platform default**: If `--platform` is omitted, the first connected platform is auto-selected -- **All platforms**: Use `--all` to post to all connected platforms at once -- **Character limits**: X (280), LinkedIn (3000), Threads (500), Bluesky (300), Mastodon (500) -- **LinkedIn mentions**: Use `@[Name](urn:li:organization:ID)` in post text; resolve IDs via `linkedin:organizations:resolve` -- **Thread creation**: Use `---` on its own line to split into multiple posts (thread) -- **Scheduling**: Use `next-free-slot` to let Typefully pick the optimal time -- **Cross-posting**: List multiple platforms separated by commas: `--platform x,linkedin` -- **Draft titles**: Use `--title` for internal organization (not posted to social media) -- **Draft scratchpad**: Use `--scratchpad` to attach notes to the draft in Typefully (NOT local files!) - perfect for thread ideas, research, context -- **X analytics**: Use `analytics:posts:list --start-date ... --end-date ...` to fetch post metrics for a social set; replies are excluded by default, and `--include-replies` opts back in -- **X followers analytics**: Use `analytics:followers:get --start-date ... --end-date ...` to fetch daily follower counts, or omit dates for the API default range -- **X content disclosures**: Use `--paid-partnership` and/or `--made-with-ai` on `drafts:create` or `drafts:update`; these flags are X-only and are applied only to X posts -- **Publishing quota**: Use `social-sets:get` and inspect `publishing_quota` to see remaining publish capacity and reset time -- **Read from file**: Use `--file ./post.txt` instead of `--text` to read content from a file -- **Sorting drafts**: Use `--sort` with values like `created_at`, `-created_at`, `scheduled_date`, etc. diff --git a/skills/typefully/references/comments.md b/skills/typefully/references/comments.md new file mode 100644 index 0000000..d255f25 --- /dev/null +++ b/skills/typefully/references/comments.md @@ -0,0 +1,47 @@ +# Comments on Drafts + +> Script paths are relative to the skill root (where `SKILL.md` lives). For X Article comment specifics, see `references/platforms/x-articles.md`. + +Drafts can have comment threads anchored to a selected span or to a whole paragraph. `drafts:get` returns `posts[*].text` (and X Article `content_markdown`) with inline `` anchors by default so you can preserve them during edits. + +## Core rules + +1. **Preserve every comment anchor when patching draft text.** Span anchors wrap selected text; self-closing anchors at the start of a paragraph are live comments on the whole following paragraph (not empty/resolved). When rewriting, move each anchor to the most semantically equivalent span or paragraph — never silently strip or drop anchors. +2. **Do not resolve or delete comments without explicit user instruction.** After editing text that may address feedback, ask whether to resolve the specific thread(s). "Clean up" / "tidy" / "looks addressed" never means resolve or delete. +3. **Talk about comments in plain English, not marker syntax** — e.g. "comment on the word 'constraint'". Only mention marker syntax when explaining a marker-related API error or when the user asks how anchors work. + +## Default edit flow + +1. `drafts:get` with anchors enabled (default). +2. Rewrite while preserving/repositioning every anchor. +3. Patch with `drafts:update --text ...` (posts) or `drafts:update --platform x_article --content-markdown ...` (X Articles), without force flags. +4. Ask whether to resolve any addressed comments. + +Use `comments:list --status all` when you need thread ids, comment bodies, authors, or resolved status. Use `drafts:get --exclude-comment-markers` only for display, LLM context, export, or preview text that will **not** be patched back. + +When the user asks to accept/apply/address a comment: fetch with anchors, edit only that anchor's text (or the paragraph after a self-closing paragraph anchor), preserve every other anchor and unrelated text, then `drafts:update` without force flags. Ask before resolving. If feedback is open-ended, propose wording or ask — don't invent silently. For "accept all comments", batch only changes whose anchors can all be preserved. + +## Force overwrite (destructive) + +`--force-overwrite-comments` resolves and strips **every** unresolved thread whose anchor is missing from the submitted text, including unrelated threads. Default: **do not use it.** If a PATCH fails because anchors don't match, re-fetch with anchors, preserve every ``, and patch again without force. + +Only use it when anchors truly cannot be preserved (a user-requested wholesale rewrite, or an anchor with no reasonable new location). Before using it: + +1. Run `comments:list --status unresolved`. +2. Tell the user which threads will be resolved and stripped (selected text + top comment). +3. State that this cannot be undone via the API. +4. Wait for an explicit "yes, proceed" — do not confirm and PATCH in the same turn. + +## Commands + +| Command | Purpose | +|---------|---------| +| `comments:list ` | List threads. Filters: `--platform`, `--status` (`unresolved` default / `resolved` / `all`), `--limit`, `--offset` | +| `comments:create --post-index --selected-text "..." --text "..."` | Create a thread anchored on exact post text. Optional: `--platform`, `--occurrence` | +| `comments:create --platform x_article --selected-text "..." --text "..."` | Create a thread on visible X Article text; omit `--post-index` | +| `comments:reply --text "..."` | Add a reply | +| `comments:resolve ` | Resolve a thread — only after explicit user confirmation | +| `comments:update --text "..."` | Edit a comment's text (comment-author only) | +| `comments:delete [comment_id]` | Delete a thread or one comment — only after explicit user instruction | + +`comments:create` requires `selected_text` to exactly match the post text (or visible X Article text with `--platform x_article`). If it repeats, pass zero-based `--occurrence`. Pass `--platform` when the draft has multiple commentable platforms or when commenting on an X Article. For LinkedIn mentions, select the entire `@[Name](urn:li:...)` substring or stay outside it. diff --git a/skills/typefully/references/local-development.md b/skills/typefully/references/local-development.md new file mode 100644 index 0000000..a69874d --- /dev/null +++ b/skills/typefully/references/local-development.md @@ -0,0 +1,22 @@ +# Local Development + +Use this only when testing the Typefully CLI against a non-production API. + +## API base override + +Pass `--api-base-url ` to any command to target another API base for that invocation. If the URL does not end in `/v2`, the CLI appends `/v2`. + +```bash +./scripts/typefully.js me:get --api-base-url http://localhost:8000 +./scripts/typefully.js drafts:list --api-base-url https://localhost:8000/v2 +``` + +## Local TLS failures + +If a local HTTPS server returns `fetch failed` or `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, use one of these development-only fixes: + +- Trust the local certificate in the OS or development CA store. +- Use the local server's HTTP URL if it supports one. +- Keep any TLS bypass setting local to the current shell and remove it before running production API commands. + +Do not commit local API URLs, API keys, or TLS bypass settings. diff --git a/skills/typefully/references/platforms/linkedin.md b/skills/typefully/references/platforms/linkedin.md new file mode 100644 index 0000000..d7f08f9 --- /dev/null +++ b/skills/typefully/references/platforms/linkedin.md @@ -0,0 +1,28 @@ +# LinkedIn + +> Script paths are relative to the skill root (where `SKILL.md` lives). Character limit: 3000. + +## Mentions + +LinkedIn mentions use text syntax inside post content: + +```text +@[Company Name](urn:li:organization:123456) +``` + +Resolve a public LinkedIn organization/school URL into ready-to-paste mention syntax with `linkedin:organizations:resolve`: + +```bash +./scripts/typefully.js linkedin:organizations:resolve --organization-url "https://www.linkedin.com/company/typefullycom/" +# Returns mention_text like: @[Typefully](urn:li:organization:86779668) +``` + +Then include the returned `mention_text` in the draft: + +```bash +./scripts/typefully.js drafts:create --platform linkedin --text "Thanks @[Typefully](urn:li:organization:86779668) for the support." +``` + +`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. diff --git a/skills/typefully/references/platforms/x-articles.md b/skills/typefully/references/platforms/x-articles.md new file mode 100644 index 0000000..3070e3d --- /dev/null +++ b/skills/typefully/references/platforms/x-articles.md @@ -0,0 +1,289 @@ +# X Articles + +> Script paths are relative to the skill root (where `SKILL.md` lives), e.g. `./scripts/typefully.js`. Character limit does not apply — X Articles are long-form. + +Use this guide when creating, updating, scheduling, publishing, or commenting on X Article drafts through the Typefully CLI. + +## Core Rules + +- Always use `--platform x_article`. +- `x_article` is standalone. Do not combine it with `x`, LinkedIn, Threads, Bluesky, Mastodon, or `--all`. +- Use `--content-markdown ` for article content. +- Use `--cover-media-id ` to set a cover image. +- Use `--cover-media-id null` on update to remove a cover image. +- Do not use post-only flags with X Articles: `--text`, `--file`, `--media`, `--append`, `--reply-to`, `--community`, `--quote-post-url`, `--paid-partnership`, or `--made-with-ai`. + +## Markdown Format + +`content_markdown` is canonical X Article Markdown. Typefully validates and normalizes it server-side. + +Required structure: + +- The first non-empty block must be `# Title`; that heading sets the article title. +- Later blocks are the body. + +Supported body blocks: + +- Paragraphs +- Blockquotes +- Ordered lists +- Unordered lists +- `#` and `##` body headings +- Bold +- Italic +- Strikethrough +- Links + +Supported block-only embeds, each alone on its own line: + +```md + +``` + +```md + +``` + +Comprehensive supported markdown example: + +```md +# Shipping Better Drafts + +Great articles start with a clear promise, then use structure to make the idea easy to scan. + +# Why structure matters + +Use body headings when the piece shifts to a new major section. A paragraph can include **bold emphasis**, *italic emphasis*, ~~strikethrough edits~~, and [helpful links](https://typefully.com). + +> A good article gives readers a path through the argument, not just a pile of notes. + +## A practical checklist + +- Lead with the core idea +- Support it with context +- Add examples only where they clarify the point + +## Publishing sequence + +1. Draft the title +2. Shape the main sections +3. Review the final flow + + + + + +End with a concise takeaway that tells readers what to do next. +``` + +Comment anchors round-trip in article markdown: + +```md +Inline selected text keeps the comment anchored. + + +Paragraph-level comments use a block marker before the commented block. +``` + +## Create Payloads + +Minimal article draft: + +```bash +ARTICLE_MARKDOWN='# Article Title + +Long-form article body.' + +./scripts/typefully.js drafts:create 123 \ + --platform x_article \ + --content-markdown "$ARTICLE_MARKDOWN" +``` + +API payload shape: + +```json +{ + "platforms": { + "x_article": { + "content_markdown": "# Article Title\n\nLong-form article body." + } + } +} +``` + +Article draft with cover, title, tags, scratchpad, and sharing: + +```bash +ARTICLE_MARKDOWN='# Product Notes + +What changed, why it matters, and what comes next.' + +./scripts/typefully.js drafts:create 123 \ + --platform x_article \ + --content-markdown "$ARTICLE_MARKDOWN" \ + --cover-media-id 550e8400-e29b-41d4-a716-446655440000 \ + --title "Product notes article" \ + --tags product,launch \ + --scratchpad "Review with product before scheduling." \ + --share +``` + +API payload shape: + +```json +{ + "platforms": { + "x_article": { + "content_markdown": "# Product Notes\n\nWhat changed, why it matters, and what comes next.", + "cover_media_id": "550e8400-e29b-41d4-a716-446655440000" + } + }, + "draft_title": "Product notes article", + "tags": ["product", "launch"], + "scratchpad_text": "Review with product before scheduling.", + "share": true +} +``` + +Create and schedule: + +```bash +./scripts/typefully.js drafts:create 123 \ + --platform x_article \ + --content-markdown "$ARTICLE_MARKDOWN" \ + --schedule "2026-07-20T14:00:00Z" +``` + +Immediate publish: + +```bash +./scripts/typefully.js drafts:create 123 \ + --platform x_article \ + --content-markdown "$ARTICLE_MARKDOWN" \ + --schedule now +``` + +## Update Payloads + +Update article markdown only: + +```bash +./scripts/typefully.js drafts:update 123 456 \ + --platform x_article \ + --content-markdown "$ARTICLE_MARKDOWN" +``` + +API payload shape: + +```json +{ + "platforms": { + "x_article": { + "content_markdown": "# Article Title\n\nUpdated body." + } + } +} +``` + +Set or replace cover: + +```bash +./scripts/typefully.js drafts:update 123 456 \ + --platform x_article \ + --cover-media-id 550e8400-e29b-41d4-a716-446655440000 +``` + +API payload shape: + +```json +{ + "platforms": { + "x_article": { + "cover_media_id": "550e8400-e29b-41d4-a716-446655440000" + } + } +} +``` + +Remove cover: + +```bash +./scripts/typefully.js drafts:update 123 456 \ + --platform x_article \ + --cover-media-id null +``` + +API payload shape: + +```json +{ + "platforms": { + "x_article": { + "cover_media_id": null + } + } +} +``` + +Update content and cover together: + +```bash +./scripts/typefully.js drafts:update 123 456 \ + --platform x_article \ + --content-markdown "$ARTICLE_MARKDOWN" \ + --cover-media-id 550e8400-e29b-41d4-a716-446655440000 +``` + +Schedule or publish an existing article draft through the normal draft commands: + +```bash +./scripts/typefully.js drafts:schedule 123 456 --time next-free-slot +./scripts/typefully.js drafts:publish 123 456 +``` + +## Comments + +Create article comments against visible article text, not markdown syntax or embed tags: + +```bash +./scripts/typefully.js comments:create 456 \ + --social-set-id 123 \ + --platform x_article \ + --selected-text "article phrase" \ + --text "Clarify this section." +``` + +If the selected text appears multiple times, pass zero-based `--occurrence`: + +```bash +./scripts/typefully.js comments:create 456 \ + --social-set-id 123 \ + --platform x_article \ + --selected-text "repeated phrase" \ + --occurrence 1 \ + --text "This second occurrence needs context." +``` + +Omit `--post-index` for X Article comments. If it is supplied, it must be `0`; the CLI omits it from the API request. + +When editing article drafts that already have comments: + +- Fetch the draft without `--exclude-comment-markers`. +- Preserve every `` anchor in `content_markdown`. +- Patch with `drafts:update --platform x_article --content-markdown ...`. +- Use `--force-overwrite-comments` only after explicit user confirmation, because missing anchors are resolved and stripped server-side. + +## Media + +Use `media:upload` first for cover images or embedded media: + +```bash +./scripts/typefully.js media:upload 123 ./cover.png +``` + +Then use the returned ready `media_id` either as: + +- `--cover-media-id ` for the article cover. +- `` inside `content_markdown` for an embedded media block. + +Cover images must be ready, account-owned static images. diff --git a/skills/typefully/references/platforms/x.md b/skills/typefully/references/platforms/x.md new file mode 100644 index 0000000..feeeb8d --- /dev/null +++ b/skills/typefully/references/platforms/x.md @@ -0,0 +1,52 @@ +# X (Twitter) + +> Script paths are relative to the skill root (where `SKILL.md` lives). All `[social_set_id]` args fall back to the configured default when omitted. Character limit: 280 per post; split threads with `---` on its own line. + +X-only features: analytics, quote posts, replies, communities, and content-disclosure labels. These flags apply only to X posts, even in a multi-platform draft. + +## Analytics + +The public API supports **X analytics only**. The CLI defaults `--platform` to `x`, so you can usually omit it. Replies are **excluded by default**; add `--include-replies` to include them. + +Post analytics return per-post metrics for an inclusive date range: `impressions` plus engagement totals/breakdowns (`likes`, `comments`, `shares`, `quotes`, `saves`, `profile_clicks`, `link_clicks`). Follower analytics return `current_followers_count` plus daily `data` points (`date`, `followers_count`); omit dates for the API default range. + +| Command | Description | +|---------|-------------| +| `analytics:posts:list [social_set_id] --start-date --end-date ` | X posts with normalized metrics for an inclusive date range | +| `analytics:posts:list ... --include-replies` | Include X replies (excluded by default) | +| `analytics:posts:list ... --limit 100 --offset 25` | Paginate | +| `analytics:followers:get [social_set_id]` | X follower counts for the API default range | +| `analytics:followers:get ... --start-date --end-date ` | X follower counts for an inclusive date range | + +Snake-case date aliases (`--start_date`, `--end_date`, `--include_replies`) and explicit `--platform x` are also accepted. + +```bash +./scripts/typefully.js analytics:posts:list --start-date 2026-03-01 --end-date 2026-03-07 +./scripts/typefully.js analytics:posts:list --start-date 2026-03-01 --end-date 2026-03-07 --include-replies +./scripts/typefully.js analytics:posts:list --start-date 2026-03-01 --end-date 2026-03-31 --limit 100 --offset 100 +./scripts/typefully.js analytics:followers:get --start-date 2026-03-01 --end-date 2026-03-31 +``` + +## Replies, quotes, and communities + +| Flag | Purpose | +|------|---------| +| `--reply-to ` | Reply to an existing X post | +| `--quote-post-url ` | Quote an existing X post (on `drafts:create` or `drafts:update`) | +| `--community ` | Post to an X community | + +```bash +./scripts/typefully.js drafts:create --platform x --text "Great thread!" --reply-to "https://x.com/user/status/123456" +./scripts/typefully.js drafts:create --platform x --text "My take on this" --quote-post-url "https://x.com/user/status/1234567890123456789" +./scripts/typefully.js drafts:create --platform x --text "Community update" --community 1493446837214187523 +./scripts/typefully.js drafts:update 456 --platform x --quote-post-url "https://x.com/user/status/1234567890123456789" --use-default +``` + +## Content disclosure labels + +`--paid-partnership` and `--made-with-ai` are X-only and apply only to X posts. Usable on `drafts:create` and `drafts:update`. + +```bash +./scripts/typefully.js drafts:create --platform x --text "Sponsored AI-assisted update" --paid-partnership --made-with-ai +./scripts/typefully.js drafts:update 456 --made-with-ai --use-default +``` diff --git a/skills/typefully/references/setup.md b/skills/typefully/references/setup.md new file mode 100644 index 0000000..b0cb5d9 --- /dev/null +++ b/skills/typefully/references/setup.md @@ -0,0 +1,69 @@ +# Setup & Configuration + +> Script paths here are relative to the skill root (where `SKILL.md` lives), e.g. `./scripts/typefully.js`. + +## API Key + +1. Get a key at https://typefully.com/?settings=api +2. Run interactive setup: `./scripts/typefully.js setup` +3. Or set an environment variable: `export TYPEFULLY_API_KEY=your_key` + +Requirements: Node.js 18+ (built-in fetch). No other dependencies. + +**Config priority** (highest to lowest): + +1. `TYPEFULLY_API_KEY` environment variable +2. `./.typefully/config.json` (project-local) +3. `~/.config/typefully/config.json` (user-global) + +Development only: pass `--api-base-url ` to target another API base; `/v2` is appended when omitted. If a local server's TLS certificate isn't trusted (`fetch failed` / `UNABLE_TO_VERIFY_LEAF_SIGNATURE`), see [`local-development.md`](local-development.md). + +## Handling "API key not found" errors + +When the CLI returns "API key not found": + +1. **Tell the user to run `./scripts/typefully.js setup`** themselves — it is interactive, so you cannot run it for them. +2. **Stop and wait.** No API operation works without a key. Do not draft or prepare content until setup is confirmed. +3. **Do not** search Keychain, `.env` files, config directories, Trash, or construct commands to find credentials. + +Trust the CLI's error messages and follow them. + +## Setup commands + +| Command | Description | +|---------|-------------| +| `setup` | Interactive setup — prompts for API key, storage location, and default social set | +| `setup --key --location ` | Non-interactive setup (auto-selects default if only one social set) | +| `setup --key --default-social-set ` | Non-interactive setup with explicit default social set | +| `setup --key --no-default` | Non-interactive setup, skip default social set selection | +| `config:show` | Show current config, API key source, and default social set | +| `config:set-default [social_set_id]` | Set default social set (interactive if ID omitted) | + +```bash +# Interactive +./scripts/typefully.js setup + +# Non-interactive (CI) — auto-selects default if only one social set +./scripts/typefully.js setup --key typ_xxx --location global + +# With explicit default social set +./scripts/typefully.js setup --key typ_xxx --location global --default-social-set 123 + +# Set default later +./scripts/typefully.js config:set-default 123 --location global +``` + +## Keeping the skill updated + +**Source**: [github.com/typefully/agent-skills](https://github.com/typefully/agent-skills) · **API docs**: [typefully.com/docs/api](https://typefully.com/docs/api) + +When the freshness check in `SKILL.md` flags the skill as stale, point the user to an update method: + +| Installation | How to update | +|--------------|---------------| +| CLI (`npx skills`) | `npx skills update` | +| Claude Code plugin | `/plugin update typefully@typefully-skills` | +| Cursor | Remote rules auto-sync from GitHub | +| Manual | Pull latest from repo or re-copy `skills/typefully/` | + +API changes ship independently of the skill. diff --git a/skills/typefully/scripts/typefully.js b/skills/typefully/scripts/typefully.js index de46315..f312d37 100755 --- a/skills/typefully/scripts/typefully.js +++ b/skills/typefully/scripts/typefully.js @@ -13,12 +13,34 @@ const os = require('os'); const readline = require('readline'); // Allow overriding API base for tests / self-hosted mocks. -const API_BASE = process.env.TYPEFULLY_API_BASE || 'https://api.typefully.com/v2'; +let API_BASE = normalizeApiBase(process.env.TYPEFULLY_API_BASE || 'https://api.typefully.com/v2'); const GLOBAL_CONFIG_DIR = path.join(os.homedir(), '.config', 'typefully'); const GLOBAL_CONFIG_FILE = path.join(GLOBAL_CONFIG_DIR, 'config.json'); const LOCAL_CONFIG_DIR = '.typefully'; const LOCAL_CONFIG_FILE = path.join(LOCAL_CONFIG_DIR, 'config.json'); const API_KEY_URL = 'https://typefully.com/?settings=api'; +const X_ARTICLE_PLATFORM = 'x_article'; +const POST_PLATFORM_ORDER = ['x', 'linkedin', 'threads', 'bluesky', 'mastodon']; +const X_ARTICLE_POST_ONLY_FLAGS = [ + ['text', '--text'], + ['file', '--file'], + ['media', '--media'], + ['append', '--append'], + ['reply-to', '--reply-to'], + ['community', '--community'], + ['quote-post-url', '--quote-post-url'], + ['quote-url', '--quote-url'], + ['paid-partnership', '--paid-partnership'], + ['paid_partnership', '--paid_partnership'], + ['made-with-ai', '--made-with-ai'], + ['made_with_ai', '--made_with_ai'], +]; + +function normalizeApiBase(value) { + const trimmed = String(value || '').trim().replace(/\/+$/, ''); + if (!trimmed) return 'https://api.typefully.com/v2'; + return trimmed.endsWith('/v2') ? trimmed : `${trimmed}/v2`; +} // Content-type mapping for media uploads const CONTENT_TYPES = { @@ -221,6 +243,28 @@ function requireApiKey() { return result.key; } +function extractGlobalArgs(args) { + const result = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--api-base-url') { + const value = args[i + 1]; + if (!value || String(value).startsWith('--')) { + error('--api-base-url requires a value'); + } + API_BASE = normalizeApiBase(value); + i++; + continue; + } + + result.push(arg); + } + + return result; +} + function extractApiErrorMessage(data) { if (!data || typeof data !== 'object') return null; @@ -423,6 +467,82 @@ function validateXOnlyPostOptions(platformList, { quotePostUrl, disclosures }) { } } +function hasParsedArg(parsed, key) { + return Object.prototype.hasOwnProperty.call(parsed, key); +} + +function parsePlatformList(value) { + const platforms = String(value) + .split(',') + .map(p => p.trim().toLowerCase()) + .filter(Boolean); + + if (platforms.length === 0) { + error('--platform requires at least one platform'); + } + + return platforms; +} + +function hasXArticleDraftFlags(parsed) { + return hasParsedArg(parsed, 'content-markdown') || hasParsedArg(parsed, 'cover-media-id'); +} + +function validateXArticlePlatformUsage(platformList, parsed) { + const hasXArticle = platformList.includes(X_ARTICLE_PLATFORM); + const hasArticleFlags = hasXArticleDraftFlags(parsed); + + if (hasXArticle && platformList.length > 1) { + error('x_article is standalone and cannot be combined with other platforms'); + } + + if (hasArticleFlags && !(platformList.length === 1 && hasXArticle)) { + error('X Article flags require --platform x_article'); + } + + if (!hasXArticle) return; + + for (const [key, flagName] of X_ARTICLE_POST_ONLY_FLAGS) { + if (hasParsedArg(parsed, key)) { + error(`${flagName} cannot be used with --platform x_article`); + } + } +} + +function buildXArticlePlatformConfig(parsed, { requireContent }) { + const hasContentMarkdown = hasParsedArg(parsed, 'content-markdown'); + const hasCoverMediaId = hasParsedArg(parsed, 'cover-media-id'); + + if (requireContent && !hasContentMarkdown) { + error('--content-markdown is required for X Article drafts'); + } + + const config = {}; + if (hasContentMarkdown) { + config.content_markdown = coerceFlagValueToString( + parsed['content-markdown'], + '--content-markdown', + ); + } + if (hasCoverMediaId) { + const coverMediaId = coerceFlagValueToString(parsed['cover-media-id'], '--cover-media-id'); + config.cover_media_id = coverMediaId === 'null' ? null : coverMediaId; + } + + return config; +} + +function getPostTextFromParsed(parsed) { + let text = parsed.text; + if (parsed.file) { + if (!fs.existsSync(parsed.file)) { + error(`File not found: ${parsed.file}`); + } + text = fs.readFileSync(parsed.file, 'utf-8'); + } + return text; +} + function parseCsvArg(value, flagName) { // parseArgs sets missing values to true (e.g. `--tags --other-flag`) if (value === true) { @@ -1062,10 +1182,9 @@ async function getFirstConnectedPlatform(socialSetId) { // Check each platform for connection // The API returns platforms as an object where each key exists if that platform is connected - const platformOrder = ['x', 'linkedin', 'threads', 'bluesky', 'mastodon']; const platforms = socialSet.platforms || {}; - for (const platform of platformOrder) { + for (const platform of POST_PLATFORM_ORDER) { if (platforms[platform]) { return platform; } @@ -1076,11 +1195,10 @@ async function getFirstConnectedPlatform(socialSetId) { async function getAllConnectedPlatforms(socialSetId) { const socialSet = await apiRequest('GET', `/social-sets/${socialSetId}`); - const platformOrder = ['x', 'linkedin', 'threads', 'bluesky', 'mastodon']; const platforms = socialSet.platforms || {}; const connected = []; - for (const platform of platformOrder) { + for (const platform of POST_PLATFORM_ORDER) { if (platforms[platform]) { connected.push(platform); } @@ -1102,19 +1220,6 @@ async function cmdDraftsCreate(args) { const quotePostUrl = getQuotePostUrlFromParsed(parsed); const xContentDisclosures = getXContentDisclosuresFromParsed(parsed); - // Get text content - let text = parsed.text; - if (parsed.file) { - if (!fs.existsSync(parsed.file)) { - error(`File not found: ${parsed.file}`); - } - text = fs.readFileSync(parsed.file, 'utf-8'); - } - - if (!text) { - error('--text or --file is required'); - } - // Determine platform(s) let platforms = parsed.platform; @@ -1122,6 +1227,10 @@ async function cmdDraftsCreate(args) { error('Cannot use both --all and --platform flags'); } + if (hasXArticleDraftFlags(parsed) && !parsed.platform) { + error('X Article flags require --platform x_article'); + } + if (parsed.all) { // Get all connected platforms const allPlatforms = await getAllConnectedPlatforms(socialSetId); @@ -1138,54 +1247,67 @@ async function cmdDraftsCreate(args) { platforms = defaultPlatform; } - const platformList = platforms.split(',').map(p => p.trim()); - validateXOnlyPostOptions(platformList, { - quotePostUrl, - disclosures: xContentDisclosures, - }); - - // Split text into posts (thread support) - const posts = splitThreadText(text); + const platformList = parsePlatformList(platforms); + validateXArticlePlatformUsage(platformList, parsed); - // Parse media IDs - const mediaIds = parsed.media ? parsed.media.split(',').map(m => m.trim()) : []; + // Build request body + const platformsObj = {}; - // Build posts array - const basePostsArray = posts.map((postText, index) => { - const post = { text: postText }; - // Attach media only to first post - if (index === 0 && mediaIds.length > 0) { - post.media_ids = mediaIds; + if (platformList.includes(X_ARTICLE_PLATFORM)) { + platformsObj[X_ARTICLE_PLATFORM] = buildXArticlePlatformConfig(parsed, { + requireContent: true, + }); + } else { + const text = getPostTextFromParsed(parsed); + if (!text) { + error('--text or --file is required'); } - return post; - }); - // Build platforms object - const platformsObj = {}; - for (const platform of platformList) { - const postsArray = platform === 'x' - ? addXContentDisclosures(addQuotePostUrl(basePostsArray, quotePostUrl), xContentDisclosures) - : basePostsArray; - const platformConfig = { - enabled: true, - posts: postsArray, - }; + validateXOnlyPostOptions(platformList, { + quotePostUrl, + disclosures: xContentDisclosures, + }); + + // Split text into posts (thread support) + const posts = splitThreadText(text); - // X-specific settings - if (platform === 'x' && (parsed['reply-to'] || parsed.community)) { - platformConfig.settings = {}; - if (parsed['reply-to']) { - platformConfig.settings.reply_to_url = parsed['reply-to']; + // Parse media IDs + const mediaIds = parsed.media ? parsed.media.split(',').map(m => m.trim()) : []; + + // Build posts array + const basePostsArray = posts.map((postText, index) => { + const post = { text: postText }; + // Attach media only to first post + if (index === 0 && mediaIds.length > 0) { + post.media_ids = mediaIds; } - if (parsed.community) { - platformConfig.settings.community_id = parsed.community; + return post; + }); + + for (const platform of platformList) { + const postsArray = platform === 'x' + ? addXContentDisclosures(addQuotePostUrl(basePostsArray, quotePostUrl), xContentDisclosures) + : basePostsArray; + const platformConfig = { + enabled: true, + posts: postsArray, + }; + + // X-specific settings + if (platform === 'x' && (parsed['reply-to'] || parsed.community)) { + platformConfig.settings = {}; + if (parsed['reply-to']) { + platformConfig.settings.reply_to_url = parsed['reply-to']; + } + if (parsed.community) { + platformConfig.settings.community_id = parsed.community; + } } - } - platformsObj[platform] = platformConfig; + platformsObj[platform] = platformConfig; + } } - // Build request body const body = { platforms: platformsObj }; if (parsed.title) { @@ -1230,22 +1352,39 @@ async function cmdDraftsUpdate(args) { const quotePostUrl = getQuotePostUrlFromParsed(parsed); const xContentDisclosures = getXContentDisclosuresFromParsed(parsed); - // Get text content - let text = parsed.text; - if (parsed.file) { - if (!fs.existsSync(parsed.file)) { - error(`File not found: ${parsed.file}`); - } - text = fs.readFileSync(parsed.file, 'utf-8'); + const body = {}; + const explicitPlatformList = parsed.platform + ? parsePlatformList(parsed.platform) + : null; + + if (hasXArticleDraftFlags(parsed) && !explicitPlatformList) { + error('X Article flags require --platform x_article'); } - const body = {}; + if (explicitPlatformList) { + validateXArticlePlatformUsage(explicitPlatformList, parsed); + } + + const shouldUpdateArticle = Boolean( + explicitPlatformList?.includes(X_ARTICLE_PLATFORM) && hasXArticleDraftFlags(parsed) + ); + + if (shouldUpdateArticle) { + body.platforms = { + [X_ARTICLE_PLATFORM]: buildXArticlePlatformConfig(parsed, { + requireContent: false, + }), + }; + } - const shouldUpdatePosts = Boolean(text || quotePostUrl || xContentDisclosures.hasAny); + // Get text content for normal post updates only. + let text = null; + if (!shouldUpdateArticle) { + text = getPostTextFromParsed(parsed); + } + + const shouldUpdatePosts = Boolean(!shouldUpdateArticle && (text || quotePostUrl || xContentDisclosures.hasAny)); if (shouldUpdatePosts) { - const explicitPlatformList = parsed.platform - ? parsed.platform.split(',').map(p => p.trim()) - : null; if (explicitPlatformList) { validateXOnlyPostOptions(explicitPlatformList, { quotePostUrl, @@ -1367,7 +1506,7 @@ async function cmdDraftsUpdate(args) { } if (Object.keys(body).length === 0) { - error('At least one of --text, --file, --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, or --force-overwrite-comments is required'); } const params = new URLSearchParams(); @@ -1401,23 +1540,31 @@ async function cmdCreateDraftAlias(args) { const forwarded = [String(socialSetId)]; // Prefer explicit --file / --text, otherwise treat positional args as the draft content. - if (Object.prototype.hasOwnProperty.call(parsed, 'file')) { - forwarded.push('--file', coerceFlagValueToString(parsed.file, '--file')); - } else { - let text; - if (Object.prototype.hasOwnProperty.call(parsed, 'text')) { - text = coerceFlagValueToString(parsed.text, '--text'); + const canSkipPostText = hasXArticleDraftFlags(parsed) + && !hasParsedArg(parsed, 'file') + && !hasParsedArg(parsed, 'text') + && parsed._positional.length === 0; + if (!canSkipPostText) { + if (Object.prototype.hasOwnProperty.call(parsed, 'file')) { + forwarded.push('--file', coerceFlagValueToString(parsed.file, '--file')); } else { - if (parsed._positional.length === 0) { - error('Draft text is required (provide it as the first argument, or use --text/--file)'); + let text; + if (Object.prototype.hasOwnProperty.call(parsed, 'text')) { + text = coerceFlagValueToString(parsed.text, '--text'); + } else { + if (parsed._positional.length === 0) { + error('Draft text is required (provide it as the first argument, or use --text/--file)'); + } + text = parsed._positional.join(' '); } - text = parsed._positional.join(' '); + forwarded.push('--text', text); } - forwarded.push('--text', text); } pushStringFlag(forwarded, parsed, 'platform', '--platform'); if (parsed.all) forwarded.push('--all'); + pushStringFlag(forwarded, parsed, 'content-markdown', '--content-markdown'); + pushStringFlag(forwarded, parsed, 'cover-media-id', '--cover-media-id'); pushStringFlag(forwarded, parsed, 'media', '--media'); pushStringFlag(forwarded, parsed, 'title', '--title'); pushStringFlag(forwarded, parsed, 'schedule', '--schedule'); @@ -1461,6 +1608,8 @@ async function cmdUpdateDraftAlias(args) { const forwarded = [String(socialSetId), String(draftId)]; pushStringFlag(forwarded, parsed, 'platform', '--platform'); + pushStringFlag(forwarded, parsed, 'content-markdown', '--content-markdown'); + pushStringFlag(forwarded, parsed, 'cover-media-id', '--cover-media-id'); if (text) forwarded.push('--text', text); if (Object.prototype.hasOwnProperty.call(parsed, 'file')) { forwarded.push('--file', coerceFlagValueToString(parsed.file, '--file')); @@ -1580,19 +1729,29 @@ async function cmdCommentsCreate(args) { const text = getRequiredStringArgFromParsed(parsed, 'text'); const selectedText = getRequiredStringArgFromParsed(parsed, 'selected-text', ['selected_text']); - const postIndexRaw = getRequiredStringArgFromParsed(parsed, 'post-index', ['post_index']); - const postIndex = Number.parseInt(postIndexRaw, 10); - if (!Number.isInteger(postIndex) || postIndex < 0) { - error('--post-index must be a non-negative integer'); - } - const body = { - post_index: postIndex, selected_text: selectedText, text, }; if (parsed.platform) body.platform = parsed.platform; + if (parsed.platform === X_ARTICLE_PLATFORM) { + const postIndexRaw = getOptionalStringArgFromParsed(parsed, 'post-index', ['post_index']); + if (postIndexRaw != null) { + const postIndex = Number.parseInt(postIndexRaw, 10); + if (!Number.isInteger(postIndex) || postIndex !== 0) { + error('--post-index must be 0 when --platform x_article'); + } + } + } else { + const postIndexRaw = getRequiredStringArgFromParsed(parsed, 'post-index', ['post_index']); + const postIndex = Number.parseInt(postIndexRaw, 10); + if (!Number.isInteger(postIndex) || postIndex < 0) { + error('--post-index must be a non-negative integer'); + } + body.post_index = postIndex; + } + if (Object.prototype.hasOwnProperty.call(parsed, 'occurrence')) { const occurrence = Number.parseInt(parsed.occurrence, 10); if (!Number.isInteger(occurrence) || occurrence < 0) { @@ -1872,6 +2031,9 @@ USAGE: NOTE: Commands that take a social_set_id as a positional argument also accept: --social-set-id (or --social_set_id ) + Global options: + --api-base-url Override API base URL for this command + (appends /v2 when omitted) SETUP: setup Interactive setup - saves API key and optional default social set @@ -1929,10 +2091,13 @@ COMMANDS: drafts:create [social_set_id] [options] Create a new draft (uses default if ID omitted) --platform Comma-separated: x,linkedin,threads,bluesky,mastodon + or standalone x_article (auto-selects first connected platform if omitted) - --all Post to all connected platforms + --all Post to all connected post platforms (excludes x_article) --text Post content (use --- on its own line for threads) --file, -f Read content from file instead of --text + --content-markdown X Article markdown (requires --platform x_article) + --cover-media-id X Article cover image; use literal null to remove on update --media Comma-separated media IDs to attach --title Draft title (internal only) --schedule <time> "now", "next-free-slot", or ISO datetime @@ -1946,10 +2111,12 @@ COMMANDS: --notes, --scratchpad <text> Internal notes/scratchpad for the draft drafts:update [social_set_id] <draft_id> [options] Update a draft - --platform <platforms> Comma-separated platforms + --platform <platforms> Comma-separated platforms, or standalone x_article (preserves draft's existing platforms if omitted) --text <text> New post content --file, -f <path> Read content from file instead of --text + --content-markdown <markdown> New X Article markdown (requires --platform x_article) + --cover-media-id <media_id|null> Set X Article cover, or use literal null to remove it --media <media_ids> Comma-separated media IDs to attach --append, -a Append to existing thread instead of replacing --title <title> New draft title @@ -1993,17 +2160,17 @@ COMMANDS: comments:list <draft_id> [options] List comment threads on a draft --social-set-id <id> Social set (uses default if omitted) - --platform <platform> Filter by platform: x, linkedin, threads, bluesky, mastodon + --platform <platform> Filter by platform: x, linkedin, threads, bluesky, mastodon, x_article --status <status> Filter by: unresolved (default), resolved, all --limit <n> Max results (default: 10, max: 50) --offset <n> Skip first N results - comments:create <draft_id> [options] Create a new comment thread anchored on a span of a post + comments:create <draft_id> [options] Create a new comment thread anchored on a span --social-set-id <id> Social set (uses default if omitted) - --post-index <n> Zero-based index of the post to anchor on (required) - --selected-text <text> Exact substring of the post's text (required) + --post-index <n> Zero-based post index (required except for x_article) + --selected-text <text> Exact substring of post text or visible X Article text --text <text> Plain-text comment body (required) - --platform <platform> Required when draft has multiple commentable platforms + --platform <platform> Required for x_article and multi-platform drafts --occurrence <n> Zero-based occurrence when selected_text repeats (default: 0) comments:reply <draft_id> <thread_id> --text <text> @@ -2089,6 +2256,15 @@ EXAMPLES: # Create a tweet with explicit social set ID ./typefully.js drafts:create 123 --text "Hello world!" + # Create an X Article (standalone platform) + ./typefully.js drafts:create 123 --platform x_article --content-markdown "# Article Title\n\nLong-form article body..." + + # Create an X Article with a cover image + ./typefully.js drafts:create 123 --platform x_article --content-markdown "# Article Title\n\nBody..." --cover-media-id media_123 + + # Remove an X Article cover image + ./typefully.js drafts:update 123 456 --platform x_article --cover-media-id null + # Create a cross-platform post (specific platforms) ./typefully.js drafts:create --platform x,linkedin --text "Big announcement!" @@ -2155,6 +2331,9 @@ EXAMPLES: # Create a comment thread anchored on the first post ./typefully.js comments:create 456 --post-index 0 --selected-text "exciting news" --text "Tighten this — passive." + # Create a comment thread anchored on visible X Article text + ./typefully.js comments:create 456 --platform x_article --selected-text "article phrase" --text "Clarify this section." + # Reply to a thread ./typefully.js comments:reply 456 7e2a... --text "Agreed, will revise." @@ -2224,7 +2403,7 @@ const COMMANDS = { }; async function main() { - const args = process.argv.slice(2); + const args = extractGlobalArgs(process.argv.slice(2)); const command = args[0] || 'help'; const commandArgs = args.slice(1); diff --git a/tests/comments.test.js b/tests/comments.test.js index bd327d1..13dc97e 100644 --- a/tests/comments.test.js +++ b/tests/comments.test.js @@ -127,6 +127,88 @@ describe('comments', () => { server.assertNoPendingExpectations(); })); + it('comments:create supports X Article comments without post_index', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey, + }) => { + server.expect('POST', '/v2/social-sets/9/drafts/d1/comment-threads', { + assert: (req) => { + authAssertFactory(apiKey)(req); + assert.deepEqual(req.bodyJson, { + platform: 'x_article', + selected_text: 'article phrase', + text: 'Clarify this section.', + occurrence: 2, + }); + }, + json: { id: 'article-thread' }, + }); + const result = await runCli( + [ + 'comments:create', 'd1', + '--social-set-id', '9', + '--platform', 'x_article', + '--selected-text', 'article phrase', + '--text', 'Clarify this section.', + '--occurrence', '2', + ], + { 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: 'article-thread' }); + server.assertNoPendingExpectations(); + })); + + it('comments:create omits post_index for X Article comments even when post_index is 0', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey, + }) => { + server.expect('POST', '/v2/social-sets/9/drafts/d1/comment-threads', { + assert: (req) => { + authAssertFactory(apiKey)(req); + assert.deepEqual(req.bodyJson, { + platform: 'x_article', + selected_text: 'article phrase', + text: 'Clarify this section.', + }); + }, + json: { id: 'article-thread' }, + }); + const result = await runCli( + [ + 'comments:create', 'd1', + '--social-set-id', '9', + '--platform', 'x_article', + '--post-index', '0', + '--selected-text', 'article phrase', + '--text', 'Clarify this section.', + ], + { 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: 'article-thread' }); + server.assertNoPendingExpectations(); + })); + + it('comments:create rejects nonzero post_index for X Article comments', withCliHarness(async ({ + sandbox, server, baseUrl, + }) => { + const result = await runCli( + [ + 'comments:create', 'd1', + '--social-set-id', '9', + '--platform', 'x_article', + '--post-index', '1', + '--selected-text', 'article phrase', + '--text', 'Clarify this section.', + ], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: 'typ_test_key' } }, + ); + assert.equal(result.code, 1); + assert.deepEqual(parseJsonOrNull(result.stdout), { + error: '--post-index must be 0 when --platform x_article', + }); + assert.equal(server.requests.length, 0); + })); + it('comments:create errors when --text is missing', withCliHarness(async ({ sandbox, server, baseUrl, }) => { diff --git a/tests/common.test.js b/tests/common.test.js index 8a6ea9a..e762816 100644 --- a/tests/common.test.js +++ b/tests/common.test.js @@ -56,6 +56,23 @@ describe('help', () => { }); describe('global flag behavior', () => { + it('supports --api-base-url and appends /v2 when omitted', withCliHarness(async ({ server, apiKey, baseUrl, run }) => { + const rootUrl = baseUrl.replace(/\/v2$/, ''); + server.expect('GET', '/v2/social-sets/9/tags', { + assert: (req) => { + authAssertFactory(apiKey)(req); + assert.equal(req.search, '?limit=50'); + }, + json: { results: [] }, + }); + + const result = await run(['--api-base-url', rootUrl, 'tags:list', '--social-set-id', '9'], { + env: { TYPEFULLY_API_BASE: 'https://example.invalid/v2' }, + }); + + expectCliOk(result, { results: [] }); + })); + it('allows tags:list with --social-set-id and no positional social set', withCliHarness(async ({ server, apiKey, run }) => { server.expect('GET', '/v2/social-sets/9/tags', { assert: (req) => { diff --git a/tests/drafts.test.js b/tests/drafts.test.js index e393b51..3d31812 100644 --- a/tests/drafts.test.js +++ b/tests/drafts.test.js @@ -220,6 +220,118 @@ describe('drafts', () => { server.assertNoPendingExpectations(); })); + it('drafts:create --all excludes x_article from connected post platforms', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey + }) => { + server.expect('GET', '/v2/social-sets/55', { + assert: authAssertFactory(apiKey), + json: { id: '55', platforms: { x_article: {}, linkedin: {}, x: {} } }, + }); + + server.expect('POST', '/v2/social-sets/55/drafts', { + assert: (req) => { + authAssertFactory(apiKey)(req); + assert.deepEqual(Object.keys(req.bodyJson.platforms), ['x', 'linkedin']); + }, + json: { id: 'd1' }, + }); + const result = await runCli( + ['drafts:create', '55', '--all', '--text', 'Hello'], + { + cwd: sandbox.cwd, + env: { + HOME: sandbox.home, + TYPEFULLY_API_BASE: baseUrl, + TYPEFULLY_API_KEY: apiKey, + }, + } + ); + + assert.equal(result.code, 0); + server.assertNoPendingExpectations(); + })); + + it('drafts:create supports standalone X Article markdown and cover media', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey + }) => { + const markdown = '# Article Title\n\nFirst section\n---\nStill the same article body'; + server.expect('POST', '/v2/social-sets/9/drafts', { + assert: (req) => { + authAssertFactory(apiKey)(req); + assert.deepEqual(req.bodyJson, { + platforms: { + x_article: { + content_markdown: markdown, + cover_media_id: 'media-123', + }, + }, + draft_title: 'Article draft', + tags: ['longform'], + }); + }, + json: { id: 'article-1' }, + }); + const result = await runCli( + [ + 'drafts:create', + '9', + '--platform', + 'x_article', + '--content-markdown', + markdown, + '--cover-media-id', + 'media-123', + '--title', + 'Article draft', + '--tags', + 'longform', + ], + { 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: 'article-1' }); + server.assertNoPendingExpectations(); + })); + + it('create-draft alias forwards X Article content and cover flags', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey + }) => { + const markdown = '# Alias Article\n\nBody'; + server.expect('POST', '/v2/social-sets/9/drafts', { + assert: (req) => { + authAssertFactory(apiKey)(req); + assert.deepEqual(req.bodyJson, { + platforms: { + x_article: { + content_markdown: markdown, + cover_media_id: 'cover-1', + }, + }, + }); + }, + json: { id: 'article-alias' }, + }); + const result = await runCli( + [ + 'create-draft', + '--social-set-id', + '9', + '--platform', + 'x_article', + '--content-markdown', + markdown, + '--cover-media-id', + 'cover-1', + ], + { 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: 'article-alias' }); + server.assertNoPendingExpectations(); + })); + it('drafts:schedule sends publish_at payload', withCliHarness(async ({ sandbox, server, baseUrl, apiKey }) => { @@ -314,7 +426,7 @@ describe('drafts', () => { ); assert.equal(result.code, 1); assert.deepEqual(parseJsonOrNull(result.stdout), { - error: 'At least one of --text, --file, --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, or --force-overwrite-comments is required', }); assert.equal(server.requests.length, 0); })); @@ -689,6 +801,76 @@ describe('drafts', () => { assert.deepEqual(parseJsonOrNull(result.stdout), { error: '--text or --file is required' }); })); + it('drafts:create requires X Article flags to use --platform x_article', withCliHarness(async ({ + sandbox, server + }) => { + const result = await runCli( + ['drafts:create', '9', '--content-markdown', '# Title\n\nBody'], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } } + ); + assert.equal(result.code, 1); + assert.deepEqual(parseJsonOrNull(result.stdout), { + error: 'X Article flags require --platform x_article', + }); + assert.equal(server.requests.length, 0); + })); + + it('drafts:create requires content markdown for X Article drafts', withCliHarness(async ({ + sandbox, server + }) => { + const result = await runCli( + ['drafts:create', '9', '--platform', 'x_article', '--cover-media-id', 'cover-1'], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } } + ); + assert.equal(result.code, 1); + assert.deepEqual(parseJsonOrNull(result.stdout), { + error: '--content-markdown is required for X Article drafts', + }); + assert.equal(server.requests.length, 0); + })); + + it('drafts:create rejects x_article combined with other platforms', withCliHarness(async ({ + sandbox, server + }) => { + const result = await runCli( + ['drafts:create', '9', '--platform', 'x_article,x', '--content-markdown', '# Title\n\nBody'], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } } + ); + assert.equal(result.code, 1); + assert.deepEqual(parseJsonOrNull(result.stdout), { + error: 'x_article is standalone and cannot be combined with other platforms', + }); + assert.equal(server.requests.length, 0); + })); + + it('drafts:create rejects post-only flags for X Article drafts', withCliHarness(async ({ + sandbox, server + }) => { + const result = await runCli( + ['drafts:create', '9', '--platform', 'x_article', '--content-markdown', '# Title\n\nBody', '--media', 'm1'], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } } + ); + assert.equal(result.code, 1); + assert.deepEqual(parseJsonOrNull(result.stdout), { + error: '--media cannot be used with --platform x_article', + }); + assert.equal(server.requests.length, 0); + })); + + it('drafts:update rejects post-only flags for X Article updates before reading files', withCliHarness(async ({ + sandbox, server + }) => { + const result = await runCli( + ['drafts:update', '9', 'article-1', '--platform', 'x_article', '--file', './missing.md'], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } } + ); + assert.equal(result.code, 1); + assert.deepEqual(parseJsonOrNull(result.stdout), { + error: '--file cannot be used with --platform x_article', + }); + assert.equal(server.requests.length, 0); + })); + it('drafts:update supports title/schedule/share/notes without fetching existing draft', withCliHarness(async ({ sandbox, server, baseUrl, apiKey }) => { @@ -715,6 +897,127 @@ describe('drafts', () => { assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true }); })); + it('drafts:update supports X Article content and cover without fetching existing draft', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey + }) => { + const markdown = '# Updated Article\n\nBody with --- preserved'; + server.expect('PATCH', '/v2/social-sets/9/drafts/article-1', { + assert: (req) => { + authAssertFactory(apiKey)(req); + assert.deepEqual(req.bodyJson, { + platforms: { + x_article: { + content_markdown: markdown, + cover_media_id: 'cover-2', + }, + }, + }); + }, + json: { id: 'article-1', ok: true }, + }); + const result = await runCli( + [ + 'drafts:update', + '9', + 'article-1', + '--platform', + 'x_article', + '--content-markdown', + markdown, + '--cover-media-id', + 'cover-2', + ], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } } + ); + + assert.equal(result.code, 0); + assert.equal(server.requests.length, 1); + assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'article-1', ok: true }); + server.assertNoPendingExpectations(); + })); + + it('drafts:update supports X Article cover-only update and removal with null', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey + }) => { + server.expect('PATCH', '/v2/social-sets/9/drafts/article-1', { + assert: (req) => { + authAssertFactory(apiKey)(req); + assert.deepEqual(req.bodyJson, { + platforms: { + x_article: { + cover_media_id: 'cover-3', + }, + }, + }); + }, + json: { id: 'article-1', ok: true }, + }); + + server.expect('PATCH', '/v2/social-sets/9/drafts/article-1', { + assert: (req) => { + authAssertFactory(apiKey)(req); + assert.deepEqual(req.bodyJson, { + platforms: { + x_article: { + cover_media_id: null, + }, + }, + }); + }, + json: { id: 'article-1', ok: true }, + }); + + const setCover = await runCli( + ['drafts:update', '9', 'article-1', '--platform', 'x_article', '--cover-media-id', 'cover-3'], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } } + ); + assert.equal(setCover.code, 0); + + const removeCover = await runCli( + ['drafts:update', '9', 'article-1', '--platform', 'x_article', '--cover-media-id', 'null'], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } } + ); + assert.equal(removeCover.code, 0); + assert.equal(server.requests.length, 2); + server.assertNoPendingExpectations(); + })); + + it('update-draft alias forwards X Article update flags', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey + }) => { + const markdown = '# Alias Update\n\nBody'; + server.expect('PATCH', '/v2/social-sets/9/drafts/article-1', { + assert: (req) => { + authAssertFactory(apiKey)(req); + assert.deepEqual(req.bodyJson, { + platforms: { + x_article: { + content_markdown: markdown, + }, + }, + }); + }, + json: { id: 'article-1', ok: true }, + }); + const result = await runCli( + [ + 'update-draft', + 'article-1', + '--social-set-id', + '9', + '--platform', + 'x_article', + '--content-markdown', + markdown, + ], + { 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: 'article-1', ok: true }); + server.assertNoPendingExpectations(); + })); + it('drafts:update reads from file via -f and can target multiple platforms', withCliHarness(async ({ sandbox, server, baseUrl, apiKey }) => {