@postaz/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.
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "postaz-agent",
3
+ "owner": {
4
+ "name": "Postaz",
5
+ "url": "https://postaz.app"
6
+ },
7
+ "metadata": {
8
+ "description": "Postaz TikTok scheduling skill — publish and schedule TikTok posts from an AI agent",
9
+ "version": "0.1.0"
10
+ },
11
+ "plugins": [
12
+ {
13
+ "name": "postaz",
14
+ "description": "TikTok-first social scheduling CLI for AI agents — schedule and publish TikTok posts (video or photo slideshows), generate captions, and manage media.",
15
+ "source": "./",
16
+ "strict": false,
17
+ "skills": ["./"]
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "postaz",
3
+ "version": "0.1.0",
4
+ "description": "TikTok-first social scheduling CLI for AI agents — schedule and publish TikTok posts (video or photo slideshows), generate captions, and manage media. JSON in, JSON out.",
5
+ "author": {
6
+ "name": "Postaz",
7
+ "url": "https://postaz.app"
8
+ },
9
+ "homepage": "https://postaz.app",
10
+ "repository": "https://github.com/tonymanh-dev/postaz-agent",
11
+ "license": "MIT",
12
+ "skills": ["./"],
13
+ "keywords": [
14
+ "postaz",
15
+ "tiktok",
16
+ "social-media",
17
+ "scheduling",
18
+ "automation",
19
+ "ai-agent"
20
+ ]
21
+ }
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # Postaz CLI
2
+
3
+ > The cheapest way to grow your app on TikTok — from your terminal or an AI agent.
4
+
5
+ `postaz` is a thin, JSON-in/JSON-out command-line wrapper over the [Postaz](https://postaz.app)
6
+ public API. It lets you (or an AI agent) schedule and publish TikTok posts, upload media, and
7
+ generate captions without touching the web app.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install -g @postaz/cli
13
+ # or
14
+ pnpm install -g @postaz/cli
15
+ ```
16
+
17
+ Requires Node.js ≥ 20.
18
+
19
+ ## Authenticate
20
+
21
+ Create an API key in your Postaz workspace settings (**API Keys → Create key**), then:
22
+
23
+ ```bash
24
+ postaz auth:login --api-key postaz_sk_xxx
25
+ # or
26
+ export POSTAZ_API_KEY=postaz_sk_xxx
27
+
28
+ postaz auth:status
29
+ ```
30
+
31
+ Credentials are stored at `~/.postaz/credentials.json` (mode `600`). The `POSTAZ_API_KEY`
32
+ environment variable takes priority. Point at a self-hosted backend with `POSTAZ_API_URL`.
33
+
34
+ ## Quick start
35
+
36
+ ```bash
37
+ # Find your TikTok platform id
38
+ postaz platforms:list
39
+
40
+ # Upload media (required before posting — returns a READY object with an id)
41
+ MEDIA_ID=$(postaz upload launch.mp4 | jq -r '.id')
42
+
43
+ # Publish now
44
+ postaz posts:create -c "We just shipped 🚀 #buildinpublic" \
45
+ -m "$MEDIA_ID" -i "<platform-id>" --mode now
46
+
47
+ # Or schedule
48
+ postaz posts:create -c "Coming soon" -m "$MEDIA_ID" -i "<platform-id>" \
49
+ -s "2026-07-01T12:00:00Z"
50
+ ```
51
+
52
+ ## Commands
53
+
54
+ | Command | Description |
55
+ |---|---|
56
+ | `auth:login --api-key <key>` | Validate and store an API key |
57
+ | `auth:status` | Check the current key |
58
+ | `auth:logout` | Remove stored credentials |
59
+ | `platforms:list` | List connected accounts and their IDs |
60
+ | `platforms:schema <id>` | Posting limits + settings for a platform |
61
+ | `upload <file>` | Upload an image/video, returns a READY media object |
62
+ | `media:list` | List uploaded media |
63
+ | `media:folders:list` / `media:folders:create <name>` | Manage folders |
64
+ | `posts:create` | Create a post (`-c` content, `-i` platform ids, `-m` media ids, `--mode`, `-s`) |
65
+ | `posts:list` | List posts in a date range |
66
+ | `posts:get <id>` | Fetch one post group |
67
+ | `posts:reschedule <id> -s <iso>` | Change scheduled time |
68
+ | `posts:delete <id>` | Delete a post |
69
+ | `slides:content` | Generate AI captions for a slideshow (uses credits) |
70
+ | `slides:render` | Bake captions onto images (uses credits) |
71
+
72
+ Both `posts:create` and `posts create` syntaxes work. Run `postaz <command> --help` for details.
73
+
74
+ ## Use as an AI agent skill
75
+
76
+ This repo ships a [`SKILL.md`](./SKILL.md) and a [`.claude-plugin`](./.claude-plugin) manifest so
77
+ agents can install and drive the CLI directly. See `SKILL.md` for the full agent playbook.
78
+
79
+ ## License
80
+
81
+ MIT
package/SKILL.md ADDED
@@ -0,0 +1,207 @@
1
+ ---
2
+ name: postaz
3
+ description: Postaz is the cheapest way to grow your app on TikTok. Schedule and publish TikTok posts (one video, or a 1–35 image photo slideshow), generate captions with AI, and manage media — all from the command line or an AI agent. JSON in, JSON out.
4
+ homepage: https://postaz.app
5
+ metadata: {"openclaw":{"emoji":"📱","requires":{"bins":[],"env":["POSTAZ_API_KEY"]}}}
6
+ ---
7
+
8
+ ## Install Postaz if it doesn't exist
9
+
10
+ ```bash
11
+ npm install -g @postaz/cli
12
+ # or
13
+ pnpm install -g @postaz/cli
14
+ ```
15
+
16
+ | Property | Value |
17
+ |----------|-------|
18
+ | **name** | postaz |
19
+ | **description** | TikTok-first social scheduling CLI |
20
+ | **allowed-tools** | Bash(postaz:*) |
21
+
22
+ ---
23
+
24
+ ## ⚠️ Two Hard Rules (Read First)
25
+
26
+ **Rule 1 — Authenticate before anything.** Every command needs a valid API key. Check with `postaz auth:status`.
27
+
28
+ **Rule 2 — Every file you post MUST be uploaded first.** TikTok only accepts Postaz-verified HTTPS URLs, never raw local paths or external links. The workflow is always: `postaz upload <file>` → take the returned `id` → pass it to `posts:create -m`. The upload returns a `READY` object in one call (no separate finalize step).
29
+
30
+ ```bash
31
+ MEDIA_ID=$(postaz upload video.mp4 | jq -r '.id')
32
+ postaz posts:create -c "caption" -m "$MEDIA_ID" -i "<platform-id>" --mode now
33
+ ```
34
+
35
+ ---
36
+
37
+ ## Authentication
38
+
39
+ Get an API key from your Postaz workspace settings (API Keys → Create key). Keys look like `postaz_sk_…` and are shown once.
40
+
41
+ ```bash
42
+ # Option 1 — store it (saved to ~/.postaz/credentials.json, chmod 600)
43
+ postaz auth:login --api-key postaz_sk_xxx
44
+
45
+ # Option 2 — environment variable (takes priority over the stored file)
46
+ export POSTAZ_API_KEY=postaz_sk_xxx
47
+
48
+ # Verify
49
+ postaz auth:status
50
+
51
+ # Custom host (self-hosted backend)
52
+ export POSTAZ_API_URL=https://api.your-domain.com
53
+ ```
54
+
55
+ API access is available on **every plan, including Free**. Per-plan limits
56
+ (posts/month, storage, connected accounts, AI credits) do the gating — a blocked
57
+ action returns a `409` with a specific code (e.g. `post_limit_reached`).
58
+
59
+ ---
60
+
61
+ ## Core Workflow
62
+
63
+ 1. **Authenticate** — `postaz auth:status`
64
+ 2. **Discover** — `postaz platforms:list` to get platform IDs; `platforms:schema <id>` for limits/settings
65
+ 3. **Prepare media** — `postaz upload <file>` (required before posting)
66
+ 4. **(Optional) Generate** — `postaz slides:content` for AI captions; `slides:render` to bake captions onto images
67
+ 5. **Post** — `postaz posts:create …`
68
+ 6. **Manage** — `postaz posts:list`, `posts:get`, `posts:reschedule`, `posts:delete`
69
+
70
+ ```bash
71
+ # 1. Auth
72
+ postaz auth:status
73
+
74
+ # 2. Discover
75
+ postaz platforms:list
76
+ PLATFORM_ID=$(postaz platforms:list | jq -r '.platforms[] | select(.provider=="tiktok") | .id' | head -n1)
77
+ postaz platforms:schema "$PLATFORM_ID"
78
+
79
+ # 3. Prepare
80
+ MEDIA_ID=$(postaz upload promo.mp4 | jq -r '.id')
81
+
82
+ # 4. Post now
83
+ postaz posts:create -c "Try our app! #fyp" -m "$MEDIA_ID" -i "$PLATFORM_ID" --mode now
84
+ ```
85
+
86
+ ---
87
+
88
+ ## Commands
89
+
90
+ Both `group:sub` (e.g. `posts:create`) and `group sub` (e.g. `posts create`) syntax work. Every command prints JSON to stdout; errors print `{ "error": { "code", "message" } }` to stderr and exit non-zero.
91
+
92
+ ### Authentication
93
+ ```bash
94
+ postaz auth:login --api-key <key> [--api-url <url>] # validate + store
95
+ postaz auth:status # check current key
96
+ postaz auth:logout # remove stored key
97
+ ```
98
+
99
+ ### Platforms
100
+ ```bash
101
+ postaz platforms:list # connected accounts + their IDs
102
+ postaz platforms:schema <platformId> # content/media limits + settings fields
103
+ ```
104
+
105
+ ### Media
106
+ ```bash
107
+ postaz upload <file> [--mime-type <t>] [--media-type image|video] [--folder-id <id>]
108
+ postaz media:list [--folder-id <id|null>] [--media-type image|video]
109
+ postaz media:folders:list
110
+ postaz media:folders:create <name>
111
+ ```
112
+
113
+ ### Posts
114
+ ```bash
115
+ # Create (date REQUIRED when --mode schedule)
116
+ postaz posts:create -c "<content>" -i "<platformId[,platformId2]>" \
117
+ [-m "<mediaId[,mediaId2]>"] [--mode draft|now|schedule] \
118
+ [-s "2026-07-01T12:00:00Z"] [--settings '{"privacy_level":"PUBLIC_TO_EVERYONE"}']
119
+
120
+ postaz posts:list [--start-date <iso>] [--end-date <iso>]
121
+ postaz posts:get <postId>
122
+ postaz posts:reschedule <postId> -s "2026-07-02T09:00:00Z"
123
+ postaz posts:delete <postId>
124
+ ```
125
+
126
+ `--mode` defaults to `now`, or `schedule` when `-s` is supplied. Use `draft` to
127
+ save without queuing.
128
+
129
+ ### Slides (AI — consumes credits)
130
+ ```bash
131
+ # Generate a caption + per-slide captions
132
+ postaz slides:content -r "5 tips for launching an indie app" -n 5 \
133
+ [--language en] [--slide-length short|medium|long]
134
+
135
+ # Bake captions onto already-uploaded images
136
+ postaz slides:render --slides '[{"index":0,"mediaId":"<id>","caption":"Tip 1"}]' \
137
+ [--caption-size small|medium|large]
138
+ ```
139
+
140
+ ---
141
+
142
+ ## TikTok Posting Notes
143
+
144
+ - **Media:** exactly **one video**, or **1–35 images** for a photo slideshow. Never mix video and images in one post.
145
+ - **Settings** (pass via `--settings '{…}'`, see `platforms:schema` for the live list):
146
+ - `content_posting_method`: `DIRECT_POST` (publishes directly) or `UPLOAD` (sends to the TikTok inbox to finish manually).
147
+ - `privacy_level`: must be one the account allows — read the allowed values from `platforms:schema`.
148
+ - `title`, `description`, `duet`, `stitch`, `comment`, `brand_content_toggle`, `brand_organic_toggle`, `video_made_with_ai`.
149
+ - TikTok may not return a public post ID immediately; a freshly published post can show `releaseId` as pending until processing completes.
150
+
151
+ ---
152
+
153
+ ## Common Patterns
154
+
155
+ ### Publish a TikTok video now
156
+ ```bash
157
+ PLATFORM_ID=$(postaz platforms:list | jq -r '.platforms[] | select(.provider=="tiktok") | .id' | head -n1)
158
+ MEDIA_ID=$(postaz upload launch.mp4 | jq -r '.id')
159
+ postaz posts:create -c "We just shipped 🚀 #buildinpublic" -m "$MEDIA_ID" -i "$PLATFORM_ID" \
160
+ --mode now --settings '{"privacy_level":"PUBLIC_TO_EVERYONE"}'
161
+ ```
162
+
163
+ ### Schedule a photo slideshow
164
+ ```bash
165
+ PLATFORM_ID=$(postaz platforms:list | jq -r '.platforms[] | select(.provider=="tiktok") | .id' | head -n1)
166
+ M1=$(postaz upload slide1.jpg | jq -r '.id')
167
+ M2=$(postaz upload slide2.jpg | jq -r '.id')
168
+ M3=$(postaz upload slide3.jpg | jq -r '.id')
169
+ postaz posts:create -c "3 reasons to try our app" -m "$M1,$M2,$M3" -i "$PLATFORM_ID" \
170
+ -s "2026-07-01T15:00:00Z"
171
+ ```
172
+
173
+ ### AI captions → render → post
174
+ ```bash
175
+ # 1. Generate copy
176
+ CONTENT=$(postaz slides:content -r "Why indie devs love our app" -n 4)
177
+ # 2. Upload base images, then render captions onto them (returns composited media ids)
178
+ # Build the --slides array from your uploaded ids + the generated captions.
179
+ # 3. posts:create with the rendered media ids.
180
+ ```
181
+
182
+ ---
183
+
184
+ ## Common Gotchas
185
+
186
+ 1. **Not authenticated** — run `postaz auth:login` or set `POSTAZ_API_KEY`.
187
+ 2. **Posting a raw file path** — you must `postaz upload` first and pass the returned `id` to `-m` (Rule 2).
188
+ 3. **`post_limit_reached` (409)** — the workspace hit its monthly post cap; upgrade the plan.
189
+ 4. **`media_not_ready`** — the media ID is wrong or not owned by this workspace. Re-upload and use the new `id`.
190
+ 5. **Wrong `privacy_level`** — use a value from `platforms:schema`; accounts differ.
191
+ 6. **Scheduling without a date** — `-s` is required when `--mode schedule`.
192
+ 7. **Mixed media** — TikTok rejects video+image in one post.
193
+
194
+ ---
195
+
196
+ ## Quick Reference
197
+
198
+ ```bash
199
+ postaz auth:status # check auth
200
+ postaz platforms:list # get platform IDs
201
+ postaz upload <file> # → { id, publicUrl, status:"READY", … }
202
+ postaz posts:create -c "text" -m "<id>" -i "<pid>" --mode now
203
+ postaz posts:create -c "text" -m "<id>" -i "<pid>" -s "2026-07-01T12:00:00Z"
204
+ postaz posts:list # recent posts
205
+ postaz posts:delete <postId>
206
+ postaz slides:content -r "topic" -n 5 # AI captions
207
+ ```
package/dist/index.js ADDED
@@ -0,0 +1,569 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/api.ts
7
+ import { readFile as readFile2 } from "fs/promises";
8
+ import { basename } from "path";
9
+
10
+ // src/config.ts
11
+ import { homedir } from "os";
12
+ import { join } from "path";
13
+ import { mkdir, readFile, writeFile, rm } from "fs/promises";
14
+ var DEFAULT_API_URL = "https://api.postaz.app";
15
+ var CONFIG_DIR = join(homedir(), ".postaz");
16
+ var CREDENTIALS_PATH = join(CONFIG_DIR, "credentials.json");
17
+ async function resolveCredentials() {
18
+ const envKey = process.env.POSTAZ_API_KEY?.trim();
19
+ if (envKey) {
20
+ return {
21
+ apiKey: envKey,
22
+ apiUrl: process.env.POSTAZ_API_URL?.trim() || void 0
23
+ };
24
+ }
25
+ const stored = await readStoredCredentials();
26
+ if (stored?.apiKey) {
27
+ return {
28
+ apiKey: stored.apiKey,
29
+ apiUrl: process.env.POSTAZ_API_URL?.trim() || stored.apiUrl
30
+ };
31
+ }
32
+ return null;
33
+ }
34
+ function resolveBaseUrl(apiUrl) {
35
+ const host = (apiUrl || DEFAULT_API_URL).replace(/\/+$/, "");
36
+ return host.endsWith("/public/v1") ? host : `${host}/public/v1`;
37
+ }
38
+ async function readStoredCredentials() {
39
+ try {
40
+ const raw = await readFile(CREDENTIALS_PATH, "utf8");
41
+ const parsed = JSON.parse(raw);
42
+ if (typeof parsed.apiKey === "string" && parsed.apiKey) {
43
+ return {
44
+ apiKey: parsed.apiKey,
45
+ apiUrl: typeof parsed.apiUrl === "string" ? parsed.apiUrl : void 0
46
+ };
47
+ }
48
+ return null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+ async function writeStoredCredentials(credentials) {
54
+ await mkdir(CONFIG_DIR, { recursive: true, mode: 448 });
55
+ await writeFile(
56
+ CREDENTIALS_PATH,
57
+ `${JSON.stringify(credentials, null, 2)}
58
+ `,
59
+ { mode: 384 }
60
+ );
61
+ return CREDENTIALS_PATH;
62
+ }
63
+ async function clearStoredCredentials() {
64
+ try {
65
+ await rm(CREDENTIALS_PATH);
66
+ return true;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ // src/output.ts
73
+ function printJson(value) {
74
+ process.stdout.write(`${JSON.stringify(value, null, 2)}
75
+ `);
76
+ }
77
+ var CliError = class extends Error {
78
+ constructor(message, code = "cli_error", details) {
79
+ super(message);
80
+ this.code = code;
81
+ this.details = details;
82
+ this.name = "CliError";
83
+ }
84
+ code;
85
+ details;
86
+ };
87
+ function failAndExit(error) {
88
+ if (error instanceof CliError) {
89
+ process.stderr.write(
90
+ `${JSON.stringify(
91
+ { error: { code: error.code, message: error.message, details: error.details } },
92
+ null,
93
+ 2
94
+ )}
95
+ `
96
+ );
97
+ } else if (error instanceof Error) {
98
+ process.stderr.write(
99
+ `${JSON.stringify(
100
+ { error: { code: "unexpected_error", message: error.message } },
101
+ null,
102
+ 2
103
+ )}
104
+ `
105
+ );
106
+ } else {
107
+ process.stderr.write(
108
+ `${JSON.stringify(
109
+ { error: { code: "unexpected_error", message: String(error) } },
110
+ null,
111
+ 2
112
+ )}
113
+ `
114
+ );
115
+ }
116
+ process.exit(1);
117
+ }
118
+
119
+ // src/api.ts
120
+ var PostazApiClient = class _PostazApiClient {
121
+ constructor(baseUrl, apiKey) {
122
+ this.baseUrl = baseUrl;
123
+ this.apiKey = apiKey;
124
+ }
125
+ baseUrl;
126
+ apiKey;
127
+ /**
128
+ * Builds a client from an explicit key (used by `auth:login` before anything
129
+ * is persisted) or from resolved credentials (env → stored file).
130
+ */
131
+ static async create(options) {
132
+ if (options?.apiKey) {
133
+ return new _PostazApiClient(resolveBaseUrl(options.apiUrl), options.apiKey);
134
+ }
135
+ const credentials = await resolveCredentials();
136
+ if (!credentials) {
137
+ throw new CliError(
138
+ "No API key found. Run `postaz auth:login --api-key <key>` or set POSTAZ_API_KEY.",
139
+ "not_authenticated"
140
+ );
141
+ }
142
+ return new _PostazApiClient(
143
+ resolveBaseUrl(options?.apiUrl ?? credentials.apiUrl),
144
+ credentials.apiKey
145
+ );
146
+ }
147
+ async get(path, query) {
148
+ return this.request("GET", path, { query });
149
+ }
150
+ async postJson(path, body) {
151
+ return this.request("POST", path, { jsonBody: body });
152
+ }
153
+ async patchJson(path, body) {
154
+ return this.request("PATCH", path, { jsonBody: body });
155
+ }
156
+ async delete(path) {
157
+ await this.request("DELETE", path, {});
158
+ }
159
+ /** Uploads a local file via multipart/form-data to `POST /media`. */
160
+ async uploadFile(filePath, options) {
161
+ let bytes;
162
+ try {
163
+ bytes = await readFile2(filePath);
164
+ } catch {
165
+ throw new CliError(`File not found or unreadable: ${filePath}`, "file_unreadable");
166
+ }
167
+ const form = new FormData();
168
+ const fileName = basename(filePath);
169
+ const mimeType = options?.mimeType ?? inferMimeType(fileName);
170
+ form.set("file", new Blob([bytes], { type: mimeType }), fileName);
171
+ form.set("fileName", fileName);
172
+ form.set("mimeType", mimeType);
173
+ if (options?.mediaType) {
174
+ form.set("mediaType", options.mediaType);
175
+ }
176
+ if (options?.folderId !== void 0) {
177
+ form.set("folderId", options.folderId === null ? "null" : options.folderId);
178
+ }
179
+ return this.request("POST", "/media", { formBody: form });
180
+ }
181
+ async request(method, path, options) {
182
+ const url = new URL(`${this.baseUrl}${path}`);
183
+ if (options.query) {
184
+ for (const [key, value] of Object.entries(options.query)) {
185
+ if (value !== void 0) {
186
+ url.searchParams.set(key, value);
187
+ }
188
+ }
189
+ }
190
+ const headers = {
191
+ authorization: `Bearer ${this.apiKey}`
192
+ };
193
+ let body;
194
+ if (options.jsonBody !== void 0) {
195
+ headers["content-type"] = "application/json";
196
+ body = JSON.stringify(options.jsonBody);
197
+ } else if (options.formBody) {
198
+ body = options.formBody;
199
+ }
200
+ let response;
201
+ try {
202
+ response = await fetch(url, { method, headers, body });
203
+ } catch (error) {
204
+ throw new CliError(
205
+ `Could not reach the Postaz API at ${url.host}. ${error instanceof Error ? error.message : String(error)}`,
206
+ "network_error"
207
+ );
208
+ }
209
+ if (response.status === 204) {
210
+ return void 0;
211
+ }
212
+ const text = await response.text();
213
+ const parsed = text ? safeJsonParse(text) : void 0;
214
+ if (!response.ok) {
215
+ const errorBody = parsed ?? {};
216
+ throw new CliError(
217
+ errorBody.message ?? `Request failed with status ${response.status}.`,
218
+ errorBody.code ?? `http_${response.status}`,
219
+ errorBody.details
220
+ );
221
+ }
222
+ return parsed;
223
+ }
224
+ };
225
+ function safeJsonParse(value) {
226
+ try {
227
+ return JSON.parse(value);
228
+ } catch {
229
+ return value;
230
+ }
231
+ }
232
+ function inferMimeType(fileName) {
233
+ const ext = fileName.toLowerCase().split(".").pop();
234
+ switch (ext) {
235
+ case "jpg":
236
+ case "jpeg":
237
+ return "image/jpeg";
238
+ case "webp":
239
+ return "image/webp";
240
+ case "mp4":
241
+ return "video/mp4";
242
+ case "webm":
243
+ return "video/webm";
244
+ case "mov":
245
+ return "video/quicktime";
246
+ default:
247
+ return "application/octet-stream";
248
+ }
249
+ }
250
+
251
+ // src/commands/auth.ts
252
+ function registerAuthCommands(program2) {
253
+ const auth = program2.command("auth").description("Authentication commands");
254
+ auth.command("login").description("Store a Postaz API key after validating it").requiredOption("--api-key <key>", "A postaz_sk_\u2026 API key from your workspace settings").option("--api-url <url>", "Override the API host (defaults to https://api.postaz.app)").action(async (options) => {
255
+ try {
256
+ const client = await PostazApiClient.create({
257
+ apiKey: options.apiKey,
258
+ apiUrl: options.apiUrl
259
+ });
260
+ await client.get("/platforms");
261
+ const path = await writeStoredCredentials({
262
+ apiKey: options.apiKey,
263
+ apiUrl: options.apiUrl
264
+ });
265
+ printJson({ status: "authenticated", credentialsPath: path });
266
+ } catch (error) {
267
+ failAndExit(error);
268
+ }
269
+ });
270
+ auth.command("status").description("Check whether the stored API key is valid").action(async () => {
271
+ try {
272
+ const credentials = await resolveCredentials();
273
+ if (!credentials) {
274
+ throw new CliError(
275
+ "Not authenticated. Run `postaz auth:login --api-key <key>` or set POSTAZ_API_KEY.",
276
+ "not_authenticated"
277
+ );
278
+ }
279
+ const client = await PostazApiClient.create();
280
+ const platforms = await client.get("/platforms");
281
+ printJson({
282
+ status: "authenticated",
283
+ source: process.env.POSTAZ_API_KEY ? "env" : "file",
284
+ connectedPlatforms: platforms.platforms.length
285
+ });
286
+ } catch (error) {
287
+ failAndExit(error);
288
+ }
289
+ });
290
+ auth.command("logout").description("Remove stored credentials").action(async () => {
291
+ try {
292
+ const removed = await clearStoredCredentials();
293
+ printJson({
294
+ status: removed ? "logged_out" : "no_stored_credentials",
295
+ credentialsPath: CREDENTIALS_PATH
296
+ });
297
+ } catch (error) {
298
+ failAndExit(error);
299
+ }
300
+ });
301
+ }
302
+
303
+ // src/commands/platforms.ts
304
+ function registerPlatformCommands(program2) {
305
+ const platforms = program2.command("platforms").description("Discover connected social accounts (TikTok, etc.)");
306
+ platforms.command("list").description("List the workspace\u2019s connected platforms and their IDs").action(async () => {
307
+ try {
308
+ const client = await PostazApiClient.create();
309
+ printJson(await client.get("/platforms"));
310
+ } catch (error) {
311
+ failAndExit(error);
312
+ }
313
+ });
314
+ platforms.command("schema <platformId>").description("Get the posting schema (limits, settings fields) for a platform").action(async (platformId) => {
315
+ try {
316
+ const client = await PostazApiClient.create();
317
+ printJson(await client.get(`/platforms/${encodeURIComponent(platformId)}/schema`));
318
+ } catch (error) {
319
+ failAndExit(error);
320
+ }
321
+ });
322
+ }
323
+
324
+ // src/commands/media.ts
325
+ function registerMediaCommands(program2) {
326
+ program2.command("upload <file>").description("Upload a local image or video and get a READY media object (with id)").option("--mime-type <type>", "Override the detected MIME type").option("--media-type <type>", "image or video (inferred from MIME type by default)").option("--folder-id <id>", "Place the upload in a media folder").action(
327
+ async (file, options) => {
328
+ try {
329
+ const client = await PostazApiClient.create();
330
+ printJson(
331
+ await client.uploadFile(file, {
332
+ mimeType: options.mimeType,
333
+ mediaType: options.mediaType,
334
+ folderId: options.folderId
335
+ })
336
+ );
337
+ } catch (error) {
338
+ failAndExit(error);
339
+ }
340
+ }
341
+ );
342
+ const media = program2.command("media").description("Media library commands");
343
+ media.command("list").description("List uploaded media objects").option("--folder-id <id>", 'Filter by folder id, or "null" for the root folder').option("--media-type <type>", "Filter by image or video").action(async (options) => {
344
+ try {
345
+ const client = await PostazApiClient.create();
346
+ printJson(
347
+ await client.get("/media", {
348
+ folderId: options.folderId,
349
+ mediaType: options.mediaType
350
+ })
351
+ );
352
+ } catch (error) {
353
+ failAndExit(error);
354
+ }
355
+ });
356
+ const folders = media.command("folders").description("Media folder commands");
357
+ folders.command("list").description("List media folders").action(async () => {
358
+ try {
359
+ const client = await PostazApiClient.create();
360
+ printJson(await client.get("/media/folders"));
361
+ } catch (error) {
362
+ failAndExit(error);
363
+ }
364
+ });
365
+ folders.command("create <name>").description("Create a media folder").action(async (name) => {
366
+ try {
367
+ const client = await PostazApiClient.create();
368
+ printJson(await client.postJson("/media/folders", { name }));
369
+ } catch (error) {
370
+ failAndExit(error);
371
+ }
372
+ });
373
+ }
374
+
375
+ // src/commands/posts.ts
376
+ var POST_MODES = ["draft", "now", "schedule"];
377
+ function splitIds(value) {
378
+ if (!value) {
379
+ return [];
380
+ }
381
+ return value.split(",").map((id) => id.trim()).filter(Boolean);
382
+ }
383
+ function registerPostCommands(program2) {
384
+ const posts = program2.command("posts").description("Create and manage posts");
385
+ posts.command("create").description("Create a post for one or more platforms").requiredOption("-c, --content <text>", "Post caption / content").requiredOption(
386
+ "-i, --platform <ids>",
387
+ "Comma-separated platform IDs (from `postaz platforms:list`)"
388
+ ).option(
389
+ "-m, --media <ids>",
390
+ "Comma-separated media IDs (from `postaz upload`). Required for TikTok."
391
+ ).option("--mode <mode>", "draft | now | schedule (default: now, or schedule if -s is set)").option("-s, --scheduled-for <iso>", "ISO 8601 time to publish, e.g. 2026-07-01T12:00:00Z").option("--settings <json>", "Provider settings as a JSON string").action(
392
+ async (options) => {
393
+ try {
394
+ const platformIds = splitIds(options.platform);
395
+ if (platformIds.length === 0) {
396
+ throw new CliError("At least one platform ID is required.", "platform_required");
397
+ }
398
+ const mode = resolveMode(options.mode, options.scheduledFor);
399
+ if (mode === "schedule" && !options.scheduledFor) {
400
+ throw new CliError(
401
+ "scheduledFor (-s) is required when --mode is schedule.",
402
+ "scheduled_for_required"
403
+ );
404
+ }
405
+ const settings = parseSettings(options.settings);
406
+ const mediaIds = splitIds(options.media);
407
+ const client = await PostazApiClient.create();
408
+ printJson(
409
+ await client.postJson("/posts", {
410
+ platformIds,
411
+ content: options.content,
412
+ mode,
413
+ ...options.scheduledFor ? { scheduledFor: options.scheduledFor } : {},
414
+ ...mediaIds.length > 0 ? { mediaIds } : {},
415
+ ...settings ? { settings } : {}
416
+ })
417
+ );
418
+ } catch (error) {
419
+ failAndExit(error);
420
+ }
421
+ }
422
+ );
423
+ posts.command("list").description("List posts in a date range (defaults to the API\u2019s default window)").option("--start-date <iso>", "ISO 8601 start of range").option("--end-date <iso>", "ISO 8601 end of range").action(async (options) => {
424
+ try {
425
+ const client = await PostazApiClient.create();
426
+ printJson(
427
+ await client.get("/posts", {
428
+ startDate: options.startDate,
429
+ endDate: options.endDate
430
+ })
431
+ );
432
+ } catch (error) {
433
+ failAndExit(error);
434
+ }
435
+ });
436
+ posts.command("get <postId>").description("Get a single post group (root + comments)").action(async (postId) => {
437
+ try {
438
+ const client = await PostazApiClient.create();
439
+ printJson(await client.get(`/posts/${encodeURIComponent(postId)}`));
440
+ } catch (error) {
441
+ failAndExit(error);
442
+ }
443
+ });
444
+ posts.command("reschedule <postId>").description("Change the scheduled time of a post").requiredOption("-s, --scheduled-for <iso>", "New ISO 8601 publish time").action(async (postId, options) => {
445
+ try {
446
+ const client = await PostazApiClient.create();
447
+ printJson(
448
+ await client.patchJson(`/posts/${encodeURIComponent(postId)}/schedule`, {
449
+ scheduledFor: options.scheduledFor
450
+ })
451
+ );
452
+ } catch (error) {
453
+ failAndExit(error);
454
+ }
455
+ });
456
+ posts.command("delete <postId>").description("Delete a post (and its group)").action(async (postId) => {
457
+ try {
458
+ const client = await PostazApiClient.create();
459
+ await client.delete(`/posts/${encodeURIComponent(postId)}`);
460
+ printJson({ status: "deleted", postId });
461
+ } catch (error) {
462
+ failAndExit(error);
463
+ }
464
+ });
465
+ }
466
+ function resolveMode(rawMode, scheduledFor) {
467
+ if (rawMode) {
468
+ if (!POST_MODES.includes(rawMode)) {
469
+ throw new CliError(
470
+ `Invalid --mode "${rawMode}". Use one of: ${POST_MODES.join(", ")}.`,
471
+ "invalid_mode"
472
+ );
473
+ }
474
+ return rawMode;
475
+ }
476
+ return scheduledFor ? "schedule" : "now";
477
+ }
478
+ function parseSettings(raw) {
479
+ if (!raw) {
480
+ return void 0;
481
+ }
482
+ try {
483
+ const parsed = JSON.parse(raw);
484
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
485
+ return parsed;
486
+ }
487
+ throw new Error("not an object");
488
+ } catch {
489
+ throw new CliError("--settings must be a JSON object string.", "invalid_settings");
490
+ }
491
+ }
492
+
493
+ // src/commands/slides.ts
494
+ function registerSlidesCommands(program2) {
495
+ const slides = program2.command("slides").description("AI caption generation and slideshow rendering (metered against AI credits)");
496
+ slides.command("content").description("Generate a caption + per-slide captions for a TikTok photo slideshow").requiredOption("-r, --request <text>", "What the slideshow should be about (max 500 chars)").requiredOption("-n, --slide-count <n>", "Number of slides (3\u201310)", (v) => Number.parseInt(v, 10)).option("-l, --language <lang>", "Language for the generated text").option(
497
+ "--slide-length <length>",
498
+ "short | medium | long \u2014 content density per slide (default: medium)"
499
+ ).action(
500
+ async (options) => {
501
+ try {
502
+ if (!Number.isInteger(options.slideCount)) {
503
+ throw new CliError("--slide-count must be an integer between 3 and 10.", "invalid_slide_count");
504
+ }
505
+ const client = await PostazApiClient.create();
506
+ printJson(
507
+ await client.postJson("/slides/content", {
508
+ userRequest: options.request,
509
+ slideCount: options.slideCount,
510
+ ...options.language ? { language: options.language } : {},
511
+ ...options.slideLength ? { slideLength: options.slideLength } : {}
512
+ })
513
+ );
514
+ } catch (error) {
515
+ failAndExit(error);
516
+ }
517
+ }
518
+ );
519
+ slides.command("render").description("Bake captions onto uploaded images, producing composited slide media").requiredOption(
520
+ "--slides <json>",
521
+ "JSON array of { index, mediaId, caption } objects"
522
+ ).option("--caption-size <size>", "small | medium | large (default: medium)").action(async (options) => {
523
+ try {
524
+ const parsed = parseSlides(options.slides);
525
+ const client = await PostazApiClient.create();
526
+ printJson(
527
+ await client.postJson("/slides/render", {
528
+ slides: parsed,
529
+ ...options.captionSize ? { captionSize: options.captionSize } : {}
530
+ })
531
+ );
532
+ } catch (error) {
533
+ failAndExit(error);
534
+ }
535
+ });
536
+ }
537
+ function parseSlides(raw) {
538
+ let parsed;
539
+ try {
540
+ parsed = JSON.parse(raw);
541
+ } catch {
542
+ throw new CliError("--slides must be a valid JSON array.", "invalid_slides");
543
+ }
544
+ if (!Array.isArray(parsed) || parsed.length === 0) {
545
+ throw new CliError("--slides must be a non-empty JSON array.", "invalid_slides");
546
+ }
547
+ return parsed;
548
+ }
549
+
550
+ // src/index.ts
551
+ var program = new Command();
552
+ program.name("postaz").description("Postaz CLI \u2014 schedule and publish TikTok posts from the terminal or an AI agent.").version("0.1.0").enablePositionalOptions();
553
+ registerAuthCommands(program);
554
+ registerPlatformCommands(program);
555
+ registerMediaCommands(program);
556
+ registerPostCommands(program);
557
+ registerSlidesCommands(program);
558
+ async function main() {
559
+ await program.parseAsync(normalizeColonArgs(process.argv));
560
+ }
561
+ function normalizeColonArgs(argv) {
562
+ const [node, script, first, ...rest] = argv;
563
+ if (first && /^[a-z]+:[a-z-]+$/.test(first)) {
564
+ const [group, sub] = first.split(":");
565
+ return [node, script, group, sub, ...rest];
566
+ }
567
+ return argv;
568
+ }
569
+ main().catch(failAndExit);
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@postaz/cli",
3
+ "version": "0.1.0",
4
+ "description": "Postaz CLI — the cheapest way to grow your app on TikTok. Schedule and publish TikTok posts (video + photo slideshows), generate captions, and manage media from the command line or an AI agent.",
5
+ "keywords": [
6
+ "postaz",
7
+ "tiktok",
8
+ "social-media",
9
+ "scheduling",
10
+ "automation",
11
+ "ai-agent",
12
+ "cli"
13
+ ],
14
+ "homepage": "https://postaz.app",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/tonymanh-dev/postaz-agent.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/tonymanh-dev/postaz-agent/issues"
21
+ },
22
+ "license": "MIT",
23
+ "type": "module",
24
+ "bin": {
25
+ "postaz": "dist/index.js"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "SKILL.md",
33
+ ".claude-plugin"
34
+ ],
35
+ "engines": {
36
+ "node": ">=20.0.0"
37
+ },
38
+ "scripts": {
39
+ "build": "tsup",
40
+ "dev": "tsup --watch",
41
+ "typecheck": "tsc --noEmit",
42
+ "prepublishOnly": "pnpm run build"
43
+ },
44
+ "dependencies": {
45
+ "commander": "^12.1.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^22.10.0",
49
+ "tsup": "^8.3.5",
50
+ "typescript": "^5.7.2"
51
+ }
52
+ }