zernio-cli 0.4.1 → 0.5.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
@@ -61,6 +61,17 @@ zernio posts:create \
61
61
  --media "https://public-url-from-upload" \
62
62
  --scheduledAt "2026-06-20T09:00:00Z"
63
63
 
64
+ # Native X/Twitter quote, reply, and thread options
65
+ zernio posts:create \
66
+ --text "Thread display title" \
67
+ --accounts <twitterAccountId> \
68
+ --threadJson '["tweet 1","tweet 2"]'
69
+
70
+ zernio posts:create \
71
+ --text "my take" \
72
+ --accounts <twitterAccountId> \
73
+ --quoteTweetId "https://x.com/user/status/2061975910467698972"
74
+
64
75
  # Queue scheduling: let Zernio assign the slot
65
76
  zernio api:call createPost \
66
77
  --body-json '{"content":"Queued post","platforms":[{"platform":"twitter","accountId":"acc_123"}],"queuedFromProfile":"profile_123"}'
@@ -118,7 +129,8 @@ For media uploads, prefer `zernio media:upload`; it implements the official pres
118
129
  - Respect `Retry-After` and `X-RateLimit-*` headers.
119
130
  - Use `--request-id` or `--idempotency-key` for mutating calls that document safe retry headers.
120
131
  - Use pagination, caching, webhooks, and bulk endpoints for automation.
121
- - Use `platformSpecificData` for per-platform post settings.
132
+ - Use `platformSpecificData` for per-platform post settings. `posts:create` wraps common X/Twitter fields with `--quoteTweetId`, `--replyToTweetId`, `--replySettings`, `--threadJson`, `--threadFile`, and `--platformSpecificData`.
133
+ - For failed `posts:create` calls, use `--debug-safe` to print non-secret account/platform diagnostics.
122
134
  - Check current platform support at https://docs.zernio.com/platforms instead of relying on a fixed count.
123
135
 
124
136
  ## Development
@@ -52,6 +52,8 @@ zernio accounts:list --pretty
52
52
  zernio accounts:health --pretty
53
53
  zernio media:upload ./photo.jpg --pretty
54
54
  zernio posts:create --text "Hello" --accounts <accountId>
55
+ zernio posts:create --text "Thread title" --accounts <twitterAccountId> --threadFile ./thread.txt
56
+ zernio posts:create --text "my take" --accounts <twitterAccountId> --quoteTweetId <tweetId-or-url>
55
57
  zernio inbox:conversations --platform instagram --pretty
56
58
  zernio contacts:list --search "john" --pretty
57
59
  zernio broadcasts:list --status draft --pretty
@@ -76,6 +78,8 @@ zernio api:call createPost --body-file ./post.json --request-id req_123 --pretty
76
78
  - Use `--request-id` or `--idempotency-key` for mutating endpoints that document safe retry headers.
77
79
  - For queue scheduling, let Zernio assign the slot with `queuedFromProfile` and optional `queueId`.
78
80
  - For media, use `media:upload`; it performs presign + direct PUT.
81
+ - For native X/Twitter posts, use `posts:create --threadJson`, `--threadFile`, `--quoteTweetId`, `--replyToTweetId`, `--replySettings`, or `--platformSpecificData` before dropping down to raw `api:call`.
82
+ - For confusing `posts:create` failures, retry with `--debug-safe --pretty` and report only the non-secret diagnostics.
79
83
  - For current platform details, consult https://docs.zernio.com/platforms instead of relying on a fixed count.
80
84
 
81
85
  ## References
@@ -1,6 +1,6 @@
1
1
  # Zernio Command Surface
2
2
 
3
- Current target: `zernio-cli` 0.4.0. JSON is default; add `--pretty` for humans.
3
+ Current target: `zernio-cli` 0.4.1+. JSON is default; add `--pretty` for humans.
4
4
 
5
5
  ## Agent/system commands
6
6
 
@@ -35,7 +35,8 @@ Use profile/account IDs from these commands before posting, inbox, broadcasts, s
35
35
 
36
36
  | Use case | Commands |
37
37
  | --- | --- |
38
- | Create/publish/schedule/draft | `posts:create --text <text> --accounts <ids> [--scheduledAt --draft --media --title --tags --hashtags --timezone]` |
38
+ | Create/publish/schedule/draft | `posts:create --text <text> --accounts <ids> [--scheduledAt --draft --media --title --tags --hashtags --timezone --debug-safe]` |
39
+ | Native X/Twitter post options | `posts:create --accounts <twitterId> --text <display> [--quoteTweetId --replyToTweetId --replySettings --threadJson --threadFile --platformSpecificData]` |
39
40
  | Post lifecycle | `posts:list [--profileId --status --platform --from --to --page --limit]`, `posts:get <id>`, `posts:delete <id>`, `posts:retry <id>` |
40
41
  | Analytics | `analytics:posts [--profileId --platform --postId --source --from --to --sortBy --order --page --limit]`, `analytics:daily [--profileId --platform --from --to]`, `analytics:best-time [--profileId --platform]` |
