typefully 0.2.0 → 0.3.0

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/README.md CHANGED
@@ -48,6 +48,7 @@ typefully setup
48
48
  tfly # interactive — pick text, platform, schedule
49
49
  tfly "Hello, world!" # instant draft from text
50
50
 
51
+ tfly schedule # interactive — browse drafts and schedule one
51
52
  tfly rm # interactive — pick drafts to delete
52
53
  tfly rm <draft_id> # delete a specific draft
53
54
 
@@ -104,6 +105,8 @@ typefully config set-default 123 --location global
104
105
  typefully config set-default 123 --scope local # --scope is an alias for --location
105
106
  typefully config set-platforms # interactive multiselect
106
107
  typefully config set-platforms --platforms x,linkedin,threads
108
+ typefully config set-timezone # interactive — pick from common IANA timezones
109
+ typefully config set-timezone --timezone America/New_York
107
110
  ```
108
111
 
109
112
  ### User & Social Sets
@@ -114,6 +117,19 @@ typefully social-sets list
114
117
  typefully social-sets get [social_set_id]
115
118
  ```
116
119
 
120
+ ### Interactive Scheduler
121
+
122
+ ```bash
123
+ tfly schedule
124
+ ```
125
+
126
+ Browse all drafts and schedule one with a keyboard-only flow:
127
+
128
+ - **Happy path** — `↑↓ Enter` pick draft → `Enter` next free slot → `Enter` confirm → `Enter` open in browser
129
+ - **Custom time** — select "Custom date & time", enter date (`YYYY-MM-DD`) and time (`HH:MM`), confirm
130
+
131
+ Timezone defaults to PST. Change it with `tfly config set-timezone`.
132
+
117
133
  ### Drafts
118
134
 
119
135
  ```bash
@@ -207,7 +223,8 @@ Config files are stored as JSON with `0600` permissions:
207
223
  {
208
224
  "apiKey": "typ_xxxx",
209
225
  "defaultSocialSetId": 12345,
210
- "defaultPlatforms": ["x", "linkedin"]
226
+ "defaultPlatforms": ["x", "linkedin"],
227
+ "defaultTimezone": "America/Los_Angeles"
211
228
  }
212
229
  ```
213
230
 
package/dist/index.js CHANGED
@@ -21,7 +21,8 @@ import { z } from "zod/v4";
21
21
  var ConfigSchema = z.object({
22
22
  apiKey: z.string().optional(),
23
23
  defaultSocialSetId: z.union([z.string(), z.number()]).optional(),
24
- defaultPlatforms: z.array(z.string()).optional()
24
+ defaultPlatforms: z.array(z.string()).optional(),
25
+ defaultTimezone: z.string().optional()
25
26
  });
26
27
  var PLATFORMS = ["x", "linkedin", "threads", "bluesky", "mastodon"];
27
28
 
