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
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
51 changes: 32 additions & 19 deletions skills/typefully/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 <markdown>` for X Article content.
- `--cover-media-id <media_id|null>` 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 <draft_id>` — list threads with `--platform`, `--status` (`unresolved` / `resolved` / `all`), `--limit`, `--offset` filters.
- `comments:create <draft_id> --post-index <n> --selected-text "..." --text "..."` — create a thread anchored on a span. Optional `--platform`, `--occurrence`.
Expand All @@ -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 `<typ:comment-thread>` 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.
Expand All @@ -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 <markdown>` for X Article content.
- `--cover-media-id <media_id|null>` 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

Expand All @@ -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

Expand All @@ -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

Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions skills/typefully/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:*)
---

Expand All @@ -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

Expand Down
8 changes: 8 additions & 0 deletions skills/typefully/references/platforms/x-articles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 5 additions & 4 deletions skills/typefully/references/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@ Requirements: Node.js 18+ (built-in fetch). No other dependencies.

Development only: pass `--api-base-url <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.

Expand Down
46 changes: 32 additions & 14 deletions skills/typefully/scripts/typefully.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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 = [];

Expand Down Expand Up @@ -322,14 +337,17 @@ 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';
error(`Validation error: ${validationMessage}`, { response: data });
}
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;
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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`);
}

Expand Down
23 changes: 23 additions & 0 deletions tests/common.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const {
authAssertFactory,
expectCliOk,
expectCliError,
parseJsonOrNull,
} = require('./typefully-cli.test-helpers');

describe('argument parsing', () => {
Expand Down Expand Up @@ -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' });
}));
});
63 changes: 63 additions & 0 deletions tests/config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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'],
Expand Down
Loading