41
42
  | Media upload | `media:upload <file>` |
@@ -28,6 +28,7 @@
28
28
  ## Error Handling
29
29
 
30
30
  - Treat `401/403` as auth/scope issues; run `doctor --connection` and verify profile/account access.
31
+ - For `posts:create` failures, rerun with `--debug-safe --pretty` to include non-secret platform/account context and recovery hints.
31
32
  - Treat `404` as wrong ID or missing scope; rediscover IDs with list/get commands.
32
33
  - Treat `409` as state conflict; refresh resource state before retrying.
33
34
  - Treat `422` as payload/schema issue; run `api:describe` and inspect request body.
@@ -64,6 +65,9 @@
64
65
 
65
66
  - Use `platforms:list` for identifiers; use `https://docs.zernio.com/platforms` as current capability source.
66
67
  - Use `platformSpecificData` in OpenAPI payloads for per-platform fields.
68
+ - For X/Twitter, curated `posts:create` supports `--quoteTweetId`, `--replyToTweetId`, `--replySettings`, `--threadJson`, `--threadFile`, and `--platformSpecificData`.
69
+ - For X/Twitter threads, top-level `--text` is only for Zernio display/search; include the root tweet in `threadItems[0]`.
70
+ - Do not mix X-specific `posts:create` options with non-X accounts. Do not combine `quoteTweetId` with top-level `--media`, or `replyToTweetId` with `replySettings`.
67
71
  - Require title where platform needs it, especially YouTube, Reddit, Pinterest-style content.
68
72
  - Check media and character constraints before posting multi-platform content.
69
73
  - Do not assume platform support count is static; current CLI includes twitter, instagram, facebook, linkedin, tiktok, youtube, pinterest, reddit, bluesky, threads, googlebusiness, telegram, snapchat, whatsapp, discord.
@@ -63,6 +63,42 @@ Draft:
63
63
  zernio posts:create --text "Draft copy" --accounts <accountId> --draft
64
64
  ```
65
65
 
66
+ Native X/Twitter thread:
67
+
68
+ ```bash
69
+ zernio posts:create \
70
+ --text "Thread display title" \
71
+ --accounts <twitterAccountId> \
72
+ --threadJson '["tweet 1","tweet 2","tweet 3"]'
73
+ ```
74
+
75
+ Thread from file:
76
+
77
+ ```bash
78
+ zernio posts:create \
79
+ --text "Thread display title" \
80
+ --accounts <twitterAccountId> \
81
+ --threadFile ./thread.txt
82
+ ```
83
+
84
+ `thread.txt` can be a JSON array or plain text separated by lines containing only `---`. When thread items are present, `--text` is only for Zernio display/search; put the first published tweet in `threadItems[0]`.
85
+
86
+ Native X/Twitter quote or reply:
87
+
88
+ ```bash
89
+ zernio posts:create \
90
+ --text "my take" \
91
+ --accounts <twitterAccountId> \
92
+ --quoteTweetId "https://x.com/user/status/2061975910467698972"
93
+
94
+ zernio posts:create \
95
+ --text "reply text" \
96
+ --accounts <twitterAccountId> \
97
+ --replyToTweetId "2061975910467698972"
98
+ ```
99
+
100
+ Use `--platformSpecificData '{"replySettings":"following"}'` for advanced X passthrough. X-specific options require only `twitter`/`x` targets.
101
+
66
102
  Track/retry:
67
103
 
68
104
  ```bash
@@ -71,6 +107,12 @@ zernio posts:get <postId> --pretty
71
107
  zernio posts:retry <postId> --pretty
72
108
  ```
73
109
 
110
+ Debug failed post creation without exposing secrets:
111
+
112
+ ```bash
113
+ zernio posts:create --text "Draft copy" --accounts <accountId> --draft --debug-safe --pretty
114
+ ```
115
+
74
116
  ## Upload Media Then Post
75
117
 
76
118
  ```bash
