diff --git a/CLAUDE.md b/CLAUDE.md index cfb526f..e07fb89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,6 +75,15 @@ When you change anything that affects how a user runs the CLI or uses the skill Changelog entries must be **user-facing only**. Do not include internal implementation details like refactors, test/CI changes, formatting-only edits, or code organization. +### Changelog Dates + +Default to dated changelog sections, not `Unreleased`. When adding user-facing entries: + +- If a section for today's date already exists, add the new entry there. +- If no section for today's date exists, create one at the top using `YYYY-MM-DD`. +- Only create or keep an `Unreleased` section when the change is intentionally not shipping with the current skill update. +- If you find shipped entries still under `Unreleased`, move them into the date section for when they shipped before adding new entries. + ## Commit & Pull Request Guidelines - NEVER add "Co-authored with Claude" or that kind of AI-assistant plugin to commit messages or PR descriptions. diff --git a/skills/typefully/CHANGELOG.md b/skills/typefully/CHANGELOG.md index 4afa973..6610855 100644 --- a/skills/typefully/CHANGELOG.md +++ b/skills/typefully/CHANGELOG.md @@ -4,11 +4,33 @@ All notable user-facing changes to the Typefully skill and its CLI are documente The format is based on Keep a Changelog. -## [Unreleased] +## 2026-07-08 + +### Fixed + +- Invalid or expired API keys now return a clear authentication failure with setup guidance instead of a generic `HTTP 401`, including during setup and default social set configuration. +- Skill instructions now tell agents to stop on missing or broken API keys instead of falling back to browser, web UI, or localhost draft access. +- X Article guidance now warns agents not to flag Typefully-normalized bold/link Markdown as broken when the rendered article is correct. + +## 2026-07-07 ### Added - `--api-base-url ` global CLI option to override the API base URL for one command; `/v2` is appended when omitted. +- 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-05-05 + +### 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`. @@ -19,6 +41,11 @@ The format is based on Keep a Changelog. - `--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. + +## 2026-04-24 + +### Added + - `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. @@ -29,21 +56,7 @@ 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] +## 2026-03-17 ### Added @@ -55,7 +68,7 @@ The format is based on Keep a Changelog. - `analytics:posts:list` now defaults `--platform` to `x` and returns a clear CLI error if another platform is requested, matching current API support. -## [2026-02-26] +## 2026-02-26 ### Added @@ -77,7 +90,7 @@ The format is based on Keep a Changelog. - unchanged behavior for non-quote draft create/update flows. - API `400 VALIDATION_ERROR` responses are surfaced as explicit validation messages in CLI output. -## [2026-02-19] +## 2026-02-19 ### Added @@ -97,7 +110,7 @@ The format is based on Keep a Changelog. - Queue command validation now returns clear CLI errors for missing required date flags and invalid `--rules` JSON input. - Clarified queue docs in `SKILL.md` to explain that queue data is scoped per social set and includes that social set's scheduled drafts/posts. -## [2026-02-10] +## 2026-02-10 ### Added diff --git a/skills/typefully/SKILL.md b/skills/typefully/SKILL.md index 0d4531b..52371c6 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-07-07 +last-updated: 2026-07-08 allowed-tools: Bash(./scripts/typefully.js:*) --- @@ -16,7 +16,7 @@ Create, schedule, and publish social media content across X, LinkedIn, Threads, > > **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). +> **Authentication failures**: If the CLI returns **"API key not found"**, **"Authentication failed"**, **"HTTP 401"**, or any invalid/expired-key message, tell the user to run `./scripts/typefully.js setup` or update `TYPEFULLY_API_KEY`, then stop. Do not hunt for credentials or fall back to the Typefully web UI, browser scraping, or a localhost dev server. See [`references/setup.md`](references/setup.md). ## Reference guides diff --git a/skills/typefully/references/platforms/x-articles.md b/skills/typefully/references/platforms/x-articles.md index 3070e3d..c1eeb33 100644 --- a/skills/typefully/references/platforms/x-articles.md +++ b/skills/typefully/references/platforms/x-articles.md @@ -17,6 +17,14 @@ Use this guide when creating, updating, scheduling, publishing, or commenting on `content_markdown` is canonical X Article Markdown. Typefully validates and normalizes it server-side. +When reviewing an existing article, treat returned Markdown as a round-trip representation, not necessarily the prettiest authoring form. Typefully may serialize adjacent marks separately, for example: + +```md +**A bold sentence ending before **[**a bold link**](https://example.com), then normal text. +``` + +That can be correct if the rendered article shows bold text followed by a bold link. Do not flag or rewrite this pattern solely because the Markdown could be prettier; only change it when the rendered/visible article is wrong, or when the user explicitly asks to normalize the source. + Required structure: - The first non-empty block must be `# Title`; that heading sets the article title. diff --git a/skills/typefully/references/setup.md b/skills/typefully/references/setup.md index b0cb5d9..a338142 100644 --- a/skills/typefully/references/setup.md +++ b/skills/typefully/references/setup.md @@ -18,13 +18,14 @@ Requirements: Node.js 18+ (built-in fetch). No other dependencies. 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 +## Handling missing or invalid API keys -When the CLI returns "API key not found": +When the CLI returns "API key not found", "Authentication failed", "HTTP 401", or says the key is invalid or expired: -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. +1. **Tell the user to run `./scripts/typefully.js setup`** themselves or update `TYPEFULLY_API_KEY` with a fresh key from https://typefully.com/?settings=api. +2. **Stop and wait.** No Typefully API operation works without a valid 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. +4. **Do not** fall back to the Typefully web UI, browser scraping, or a localhost development server to read or edit drafts. A Typefully draft URL only provides IDs; the API must be the source of truth. Trust the CLI's error messages and follow them. diff --git a/skills/typefully/scripts/typefully.js b/skills/typefully/scripts/typefully.js index f312d37..291a594 100755 --- a/skills/typefully/scripts/typefully.js +++ b/skills/typefully/scripts/typefully.js @@ -19,6 +19,7 @@ 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 AUTH_FAILURE_MESSAGE = `Authentication failed: Typefully API key is invalid, expired, or lacks access. Run 'typefully.js setup' to configure a valid key.`; const X_ARTICLE_PLATFORM = 'x_article'; const POST_PLATFORM_ORDER = ['x', 'linkedin', 'threads', 'bluesky', 'mastodon']; const X_ARTICLE_POST_ONLY_FLAGS = [ @@ -243,6 +244,20 @@ function requireApiKey() { return result.key; } +function authenticationFailureDetails(response) { + return { + action: 'Run: typefully.js setup', + api_key_url: API_KEY_URL, + response, + }; +} + +function errorIfAuthenticationFailure(err) { + if (err?.status === 401) { + error(AUTH_FAILURE_MESSAGE, authenticationFailureDetails(err.response)); + } +} + function extractGlobalArgs(args) { const result = []; @@ -322,6 +337,9 @@ async function apiRequest(method, endpoint, body = null, opts = {}) { if (!response.ok) { if (exitOnError) { + if (response.status === 401) { + error(AUTH_FAILURE_MESSAGE, authenticationFailureDetails(data)); + } const validationCode = data?.code || data?.error?.code; if (response.status === 400 && validationCode === 'VALIDATION_ERROR') { const validationMessage = extractApiErrorMessage(data) || 'Request validation failed'; @@ -329,7 +347,7 @@ async function apiRequest(method, endpoint, body = null, opts = {}) { } error(`HTTP ${response.status}`, { response: data }); } - const err = new Error(`HTTP ${response.status}`); + const err = new Error(response.status === 401 ? AUTH_FAILURE_MESSAGE : `HTTP ${response.status}`); err.response = data; err.status = response.status; throw err; @@ -919,18 +937,15 @@ async function cmdSetup(args) { process.env.TYPEFULLY_API_KEY = apiKey; try { await apiRequest('GET', `/social-sets/${defaultSocialSetArg}`, null, { exitOnError: false }); - } catch { + } catch (err) { + errorIfAuthenticationFailure(err); + error(`Social set ${defaultSocialSetArg} not found or not accessible`); + } finally { if (origKey) { process.env.TYPEFULLY_API_KEY = origKey; } else { delete process.env.TYPEFULLY_API_KEY; } - error(`Social set ${defaultSocialSetArg} not found or not accessible`); - } - if (origKey) { - process.env.TYPEFULLY_API_KEY = origKey; - } else { - delete process.env.TYPEFULLY_API_KEY; } defaultSocialSetId = defaultSocialSetArg; @@ -944,18 +959,20 @@ async function cmdSetup(args) { } else { // Fetch social sets to determine what to do let socialSets = null; + const origKey = process.env.TYPEFULLY_API_KEY; + process.env.TYPEFULLY_API_KEY = apiKey; try { - const origKey = process.env.TYPEFULLY_API_KEY; - process.env.TYPEFULLY_API_KEY = apiKey; socialSets = await apiRequest('GET', '/social-sets?limit=50', null, { exitOnError: false }); + } catch (err) { + errorIfAuthenticationFailure(err); + console.error(fmt.warn(`Could not fetch social sets: ${err.message}`)); + console.error(fmt.dim('You can set a default later with: typefully.js config:set-default')); + } finally { if (origKey) { process.env.TYPEFULLY_API_KEY = origKey; } else { delete process.env.TYPEFULLY_API_KEY; } - } catch (err) { - console.error(fmt.warn(`Could not fetch social sets: ${err.message}`)); - console.error(fmt.dim('You can set a default later with: typefully.js config:set-default')); } if (socialSets) { @@ -1104,7 +1121,8 @@ async function cmdConfigSetDefault(args) { // Verify the social set exists try { await apiRequest('GET', `/social-sets/${socialSetId}`, null, { exitOnError: false }); - } catch { + } catch (err) { + errorIfAuthenticationFailure(err); error(`Social set ${socialSetId} not found or not accessible`); } diff --git a/tests/common.test.js b/tests/common.test.js index e762816..15d9f23 100644 --- a/tests/common.test.js +++ b/tests/common.test.js @@ -6,6 +6,7 @@ const { authAssertFactory, expectCliOk, expectCliError, + parseJsonOrNull, } = require('./typefully-cli.test-helpers'); describe('argument parsing', () => { @@ -86,3 +87,25 @@ describe('global flag behavior', () => { expectCliOk(result, { results: [] }); })); }); + +describe('api errors', () => { + it('explains 401 responses as authentication failures', withCliHarness(async ({ server, apiKey, run }) => { + server.expect('GET', '/v2/me', { + assert: authAssertFactory(apiKey), + status: 401, + json: { error: 'Invalid token' }, + }); + + const result = await run(['me:get']); + + expectCliError(result); + const out = parseJsonOrNull(result.stdout); + assert.equal( + out.error, + `Authentication failed: Typefully API key is invalid, expired, or lacks access. Run 'typefully.js setup' to configure a valid key.`, + ); + assert.equal(out.action, 'Run: typefully.js setup'); + assert.equal(out.api_key_url, 'https://typefully.com/?settings=api'); + assert.deepEqual(out.response, { error: 'Invalid token' }); + })); +}); diff --git a/tests/config.test.js b/tests/config.test.js index 90f6b9c..c38fed6 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -8,8 +8,11 @@ const { parseJsonOrNull, authAssertFactory, expectCliOk, + expectCliError, } = require('./typefully-cli.test-helpers'); +const AUTH_FAILURE_MESSAGE = `Authentication failed: Typefully API key is invalid, expired, or lacks access. Run 'typefully.js setup' to configure a valid key.`; + async function readLocalConfig(cwd) { return JSON.parse(await fs.readFile(path.join(cwd, '.typefully', 'config.json'), 'utf8')); } @@ -48,6 +51,23 @@ describe('config:set-default', () => { const cfg = await readLocalConfig(sandbox.cwd); assert.equal(cfg.defaultSocialSetId, '123'); })); + + it('returns authentication guidance when default validation gets a 401', withCliHarness(async ({ server, apiKey, run }) => { + server.expect('GET', '/v2/social-sets/123', { + assert: authAssertFactory(apiKey), + status: 401, + json: { error: 'Invalid token' }, + }); + + const result = await run(['config:set-default', '--social-set-id', '123', '--location', 'local']); + + expectCliError(result); + const out = parseJsonOrNull(result.stdout); + assert.equal(out.error, AUTH_FAILURE_MESSAGE); + assert.equal(out.action, 'Run: typefully.js setup'); + assert.equal(out.api_key_url, 'https://typefully.com/?settings=api'); + assert.deepEqual(out.response, { error: 'Invalid token' }); + })); }); describe('setup', () => { @@ -74,6 +94,49 @@ describe('setup', () => { assert.ok(gitignore.includes('.typefully/')); })); + it('returns authentication guidance when default social set validation gets a 401', withCliHarness(async ({ server, run }) => { + server.expect('GET', '/v2/social-sets/123', { + assert: authAssertFactory('typ_setup_key'), + status: 401, + json: { error: 'Invalid token' }, + }); + + const result = await run( + ['setup', '--key', 'typ_setup_key', '--location', 'local', '--default-social-set', '123'], + { env: { TYPEFULLY_API_KEY: '' } } + ); + + expectCliError(result); + const out = parseJsonOrNull(result.stdout); + assert.equal(out.error, AUTH_FAILURE_MESSAGE); + assert.equal(out.action, 'Run: typefully.js setup'); + assert.equal(out.api_key_url, 'https://typefully.com/?settings=api'); + assert.deepEqual(out.response, { error: 'Invalid token' }); + })); + + it('returns authentication guidance when social set discovery gets a 401', withCliHarness(async ({ server, run }) => { + server.expect('GET', '/v2/social-sets', { + assert: (req) => { + authAssertFactory('typ_setup_key')(req); + assert.equal(req.search, '?limit=50'); + }, + status: 401, + json: { error: 'Invalid token' }, + }); + + const result = await run( + ['setup', '--key', 'typ_setup_key', '--location', 'local'], + { env: { TYPEFULLY_API_KEY: '' } } + ); + + expectCliError(result); + const out = parseJsonOrNull(result.stdout); + assert.equal(out.error, AUTH_FAILURE_MESSAGE); + assert.equal(out.action, 'Run: typefully.js setup'); + assert.equal(out.api_key_url, 'https://typefully.com/?settings=api'); + assert.deepEqual(out.response, { error: 'Invalid token' }); + })); + it('supports --no-default and avoids API calls', withCliHarness(async ({ sandbox, server, run }) => { const result = await run( ['setup', '--key', 'typ_setup_key', '--location', 'local', '--no-default'],