chirpie 1.0.43 → 1.0.45

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.
Files changed (3) hide show
  1. package/README.md +25 -3
  2. package/dist/index.js +190 -38
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -30,9 +30,9 @@ export CHIRPIE_API_KEY=chirpie_sk_...
30
30
  | Command | What it does |
31
31
  |---------|--------------|
32
32
  | `chirpie login` / `logout` / `auth` / `whoami` | Manage credentials |
33
- | `chirpie post <text>` | Create a post. `-a` account, `-m` media files or URLs, `--alt` descriptions, `-s` schedule |
34
- | `chirpie thread <post> <post> ...` | Create a 2 to 25 post thread. `-f` takes a JSON payload file |
35
- | `chirpie posts` | List posts. `--status`, `--account`, `--limit` |
33
+ | `chirpie post <text>` | Create a post. `-a` account (repeat it for several accounts), `-m` media files or URLs, `--alt` descriptions, `--config` per-account overrides, `-s` schedule |
34
+ | `chirpie thread <post> <post> ...` | Create a 2 to 25 post thread. `-a` is repeatable, `--config` takes per-account posts, `-f` takes a JSON payload file |
35
+ | `chirpie posts` | List posts. `--status`, `--account`, `--group`, `--limit` |
36
36
  | `chirpie posts get <id>` | Fetch one post |
37
37
  | `chirpie posts update <id>` | Edit a post that has not published yet. `--text`, `--media`, `--alt`, `--clear-media`, `--schedule-at`. Leaving `--schedule-at` out keeps the time it already has |
38
38
  | `chirpie posts delete <id>` | Delete a post. Deleting any post of a scheduled thread cancels the thread |
@@ -70,6 +70,28 @@ chirpie thread "One" "Two" "Three" -a 550e8400-e29b-41d4-a716-446655440000
70
70
  chirpie posts --status scheduled
