chirpie 1.0.41 → 1.0.43

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 +4 -3
  2. package/dist/index.js +115 -19
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -30,10 +30,11 @@ 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 URLs, `-s` schedule |
33
+ | `chirpie post <text>` | Create a post. `-a` account, `-m` media files or URLs, `--alt` descriptions, `-s` schedule |
34
34
  | `chirpie thread <post> <post> ...` | Create a 2 to 25 post thread. `-f` takes a JSON payload file |
35
35
  | `chirpie posts` | List posts. `--status`, `--account`, `--limit` |
36
36
  | `chirpie posts get <id>` | Fetch one post |
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 |
37
38
  | `chirpie posts delete <id>` | Delete a post. Deleting any post of a scheduled thread cancels the thread |
38
39
  | `chirpie accounts` | List connected accounts, active and inactive |
39
40
  | `chirpie accounts activate <id>` / `deactivate <id>` | Choose which accounts publish. Deactivating cancels that account's scheduled posts |
@@ -56,8 +57,8 @@ Run `chirpie <command> --help` for the full option list. Threads, Instagram, Fac
56
57
  # Post now, picking the account interactively
57
58
  chirpie post "Shipped a new release."
58
59
 
59
- # Post with media to a specific account
60
- chirpie post "Look at this" -a 550e8400-e29b-41d4-a716-446655440000 -m https://example.com/a.png
60
+ # Post with media to a specific account. A local file is uploaded first.
61
+ chirpie post "Look at this" -a 550e8400-e29b-41d4-a716-446655440000 -m ./shot.png --alt "The new dashboard"
61
62
 
62
63
  # Schedule. Any ISO 8601 timestamp that carries a timezone.
63
64
  chirpie post "Later" -s 2026-04-01T14:00:00Z
