zernio-cli 0.4.0 → 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.
@@ -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.
@@ -13,3 +13,7 @@
13
13
  - Fixed `doctor --connection` so failed API checks exit non-zero for CI and scripts.
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
+ - 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.0",
3
+ "version": "0.5.0",
4
4
  "description": "Unofficial agent-friendly CLI for the Zernio API",
5
5
  "type": "module",
6
6
  "bin": {