71
71
  ```
72
72
 
73
+ ## Several accounts at once
74
+
75
+ Repeat `-a` to publish the same post to up to 25 accounts in one call.
76
+
77
+ ```bash
78
+ chirpie post "Shipped a new release." -a "$X_ACCOUNT" -a "$BSKY_ACCOUNT"
79
+
80
+ # Give one account its own text, inline or from a file
81
+ chirpie post "Shipped a new release." -a "$X_ACCOUNT" -a "$BSKY_ACCOUNT" \
82
+ --config "{\"$BSKY_ACCOUNT\":{\"text\":\"Shorter, for Bluesky\"}}"
83
+ chirpie post "Shipped a new release." -a "$X_ACCOUNT" -a "$BSKY_ACCOUNT" --config ./overrides.json
84
+
85
+ # A thread, with a thread of its own for one account
86
+ chirpie thread "One" "Two" -a "$X_ACCOUNT" -a "$BSKY_ACCOUNT" \
87
+ --config "{\"$BSKY_ACCOUNT\":{\"posts\":[{\"text\":\"A\"},{\"text\":\"B\"}]}}"
88
+
89
+ # Read the whole group back
90
+ chirpie posts --group 550e8400-e29b-41d4-a716-446655440000
91
+ ```
92
+
93
+ One line is printed per account, with a group ID above them. An account the platform refused is shown with its error while the others stay published, and the command leaves with a non-zero status when any account failed. An override says only what differs, and naming media replaces the shared media for that account.
94
+
73
95
  ## JSON output
74
96
 
75
97
  Every command accepts `--json` and prints the raw API response, which is what you want in a script:
package/dist/index.js CHANGED
@@ -181,28 +181,127 @@ async function resolveMediaArgs(client, media, alt) {
181
181
  var MEDIA_OPTION_HELP = "Image or video files on this machine, or public URLs. Files are uploaded first. Image caps: X, Bluesky, LinkedIn and Mastodon 4, Threads 1, Instagram, Facebook and Telegram 10. Video: X, Mastodon and Telegram only, one per post, never with images. Anything a platform cannot take is refused rather than dropped.";
182
182
  var ALT_OPTION_HELP = "Describe each item for people using a screen reader, in the same order as --media. Carried to X, Bluesky, LinkedIn, Mastodon, Instagram and Facebook.";
183
183
 
184
- // src/commands/post.ts
185
- var postCommand = new Command3("post").description("Create a post").argument("<text>", "Post text").option("-a, --account <id>", "Account ID").option("-m, --media <files...>", MEDIA_OPTION_HELP).option("--alt <text...>", ALT_OPTION_HELP).option("-s, --schedule <datetime>", "Schedule for later. Any ISO 8601 timestamp with a timezone (2026-04-01T14:00:00Z or 2026-04-01T16:00:00+02:00). Posts publish within ~5 minutes of the scheduled time.").option("--json", "Output raw JSON").action(async (text, opts) => {
186
- const client = getClient();
187
- let accountId = opts.account;
188
- if (!accountId) {
189
- const accounts = await client.listAccounts();
190
- const active = accounts.filter((a) => a.is_active);
191
- if (active.length === 0) {
192
- error("No connected accounts. Run: chirpie accounts connect");
184
+ // src/lib/accounts.ts
185
+ import { readFileSync } from "fs";
186
+ function collect(value, previous = []) {
187
+ return [...previous, value];
188
+ }
189
+ async function resolveSingleAccountId(client) {
190
+ const accounts = await client.listAccounts();
191
+ const active = accounts.filter((a) => a.is_active);
192
+ if (active.length === 0) {
193
+ error("No connected accounts. Run: chirpie accounts connect");
194
+ process.exit(1);
195
+ }
196
+ if (active.length > 1) {
197
+ error("Multiple accounts found. Use --account <id> to specify:");
198
+ active.forEach((a) => console.log(` ${a.id} @${a.username}`));
199
+ process.exit(1);
200
+ }
201
+ return active[0].id;
202
+ }
203
+ function parseAccountConfigurations(value) {
204
+ if (!value) return void 0;
205
+ const trimmed = value.trim();
206
+ let raw;
207
+ if (trimmed.startsWith("{")) {
208
+ raw = trimmed;
209
+ } else {
210
+ try {
211
+ raw = readFileSync(trimmed, "utf8");
212
+ } catch {
213
+ error(`Could not read --config file: ${trimmed}`);
193
214
  process.exit(1);
194
215
  }
195
- if (active.length > 1) {
196
- error("Multiple accounts found. Use --account <id> to specify:");
197
- active.forEach((a) => console.log(` ${a.id} @${a.username}`));
198
- process.exit(1);
216
+ }
217
+ let parsed;
218
+ try {
219
+ parsed = JSON.parse(raw);
220
+ } catch {
221
+ error("--config must be a JSON object, or a path to a file holding one");
222
+ process.exit(1);
223
+ }
224
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
225
+ error("--config must be a JSON object keyed by account ID");
226
+ process.exit(1);
227
+ }
228
+ return parsed;
229
+ }
230
+ function printFanOut(groupId, lines) {
231
+ const failed = lines.filter((line) => !line.success);
232
+ console.log(`Group ${groupId}`);
233
+ for (const line of lines) {
234
+ if (line.success) {
235
+ const where = line.platform_post_url ? ` ${line.platform_post_url}` : "";
236
+ console.log(
237
+ `\x1B[32m\u2713\x1B[0m ${line.platform} ${line.account_id} ${line.status ?? ""} ${line.id ?? ""}${where}`.trimEnd()
238
+ );
239
+ } else {
240
+ const reason = line.error ? `${line.error.code}: ${line.error.message}` : "Failed";
241
+ console.log(
242
+ `\x1B[31m\u2717\x1B[0m ${line.platform} ${line.account_id} ${reason}`
243
+ );
199
244
  }
200
- accountId = active[0].id;
201
245
  }
246
+ console.log(
247
+ `${lines.length - failed.length} of ${lines.length} accounts succeeded`
248
+ );
249
+ return failed.length;
250
+ }
251
+
252
+ // src/commands/post.ts
253
+ var postCommand = new Command3("post").description("Create a post").argument("<text>", "Post text").option(
254
+ "-a, --account <id>",
255
+ "Account ID. Repeat it to publish the same post to several accounts at once (up to 25)",
256
+ collect
257
+ ).option("-m, --media <files...>", MEDIA_OPTION_HELP).option("--alt <text...>", ALT_OPTION_HELP).option(
258
+ "--config <json-or-file>",
259
+ 'Per-account overrides as JSON, or a path to a JSON file holding them: { "<account id>": { "text": "...", "media_urls": ["..."] } }. Every key must be an account named with --account'
260
+ ).option("-s, --schedule <datetime>", "Schedule for later. Any ISO 8601 timestamp with a timezone (2026-04-01T14:00:00Z or 2026-04-01T16:00:00+02:00). Posts publish within ~5 minutes of the scheduled time.").option("--json", "Output raw JSON").action(async (text, opts) => {
261
+ const client = getClient();
262
+ const named = opts.account ?? [];
263
+ if (opts.config && named.length === 0) {
264
+ error("--config names accounts, so pass --account <id> for each of them");
265
+ process.exit(1);
266
+ }
267
+ const accountIds = named.length > 0 ? named : [await resolveSingleAccountId(client)];
268
+ const fanOut = named.length > 1 || Boolean(opts.config);
202
269
  try {
203
270
  const media = await resolveMediaArgs(client, opts.media, opts.alt);
271
+ if (fanOut) {
272
+ const result = await client.createPost({
273
+ account_ids: accountIds,
274
+ text,
275
+ ...media ? { media } : {},
276
+ ...opts.config ? {
277
+ account_configurations: parseAccountConfigurations(
278
+ opts.config
279
+ )
280
+ } : {},
281
+ schedule_at: opts.schedule
282
+ });
283
+ if (opts.json) {
284
+ json(result);
285
+ if (result.results.some((r) => !r.success)) process.exit(1);
286
+ return;
287
+ }
288
+ const failed = printFanOut(
289
+ result.group_id,
290
+ result.results.map((r) => ({
291
+ account_id: r.account_id,
292
+ platform: r.platform,
293
+ success: r.success,
294
+ id: r.post_id,
295
+ status: r.status,
296
+ platform_post_url: r.platform_post_url,
297
+ error: r.error
298
+ }))
299
+ );
300
+ if (failed > 0) process.exit(1);
301
+ return;
302
+ }
204
303
  const post = await client.createPost({
205
- account_id: accountId,
304
+ account_id: accountIds[0],
206
305
  text,
207
306
  ...media ? { media } : {},
208
307
  schedule_at: opts.schedule
@@ -222,7 +321,7 @@ var postCommand = new Command3("post").description("Create a post").argument("<t
222
321
 
223
322
  // src/commands/thread.ts
224
323
  import { Command as Command4 } from "commander";
225
- import { readFileSync } from "fs";
324
+ import { readFileSync as readFileSync2 } from "fs";
226
325
  function parseMediaSpec(spec) {
227
326
  const m = spec.match(/^(\d+):(.+)$/);
228
327
  if (!m) return null;
@@ -238,7 +337,11 @@ function parseAltSpec(spec) {
238
337
  alts: m[2].split(",").map((a) => a.trim())
239
338
  };
240
339
  }
241
- var threadCommand = new Command4("thread").description("Create a thread").argument("[posts...]", "Thread posts (each argument is one post). Omit when using --file.").option("-a, --account <id>", "Account ID").option(
340
+ var threadCommand = new Command4("thread").description("Create a thread").argument("[posts...]", "Thread posts (each argument is one post). Omit when using --file.").option(
341
+ "-a, --account <id>",
342
+ "Account ID. Repeat it to publish the same thread to several accounts at once (up to 25)",
343
+ collect
344
+ ).option(
242
345
  "-m, --media <specs...>",
243
346
  'Per-post media as "INDEX:file1,file2" (repeatable). Files on this machine are uploaded first; public URLs are used as they are. Example: -m 0:./shot.png 2:https://b.png,https://c.png'
244
347
  ).option(
@@ -246,32 +349,27 @@ var threadCommand = new Command4("thread").description("Create a thread").argume
246
349
  'Per-post alt text as "INDEX:first,second", lining up with --media. Carried to X, Bluesky, LinkedIn, Mastodon, Instagram and Facebook.'
247
350
  ).option(
248
351
  "-f, --file <path>",
249
- "JSON file with a full thread payload: { posts: [{ text, media_urls? }, ...], schedule_at?: string }"
352
+ "JSON file with a full thread payload: { posts: [{ text, media_urls? }, ...], schedule_at?: string }. It may also carry account_ids and account_configurations."
353
+ ).option(
354
+ "--config <json-or-file>",
355
+ `Per-account overrides as JSON, or a path to a JSON file holding them: { "<account id>": { "posts": [{ "text": "one" }, { "text": "two" }] } }. An account's posts replace the whole thread for that account`
250
356
  ).option("-s, --schedule <datetime>", "Schedule for later. Any ISO 8601 timestamp with a timezone (2026-04-01T14:00:00Z or 2026-04-01T16:00:00+02:00). Threads publish within ~5 minutes of the scheduled time.").option("--json", "Output raw JSON").action(async (posts, opts) => {
251
357
  const client = getClient();
252
- let accountId = opts.account;
253
- if (!accountId) {
254
- const accounts = await client.listAccounts();
255
- const active = accounts.filter((a) => a.is_active);
256
- if (active.length === 0) {
257
- error("No connected accounts. Run: chirpie accounts connect");
258
- process.exit(1);
259
- }
260
- if (active.length > 1) {
261
- error("Multiple accounts found. Use --account <id> to specify:");
262
- active.forEach((a) => console.log(` ${a.id} @${a.username}`));
263
- process.exit(1);
264
- }
265
- accountId = active[0].id;
266
- }
267
358
  let postPayload;
268
359
  let scheduleAt = opts.schedule;
360
+ let fileAccountIds;
361
+ let fileConfigurations;
269
362
  if (opts.file) {
270
363
  try {
271
- const raw = JSON.parse(readFileSync(opts.file, "utf8"));
364
+ const raw = JSON.parse(readFileSync2(opts.file, "utf8"));
272
365
  if (!Array.isArray(raw.posts)) throw new Error("File must contain { posts: [...] }");
273
366
  postPayload = raw.posts;
274
367
  scheduleAt = scheduleAt ?? raw.schedule_at;
368
+ if (raw.account_ids !== void 0 && (!Array.isArray(raw.account_ids) || raw.account_ids.some((id) => typeof id !== "string"))) {
369
+ throw new Error("account_ids in the file must be an array of account IDs");
370
+ }
371
+ fileAccountIds = raw.account_ids;
372
+ fileConfigurations = raw.account_configurations;
275
373
  } catch (err) {
276
374
  error(err instanceof Error ? err.message : "Failed to read thread file");
277
375
  process.exit(1);
@@ -314,9 +412,44 @@ var threadCommand = new Command4("thread").description("Create a thread").argume
314
412
  }
315
413
  }
316
414
  }
415
+ const configurations = parseAccountConfigurations(opts.config) ?? fileConfigurations;
416
+ const named = opts.account ?? fileAccountIds ?? [];
417
+ if (configurations && named.length === 0) {
418
+ error("Per-account posts name accounts, so pass --account <id> for each of them");
419
+ process.exit(1);
420
+ }
421
+ const accountIds = named.length > 0 ? named : [await resolveSingleAccountId(client)];
422
+ const fanOut = named.length > 1 || Boolean(configurations);
317
423
  try {
424
+ if (fanOut) {
425
+ const result = await client.createThread({
426
+ account_ids: accountIds,
427
+ posts: postPayload,
428
+ ...configurations ? { account_configurations: configurations } : {},
429
+ schedule_at: scheduleAt
430
+ });
431
+ if (opts.json) {
432
+ json(result);
433
+ if (result.results.some((r) => !r.success)) process.exit(1);
434
+ return;
435
+ }
436
+ const failed = printFanOut(
437
+ result.group_id,
438
+ result.results.map((r) => ({
439
+ account_id: r.account_id,
440
+ platform: r.platform,
441
+ success: r.success,
442
+ id: r.thread_id,
443
+ status: r.status,
444
+ platform_post_url: r.platform_post_url,
445
+ error: r.error
446
+ }))
447
+ );
448
+ if (failed > 0) process.exit(1);
449
+ return;
450
+ }
318
451
  const thread = await client.createThread({
319
- account_id: accountId,
452
+ account_id: accountIds[0],
320
453
  posts: postPayload,
321
454
  schedule_at: scheduleAt
322
455
  });
@@ -333,6 +466,7 @@ var threadCommand = new Command4("thread").description("Create a thread").argume
333
466
 
334
467
  // src/commands/posts.ts
335
468
  import { Command as Command5 } from "commander";
469
+ import { postFailureReason } from "@chirpie/sdk";
336
470
 
337
471
  // src/lib/flags.ts
338
472
  function wantsJson(opts, cmd) {
@@ -341,12 +475,16 @@ function wantsJson(opts, cmd) {
341
475
  }
342
476
 
343
477
  // src/commands/posts.ts
344
- var postsCommand = new Command5("posts").description("List, get, update, or delete posts").option("--status <status>", "Filter by status").option("--account <id>", "Filter by account").option("--limit <n>", "Limit results", "20").option("--json", "Output raw JSON").action(async (opts, cmd) => {
478
+ var postsCommand = new Command5("posts").description("List, get, update, or delete posts").option("--status <status>", "Filter by status").option("--account <id>", "Filter by account").option(
479
+ "--group <id>",
480
+ "Only the posts of one fan-out, from the group ID a multi-account post returned"
481
+ ).option("--limit <n>", "Limit results", "20").option("--json", "Output raw JSON").action(async (opts, cmd) => {
345
482
  const client = getClient();
346
483
  try {
347
484
  const posts = await client.listPosts({
348
485
  status: opts.status,
349
486
  account_id: opts.account,
487
+ group_id: opts.group,
350
488
  limit: parseInt(opts.limit)
351
489
  });
352
490
  if (wantsJson(opts, cmd)) {
@@ -357,7 +495,10 @@ var postsCommand = new Command5("posts").description("List, get, update, or dele
357
495
  id: p.id.slice(0, 8),
358
496
  status: p.status,
359
497
  text: p.text.length > 50 ? p.text.slice(0, 50) + "..." : p.text,
360
- created: p.created_at.slice(0, 10)
498
+ created: p.created_at.slice(0, 10),
499
+ // Why a post is not where you expected it. Empty for the ones
500
+ // that are exactly where you left them.
501
+ issue: postFailureReason(p) ?? ""
361
502
  }))
362
503
  );
363
504
  }
@@ -380,6 +521,10 @@ postsCommand.command("get <id>").description("Get a post by ID").option("--json"
380
521
  if (post.scheduled_at) console.log(`Scheduled: ${post.scheduled_at}`);
381
522
  if (post.published_at) console.log(`Published: ${post.published_at}`);
382
523
  if (post.platform_post_id) console.log(`Platform Post ID: ${post.platform_post_id}`);
524
+ if (post.platform_post_url) console.log(`URL: ${post.platform_post_url}`);
525
+ if (post.retry_count > 0) console.log(`Attempts: ${post.retry_count}`);
526
+ const issue = postFailureReason(post);
527
+ if (issue) console.log(`Issue: ${issue}`);
383
528
  }
384
529
  } catch (err) {
385
530
  error(err instanceof Error ? err.message : "Failed to get post");
@@ -525,7 +670,7 @@ var accountsCommand = new Command6("accounts").description("List connected socia
525
670
  // Inactive accounts are listed too, so a Facebook Page that was
526
671
  // connected past the plan's account limit is visible and can be
527
672
  // switched on with `chirpie accounts activate <id>`.
528
- note: a.is_active ? "" : a.inactive_reason === "plan_limit" ? "over plan limit" : ""
673
+ note: a.is_active ? "" : inactiveNote(a.inactive_reason)
529
674
  }))
530
675
  );
531
676
  }
@@ -534,6 +679,13 @@ var accountsCommand = new Command6("accounts").description("List connected socia
534
679
  process.exit(1);
535
680
  }
536
681
  });
682
+ function inactiveNote(reason) {
683
+ if (reason === "plan_limit") return "over plan limit";
684
+ if (reason === "token_revoked" || reason === "token_expired" || reason === "reauth_required") {
685
+ return "needs reconnecting";
686
+ }
687
+ return "";
688
+ }
537
689
  accountsCommand.command("activate").argument("<id>", "Account ID (from `chirpie accounts`)").description("Activate a connected account so it can publish").option("--json", "Output raw JSON").action(async (id, opts, cmd) => {
538
690
  const client = getClient();
539
691
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chirpie",
3
- "version": "1.0.43",
3
+ "version": "1.0.45",
4
4
  "description": "Chirpie CLI: post to social media from the command line",
5
5
  "repository": {
6
6
  "type": "git",
@@ -44,7 +44,7 @@
44
44
  ],
45
45
  "license": "MIT",
46
46
  "dependencies": {
47
- "@chirpie/sdk": "^1.0.40",
47
+ "@chirpie/sdk": "^1.0.42",
48
48
  "commander": "^13"
49
49
  },
50
50
  "devDependencies": {