@@ -0,0 +1,2 @@
1
+ import type { Argv } from 'yargs';
2
+ export declare function registerPostCreateCommand(yargs: Argv): Argv;
@@ -0,0 +1,110 @@
1
+ import { createClient } from '../client.js';
2
+ import { addAccountHealthDiagnostics, handlePostCreateError } from '../utils/posts-create-diagnostics.js';
3
+ import { applyTwitterPlatformSpecificData, buildMediaItems, buildTwitterPlatformSpecificData, PostsCreateValidationError, validateTwitterPlatformSpecificData, } from '../utils/posts-create-platform-data.js';
4
+ import { output, outputError } from '../utils/output.js';
5
+ export function registerPostCreateCommand(yargs) {
6
+ return yargs.command('posts:create', 'Create or schedule a post', (y) => y
7
+ .option('text', { type: 'string', describe: 'Post content text', demandOption: true })
8
+ .option('accounts', { type: 'string', describe: 'Comma-separated account IDs', demandOption: true })
9
+ .option('scheduledAt', { type: 'string', describe: 'ISO 8601 date to schedule (omit to publish now)' })
10
+ .option('draft', { type: 'boolean', describe: 'Save as draft', default: false })
11
+ .option('media', { type: 'string', describe: 'Comma-separated media URLs' })
12
+ .option('title', { type: 'string', describe: 'Post title (YouTube, Reddit, etc.)' })
13
+ .option('tags', { type: 'string', describe: 'Comma-separated tags' })
14
+ .option('hashtags', { type: 'string', describe: 'Comma-separated hashtags' })
15
+ .option('timezone', { type: 'string', describe: 'Timezone (e.g. America/New_York)' })
16
+ .option('quoteTweetId', { type: 'string', describe: 'X/Twitter tweet ID or status URL to quote' })
17
+ .option('replyToTweetId', { type: 'string', describe: 'X/Twitter tweet ID to reply to' })
18
+ .option('replySettings', {
19
+ type: 'string',
20
+ describe: 'X/Twitter reply settings: following, mentionedUsers, subscribers, verified',
21
+ })
22
+ .option('threadJson', { type: 'string', describe: 'X/Twitter threadItems as a JSON array' })
23
+ .option('threadFile', { type: 'string', describe: 'X/Twitter thread file, JSON array or --- separated text' })
24
+ .option('platformSpecificData', {
25
+ type: 'string',
26
+ describe: 'Advanced platformSpecificData JSON object for X/Twitter targets',
27
+ })
28
+ .option('debug-safe', {
29
+ type: 'boolean',
30
+ describe: 'Include non-secret post/account diagnostics when create fails',
31
+ default: false,
32
+ }), async (argv) => {
33
+ let platforms = [];
34
+ let selectedAccounts = [];
35
+ try {
36
+ const late = createClient();
37
+ const { data: accountsData } = await late.accounts.listAccounts();
38
+ const accountIds = argv.accounts.split(',').map((s) => s.trim()).filter(Boolean);
39
+ const allAccounts = accountsData?.accounts || [];
40
+ selectedAccounts = accountIds.map((id) => {
41
+ const account = allAccounts.find((a) => (a._id || a.id) === id);
42
+ if (!account) {
43
+ throw new PostsCreateValidationError(`Account ${id} not found. Run "zernio accounts:list" to see available accounts.`, 'ACCOUNT_NOT_FOUND', 404);
44
+ }
45
+ return account;
46
+ });
47
+ platforms = selectedAccounts.map((account, index) => ({
48
+ platform: String(account.platform),
49
+ accountId: accountIds[index],
50
+ }));
51
+ const mediaItems = buildMediaItems(argv.media);
52
+ const twitterData = buildTwitterPlatformSpecificData({
53
+ quoteTweetId: argv.quoteTweetId,
54
+ replyToTweetId: argv.replyToTweetId,
55
+ replySettings: argv.replySettings,
56
+ threadJson: argv.threadJson,
57
+ threadFile: argv.threadFile,
58
+ platformSpecificData: argv.platformSpecificData,
59
+ });
60
+ validateTwitterPlatformSpecificData(twitterData, platforms, mediaItems);
61
+ platforms = applyTwitterPlatformSpecificData(platforms, twitterData);
62
+ if (argv.debugSafe) {
63
+ selectedAccounts = await addAccountHealthDiagnostics(late, accountIds, selectedAccounts);
64
+ }
65
+ const body = buildCreatePostBody(argv, platforms, mediaItems);
66
+ const { data } = await late.posts.createPost({ body });
67
+ output(data, argv.pretty);
68
+ }
69
+ catch (err) {
70
+ if (err instanceof PostsCreateValidationError) {
71
+ outputError(err.message, err.status, { code: err.code }, argv.pretty);
72
+ }
73
+ handlePostCreateError(err, {
74
+ debugSafe: argv.debugSafe,
75
+ pretty: argv.pretty,
76
+ platforms,
77
+ accounts: selectedAccounts,
78
+ });
79
+ }
80
+ });
81
+ }
82
+ function buildCreatePostBody(argv, platforms, mediaItems) {
83
+ const body = {
84
+ content: argv.text,
85
+ platforms,
86
+ };
87
+ if (mediaItems?.length)
88
+ body.mediaItems = mediaItems;
89
+ if (argv.title)
90
+ body.title = argv.title;
91
+ if (argv.timezone)
92
+ body.timezone = argv.timezone;
93
+ if (argv.tags)
94
+ body.tags = csv(argv.tags);
95
+ if (argv.hashtags)
96
+ body.hashtags = csv(argv.hashtags);
97
+ if (argv.draft) {
98
+ body.isDraft = true;
99
+ }
100
+ else if (argv.scheduledAt) {
101
+ body.scheduledFor = argv.scheduledAt;
102
+ }
103
+ else {
104
+ body.publishNow = true;
105
+ }
106
+ return body;
107
+ }
108
+ function csv(value) {
109
+ return value.split(',').map((s) => s.trim()).filter(Boolean);
110
+ }
@@ -1,70 +1,10 @@
1
1
  import { createClient } from '../client.js';
