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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions skills/typefully/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable user-facing changes to the Typefully skill and its CLI are documente

The format is based on Keep a Changelog.

## 2026-08-05

### Added

- Planned drafts: `--plan <iso|next-free-slot>` on `drafts:create` and `drafts:update` puts a draft on the queue/calendar at a date **without arming auto-publish** (mutually exclusive with `--schedule`). New `drafts:plan <draft_id> --time <...>` command mirrors `drafts:schedule`. Confirm a plan into a real schedule with `drafts:schedule`, publish it with `drafts:publish`, or clear it with `drafts:update --plan null`. Filter with `drafts:list --status planned`. A planned draft whose date has passed is not overdue and not a failure — replan or confirm it.

## 2026-07-29

### Added
Expand Down
14 changes: 10 additions & 4 deletions skills/typefully/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,13 @@ To publish the URL as plain text with no card, pass `--hide-link-preview`. Suppr
./scripts/typefully.js drafts:create --text "..." --schedule next-free-slot # or an ISO time, or "now"
./scripts/typefully.js drafts:schedule <draft_id> --time next-free-slot --use-default
./scripts/typefully.js drafts:publish <draft_id> --use-default
./scripts/typefully.js drafts:create --text "..." --plan next-free-slot # planned: dated but inert
./scripts/typefully.js drafts:plan <draft_id> --time next-free-slot --use-default
```

- `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.
- **Planned drafts** sit on the queue/calendar at their date but never auto-publish. Confirm one into a real schedule with `drafts:schedule` (or publish it with `drafts:publish`). A planned draft whose date has passed is **not overdue and not a failure** — it simply hasn't been confirmed; replan or confirm it. `--plan`/`--schedule` are mutually exclusive; `drafts:update --plan null` returns the draft to plain draft status.
- Single-arg commands require `--use-default` when a default social set is configured (see the [safety note](#commands) below).

---
Expand All @@ -146,6 +149,7 @@ To publish the URL as plain text with no card, pass `--hide-link-preview`. Suppr
| "What's scheduled?" / "Recent posts?" | `drafts:list --status scheduled` / `--status published` |
| "Schedule this for tomorrow" | `drafts:create --text "..." --schedule "<ISO time>"` |
| "Post this now" | `drafts:create --text "..." --schedule now` or `drafts:publish <id> --use-default` |
| "Pencil this in for Tuesday" (no commitment to publish) | `drafts:create --text "..." --plan "<ISO time>"`, confirm later with `drafts:schedule` |
| "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) |
Expand All @@ -159,7 +163,7 @@ To publish the URL as plain text with no card, pass `--hide-link-preview`. Suppr

All commands output JSON. Every `[social_set_id]` is optional and falls back to the configured default.

> **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.
> **Safety note**: `drafts:get`, `drafts:update`, `drafts:delete`, `drafts:schedule`, `drafts:plan`, and `drafts:publish` require `--use-default` when you pass a single argument (the draft_id) while a default social set is configured.

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).

Expand Down Expand Up @@ -198,6 +202,7 @@ Add any of these flags to a `drafts:create` or `drafts:update` command. The **Ap
| `--scratchpad "<notes>"` | Attach internal notes (see [Scratchpad notes](#scratchpad-notes)) | create, update |
| `--share` | Generate a public share URL | create, update |
| `--schedule <iso\|next-free-slot\|now>` | Schedule or reschedule the draft | create, update |
| `--plan <iso\|next-free-slot>` | Plan the draft: dated but inert until confirmed (mutually exclusive with `--schedule`; `null` on update returns it to plain draft) | create, update |
| `--hide-link-preview` | Suppress the link-preview card (LinkedIn/Threads/Substack only — see [Link previews](#link-previews)) | create, update |
| `--exclude-comment-markers` | Render response without anchors (display only; validation still applies) | update |
| `--force-overwrite-comments` | Destructive last resort — see [`comments.md`](references/comments.md) | update |
Expand All @@ -217,17 +222,18 @@ Single-arg forms require `--use-default` when a default social set is configured

| Command | Description |
|---------|-------------|
| `drafts:schedule <social_set_id> <draft_id> --time <iso\|next-free-slot>` | Schedule to a time or the next available slot |
| `drafts:schedule <social_set_id> <draft_id> --time <iso\|next-free-slot>` | Schedule to a time or the next available slot (also confirms a planned draft) |
| `drafts:plan <social_set_id> <draft_id> --time <iso\|next-free-slot>` | Plan to a date without arming auto-publish; confirm later with `drafts:schedule` |
| `drafts:publish <social_set_id> <draft_id>` | Publish immediately |
| `drafts:delete <social_set_id> <draft_id>` | Delete a draft |

### Queue

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.
The queue is a **social-set-specific timeline**: free queue slots (from the social set's queue schedule) plus scheduled and planned 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 <YYYY-MM-DD> --end-date <YYYY-MM-DD>` | Queue timeline: free slots + scheduled drafts/posts in a date range |
| `queue:get [social_set_id] --start-date <YYYY-MM-DD> --end-date <YYYY-MM-DD>` | Queue timeline: free slots + scheduled/planned drafts and posts in a date range (check each draft's `status` — planned drafts never auto-publish) |
| `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) |

Expand Down
59 changes: 56 additions & 3 deletions skills/typefully/scripts/typefully.js
Original file line number Diff line number Diff line change
Expand Up @@ -1396,10 +1396,18 @@ async function cmdDraftsCreate(args) {
body.draft_title = parsed.title;
}

if (parsed.schedule && parsed.plan) {
error('--schedule and --plan are mutually exclusive - provide only one');
}

if (parsed.schedule) {
body.publish_at = parsed.schedule;
}

if (parsed.plan) {
body.plan_at = parsed.plan;
}

if (Object.prototype.hasOwnProperty.call(parsed, 'tags')) {
body.tags = parseCsvArg(parsed.tags, '--tags');
}
Expand Down Expand Up @@ -1596,10 +1604,19 @@ async function cmdDraftsUpdate(args) {
body.draft_title = parsed.title;
}

if (parsed.schedule && parsed.plan) {
error('--schedule and --plan are mutually exclusive - provide only one');
}

if (parsed.schedule) {
body.publish_at = parsed.schedule;
}

if (parsed.plan) {
// Literal null returns a planned/scheduled draft to plain draft status
body.plan_at = parsed.plan === 'null' ? null : parsed.plan;
}

if (parsed.share) {
body.share = true;
}
Expand All @@ -1617,7 +1634,7 @@ async function cmdDraftsUpdate(args) {
}

if (Object.keys(body).length === 0) {
error('At least one of --text, --file, --content-markdown, --cover-media-id, --title, --schedule, --share, --notes, --tags, --quote-post-url, --paid-partnership, --made-with-ai, --hide-link-preview, or --force-overwrite-comments is required');
error('At least one of --text, --file, --content-markdown, --cover-media-id, --title, --schedule, --plan, --share, --notes, --tags, --quote-post-url, --paid-partnership, --made-with-ai, --hide-link-preview, or --force-overwrite-comments is required');
}

const params = new URLSearchParams();
Expand Down Expand Up @@ -1764,6 +1781,21 @@ async function cmdDraftsSchedule(args) {
output(data);
}

async function cmdDraftsPlan(args) {
const parsed = parseArgs(args, { 'use-default': 'boolean' });
// Require explicit --use-default when using default with single arg
const { socialSetId, draftId } = resolveDraftTargetFromParsed(parsed, 'drafts:plan');

if (!parsed.time) {
error('--time is required (use "next-free-slot" or a future ISO datetime)');
}

const data = await apiRequest('PATCH', `/social-sets/${socialSetId}/drafts/${draftId}`, {
plan_at: parsed.time,
});
output(data);
}

async function cmdDraftsPublish(args) {
const parsed = parseArgs(args, { 'use-default': 'boolean' });
// Destructive operation - require explicit --use-default when using default with single arg
Expand Down Expand Up @@ -2187,7 +2219,7 @@ COMMANDS:
Also accepts: --end_date

drafts:list [social_set_id] [options] List drafts (uses default if ID omitted)
--status <status> Filter by: draft, scheduled, published, error, publishing
--status <status> Filter by: draft, planned, scheduled, published, error, publishing
--tag <tag_slug> Filter by tag slug
--sort <order> Sort by: created_at, -created_at, updated_at, -updated_at,
scheduled_date, -scheduled_date, published_at, -published_at
Expand All @@ -2213,6 +2245,9 @@ COMMANDS:
--media <media_ids> Comma-separated media IDs to attach
--title <title> Draft title (internal only)
--schedule <time> "now", "next-free-slot", or ISO datetime
--plan <time> "next-free-slot" or future ISO datetime. Plans the
draft: dated but inert until confirmed (mutually
exclusive with --schedule)
--tags <tag_slugs> Comma-separated tag slugs
--reply-to <url> URL of X post to reply to
--community <id> X community ID to post to
Expand All @@ -2235,6 +2270,10 @@ COMMANDS:
--append, -a Append to existing thread instead of replacing
--title <title> New draft title
--schedule <time> "now", "next-free-slot", or ISO datetime
--plan <time|null> "next-free-slot" or future ISO datetime. Plans or
replans the draft (dated but inert, mutually exclusive
with --schedule); literal null returns a planned or
scheduled draft to plain draft status
--tags <tag_slugs> Comma-separated tag slugs
--quote-post-url, --quote-url <url> Quote an X post URL (X only)
--paid-partnership, --paid_partnership Label X posts as paid partnership
Expand Down Expand Up @@ -2263,6 +2302,10 @@ COMMANDS:
--time <time> "next-free-slot" or ISO datetime (required)
--use-default Required when using default social set with single arg

drafts:plan <social_set_id> <draft_id> [options] Plan a draft (dated but inert until confirmed)
--time <time> "next-free-slot" or future ISO datetime (required)
--use-default Required when using default social set with single arg

drafts:publish <social_set_id> <draft_id> Publish a draft immediately
--use-default Required when using default social set with single arg

Expand Down Expand Up @@ -2397,7 +2440,16 @@ EXAMPLES:
./typefully.js drafts:create 123 --platform x --text "Scheduled post" --schedule next-free-slot

# Schedule for specific time
./typefully.js drafts:create 123 --platform x --text "Timed post" --schedule "2025-01-20T14:00:00Z"
./typefully.js drafts:create 123 --platform x --text "Timed post" --schedule "2027-01-20T14:00:00Z"

# Plan a draft: on the queue/calendar but inert until confirmed
./typefully.js drafts:create 123 --platform x --text "Pencil this in" --plan next-free-slot

# Confirm a planned draft into a real schedule
./typefully.js drafts:schedule 123 456 --time "2027-01-20T14:00:00Z"

# List planned drafts sorted by date
./typefully.js drafts:list 123 --status planned --sort scheduled_date

# List scheduled drafts sorted by date
./typefully.js drafts:list 123 --status scheduled --sort scheduled_date
Expand Down Expand Up @@ -2497,6 +2549,7 @@ const COMMANDS = {
'update-draft': cmdUpdateDraftAlias,
'drafts:delete': cmdDraftsDelete,
'drafts:schedule': cmdDraftsSchedule,
'drafts:plan': cmdDraftsPlan,
'drafts:publish': cmdDraftsPublish,
'queue:get': cmdQueueGet,
'queue:schedule:get': cmdQueueScheduleGet,
Expand Down
120 changes: 119 additions & 1 deletion tests/drafts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,124 @@ describe('drafts', () => {
server.assertNoPendingExpectations();
}));

it('drafts:plan sends plan_at payload', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, { plan_at: 'next-free-slot' });
},
json: { id: 'd1', status: 'planned' },
});
const result = await runCli(
['drafts:plan', '9', 'd1', '--time', 'next-free-slot'],
{
cwd: sandbox.cwd,
env: {
HOME: sandbox.home,
TYPEFULLY_API_BASE: baseUrl,
TYPEFULLY_API_KEY: apiKey,
},
}
);

assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', status: 'planned' });
server.assertNoPendingExpectations();
}));

it('drafts:plan requires --time', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
const result = await runCli(
['drafts:plan', '9', 'd1'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);

assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), {
error: '--time is required (use "next-free-slot" or a future ISO datetime)',
});
assert.equal(server.requests.length, 0);
}));

it('drafts:create sends plan_at payload with --plan', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('POST', '/v2/social-sets/9/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.equal(req.bodyJson.plan_at, 'next-free-slot');
assert.ok(!('publish_at' in req.bodyJson));
},
json: { id: 'd1', status: 'planned' },
});
const result = await runCli(
['drafts:create', '9', '--platform', 'x', '--text', 'Pencil this in', '--plan', 'next-free-slot'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);

assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', status: 'planned' });
server.assertNoPendingExpectations();
}));

it('drafts:create rejects --schedule combined with --plan', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
const result = await runCli(
['drafts:create', '9', '--platform', 'x', '--text', 'Hello', '--schedule', 'now', '--plan', 'next-free-slot'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);

assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), {
error: '--schedule and --plan are mutually exclusive - provide only one',
});
assert.equal(server.requests.length, 0);
}));

it('drafts:update sends plan_at payload with --plan', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, { plan_at: '2027-01-20T14:00:00Z' });
},
json: { id: 'd1', status: 'planned' },
});
const result = await runCli(
['drafts:update', '9', 'd1', '--plan', '2027-01-20T14:00:00Z'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);

assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', status: 'planned' });
server.assertNoPendingExpectations();
}));

it('drafts:update --plan null clears the plan (sends plan_at: null)', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, { plan_at: null });
},
json: { id: 'd1', status: 'draft' },
});
const result = await runCli(
['drafts:update', '9', 'd1', '--plan', 'null'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);

assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', status: 'draft' });
server.assertNoPendingExpectations();
}));

it('drafts:list builds query params (status/tag/sort/limit)', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
Expand Down Expand Up @@ -513,7 +631,7 @@ describe('drafts', () => {
);
assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), {
error: 'At least one of --text, --file, --content-markdown, --cover-media-id, --title, --schedule, --share, --notes, --tags, --quote-post-url, --paid-partnership, --made-with-ai, --hide-link-preview, or --force-overwrite-comments is required',
error: 'At least one of --text, --file, --content-markdown, --cover-media-id, --title, --schedule, --plan, --share, --notes, --tags, --quote-post-url, --paid-partnership, --made-with-ai, --hide-link-preview, or --force-overwrite-comments is required',
});
assert.equal(server.requests.length, 0);
}));
Expand Down
Loading