package/dist/index.js CHANGED
@@ -45,10 +45,10 @@ var authCommand = new Command("auth").description("Configure your Chirpie API ke
45
45
  let apiKey = opts.key;
46
46
  if (!apiKey) {
47
47
  const rl = createInterface({ input: process.stdin, output: process.stdout });
48
- apiKey = await new Promise((resolve) => {
48
+ apiKey = await new Promise((resolve2) => {
49
49
  rl.question("Enter your Chirpie API key: ", (answer) => {
50
50
  rl.close();
51
- resolve(answer.trim());
51
+ resolve2(answer.trim());
52
52
  });
53
53
  });
54
54
  }
@@ -156,8 +156,33 @@ function getClient() {
156
156
  });
157
157
  }
158
158
 
159
+ // src/lib/media.ts
160
+ import { readFile } from "fs/promises";
161
+ import { basename, resolve } from "path";
162
+ async function resolveMediaArgs(client, media, alt) {
163
+ if (!media?.length) return void 0;
164
+ const items = [];
165
+ for (const [index, value] of media.entries()) {
166
+ const description = alt?.[index];
167
+ if (/^https?:\/\//i.test(value)) {
168
+ items.push({ url: value, ...description ? { alt: description } : {} });
169
+ continue;
170
+ }
171
+ const path = resolve(value);
172
+ const bytes = await readFile(path);
173
+ const uploaded = await client.uploadMedia({
174
+ data: new Uint8Array(bytes),
175
+ filename: basename(path)
176
+ });
177
+ items.push({ id: uploaded.id, ...description ? { alt: description } : {} });
178
+ }
179
+ return items;
180
+ }
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
+ 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
+
159
184
  // src/commands/post.ts
160
- var postCommand = new Command3("post").description("Create a post").argument("<text>", "Post text").option("-a, --account <id>", "Account ID").option("-m, --media <urls...>", "Image or video URLs. 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.").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) => {
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) => {
161
186
  const client = getClient();
162
187
  let accountId = opts.account;
163
188
  if (!accountId) {
@@ -175,10 +200,11 @@ var postCommand = new Command3("post").description("Create a post").argument("<t
175
200
  accountId = active[0].id;
176
201
  }
177
202
  try {
203
+ const media = await resolveMediaArgs(client, opts.media, opts.alt);
178
204
  const post = await client.createPost({
179
205
  account_id: accountId,
180
206
  text,
181
- media_urls: opts.media,
207
+ ...media ? { media } : {},
182
208
  schedule_at: opts.schedule
183
209
  });
184
210
  if (opts.json) {
@@ -201,12 +227,23 @@ function parseMediaSpec(spec) {
201
227
  const m = spec.match(/^(\d+):(.+)$/);
202
228
  if (!m) return null;
203
229
  const index = parseInt(m[1], 10);
204
- const urls = m[2].split(",").map((u) => u.trim()).filter(Boolean);
205
- return { index, urls };
230
+ const files = m[2].split(",").map((u) => u.trim()).filter(Boolean);
231
+ return { index, files };
232
+ }
233
+ function parseAltSpec(spec) {
234
+ const m = spec.match(/^(\d+):(.+)$/);
235
+ if (!m) return null;
236
+ return {
237
+ index: parseInt(m[1], 10),
238
+ alts: m[2].split(",").map((a) => a.trim())
239
+ };
206
240
  }
207
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(
208
242
  "-m, --media <specs...>",
209
- 'Per-post media as "INDEX:url1,url2" (repeatable). Example: -m 0:https://a.png 2:https://b.png,https://c.png'
243
+ '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
+ ).option(
245
+ "--alt <specs...>",
246
+ 'Per-post alt text as "INDEX:first,second", lining up with --media. Carried to X, Bluesky, LinkedIn, Mastodon, Instagram and Facebook.'
210
247
  ).option(
211
248
  "-f, --file <path>",
212
249
  "JSON file with a full thread payload: { posts: [{ text, media_urls? }, ...], schedule_at?: string }"
@@ -245,6 +282,19 @@ var threadCommand = new Command4("thread").description("Create a thread").argume
245
282
  process.exit(1);
246
283
  }
247
284
  postPayload = posts.map((text) => ({ text }));
285
+ if (opts.alt && !opts.media) {
286
+ error("--alt describes the items in --media, so pass --media too");
287
+ process.exit(1);
288
+ }
289
+ const altByIndex = /* @__PURE__ */ new Map();
290
+ for (const spec of opts.alt ?? []) {
291
+ const parsed = parseAltSpec(spec);
292
+ if (!parsed) {
293
+ error(`Invalid --alt spec: "${spec}". Expected "INDEX:first,second".`);
294
+ process.exit(1);
295
+ }
296
+ altByIndex.set(parsed.index, parsed.alts);
297
+ }
248
298
  if (opts.media) {
249
299
  for (const spec of opts.media) {
250
300
  const parsed = parseMediaSpec(spec);
@@ -256,7 +306,11 @@ var threadCommand = new Command4("thread").description("Create a thread").argume
256
306
  error(`--media index ${parsed.index} out of range (thread has ${postPayload.length} posts)`);
257
307
  process.exit(1);
258
308
  }
259
- postPayload[parsed.index].media_urls = parsed.urls;
309
+ postPayload[parsed.index].media = await resolveMediaArgs(
310
+ client,
311
+ parsed.files,
312
+ altByIndex.get(parsed.index)
313
+ );
260
314
  }
261
315
  }
262
316
  }
@@ -287,7 +341,7 @@ function wantsJson(opts, cmd) {
287
341
  }
288
342
 
289
343
  // src/commands/posts.ts
290
- var postsCommand = new Command5("posts").description("List, get, 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) => {
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) => {
291
345
  const client = getClient();
292
346
  try {
293
347
  const posts = await client.listPosts({
@@ -332,6 +386,48 @@ postsCommand.command("get <id>").description("Get a post by ID").option("--json"
332
386
  process.exit(1);
333
387
  }
334
388
  });
389
+ postsCommand.command("update <id>").description(
390
+ "Edit a post that has not published yet. Leaving --schedule-at out keeps the time it already has"
391
+ ).option("-t, --text <text>", "Replacement post text").option(
392
+ "-m, --media <files...>",
393
+ "Replacement image or video files on this machine, or public URLs, following the same per-platform rules as creating a post. Pass --clear-media to remove the media instead."
394
+ ).option("--alt <text...>", ALT_OPTION_HELP).option("--clear-media", "Remove the media from the post").option(
395
+ "-s, --schedule-at <datetime>",
396
+ "New publish time. Any ISO 8601 timestamp with a timezone (2027-04-01T14:00:00Z or 2027-04-01T16:00:00+02:00). Omit it to keep the time the post already has. Rescheduling one part of a thread moves the whole thread."
397
+ ).option("--json", "Output raw JSON").action(async (id, opts, cmd) => {
398
+ if (opts.media && opts.clearMedia) {
399
+ error("Pass either --media or --clear-media, not both");
400
+ process.exit(1);
401
+ }
402
+ if (!opts.text && !opts.media && !opts.clearMedia && !opts.scheduleAt) {
403
+ error("Nothing to change. Pass --text, --media, --clear-media or --schedule-at");
404
+ process.exit(1);
405
+ }
406
+ const client = getClient();
407
+ try {
408
+ const media = await resolveMediaArgs(client, opts.media, opts.alt);
409
+ const post = await client.updatePost(id, {
410
+ ...opts.text !== void 0 ? { text: opts.text } : {},
411
+ ...opts.clearMedia ? { media: [] } : {},
412
+ ...media ? { media } : {},
413
+ ...opts.scheduleAt ? { schedule_at: opts.scheduleAt } : {}
414
+ });
415
+ if (wantsJson(opts, cmd)) {
416
+ json(post);
417
+ } else {
418
+ const moved = post.rescheduled_post_ids ?? [];
419
+ success(`Post ${post.id} updated`);
420
+ if (opts.scheduleAt) {
421
+ success(
422
+ moved.length > 1 ? `Thread rescheduled for ${post.scheduled_at} (${moved.length} posts)` : `Scheduled for ${post.scheduled_at}`
423
+ );
424
+ }
425
+ }
426
+ } catch (err) {
427
+ apiError(err, "Failed to update post");
428
+ process.exit(1);
429
+ }
430
+ });
335
431
  postsCommand.command("delete <id>").description(
336
432
  "Delete a post (deleting any post of a scheduled thread cancels the thread)"
337
433
  ).option("--json", "Output raw JSON").action(async (id, opts, cmd) => {
@@ -362,14 +458,14 @@ async function readSecretFromStdin(prompt) {
362
458
  if (!stdin.isTTY) {
363
459
  const readline = await import("readline");
364
460
  const rl = readline.createInterface({ input: stdin });
365
- return new Promise((resolve) => {
461
+ return new Promise((resolve2) => {
366
462
  rl.on("line", (line) => {
367
463
  rl.close();
368
- resolve(line.trim());
464
+ resolve2(line.trim());
369
465
  });
370
466
  });
371
467
  }
372
- return new Promise((resolve) => {
468
+ return new Promise((resolve2) => {
373
469
  const chars = [];
374
470
  stdin.setRawMode(true);
375
471
  stdin.resume();
@@ -383,7 +479,7 @@ async function readSecretFromStdin(prompt) {
383
479
  stdin.pause();
384
480
  stdin.off("data", onData);
385
481
  process.stderr.write("\n");
386
- resolve(chars.join("").trim());
482
+ resolve2(chars.join("").trim());
387
483
  break;
388
484
  case "":
389
485
  stdin.setRawMode(false);
@@ -507,10 +603,10 @@ async function confirm(question) {
507
603
  input: process.stdin,
508
604
  output: process.stderr
509
605
  });
510
- return new Promise((resolve) => {
606
+ return new Promise((resolve2) => {
511
607
  rl.question(`${question} [y/N] `, (answer) => {
512
608
  rl.close();
513
- resolve(answer.trim().toLowerCase() === "y");
609
+ resolve2(answer.trim().toLowerCase() === "y");
514
610
  });
515
611
  });
516
612
  }
@@ -683,11 +779,11 @@ accountsCommand.command("connect-telegram").description("Connect a Telegram bot
683
779
  if (!chatId) {
684
780
  const readline = await import("readline");
685
781
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
686
- chatId = await new Promise((resolve) => {
782
+ chatId = await new Promise((resolve2) => {
687
783
  process.stderr.write("Chat ID: ");
688
784
  rl.question("", (answer) => {
689
785
  rl.close();
690
- resolve(answer.trim());
786
+ resolve2(answer.trim());
691
787
  });
692
788
  });
693
789
  if (!chatId) {
@@ -895,10 +991,10 @@ async function confirm2(question) {
895
991
  input: process.stdin,
896
992
  output: process.stderr
897
993
  });
898
- return new Promise((resolve) => {
994
+ return new Promise((resolve2) => {
899
995
  rl.question(`${question} [y/N] `, (answer) => {
900
996
  rl.close();
901
- resolve(answer.trim().toLowerCase() === "y");
997
+ resolve2(answer.trim().toLowerCase() === "y");
902
998
  });
903
999
  });
904
1000
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chirpie",
3
- "version": "1.0.41",
3
+ "version": "1.0.43",
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.38",
47
+ "@chirpie/sdk": "^1.0.40",
48
48
  "commander": "^13"
49
49
  },
50
50
  "devDependencies": {