diff --git a/skills/typefully/CHANGELOG.md b/skills/typefully/CHANGELOG.md index ab7b8d4..b1509d6 100644 --- a/skills/typefully/CHANGELOG.md +++ b/skills/typefully/CHANGELOG.md @@ -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 ` 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 --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 diff --git a/skills/typefully/SKILL.md b/skills/typefully/SKILL.md index 4041e32..ef4e0fc 100644 --- a/skills/typefully/SKILL.md +++ b/skills/typefully/SKILL.md @@ -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 --time next-free-slot --use-default ./scripts/typefully.js drafts:publish --use-default +./scripts/typefully.js drafts:create --text "..." --plan next-free-slot # planned: dated but inert +./scripts/typefully.js drafts:plan --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). --- @@ -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 ""` | | "Post this now" | `drafts:create --text "..." --schedule now` or `drafts:publish --use-default` | +| "Pencil this in for Tuesday" (no commitment to publish) | `drafts:create --text "..." --plan ""`, 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) | @@ -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). @@ -198,6 +202,7 @@ Add any of these flags to a `drafts:create` or `drafts:update` command. The **Ap | `--scratchpad ""` | Attach internal notes (see [Scratchpad notes](#scratchpad-notes)) | create, update | | `--share` | Generate a public share URL | create, update | | `--schedule ` | Schedule or reschedule the draft | create, update | +| `--plan ` | 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 | @@ -217,17 +222,18 @@ Single-arg forms require `--use-default` when a default social set is configured | Command | Description | |---------|-------------| -| `drafts:schedule --time ` | Schedule to a time or the next available slot | +| `drafts:schedule --time ` | Schedule to a time or the next available slot (also confirms a planned draft) | +| `drafts:plan --time ` | Plan to a date without arming auto-publish; confirm later with `drafts:schedule` | | `drafts:publish ` | Publish immediately | | `drafts:delete ` | 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 --end-date ` | Queue timeline: free slots + scheduled drafts/posts in a date range | +| `queue:get [social_set_id] --start-date --end-date ` | 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) | diff --git a/skills/typefully/scripts/typefully.js b/skills/typefully/scripts/typefully.js index aa75320..10f0a67 100755 --- a/skills/typefully/scripts/typefully.js +++ b/skills/typefully/scripts/typefully.js @@ -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'); } @@ -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; } @@ -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(); @@ -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 @@ -2187,7 +2219,7 @@ COMMANDS: Also accepts: --end_date drafts:list [social_set_id] [options] List drafts (uses default if ID omitted) - --status Filter by: draft, scheduled, published, error, publishing + --status Filter by: draft, planned, scheduled, published, error, publishing --tag Filter by tag slug --sort Sort by: created_at, -created_at, updated_at, -updated_at, scheduled_date, -scheduled_date, published_at, -published_at @@ -2213,6 +2245,9 @@ COMMANDS: --media Comma-separated media IDs to attach --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 @@ -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 @@ -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 @@ -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 @@ -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, diff --git a/tests/drafts.test.js b/tests/drafts.test.js index d811498..a95129a 100644 --- a/tests/drafts.test.js +++ b/tests/drafts.test.js @@ -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 }) => { @@ -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); }));