@wahlu/cli 0.1.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/dist/index.js ADDED
@@ -0,0 +1,1294 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command as Command11 } from "commander";
5
+
6
+ // src/commands/auth.ts
7
+ import { Command } from "commander";
8
+
9
+ // src/lib/config.ts
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
11
+ import { homedir } from "os";
12
+ import { join } from "path";
13
+ var CONFIG_DIR = join(homedir(), ".config", "wahlu");
14
+ var CONFIG_FILE = join(CONFIG_DIR, "config.json");
15
+ function ensureDir() {
16
+ if (!existsSync(CONFIG_DIR)) {
17
+ mkdirSync(CONFIG_DIR, { recursive: true });
18
+ }
19
+ }
20
+ function readConfig() {
21
+ if (!existsSync(CONFIG_FILE)) return {};
22
+ try {
23
+ return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
24
+ } catch {
25
+ return {};
26
+ }
27
+ }
28
+ function writeConfig(config) {
29
+ ensureDir();
30
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n");
31
+ }
32
+ function getApiKey() {
33
+ const envKey = process.env.WAHLU_API_KEY;
34
+ if (envKey) return envKey;
35
+ const config = readConfig();
36
+ if (config.api_key) return config.api_key;
37
+ console.error(
38
+ "No API key found. Set WAHLU_API_KEY or run: wahlu auth login"
39
+ );
40
+ process.exit(1);
41
+ }
42
+ function getApiUrl() {
43
+ return process.env.WAHLU_API_URL || readConfig().api_url || "https://api.wahlu.com";
44
+ }
45
+ function getDefaultBrandId() {
46
+ return process.env.WAHLU_BRAND_ID || readConfig().default_brand_id;
47
+ }
48
+
49
+ // src/commands/auth.ts
50
+ var authCommand = new Command("auth").description("Authenticate with your Wahlu API key").addHelpText(
51
+ "after",
52
+ `
53
+ Manage your Wahlu API key for CLI authentication.
54
+
55
+ Subcommands:
56
+ login <key> Save an API key to ~/.config/wahlu/config.json
57
+ logout Remove the saved API key
58
+ status Show current authentication state and config
59
+
60
+ Authentication priority:
61
+ 1. WAHLU_API_KEY environment variable (highest priority)
62
+ 2. Saved key in ~/.config/wahlu/config.json
63
+
64
+ Generate an API key at wahlu.com under Settings > API Keys.
65
+ Full documentation: https://wahlu.com/docs`
66
+ );
67
+ authCommand.command("login").description("Save your API key").argument("<api-key>", "Your Wahlu API key (wahlu_live_... or wahlu_test_...)").addHelpText(
68
+ "after",
69
+ `
70
+ Saves the API key to ~/.config/wahlu/config.json for use in future commands.
71
+ The key must start with wahlu_live_ (production) or wahlu_test_ (development).
72
+
73
+ Examples:
74
+ wahlu auth login wahlu_live_abc123def456...
75
+ wahlu auth login wahlu_test_abc123def456...
76
+
77
+ You can also skip this and use an environment variable instead:
78
+ export WAHLU_API_KEY=wahlu_live_abc123...`
79
+ ).action((apiKey) => {
80
+ if (!apiKey.startsWith("wahlu_live_") && !apiKey.startsWith("wahlu_test_")) {
81
+ console.error(
82
+ "Invalid API key format. Keys start with wahlu_live_ or wahlu_test_"
83
+ );
84
+ process.exit(1);
85
+ }
86
+ const config = readConfig();
87
+ config.api_key = apiKey;
88
+ writeConfig(config);
89
+ console.log("API key saved to ~/.config/wahlu/config.json");
90
+ });
91
+ authCommand.command("logout").description("Remove saved API key").addHelpText(
92
+ "after",
93
+ `
94
+ Removes the API key from ~/.config/wahlu/config.json.
95
+ Does not affect the WAHLU_API_KEY environment variable.`
96
+ ).action(() => {
97
+ const config = readConfig();
98
+ delete config.api_key;
99
+ writeConfig(config);
100
+ console.log("API key removed.");
101
+ });
102
+ authCommand.command("status").description("Show current auth status").addHelpText(
103
+ "after",
104
+ `
105
+ Shows which authentication method is active and any saved configuration.
106
+
107
+ Displays:
108
+ - Auth source (env var or config file) and masked key
109
+ - Custom API URL if set
110
+ - Default brand ID if set`
111
+ ).action(() => {
112
+ const envKey = process.env.WAHLU_API_KEY;
113
+ const config = readConfig();
114
+ if (envKey) {
115
+ console.log(
116
+ `Authenticated via WAHLU_API_KEY env var (${mask(envKey)})`
117
+ );
118
+ } else if (config.api_key) {
119
+ console.log(
120
+ `Authenticated via config file (${mask(config.api_key)})`
121
+ );
122
+ } else {
123
+ console.log("Not authenticated. Run: wahlu auth login <api-key>");
124
+ }
125
+ if (config.api_url) {
126
+ console.log(`API URL: ${config.api_url}`);
127
+ }
128
+ if (config.default_brand_id) {
129
+ console.log(`Default brand: ${config.default_brand_id}`);
130
+ }
131
+ });
132
+ function mask(key) {
133
+ if (key.length <= 16) return "***";
134
+ return `${key.slice(0, 12)}...${key.slice(-4)}`;
135
+ }
136
+
137
+ // src/commands/brand.ts
138
+ import { Command as Command2 } from "commander";
139
+
140
+ // src/lib/client.ts
141
+ var WahluClient = class {
142
+ baseUrl;
143
+ apiKey;
144
+ constructor(apiKey, baseUrl = "https://api.wahlu.com") {
145
+ this.apiKey = apiKey;
146
+ this.baseUrl = baseUrl.replace(/\/$/, "");
147
+ }
148
+ async request(method, path, body) {
149
+ const url = `${this.baseUrl}/v1${path}`;
150
+ const headers = {
151
+ Authorization: `Bearer ${this.apiKey}`,
152
+ "User-Agent": "wahlu-cli"
153
+ };
154
+ if (body) {
155
+ headers["Content-Type"] = "application/json";
156
+ }
157
+ const res = await fetch(url, {
158
+ method,
159
+ headers,
160
+ body: body ? JSON.stringify(body) : void 0
161
+ });
162
+ if (!res.ok) {
163
+ const text = await res.text();
164
+ let message;
165
+ try {
166
+ const json = JSON.parse(text);
167
+ message = json.error?.message || json.message || `HTTP ${res.status}`;
168
+ } catch {
169
+ message = text || `HTTP ${res.status} ${res.statusText}`;
170
+ }
171
+ throw new CliError(message, res.status);
172
+ }
173
+ return res.json();
174
+ }
175
+ async get(path) {
176
+ return this.request("GET", path);
177
+ }
178
+ async list(path, page, limit) {
179
+ const params = new URLSearchParams();
180
+ if (page !== void 0) params.set("page", String(page));
181
+ if (limit !== void 0) params.set("limit", String(limit));
182
+ const qs = params.toString();
183
+ return this.request(
184
+ "GET",
185
+ qs ? `${path}?${qs}` : path
186
+ );
187
+ }
188
+ async post(path, body) {
189
+ return this.request("POST", path, body);
190
+ }
191
+ async patch(path, body) {
192
+ return this.request("PATCH", path, body);
193
+ }
194
+ async delete(path) {
195
+ return this.request("DELETE", path);
196
+ }
197
+ };
198
+ var CliError = class extends Error {
199
+ status;
200
+ constructor(message, status) {
201
+ super(message);
202
+ this.name = "CliError";
203
+ this.status = status;
204
+ }
205
+ };
206
+
207
+ // src/lib/output.ts
208
+ function output(data, opts = {}) {
209
+ if (opts.json) {
210
+ console.log(JSON.stringify(data, null, 2));
211
+ return;
212
+ }
213
+ if (opts.columns && Array.isArray(data)) {
214
+ printTable(data, opts.columns);
215
+ return;
216
+ }
217
+ if (data && typeof data === "object" && !Array.isArray(data)) {
218
+ const record = data;
219
+ const maxKey = Math.max(...Object.keys(record).map((k) => k.length));
220
+ for (const [key, value] of Object.entries(record)) {
221
+ const display = value === null || value === void 0 ? "-" : typeof value === "object" ? JSON.stringify(value) : String(value);
222
+ console.log(`${key.padEnd(maxKey + 2)}${display}`);
223
+ }
224
+ return;
225
+ }
226
+ console.log(JSON.stringify(data, null, 2));
227
+ }
228
+ function printTable(rows, columns) {
229
+ if (rows.length === 0) {
230
+ console.log("No results.");
231
+ return;
232
+ }
233
+ const widths = columns.map((col) => {
234
+ const headerLen = col.header.length;
235
+ const maxData = rows.reduce((max, row) => {
236
+ const val = formatCell(row[col.key], col.transform);
237
+ return Math.max(max, val.length);
238
+ }, 0);
239
+ return col.width || Math.min(Math.max(headerLen, maxData), 50);
240
+ });
241
+ const header = columns.map((col, i) => col.header.padEnd(widths[i])).join(" ");
242
+ console.log(header);
243
+ console.log(widths.map((w) => "\u2500".repeat(w)).join(" "));
244
+ for (const row of rows) {
245
+ const line = columns.map((col, i) => {
246
+ const val = formatCell(row[col.key], col.transform);
247
+ return truncate(val, widths[i]).padEnd(widths[i]);
248
+ }).join(" ");
249
+ console.log(line);
250
+ }
251
+ }
252
+ function formatCell(value, transform) {
253
+ if (transform) return transform(value);
254
+ if (value === null || value === void 0) return "-";
255
+ if (typeof value === "object") return JSON.stringify(value);
256
+ return String(value);
257
+ }
258
+ function truncate(str, max) {
259
+ if (str.length <= max) return str;
260
+ return str.slice(0, max - 1) + "\u2026";
261
+ }
262
+
263
+ // src/commands/brand.ts
264
+ var brandCommand = new Command2("brand").description("List brands, view details, set a default brand").addHelpText(
265
+ "after",
266
+ `
267
+ A brand represents a social media profile in Wahlu. All posts, media,
268
+ schedules, and queues belong to a brand. Most commands require a brand.
269
+
270
+ Subcommands:
271
+ list List all brands accessible to your API key
272
+ get <id> View full details for a brand
273
+ switch <id> Set a default brand for all future commands
274
+
275
+ Setting a default brand:
276
+ wahlu brand switch <brand-id>
277
+
278
+ This saves the brand ID to ~/.config/wahlu/config.json so you don't
279
+ need --brand on every command. You can also set WAHLU_BRAND_ID as
280
+ an environment variable.
281
+
282
+ Full documentation: https://wahlu.com/docs`
283
+ );
284
+ brandCommand.command("list").description("List all brands").option("--json", "Output as JSON").addHelpText(
285
+ "after",
286
+ `
287
+ Lists all brands accessible to your API key.
288
+
289
+ Response fields:
290
+ id string Brand ID
291
+ name string Brand name
292
+ description string|null Brand description
293
+ logo_url string|null Logo URL
294
+ timezone string|null IANA timezone (e.g. "Australia/Sydney")
295
+ website string|null Brand website URL
296
+ business_category string|null Business category
297
+ brand_kit object|null Brand kit (fonts, colours, voice)
298
+ content_preferences object|null CTA, logo frequency settings
299
+ image_posting object|null Image posting preferences
300
+ video_posting object|null Video posting preferences
301
+ created_at string ISO 8601 timestamp
302
+ updated_at string ISO 8601 timestamp
303
+
304
+ Examples:
305
+ wahlu brand list
306
+ wahlu brand list --json`
307
+ ).action(async (opts) => {
308
+ const client = new WahluClient(getApiKey(), getApiUrl());
309
+ const res = await client.get("/brands");
310
+ output(res.data, {
311
+ json: opts.json,
312
+ columns: [
313
+ { key: "id", header: "ID", width: 24 },
314
+ { key: "name", header: "Name", width: 30 },
315
+ { key: "timezone", header: "Timezone", width: 20 },
316
+ { key: "website", header: "Website", width: 30 }
317
+ ]
318
+ });
319
+ });
320
+ brandCommand.command("get").description("Get brand details").argument("<brand-id>", "Brand ID").option("--json", "Output as JSON").addHelpText(
321
+ "after",
322
+ `
323
+ Returns full details for a single brand including brand kit, content
324
+ preferences, and posting configuration.
325
+
326
+ Examples:
327
+ wahlu brand get abc123
328
+ wahlu brand get abc123 --json`
329
+ ).action(async (brandId, opts) => {
330
+ const client = new WahluClient(getApiKey(), getApiUrl());
331
+ const res = await client.get(`/brands/${brandId}`);
332
+ output(res.data, { json: opts.json });
333
+ });
334
+ brandCommand.command("switch").description("Set default brand for all commands").argument("<brand-id>", "Brand ID to use as default").addHelpText(
335
+ "after",
336
+ `
337
+ Saves the brand ID to ~/.config/wahlu/config.json. All commands that
338
+ require a brand will use this ID unless overridden with --brand.
339
+
340
+ Examples:
341
+ wahlu brand switch abc123
342
+ wahlu post list # uses abc123 automatically
343
+ wahlu post list --brand xyz789 # overrides for this command`
344
+ ).action((brandId) => {
345
+ const config = readConfig();
346
+ config.default_brand_id = brandId;
347
+ writeConfig(config);
348
+ console.log(`Default brand set to ${brandId}`);
349
+ });
350
+
351
+ // src/commands/idea.ts
352
+ import { Command as Command3 } from "commander";
353
+
354
+ // src/lib/resolve-brand.ts
355
+ function resolveBrandId(cmd) {
356
+ const brandId = cmd.optsWithGlobals().brand || getDefaultBrandId();
357
+ if (!brandId) {
358
+ console.error(
359
+ "No brand specified. Use --brand <id>, set WAHLU_BRAND_ID, or run: wahlu brand switch <id>"
360
+ );
361
+ process.exit(1);
362
+ }
363
+ return brandId;
364
+ }
365
+
366
+ // src/commands/idea.ts
367
+ var ideaCommand = new Command3("idea").description("Save, list, and manage content ideas").addHelpText(
368
+ "after",
369
+ `
370
+ Ideas are a scratchpad for future content concepts. Save ideas as you
371
+ think of them, then develop them into full posts later.
372
+
373
+ Subcommands:
374
+ list List all ideas
375
+ create <name> Save a new idea
376
+ delete <id> Delete an idea
377
+
378
+ Full documentation: https://wahlu.com/docs`
379
+ );
380
+ ideaCommand.command("list").description("List all ideas").option("--page <n>", "Page number (default: 1)", parseInt).option("--limit <n>", "Items per page (default: 50, max: 100)", parseInt).option("--json", "Output as JSON").addHelpText(
381
+ "after",
382
+ `
383
+ Returns a paginated list of content ideas.
384
+
385
+ Response fields:
386
+ id string Idea ID
387
+ name string|null Idea name/title
388
+ description string|null Detailed description
389
+ type string|null Idea type
390
+ status string Status
391
+ labels string[] Text labels
392
+ last_used_at string|null Last used timestamp
393
+ created_at string ISO 8601 timestamp
394
+ updated_at string ISO 8601 timestamp
395
+
396
+ Examples:
397
+ wahlu idea list
398
+ wahlu idea list --limit 10 --json`
399
+ ).action(async function(opts) {
400
+ const brandId = resolveBrandId(this);
401
+ const client = new WahluClient(getApiKey(), getApiUrl());
402
+ const res = await client.list(
403
+ `/brands/${brandId}/ideas`,
404
+ opts.page,
405
+ opts.limit
406
+ );
407
+ output(res.data, {
408
+ json: opts.json,
409
+ columns: [
410
+ { key: "id", header: "ID", width: 24 },
411
+ { key: "name", header: "Name", width: 40 },
412
+ { key: "status", header: "Status", width: 12 },
413
+ { key: "type", header: "Type", width: 12 }
414
+ ]
415
+ });
416
+ });
417
+ ideaCommand.command("create").description("Save a new content idea").argument("<name>", "Idea name/title (max 500 chars)").option("--description <text>", "Detailed description (max 10000 chars)").option("--type <type>", "Idea type (max 50 chars)").option("--json", "Output as JSON").addHelpText(
418
+ "after",
419
+ `
420
+ Saves a new content idea. Only the name is required.
421
+
422
+ Fields:
423
+ <name> string Idea name/title (required, max 500 chars)
424
+ --description string Detailed description (max 10000 chars)
425
+ --type string Idea type (max 50 chars)
426
+
427
+ Examples:
428
+ wahlu idea create "Blog about summer trends"
429
+ wahlu idea create "Product launch" --description "Announce the new feature" --type "campaign"
430
+ wahlu idea create "Quick tip series" --json`
431
+ ).action(async function(name, opts) {
432
+ const brandId = resolveBrandId(this);
433
+ const client = new WahluClient(getApiKey(), getApiUrl());
434
+ const body = { name };
435
+ if (opts.description) body.description = opts.description;
436
+ if (opts.type) body.type = opts.type;
437
+ const res = await client.post(`/brands/${brandId}/ideas`, body);
438
+ output(res.data, { json: opts.json });
439
+ });
440
+ ideaCommand.command("delete").description("Delete an idea").argument("<idea-id>", "Idea ID").addHelpText(
441
+ "after",
442
+ `
443
+ Permanently deletes an idea. This cannot be undone.
444
+
445
+ Examples:
446
+ wahlu idea delete idea-abc123`
447
+ ).action(async function(ideaId) {
448
+ const brandId = resolveBrandId(this);
449
+ const client = new WahluClient(getApiKey(), getApiUrl());
450
+ await client.delete(`/brands/${brandId}/ideas/${ideaId}`);
451
+ console.log(`Idea ${ideaId} deleted.`);
452
+ });
453
+
454
+ // src/commands/integration.ts
455
+ import { Command as Command4 } from "commander";
456
+ var integrationCommand = new Command4("integration").description("View connected social media accounts (read-only)").addHelpText(
457
+ "after",
458
+ `
459
+ Integrations are connected social media accounts. You need integration IDs
460
+ when scheduling posts (wahlu schedule create --integrations <id>).
461
+
462
+ Subcommands:
463
+ list List all connected integrations
464
+
465
+ Integrations are connected and managed in the Wahlu web app.
466
+ This command is read-only \u2014 you cannot connect or disconnect accounts via the CLI.
467
+
468
+ Full documentation: https://wahlu.com/docs`
469
+ );
470
+ integrationCommand.command("list").description("List connected integrations").option("--json", "Output as JSON").addHelpText(
471
+ "after",
472
+ `
473
+ Returns all connected integrations for the brand (no pagination).
474
+ Credentials (tokens, secrets) are never exposed.
475
+
476
+ Response fields:
477
+ id string Integration ID (use in --integrations when scheduling)
478
+ platform string Platform: "instagram" | "tiktok" | "facebook" | "youtube" | "linkedin"
479
+ status string Connection status
480
+ brand_id string Brand ID
481
+ display_name string|null Display name on the platform
482
+ username string|null Platform username/handle
483
+ avatar_url string|null Profile avatar URL
484
+ permissions object|null Granted permissions
485
+ created_at string ISO 8601 timestamp
486
+ updated_at string ISO 8601 timestamp
487
+
488
+ Examples:
489
+ wahlu integration list
490
+ wahlu integration list --json
491
+ wahlu integration list --json | jq '.[] | {id, platform, username}'`
492
+ ).action(async function(opts) {
493
+ const brandId = resolveBrandId(this);
494
+ const client = new WahluClient(getApiKey(), getApiUrl());
495
+ const res = await client.get(
496
+ `/brands/${brandId}/integrations`
497
+ );
498
+ output(res.data, {
499
+ json: opts.json,
500
+ columns: [
501
+ { key: "id", header: "ID", width: 24 },
502
+ { key: "platform", header: "Platform", width: 12 },
503
+ { key: "username", header: "Username", width: 20 },
504
+ { key: "status", header: "Status", width: 12 }
505
+ ]
506
+ });
507
+ });
508
+
509
+ // src/commands/label.ts
510
+ import { Command as Command5 } from "commander";
511
+ var labelCommand = new Command5("label").description("Create and manage labels for organising posts").addHelpText(
512
+ "after",
513
+ `
514
+ Labels are used to categorise and organise posts and media.
515
+
516
+ Subcommands:
517
+ list List all labels
518
+ create <name> Create a new label
519
+ delete <id> Delete a label
520
+
521
+ Labels can be attached to posts:
522
+ wahlu post create --name "My post" --labels label-1 label-2
523
+
524
+ Full documentation: https://wahlu.com/docs`
525
+ );
526
+ labelCommand.command("list").description("List all labels").option("--json", "Output as JSON").addHelpText(
527
+ "after",
528
+ `
529
+ Returns all labels for the brand (no pagination \u2014 returns all at once).
530
+
531
+ Response fields:
532
+ id string Label ID
533
+ name string Label name
534
+ color string|null Colour hex code (e.g. "#ff5500")
535
+ created_at string ISO 8601 timestamp
536
+ updated_at string ISO 8601 timestamp
537
+
538
+ Examples:
539
+ wahlu label list
540
+ wahlu label list --json`
541
+ ).action(async function(opts) {
542
+ const brandId = resolveBrandId(this);
543
+ const client = new WahluClient(getApiKey(), getApiUrl());
544
+ const res = await client.get(`/brands/${brandId}/labels`);
545
+ output(res.data, {
546
+ json: opts.json,
547
+ columns: [
548
+ { key: "id", header: "ID", width: 24 },
549
+ { key: "name", header: "Name", width: 30 },
550
+ { key: "color", header: "Colour", width: 10 }
551
+ ]
552
+ });
553
+ });
554
+ labelCommand.command("create").description("Create a label").argument("<name>", "Label name (required, max 100 chars)").option("--color <hex>", 'Colour hex code (e.g. "#ff5500", max 20 chars)').option("--json", "Output as JSON").addHelpText(
555
+ "after",
556
+ `
557
+ Creates a new label for organising posts and media.
558
+
559
+ Fields:
560
+ <name> string Label name (required, max 100 chars)
561
+ --color string Colour hex code (optional, max 20 chars)
562
+
563
+ Examples:
564
+ wahlu label create "Urgent"
565
+ wahlu label create "Promo" --color "#ff5500"
566
+ wahlu label create "Campaign Q1" --json`
567
+ ).action(async function(name, opts) {
568
+ const brandId = resolveBrandId(this);
569
+ const client = new WahluClient(getApiKey(), getApiUrl());
570
+ const body = { name };
571
+ if (opts.color) body.color = opts.color;
572
+ const res = await client.post(`/brands/${brandId}/labels`, body);
573
+ output(res.data, { json: opts.json });
574
+ });
575
+ labelCommand.command("delete").description("Delete a label").argument("<label-id>", "Label ID").addHelpText(
576
+ "after",
577
+ `
578
+ Permanently deletes a label. This does not remove the label from
579
+ posts that already have it attached.
580
+
581
+ Examples:
582
+ wahlu label delete label-abc123`
583
+ ).action(async function(labelId) {
584
+ const brandId = resolveBrandId(this);
585
+ const client = new WahluClient(getApiKey(), getApiUrl());
586
+ await client.delete(`/brands/${brandId}/labels/${labelId}`);
587
+ console.log(`Label ${labelId} deleted.`);
588
+ });
589
+
590
+ // src/commands/media.ts
591
+ import { readFileSync as readFileSync2, statSync } from "fs";
592
+ import { basename } from "path";
593
+ import { Command as Command6 } from "commander";
594
+ var MIME_TYPES = {
595
+ ".png": "image/png",
596
+ ".jpg": "image/jpeg",
597
+ ".jpeg": "image/jpeg",
598
+ ".gif": "image/gif",
599
+ ".webp": "image/webp",
600
+ ".mp4": "video/mp4",
601
+ ".mov": "video/quicktime",
602
+ ".webm": "video/webm"
603
+ };
604
+ var mediaCommand = new Command6("media").description("Upload images/videos and manage your media library").addHelpText(
605
+ "after",
606
+ `
607
+ Media files (images and videos) that can be attached to posts via media_ids
608
+ in platform settings.
609
+
610
+ Subcommands:
611
+ list List all media files in the library
612
+ upload <file> Upload a local image or video file
613
+ delete <id> Permanently delete a media file
614
+
615
+ Supported formats:
616
+ Images: .png, .jpg, .jpeg, .gif, .webp
617
+ Videos: .mp4, .mov, .webm
618
+
619
+ Typical workflow:
620
+ 1. Upload: wahlu media upload ./photo.jpg
621
+ 2. Get the media ID from the output
622
+ 3. Use in a post: wahlu post create --name "Photo post" \\
623
+ --instagram '{"description":"Nice!","post_type":"grid_post","media_ids":["<media-id>"]}'
624
+
625
+ Full documentation: https://wahlu.com/docs`
626
+ );
627
+ mediaCommand.command("list").description("List media files").option("--page <n>", "Page number (default: 1)", parseInt).option("--limit <n>", "Items per page (default: 50, max: 100)", parseInt).option("--json", "Output as JSON").addHelpText(
628
+ "after",
629
+ `
630
+ Returns a paginated list of media files for the brand.
631
+
632
+ Response fields:
633
+ id string Media ID (use in media_ids arrays)
634
+ file_name string Original filename
635
+ content_type string MIME type (e.g. "image/jpeg", "video/mp4")
636
+ size number File size in bytes
637
+ duration number|null Duration in seconds (video/audio only)
638
+ status string Processing status: "available" | "processing" | "completed" | "failed"
639
+ workflow_status string|null Normalised lifecycle status
640
+ label_ids string[] Attached label IDs
641
+ folder_id string|null Folder ID
642
+ download_url string|null Signed download URL
643
+ thumbnail_large_url string|null Large thumbnail URL
644
+ thumbnail_small_url string|null Small thumbnail URL
645
+ source string|null Upload source: "upload" | "generated" | "stock" | "scan"
646
+ description string|null Media description
647
+ last_used_at string|null Last used in a post
648
+ created_at string ISO 8601 timestamp
649
+ updated_at string ISO 8601 timestamp
650
+
651
+ Examples:
652
+ wahlu media list
653
+ wahlu media list --limit 10 --json`
654
+ ).action(async function(opts) {
655
+ const brandId = resolveBrandId(this);
656
+ const client = new WahluClient(getApiKey(), getApiUrl());
657
+ const res = await client.list(
658
+ `/brands/${brandId}/media`,
659
+ opts.page,
660
+ opts.limit
661
+ );
662
+ output(res.data, {
663
+ json: opts.json,
664
+ columns: [
665
+ { key: "id", header: "ID", width: 24 },
666
+ { key: "file_name", header: "Filename", width: 30 },
667
+ { key: "content_type", header: "Type", width: 16 },
668
+ { key: "status", header: "Status", width: 12 },
669
+ {
670
+ key: "size",
671
+ header: "Size",
672
+ width: 10,
673
+ transform: (v) => {
674
+ if (!v) return "-";
675
+ const bytes = v;
676
+ if (bytes < 1024) return `${bytes}B`;
677
+ if (bytes < 1024 * 1024)
678
+ return `${(bytes / 1024).toFixed(0)}KB`;
679
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
680
+ }
681
+ }
682
+ ]
683
+ });
684
+ });
685
+ mediaCommand.command("upload").description("Upload a local file to the media library").argument("<file>", "Local file path (e.g. ./photo.jpg, ~/videos/clip.mp4)").option("--json", "Output as JSON (returns media ID)").addHelpText(
686
+ "after",
687
+ `
688
+ Uploads a local file to your brand's media library. The upload is a 3-step
689
+ process handled automatically:
690
+ 1. Request a signed upload URL from the API
691
+ 2. Upload the file bytes to the signed URL
692
+ 3. Mark the media as available for processing
693
+
694
+ After upload, Wahlu processes the media (generates thumbnails, etc.).
695
+ Use 'wahlu media list' to check when status changes to "completed".
696
+
697
+ The returned media ID can be used in post platform settings:
698
+ --instagram '{"media_ids":["<media-id>"],...}'
699
+
700
+ Examples:
701
+ wahlu media upload ./photo.jpg
702
+ wahlu media upload ~/Desktop/video.mp4 --json`
703
+ ).action(async function(filePath, opts) {
704
+ const brandId = resolveBrandId(this);
705
+ const client = new WahluClient(getApiKey(), getApiUrl());
706
+ const filename = basename(filePath);
707
+ const ext = filePath.substring(filePath.lastIndexOf(".")).toLowerCase();
708
+ const contentType = MIME_TYPES[ext];
709
+ if (!contentType) {
710
+ console.error(
711
+ `Unsupported file type: ${ext}
712
+ Supported: ${Object.keys(MIME_TYPES).join(", ")}`
713
+ );
714
+ process.exit(1);
715
+ }
716
+ const stat = statSync(filePath);
717
+ const urlRes = await client.post(`/brands/${brandId}/media/upload-url`, {
718
+ filename,
719
+ content_type: contentType,
720
+ size: stat.size
721
+ });
722
+ const { id, upload_url } = urlRes.data;
723
+ const fileBytes = readFileSync2(filePath);
724
+ const uploadRes = await fetch(upload_url, {
725
+ method: "PUT",
726
+ headers: { "Content-Type": contentType },
727
+ body: fileBytes
728
+ });
729
+ if (!uploadRes.ok) {
730
+ console.error(`Upload failed: HTTP ${uploadRes.status}`);
731
+ process.exit(1);
732
+ }
733
+ await client.patch(`/brands/${brandId}/media/${id}`, {
734
+ status: "available"
735
+ });
736
+ if (opts.json) {
737
+ console.log(
738
+ JSON.stringify({ id, filename, status: "processing" }, null, 2)
739
+ );
740
+ } else {
741
+ console.log(`Uploaded ${filename} \u2014 media ID: ${id}`);
742
+ console.log(
743
+ "Processing will complete shortly. Use 'wahlu media list' to check status."
744
+ );
745
+ }
746
+ });
747
+ mediaCommand.command("delete").description("Permanently delete a media file").argument("<media-id>", "Media ID").addHelpText(
748
+ "after",
749
+ `
750
+ Permanently deletes a media file. This cannot be undone.
751
+
752
+ Examples:
753
+ wahlu media delete mid-abc123`
754
+ ).action(async function(mediaId) {
755
+ const brandId = resolveBrandId(this);
756
+ const client = new WahluClient(getApiKey(), getApiUrl());
757
+ await client.delete(`/brands/${brandId}/media/${mediaId}`);
758
+ console.log(`Media ${mediaId} deleted.`);
759
+ });
760
+
761
+ // src/commands/post.ts
762
+ import { Command as Command7 } from "commander";
763
+ var postCommand = new Command7("post").description("Create, update, list, and delete posts with per-platform settings").addHelpText(
764
+ "after",
765
+ `
766
+ Posts are the core content unit in Wahlu. Each post can have platform-specific
767
+ settings for Instagram, TikTok, Facebook, YouTube, and LinkedIn.
768
+
769
+ Subcommands:
770
+ list List all posts for the brand
771
+ get <id> View full post details including platform settings
772
+ create Create a new post with optional platform settings
773
+ update <id> Update an existing post (only provided fields change)
774
+ delete <id> Permanently delete a post
775
+
776
+ Typical workflow:
777
+ 1. Upload media: wahlu media upload ./photo.jpg
778
+ 2. Create a post: wahlu post create --name "My post" --instagram '...'
779
+ 3. Schedule or queue: wahlu schedule create <post-id> --at <datetime> --integrations <id>
780
+
781
+ Run 'wahlu post create --help' for full platform settings reference.
782
+ Full documentation: https://wahlu.com/docs`
783
+ );
784
+ postCommand.command("list").description("List posts").option("--page <n>", "Page number (default: 1)", parseInt).option("--limit <n>", "Items per page (default: 50, max: 100)", parseInt).option("--json", "Output as JSON").addHelpText(
785
+ "after",
786
+ `
787
+ Returns a paginated list of posts for the brand.
788
+
789
+ Response fields (per post):
790
+ id string Post ID
791
+ name string|null Post name
792
+ brand_id string Brand ID
793
+ label_ids string[] Attached label IDs
794
+ created_by string|null Creator user ID
795
+ thumbnail_timestamp number Thumbnail timestamp (seconds)
796
+ instagram_settings object|null Instagram configuration
797
+ tiktok_settings object|null TikTok configuration
798
+ facebook_settings object|null Facebook configuration
799
+ youtube_settings object|null YouTube configuration
800
+ linkedin_settings object|null LinkedIn configuration
801
+ created_at string ISO 8601 timestamp
802
+ updated_at string ISO 8601 timestamp
803
+
804
+ Examples:
805
+ wahlu post list
806
+ wahlu post list --limit 10 --page 2
807
+ wahlu post list --json | jq '.[].name'`
808
+ ).action(async function(opts) {
809
+ const brandId = resolveBrandId(this);
810
+ const client = new WahluClient(getApiKey(), getApiUrl());
811
+ const res = await client.list(
812
+ `/brands/${brandId}/posts`,
813
+ opts.page,
814
+ opts.limit
815
+ );
816
+ output(res.data, {
817
+ json: opts.json,
818
+ columns: [
819
+ { key: "id", header: "ID", width: 24 },
820
+ { key: "name", header: "Name", width: 40 },
821
+ {
822
+ key: "created_at",
823
+ header: "Created",
824
+ width: 20,
825
+ transform: (v) => v ? new Date(v).toLocaleDateString() : "-"
826
+ }
827
+ ]
828
+ });
829
+ if (!opts.json && res.pagination?.has_more) {
830
+ console.log(
831
+ `
832
+ Page ${res.pagination.page} \u2014 more results available (--page ${res.pagination.page + 1})`
833
+ );
834
+ }
835
+ });
836
+ postCommand.command("get").description("Get post details").argument("<post-id>", "Post ID").option("--json", "Output as JSON").addHelpText(
837
+ "after",
838
+ `
839
+ Returns full details for a single post including all platform settings.
840
+
841
+ Examples:
842
+ wahlu post get abc123
843
+ wahlu post get abc123 --json`
844
+ ).action(async function(postId, opts) {
845
+ const brandId = resolveBrandId(this);
846
+ const client = new WahluClient(getApiKey(), getApiUrl());
847
+ const res = await client.get(`/brands/${brandId}/posts/${postId}`);
848
+ output(res.data, { json: opts.json });
849
+ });
850
+ postCommand.command("create").description("Create a new post").option("--name <name>", "Post name (max 500 chars)").option("--instagram <json>", "Instagram settings as JSON string").option("--tiktok <json>", "TikTok settings as JSON string").option("--facebook <json>", "Facebook settings as JSON string").option("--youtube <json>", "YouTube settings as JSON string").option("--linkedin <json>", "LinkedIn settings as JSON string").option("--labels <ids...>", "Label IDs to attach (max 50)").option("--json", "Output as JSON").addHelpText(
851
+ "after",
852
+ `
853
+ Creates a new post with optional platform-specific settings. You can target
854
+ multiple platforms in a single post by providing multiple --<platform> flags.
855
+
856
+ Examples:
857
+ wahlu post create --name "Monday post" \\
858
+ --instagram '{"description":"Hello!","post_type":"grid_post"}'
859
+
860
+ wahlu post create --name "Cross-platform video" \\
861
+ --tiktok '{"description":"Check this out","post_type":"video","media_ids":["mid-123"]}' \\
862
+ --instagram '{"description":"Check this out","post_type":"reel","media_ids":["mid-123"]}'
863
+
864
+ wahlu post create --name "Article share" \\
865
+ --linkedin '{"description":"Read our latest post","post_type":"li_article","original_url":"https://example.com/post","title":"Our Latest Post"}'
866
+
867
+ Platform settings reference:
868
+
869
+ Instagram (--instagram):
870
+ Field Type Values / Description
871
+ description string Caption text
872
+ post_type string "grid_post" | "reel" | "story"
873
+ media_ids string[] Media IDs to attach
874
+ trial_reel boolean Post as trial reel (shown to non-followers first)
875
+ graduation_strategy string "MANUAL" | "SS_PERFORMANCE" (auto-graduate trial reels)
876
+
877
+ TikTok (--tiktok):
878
+ Field Type Values / Description
879
+ description string Caption text
880
+ post_type string "video" | "image" | "carousel"
881
+ media_ids string[] Media IDs to attach
882
+ privacy_level string "PUBLIC_TO_EVERYONE" | "MUTUAL_FOLLOW_FRIENDS" | "FOLLOWER_OF_CREATOR" | "SELF_ONLY"
883
+ allow_comment boolean Allow comments (default: true)
884
+ allow_duet boolean Allow duets (default: true, video only)
885
+ allow_stitch boolean Allow stitches (default: true, video only)
886
+ auto_add_music boolean Auto-add music (photo/carousel only)
887
+ is_aigc boolean Disclose as AI-generated content
888
+ is_commercial_content boolean Mark as commercial/branded content
889
+
890
+ Facebook (--facebook):
891
+ Field Type Values / Description
892
+ description string Caption text
893
+ post_type string "fb_post" | "fb_story" | "fb_reel" | "fb_text"
894
+ media_ids string[] Media IDs to attach
895
+
896
+ YouTube (--youtube):
897
+ Field Type Values / Description
898
+ title string Video title
899
+ description string Video description
900
+ post_type string "yt_short" | "yt_video"
901
+ media_ids string[] Media IDs to attach
902
+ privacy_level string "public" | "unlisted" | "private"
903
+ notify_subscribers boolean Notify subscribers on publish
904
+
905
+ LinkedIn (--linkedin):
906
+ Field Type Values / Description
907
+ description string Post text
908
+ post_type string "li_text" | "li_image" | "li_video" | "li_article"
909
+ media_ids string[] Media IDs to attach
910
+ visibility string "PUBLIC" | "CONNECTIONS"
911
+ title string Article title (li_article only)
912
+ original_url string Article URL (li_article only)
913
+
914
+ Full documentation: https://wahlu.com/docs`
915
+ ).action(async function(opts) {
916
+ const brandId = resolveBrandId(this);
917
+ const client = new WahluClient(getApiKey(), getApiUrl());
918
+ const body = {};
919
+ if (opts.name) body.name = opts.name;
920
+ if (opts.labels) body.label_ids = opts.labels;
921
+ if (opts.instagram)
922
+ body.instagram_settings = JSON.parse(opts.instagram);
923
+ if (opts.tiktok) body.tiktok_settings = JSON.parse(opts.tiktok);
924
+ if (opts.facebook)
925
+ body.facebook_settings = JSON.parse(opts.facebook);
926
+ if (opts.youtube) body.youtube_settings = JSON.parse(opts.youtube);
927
+ if (opts.linkedin)
928
+ body.linkedin_settings = JSON.parse(opts.linkedin);
929
+ const res = await client.post(`/brands/${brandId}/posts`, body);
930
+ output(res.data, { json: opts.json });
931
+ });
932
+ postCommand.command("update").description("Update a post (only provided fields are changed)").argument("<post-id>", "Post ID").option("--name <name>", "Post name (max 500 chars)").option("--instagram <json>", "Instagram settings as JSON string").option("--tiktok <json>", "TikTok settings as JSON string").option("--facebook <json>", "Facebook settings as JSON string").option("--youtube <json>", "YouTube settings as JSON string").option("--linkedin <json>", "LinkedIn settings as JSON string").option("--labels <ids...>", "Label IDs to attach (max 50)").option("--json", "Output as JSON").addHelpText(
933
+ "after",
934
+ `
935
+ Updates an existing post. Only the fields you provide are changed \u2014
936
+ omitted fields remain unchanged.
937
+
938
+ Examples:
939
+ wahlu post update abc123 --name "New name"
940
+ wahlu post update abc123 --instagram '{"description":"Updated caption"}'
941
+ wahlu post update abc123 --labels label-1 label-2
942
+
943
+ See 'wahlu post create --help' for full platform settings reference.
944
+ Full documentation: https://wahlu.com/docs`
945
+ ).action(async function(postId, opts) {
946
+ const brandId = resolveBrandId(this);
947
+ const client = new WahluClient(getApiKey(), getApiUrl());
948
+ const body = {};
949
+ if (opts.name) body.name = opts.name;
950
+ if (opts.labels) body.label_ids = opts.labels;
951
+ if (opts.instagram)
952
+ body.instagram_settings = JSON.parse(opts.instagram);
953
+ if (opts.tiktok) body.tiktok_settings = JSON.parse(opts.tiktok);
954
+ if (opts.facebook)
955
+ body.facebook_settings = JSON.parse(opts.facebook);
956
+ if (opts.youtube) body.youtube_settings = JSON.parse(opts.youtube);
957
+ if (opts.linkedin)
958
+ body.linkedin_settings = JSON.parse(opts.linkedin);
959
+ const res = await client.patch(
960
+ `/brands/${brandId}/posts/${postId}`,
961
+ body
962
+ );
963
+ output(res.data, { json: opts.json });
964
+ });
965
+ postCommand.command("delete").description("Permanently delete a post").argument("<post-id>", "Post ID").addHelpText(
966
+ "after",
967
+ `
968
+ Permanently deletes a post. This cannot be undone.
969
+
970
+ Examples:
971
+ wahlu post delete abc123`
972
+ ).action(async function(postId) {
973
+ const brandId = resolveBrandId(this);
974
+ const client = new WahluClient(getApiKey(), getApiUrl());
975
+ await client.delete(`/brands/${brandId}/posts/${postId}`);
976
+ console.log(`Post ${postId} deleted.`);
977
+ });
978
+
979
+ // src/commands/publication.ts
980
+ import { Command as Command8 } from "commander";
981
+ var publicationCommand = new Command8("publication").description("View posts that have been published to platforms (read-only)").addHelpText(
982
+ "after",
983
+ `
984
+ Publications are records of posts that have been successfully published
985
+ to social media platforms. This is a read-only view of your publishing history.
986
+
987
+ Subcommands:
988
+ list List all publications
989
+
990
+ Full documentation: https://wahlu.com/docs`
991
+ );
992
+ publicationCommand.command("list").description("List published posts").option("--page <n>", "Page number (default: 1)", parseInt).option("--limit <n>", "Items per page (default: 50, max: 100)", parseInt).option("--json", "Output as JSON").addHelpText(
993
+ "after",
994
+ `
995
+ Returns a paginated list of published posts.
996
+
997
+ Response fields:
998
+ id string Publication ID
999
+ platform string Platform: "instagram" | "tiktok" | "facebook" | "youtube" | "linkedin"
1000
+ post_id string Source post ID
1001
+ post_name string|null Post name
1002
+ post_type string|null Post type (e.g. "grid_post", "reel", "video")
1003
+ media_type string|null Media type
1004
+ status string "processing" | "published" | "failed"
1005
+ source string|null "calendar" (from schedule) or "queue" (from queue)
1006
+ failure_reason string|null Failure reason if publishing failed
1007
+ integration_id string Integration used for publishing
1008
+ publish_id string|null Platform-specific content ID
1009
+ published_at string When it was published (ISO 8601)
1010
+ created_at string ISO 8601 timestamp
1011
+ updated_at string ISO 8601 timestamp
1012
+
1013
+ Examples:
1014
+ wahlu publication list
1015
+ wahlu publication list --limit 10 --json
1016
+ wahlu publication list --json | jq '.[] | select(.status == "failed")'`
1017
+ ).action(async function(opts) {
1018
+ const brandId = resolveBrandId(this);
1019
+ const client = new WahluClient(getApiKey(), getApiUrl());
1020
+ const res = await client.list(
1021
+ `/brands/${brandId}/publications`,
1022
+ opts.page,
1023
+ opts.limit
1024
+ );
1025
+ output(res.data, {
1026
+ json: opts.json,
1027
+ columns: [
1028
+ { key: "id", header: "ID", width: 24 },
1029
+ { key: "platform", header: "Platform", width: 12 },
1030
+ { key: "post_name", header: "Post", width: 30 },
1031
+ { key: "status", header: "Status", width: 12 },
1032
+ {
1033
+ key: "published_at",
1034
+ header: "Published",
1035
+ width: 20,
1036
+ transform: (v) => v ? new Date(v).toLocaleString() : "-"
1037
+ }
1038
+ ]
1039
+ });
1040
+ });
1041
+
1042
+ // src/commands/queue.ts
1043
+ import { Command as Command9 } from "commander";
1044
+ var queueCommand = new Command9("queue").description("View publishing queues and add posts to them").addHelpText(
1045
+ "after",
1046
+ `
1047
+ Queues define recurring time slots for automatic post publishing.
1048
+ Posts added to a queue are published at the next available slot.
1049
+
1050
+ Subcommands:
1051
+ list List all queues and their status
1052
+ add <queue-id> <post-id> Add a post to a queue
1053
+
1054
+ Queues are created and configured in the Wahlu web app.
1055
+
1056
+ Full documentation: https://wahlu.com/docs`
1057
+ );
1058
+ queueCommand.command("list").description("List all queues").option("--json", "Output as JSON").addHelpText(
1059
+ "after",
1060
+ `
1061
+ Returns all queues for the brand (no pagination \u2014 returns all at once).
1062
+
1063
+ Response fields:
1064
+ id string Queue ID
1065
+ name string Queue name
1066
+ brand_id string Brand ID
1067
+ active boolean Whether the queue is active
1068
+ mode string Queue mode
1069
+ interval number|null Interval between posts
1070
+ interval_unit string|null Interval unit
1071
+ times_of_day string[] Scheduled times (e.g. ["09:00", "17:00"])
1072
+ timezone string|null IANA timezone
1073
+ valid_from string|null ISO 8601 start date
1074
+ valid_until string|null ISO 8601 end date
1075
+ next_run_at string|null Next scheduled publishing time
1076
+ loop boolean Whether to loop through posts
1077
+ post_ids string[] Ordered list of post IDs in the queue
1078
+ integration_ids string[] Integration IDs to publish to
1079
+ skip_count number Number of posts skipped
1080
+ created_at string ISO 8601 timestamp
1081
+ updated_at string ISO 8601 timestamp
1082
+
1083
+ Examples:
1084
+ wahlu queue list
1085
+ wahlu queue list --json`
1086
+ ).action(async function(opts) {
1087
+ const brandId = resolveBrandId(this);
1088
+ const client = new WahluClient(getApiKey(), getApiUrl());
1089
+ const res = await client.get(`/brands/${brandId}/queues`);
1090
+ output(res.data, {
1091
+ json: opts.json,
1092
+ columns: [
1093
+ { key: "id", header: "ID", width: 24 },
1094
+ { key: "name", header: "Name", width: 30 },
1095
+ {
1096
+ key: "active",
1097
+ header: "Active",
1098
+ width: 8,
1099
+ transform: (v) => v ? "yes" : "no"
1100
+ },
1101
+ { key: "mode", header: "Mode", width: 12 },
1102
+ {
1103
+ key: "next_run_at",
1104
+ header: "Next Run",
1105
+ width: 20,
1106
+ transform: (v) => v ? new Date(v).toLocaleString() : "-"
1107
+ }
1108
+ ]
1109
+ });
1110
+ });
1111
+ queueCommand.command("add").description("Add a post to a queue").argument("<queue-id>", "Queue ID").argument("<post-id>", "Post ID to add").option("--json", "Output as JSON").addHelpText(
1112
+ "after",
1113
+ `
1114
+ Adds a post to a queue. The post will be published at the queue's
1115
+ next available time slot.
1116
+
1117
+ Examples:
1118
+ wahlu queue add queue-abc post-xyz
1119
+ wahlu queue add queue-abc post-xyz --json`
1120
+ ).action(async function(queueId, postId, opts) {
1121
+ const brandId = resolveBrandId(this);
1122
+ const client = new WahluClient(getApiKey(), getApiUrl());
1123
+ const res = await client.patch(
1124
+ `/brands/${brandId}/queues/${queueId}`,
1125
+ {
1126
+ post_ids: [postId]
1127
+ }
1128
+ );
1129
+ output(res.data, { json: opts.json });
1130
+ });
1131
+
1132
+ // src/commands/schedule.ts
1133
+ import { Command as Command10 } from "commander";
1134
+ var scheduleCommand = new Command10("schedule").description("Schedule posts for future publishing to specific integrations").addHelpText(
1135
+ "after",
1136
+ `
1137
+ Scheduled posts are queued for publishing at a specific date and time
1138
+ to one or more connected social media integrations.
1139
+
1140
+ Subcommands:
1141
+ list List all scheduled posts
1142
+ create <post-id> Schedule a post for future publishing
1143
+ delete <id> Remove a post from the schedule (does not delete the post)
1144
+
1145
+ Typical workflow:
1146
+ 1. Create a post: wahlu post create --name "My post" --instagram '...'
1147
+ 2. Find integration IDs: wahlu integration list
1148
+ 3. Schedule it: wahlu schedule create <post-id> --at <datetime> --integrations <id>
1149
+
1150
+ Full documentation: https://wahlu.com/docs`
1151
+ );
1152
+ scheduleCommand.command("list").description("List scheduled posts").option("--page <n>", "Page number (default: 1)", parseInt).option("--limit <n>", "Items per page (default: 50, max: 100)", parseInt).option("--json", "Output as JSON").addHelpText(
1153
+ "after",
1154
+ `
1155
+ Returns a paginated list of scheduled posts, sorted by scheduled_at.
1156
+
1157
+ Response fields:
1158
+ id string Scheduled post ID
1159
+ brand_id string Brand ID
1160
+ post_id string Referenced post ID
1161
+ scheduled_at string ISO 8601 datetime for publishing
1162
+ integration_ids string[] Integration IDs to publish to
1163
+ status string Status (e.g. "ready_for_publishing", "published", "failed")
1164
+ approval_status string|null Approval status
1165
+ source string|null "api" for API-created entries
1166
+ failure_reason string|null Failure reason if publishing failed
1167
+ thumbnail_url string|null Post thumbnail URL
1168
+ created_at string ISO 8601 timestamp
1169
+ updated_at string ISO 8601 timestamp
1170
+
1171
+ Examples:
1172
+ wahlu schedule list
1173
+ wahlu schedule list --limit 5 --json`
1174
+ ).action(async function(opts) {
1175
+ const brandId = resolveBrandId(this);
1176
+ const client = new WahluClient(getApiKey(), getApiUrl());
1177
+ const res = await client.list(
1178
+ `/brands/${brandId}/scheduled-posts`,
1179
+ opts.page,
1180
+ opts.limit
1181
+ );
1182
+ output(res.data, {
1183
+ json: opts.json,
1184
+ columns: [
1185
+ { key: "id", header: "ID", width: 24 },
1186
+ { key: "post_id", header: "Post ID", width: 24 },
1187
+ { key: "status", header: "Status", width: 12 },
1188
+ {
1189
+ key: "scheduled_at",
1190
+ header: "Scheduled At",
1191
+ width: 20,
1192
+ transform: (v) => v ? new Date(v).toLocaleString() : "-"
1193
+ }
1194
+ ]
1195
+ });
1196
+ });
1197
+ scheduleCommand.command("create").description("Schedule a post for future publishing").argument("<post-id>", "Post ID to schedule (must exist in the brand)").requiredOption(
1198
+ "--at <datetime>",
1199
+ "ISO 8601 datetime (e.g. 2026-03-15T14:00:00Z)"
1200
+ ).requiredOption(
1201
+ "--integrations <ids...>",
1202
+ "Integration IDs to publish to (max 20)"
1203
+ ).option("--json", "Output as JSON").addHelpText(
1204
+ "after",
1205
+ `
1206
+ Schedules an existing post for future publishing. The post must already
1207
+ exist in the brand, and the integration IDs must be connected accounts.
1208
+
1209
+ Required fields:
1210
+ <post-id> The ID of an existing post in this brand
1211
+ --at <datetime> ISO 8601 datetime (e.g. "2026-03-15T14:00:00Z")
1212
+ --integrations <ids> Space-separated integration IDs
1213
+
1214
+ Find integration IDs with: wahlu integration list
1215
+
1216
+ Examples:
1217
+ wahlu schedule create post-abc \\
1218
+ --at 2026-03-15T14:00:00Z \\
1219
+ --integrations int-123
1220
+
1221
+ wahlu schedule create post-abc \\
1222
+ --at 2026-03-15T14:00:00Z \\
1223
+ --integrations int-123 int-456 int-789 \\
1224
+ --json`
1225
+ ).action(async function(postId, opts) {
1226
+ const brandId = resolveBrandId(this);
1227
+ const client = new WahluClient(getApiKey(), getApiUrl());
1228
+ const res = await client.post(`/brands/${brandId}/scheduled-posts`, {
1229
+ post_id: postId,
1230
+ scheduled_at: opts.at,
1231
+ integration_ids: opts.integrations
1232
+ });
1233
+ output(res.data, { json: opts.json });
1234
+ });
1235
+ scheduleCommand.command("delete").description("Remove a post from the schedule").argument("<scheduled-post-id>", "Scheduled post ID (not the post ID)").addHelpText(
1236
+ "after",
1237
+ `
1238
+ Removes a scheduled post from the publishing queue. The post itself is
1239
+ not deleted \u2014 only the schedule entry is removed.
1240
+
1241
+ Examples:
1242
+ wahlu schedule delete sched-abc123`
1243
+ ).action(async function(id) {
1244
+ const brandId = resolveBrandId(this);
1245
+ const client = new WahluClient(getApiKey(), getApiUrl());
1246
+ await client.delete(`/brands/${brandId}/scheduled-posts/${id}`);
1247
+ console.log(`Scheduled post ${id} removed.`);
1248
+ });
1249
+
1250
+ // src/index.ts
1251
+ var program = new Command11().name("wahlu").description("Wahlu CLI \u2014 manage your social media from the terminal").version("0.1.0").option("--brand <id>", "Brand ID (overrides default)").configureHelp({ sortSubcommands: true }).addHelpText(
1252
+ "after",
1253
+ `
1254
+ Getting started:
1255
+ 1. Generate an API key at wahlu.com under Settings > API Keys
1256
+ 2. wahlu auth login <your-api-key>
1257
+ 3. wahlu brand list
1258
+ 4. wahlu brand switch <brand-id>
1259
+ 5. wahlu post list
1260
+
1261
+ All list/get/create/update commands support --json for machine-readable output.
1262
+ Run 'wahlu <command> --help' for detailed usage, fields, and examples.
1263
+
1264
+ Full documentation: https://wahlu.com/docs`
1265
+ );
1266
+ program.addCommand(authCommand);
1267
+ program.addCommand(brandCommand);
1268
+ program.addCommand(ideaCommand);
1269
+ program.addCommand(integrationCommand);
1270
+ program.addCommand(labelCommand);
1271
+ program.addCommand(mediaCommand);
1272
+ program.addCommand(postCommand);
1273
+ program.addCommand(publicationCommand);
1274
+ program.addCommand(queueCommand);
1275
+ program.addCommand(scheduleCommand);
1276
+ program.exitOverride();
1277
+ async function main() {
1278
+ try {
1279
+ await program.parseAsync(process.argv);
1280
+ } catch (err) {
1281
+ if (err instanceof CliError) {
1282
+ console.error(`Error: ${err.message}`);
1283
+ process.exit(1);
1284
+ }
1285
+ if (err && typeof err === "object" && "code" in err && err.code === "commander.helpDisplayed") {
1286
+ process.exit(0);
1287
+ }
1288
+ if (err && typeof err === "object" && "code" in err && err.code === "commander.version") {
1289
+ process.exit(0);
1290
+ }
1291
+ throw err;
1292
+ }
1293
+ }
1294
+ main();