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.
npx claudepluginhub the-focus-ai/claude-marketplace --plugin focus-skillsThis skill uses the workspace's default tool permissions.
Build command-line applications that authenticate with Twitter/X using OAuth 2.0 PKCE flow.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Checks Next.js compilation errors using a running Turbopack dev server after code edits. Fixes actionable issues before reporting complete. Replaces `next build`.
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!