From focus-skills
Build CLI tools that authenticate with Twitter/X OAuth 2.0 using PKCE flow. Use when creating command-line apps that need Twitter tokens, implementing device-code style auth for scripts, or building automation tools that post to Twitter. Trigger when user needs Twitter API access from terminal, wants to build a tweet poster, or needs OAuth 2.0 PKCE implementation.
How this skill is triggered — by the user, by Claude, or both
Slash command
/focus-skills:twitter-oauth-cliThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Build command-line applications that authenticate with Twitter/X using OAuth 2.0 PKCE flow.
Build command-line applications that authenticate with Twitter/X using OAuth 2.0 PKCE flow.
Use this skill when:
CRITICAL: Configure your app correctly FIRST.
Go to Developer Portal → Settings → User authentication settings
Set App type: "Web App, Automated App or Bot"
Set App permissions: "Read and Write"
Set Callback URI: http://127.0.0.1:3000/callback
⚠️ IMPORTANT: Do NOT use localhost! Twitter rejects it with a cryptic "Something went wrong" error. Use one of:
http://127.0.0.1:3000/callback (recommended)http://www.localhost:3000/callbackSet Website URL: http://127.0.0.1:3000
Save settings
Go to "Keys and tokens" tab → Copy Client ID and Client Secret
const SCOPES = [
'tweet.read',
'tweet.write',
'users.read',
'offline.access' // CRITICAL for refresh tokens!
].join(' ');
Why offline.access is critical: Without it, your token expires in 2 hours. With it, you get a refresh token and can stay authenticated indefinitely.
mkdir twitter-cli && cd twitter-cli
pnpm init
pnpm add express open
pnpm add -D typescript @types/express @types/node
package.json:
{
"type": "module",
"scripts": {
"auth": "tsx src/auth.ts",
"post": "tsx src/post.ts"
}
}
// src/auth.ts
import crypto from 'crypto';
import express from 'express';
import open from 'open';
import fs from 'fs';
const CLIENT_ID = process.env.TWITTER_CLIENT_ID!;
const CLIENT_SECRET = process.env.TWITTER_CLIENT_SECRET!;
const PORT = 3000;
const CALLBACK_URL = `http://127.0.0.1:${PORT}/callback`;
const SCOPES = [
'tweet.read',
'tweet.write',
'users.read',
'offline.access'
].join(' ');
// Generate PKCE challenge
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
const state = crypto.randomBytes(16).toString('hex');
async function main() {
const app = express();
// Build OAuth URL
const authUrl = new URL('https://twitter.com/i/oauth2/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', CALLBACK_URL);
authUrl.searchParams.set('scope', SCOPES);
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
// Handle callback
app.get('/callback', async (req, res) => {
const { code, state: returnedState } = req.query;
if (returnedState !== state) {
res.send('State mismatch! Possible CSRF attack.');
process.exit(1);
}
// Exchange code for tokens
const tokenResponse = await fetch('https://api.twitter.com/2/oauth2/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`
},
body: new URLSearchParams({
code: code as string,
grant_type: 'authorization_code',
redirect_uri: CALLBACK_URL,
code_verifier: codeVerifier
})
});
const tokens = await tokenResponse.json();
if (tokens.error) {
res.send(`Error: ${tokens.error_description}`);
process.exit(1);
}
// Save tokens
fs.writeFileSync('.twitter-tokens.json', JSON.stringify({
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: Date.now() + (tokens.expires_in * 1000)
}, null, 2));
res.send('Authentication successful! You can close this window.');
console.log('✅ Tokens saved to .twitter-tokens.json');
process.exit(0);
});
const server = app.listen(PORT, () => {
console.log(`Opening browser for authentication...`);
console.log(`If browser doesn't open, visit: ${authUrl.toString()}`);
open(authUrl.toString());
});
}
main().catch(console.error);
// src/tokens.ts
import fs from 'fs';
interface Tokens {
access_token: string;
refresh_token: string;
expires_at: number;
}
export function loadTokens(): Tokens {
if (!fs.existsSync('.twitter-tokens.json')) {
throw new Error('No tokens found. Run auth first.');
}
return JSON.parse(fs.readFileSync('.twitter-tokens.json', 'utf-8'));
}
export async function refreshTokens(tokens: Tokens): Promise<Tokens> {
const response = await fetch('https://api.twitter.com/2/oauth2/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: tokens.refresh_token
})
});
const newTokens = await response.json();
const updated = {
access_token: newTokens.access_token,
refresh_token: newTokens.refresh_token,
expires_at: Date.now() + (newTokens.expires_in * 1000)
};
fs.writeFileSync('.twitter-tokens.json', JSON.stringify(updated, null, 2));
return updated;
}
export async function getValidToken(): Promise<string> {
let tokens = loadTokens();
// Refresh if expired (with 5 minute buffer)
if (tokens.expires_at < Date.now() + 300000) {
console.log('Token expired, refreshing...');
tokens = await refreshTokens(tokens);
}
return tokens.access_token;
}
// src/post.ts
import { getValidToken } from './tokens.js';
async function postTweet(text: string) {
const token = await getValidToken();
const response = await fetch('https://api.twitter.com/2/tweets', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ text })
});
const result = await response.json();
if (result.errors) {
throw new Error(result.errors[0].message);
}
console.log(`✅ Tweet posted: https://twitter.com/i/status/${result.data.id}`);
return result.data;
}
// CLI usage
const text = process.argv.slice(2).join(' ');
if (!text) {
console.error('Usage: pnpm post "Your tweet text"');
process.exit(1);
}
postTweet(text).catch(console.error);
# .env (add to .gitignore!)
TWITTER_CLIENT_ID=your_client_id
TWITTER_CLIENT_SECRET=your_client_secret
Load with:
import 'dotenv/config';
// or use shell: export $(cat .env | xargs)
.twitter-tokens.json to .gitignore127.0.0.1 is fine for local dev onlyCause: Using localhost instead of 127.0.0.1 in callback URL
Fix: Use http://127.0.0.1:3000/callback
Cause: Missing offline.access scope
Fix: Add offline.access to scopes array
Cause: Mismatch between code and Developer Portal setting Fix: Ensure exact match including port and path
Cause: Expired token not refreshed
Fix: Use getValidToken() which auto-refreshes
Cause: Too many requests Fix: Implement exponential backoff
async function getMe(token: string) {
const response = await fetch('https://api.twitter.com/2/users/me', {
headers: { 'Authorization': `Bearer ${token}` }
});
return response.json();
}
// Requires additional scopes and media upload endpoint
// See Twitter API v2 documentation for media upload flow
async function getTimeline(token: string) {
const user = await getMe(token);
const response = await fetch(
`https://api.twitter.com/2/users/${user.data.id}/tweets`,
{ headers: { 'Authorization': `Bearer ${token}` } }
);
return response.json();
}
Use Focus Account Integration skill to:
GET /api/xToken for retrieved tokensBefore deploying your Twitter CLI tool:
127.0.0.1, not localhostoffline.access).gitignore includes token files and .envSee the complete working implementation pattern:
pnpm auth to authenticatepnpm post "Hello Twitter!" to postThis pattern works for any OAuth 2.0 PKCE CLI application, not just Twitter!
npx claudepluginhub the-focus-ai/claude-marketplace --plugin focus-skillsPosts text, images, and videos to X (Twitter) from the command line using a Python CLI script with OAuth credentials. Supports chunked video uploads and requires user confirmation before posting.
Integrates with X/Twitter API to post tweets and threads, read timelines and user data, search content, and retrieve analytics. Handles OAuth 1.0a/2.0 authentication and rate limits for programmatic use.
X/Twitter API integration for posting tweets and threads, reading timelines, searching, and analytics. Covers OAuth auth patterns and rate limits.