Files
Francesco Di Lorenzo 165581bef4 feat(typefully): support X Articles and reference guides (#26)
Add first-class X Article support to the Typefully skill and CLI while
keeping articles inside the normal draft lifecycle, and split detailed
Typefully guidance into on-demand reference guides.

## Summary
- Add `drafts:create` and `drafts:update` support for `--platform
x_article` with article-specific `--content-markdown` and
`--cover-media-id` flags.
- Enforce X Article constraints: standalone platform only, no post-only
flags, `--all` remains limited to post platforms, and `--cover-media-id
null` removes the cover.
- Update `comments:create` so article comments do not require
`--post-index`, while still rejecting non-zero article indexes.
- Move detailed article instructions into
`skills/typefully/references/platforms/x-articles.md`, with payload
examples, supported markdown, embeds, cover handling, and comment
guidance.
- Split skill documentation into focused references for setup, comments,
X, LinkedIn, X Articles, and local development while keeping `SKILL.md`
concise.
- Document the dev-only `--api-base-url` override and cover it with a
focused test.

## Validation
- [x] `node --check skills/typefully/scripts/typefully.js`
- [x] `npm run test:drafts`
- [x] `npm run test:all`
- [x] `git diff --check`
- [x] `git diff --cached --check`

## Manual QA
- [ ] Create an X Article draft with markdown and a cover image.
- [ ] Update an X Article cover with `--cover-media-id null`.
- [ ] Create an X Article comment without `--post-index`.

## After Merge
No action required.
2026-07-07 12:00:29 +01:00

370 lines
14 KiB
JavaScript

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 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,
}) => {
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);
}));
});