From 5cc444525c2891b85cd18f9d0306a79550eebe74 Mon Sep 17 00:00:00 2001 From: Rajat Kapoor Date: Tue, 5 May 2026 12:43:36 +0530 Subject: [PATCH] feat(typefully): add comment-thread CRUD and comment-marker flags Adds six `comments:*` subcommands (list, create, reply, resolve, update, delete) for managing per-draft comment threads, plus `--exclude-comment-markers` on `drafts:get`/`drafts:update` for marker-free display rendering and `--force-overwrite-comments` on `drafts:update` to intentionally drop threads when submitting text without their markers. Includes a new `tests/comments.test.js` covering all six commands and four new `drafts.test.js` cases exercising the marker-handling flags. --- skills/typefully/CHANGELOG.md | 10 + skills/typefully/SKILL.md | 45 +++- skills/typefully/scripts/typefully.js | 265 +++++++++++++++++++++++- tests/comments.test.js | 287 ++++++++++++++++++++++++++ tests/drafts.test.js | 95 ++++++++- 5 files changed, 695 insertions(+), 7 deletions(-) create mode 100644 tests/comments.test.js diff --git a/skills/typefully/CHANGELOG.md b/skills/typefully/CHANGELOG.md index 67c8f00..cdde431 100644 --- a/skills/typefully/CHANGELOG.md +++ b/skills/typefully/CHANGELOG.md @@ -8,6 +8,16 @@ The format is based on Keep a Changelog. ### Added +- 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`. + - `comments:reply --text "..."` — append a comment to an existing thread. + - `comments:resolve ` — resolve a thread (also strips its markers from the post text). + - `comments:update --text "..."` — edit a comment's text (comment-author only). + - `comments:delete [comment_id]` — delete the whole thread, or a single comment within it. +- `--exclude-comment-markers` (alias: `--exclude_comment_markers`) on `drafts:get` and `drafts:update` to render `posts[*].text` without inline `` markers (read-only / display use; round-trip back to `drafts:update` will lose comment anchors). +- `--force-overwrite-comments` (alias: `--force_overwrite_comments`) on `drafts:update` to accept submitted text whose markers don't cover every stored comment thread; missing threads are resolved server-side and their anchors stripped. +- SKILL docs cover the comment-thread workflow, marker round-trip rules, and when to use the new flags. - `analytics:followers:get [social_set_id]` to fetch X follower analytics, with optional `--start-date` / `--end-date` date filters and snake_case aliases. - `analytics:posts:list` now supports `--include-replies` (alias: `--include_replies`) to opt in to X reply posts. - `--paid-partnership` / `--paid_partnership` and `--made-with-ai` / `--made_with_ai` for X draft create/update disclosure flags. diff --git a/skills/typefully/SKILL.md b/skills/typefully/SKILL.md index 601a0ad..a0bd016 100644 --- a/skills/typefully/SKILL.md +++ b/skills/typefully/SKILL.md @@ -4,7 +4,7 @@ description: > Create, schedule, and manage social media posts via Typefully. ALWAYS use this skill when asked to draft, schedule, post, or check tweets, posts, threads, or social media content for Twitter/X, LinkedIn, Threads, Bluesky, or Mastodon. -last-updated: 2026-04-24 +last-updated: 2026-05-05 allowed-tools: Bash(./scripts/typefully.js:*) --- @@ -123,6 +123,11 @@ When determining which social set to use: | "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 @@ -227,6 +232,39 @@ Then include that `mention_text` in your LinkedIn draft text: ./scripts/typefully.js drafts:create --platform linkedin --text "Thanks @[Typefully](urn:li:organization:86779668) for the support." ``` +## Comments on Drafts + +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 + +`--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. + +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. + +| 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 | + +`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. + ## Commands Reference ### User & Social Sets @@ -277,10 +315,13 @@ Follower analytics returns `current_followers_count` plus daily `data` points wi 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. +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). + | 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 | @@ -301,6 +342,8 @@ All drafts commands support an optional `[social_set_id]` - if omitted, the conf | `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 diff --git a/skills/typefully/scripts/typefully.js b/skills/typefully/scripts/typefully.js index 935decf..de46315 100755 --- a/skills/typefully/scripts/typefully.js +++ b/skills/typefully/scripts/typefully.js @@ -1037,10 +1037,23 @@ async function cmdDraftsList(args) { } async function cmdDraftsGet(args) { - const parsed = parseArgs(args, { 'use-default': 'boolean' }); + const parsed = parseArgs(args, { + 'use-default': 'boolean', + 'exclude-comment-markers': 'boolean', + exclude_comment_markers: 'boolean', + }); const { socialSetId, draftId } = resolveDraftTargetFromParsed(parsed, 'drafts:get'); - const data = await apiRequest('GET', `/social-sets/${socialSetId}/drafts/${draftId}`); + const params = new URLSearchParams(); + if (parsed['exclude-comment-markers'] || parsed.exclude_comment_markers) { + params.set('exclude_comment_markers', 'true'); + } + const qs = params.toString(); + const url = qs + ? `/social-sets/${socialSetId}/drafts/${draftId}?${qs}` + : `/social-sets/${socialSetId}/drafts/${draftId}`; + + const data = await apiRequest('GET', url); output(data); } @@ -1208,6 +1221,10 @@ async function cmdDraftsUpdate(args) { paid_partnership: 'boolean', 'made-with-ai': 'boolean', made_with_ai: 'boolean', + 'exclude-comment-markers': 'boolean', + exclude_comment_markers: 'boolean', + 'force-overwrite-comments': 'boolean', + force_overwrite_comments: 'boolean', }); const { socialSetId, draftId } = resolveDraftTargetFromParsed(parsed, 'drafts:update'); const quotePostUrl = getQuotePostUrlFromParsed(parsed); @@ -1345,11 +1362,24 @@ async function cmdDraftsUpdate(args) { body.tags = parseCsvArg(parsed.tags, '--tags'); } + if (parsed['force-overwrite-comments'] || parsed.force_overwrite_comments) { + body.force_overwrite_comments = true; + } + if (Object.keys(body).length === 0) { - error('At least one of --text, --file, --title, --schedule, --share, --notes, --tags, --quote-post-url, --paid-partnership, or --made-with-ai is required'); + 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'); } - const data = await apiRequest('PATCH', `/social-sets/${socialSetId}/drafts/${draftId}`, body); + const params = new URLSearchParams(); + if (parsed['exclude-comment-markers'] || parsed.exclude_comment_markers) { + params.set('exclude_comment_markers', 'true'); + } + const qs = params.toString(); + const url = qs + ? `/social-sets/${socialSetId}/drafts/${draftId}?${qs}` + : `/social-sets/${socialSetId}/drafts/${draftId}`; + + const data = await apiRequest('PATCH', url, body); output(data); } @@ -1485,6 +1515,165 @@ async function cmdDraftsPublish(args) { output(data); } +// --------------------------------------------------------------------------- +// Comments (per-draft comment threads) +// --------------------------------------------------------------------------- + +function requireDraftIdPositional(parsed, commandName) { + const positional = parsed._positional; + if (positional.length === 0) { + error(`draft_id is required`, { + hint: `Usage: typefully.js ${commandName} [--social-set-id ]`, + }); + } + const socialSetId = requireSocialSetId(getSocialSetIdFromParsed(parsed)); + return { socialSetId, draftId: positional[0] }; +} + +function requireThreadPositional(parsed, commandName) { + const positional = parsed._positional; + if (positional.length < 2) { + error('draft_id and thread_id are required', { + hint: `Usage: typefully.js ${commandName} [--social-set-id ]`, + }); + } + const socialSetId = requireSocialSetId(getSocialSetIdFromParsed(parsed)); + return { socialSetId, draftId: positional[0], threadId: positional[1] }; +} + +function requireCommentPositional(parsed, commandName) { + const positional = parsed._positional; + if (positional.length < 3) { + error('draft_id, thread_id, and comment_id are required', { + hint: `Usage: typefully.js ${commandName} [--social-set-id ]`, + }); + } + const socialSetId = requireSocialSetId(getSocialSetIdFromParsed(parsed)); + return { + socialSetId, + draftId: positional[0], + threadId: positional[1], + commentId: positional[2], + }; +} + +async function cmdCommentsList(args) { + const parsed = parseArgs(args); + const { socialSetId, draftId } = requireDraftIdPositional(parsed, 'comments:list'); + + const params = new URLSearchParams(); + if (parsed.platform) params.set('platform', parsed.platform); + if (parsed.status) params.set('status', parsed.status); + params.set('limit', parsed.limit || '10'); + if (parsed.offset) params.set('offset', parsed.offset); + + const data = await apiRequest( + 'GET', + `/social-sets/${socialSetId}/drafts/${draftId}/comment-threads?${params}`, + ); + output(data); +} + +async function cmdCommentsCreate(args) { + const parsed = parseArgs(args); + const { socialSetId, draftId } = requireDraftIdPositional(parsed, 'comments:create'); + + 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 (Object.prototype.hasOwnProperty.call(parsed, 'occurrence')) { + const occurrence = Number.parseInt(parsed.occurrence, 10); + if (!Number.isInteger(occurrence) || occurrence < 0) { + error('--occurrence must be a non-negative integer'); + } + body.occurrence = occurrence; + } + + const data = await apiRequest( + 'POST', + `/social-sets/${socialSetId}/drafts/${draftId}/comment-threads`, + body, + ); + output(data); +} + +async function cmdCommentsReply(args) { + const parsed = parseArgs(args); + const { socialSetId, draftId, threadId } = requireThreadPositional(parsed, 'comments:reply'); + const text = getRequiredStringArgFromParsed(parsed, 'text'); + + const data = await apiRequest( + 'POST', + `/social-sets/${socialSetId}/drafts/${draftId}/comment-threads/${threadId}/comments`, + { text }, + ); + output(data); +} + +async function cmdCommentsResolve(args) { + const parsed = parseArgs(args); + const { socialSetId, draftId, threadId } = requireThreadPositional(parsed, 'comments:resolve'); + + const data = await apiRequest( + 'POST', + `/social-sets/${socialSetId}/drafts/${draftId}/comment-threads/${threadId}/resolve`, + ); + output(data); +} + +async function cmdCommentsUpdate(args) { + const parsed = parseArgs(args); + const { socialSetId, draftId, threadId, commentId } = requireCommentPositional( + parsed, + 'comments:update', + ); + const text = getRequiredStringArgFromParsed(parsed, 'text'); + + const data = await apiRequest( + 'PATCH', + `/social-sets/${socialSetId}/drafts/${draftId}/comment-threads/${threadId}/comments/${commentId}`, + { text }, + ); + output(data); +} + +async function cmdCommentsDelete(args) { + const parsed = parseArgs(args, { 'use-default': 'boolean' }); + const positional = parsed._positional; + + if (positional.length < 2) { + error('draft_id and thread_id are required', { + hint: 'Usage: typefully.js comments:delete [comment_id] [--social-set-id ]', + }); + } + const socialSetId = requireSocialSetId(getSocialSetIdFromParsed(parsed)); + const draftId = positional[0]; + const threadId = positional[1]; + const commentId = positional[2] || null; + + const url = commentId + ? `/social-sets/${socialSetId}/drafts/${draftId}/comment-threads/${threadId}/comments/${commentId}` + : `/social-sets/${socialSetId}/drafts/${draftId}/comment-threads/${threadId}`; + + await apiRequest('DELETE', url); + output({ + success: true, + message: commentId ? 'Comment deleted' : 'Comment thread deleted', + }); +} + async function cmdQueueGet(args) { const parsed = parseArgs(args); const socialSetId = resolveSocialSetIdFromParsed(parsed, parsed._positional[0]); @@ -1732,6 +1921,10 @@ COMMANDS: --limit Max results (default: 10, max: 50) drafts:get [social_set_id] Get a specific draft + --exclude-comment-markers Render posts[*].text as plain text without + markers (read-only display use). + Round-tripping the result back to PATCH will lose + comment anchors. Also accepts: --exclude_comment_markers --use-default Required when using default social set with single arg drafts:create [social_set_id] [options] Create a new draft (uses default if ID omitted) @@ -1767,6 +1960,14 @@ COMMANDS: --made-with-ai, --made_with_ai Label X posts as made with AI --share Generate a public share URL for the draft --notes, --scratchpad Internal notes/scratchpad for the draft + --exclude-comment-markers Render response posts[*].text without + markers (display only). + Also accepts: --exclude_comment_markers + --force-overwrite-comments Destructive last resort: resolves every thread whose + anchor is missing from submitted text, including + unrelated threads. Agents must list affected threads + and get explicit user confirmation before using it. + Also accepts: --force_overwrite_comments --use-default Required when using default social set with single arg create-draft [options] Alias for drafts:create (positional text + --social-set-id) @@ -1790,6 +1991,38 @@ COMMANDS: Replace queue schedule rules (uses default if ID omitted) Rule shape: [{"h":9,"m":30,"days":["mon","wed","fri"]}] + comments:list [options] List comment threads on a draft + --social-set-id Social set (uses default if omitted) + --platform Filter by platform: x, linkedin, threads, bluesky, mastodon + --status Filter by: unresolved (default), resolved, all + --limit Max results (default: 10, max: 50) + --offset Skip first N results + + comments:create [options] Create a new comment thread anchored on a span of a post + --social-set-id Social set (uses default if omitted) + --post-index Zero-based index of the post to anchor on (required) + --selected-text Exact substring of the post's text (required) + --text Plain-text comment body (required) + --platform Required when draft has multiple commentable platforms + --occurrence Zero-based occurrence when selected_text repeats (default: 0) + + comments:reply --text + Add a comment to an existing thread + --social-set-id Social set (uses default if omitted) + + comments:resolve Resolve a comment thread (strips its markers from text) + --social-set-id Social set (uses default if omitted) + + comments:update --text + Update a comment's text (author only) + --social-set-id Social set (uses default if omitted) + + comments:delete [comment_id] + Delete a thread, or a single comment within it. + If comment_id is the root, the entire thread (and + its markers) is deleted. + --social-set-id Social set (uses default if omitted) + tags:list [social_set_id] List all tags (uses default if ID omitted) tags:create [social_set_id] --name Create a new tag (uses default if ID omitted) @@ -1916,6 +2149,24 @@ EXAMPLES: # Create draft with share URL ./typefully.js drafts:create 123 --platform x --text "Check this out" --share + # List unresolved comment threads on a draft + ./typefully.js comments:list 456 + + # 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." + + # Reply to a thread + ./typefully.js comments:reply 456 7e2a... --text "Agreed, will revise." + + # Resolve a thread (also strips its markers from posts[*].text) + ./typefully.js comments:resolve 456 7e2a... + + # Delete the whole thread (comment_id omitted) + ./typefully.js comments:delete 456 7e2a... + + # Get a draft as plain text (no markers) for LLM context / exports + ./typefully.js drafts:get 456 --exclude-comment-markers + # Upload media and create post with it ./typefully.js media:upload 123 ./image.jpg # Returns: {"media_id": "abc-123", "status": "ready", "message": "Media uploaded and ready to use"} @@ -1955,6 +2206,12 @@ const COMMANDS = { 'queue:get': cmdQueueGet, 'queue:schedule:get': cmdQueueScheduleGet, 'queue:schedule:put': cmdQueueSchedulePut, + 'comments:list': cmdCommentsList, + 'comments:create': cmdCommentsCreate, + 'comments:reply': cmdCommentsReply, + 'comments:resolve': cmdCommentsResolve, + 'comments:update': cmdCommentsUpdate, + 'comments:delete': cmdCommentsDelete, 'tags:list': cmdTagsList, 'tags:create': cmdTagsCreate, 'media:upload': cmdMediaUpload, diff --git a/tests/comments.test.js b/tests/comments.test.js new file mode 100644 index 0000000..bd327d1 --- /dev/null +++ b/tests/comments.test.js @@ -0,0 +1,287 @@ +const { + describe, + it, + assert, + runCli, + parseJsonOrNull, + authAssertFactory, + withCliHarness, +} = require('./typefully-cli.test-helpers'); + +describe('comments', () => { + it('comments:list sends GET with default limit and no filters', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey, + }) => { + server.expect('GET', '/v2/social-sets/9/drafts/d1/comment-threads', { + assert: (req) => { + authAssertFactory(apiKey)(req); + assert.equal(req.search, '?limit=10'); + }, + json: { results: [] }, + }); + const result = await runCli( + ['comments:list', 'd1', '--social-set-id', '9'], + { 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), { results: [] }); + server.assertNoPendingExpectations(); + })); + + it('comments:list forwards platform/status/limit/offset query params', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey, + }) => { + server.expect('GET', '/v2/social-sets/9/drafts/d1/comment-threads', { + assert: (req) => { + authAssertFactory(apiKey)(req); + const q = new URL('http://x' + req.path + req.search).searchParams; + assert.equal(q.get('platform'), 'x'); + assert.equal(q.get('status'), 'open'); + assert.equal(q.get('limit'), '5'); + assert.equal(q.get('offset'), '20'); + }, + json: { results: [{ id: 't1' }] }, + }); + const result = await runCli( + ['comments:list', 'd1', '--social-set-id', '9', '--platform', 'x', '--status', 'open', '--limit', '5', '--offset', '20'], + { 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), { results: [{ id: 't1' }] }); + server.assertNoPendingExpectations(); + })); + + it('comments:list errors when draft_id positional is missing', withCliHarness(async ({ + sandbox, server, baseUrl, + }) => { + const result = await runCli( + ['comments:list', '--social-set-id', '9'], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: 'typ_test_key' } }, + ); + assert.equal(result.code, 1); + const out = parseJsonOrNull(result.stdout); + assert.equal(out?.error, 'draft_id is required'); + assert.ok(out?.hint?.includes('comments:list')); + assert.equal(server.requests.length, 0); + })); + + it('comments:create posts required fields and optional platform/occurrence', 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, { + post_index: 0, + selected_text: 'exciting news', + text: 'Tighten this — passive.', + platform: 'x', + occurrence: 1, + }); + }, + json: { id: 'thread1' }, + }); + const result = await runCli( + [ + 'comments:create', 'd1', + '--social-set-id', '9', + '--post-index', '0', + '--selected-text', 'exciting news', + '--text', 'Tighten this — passive.', + '--platform', 'x', + '--occurrence', '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: 'thread1' }); + server.assertNoPendingExpectations(); + })); + + it('comments:create omits platform/occurrence when not provided', 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, { + post_index: 2, + selected_text: 'pick', + text: 'Why this word?', + }); + }, + json: { id: 'thread2' }, + }); + const result = await runCli( + [ + 'comments:create', 'd1', + '--social-set-id', '9', + '--post-index', '2', + '--selected-text', 'pick', + '--text', 'Why this word?', + ], + { 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: 'thread2' }); + server.assertNoPendingExpectations(); + })); + + it('comments:create errors when --text is missing', withCliHarness(async ({ + sandbox, server, baseUrl, + }) => { + const result = await runCli( + ['comments:create', 'd1', '--social-set-id', '9', '--post-index', '0', '--selected-text', 'foo'], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: 'typ_test_key' } }, + ); + assert.equal(result.code, 1); + const out = parseJsonOrNull(result.stdout); + assert.equal(out?.error, '--text is required'); + assert.equal(server.requests.length, 0); + })); + + it('comments:create errors when --post-index is not a non-negative integer', withCliHarness(async ({ + sandbox, server, baseUrl, + }) => { + const result = await runCli( + ['comments:create', 'd1', '--social-set-id', '9', '--text', 'hi', '--selected-text', 'foo', '--post-index', '-1'], + { 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 a non-negative integer', + }); + assert.equal(server.requests.length, 0); + })); + + it('comments:reply posts a reply to the thread', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey, + }) => { + server.expect('POST', '/v2/social-sets/9/drafts/d1/comment-threads/t1/comments', { + assert: (req) => { + authAssertFactory(apiKey)(req); + assert.deepEqual(req.bodyJson, { text: 'Agreed, will revise.' }); + }, + json: { id: 'c2' }, + }); + const result = await runCli( + ['comments:reply', 'd1', 't1', '--social-set-id', '9', '--text', 'Agreed, will revise.'], + { 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: 'c2' }); + server.assertNoPendingExpectations(); + })); + + it('comments:reply errors when thread_id is missing', withCliHarness(async ({ + sandbox, server, baseUrl, + }) => { + const result = await runCli( + ['comments:reply', 'd1', '--social-set-id', '9', '--text', 'hi'], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: 'typ_test_key' } }, + ); + assert.equal(result.code, 1); + const out = parseJsonOrNull(result.stdout); + assert.equal(out?.error, 'draft_id and thread_id are required'); + assert.ok(out?.hint?.includes('comments:reply')); + assert.equal(server.requests.length, 0); + })); + + it('comments:resolve posts to the resolve endpoint with no body', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey, + }) => { + server.expect('POST', '/v2/social-sets/9/drafts/d1/comment-threads/t1/resolve', { + assert: (req) => { + authAssertFactory(apiKey)(req); + assert.equal(req.bodyText, ''); + }, + json: { id: 't1', status: 'resolved' }, + }); + const result = await runCli( + ['comments:resolve', 'd1', 't1', '--social-set-id', '9'], + { 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: 't1', status: 'resolved' }); + server.assertNoPendingExpectations(); + })); + + it('comments:update sends PATCH with new text', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey, + }) => { + server.expect('PATCH', '/v2/social-sets/9/drafts/d1/comment-threads/t1/comments/c1', { + assert: (req) => { + authAssertFactory(apiKey)(req); + assert.deepEqual(req.bodyJson, { text: 'Updated comment body' }); + }, + json: { id: 'c1', text: 'Updated comment body' }, + }); + const result = await runCli( + ['comments:update', 'd1', 't1', 'c1', '--social-set-id', '9', '--text', 'Updated comment body'], + { 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: 'c1', text: 'Updated comment body' }); + server.assertNoPendingExpectations(); + })); + + it('comments:update errors when comment_id positional is missing', withCliHarness(async ({ + sandbox, server, baseUrl, + }) => { + const result = await runCli( + ['comments:update', 'd1', 't1', '--social-set-id', '9', '--text', 'hi'], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: 'typ_test_key' } }, + ); + assert.equal(result.code, 1); + const out = parseJsonOrNull(result.stdout); + assert.equal(out?.error, 'draft_id, thread_id, and comment_id are required'); + assert.ok(out?.hint?.includes('comments:update')); + assert.equal(server.requests.length, 0); + })); + + it('comments:delete deletes the entire thread when no comment_id is given', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey, + }) => { + server.expect('DELETE', '/v2/social-sets/9/drafts/d1/comment-threads/t1', { + assert: authAssertFactory(apiKey), + json: {}, + }); + const result = await runCli( + ['comments:delete', 'd1', 't1', '--social-set-id', '9'], + { 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), { success: true, message: 'Comment thread deleted' }); + server.assertNoPendingExpectations(); + })); + + it('comments:delete deletes a single comment when comment_id is given', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey, + }) => { + server.expect('DELETE', '/v2/social-sets/9/drafts/d1/comment-threads/t1/comments/c1', { + assert: authAssertFactory(apiKey), + json: {}, + }); + const result = await runCli( + ['comments:delete', 'd1', 't1', 'c1', '--social-set-id', '9'], + { 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), { success: true, message: 'Comment deleted' }); + server.assertNoPendingExpectations(); + })); + + it('comments:delete errors when thread_id is missing', withCliHarness(async ({ + sandbox, server, baseUrl, + }) => { + const result = await runCli( + ['comments:delete', 'd1', '--social-set-id', '9'], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: 'typ_test_key' } }, + ); + assert.equal(result.code, 1); + const out = parseJsonOrNull(result.stdout); + assert.equal(out?.error, 'draft_id and thread_id are required'); + assert.ok(out?.hint?.includes('comments:delete')); + assert.equal(server.requests.length, 0); + })); +}); diff --git a/tests/drafts.test.js b/tests/drafts.test.js index 9058cc3..e393b51 100644 --- a/tests/drafts.test.js +++ b/tests/drafts.test.js @@ -314,7 +314,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, or --made-with-ai is required', + 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', }); assert.equal(server.requests.length, 0); })); @@ -1092,7 +1092,7 @@ describe('drafts', () => { })); it('global flag: drafts:create accepts --social-set-id (kebab-case) with no positional social set', withCliHarness(async ({ - sandbox, server, baseUrl, apiKey + sandbox, server, baseUrl, apiKey }) => { server.expect('POST', '/v2/social-sets/9/drafts', { assert: (req) => { @@ -1109,4 +1109,95 @@ describe('drafts', () => { assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' }); server.assertNoPendingExpectations(); })); + + it('drafts:get with --exclude-comment-markers adds exclude_comment_markers query param', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey, + }) => { + server.expect('GET', '/v2/social-sets/9/drafts/d1', { + assert: (req) => { + authAssertFactory(apiKey)(req); + assert.equal(req.search, '?exclude_comment_markers=true'); + }, + json: { id: 'd1', platforms: { x: { posts: [{ text: 'plain text' }] } } }, + }); + const result = await runCli( + ['drafts:get', '9', 'd1', '--exclude-comment-markers'], + { 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', + platforms: { x: { posts: [{ text: 'plain text' }] } }, + }); + server.assertNoPendingExpectations(); + })); + + it('drafts:update with only --force-overwrite-comments sends PATCH without fetching existing draft', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey, + }) => { + server.expect('PATCH', '/v2/social-sets/9/drafts/d1', { + assert: (req) => { + authAssertFactory(apiKey)(req); + assert.equal(req.search, ''); + assert.deepEqual(req.bodyJson, { force_overwrite_comments: true }); + }, + json: { id: 'd1', ok: true }, + }); + const result = await runCli( + ['drafts:update', '9', 'd1', '--force-overwrite-comments'], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } } + ); + assert.equal(result.code, 0); + assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true }); + assert.equal(server.requests.length, 1); + server.assertNoPendingExpectations(); + })); + + it('drafts:update combines --text with --force-overwrite-comments in body', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey, + }) => { + server.expect('GET', '/v2/social-sets/9/drafts/d1', { + assert: authAssertFactory(apiKey), + json: { id: 'd1', platforms: { x: { enabled: true, posts: [{ text: 'Old' }] } } }, + }); + server.expect('PATCH', '/v2/social-sets/9/drafts/d1', { + assert: (req) => { + authAssertFactory(apiKey)(req); + assert.deepEqual(req.bodyJson, { + platforms: { + x: { enabled: true, posts: [{ text: 'Rewritten body' }] }, + }, + force_overwrite_comments: true, + }); + }, + json: { id: 'd1', ok: true }, + }); + const result = await runCli( + ['drafts:update', '9', 'd1', '--text', 'Rewritten body', '--force-overwrite-comments'], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } } + ); + assert.equal(result.code, 0); + assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true }); + server.assertNoPendingExpectations(); + })); + + it('drafts:update with --exclude-comment-markers adds query string to PATCH URL', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey, + }) => { + server.expect('PATCH', '/v2/social-sets/9/drafts/d1', { + assert: (req) => { + authAssertFactory(apiKey)(req); + assert.equal(req.search, '?exclude_comment_markers=true'); + assert.deepEqual(req.bodyJson, { force_overwrite_comments: true }); + }, + json: { id: 'd1', ok: true }, + }); + const result = await runCli( + ['drafts:update', '9', 'd1', '--force-overwrite-comments', '--exclude-comment-markers'], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } } + ); + assert.equal(result.code, 0); + assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true }); + server.assertNoPendingExpectations(); + })); });