apitweet-cli 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.js ADDED
@@ -0,0 +1,301 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { CONFIG_FILE_NAME, DEFAULT_BASE_URL, DEFAULT_CONFIG } from "./constants.js";
5
+ import { exitWithError, findOption, hasFlag, maskSecret, printJson, sanitizeForOutput } from "./utils.js";
6
+
7
+ export function configFilePath(configDir) {
8
+ return path.join(configDir, CONFIG_FILE_NAME);
9
+ }
10
+
11
+ export async function loadConfig(configDir) {
12
+ try {
13
+ const contents = await fs.readFile(configFilePath(configDir), "utf8");
14
+ const parsed = JSON.parse(contents);
15
+ return {
16
+ ...structuredClone(DEFAULT_CONFIG),
17
+ ...parsed,
18
+ apps: parsed.apps || {},
19
+ profiles: parsed.profiles || {},
20
+ };
21
+ } catch (error) {
22
+ if (error.code === "ENOENT") {
23
+ return structuredClone(DEFAULT_CONFIG);
24
+ }
25
+ exitWithError("Failed to load config.", error.message);
26
+ }
27
+ }
28
+
29
+ export async function saveConfig(configDir, config) {
30
+ await fs.mkdir(configDir, { recursive: true });
31
+ await fs.writeFile(configFilePath(configDir), `${JSON.stringify(config, null, 2)}\n`, "utf8");
32
+ }
33
+
34
+ export function printConfigSummary(configDir, config) {
35
+ const display = {
36
+ configDir,
37
+ currentApp: config.currentApp || null,
38
+ currentProfile: config.currentProfile || null,
39
+ apps: Object.fromEntries(
40
+ Object.entries(config.apps).map(([name, app]) => [
41
+ name,
42
+ {
43
+ baseUrl: app.baseUrl,
44
+ apiKey: maskSecret(app.apiKey),
45
+ },
46
+ ]),
47
+ ),
48
+ profiles: Object.fromEntries(
49
+ Object.entries(config.profiles).map(([name, profile]) => [
50
+ name,
51
+ {
52
+ cookie: profile.cookie ? maskSecret(profile.cookie) : "",
53
+ authToken: profile.authToken ? maskSecret(profile.authToken) : "",
54
+ ct0: profile.ct0 ? maskSecret(profile.ct0) : "",
55
+ },
56
+ ]),
57
+ ),
58
+ };
59
+
60
+ printJson(display);
61
+ }
62
+
63
+ export function resolveAppState(state, config) {
64
+ const configuredApp = state.appName
65
+ ? config.apps[state.appName]
66
+ : config.currentApp
67
+ ? config.apps[config.currentApp]
68
+ : null;
69
+
70
+ if (state.appName && !configuredApp) {
71
+ exitWithError(`Unknown app profile: ${state.appName}`);
72
+ }
73
+
74
+ return {
75
+ appName: state.appName || config.currentApp || "",
76
+ apiKey: state.apiKey || process.env.APITWEET_KEY || configuredApp?.apiKey || "",
77
+ baseUrl: (
78
+ state.baseUrl ||
79
+ process.env.APITWEET_BASE_URL ||
80
+ configuredApp?.baseUrl ||
81
+ DEFAULT_BASE_URL
82
+ ).replace(/\/+$/, ""),
83
+ };
84
+ }
85
+
86
+ export function resolveProfile(config, preferredName) {
87
+ const profileName = preferredName || config.currentProfile || "";
88
+ if (!profileName) {
89
+ return { profileName: "", profile: null };
90
+ }
91
+
92
+ const profile = config.profiles[profileName];
93
+ if (!profile) {
94
+ exitWithError(`Unknown profile: ${profileName}`);
95
+ }
96
+
97
+ return { profileName, profile };
98
+ }
99
+
100
+ export function profileCredential(profile) {
101
+ if (!profile) {
102
+ return "";
103
+ }
104
+ if (profile.cookie) {
105
+ return profile.cookie;
106
+ }
107
+ if (profile.authToken && profile.ct0) {
108
+ return `ct0=${profile.ct0}; auth_token=${profile.authToken}`;
109
+ }
110
+ if (profile.authToken) {
111
+ return profile.authToken;
112
+ }
113
+ return "";
114
+ }
115
+
116
+ export function resolveCookieArg(args, state, config, required = true) {
117
+ const explicitCookie = findOption(args, "--cookie");
118
+ if (explicitCookie) {
119
+ return explicitCookie;
120
+ }
121
+
122
+ const explicitProfile = findOption(args, "--profile") || state.profileName;
123
+ const { profile } = resolveProfile(config, explicitProfile);
124
+ const savedValue = profileCredential(profile);
125
+ if (savedValue) {
126
+ return savedValue;
127
+ }
128
+
129
+ if (required && !state.dryRun) {
130
+ exitWithError("Missing cookie/auth token.", "Pass --cookie or configure a saved profile.");
131
+ }
132
+ return "";
133
+ }
134
+
135
+ export async function handleConfigCommand(state, config, args) {
136
+ const action = args[1] || "show";
137
+ if (action === "show") {
138
+ printConfigSummary(state.configDir, config);
139
+ return;
140
+ }
141
+
142
+ if (action === "path") {
143
+ console.log(configFilePath(state.configDir));
144
+ return;
145
+ }
146
+
147
+ exitWithError("Unknown config command.", "Use: config show | config path");
148
+ }
149
+
150
+ export async function handleAuthAppsCommand(state, config, args) {
151
+ const action = args[2];
152
+
153
+ if (action === "add") {
154
+ const name = findOption(args, "--name");
155
+ const apiKey = findOption(args, "--api-key");
156
+ if (!name || !apiKey) {
157
+ exitWithError("Usage: apitweet auth apps add --name <name> --api-key <key> [--base-url <url>]");
158
+ }
159
+
160
+ const baseUrl = (findOption(args, "--base-url") || DEFAULT_BASE_URL).replace(/\/+$/, "");
161
+ config.apps[name] = { apiKey, baseUrl };
162
+ if (!config.currentApp || hasFlag(args, "--use")) {
163
+ config.currentApp = name;
164
+ }
165
+ await saveConfig(state.configDir, config);
166
+
167
+ printJson({
168
+ saved: name,
169
+ currentApp: config.currentApp,
170
+ app: {
171
+ baseUrl,
172
+ apiKey: maskSecret(apiKey),
173
+ },
174
+ });
175
+ return;
176
+ }
177
+
178
+ if (action === "list") {
179
+ printJson({
180
+ currentApp: config.currentApp || null,
181
+ apps: Object.entries(config.apps).map(([name, app]) => ({
182
+ name,
183
+ isCurrent: name === config.currentApp,
184
+ baseUrl: app.baseUrl,
185
+ apiKey: maskSecret(app.apiKey),
186
+ })),
187
+ });
188
+ return;
189
+ }
190
+
191
+ if (action === "use") {
192
+ const name = args[3];
193
+ if (!name) {
194
+ exitWithError("Usage: apitweet auth apps use <name>");
195
+ }
196
+ if (!config.apps[name]) {
197
+ exitWithError(`Unknown app profile: ${name}`);
198
+ }
199
+ config.currentApp = name;
200
+ await saveConfig(state.configDir, config);
201
+ printJson({ currentApp: name });
202
+ return;
203
+ }
204
+
205
+ if (action === "remove") {
206
+ const name = args[3];
207
+ if (!name) {
208
+ exitWithError("Usage: apitweet auth apps remove <name>");
209
+ }
210
+ if (!config.apps[name]) {
211
+ exitWithError(`Unknown app profile: ${name}`);
212
+ }
213
+ delete config.apps[name];
214
+ if (config.currentApp === name) {
215
+ config.currentApp = "";
216
+ }
217
+ await saveConfig(state.configDir, config);
218
+ printJson({ removed: name, currentApp: config.currentApp || null });
219
+ return;
220
+ }
221
+
222
+ exitWithError("Unknown auth apps command.", "Use: add | list | use | remove");
223
+ }
224
+
225
+ export async function handleAuthProfilesCommand(state, config, args) {
226
+ const action = args[2];
227
+
228
+ if (action === "add") {
229
+ const name = findOption(args, "--name");
230
+ const cookie = findOption(args, "--cookie");
231
+ const authToken = findOption(args, "--auth-token");
232
+ const ct0 = findOption(args, "--ct0");
233
+ if (!name || (!cookie && !authToken)) {
234
+ exitWithError("Usage: apitweet auth profiles add --name <name> [--cookie <value> | --auth-token <value>] [--ct0 <value>]");
235
+ }
236
+
237
+ config.profiles[name] = {
238
+ ...(cookie ? { cookie } : {}),
239
+ ...(authToken ? { authToken } : {}),
240
+ ...(ct0 ? { ct0 } : {}),
241
+ };
242
+ if (!config.currentProfile || hasFlag(args, "--use")) {
243
+ config.currentProfile = name;
244
+ }
245
+ await saveConfig(state.configDir, config);
246
+
247
+ printJson({
248
+ saved: name,
249
+ currentProfile: config.currentProfile,
250
+ profile: sanitizeForOutput(config.profiles[name]),
251
+ });
252
+ return;
253
+ }
254
+
255
+ if (action === "list") {
256
+ printJson({
257
+ currentProfile: config.currentProfile || null,
258
+ profiles: Object.entries(config.profiles).map(([name, profile]) => ({
259
+ name,
260
+ isCurrent: name === config.currentProfile,
261
+ hasCookie: Boolean(profile.cookie),
262
+ hasAuthToken: Boolean(profile.authToken),
263
+ hasCt0: Boolean(profile.ct0),
264
+ })),
265
+ });
266
+ return;
267
+ }
268
+
269
+ if (action === "use") {
270
+ const name = args[3];
271
+ if (!name) {
272
+ exitWithError("Usage: apitweet auth profiles use <name>");
273
+ }
274
+ if (!config.profiles[name]) {
275
+ exitWithError(`Unknown profile: ${name}`);
276
+ }
277
+ config.currentProfile = name;
278
+ await saveConfig(state.configDir, config);
279
+ printJson({ currentProfile: name });
280
+ return;
281
+ }
282
+
283
+ if (action === "remove") {
284
+ const name = args[3];
285
+ if (!name) {
286
+ exitWithError("Usage: apitweet auth profiles remove <name>");
287
+ }
288
+ if (!config.profiles[name]) {
289
+ exitWithError(`Unknown profile: ${name}`);
290
+ }
291
+ delete config.profiles[name];
292
+ if (config.currentProfile === name) {
293
+ config.currentProfile = "";
294
+ }
295
+ await saveConfig(state.configDir, config);
296
+ printJson({ removed: name, currentProfile: config.currentProfile || null });
297
+ return;
298
+ }
299
+
300
+ exitWithError("Unknown auth profiles command.", "Use: add | list | use | remove");
301
+ }
@@ -0,0 +1,34 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ export const DEFAULT_BASE_URL = "https://apitweet.com";
5
+ export const API_PATH_PREFIX = "/api";
6
+ export const CONFIG_FILE_NAME = "config.json";
7
+ export const DEFAULT_CONFIG_DIR =
8
+ process.env.APITWEET_CONFIG_DIR ||
9
+ path.join(os.homedir(), ".apitweet");
10
+ export const DEFAULT_CONFIG = {
11
+ version: 1,
12
+ currentApp: "",
13
+ currentProfile: "",
14
+ apps: {},
15
+ profiles: {},
16
+ };
17
+
18
+ export const LEADING_GLOBAL_OPTIONS = {
19
+ "-X": { takesValue: true, key: "method" },
20
+ "--method": { takesValue: true, key: "method" },
21
+ "-d": { takesValue: true, key: "data" },
22
+ "--data": { takesValue: true, key: "data" },
23
+ "-H": { takesValue: true, key: "header" },
24
+ "--header": { takesValue: true, key: "header" },
25
+ "--app": { takesValue: true, key: "appName" },
26
+ "--profile": { takesValue: true, key: "profileName" },
27
+ "--api-key": { takesValue: true, key: "apiKey" },
28
+ "--base-url": { takesValue: true, key: "baseUrl" },
29
+ "--config-dir": { takesValue: true, key: "configDir" },
30
+ "--dry-run": { takesValue: false, key: "dryRun" },
31
+ "--raw": { takesValue: false, key: "raw" },
32
+ "-h": { takesValue: false, key: "help" },
33
+ "--help": { takesValue: false, key: "help" },
34
+ };
package/src/help.js ADDED
@@ -0,0 +1,86 @@
1
+ export function printHelp() {
2
+ console.log(`apitweet - command-line client for Twitter/X apitweet APIs
3
+
4
+ Usage:
5
+ apitweet [global options] <path> # Raw API request
6
+
7
+ # Twitter/X User and Search
8
+ apitweet [global options] users <username...> # Multi-user lookup
9
+ apitweet [global options] about <screen_name> # User profile details
10
+ apitweet [global options] search tweets <term...> [--count <n>] # Search Twitter/X tweets
11
+ apitweet [global options] search users <keyword> [--count <n>] # Search Twitter/X users
12
+ apitweet [global options] followers <screen_name> [--count <n>] # List user followers
13
+ apitweet [global options] following <screen_name> [--count <n>] # List users followed
14
+
15
+ # Twitter/X Lists
16
+ apitweet [global options] list search --query <text> [--count <n>] # Search for lists
17
+ apitweet [global options] list create --name <n> --desc <t> [--private] # Create a new list
18
+ apitweet [global options] list members <list_id> [--count <n>] # List members in a list
19
+ apitweet [global options] list subscribers <list_id> [--count <n>] # List list subscribers
20
+
21
+ # Twitter/X Articles and DMs
22
+ apitweet [global options] article markdown <tweet_id> # Fetch article as Markdown
23
+ apitweet [global options] article lookup <tweet_id...> # Batch article details
24
+ apitweet [global options] article publish-md <file.md> --title <title> # Publish Markdown as an X article
25
+ apitweet [global options] dm history <username> [--max-id <id>] # Fetch DM history
26
+ apitweet [global options] dm send <username> --text <content> # Send a Direct Message
27
+
28
+ # Twitter/X Profile and Timeline
29
+ apitweet [global options] profile update [--name <n>] [--desc <t>] ... # Update your profile info
30
+ apitweet [global options] timeline user <screen_name> [--cursor <c>] # Fetch user timeline page
31
+
32
+ # Twitter/X Global Trending
33
+ apitweet [global options] trending tweets --country <country> # Fetch global trending tweets
34
+
35
+ # Twitter/X Tweets
36
+ apitweet [global options] tweet create --text <c> [--media-url <u>] # Create a new tweet
37
+ apitweet [global options] tweet quote --text <c> --quote-url <u> # Quote a tweet
38
+ apitweet [global options] tweet lookup <tweet_id...> [--summary] # Batch tweet lookup
39
+ apitweet [global options] tweet replies <tweet_id> [--count <n>] # Fetch tweet replies
40
+ apitweet [global options] tweet like <tweet_id> # Like a tweet
41
+ apitweet [global options] tweet bookmark <tweet_id> # Bookmark a tweet
42
+ apitweet [global options] tweet retweet <tweet_id> # Retweet a tweet
43
+
44
+ # Twitter/X Actions
45
+ apitweet [global options] user follow <username> # Follow a user
46
+ apitweet [global options] user unfollow <username> # Unfollow a user
47
+
48
+ # Config and Auth
49
+ apitweet [global options] auth apps add --name <n> --api-key <k> # Add an app config
50
+ apitweet [global options] auth profiles add --name <n> [--cookie <v>] # Add an auth profile
51
+ apitweet [global options] auth cookie --auth-token <t> [--save-as <p>] # Create profile from token
52
+ apitweet [global options] config show # Show current config
53
+
54
+ Examples:
55
+ apitweet --app prod users elonmusk sama
56
+ apitweet --profile founder tweet create --text "hello world" --media-url "https://example.com/image.jpg"
57
+ apitweet --app prod --profile founder article publish-md ./article.md --title "Launch Notes" --cover-image "https://example.com/cover.jpg"
58
+ apitweet --profile founder tweet like 1900000000000000000
59
+ apitweet --app prod trending tweets --country "United States" --topic "Sports" --content "NFL" --count 50
60
+ apitweet --app prod -X POST -d '["1900000000000000000"]' /api/twitter/tweets/lookup # Twitter/X tweet lookup
61
+ apitweet auth apps add --name prod --api-key "twitterx_..."
62
+ apitweet auth profiles add --name founder --cookie "ct0=...; auth_token=..."
63
+ apitweet auth cookie --auth-token "<auth_token>" --save-as founder
64
+ apitweet config show
65
+
66
+ Global options:
67
+ -X, --method <METHOD> HTTP method for generic path requests, default GET
68
+ -d, --data <JSON> JSON body for generic path requests
69
+ -H, --header <K:V> Extra header for generic path requests, can be repeated
70
+ --app <NAME> Use a saved app config
71
+ --profile <NAME> Use a saved profile config
72
+ --api-key <KEY> Override API key for this request
73
+ --base-url <URL> Override base URL for this request
74
+ --config-dir <DIR> Override config directory, default ~/.apitweet
75
+ --dry-run Print the request payload without sending it
76
+ --raw Print response body without pretty JSON formatting
77
+ -h, --help Show help
78
+
79
+ Notes:
80
+ Global options should be placed before the command.
81
+ Saved apps store API keys and base URLs.
82
+ Saved profiles store cookies or auth_token values for write actions.
83
+ Get API keys from https://apitweet.io/dashboard.
84
+ Copy the API key from the dashboard and save it with auth apps add.
85
+ `);
86
+ }
package/src/index.js ADDED
@@ -0,0 +1,15 @@
1
+ import { loadConfig } from "./config.js";
2
+ import { printHelp } from "./help.js";
3
+ import { parseGlobalArgs } from "./parser.js";
4
+ import { runCommand } from "./commands.js";
5
+
6
+ export async function main(argv = process.argv.slice(2)) {
7
+ const state = parseGlobalArgs(argv);
8
+ if (state.help || state.commandArgs.length === 0) {
9
+ printHelp();
10
+ return;
11
+ }
12
+
13
+ const config = await loadConfig(state.configDir);
14
+ await runCommand(state, config);
15
+ }
package/src/parser.js ADDED
@@ -0,0 +1,55 @@
1
+ import { DEFAULT_CONFIG_DIR, LEADING_GLOBAL_OPTIONS } from "./constants.js";
2
+ import { parseHeader, parseJson, exitWithError } from "./utils.js";
3
+
4
+ export function parseGlobalArgs(argv) {
5
+ const state = {
6
+ method: "GET",
7
+ data: undefined,
8
+ headers: {},
9
+ appName: "",
10
+ profileName: "",
11
+ apiKey: "",
12
+ baseUrl: "",
13
+ configDir: DEFAULT_CONFIG_DIR,
14
+ dryRun: false,
15
+ raw: false,
16
+ help: false,
17
+ commandArgs: [],
18
+ };
19
+
20
+ let index = 0;
21
+ while (index < argv.length) {
22
+ const current = argv[index];
23
+ const descriptor = LEADING_GLOBAL_OPTIONS[current];
24
+ if (!descriptor) {
25
+ break;
26
+ }
27
+
28
+ if (descriptor.takesValue) {
29
+ const value = argv[index + 1];
30
+ if (!value) {
31
+ exitWithError(`Missing value for ${current}.`);
32
+ }
33
+
34
+ if (descriptor.key === "method") {
35
+ state.method = value.toUpperCase();
36
+ } else if (descriptor.key === "data") {
37
+ state.data = parseJson(current, value);
38
+ } else if (descriptor.key === "header") {
39
+ const [name, headerValue] = parseHeader(value);
40
+ state.headers[name] = headerValue;
41
+ } else {
42
+ state[descriptor.key] = value;
43
+ }
44
+
45
+ index += 2;
46
+ continue;
47
+ }
48
+
49
+ state[descriptor.key] = true;
50
+ index += 1;
51
+ }
52
+
53
+ state.commandArgs = argv.slice(index);
54
+ return state;
55
+ }
package/src/request.js ADDED
@@ -0,0 +1,95 @@
1
+ import { resolveAppState } from "./config.js";
2
+ import { apiPath, exitWithError, printJson, sanitizeForOutput } from "./utils.js";
3
+
4
+ export async function performRequest(state, config, request) {
5
+ const appState = resolveAppState(state, config);
6
+ if (!appState.apiKey && !state.dryRun) {
7
+ const guidance = [
8
+ "Use --api-key, set APITWEET_KEY, or configure an app with auth apps add.",
9
+ "Get an API key here: https://apitweet.io/dashboard",
10
+ 'Example: apitweet auth apps add --name prod --api-key "twitterx_..."',
11
+ ].join("\n");
12
+ exitWithError("Missing API key.", guidance);
13
+ }
14
+
15
+ const target = apiPath(request.path);
16
+ const url = /^https?:\/\//i.test(target) ? target : `${appState.baseUrl}${target}`;
17
+ const headers = {
18
+ Accept: "application/json",
19
+ ...state.headers,
20
+ ...request.headers,
21
+ };
22
+
23
+ if (appState.apiKey) {
24
+ headers.Authorization = `Bearer ${appState.apiKey}`;
25
+ }
26
+
27
+ let body;
28
+ if (request.body !== undefined) {
29
+ headers["Content-Type"] = headers["Content-Type"] || "application/json";
30
+ body = headers["Content-Type"].includes("application/json")
31
+ ? JSON.stringify(request.body)
32
+ : String(request.body);
33
+ }
34
+
35
+ if (state.dryRun) {
36
+ const preview = sanitizeForOutput({
37
+ method: request.method,
38
+ url,
39
+ headers,
40
+ ...(body
41
+ ? {
42
+ body: headers["Content-Type"]?.includes("application/json") ? JSON.parse(body) : body,
43
+ }
44
+ : {}),
45
+ });
46
+ printJson(preview);
47
+ return { dryRun: true, data: preview };
48
+ }
49
+
50
+ const response = await fetch(url, {
51
+ method: request.method,
52
+ headers,
53
+ body,
54
+ });
55
+
56
+ const text = await response.text();
57
+ const contentType = response.headers.get("content-type") || "";
58
+ const isJson = contentType.includes("application/json");
59
+ let data = text;
60
+
61
+ if (isJson && text) {
62
+ try {
63
+ data = JSON.parse(text);
64
+ } catch (error) {
65
+ exitWithError("Failed to parse JSON response.", error.message);
66
+ }
67
+ }
68
+
69
+ if (!response.ok) {
70
+ if (isJson) {
71
+ console.error(JSON.stringify(data, null, 2));
72
+ } else {
73
+ console.error(text);
74
+ }
75
+ process.exit(response.status || 1);
76
+ }
77
+
78
+ if (!request.silent) {
79
+ if (state.raw || !isJson) {
80
+ process.stdout.write(text);
81
+ if (!text.endsWith("\n")) {
82
+ process.stdout.write("\n");
83
+ }
84
+ } else {
85
+ printJson(data);
86
+ }
87
+ }
88
+
89
+ return {
90
+ response,
91
+ text,
92
+ isJson,
93
+ data,
94
+ };
95
+ }