2
- import { output, outputError } from '../utils/output.js';
2
+ import { output } from '../utils/output.js';
3
3
  import { handleError } from '../utils/errors.js';
4
+ import { registerPostCreateCommand } from './posts-create.js';
4
5
  /** Register post commands: posts:create, posts:list, posts:get, posts:delete, posts:retry */
5
6
  export function registerPostCommands(yargs) {
6
- return yargs
7
- .command('posts:create', 'Create or schedule a post', (y) => y
8
- .option('text', { type: 'string', describe: 'Post content text', demandOption: true })
9
- .option('accounts', { type: 'string', describe: 'Comma-separated account IDs', demandOption: true })
10
- .option('scheduledAt', { type: 'string', describe: 'ISO 8601 date to schedule (omit to publish now)' })
11
- .option('draft', { type: 'boolean', describe: 'Save as draft', default: false })
12
- .option('media', { type: 'string', describe: 'Comma-separated media URLs' })
13
- .option('title', { type: 'string', describe: 'Post title (YouTube, Reddit, etc.)' })
14
- .option('tags', { type: 'string', describe: 'Comma-separated tags' })
15
- .option('hashtags', { type: 'string', describe: 'Comma-separated hashtags' })
16
- .option('timezone', { type: 'string', describe: 'Timezone (e.g. America/New_York)' }), async (argv) => {
17
- try {
18
- const late = createClient();
19
- // Look up accounts to resolve platform types
20
- const { data: accountsData } = await late.accounts.listAccounts();
21
- const allAccounts = accountsData?.accounts || [];
22
- const accountIds = argv.accounts.split(',').map((s) => s.trim()).filter(Boolean);
23
- const platforms = accountIds.map((id) => {
24
- const account = allAccounts.find((a) => (a._id || a.id) === id);
25
- if (!account) {
26
- outputError(`Account ${id} not found. Run "late accounts:list" to see available accounts.`, 404);
27
- }
28
- return { platform: account.platform, accountId: id };
29
- });
30
- // Build media items
31
- const mediaItems = argv.media
32
- ? argv.media.split(',').map((url) => {
33
- const trimmed = url.trim();
34
- const isVideo = /\.(mp4|mov|avi|webm|m4v)$/i.test(trimmed);
35
- return { type: (isVideo ? 'video' : 'image'), url: trimmed };
36
- })
37
- : undefined;
38
- const body = {
39
- content: argv.text,
40
- platforms,
41
- };
42
- if (mediaItems?.length)
43
- body.mediaItems = mediaItems;
44
- if (argv.title)
45
- body.title = argv.title;
46
- if (argv.timezone)
47
- body.timezone = argv.timezone;
48
- if (argv.tags)
49
- body.tags = argv.tags.split(',').map((s) => s.trim());
50
- if (argv.hashtags)
51
- body.hashtags = argv.hashtags.split(',').map((s) => s.trim());
52
- if (argv.draft) {
53
- body.isDraft = true;
54
- }
55
- else if (argv.scheduledAt) {
56
- body.scheduledFor = argv.scheduledAt;
57
- }
58
- else {
59
- body.publishNow = true;
60
- }
61
- const { data } = await late.posts.createPost({ body });
62
- output(data, argv.pretty);
63
- }
64
- catch (err) {
65
- handleError(err);
66
- }
67
- })
7
+ return registerPostCreateCommand(yargs)
68
8
  .command('posts:list', 'List posts', (y) => y
69
9
  .option('profileId', { type: 'string', describe: 'Filter by profile ID' })
70
10
  .option('status', { type: 'string', describe: 'Filter by status (scheduled, published, failed, draft)' })
@@ -0,0 +1,22 @@
1
+ import type { PlatformTarget } from './posts-create-platform-data.js';
2
+ type AccountRecord = Record<string, unknown>;
3
+ type PostCreateErrorContext = {
4
+ debugSafe: boolean;
5
+ pretty: boolean;
6
+ platforms: PlatformTarget[];
7
+ accounts: AccountRecord[];
8
+ };
9
+ export declare function handlePostCreateError(err: unknown, context: PostCreateErrorContext): never;
10
+ export declare function buildPostCreateDiagnostic(context: PostCreateErrorContext, status?: number): Record<string, unknown>;
11
+ export declare function addAccountHealthDiagnostics(late: {
12
+ accounts?: {
13
+ getAccountHealth?: (args: {
14
+ path: {
15
+ accountId: string;
16
+ };
17
+ }) => Promise<{
18
+ data: unknown;
19
+ }>;
20
+ };
21
+ }, accountIds: string[], accounts: AccountRecord[]): Promise<AccountRecord[]>;
22
+ export {};
@@ -0,0 +1,83 @@
1
+ import { LateApiError } from '@zernio/node';
2
+ import { handleError } from './errors.js';
3
+ import { outputError } from './output.js';
4
+ const SAFE_ACCOUNT_FIELDS = [
5
+ 'profileId',
6
+ 'profileName',
7
+ 'platform',
8
+ 'username',
9
+ 'displayName',
10
+ 'status',
11
+ 'canPost',
12
+ 'tokenValid',
13
+ 'needsReconnect',
14
+ 'issues',
15
+ ];
16
+ export function handlePostCreateError(err, context) {
17
+ if (!context.debugSafe)
18
+ handleError(err);
19
+ const status = err instanceof LateApiError ? err.statusCode : undefined;
20
+ const message = err instanceof Error ? err.message : String(err);
21
+ outputError(message, status, {
22
+ code: status === 401 ? 'POST_CREATE_UNAUTHORIZED' : 'POST_CREATE_FAILED',
23
+ diagnostic: buildPostCreateDiagnostic(context, status),
24
+ }, context.pretty);
25
+ }
26
+ export function buildPostCreateDiagnostic(context, status) {
27
+ return {
28
+ command: 'posts:create',
29
+ resolvedPlatforms: context.platforms,
30
+ targetAccounts: context.platforms.map((target) => summarizeAccount(target, context.accounts)),
31
+ suggestions: suggestionsForStatus(status),
32
+ };
33
+ }
34
+ export async function addAccountHealthDiagnostics(late, accountIds, accounts) {
35
+ if (!late.accounts?.getAccountHealth)
36
+ return accounts;
37
+ const healthEntries = await Promise.all(accountIds.map(async (id) => {
38
+ try {
39
+ const { data } = await late.accounts.getAccountHealth({ path: { accountId: id } });
40
+ return [id, data];
41
+ }
42
+ catch {
43
+ return [id, undefined];
44
+ }
45
+ }));
46
+ const healthById = new Map(healthEntries.filter((entry) => isRecord(entry[1])));
47
+ return accounts.map((account) => {
48
+ const id = accountId(account);
49
+ return id && healthById.has(id) ? { ...account, ...healthById.get(id) } : account;
50
+ });
51
+ }
52
+ function summarizeAccount(target, accounts) {
53
+ const account = accounts.find((candidate) => accountId(candidate) === target.accountId) || {};
54
+ const summary = {
55
+ accountId: target.accountId,
56
+ platform: target.platform,
57
+ };
58
+ for (const field of SAFE_ACCOUNT_FIELDS) {
59
+ if (account[field] !== undefined && field !== 'platform')
60
+ summary[field] = account[field];
61
+ }
62
+ return summary;
63
+ }
64
+ function accountId(account) {
65
+ const id = account.id || account._id || account.accountId;
66
+ return id === undefined ? undefined : String(id);
67
+ }
68
+ function isRecord(value) {
69
+ return typeof value === 'object' && value !== null;
70
+ }
71
+ function suggestionsForStatus(status) {
72
+ if (status === 401) {
73
+ return [
74
+ 'Run zernio auth:check --pretty to verify the API key.',
75
+ 'Run zernio accounts:health --pretty and compare the target account status.',
76
+ 'If health is valid but create still returns 401, reconnect the account or report a backend authorization mismatch.',
77
+ ];
78
+ }
79
+ return [
80
+ 'Run zernio doctor --connection --pretty to verify API connectivity.',
81
+ 'Run the same payload with zernio api:call createPost --dry-run to inspect request shape.',
82
+ ];
83
+ }
@@ -0,0 +1,27 @@
1
+ export { PostsCreateValidationError } from './posts-create-validation-error.js';
2
+ export type MediaItem = {
3
+ type: 'image' | 'video';
4
+ url: string;
5
+ [key: string]: unknown;
6
+ };
7
+ export type PlatformTarget = {
8
+ platform: string;
9
+ accountId: string;
10
+ platformSpecificData?: Record<string, unknown>;
11
+ };
12
+ export type TwitterPlatformSpecificDataResult = {
13
+ hasData: boolean;
14
+ data?: Record<string, unknown>;
15
+ };
16
+ type TwitterPlatformOptions = {
17
+ quoteTweetId?: unknown;
18
+ replyToTweetId?: unknown;
19
+ replySettings?: unknown;
20
+ threadJson?: unknown;
21
+ threadFile?: unknown;
22
+ platformSpecificData?: unknown;
23
+ };
24
+ export declare function buildMediaItems(media?: unknown): MediaItem[] | undefined;
25
+ export declare function buildTwitterPlatformSpecificData(options: TwitterPlatformOptions): TwitterPlatformSpecificDataResult;
26
+ export declare function validateTwitterPlatformSpecificData(result: TwitterPlatformSpecificDataResult, platforms: PlatformTarget[], mediaItems?: MediaItem[]): void;
27
+ export declare function applyTwitterPlatformSpecificData(platforms: PlatformTarget[], result: TwitterPlatformSpecificDataResult): PlatformTarget[];
@@ -0,0 +1,103 @@
1
+ import { parseThreadItems } from './posts-create-thread-items.js';
2
+ import { PostsCreateValidationError } from './posts-create-validation-error.js';
3
+ export { PostsCreateValidationError } from './posts-create-validation-error.js';
4
+ const REPLY_SETTINGS = new Set(['following', 'mentionedUsers', 'subscribers', 'verified']);
5
+ export function buildMediaItems(media) {
6
+ const urls = commaList(media);
7
+ if (!urls.length)
8
+ return undefined;
9
+ return urls.map((url) => ({
10
+ type: /\.(mp4|mov|avi|webm|m4v)$/i.test(url) ? 'video' : 'image',
11
+ url,
12
+ }));
13
+ }
14
+ export function buildTwitterPlatformSpecificData(options) {
15
+ const data = parsePlatformSpecificData(options.platformSpecificData);
16
+ const quoteTweetId = stringOption(options.quoteTweetId);
17
+ const replyToTweetId = stringOption(options.replyToTweetId);
18
+ const replySettings = stringOption(options.replySettings);
19
+ if (quoteTweetId)
20
+ data.quoteTweetId = quoteTweetId;
21
+ if (replyToTweetId)
22
+ data.replyToTweetId = replyToTweetId;
23
+ if (replySettings) {
24
+ if (!REPLY_SETTINGS.has(replySettings)) {
25
+ throw new PostsCreateValidationError('replySettings must be one of: following, mentionedUsers, subscribers, verified.', 'INVALID_REPLY_SETTINGS');
26
+ }
27
+ data.replySettings = replySettings;
28
+ }
29
+ const threadItems = parseThreadItems(options.threadJson, options.threadFile);
30
+ if (threadItems)
31
+ data.threadItems = threadItems;
32
+ return Object.keys(data).length ? { hasData: true, data } : { hasData: false };
33
+ }
34
+ export function validateTwitterPlatformSpecificData(result, platforms, mediaItems = []) {
35
+ if (!result.hasData || !result.data)
36
+ return;
37
+ const invalidTargets = platforms.filter((target) => !isTwitterPlatform(target.platform));
38
+ if (invalidTargets.length) {
39
+ throw new PostsCreateValidationError('X/Twitter-specific options require only twitter/x accounts.', 'TWITTER_PLATFORM_DATA_REQUIRES_TWITTER');
40
+ }
41
+ if (result.data.quoteTweetId && mediaItems.length) {
42
+ throw new PostsCreateValidationError('quoteTweetId cannot be combined with --media.', 'TWITTER_QUOTE_REJECTS_MEDIA');
43
+ }
44
+ if (result.data.quoteTweetId && result.data.poll) {
45
+ throw new PostsCreateValidationError('quoteTweetId cannot be combined with poll.', 'TWITTER_QUOTE_REJECTS_POLL');
46
+ }
47
+ if (result.data.replyToTweetId && result.data.replySettings) {
48
+ throw new PostsCreateValidationError('replyToTweetId cannot be combined with replySettings.', 'TWITTER_REPLY_REJECTS_REPLY_SETTINGS');
49
+ }
50
+ if (result.data.poll && mediaItems.length) {
51
+ throw new PostsCreateValidationError('poll cannot be combined with --media.', 'TWITTER_POLL_REJECTS_MEDIA');
52
+ }
53
+ if (result.data.threadItems && result.data.poll) {
54
+ throw new PostsCreateValidationError('threadItems cannot be combined with poll.', 'TWITTER_THREAD_REJECTS_POLL');
55
+ }
56
+ }
57
+ export function applyTwitterPlatformSpecificData(platforms, result) {
58
+ if (!result.hasData || !result.data)
59
+ return platforms;
60
+ return platforms.map((target) => ({
61
+ ...target,
62
+ platformSpecificData: {
63
+ ...(target.platformSpecificData || {}),
64
+ ...result.data,
65
+ },
66
+ }));
67
+ }
68
+ function parsePlatformSpecificData(input) {
69
+ const raw = stringOption(input);
70
+ if (!raw)
71
+ return {};
72
+ const parsed = parseJson(raw, '--platformSpecificData');
73
+ if (!isRecord(parsed) || Array.isArray(parsed)) {
74
+ throw new PostsCreateValidationError('--platformSpecificData must be a JSON object.', 'INVALID_PLATFORM_SPECIFIC_DATA');
75
+ }
76
+ return { ...parsed };
77
+ }
78
+ function parseJson(raw, optionName) {
79
+ try {
80
+ return JSON.parse(raw);
81
+ }
82
+ catch (error) {
83
+ throw new PostsCreateValidationError(`${optionName} must contain valid JSON: ${error.message}`, 'INVALID_JSON');
84
+ }
85
+ }
86
+ function stringOption(value) {
87
+ if (value === undefined || value === null)
88
+ return undefined;
89
+ const text = String(value).trim();
90
+ return text || undefined;
91
+ }
92
+ function commaList(value) {
93
+ const raw = stringOption(value);
94
+ if (!raw)
95
+ return [];
96
+ return raw.split(',').map((item) => item.trim()).filter(Boolean);
97
+ }
98
+ function isRecord(value) {
99
+ return typeof value === 'object' && value !== null;
100
+ }
101
+ function isTwitterPlatform(platform) {
102
+ return platform === 'twitter' || platform === 'x';
103
+ }
@@ -0,0 +1 @@
1
+ export declare function parseThreadItems(threadJson?: unknown, threadFile?: unknown): Record<string, unknown>[] | undefined;
@@ -0,0 +1,83 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { PostsCreateValidationError } from './posts-create-validation-error.js';
3
+ export function parseThreadItems(threadJson, threadFile) {
4
+ const inline = stringOption(threadJson);
5
+ const file = stringOption(threadFile);
6
+ if (inline && file) {
7
+ throw new PostsCreateValidationError('Use either --threadJson or --threadFile, not both.', 'AMBIGUOUS_THREAD_INPUT');
8
+ }
9
+ if (inline)
10
+ return normalizeThreadItems(parseJson(inline, '--threadJson'));
11
+ if (!file)
12
+ return undefined;
13
+ const raw = readFileSync(file, 'utf8');
14
+ const trimmed = raw.trim();
15
+ if (trimmed.startsWith('['))
16
+ return normalizeThreadItems(parseJson(trimmed, '--threadFile'));
17
+ return normalizeThreadItems(splitThreadFile(raw));
18
+ }
19
+ function normalizeThreadItems(input) {
20
+ if (!Array.isArray(input)) {
21
+ throw new PostsCreateValidationError('threadItems must be a JSON array.', 'INVALID_THREAD_ITEMS');
22
+ }
23
+ if (!input.length)
24
+ throw new PostsCreateValidationError('threadItems cannot be empty.', 'INVALID_THREAD_ITEMS');
25
+ return input.map((item, index) => {
26
+ if (typeof item === 'string') {
27
+ if (!item.trim()) {
28
+ throw new PostsCreateValidationError(`threadItems[${index}] content cannot be empty.`, 'INVALID_THREAD_ITEMS');
29
+ }
30
+ return { content: item };
31
+ }
32
+ if (!isRecord(item) || Array.isArray(item)) {
33
+ throw new PostsCreateValidationError(`threadItems[${index}] must be a string or object.`, 'INVALID_THREAD_ITEMS');
34
+ }
35
+ if (item.content !== undefined && typeof item.content !== 'string') {
36
+ throw new PostsCreateValidationError(`threadItems[${index}].content must be a string.`, 'INVALID_THREAD_ITEMS');
37
+ }
38
+ if (item.mediaItems !== undefined && !Array.isArray(item.mediaItems)) {
39
+ throw new PostsCreateValidationError(`threadItems[${index}].mediaItems must be an array.`, 'INVALID_THREAD_ITEMS');
40
+ }
41
+ if (!item.content && !item.mediaItems) {
42
+ throw new PostsCreateValidationError(`threadItems[${index}] must include content or mediaItems.`, 'INVALID_THREAD_ITEMS');
43
+ }
44
+ return { ...item };
45
+ });
46
+ }
47
+ function splitThreadFile(raw) {
48
+ const items = [];
49
+ let current = [];
50
+ for (const line of raw.split(/\r?\n/)) {
51
+ if (line.trim() === '---') {
52
+ pushThreadFileItem(items, current);
53
+ current = [];
54
+ }
55
+ else {
56
+ current.push(line);
57
+ }
58
+ }
59
+ pushThreadFileItem(items, current);
60
+ return items;
61
+ }
62
+ function pushThreadFileItem(items, lines) {
63
+ const content = lines.join('\n').trim();
64
+ if (content)
65
+ items.push(content);
66
+ }
67
+ function parseJson(raw, optionName) {
68
+ try {
69
+ return JSON.parse(raw);
70
+ }
71
+ catch (error) {
72
+ throw new PostsCreateValidationError(`${optionName} must contain valid JSON: ${error.message}`, 'INVALID_JSON');
73
+ }
74
+ }
75
+ function stringOption(value) {
76
+ if (value === undefined || value === null)
77
+ return undefined;
78
+ const text = String(value).trim();
79
+ return text || undefined;
80
+ }
81
+ function isRecord(value) {
82
+ return typeof value === 'object' && value !== null;
83
+ }
@@ -0,0 +1,5 @@
1
+ export declare class PostsCreateValidationError extends Error {
2
+ readonly code: string;
3
+ readonly status: number;
4
+ constructor(message: string, code: string, status?: number);
5
+ }
@@ -0,0 +1,10 @@
1
+ export class PostsCreateValidationError extends Error {
2
+ code;
3
+ status;
4
+ constructor(message, code, status = 400) {
5
+ super(message);
6
+ this.code = code;
7
+ this.status = status;
8
+ this.name = 'PostsCreateValidationError';
9
+ }
10
+ }
package/docs/cli.md CHANGED
@@ -88,6 +88,63 @@ zernio media:upload ./photo.jpg
88
88
 
89
89
  The command implements Zernio's two-step upload: presign, direct PUT, then return public URL. Use that URL in `posts:create` or `api:call createPost`.
90
90
 
91
+ ## Posts
92
+
93
+ Generic post creation:
94
+
95
+ ```bash
96
+ zernio posts:create --text "Hello" --accounts <accountId>
97
+ zernio posts:create --text "Draft" --accounts <accountId> --draft
98
+ zernio posts:create --text "Launch" --accounts <accountId> --scheduledAt "2026-06-20T09:00:00Z"
99
+ ```
100
+
101
+ Native X/Twitter fields are available on `posts:create`:
102
+
103
+ ```bash
104
+ zernio posts:create \
105
+ --text "Thread display title" \
106
+ --accounts <twitterAccountId> \
107
+ --threadJson '["tweet 1","tweet 2"]'
108
+
109
+ zernio posts:create \
110
+ --text "Thread display title" \
111
+ --accounts <twitterAccountId> \
112
+ --threadFile ./thread.txt
113
+
114
+ zernio posts:create \
115
+ --text "my take" \
116
+ --accounts <twitterAccountId> \
117
+ --quoteTweetId "https://x.com/user/status/2061975910467698972"
118
+
119
+ zernio posts:create \
120
+ --text "reply text" \
121
+ --accounts <twitterAccountId> \
122
+ --replyToTweetId "2061975910467698972"
123
+ ```
124
+
125
+ `--threadJson` accepts a JSON array of strings or objects. Object items can include `mediaItems`, so per-tweet media stays inside the relevant thread item. `--threadFile` accepts either a JSON array or plain text separated by lines containing only `---`.
126
+
127
+ When `threadItems` are present, top-level `--text` is only for Zernio display/search. It is not published as the root tweet; include the root tweet as `threadItems[0]`.
128
+
129
+ Advanced X/Twitter passthrough:
130
+
131
+ ```bash
132
+ zernio posts:create \
133
+ --text "custom x data" \
134
+ --accounts <twitterAccountId> \
135
+ --platformSpecificData '{"replySettings":"following"}'
136
+ ```
137
+
138
+ X/Twitter-specific options reject non-X targets. `quoteTweetId` cannot be combined with top-level `--media`, and `replyToTweetId` cannot be combined with `replySettings`.
139
+
140
+ For safe diagnostics on publish failures:
141
+
142
+ ```bash
143
+ zernio posts:create --text "Draft" --accounts <accountId> --draft --debug-safe --pretty
144
+ ```
145
+
146
+ `--debug-safe` includes resolved platform/account context and recovery hints without printing API keys or social tokens.
147
+
91
148
  ## Queue Scheduling
92
149
 
93
150
  Use `queuedFromProfile` and optional `queueId` in create-post payloads. Do not use `queue/next-slot` as the scheduled time; it is preview-only.
@@ -24,9 +24,10 @@ Status: Complete
24
24
  - Semantic-release stable/beta workflow.
25
25
  - NPM token based publish.
26
26
 
27
- ## Phase 4 - Follow-Up
27
+ ## Phase 4 - Curated Command Follow-Up
28
28
 
29
- Status: Planned
29
+ Status: In Progress
30
30
 
31
- - Enable GitHub Issues if `ck:vibe` issue tracking is required.
31
+ - Enable GitHub Issues if `ck:vibe` issue tracking is required. Complete.
32
32
  - Add more curated commands for high-use endpoints discovered through real usage.
33
+ - Add native X/Twitter `posts:create` support for quote, reply, threadItems, and safe publish diagnostics. Complete.
@@ -14,3 +14,6 @@
14
14
  - Added coverage-gated tests for critical agent CLI paths: `api:*`, `doctor`, `platforms`, config, request construction, parsing, output, and error handling.
15
15
  - Updated docs and skill guidance for media uploads, queue scheduling, errors, rate limits, and platforms.
16
16
  - Expanded the bundled `zernio` skill to cover all shipped curated commands, full OpenAPI fallback usage, and end-to-end agent workflows.
17
+ - Added native X/Twitter `posts:create` options for quote tweets, replies, reply settings, thread JSON/files, and advanced `platformSpecificData`.
18
+ - Added safe `posts:create --debug-safe` diagnostics for failed publish attempts without exposing secrets.
19
+ - Added focused tests for X platform-specific payload mapping, validation, thread parsing, and safe 401 diagnostics.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zernio-cli",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Unofficial agent-friendly CLI for the Zernio API",
5
5
  "type": "module",
6
6
  "bin": {