A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.
1import { Command } from 'commander';
2import inquirer from 'inquirer';
3import { addMapping, getConfig, removeMapping, saveConfig, updateTwitterConfig } from './config-manager.js';
4
5const program = new Command();
6
7program
8 .name('tweets-2-bsky-cli')
9 .description('CLI to manage Twitter to Bluesky crossposting mappings')
10 .version('1.0.0');
11
12program
13 .command('setup-ai')
14 .description('Configure AI settings for alt text generation')
15 .action(async () => {
16 const config = getConfig();
17 const currentAi = config.ai || { provider: 'gemini' };
18
19 // Check legacy gemini key if not in new config
20 if (!config.ai && config.geminiApiKey) {
21 currentAi.apiKey = config.geminiApiKey;
22 }
23
24 const answers = await inquirer.prompt([
25 {
26 type: 'list',
27 name: 'provider',
28 message: 'Select AI Provider:',
29 choices: [
30 { name: 'Google Gemini (Default)', value: 'gemini' },
31 { name: 'OpenAI / OpenRouter', value: 'openai' },
32 { name: 'Anthropic (Claude)', value: 'anthropic' },
33 { name: 'Custom (OpenAI Compatible)', value: 'custom' },
34 ],
35 default: currentAi.provider,
36 },
37 {
38 type: 'input',
39 name: 'apiKey',
40 message: 'Enter API Key (optional for some custom providers):',
41 default: currentAi.apiKey,
42 validate: (input: string, answers: any) => {
43 if (['gemini', 'anthropic'].includes(answers.provider) && !input) {
44 return 'API Key is required for this provider.';
45 }
46 return true;
47 },
48 },
49 {
50 type: 'input',
51 name: 'model',
52 message: 'Enter Model ID (optional, leave empty for default):',
53 default: currentAi.model,
54 },
55 {
56 type: 'input',
57 name: 'baseUrl',
58 message: 'Enter Base URL (optional, e.g. for OpenRouter):',
59 default: currentAi.baseUrl,
60 when: (answers) => ['openai', 'anthropic', 'custom'].includes(answers.provider),
61 },
62 ]);
63
64 config.ai = {
65 provider: answers.provider,
66 apiKey: answers.apiKey,
67 model: answers.model || undefined,
68 baseUrl: answers.baseUrl || undefined,
69 };
70
71 // Clear legacy key to avoid confusion
72 delete config.geminiApiKey;
73
74 saveConfig(config);
75 console.log('AI configuration updated!');
76 });
77
78program
79 .command('setup-twitter')
80 .description('Setup Twitter auth cookies')
81 .action(async () => {
82 const config = getConfig();
83 const answers = await inquirer.prompt([
84 {
85 type: 'input',
86 name: 'authToken',
87 message: 'Enter Twitter auth_token:',
88 default: config.twitter.authToken,
89 },
90 {
91 type: 'input',
92 name: 'ct0',
93 message: 'Enter Twitter ct0:',
94 default: config.twitter.ct0,
95 },
96 ]);
97 updateTwitterConfig(answers);
98 console.log('Twitter config updated!');
99 });
100
101program
102 .command('add-mapping')
103 .description('Add a new Twitter to Bluesky mapping')
104 .action(async () => {
105 const answers = await inquirer.prompt([
106 {
107 type: 'input',
108 name: 'twitterUsernames',
109 message: 'Twitter username(s) to watch (comma separated, without @):',
110 },
111 {
112 type: 'input',
113 name: 'bskyIdentifier',
114 message: 'Bluesky identifier (handle or email):',
115 },
116 {
117 type: 'password',
118 name: 'bskyPassword',
119 message: 'Bluesky app password:',
120 },
121 {
122 type: 'input',
123 name: 'bskyServiceUrl',
124 message: 'Bluesky service URL:',
125 default: 'https://bsky.social',
126 },
127 ]);
128
129 const usernames = answers.twitterUsernames
130 .split(',')
131 .map((u: string) => u.trim())
132 .filter((u: string) => u.length > 0);
133
134 addMapping({
135 ...answers,
136 twitterUsernames: usernames,
137 });
138 console.log('Mapping added successfully!');
139 });
140
141program
142 .command('edit-mapping')
143 .description('Edit an existing mapping')
144 .action(async () => {
145 const config = getConfig();
146 if (config.mappings.length === 0) {
147 console.log('No mappings found.');
148 return;
149 }
150
151 const { id } = await inquirer.prompt([
152 {
153 type: 'list',
154 name: 'id',
155 message: 'Select a mapping to edit:',
156 choices: config.mappings.map((m) => ({
157 name: `${m.twitterUsernames.join(', ')} -> ${m.bskyIdentifier}`,
158 value: m.id,
159 })),
160 },
161 ]);
162
163 const mapping = config.mappings.find((m) => m.id === id);
164 if (!mapping) return;
165
166 const answers = await inquirer.prompt([
167 {
168 type: 'input',
169 name: 'twitterUsernames',
170 message: 'Twitter username(s) (comma separated):',
171 default: mapping.twitterUsernames.join(', '),
172 },
173 {
174 type: 'input',
175 name: 'bskyIdentifier',
176 message: 'Bluesky identifier:',
177 default: mapping.bskyIdentifier,
178 },
179 {
180 type: 'password',
181 name: 'bskyPassword',
182 message: 'Bluesky app password (leave empty to keep current):',
183 },
184 {
185 type: 'input',
186 name: 'bskyServiceUrl',
187 message: 'Bluesky service URL:',
188 default: mapping.bskyServiceUrl || 'https://bsky.social',
189 },
190 ]);
191
192 const usernames = answers.twitterUsernames
193 .split(',')
194 .map((u: string) => u.trim())
195 .filter((u: string) => u.length > 0);
196
197 // Update the mapping directly
198 const index = config.mappings.findIndex((m) => m.id === id);
199 const existingMapping = config.mappings[index];
200
201 if (index !== -1 && existingMapping) {
202 const updatedMapping = {
203 ...existingMapping,
204 twitterUsernames: usernames,
205 bskyIdentifier: answers.bskyIdentifier,
206 bskyServiceUrl: answers.bskyServiceUrl,
207 };
208
209 if (answers.bskyPassword && answers.bskyPassword.trim().length > 0) {
210 updatedMapping.bskyPassword = answers.bskyPassword;
211 }
212
213 config.mappings[index] = updatedMapping;
214 saveConfig(config);
215 console.log('Mapping updated successfully!');
216 }
217 });
218
219program
220 .command('list')
221 .description('List all mappings')
222 .action(() => {
223 const config = getConfig();
224 if (config.mappings.length === 0) {
225 console.log('No mappings found.');
226 return;
227 }
228 console.table(
229 config.mappings.map((m) => ({
230 id: m.id,
231 twitter: m.twitterUsernames.join(', '),
232 bsky: m.bskyIdentifier,
233 enabled: m.enabled,
234 })),
235 );
236 });
237
238program
239 .command('remove')
240 .description('Remove a mapping')
241 .action(async () => {
242 const config = getConfig();
243 if (config.mappings.length === 0) {
244 console.log('No mappings to remove.');
245 return;
246 }
247 const { id } = await inquirer.prompt([
248 {
249 type: 'list',
250 name: 'id',
251 message: 'Select a mapping to remove:',
252 choices: config.mappings.map((m) => ({
253 name: `${m.twitterUsernames.join(', ')} -> ${m.bskyIdentifier}`,
254 value: m.id,
255 })),
256 },
257 ]);
258 removeMapping(id);
259 console.log('Mapping removed.');
260 });
261
262program
263 .command('import-history')
264 .description('Import history for a specific mapping')
265 .action(async () => {
266 const config = getConfig();
267 if (config.mappings.length === 0) {
268 console.log('No mappings found.');
269 return;
270 }
271 const { id } = await inquirer.prompt([
272 {
273 type: 'list',
274 name: 'id',
275 message: 'Select a mapping to import history for:',
276 choices: config.mappings.map((m) => ({
277 name: `${m.twitterUsernames.join(', ')} -> ${m.bskyIdentifier}`,
278 value: m.id,
279 })),
280 },
281 ]);
282
283 const mapping = config.mappings.find((m) => m.id === id);
284 if (!mapping) return;
285
286 console.log(`
287To import history, run one of the following commands:`);
288 for (const username of mapping.twitterUsernames) {
289 console.log(` npm run import -- --username ${username}`);
290 }
291 console.log(`
292You can also use additional flags:`);
293 console.log(' --limit <number> Limit the number of tweets to import');
294 console.log(' --dry-run Fetch and show tweets without posting');
295 console.log(`
296Example:`);
297 console.log(` npm run import -- --username ${mapping.twitterUsernames[0]} --limit 10 --dry-run
298`);
299 });
300
301program
302 .command('set-interval')
303 .description('Set check interval in minutes')
304 .argument('<minutes>', 'Interval in minutes')
305 .action((minutes) => {
306 const config = getConfig();
307 config.checkIntervalMinutes = Number.parseInt(minutes, 10);
308 saveConfig(config);
309 console.log(`Interval set to ${minutes} minutes.`);
310 });
311
312program.parse();