@@ -165,6 +166,26 @@ function getGlobalConfigFile() {
165
166
  function getLocalConfigFile() {
166
167
  return path.join(process.cwd(), LOCAL_CONFIG_FILE);
167
168
  }
169
+ var DEFAULT_TIMEZONE = "America/Los_Angeles";
170
+ function getDefaultTimezone() {
171
+ const localPath = path.join(process.cwd(), LOCAL_CONFIG_FILE);
172
+ const localConfig = readConfigFile(localPath);
173
+ if (localConfig?.defaultTimezone) return localConfig.defaultTimezone;
174
+ const globalConfig = readConfigFile(GLOBAL_CONFIG_FILE);
175
+ if (globalConfig?.defaultTimezone) return globalConfig.defaultTimezone;
176
+ return DEFAULT_TIMEZONE;
177
+ }
178
+ function tzLabel(tz) {
179
+ try {
180
+ const parts = new Intl.DateTimeFormat("en-US", {
181
+ timeZone: tz,
182
+ timeZoneName: "short"
183
+ }).formatToParts(/* @__PURE__ */ new Date());
184
+ return parts.find((p4) => p4.type === "timeZoneName")?.value ?? tz;
185
+ } catch {
186
+ return tz;
187
+ }
188
+ }
168
189
 
169
190
  // src/utils/output.ts
170
191
  import ora from "ora";
@@ -196,9 +217,9 @@ var NOOP_SPINNER = {
196
217
  fail() {
197
218
  }
198
219
  };
199
- function spin(text5) {
220
+ function spin(text6) {
200
221
  if (isJsonMode()) return NOOP_SPINNER;
201
- return ora({ text: text5, color: "cyan" });
222
+ return ora({ text: text6, color: "cyan" });
202
223
  }
203
224
 
204
225
  // src/utils/api.ts
@@ -219,12 +240,12 @@ async function apiRequest(method, endpoint, body, opts = {}) {
219
240
  }
220
241
  const res = await fetch(url, fetchOpts);
221
242
  if (!res.ok) {
222
- const text6 = await res.text().catch(() => "");
243
+ const text7 = await res.text().catch(() => "");
223
244
  let parsed;
224
245
  try {
225
- parsed = JSON.parse(text6);
246
+ parsed = JSON.parse(text7);
226
247
  } catch {
227
- parsed = text6;
248
+ parsed = text7;
228
249
  }
229
250
  if (exitOnError) {
230
251
  console.error(JSON.stringify({ error: `HTTP ${res.status}`, response: parsed }, null, 2));
@@ -235,12 +256,12 @@ async function apiRequest(method, endpoint, body, opts = {}) {
235
256
  err.status = res.status;
236
257
  throw err;
237
258
  }
238
- const text5 = await res.text();
239
- if (!text5) return {};
259
+ const text6 = await res.text();
260
+ if (!text6) return {};
240
261
  try {
241
- return JSON.parse(text5);
262
+ return JSON.parse(text6);
242
263
  } catch {
243
- return text5;
264
+ return text6;
244
265
  }
245
266
  }
246
267
  function sleep(ms) {
@@ -250,8 +271,8 @@ function sleep(ms) {
250
271
  // src/utils/helpers.ts
251
272
  import path2 from "path";
252
273
  import pc2 from "picocolors";
253
- function splitThreadText(text5) {
254
- return text5.split(/\r?\n[ \t]*---[ \t]*\r?\n/).filter((t) => t.trim());
274
+ function splitThreadText(text6) {
275
+ return text6.split(/\r?\n[ \t]*---[ \t]*\r?\n/).filter((t) => t.trim());
255
276
  }
256
277
  function sanitizeFilename(filename) {
257
278
  const ext = path2.extname(filename).toLowerCase();
@@ -430,10 +451,10 @@ function registerDraftsCommand(program2) {
430
451
  if (opts.status) params.set("status", opts.status);
431
452
  if (opts.tag) params.set("tag", opts.tag);
432
453
  if (opts.sort) params.set("order_by", opts.sort);
433
- const spinner3 = spin("Fetching drafts\u2026");
434
- spinner3.start();
454
+ const spinner4 = spin("Fetching drafts\u2026");
455
+ spinner4.start();
435
456
  const data = await apiRequest("GET", `/social-sets/${id}/drafts?${params}`);
436
- spinner3.stop();
457
+ spinner4.stop();
437
458
  display(data, () => renderDraftsList(data));
438
459
  });
439
460
  cmd.command("get").description("Get a specific draft").argument("[first_arg]", "social_set_id or draft_id").argument("[second_arg]", "draft_id (when first arg is social_set_id)").option("--social-set-id <id>", "Social set ID via flag (first arg becomes draft_id)").option("--use-default", "Confirm using default social set").action(
@@ -445,22 +466,22 @@ function registerDraftsCommand(program2) {
445
466
  !!opts.useDefault,
446
467
  opts.socialSetId
447
468
  );
448
- const spinner3 = spin("Fetching draft\u2026");
449
- spinner3.start();
469
+ const spinner4 = spin("Fetching draft\u2026");
470
+ spinner4.start();
450
471
  const data = await apiRequest("GET", `/social-sets/${socialSetId}/drafts/${draftId}`);
451
- spinner3.stop();
472
+ spinner4.stop();
452
473
  display(data, () => renderDraft(data));
453
474
  }
454
475
  );
455
476
  cmd.command("create").description("Create a new draft").argument("[social_set_id]", "Social set ID (uses default if omitted)").option("--social-set-id <id>", "Social set ID via flag (overrides positional)").option("--text <text>", "Post content (use --- on its own line for threads)").option("-f, --file <path>", "Read content from file").option("--platform <platforms>", "Comma-separated platforms").option("--all", "Post to all connected platforms").option("--media <media_ids>", "Comma-separated media IDs").option("--title <title>", "Draft title (internal only)").option("--schedule <time>", '"now", "next-free-slot", or ISO datetime').option("--tags <tags>", "Comma-separated tag slugs").option("--reply-to <url>", "URL of X post to reply to").option("--community <id>", "X community ID").option("--share", "Generate a public share URL").option("--scratchpad <text>", "Internal notes/scratchpad").option("--notes <text>", "Internal notes/scratchpad (alias for --scratchpad)").action(async (socialSetId, opts) => {
456
477
  const id = resolveSocialSetId(opts.socialSetId ?? socialSetId);
457
- let text5 = opts.text;
478
+ let text6 = opts.text;
458
479
  if (opts.file) {
459
480
  const filePath = opts.file;
460
481
  if (!fs2.existsSync(filePath)) exitWithError(`File not found: ${filePath}`);
461
- text5 = fs2.readFileSync(filePath, "utf-8");
482
+ text6 = fs2.readFileSync(filePath, "utf-8");
462
483
  }
463
- if (!text5) exitWithError("--text or --file is required");
484
+ if (!text6) exitWithError("--text or --file is required");
464
485
  if (opts.all && opts.platform) {
465
486
  exitWithError("Cannot use both --all and --platform flags");
466
487
  }
@@ -470,12 +491,12 @@ function registerDraftsCommand(program2) {
470
491
  if (allPlatforms.length === 0) exitWithError("No connected platforms found");
471
492
  platformList = [...allPlatforms];
472
493
  } else if (opts.platform) {
473
- platformList = opts.platform.split(",").map((p3) => p3.trim());
494
+ platformList = opts.platform.split(",").map((p4) => p4.trim());
474
495
  } else {
475
496
  const saved = getDefaultPlatforms();
476
497
  if (saved?.length) {
477
498
  const connected = await getAllConnectedPlatforms(id);
478
- platformList = saved.filter((p3) => connected.includes(p3));
499
+ platformList = saved.filter((p4) => connected.includes(p4));
479
500
  if (platformList.length === 0) platformList = [...connected];
480
501
  } else {
481
502
  const defaultPlatform = await getFirstConnectedPlatform(id);
@@ -483,7 +504,7 @@ function registerDraftsCommand(program2) {
483
504
  platformList = [defaultPlatform];
484
505
  }
485
506
  }
486
- const posts = splitThreadText(text5);
507
+ const posts = splitThreadText(text6);
487
508
  const mediaIds = opts.media ? opts.media.split(",").map((m) => m.trim()) : [];
488
509
  const postsArray = posts.map((postText, index) => {
489
510
  const post = { text: postText };
@@ -510,10 +531,10 @@ function registerDraftsCommand(program2) {
510
531
  if (opts.share) body.share = true;
511
532
  const scratchpad = opts.notes ?? opts.scratchpad;
512
533
  if (scratchpad) body.scratchpad_text = scratchpad;
513
- const spinner3 = spin("Creating draft\u2026");
514
- spinner3.start();
534
+ const spinner4 = spin("Creating draft\u2026");
535
+ spinner4.start();
515
536
  const data = await apiRequest("POST", `/social-sets/${id}/drafts`, body);
516
- spinner3.stop();
537
+ spinner4.stop();
517
538
  display(data, () => renderDraft(data, "Draft created"));
518
539
  });
519
540
  cmd.command("update").description("Update an existing draft").argument("[first_arg]", "social_set_id or draft_id").argument("[second_arg]", "draft_id (when first arg is social_set_id)").option("--social-set-id <id>", "Social set ID via flag (first arg becomes draft_id)").option("--text <text>", "New post content").option("-f, --file <path>", "Read content from file").option("--platform <platforms>", "Comma-separated platforms").option("--media <media_ids>", "Comma-separated media IDs").option("-a, --append", "Append to existing thread").option("--title <title>", "New draft title").option("--schedule <time>", '"now", "next-free-slot", or ISO datetime').option("--tags <tags>", "Comma-separated tag slugs").option("--share", "Generate a public share URL").option("--scratchpad <text>", "Internal notes/scratchpad").option("--notes <text>", "Internal notes/scratchpad (alias for --scratchpad)").option("--use-default", "Confirm using default social set").action(
@@ -525,14 +546,14 @@ function registerDraftsCommand(program2) {
525
546
  !!opts.useDefault,
526
547
  opts.socialSetId
527
548
  );
528
- let text5 = opts.text;
549
+ let text6 = opts.text;
529
550
  if (opts.file) {
530
551
  const filePath = opts.file;
531
552
  if (!fs2.existsSync(filePath)) exitWithError(`File not found: ${filePath}`);
532
- text5 = fs2.readFileSync(filePath, "utf-8");
553
+ text6 = fs2.readFileSync(filePath, "utf-8");
533
554
  }
534
555
  const body = {};
535
- if (text5) {
556
+ if (text6) {
536
557
  const mediaIds = opts.media ? opts.media.split(",").map((m) => m.trim()) : [];
537
558
  const existing = await apiRequest(
538
559
  "GET",
@@ -540,7 +561,7 @@ function registerDraftsCommand(program2) {
540
561
  );
541
562
  let platformList;
542
563
  if (opts.platform) {
543
- platformList = opts.platform.split(",").map((p3) => p3.trim());
564
+ platformList = opts.platform.split(",").map((p4) => p4.trim());
544
565
  } else {
545
566
  const existingPlatforms = existing.platforms;
546
567
  platformList = Object.entries(existingPlatforms ?? {}).filter(([, config]) => config.enabled).map(([platform]) => platform);
@@ -560,11 +581,11 @@ function registerDraftsCommand(program2) {
560
581
  break;
561
582
  }
562
583
  }
563
- const newPost = { text: text5 };
584
+ const newPost = { text: text6 };
564
585
  if (mediaIds.length > 0) newPost.media_ids = mediaIds;
565
586
  postsArray = [...existingPosts, newPost];
566
587
  } else {
567
- const posts = splitThreadText(text5);
588
+ const posts = splitThreadText(text6);
568
589
  postsArray = posts.map((postText, index) => {
569
590
  const post = { text: postText };
570
591
  if (index === 0 && mediaIds.length > 0) post.media_ids = mediaIds;
@@ -572,8 +593,8 @@ function registerDraftsCommand(program2) {
572
593
  });
573
594
  }
574
595
  const platformsObj = {};
575
- for (const p3 of platformList) {
576
- platformsObj[p3] = { enabled: true, posts: postsArray };
596
+ for (const p4 of platformList) {
597
+ platformsObj[p4] = { enabled: true, posts: postsArray };
577
598
  }
578
599
  body.platforms = platformsObj;
579
600
  }
@@ -590,14 +611,14 @@ function registerDraftsCommand(program2) {
590
611
  "At least one option is required (--text, --file, --title, --schedule, --share, --scratchpad/--notes, or --tags)"
591
612
  );
592
613
  }
593
- const spinner3 = spin("Updating draft\u2026");
594
- spinner3.start();
614
+ const spinner4 = spin("Updating draft\u2026");
615
+ spinner4.start();
595
616
  const data = await apiRequest(
596
617
  "PATCH",
597
618
  `/social-sets/${socialSetId}/drafts/${draftId}`,
598
619
  body
599
620
  );
600
- spinner3.stop();
621
+ spinner4.stop();
601
622
  display(data, () => renderDraft(data, "Draft updated"));
602
623
  }
603
624
  );
@@ -610,10 +631,10 @@ function registerDraftsCommand(program2) {
610
631
  !!opts.useDefault,
611
632
  opts.socialSetId
612
633
  );
613
- const spinner3 = spin("Deleting draft\u2026");
614
- spinner3.start();
634
+ const spinner4 = spin("Deleting draft\u2026");
635
+ spinner4.start();
615
636
  await apiRequest("DELETE", `/social-sets/${socialSetId}/drafts/${draftId}`);
616
- spinner3.succeed("Draft deleted");
637
+ spinner4.succeed("Draft deleted");
617
638
  display({ success: true, message: "Draft deleted" }, () => {
618
639
  });
619
640
  }
@@ -628,12 +649,12 @@ function registerDraftsCommand(program2) {
628
649
  opts.socialSetId
629
650
  );
630
651
  if (!opts.time) exitWithError('--time is required (use "next-free-slot" or ISO datetime)');
631
- const spinner3 = spin("Scheduling draft\u2026");
632
- spinner3.start();
652
+ const spinner4 = spin("Scheduling draft\u2026");
653
+ spinner4.start();
633
654
  const data = await apiRequest("PATCH", `/social-sets/${socialSetId}/drafts/${draftId}`, {
634
655
  publish_at: opts.time
635
656
  });
636
- spinner3.stop();
657
+ spinner4.stop();
637
658
  display(data, () => renderDraft(data, "Draft scheduled"));
638
659
  }
639
660
  );
@@ -646,12 +667,12 @@ function registerDraftsCommand(program2) {
646
667
  !!opts.useDefault,
647
668
  opts.socialSetId
648
669
  );
649
- const spinner3 = spin("Publishing draft\u2026");
650
- spinner3.start();
670
+ const spinner4 = spin("Publishing draft\u2026");
671
+ spinner4.start();
651
672
  const data = await apiRequest("PATCH", `/social-sets/${socialSetId}/drafts/${draftId}`, {
652
673
  publish_at: "now"
653
674
  });
654
- spinner3.stop();
675
+ spinner4.stop();
655
676
  display(data, () => renderDraft(data, "Draft published"));
656
677
  }
657
678
  );
@@ -661,15 +682,15 @@ function registerDraftsCommand(program2) {
661
682
  function registerAliasCommands(program2) {
662
683
  program2.command("create-draft").description('Create a draft \u2014 alias for "drafts create" with positional text').argument("[text]", "Draft text (or use --text/--file)").option("--social-set-id <id>", "Social set ID (uses default if omitted)").option("--text <text>", "Post content (overrides positional text)").option("-f, --file <path>", "Read content from file").option("--platform <platforms>", "Comma-separated platforms").option("--all", "Post to all connected platforms").option("--media <media_ids>", "Comma-separated media IDs").option("--title <title>", "Draft title (internal only)").option("--schedule <time>", '"now", "next-free-slot", or ISO datetime').option("--tags <tags>", "Comma-separated tag slugs").option("--reply-to <url>", "URL of X post to reply to").option("--community <id>", "X community ID").option("--share", "Generate a public share URL").option("--scratchpad <text>", "Internal notes/scratchpad").option("--notes <text>", "Internal notes/scratchpad (alias for --scratchpad)").action(async (positionalText, opts) => {
663
684
  const id = requireSocialSetId(opts.socialSetId ?? null);
664
- let text5;
685
+ let text6;
665
686
  if (opts.file) {
666
687
  const filePath = opts.file;
667
688
  if (!fs3.existsSync(filePath)) exitWithError(`File not found: ${filePath}`);
668
- text5 = fs3.readFileSync(filePath, "utf-8");
689
+ text6 = fs3.readFileSync(filePath, "utf-8");
669
690
  } else {
670
- text5 = opts.text ?? positionalText;
691
+ text6 = opts.text ?? positionalText;
671
692
  }
672
- if (!text5)
693
+ if (!text6)
673
694
  exitWithError("Draft text is required (provide as argument, or use --text/--file)");
674
695
  if (opts.all && opts.platform) exitWithError("Cannot use both --all and --platform flags");
675
696
  let platformList;
@@ -678,12 +699,12 @@ function registerAliasCommands(program2) {
678
699
  if (allPlatforms.length === 0) exitWithError("No connected platforms found");
679
700
  platformList = [...allPlatforms];
680
701
  } else if (opts.platform) {
681
- platformList = opts.platform.split(",").map((p3) => p3.trim());
702
+ platformList = opts.platform.split(",").map((p4) => p4.trim());
682
703
  } else {
683
704
  const saved = getDefaultPlatforms();
684
705
  if (saved?.length) {
685
706
  const connected = await getAllConnectedPlatforms(id);
686
- platformList = saved.filter((p3) => connected.includes(p3));
707
+ platformList = saved.filter((p4) => connected.includes(p4));
687
708
  if (platformList.length === 0) platformList = [...connected];
688
709
  } else {
689
710
  const defaultPlatform = await getFirstConnectedPlatform(id);
@@ -691,7 +712,7 @@ function registerAliasCommands(program2) {
691
712
  platformList = [defaultPlatform];
692
713
  }
693
714
  }
694
- const posts = splitThreadText(text5);
715
+ const posts = splitThreadText(text6);
695
716
  const mediaIds = opts.media ? opts.media.split(",").map((m) => m.trim()) : [];
696
717
  const postsArray = posts.map((postText, index) => {
697
718
  const post = { text: postText };
@@ -716,25 +737,25 @@ function registerAliasCommands(program2) {
716
737
  if (opts.share) body.share = true;
717
738
  const scratchpad = opts.notes ?? opts.scratchpad;
718
739
  if (scratchpad) body.scratchpad_text = scratchpad;
719
- const spinner3 = spin("Creating draft\u2026");
720
- spinner3.start();
740
+ const spinner4 = spin("Creating draft\u2026");
741
+ spinner4.start();
721
742
  const data = await apiRequest("POST", `/social-sets/${id}/drafts`, body);
722
- spinner3.stop();
743
+ spinner4.stop();
723
744
  display(data, () => renderDraft(data, "Draft created"));
724
745
  });
725
746
  program2.command("update-draft").description('Update a draft \u2014 alias for "drafts update" with positional draft_id').argument("<draft_id>", "Draft ID").argument("[text]", "New draft text (or use --text/--file)").option("--social-set-id <id>", "Social set ID (uses default if omitted)").option("--text <text>", "New post content (overrides positional text)").option("-f, --file <path>", "Read content from file").option("--platform <platforms>", "Comma-separated platforms").option("--media <media_ids>", "Comma-separated media IDs").option("-a, --append", "Append to existing thread").option("--title <title>", "New draft title").option("--schedule <time>", '"now", "next-free-slot", or ISO datetime').option("--tags <tags>", "Comma-separated tag slugs").option("--share", "Generate a public share URL").option("--scratchpad <text>", "Internal notes/scratchpad").option("--notes <text>", "Internal notes/scratchpad (alias for --scratchpad)").action(
726
747
  async (draftId, positionalText, opts) => {
727
748
  const socialSetId = requireSocialSetId(opts.socialSetId ?? null);
728
- let text5;
749
+ let text6;
729
750
  if (opts.file) {
730
751
  const filePath = opts.file;
731
752
  if (!fs3.existsSync(filePath)) exitWithError(`File not found: ${filePath}`);
732
- text5 = fs3.readFileSync(filePath, "utf-8");
753
+ text6 = fs3.readFileSync(filePath, "utf-8");
733
754
  } else {
734
- text5 = opts.text ?? positionalText;
755
+ text6 = opts.text ?? positionalText;
735
756
  }
736
757
  const body = {};
737
- if (text5) {
758
+ if (text6) {
738
759
  const mediaIds = opts.media ? opts.media.split(",").map((m) => m.trim()) : [];
739
760
  const existing = await apiRequest(
740
761
  "GET",
@@ -742,7 +763,7 @@ function registerAliasCommands(program2) {
742
763
  );
743
764
  let platformList;
744
765
  if (opts.platform) {
745
- platformList = opts.platform.split(",").map((p3) => p3.trim());
766
+ platformList = opts.platform.split(",").map((p4) => p4.trim());
746
767
  } else {
747
768
  const existingPlatforms = existing.platforms;
748
769
  platformList = Object.entries(existingPlatforms ?? {}).filter(([, config]) => config.enabled).map(([platform]) => platform);
@@ -762,11 +783,11 @@ function registerAliasCommands(program2) {
762
783
  break;
763
784
  }
764
785
  }
765
- const newPost = { text: text5 };
786
+ const newPost = { text: text6 };
766
787
  if (mediaIds.length > 0) newPost.media_ids = mediaIds;
767
788
  postsArray = [...existingPosts, newPost];
768
789
  } else {
769
- const posts = splitThreadText(text5);
790
+ const posts = splitThreadText(text6);
770
791
  postsArray = posts.map((postText, index) => {
771
792
  const post = { text: postText };
772
793
  if (index === 0 && mediaIds.length > 0) post.media_ids = mediaIds;
@@ -774,8 +795,8 @@ function registerAliasCommands(program2) {
774
795
  });
775
796
  }
776
797
  const platformsObj = {};
777
- for (const p3 of platformList) {
778
- platformsObj[p3] = { enabled: true, posts: postsArray };
798
+ for (const p4 of platformList) {
799
+ platformsObj[p4] = { enabled: true, posts: postsArray };
779
800
  }
780
801
  body.platforms = platformsObj;
781
802
  }
@@ -790,24 +811,24 @@ function registerAliasCommands(program2) {
790
811
  "At least one option is required (--text, --file, --title, --schedule, --share, --scratchpad/--notes, or --tags)"
791
812
  );
792
813
  }
793
- const spinner3 = spin("Updating draft\u2026");
794
- spinner3.start();
814
+ const spinner4 = spin("Updating draft\u2026");
815
+ spinner4.start();
795
816
  const data = await apiRequest(
796
817
  "PATCH",
797
818
  `/social-sets/${socialSetId}/drafts/${draftId}`,
798
819
  body
799
820
  );
800
- spinner3.stop();
821
+ spinner4.stop();
801
822
  display(data, () => renderDraft(data, "Draft updated"));
802
823
  }
803
824
  );
804
825
  program2.command("rm").description("Delete a draft \u2014 provide draft_id or pick interactively").argument("[draft_id]", "Draft ID to delete (omit for interactive picker)").option("--social-set-id <id>", "Social set ID (uses default if omitted)").option("--status <status>", "Filter drafts by status in picker (default: draft)").option("--limit <n>", "Max drafts to show in picker (default: 20)").action(async (draftId, opts) => {
805
826
  const socialSetId = requireSocialSetId(opts.socialSetId ?? null);
806
827
  if (draftId) {
807
- const spinner3 = spin("Deleting draft\u2026");
808
- spinner3.start();
828
+ const spinner4 = spin("Deleting draft\u2026");
829
+ spinner4.start();
809
830
  await apiRequest("DELETE", `/social-sets/${socialSetId}/drafts/${draftId}`);
810
- spinner3.succeed("Draft deleted");
831
+ spinner4.succeed("Draft deleted");
811
832
  display({ success: true, message: "Draft deleted" }, () => {
812
833
  });
813
834
  return;
@@ -854,11 +875,11 @@ function registerAliasCommands(program2) {
854
875
  process.exit(0);
855
876
  }
856
877
  const ids = selected;
857
- const confirm3 = await p.confirm({
878
+ const confirm4 = await p.confirm({
858
879
  message: `Delete ${ids.length} draft${ids.length !== 1 ? "s" : ""}?`,
859
880
  initialValue: false
860
881
  });
861
- if (p.isCancel(confirm3) || !confirm3) {
882
+ if (p.isCancel(confirm4) || !confirm4) {
862
883
  p.cancel("Cancelled.");
863
884
  process.exit(0);
864
885
  }
@@ -901,6 +922,8 @@ function renderConfigShow(data) {
901
922
  } else {
902
923
  console.log(pc5.dim(" Default platforms: not set \xB7 Run: typefully config set-platforms"));
903
924
  }
925
+ const tz = getDefaultTimezone();
926
+ console.log(pc5.dim(` Default timezone: ${tz} (${tzLabel(tz)})`));
904
927
  console.log("");
905
928
  }
906
929
  function registerConfigCommand(program2) {
@@ -942,10 +965,10 @@ function registerConfigCommand(program2) {
942
965
  let socialSetId = socialSetIdArg;
943
966
  let location = opts.scope ?? opts.location;
944
967
  if (!socialSetId) {
945
- const spinner3 = spin("Fetching social sets\u2026");
946
- spinner3.start();
968
+ const spinner4 = spin("Fetching social sets\u2026");
969
+ spinner4.start();
947
970
  const socialSets = await apiRequest("GET", "/social-sets?limit=50");
948
- spinner3.stop();
971
+ spinner4.stop();
949
972
  const results = socialSets.results;
950
973
  if (!results || results.length === 0) {
951
974
  exitWithError("No social sets found. Create one at typefully.com first.");
@@ -1016,10 +1039,100 @@ function registerConfigCommand(program2) {
1016
1039
  console.log("");
1017
1040
  });
1018
1041
  });
1042
+ cmd.command("set-timezone").description("Set default timezone for scheduling").option("--timezone <tz>", "IANA timezone name (skips interactive)").option("--location <location>", "Storage location: global or local").option("--scope <scope>", "Alias for --location").action(async (opts) => {
1043
+ const COMMON_TIMEZONES = [
1044
+ { value: "America/Los_Angeles", label: "America/Los_Angeles", hint: "PST/PDT" },
1045
+ { value: "America/Denver", label: "America/Denver", hint: "MST/MDT" },
1046
+ { value: "America/Chicago", label: "America/Chicago", hint: "CST/CDT" },
1047
+ { value: "America/New_York", label: "America/New_York", hint: "EST/EDT" },
1048
+ { value: "America/Sao_Paulo", label: "America/Sao_Paulo", hint: "BRT" },
1049
+ { value: "Europe/London", label: "Europe/London", hint: "GMT/BST" },
1050
+ { value: "Europe/Paris", label: "Europe/Paris", hint: "CET/CEST" },
1051
+ { value: "Europe/Istanbul", label: "Europe/Istanbul", hint: "TRT" },
1052
+ { value: "Asia/Dubai", label: "Asia/Dubai", hint: "GST" },
1053
+ { value: "Asia/Karachi", label: "Asia/Karachi", hint: "PKT" },
1054
+ { value: "Asia/Kolkata", label: "Asia/Kolkata", hint: "IST" },
1055
+ { value: "Asia/Shanghai", label: "Asia/Shanghai", hint: "CST" },
1056
+ { value: "Asia/Tokyo", label: "Asia/Tokyo", hint: "JST" },
1057
+ { value: "Australia/Sydney", label: "Australia/Sydney", hint: "AEST/AEDT" },
1058
+ { value: "custom", label: "Custom IANA timezone\u2026", hint: "enter manually" }
1059
+ ];
1060
+ let timezone;
1061
+ if (opts.timezone) {
1062
+ timezone = opts.timezone;
1063
+ } else {
1064
+ const current = getDefaultTimezone();
1065
+ const tzChoice = await clack2.select({
1066
+ message: "Default timezone for scheduling",
1067
+ initialValue: COMMON_TIMEZONES.some((t) => t.value === current) ? current : "custom",
1068
+ options: COMMON_TIMEZONES
1069
+ });
1070
+ if (clack2.isCancel(tzChoice)) {
1071
+ clack2.cancel("Cancelled.");
1072
+ process.exit(0);
1073
+ }
1074
+ if (tzChoice === "custom") {
1075
+ const customTz = await clack2.text({
1076
+ message: "Enter IANA timezone name",
1077
+ placeholder: DEFAULT_TIMEZONE,
1078
+ validate: (v = "") => {
1079
+ if (!v.trim()) return "Timezone is required";
1080
+ try {
1081
+ Intl.DateTimeFormat(void 0, { timeZone: v });
1082
+ } catch {
1083
+ return `Invalid timezone: ${v}`;
1084
+ }
1085
+ }
1086
+ });
1087
+ if (clack2.isCancel(customTz)) {
1088
+ clack2.cancel("Cancelled.");
1089
+ process.exit(0);
1090
+ }
1091
+ timezone = customTz;
1092
+ } else {
1093
+ timezone = tzChoice;
1094
+ }
1095
+ }
1096
+ let location = opts.scope ?? opts.location;
1097
+ if (!location) {
1098
+ const choice = await clack2.select({
1099
+ message: "Where should this be stored?",
1100
+ options: [
1101
+ {
1102
+ value: "global",
1103
+ label: `Global ${pc5.dim("(~/.config/typefully/)")}`,
1104
+ hint: "all projects"
1105
+ },
1106
+ {
1107
+ value: "local",
1108
+ label: `Local ${pc5.dim("(./.typefully/)")}`,
1109
+ hint: "this project only"
1110
+ }
1111
+ ]
1112
+ });
1113
+ if (clack2.isCancel(choice)) {
1114
+ clack2.cancel("Cancelled.");
1115
+ process.exit(0);
1116
+ }
1117
+ location = choice;
1118
+ }
1119
+ const isLocal = location === "local";
1120
+ const configPath = isLocal ? getLocalConfigFile() : getGlobalConfigFile();
1121
+ const existingConfig = readConfigFile(configPath) ?? {};
1122
+ writeConfig(configPath, { ...existingConfig, defaultTimezone: timezone });
1123
+ display({ success: true, default_timezone: timezone, config_path: configPath }, () => {
1124
+ console.log("");
1125
+ console.log(
1126
+ ` ${pc5.green("\u2713")} Default timezone saved: ${pc5.bold(timezone)} ${pc5.dim(`(${tzLabel(timezone)})`)}`
1127
+ );
1128
+ console.log(pc5.dim(` Config: ${configPath}`));
1129
+ console.log("");
1130
+ });
1131
+ });
1019
1132
  cmd.command("set-platforms").description("Set default platforms for new drafts").option("--platforms <platforms>", "Comma-separated platforms (skips interactive)").option("--location <location>", "Storage location: global or local").option("--scope <scope>", "Alias for --location").action(async (opts) => {
1020
1133
  let platformList;
1021
1134
  if (opts.platforms) {
1022
- platformList = opts.platforms.split(",").map((p3) => p3.trim());
1135
+ platformList = opts.platforms.split(",").map((p4) => p4.trim());
1023
1136
  } else {
1024
1137
  const selected = await clack2.multiselect({
1025
1138
  message: "Default platforms for new drafts",
@@ -1075,28 +1188,28 @@ function registerConfigCommand(program2) {
1075
1188
 
1076
1189
  // src/commands/interactive.ts
1077
1190
  import * as p2 from "@clack/prompts";
1078
- async function createDraftDirect(text5) {
1191
+ async function createDraftDirect(text6) {
1079
1192
  const id = requireSocialSetId(null);
1080
1193
  let platformList;
1081
1194
  const saved = getDefaultPlatforms();
1082
1195
  if (saved?.length) {
1083
1196
  const connected = await getAllConnectedPlatforms(id);
1084
- platformList = saved.filter((p3) => connected.includes(p3));
1197
+ platformList = saved.filter((p4) => connected.includes(p4));
1085
1198
  if (platformList.length === 0) platformList = [...connected];
1086
1199
  } else {
1087
1200
  const platform = await getFirstConnectedPlatform(id);
1088
1201
  if (!platform) exitWithError("No connected platforms found. Run: typefully setup");
1089
1202
  platformList = [platform];
1090
1203
  }
1091
- const posts = splitThreadText(text5);
1204
+ const posts = splitThreadText(text6);
1092
1205
  const postsArray = posts.map((postText) => ({ text: postText }));
1093
1206
  const platformsObj = {};
1094
- for (const p3 of platformList) platformsObj[p3] = { enabled: true, posts: postsArray };
1207
+ for (const p4 of platformList) platformsObj[p4] = { enabled: true, posts: postsArray };
1095
1208
  const body = { platforms: platformsObj };
1096
- const spinner3 = spin("Creating draft\u2026");
1097
- spinner3.start();
1209
+ const spinner4 = spin("Creating draft\u2026");
1210
+ spinner4.start();
1098
1211
  const data = await apiRequest("POST", `/social-sets/${id}/drafts`, body);
1099
- spinner3.stop();
1212
+ spinner4.stop();
1100
1213
  display(data, () => renderDraft(data, "Draft created"));
1101
1214
  }
1102
1215
  async function createDraftInteractive() {
@@ -1110,14 +1223,14 @@ async function createDraftInteractive() {
1110
1223
  p2.cancel("Cancelled.");
1111
1224
  process.exit(0);
1112
1225
  }
1113
- const text5 = textResult;
1226
+ const text6 = textResult;
1114
1227
  let availablePlatforms = [];
1115
1228
  try {
1116
1229
  availablePlatforms = await getAllConnectedPlatforms(id);
1117
1230
  } catch {
1118
1231
  }
1119
1232
  const savedPlatforms = getDefaultPlatforms();
1120
- const defaultInitial = savedPlatforms?.length ? availablePlatforms.filter((p3) => savedPlatforms.includes(p3)) : [availablePlatforms[0]];
1233
+ const defaultInitial = savedPlatforms?.length ? availablePlatforms.filter((p4) => savedPlatforms.includes(p4)) : [availablePlatforms[0]];
1121
1234
  let platformList;
1122
1235
  if (availablePlatforms.length > 1) {
1123
1236
  const selected = await p2.multiselect({
@@ -1155,7 +1268,7 @@ async function createDraftInteractive() {
1155
1268
  p2.cancel("Cancelled.");
1156
1269
  process.exit(0);
1157
1270
  }
1158
- const posts = splitThreadText(text5);
1271
+ const posts = splitThreadText(text6);
1159
1272
  const postsArray = posts.map((postText) => ({ text: postText }));
1160
1273
  const platformsObj = {};
1161
1274
  for (const platform of platformList) {
@@ -1169,9 +1282,9 @@ async function createDraftInteractive() {
1169
1282
  s.stop("");
1170
1283
  display(data, () => renderDraft(data, "Draft created"));
1171
1284
  }
1172
- async function runDraft(text5) {
1173
- if (text5) {
1174
- await createDraftDirect(text5);
1285
+ async function runDraft(text6) {
1286
+ if (text6) {
1287
+ await createDraftDirect(text6);
1175
1288
  } else {
1176
1289
  await createDraftInteractive();
1177
1290
  }
@@ -1216,10 +1329,10 @@ function renderMe(data) {
1216
1329
  }
1217
1330
  function registerMeCommand(program2) {
1218
1331
  program2.command("me").description("Get authenticated user info").action(async () => {
1219
- const spinner3 = spin("Fetching user info\u2026");
1220
- spinner3.start();
1332
+ const spinner4 = spin("Fetching user info\u2026");
1333
+ spinner4.start();
1221
1334
  const data = await apiRequest("GET", "/me");
1222
- spinner3.stop();
1335
+ spinner4.stop();
1223
1336
  display(data, () => renderMe(data));
1224
1337
  });
1225
1338
  }
@@ -1244,8 +1357,8 @@ function registerMediaCommand(program2) {
1244
1357
  const n = Number.parseInt(raw, 10);
1245
1358
  return Number.isFinite(n) && n >= 0 ? n : 2e3;
1246
1359
  })();
1247
- const spinner3 = spin(`Uploading ${pc7.bold(rawFilename)}\u2026`);
1248
- spinner3.start();
1360
+ const spinner4 = spin(`Uploading ${pc7.bold(rawFilename)}\u2026`);
1361
+ spinner4.start();
1249
1362
  const presignedResponse = await apiRequest("POST", `/social-sets/${id}/media/upload`, {
1250
1363
  file_name: filename
1251
1364
  });
@@ -1256,14 +1369,14 @@ function registerMediaCommand(program2) {
1256
1369
  const fileBuffer = fs4.readFileSync(filePath);
1257
1370
  const uploadResponse = await fetch(uploadUrl, { method: "PUT", body: fileBuffer });
1258
1371
  if (!uploadResponse.ok) {
1259
- spinner3.fail("Upload failed");
1372
+ spinner4.fail("Upload failed");
1260
1373
  exitWithError("Failed to upload file to S3", {
1261
1374
  http_code: uploadResponse.status,
1262
1375
  status_text: uploadResponse.statusText
1263
1376
  });
1264
1377
  }
1265
1378
  if (opts.wait === false) {
1266
- spinner3.succeed("Uploaded");
1379
+ spinner4.succeed("Uploaded");
1267
1380
  display(
1268
1381
  { media_id: mediaId, message: "Upload complete. Use media status to check processing." },
1269
1382
  () => {
@@ -1275,7 +1388,7 @@ function registerMediaCommand(program2) {
1275
1388
  );
1276
1389
  return;
1277
1390
  }
1278
- spinner3.text = "Processing\u2026";
1391
+ spinner4.text = "Processing\u2026";
1279
1392
  const startTime = Date.now();
1280
1393
  while (Date.now() - startTime < timeout) {
1281
1394
  const statusResponse = await apiRequest(
@@ -1283,7 +1396,7 @@ function registerMediaCommand(program2) {
1283
1396
  `/social-sets/${id}/media/${mediaId}`
1284
1397
  );
1285
1398
  if (statusResponse.status === "ready") {
1286
- spinner3.succeed("Media ready");
1399
+ spinner4.succeed("Media ready");
1287
1400
  display({ media_id: mediaId, status: "ready", message: "Media uploaded and ready" }, () => {
1288
1401
  console.log("");
1289
1402
  console.log(` ${pc7.green("\u2713")} Media ready \xB7 ID: ${pc7.bold(mediaId)}`);
@@ -1292,12 +1405,12 @@ function registerMediaCommand(program2) {
1292
1405
  return;
1293
1406
  }
1294
1407
  if (statusResponse.status === "error" || statusResponse.status === "failed") {
1295
- spinner3.fail("Processing failed");
1408
+ spinner4.fail("Processing failed");
1296
1409
  exitWithError("Media processing failed", { status: statusResponse });
1297
1410
  }
1298
1411
  await sleep(pollIntervalMs);
1299
1412
  }
1300
- spinner3.stop();
1413
+ spinner4.stop();
1301
1414
  const timeoutResult = {
1302
1415
  media_id: mediaId,
1303
1416
  status: "processing",
@@ -1316,10 +1429,10 @@ function registerMediaCommand(program2) {
1316
1429
  async (mediaId, socialSetId, opts) => {
1317
1430
  const flagId = opts.socialSetId;
1318
1431
  const id = requireSocialSetId(flagId ?? socialSetId ?? null);
1319
- const spinner3 = spin("Checking media status\u2026");
1320
- spinner3.start();
1432
+ const spinner4 = spin("Checking media status\u2026");
1433
+ spinner4.start();
1321
1434
  const data = await apiRequest("GET", `/social-sets/${id}/media/${mediaId}`);
1322
- spinner3.stop();
1435
+ spinner4.stop();
1323
1436
  display(data, () => {
1324
1437
  const d = data;
1325
1438
  const status = String(d.status ?? "unknown");
@@ -1515,21 +1628,21 @@ function registerSetupCommand(program2) {
1515
1628
  import pc9 from "picocolors";
1516
1629
  var PLATFORM_ORDER = ["x", "linkedin", "threads", "bluesky", "mastodon"];
1517
1630
  function renderPlatforms(platforms, indent = " ") {
1518
- for (const p3 of PLATFORM_ORDER) {
1519
- const cfg = platforms[p3];
1631
+ for (const p4 of PLATFORM_ORDER) {
1632
+ const cfg = platforms[p4];
1520
1633
  if (!cfg) continue;
1521
1634
  const isConnected = cfg.connected !== false;
1522
1635
  const dot = isConnected ? pc9.green("\u25CF") : pc9.dim("\u25CB");
1523
1636
  const handle = cfg.username ? pc9.dim(` @${cfg.username}`) : "";
1524
1637
  const status = isConnected ? "" : pc9.dim(" (not connected)");
1525
- console.log(`${indent}${dot} ${p3.padEnd(9)}${handle}${status}`);
1638
+ console.log(`${indent}${dot} ${p4.padEnd(9)}${handle}${status}`);
1526
1639
  }
1527
- for (const [p3, cfg] of Object.entries(platforms)) {
1528
- if (PLATFORM_ORDER.includes(p3)) continue;
1640
+ for (const [p4, cfg] of Object.entries(platforms)) {
1641
+ if (PLATFORM_ORDER.includes(p4)) continue;
1529
1642
  const isConnected = cfg.connected !== false;
1530
1643
  const dot = isConnected ? pc9.green("\u25CF") : pc9.dim("\u25CB");
1531
1644
  const handle = cfg.username ? pc9.dim(` @${cfg.username}`) : "";
1532
- console.log(`${indent}${dot} ${p3.padEnd(9)}${handle}`);
1645
+ console.log(`${indent}${dot} ${p4.padEnd(9)}${handle}`);
1533
1646
  }
1534
1647
  }
1535
1648
  function renderSocialSetCard(set, index) {
@@ -1572,38 +1685,199 @@ function renderSocialSet(data) {
1572
1685
  function registerSocialSetsCommand(program2) {
1573
1686
  const cmd = program2.command("social-sets").description("Manage social sets");
1574
1687
  cmd.command("list").description("List all social sets").action(async () => {
1575
- const spinner3 = spin("Fetching social sets\u2026");
1576
- spinner3.start();
1688
+ const spinner4 = spin("Fetching social sets\u2026");
1689
+ spinner4.start();
1577
1690
  const data = await apiRequest("GET", "/social-sets?limit=50");
1578
- spinner3.stop();
1691
+ spinner4.stop();
1579
1692
  display(data, () => renderSocialSetsList(data));
1580
1693
  });
1581
1694
  cmd.command("get").description("Get social set details").argument("[social_set_id]", "Social set ID (uses default if omitted)").action(async (socialSetId) => {
1582
1695
  const id = requireSocialSetId(socialSetId ?? null);
1583
- const spinner3 = spin("Fetching social set\u2026");
1584
- spinner3.start();
1696
+ const spinner4 = spin("Fetching social set\u2026");
1697
+ spinner4.start();
1585
1698
  const data = await apiRequest("GET", `/social-sets/${id}`);
1586
- spinner3.stop();
1699
+ spinner4.stop();
1587
1700
  display(data, () => renderSocialSet(data));
1588
1701
  });
1589
1702
  }
1590
1703
 
1591
- // src/commands/tags.ts
1704
+ // src/commands/schedule.ts
1705
+ import { exec } from "child_process";
1706
+ import * as p3 from "@clack/prompts";
1592
1707
  import pc10 from "picocolors";
1708
+ function toUTCIso(dateStr, timeStr, tz) {
1709
+ const naiveUTC = /* @__PURE__ */ new Date(`${dateStr}T${timeStr}:00Z`);
1710
+ const parts = new Intl.DateTimeFormat("en-US", {
1711
+ timeZone: tz,
1712
+ year: "numeric",
1713
+ month: "2-digit",
1714
+ day: "2-digit",
1715
+ hour: "2-digit",
1716
+ minute: "2-digit",
1717
+ second: "2-digit",
1718
+ hour12: false
1719
+ }).formatToParts(naiveUTC);
1720
+ const get = (type) => parts.find((pt) => pt.type === type)?.value ?? "00";
1721
+ const localISO = `${get("year")}-${get("month")}-${get("day")}T${get("hour")}:${get("minute")}:${get("second")}Z`;
1722
+ const localDate = new Date(localISO);
1723
+ const offset = naiveUTC.getTime() - localDate.getTime();
1724
+ return new Date(naiveUTC.getTime() + offset).toISOString();
1725
+ }
1726
+ function todayInTimezone(tz) {
1727
+ return (/* @__PURE__ */ new Date()).toLocaleDateString("en-CA", { timeZone: tz });
1728
+ }
1729
+ function formatScheduledAt(draft, tz) {
1730
+ if (!draft.scheduled_at) return "";
1731
+ try {
1732
+ return new Date(String(draft.scheduled_at)).toLocaleString("en-US", {
1733
+ timeZone: tz,
1734
+ month: "short",
1735
+ day: "numeric",
1736
+ hour: "numeric",
1737
+ minute: "2-digit",
1738
+ hour12: true
1739
+ });
1740
+ } catch {
1741
+ return String(draft.scheduled_at);
1742
+ }
1743
+ }
1744
+ async function runScheduler() {
1745
+ const id = requireSocialSetId(null);
1746
+ const tz = getDefaultTimezone();
1747
+ const tz_label = tzLabel(tz);
1748
+ const fetchSpinner = p3.spinner();
1749
+ fetchSpinner.start("Fetching drafts\u2026");
1750
+ const data = await apiRequest("GET", `/social-sets/${id}/drafts?limit=50`);
1751
+ const results = data.results ?? [];
1752
+ const schedulable = results.filter((d) => d.status === "draft" || d.status === "scheduled");
1753
+ if (schedulable.length === 0) {
1754
+ fetchSpinner.stop("No drafts found.");
1755
+ p3.cancel("No drafts available to schedule.");
1756
+ process.exit(0);
1757
+ }
1758
+ const fullDrafts = await Promise.all(
1759
+ schedulable.map(
1760
+ (draft) => apiRequest("GET", `/social-sets/${id}/drafts/${draft.id}`).catch(() => draft)
1761
+ )
1762
+ );
1763
+ fetchSpinner.stop(`${fullDrafts.length} draft${fullDrafts.length !== 1 ? "s" : ""} loaded`);
1764
+ const draftChoice = await p3.select({
1765
+ message: "Pick a draft",
1766
+ options: fullDrafts.map((draft) => {
1767
+ const preview = firstPostText(draft, 65) || pc10.dim("(no text)");
1768
+ const status = String(draft.status ?? "draft");
1769
+ const scheduled = formatScheduledAt(draft, tz);
1770
+ const hint = scheduled ? `${status} \xB7 ${scheduled} ${tz_label}` : status;
1771
+ return {
1772
+ value: String(draft.id),
1773
+ label: `${preview}
1774
+ `,
1775
+ hint
1776
+ };
1777
+ })
1778
+ });
1779
+ if (p3.isCancel(draftChoice)) {
1780
+ p3.cancel("Cancelled.");
1781
+ process.exit(0);
1782
+ }
1783
+ const scheduleType = await p3.select({
1784
+ message: "When to publish?",
1785
+ initialValue: "next-free-slot",
1786
+ options: [
1787
+ { value: "next-free-slot", label: "Next free slot" },
1788
+ { value: "custom", label: `Custom date & time ${pc10.dim(`(${tz_label})`)}` }
1789
+ ]
1790
+ });
1791
+ if (p3.isCancel(scheduleType)) {
1792
+ p3.cancel("Cancelled.");
1793
+ process.exit(0);
1794
+ }
1795
+ let publishAt;
1796
+ if (scheduleType === "next-free-slot") {
1797
+ publishAt = "next-free-slot";
1798
+ } else {
1799
+ const today = todayInTimezone(tz);
1800
+ const dateInput = await p3.text({
1801
+ message: `Date ${pc10.dim(`(${tz_label}, YYYY-MM-DD)`)}`,
1802
+ placeholder: today,
1803
+ initialValue: today,
1804
+ validate: (v = "") => {
1805
+ if (!v.trim()) return "Date is required";
1806
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(v.trim())) return "Use YYYY-MM-DD format";
1807
+ if (Number.isNaN(Date.parse(v.trim()))) return "Invalid date";
1808
+ }
1809
+ });
1810
+ if (p3.isCancel(dateInput)) {
1811
+ p3.cancel("Cancelled.");
1812
+ process.exit(0);
1813
+ }
1814
+ const timeInput = await p3.text({
1815
+ message: `Time ${pc10.dim(`(${tz_label}, HH:MM 24h)`)}`,
1816
+ placeholder: "09:00",
1817
+ initialValue: "09:00",
1818
+ validate: (v = "") => {
1819
+ if (!v.trim()) return "Time is required";
1820
+ if (!/^\d{2}:\d{2}$/.test(v.trim())) return "Use HH:MM format";
1821
+ const [h, m] = v.trim().split(":").map(Number);
1822
+ if (h > 23 || m > 59) return "Invalid time";
1823
+ }
1824
+ });
1825
+ if (p3.isCancel(timeInput)) {
1826
+ p3.cancel("Cancelled.");
1827
+ process.exit(0);
1828
+ }
1829
+ publishAt = toUTCIso(dateInput.trim(), timeInput.trim(), tz);
1830
+ }
1831
+ const confirmMsg = scheduleType === "next-free-slot" ? "Schedule to next free slot?" : `Schedule for ${new Date(publishAt).toLocaleString("en-US", { timeZone: tz, month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", hour12: true })} ${tz_label}?`;
1832
+ const confirmed = await p3.confirm({
1833
+ message: confirmMsg,
1834
+ initialValue: true
1835
+ });
1836
+ if (p3.isCancel(confirmed) || !confirmed) {
1837
+ p3.cancel("Cancelled.");
1838
+ process.exit(0);
1839
+ }
1840
+ const s = p3.spinner();
1841
+ s.start("Scheduling\u2026");
1842
+ const result = await apiRequest(
1843
+ "PATCH",
1844
+ `/social-sets/${id}/drafts/${draftChoice}`,
1845
+ { publish_at: publishAt }
1846
+ );
1847
+ s.stop("");
1848
+ display(result, () => renderDraft(result, "Draft scheduled"));
1849
+ const draftUrl = String(result.share_url ?? `https://typefully.com/?d=${draftChoice}`);
1850
+ const openBrowser = await p3.confirm({
1851
+ message: "Open in browser?",
1852
+ initialValue: true
1853
+ });
1854
+ if (!p3.isCancel(openBrowser) && openBrowser) {
1855
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
1856
+ exec(`${cmd} "${draftUrl}"`);
1857
+ }
1858
+ }
1859
+ function registerScheduleCommand(program2) {
1860
+ program2.command("schedule").description("Interactively browse drafts and schedule one").action(async () => {
1861
+ await runScheduler();
1862
+ });
1863
+ }
1864
+
1865
+ // src/commands/tags.ts
1866
+ import pc11 from "picocolors";
1593
1867
  function renderTagsList(data) {
1594
1868
  const results = data.results ?? [];
1595
1869
  if (results.length === 0) {
1596
- console.log(pc10.yellow("\n No tags found.\n"));
1870
+ console.log(pc11.yellow("\n No tags found.\n"));
1597
1871
  return;
1598
1872
  }
1599
1873
  console.log("");
1600
- console.log(pc10.dim(` ${results.length} tag${results.length !== 1 ? "s" : ""}`));
1874
+ console.log(pc11.dim(` ${results.length} tag${results.length !== 1 ? "s" : ""}`));
1601
1875
  console.log("");
1602
1876
  for (let i = 0; i < results.length; i++) {
1603
1877
  const tag = results[i];
1604
- const num = pc10.dim(`${String(i + 1)}.`.padStart(3));
1605
- const name = pc10.bold(String(tag.name ?? ""));
1606
- const slug = tag.slug ? pc10.dim(` (${tag.slug})`) : "";
1878
+ const num = pc11.dim(`${String(i + 1)}.`.padStart(3));
1879
+ const name = pc11.bold(String(tag.name ?? ""));
1880
+ const slug = tag.slug ? pc11.dim(` (${tag.slug})`) : "";
1607
1881
  console.log(` ${num} ${name}${slug}`);
1608
1882
  }
1609
1883
  console.log("");
@@ -1612,37 +1886,37 @@ function registerTagsCommand(program2) {
1612
1886
  const cmd = program2.command("tags").description("Manage tags");
1613
1887
  cmd.command("list").description("List all tags").argument("[social_set_id]", "Social set ID (uses default if omitted)").action(async (socialSetId) => {
1614
1888
  const id = requireSocialSetId(socialSetId ?? null);
1615
- const spinner3 = spin("Fetching tags\u2026");
1616
- spinner3.start();
1889
+ const spinner4 = spin("Fetching tags\u2026");
1890
+ spinner4.start();
1617
1891
  const data = await apiRequest("GET", `/social-sets/${id}/tags?limit=50`);
1618
- spinner3.stop();
1892
+ spinner4.stop();
1619
1893
  display(data, () => renderTagsList(data));
1620
1894
  });
1621
1895
  cmd.command("create").description("Create a new tag").argument("[social_set_id]", "Social set ID (uses default if omitted)").option("--name <name>", "Tag name (required)").action(async (socialSetId, opts) => {
1622
1896
  const id = requireSocialSetId(socialSetId ?? null);
1623
1897
  if (!opts.name) exitWithError("--name is required");
1624
- const spinner3 = spin("Creating tag\u2026");
1625
- spinner3.start();
1898
+ const spinner4 = spin("Creating tag\u2026");
1899
+ spinner4.start();
1626
1900
  const data = await apiRequest("POST", `/social-sets/${id}/tags`, { name: opts.name });
1627
- spinner3.stop();
1901
+ spinner4.stop();
1628
1902
  display(data, () => {
1629
1903
  const tag = data;
1630
1904
  console.log("");
1631
- console.log(` ${pc10.green("\u2713")} Tag created: ${pc10.bold(String(tag.name ?? opts.name))}`);
1632
- if (tag.slug) console.log(pc10.dim(` Slug: ${tag.slug}`));
1905
+ console.log(` ${pc11.green("\u2713")} Tag created: ${pc11.bold(String(tag.name ?? opts.name))}`);
1906
+ if (tag.slug) console.log(pc11.dim(` Slug: ${tag.slug}`));
1633
1907
  console.log("");
1634
1908
  });
1635
1909
  });
1636
1910
  }
1637
1911
 
1638
1912
  // src/utils/banner.ts
1639
- import pc11 from "picocolors";
1913
+ import pc12 from "picocolors";
1640
1914
  var BANNER = `
1641
1915
  \u281B\u28FF\u281B \u28FF\u28E4\u28FF \u28FF\u281B\u28FF \u28FF\u281B\u281B \u28FF\u281B\u281B \u28FF \u28FF \u28FF \u28FF \u28FF\u28E4\u28FF
1642
1916
  \u28FF \u28FF \u28FF\u281B\u281B \u28FF\u28FF\u28E4 \u28FF\u281B \u28FF\u28E4\u28FF \u28FF\u28E4\u28E4 \u28FF\u28E4\u28E4 \u28FF
1643
1917
  `;
1644
1918
  function showBanner() {
1645
- console.error(pc11.dim(BANNER));
1919
+ console.error(pc12.dim(BANNER));
1646
1920
  }
1647
1921
 
1648
1922
  // src/cli.ts
@@ -1650,8 +1924,8 @@ var require2 = createRequire(import.meta.url);
1650
1924
  var pkg = require2("../package.json");
1651
1925
  function createCli() {
1652
1926
  const program2 = new Command();
1653
- program2.name("typefully").description("Manage social media posts via the Typefully API").version(pkg.version, "-v, --version").option("-j, --json", "Output raw JSON instead of human-readable text").argument("[text]", "Post text \u2014 creates a draft directly, or omit for interactive mode").action(async (text5) => {
1654
- await runDraft(text5);
1927
+ program2.name("typefully").description("Manage social media posts via the Typefully API").version(pkg.version, "-v, --version").option("-j, --json", "Output raw JSON instead of human-readable text").argument("[text]", "Post text \u2014 creates a draft directly, or omit for interactive mode").action(async (text6) => {
1928
+ await runDraft(text6);
1655
1929
  }).hook("preAction", (_thisCommand) => {
1656
1930
  const jsonMode = !!program2.opts().json;
1657
1931
  setJsonMode(jsonMode);
@@ -1668,6 +1942,7 @@ function createCli() {
1668
1942
  registerTagsCommand(program2);
1669
1943
  registerMediaCommand(program2);
1670
1944
  registerConfigCommand(program2);
1945
+ registerScheduleCommand(program2);
1671
1946
  return program2;
1672
1947
  }
1673
1948
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typefully",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "typefully": "./dist/index.js",
package/skills/spec.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > **This document is the authoritative contract for the Typefully CLI.**
4
4
  > All commands, options, arguments, output shapes, and behaviors described here must be satisfied by any conforming implementation.
5
5
 
6
- Last updated: 2026-02-18
6
+ Last updated: 2026-02-19
7
7
 
8
8
  ---
9
9
 
@@ -35,6 +35,8 @@ Last updated: 2026-02-18
35
35
  - [config show](#config-show)
36
36
  - [config set-default](#config-set-default)
37
37
  - [config set-platforms](#config-set-platforms)
38
+ - [config set-timezone](#config-set-timezone)
39
+ - [schedule](#schedule)
38
40
  - [rm (alias)](#rm-alias)
39
41
  - [Default command (tfly)](#default-command-tfly)
40
42
  7. [Thread Syntax](#thread-syntax)
@@ -96,7 +98,8 @@ The banner is shown once per invocation before the first command action, **unles
96
98
  {
97
99
  "apiKey": "typ_xxxx",
98
100
  "defaultSocialSetId": 12345,
99
- "defaultPlatforms": ["x", "linkedin"]
101
+ "defaultPlatforms": ["x", "linkedin"],
102
+ "defaultTimezone": "America/Los_Angeles"
100
103
  }
101
104
  ```
102
105
 
@@ -787,6 +790,92 @@ tfly config set-platforms [options]
787
790
 
788
791
  ---
789
792
 
793
+ ### `config set-timezone`
794
+
795
+ Sets the default timezone used when entering custom schedule times.
796
+
797
+ ```
798
+ typefully config set-timezone [options]
799
+ tfly config set-timezone [options]
800
+ ```
801
+
802
+ **Options**
803
+
804
+ | Flag | Description |
805
+ |------|-------------|
806
+ | `--timezone <tz>` | IANA timezone name (skips interactive). |
807
+ | `--location <global\|local>` | Where to save: `global` or `local`. Interactive if omitted. |
808
+ | `--scope <global\|local>` | Alias for `--location`. |
809
+
810
+ **Behavior**
811
+
812
+ - If `--timezone` is omitted: shows a clack `select` with 14 common IANA timezones plus a "Custom" option that prompts for free-text input.
813
+ - The entered value is validated via `Intl.DateTimeFormat` before saving.
814
+ - Default when not configured: `America/Los_Angeles` (PST/PDT).
815
+ - Stored as `defaultTimezone` in the config JSON.
816
+
817
+ **JSON output shape**
818
+
819
+ ```json
820
+ {
821
+ "success": true,
822
+ "default_timezone": "America/Los_Angeles",
823
+ "config_path": "/Users/you/.config/typefully/config.json"
824
+ }
825
+ ```
826
+
827
+ ---
828
+
829
+ ### `schedule`
830
+
831
+ Interactively browse drafts and schedule one. Optimised for keyboard-only use.
832
+
833
+ ```
834
+ tfly schedule
835
+ typefully schedule
836
+ ```
837
+
838
+ **No arguments or options.**
839
+
840
+ **Behavior**
841
+
842
+ 1. Fetches up to 50 drafts (status `draft` or `scheduled`) for the default social set.
843
+ 2. Fetches each draft's full details in parallel to load post text.
844
+ 3. clack `select` — pick a draft. Each option shows the post preview and current status / scheduled time.
845
+ 4. clack `select` — choose when to publish, with **Next free slot** pre-selected:
846
+ - `Next free slot` — sends `publish_at: "next-free-slot"` to the API.
847
+ - `Custom date & time` — prompts for date (`YYYY-MM-DD`) and time (`HH:MM`, 24h) in the configured timezone, then converts to UTC ISO 8601 before sending.
848
+ 5. clack `confirm` — confirm the schedule (defaults to yes).
849
+ 6. Calls `PATCH /social-sets/:id/drafts/:draft_id` with `{ publish_at }`.
850
+ 7. Displays the updated draft.
851
+ 8. clack `confirm` — **Open in browser?** (defaults to yes). Opens `share_url` or `https://typefully.com/?d=<id>` using the platform-native open command.
852
+
853
+ **Happy path (keyboard flow)**
854
+
855
+ ```
856
+ ↑↓ Enter — pick draft
857
+ Enter — confirm "Next free slot" (pre-selected)
858
+ Enter — confirm schedule
859
+ Enter — open in browser
860
+ ```
861
+
862
+ **Custom time flow** (breaks the enter-only path intentionally)
863
+
864
+ ```
865
+ ↑↓ Enter — pick draft
866
+ ↓ Enter — select "Custom date & time"
867
+ <date> Enter
868
+ <time> Enter
869
+ Enter — confirm schedule
870
+ Enter — open in browser
871
+ ```
872
+
873
+ Cancelling at any prompt (Ctrl+C or selecting No on confirm) exits cleanly with code 0.
874
+
875
+ **JSON output shape** — single draft object (same as `drafts schedule`).
876
+
877
+ ---
878
+
790
879
  ### `rm` (alias)
791
880
 
792
881
  Deletes a draft. Provide a draft ID for direct deletion, or omit it for an interactive clack multiselect picker.