@standchat/cli 0.0.0-stage → 0.1.1

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,385 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { CliError, isObject, usage } from './errors.js';
3
+ import { BUNDLED_SCHEMA } from './contract.js';
4
+ const COMMAND_DEFINITIONS = [
5
+ { "command": "org plan", "args": [], "description": "Read the current plan.", "scope": "org:read", "method": "GET", "path": "/v1/org/plan", "options": [], "confirmation": false, "bodyRequired": false },
6
+ { "command": "org usage", "args": [], "description": "Read current plan usage.", "scope": "org:read", "method": "GET", "path": "/v1/org/usage", "options": [], "confirmation": false, "bodyRequired": false },
7
+ { "command": "standins status", "args": [], "description": "Read the account-wide Stand-in switch.", "scope": "standins:read", "method": "GET", "path": "/v1/reps/me/standin/status", "options": [], "confirmation": false, "bodyRequired": false },
8
+ { "command": "knowledge list", "args": [], "description": "List knowledge bases and capabilities.", "scope": "knowledge:read", "method": "GET", "path": "/v1/knowledge-bases", "options": [], "confirmation": false, "bodyRequired": false },
9
+ { "command": "knowledge create", "args": [], "description": "Create a knowledge base.", "scope": "knowledge:write", "method": "POST", "path": "/v1/knowledge-bases", "options": ["body-file"], "confirmation": true, "bodyRequired": true },
10
+ { "command": "knowledge crawl", "args": ["knowledgeBaseId"], "description": "Crawl approved public website content.", "scope": "knowledge:write", "method": "POST", "path": "/v1/knowledge-bases/{knowledgeBaseId}/crawls", "options": ["body-file"], "confirmation": true, "bodyRequired": true },
11
+ { "command": "knowledge sources", "args": ["knowledgeBaseId"], "description": "Read knowledge sources.", "scope": "knowledge:read", "method": "GET", "path": "/v1/knowledge-bases/{knowledgeBaseId}/sources", "options": [], "confirmation": false, "bodyRequired": false },
12
+ { "command": "knowledge tasks", "args": ["knowledgeBaseId"], "description": "Read ingestion tasks.", "scope": "knowledge:read", "method": "GET", "path": "/v1/knowledge-bases/{knowledgeBaseId}/tasks", "options": [], "confirmation": false, "bodyRequired": false },
13
+ { "command": "knowledge documents", "args": ["knowledgeBaseId"], "description": "Read indexed documents.", "scope": "knowledge:read", "method": "GET", "path": "/v1/knowledge-bases/{knowledgeBaseId}/documents", "options": [], "confirmation": false, "bodyRequired": false },
14
+ { "command": "standins knowledge get", "args": ["standinProfileId"], "description": "Read knowledge attachments.", "scope": "knowledge:read", "method": "GET", "path": "/v1/reps/me/standins/{standinProfileId}/knowledge-bases", "options": [], "confirmation": false, "bodyRequired": false },
15
+ { "command": "standins knowledge set", "args": ["standinProfileId"], "description": "Replace the complete approved attachment list.", "scope": "knowledge:write", "method": "PUT", "path": "/v1/reps/me/standins/{standinProfileId}/knowledge-bases", "options": ["body-file"], "confirmation": true, "bodyRequired": true },
16
+ { command: 'standins master', args: [], description: 'Change the account-wide switch; affects other enabled Stand-ins.', scope: 'standins:write', method: 'POST', path: '/v1/reps/me/standin/toggle', options: ['enabled'], confirmation: true },
17
+ { command: 'whoami', args: [], description: 'Get the authenticated user.', scope: 'profile:read', method: 'GET', path: '/v1/reps/me', options: [] },
18
+ { command: 'profile get', args: [], description: 'Get your representative profile.', scope: 'profile:read', method: 'GET', path: '/v1/reps/profile', options: [] },
19
+ { command: 'team list', args: [], description: 'List the current team.', scope: 'team:read', method: 'GET', path: '/v1/team', options: [] },
20
+ { command: 'sites list', args: [], description: 'List sites in your organization.', scope: 'sites:read', method: 'GET', path: '/v1/sites', options: [] },
21
+ { command: 'sites get', args: ['siteId'], description: 'Read one authorized site directly.', scope: 'sites:read', method: 'GET', path: '/v1/sites/{siteId}', options: [] },
22
+ { command: 'sites create', args: [], description: 'Create a site.', scope: 'sites:write', method: 'POST', path: '/v1/sites', options: ['domain', 'name', 'body-file'], confirmation: true },
23
+ { command: 'sites update', args: ['siteId'], description: 'Update supplied site settings; requires organization ownership. Legacy embedOptions replaces behavior rules and resets experimentation consent; use complete behaviorConfig to retain behavior.', scope: 'sites:write', method: 'PUT', path: '/v1/sites/{siteId}', options: ['name', 'enabled', 'language', 'body-file'], confirmation: true },
24
+ { command: 'sites delete', args: ['siteId'], description: 'Delete a site; requires organization ownership.', scope: 'sites:write', method: 'DELETE', path: '/v1/sites/{siteId}', options: [], confirmation: true },
25
+ { command: 'sessions list', args: [], description: 'List active session identifiers. Does not change rep availability.', scope: 'sessions:read', method: 'GET', path: '/v1/sessions', options: ['watch', 'interval', 'count'] },
26
+ { command: 'sessions get', args: ['sessionId'], description: 'Read a session and up to 500 messages.', scope: 'sessions:read', method: 'GET', path: '/v1/sessions/{sessionId}?messageLimit=500', options: ['watch', 'interval', 'count'] },
27
+ { command: 'sessions send', args: ['sessionId'], description: 'Send a message after verifying your identity (also requires profile:read). Adds clientMessageId when absent; errors after dispatch include reconciliation IDs.', scope: 'sessions:write', additionalScopes: ['profile:read'], method: 'POST', path: '/v1/sessions/{sessionId}/messages', options: ['body-file'], confirmation: true },
28
+ { command: 'sessions close', args: ['sessionId'], description: 'Close a session as the authenticated user.', scope: 'sessions:write', method: 'DELETE', path: '/v1/sessions/{sessionId}', options: [], confirmation: true },
29
+ { command: 'history search', args: [], description: 'Search archived sessions; dates are explicit instants and closed-to is exclusive.', scope: 'sessions:read', method: 'GET', path: '/v1/sessions/archived', options: ['query', 'page', 'size', 'type', 'scope', 'site', 'contact', 'label', 'closed-from', 'closed-to', 'sort-by', 'sort-dir', 'all', 'max-pages'] },
30
+ { command: 'history export', args: [], description: 'Stream a history export into a new private file; never overwrites an existing file.', scope: 'sessions:read', method: 'GET', path: '/v1/sessions/archived/export', options: ['output', 'max-bytes', 'query', 'type', 'scope', 'site', 'contact', 'label', 'closed-from', 'closed-to', 'sort-by', 'sort-dir'] },
31
+ ...([
32
+ ['profile update', [], 'Update an already-completed profile; initial onboarding remains in the browser.', 'profile:write', 'PUT', '/v1/reps/profile', true],
33
+ ['sites matches', [], 'List responder matches for installed sites.', 'sites:read', 'GET', '/v1/sites/matches'],
34
+ ['sites preferences get', [], 'Get the sites selected for your representative profile.', 'sites:read', 'GET', '/v1/reps/profile/sites'],
35
+ ['sites preferences set', [], 'Set the sites selected for your representative profile.', 'sites:write', 'PUT', '/v1/reps/profile/sites', true],
36
+ ['sites prompt get', ['siteId'], 'Get site instructions.', 'sites:read', 'GET', '/v1/sites/{siteId}/prompt'],
37
+ ['sites prompt set', ['siteId'], 'Replace site instructions.', 'sites:write', 'PUT', '/v1/sites/{siteId}/prompt', true],
38
+ ['sites prompt delete', ['siteId'], 'Delete site instructions.', 'sites:write', 'DELETE', '/v1/sites/{siteId}/prompt'],
39
+ ['sites prompt history', ['siteId'], 'Read site instruction history.', 'sites:read', 'GET', '/v1/sites/{siteId}/prompt/history'],
40
+ ['org prompt get', ['orgId'], 'Get organization instructions.', 'org:read', 'GET', '/v1/orgs/{orgId}/prompt'],
41
+ ['org prompt set', ['orgId'], 'Replace organization instructions.', 'org:write', 'PUT', '/v1/orgs/{orgId}/prompt', true],
42
+ ['org prompt delete', ['orgId'], 'Delete organization instructions.', 'org:write', 'DELETE', '/v1/orgs/{orgId}/prompt'],
43
+ ['org prompt history', ['orgId'], 'Read organization instruction history.', 'org:read', 'GET', '/v1/orgs/{orgId}/prompt/history'],
44
+ ['org privacy get', ['orgId'], 'Read organization privacy preferences.', 'org:read', 'GET', '/v1/orgs/{orgId}/privacy-settings'],
45
+ ['org privacy set', ['orgId'], 'Update organization privacy preferences.', 'org:write', 'PUT', '/v1/orgs/{orgId}/privacy-settings', true],
46
+ ['sessions seen', ['sessionId'], 'Mark a session as seen.', 'sessions:write', 'PUT', '/v1/sessions/{sessionId}/seen'],
47
+ ['sessions take-over', ['sessionId'], 'Take over a session from its Stand-in.', 'sessions:write', 'POST', '/v1/sessions/{sessionId}/handoff'],
48
+ ['sessions offer-handoff', ['sessionId'], 'Offer a session handoff.', 'sessions:write', 'POST', '/v1/sessions/{sessionId}/handoff/offer'],
49
+ ['greetings list', [], 'List greeting variants.', 'profile:read', 'GET', '/v1/reps/me/greeting-variants'],
50
+ ['greetings set', [], 'Replace greeting variants.', 'profile:write', 'PUT', '/v1/reps/me/greeting-variants', true],
51
+ ['digest preferences get', [], 'Read digest delivery preferences.', 'digest:read', 'GET', '/v1/digest/preferences'],
52
+ ['digest preferences set', [], 'Update digest delivery preferences.', 'digest:write', 'PUT', '/v1/digest/preferences', true],
53
+ ['digest ai-preferences get', [], 'Read AI digest preferences.', 'digest:read', 'GET', '/v1/digest/ai-preferences'],
54
+ ['digest ai-preferences set', [], 'Update AI digest preferences.', 'digest:write', 'PUT', '/v1/digest/ai-preferences', true],
55
+ ['digest send', [], 'Send the configured digest now.', 'digest:write', 'POST', '/v1/digest/send-now'],
56
+ ['standins list', [], 'List your Stand-ins.', 'standins:read', 'GET', '/v1/reps/me/standins'],
57
+ ['standins create', [], 'Create a Stand-in.', 'standins:write', 'POST', '/v1/reps/me/standins', true],
58
+ ['standins get', ['id'], 'Read a Stand-in.', 'standins:read', 'GET', '/v1/reps/me/standins/{id}'],
59
+ ['standins update', ['id'], 'Save Stand-in settings. Chat history is preserved by creating a replacement version when needed; use the returned standinProfileId for later calls. Read and preserve existing fields before saving.', 'standins:write', 'PUT', '/v1/reps/me/standins/{id}', true],
60
+ ['standins delete', ['id'], 'Delete a Stand-in.', 'standins:write', 'DELETE', '/v1/reps/me/standins/{id}'],
61
+ ['standins clone', ['id'], 'Clone a Stand-in as an independent, initially disabled copy.', 'standins:write', 'POST', '/v1/reps/me/standins/{id}/clone'],
62
+ ]).map(([command, args, description, scope, method, path, bodyRequired]) => ({
63
+ command, args, description, scope, method, path, bodyRequired,
64
+ options: bodyRequired ? ['body-file'] : [], confirmation: method !== 'GET',
65
+ })),
66
+ { command: 'team provider-default', args: [], description: 'Resolve the required provider for a prospective invitation.', scope: 'team:read', method: 'GET', path: '/v1/team/invite-provider-default', options: ['email'] },
67
+ { command: 'standins set-enabled', args: ['id'], description: 'Enable or disable a Stand-in.', scope: 'standins:write', method: 'POST', path: '/v1/reps/me/standins/{id}/enabled', options: ['enabled'], confirmation: true },
68
+ { command: 'sites preferences update', args: ['siteId'], description: 'Change your chat selection for one site, retaining the other selected sites.', scope: 'sites:write', method: 'PUT', path: '/v1/reps/profile/sites/{siteId}', options: ['enabled'], confirmation: true },
69
+ ];
70
+ // Only operations present in the bundled public contract are available. The
71
+ // private definition catalog may include wrappers for routes excluded by policy.
72
+ export const COMMANDS = COMMAND_DEFINITIONS.flatMap(command => {
73
+ const paths = isObject(BUNDLED_SCHEMA) && isObject(BUNDLED_SCHEMA.paths) ? BUNDLED_SCHEMA.paths : {};
74
+ const item = paths[command.path.split('?')[0]];
75
+ const operation = isObject(item) ? item[command.method.toLowerCase()] : undefined;
76
+ if (!isObject(operation))
77
+ return [];
78
+ if (typeof operation.operationId !== 'string' || typeof operation['x-required-scope'] !== 'string') {
79
+ throw new Error('Public API operation is missing its identifier or required scope.');
80
+ }
81
+ return [{
82
+ ...command,
83
+ options: command.options,
84
+ operationId: operation.operationId,
85
+ scope: operation['x-required-scope'],
86
+ }];
87
+ });
88
+ export function stringFlag(flags, key) { const value = flags[key]; return typeof value === 'string' ? value : undefined; }
89
+ export function integerFlag(flags, key, defaultValue, min, max) {
90
+ const raw = stringFlag(flags, key);
91
+ if (raw === undefined)
92
+ return defaultValue;
93
+ if (!/^\d+$/.test(raw))
94
+ usage(`--${key} must be an integer from ${min} to ${max}.`);
95
+ const value = Number(raw);
96
+ if (!Number.isSafeInteger(value) || value < min || value > max)
97
+ usage(`--${key} must be an integer from ${min} to ${max}.`);
98
+ return value;
99
+ }
100
+ export async function readInput(stream, maxBytes = 1024 * 1024, signal) {
101
+ const cancelled = () => new CliError('CANCELLED', 'Input cancelled. No request was sent.', 130);
102
+ if (signal?.aborted)
103
+ throw cancelled();
104
+ const cancel = () => {
105
+ if ('destroy' in stream && typeof stream.destroy === 'function')
106
+ stream.destroy(cancelled());
107
+ };
108
+ signal?.addEventListener('abort', cancel, { once: true });
109
+ const chunks = [];
110
+ let total = 0;
111
+ try {
112
+ for await (const chunk of stream) {
113
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
114
+ total += buffer.length;
115
+ if (total > maxBytes)
116
+ throw new CliError('INPUT_TOO_LARGE', `Input exceeds the ${maxBytes}-byte limit.`, 2);
117
+ chunks.push(buffer);
118
+ }
119
+ if (signal?.aborted)
120
+ throw cancelled();
121
+ return Buffer.concat(chunks).toString('utf8');
122
+ }
123
+ finally {
124
+ signal?.removeEventListener('abort', cancel);
125
+ }
126
+ }
127
+ export async function requestBody(flags, stdin, signal) {
128
+ const file = stringFlag(flags, 'body-file');
129
+ if (!file)
130
+ return undefined;
131
+ let text;
132
+ try {
133
+ if (file === '-')
134
+ text = await readInput(stdin, undefined, signal);
135
+ else {
136
+ // Stream files too, so a mistaken large path does not exhaust memory.
137
+ const { createReadStream } = await import('node:fs');
138
+ text = await readInput(createReadStream(file), undefined, signal);
139
+ }
140
+ }
141
+ catch (error) {
142
+ if (error instanceof CliError)
143
+ throw error;
144
+ throw new CliError('INPUT_READ_FAILED', 'Cannot read the request body file.', 2);
145
+ }
146
+ let body;
147
+ try {
148
+ body = JSON.parse(text);
149
+ }
150
+ catch {
151
+ return usage('Request body must be valid JSON.');
152
+ }
153
+ if (!isObject(body))
154
+ usage('Request body must be a JSON object.');
155
+ return body;
156
+ }
157
+ export function findCommand(words) {
158
+ const definition = COMMANDS.find(item => item.command.split(' ').every((word, i) => words[i] === word));
159
+ if (!definition)
160
+ return undefined;
161
+ const args = words.slice(definition.command.split(' ').length);
162
+ if (args.length !== definition.args.length)
163
+ usage(`Usage: stand ${definition.command}${definition.args.map(arg => ` <${arg}>`).join(' ')}.`);
164
+ if (args.some(value => !value || value === '.' || value === '..' || /[\x00-\x20\x7f/\\]/.test(value)))
165
+ usage('Resource identifiers must be nonempty path segments.');
166
+ return { definition, args };
167
+ }
168
+ export function resolvePath(command, args) {
169
+ return command.args.reduce((path, name, index) => path.replace(`{${name}}`, encodeURIComponent(args[index])), command.path);
170
+ }
171
+ function enumFlag(flags, key, values, fallback) {
172
+ const value = stringFlag(flags, key) ?? fallback;
173
+ if (!values.includes(value))
174
+ usage(`--${key} must be one of: ${values.join(', ')}.`);
175
+ return value;
176
+ }
177
+ function validTimestamp(value) {
178
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/.exec(value);
179
+ if (!match || !Number.isFinite(Date.parse(value)))
180
+ return false;
181
+ const [year, month, day, hour, minute, second] = match.slice(1, 7).map(Number);
182
+ if (month < 1 || month > 12 || hour > 23 || minute > 59 || second > 59 || Number(match[7] ?? 0) > 23 || Number(match[8] ?? 0) > 59)
183
+ return false;
184
+ const calendar = new Date(`${match[1]}-${match[2]}-01T00:00:00Z`);
185
+ calendar.setUTCMonth(month);
186
+ calendar.setUTCDate(0);
187
+ return calendar.getUTCFullYear() === year && day >= 1 && day <= calendar.getUTCDate();
188
+ }
189
+ export function historyParams(flags) {
190
+ const page = integerFlag(flags, 'page', 0, 0, 2_147_483_647);
191
+ const size = integerFlag(flags, 'size', 20, 1, 100);
192
+ if (page * size > 2_147_483_647)
193
+ usage('--page and --size exceed the maximum history offset.');
194
+ // Match Java String.trim() and String.length(), including its UTF-16 counting.
195
+ const rawQuery = stringFlag(flags, 'query');
196
+ let start = 0, end = rawQuery?.length ?? 0;
197
+ while (start < end && rawQuery.charCodeAt(start) <= 0x20)
198
+ start++;
199
+ while (end > start && rawQuery.charCodeAt(end - 1) <= 0x20)
200
+ end--;
201
+ const query = rawQuery?.slice(start, end);
202
+ if (query && query.length > 200)
203
+ usage('--query must contain at most 200 UTF-16 code units after removing leading and trailing U+0000 through U+0020 characters.');
204
+ const params = new URLSearchParams({
205
+ page: String(page), size: String(size),
206
+ type: enumFlag(flags, 'type', ['all', 'rep', 'agent'], 'all'),
207
+ scope: enumFlag(flags, 'scope', ['myself', 'org'], 'myself'),
208
+ contact: enumFlag(flags, 'contact', ['all', 'email', 'phone', 'any'], 'all'),
209
+ sortBy: enumFlag(flags, 'sort-by', ['closedAt', 'createdAt', 'page', 'messageCount'], 'closedAt'),
210
+ sortDir: enumFlag(flags, 'sort-dir', ['asc', 'desc'], 'desc'),
211
+ });
212
+ for (const key of ['site', 'label']) {
213
+ const value = stringFlag(flags, key);
214
+ if (value)
215
+ params.set(key, value);
216
+ }
217
+ if (query)
218
+ params.set('q', query);
219
+ for (const [flag, key] of [['closed-from', 'closedFrom'], ['closed-to', 'closedTo']]) {
220
+ const value = stringFlag(flags, flag);
221
+ if (value !== undefined) {
222
+ if (!validTimestamp(value))
223
+ usage(`--${flag} must be an ISO 8601 timestamp with timezone (for example 2026-10-15T00:00:00Z).`);
224
+ params.set(key, new Date(value).toISOString());
225
+ }
226
+ }
227
+ const from = params.get('closedFrom');
228
+ const to = params.get('closedTo');
229
+ if (from && to && from >= to)
230
+ usage('--closed-from must be earlier than --closed-to.');
231
+ return params;
232
+ }
233
+ export async function executeCommand(client, command, args, flags, stdin, options, preparedBody) {
234
+ const body = preparedBody ?? await requestBody(flags, stdin, options.signal);
235
+ if (command.bodyRequired && !body)
236
+ usage('This command requires --body-file FILE (or - for stdin). Run stand schema OPERATION_ID for its request schema.');
237
+ if (command.command === 'history export') {
238
+ const file = stringFlag(flags, 'output');
239
+ if (!file || file === '-')
240
+ usage('History export requires --output FILE. Use a new file; existing files are never overwritten.');
241
+ const params = historyParams(flags);
242
+ params.delete('page');
243
+ params.delete('size');
244
+ return client.download(`${command.path}?${params}`, file, integerFlag(flags, 'max-bytes', 100 * 1024 * 1024, 1, 1024 * 1024 * 1024), options);
245
+ }
246
+ if (command.command === 'history search') {
247
+ if (flags['max-pages'] !== undefined && !flags.all)
248
+ usage('--max-pages requires --all.');
249
+ const params = historyParams(flags);
250
+ const startPage = Number(params.get('page'));
251
+ if (!flags.all)
252
+ return client.request(`${command.path}?${params}`, options);
253
+ const maxPages = integerFlag(flags, 'max-pages', 20, 1, 1000);
254
+ const sessions = [];
255
+ let accumulatedBytes = 0;
256
+ let totalPages = 0;
257
+ let totalElements = 0;
258
+ let fetched = 0;
259
+ for (; fetched < maxPages; fetched++) {
260
+ params.set('page', String(startPage + fetched));
261
+ const response = await client.request(`${command.path}?${params}`, options);
262
+ if (!isObject(response) || !Array.isArray(response.sessions) || !Number.isInteger(response.totalPages)
263
+ || typeof response.totalPages !== 'number' || response.totalPages < 0 || typeof response.totalElements !== 'number'
264
+ || response.page !== startPage + fetched)
265
+ throw new CliError('INVALID_RESPONSE', 'History response has invalid pagination metadata.', 5);
266
+ accumulatedBytes += Buffer.byteLength(JSON.stringify(response.sessions));
267
+ if (accumulatedBytes > 32 * 1024 * 1024)
268
+ throw new CliError('RESPONSE_TOO_LARGE', 'Combined history exceeds 32 MiB. Request smaller page ranges or narrower filters.', 2);
269
+ sessions.push(...response.sessions);
270
+ totalPages = response.totalPages;
271
+ totalElements = response.totalElements;
272
+ if (startPage + fetched + 1 >= totalPages) {
273
+ fetched++;
274
+ break;
275
+ }
276
+ }
277
+ const nextPage = startPage + fetched < totalPages ? startPage + fetched : null;
278
+ return { sessions, startPage, pagesFetched: fetched, totalElements, complete: nextPage === null, nextPage,
279
+ consistency: 'Live offset pagination; concurrent changes can affect page boundaries.' };
280
+ }
281
+ let payload = body;
282
+ if (command.command === 'team provider-default') {
283
+ const email = stringFlag(flags, 'email');
284
+ if (!email || !/^[^\s@]+@[^\s@]+$/.test(email))
285
+ usage('--email must contain an email address.');
286
+ return client.request(`${command.path}?${new URLSearchParams({ email })}`, options);
287
+ }
288
+ if (['standins set-enabled', 'standins master'].includes(command.command) || command.command === 'sites preferences update') {
289
+ const enabled = stringFlag(flags, 'enabled');
290
+ if (enabled !== 'true' && enabled !== 'false')
291
+ usage('--enabled must be true or false.');
292
+ payload = { enabled: enabled === 'true' };
293
+ }
294
+ if (command.command === 'sites create' || command.command === 'sites update') {
295
+ const fieldFlags = ['domain', 'name', 'enabled', 'language'].filter(key => flags[key] !== undefined);
296
+ if (body && fieldFlags.length)
297
+ usage('Use --body-file or field flags, not both.');
298
+ payload ??= {};
299
+ for (const key of ['domain', 'name']) {
300
+ const value = stringFlag(flags, key);
301
+ if (value !== undefined)
302
+ payload[key] = value;
303
+ }
304
+ const enabled = stringFlag(flags, 'enabled');
305
+ if (enabled !== undefined) {
306
+ if (enabled !== 'true' && enabled !== 'false')
307
+ usage('--enabled must be true or false.');
308
+ payload.enabled = enabled === 'true';
309
+ }
310
+ const language = stringFlag(flags, 'language');
311
+ if (language !== undefined)
312
+ payload.defaultLanguageCode = language === 'auto' ? null : language;
313
+ if (command.command === 'sites create' && (typeof payload.domain !== 'string' || !payload.domain.trim()))
314
+ usage('Provide --domain or a JSON body with a nonempty domain.');
315
+ if (command.command === 'sites update' && !Object.keys(payload).length)
316
+ usage('Provide at least one site setting.');
317
+ }
318
+ if (command.command === 'sessions send') {
319
+ if (!payload || typeof payload.body !== 'string' || !payload.body.trim())
320
+ usage('Provide --body-file with a nonempty body string.');
321
+ payload.type ??= 'text';
322
+ payload.clientMessageId ??= randomUUID();
323
+ if (typeof payload.clientMessageId !== 'string' || !payload.clientMessageId.length
324
+ || payload.clientMessageId.length > 256 || payload.clientMessageId.trim() !== payload.clientMessageId
325
+ || /[\x00-\x1f\x7f-\x9f]/.test(payload.clientMessageId)) {
326
+ usage('clientMessageId must be a nonblank string of at most 256 characters, without control characters or surrounding whitespace. A UUID is recommended.');
327
+ }
328
+ if (args[0].length > 256)
329
+ usage('Session identifiers must contain at most 256 characters.');
330
+ const reconciliation = { sessionId: args[0], clientMessageId: payload.clientMessageId };
331
+ // The server deduplicates by clientMessageId and can return someone else's
332
+ // earlier message. Obtain the authenticated identity before making a write.
333
+ const user = await client.request('/v1/reps/me', options);
334
+ if (!isObject(user) || typeof user.userId !== 'string' || !user.userId.trim()) {
335
+ throw new CliError('INVALID_RESPONSE', 'Could not verify your identity. No reply was sent.', 5);
336
+ }
337
+ let dispatched = false;
338
+ try {
339
+ const result = await client.request(resolvePath(command, args), {
340
+ ...options, method: command.method, body: payload,
341
+ onDispatch: () => { options.onDispatch?.(); dispatched = true; },
342
+ });
343
+ if (!isObject(result) || typeof result.messageId !== 'string' || !result.messageId.trim()
344
+ || ['sessionId', 'senderId', 'senderType', 'type', 'body', 'clientMessageId'].some(key => typeof result[key] !== 'string')) {
345
+ throw new CliError('INVALID_RESPONSE', 'The server did not return a complete reply. Its outcome is unconfirmed.', 5);
346
+ }
347
+ if (result.sessionId !== args[0] || result.clientMessageId !== payload.clientMessageId || result.body !== payload.body
348
+ || result.senderType !== 'rep' || result.senderId !== user.userId || result.type !== 'text') {
349
+ throw new CliError('REPLY_CONFLICT', 'The returned message does not match the approved reply and authenticated sender. It does not confirm that your reply was sent.', 1);
350
+ }
351
+ return result;
352
+ }
353
+ catch (error) {
354
+ if (!dispatched)
355
+ throw error;
356
+ const failure = error instanceof CliError ? error : new CliError('NETWORK', 'Reply outcome could not be confirmed.', 5);
357
+ throw new CliError(failure.code, `${failure.message} Before retrying, read the conversation and check the reply identity, sender, type and body. Reuse this clientMessageId for an intentional retry. The responder may already have changed.`, failure.exitCode, failure.status, failure.retryAfter, reconciliation);
358
+ }
359
+ }
360
+ try {
361
+ return await client.request(resolvePath(command, args), { ...options, method: command.method, body: payload });
362
+ }
363
+ catch (error) {
364
+ // Preserve the named site lookup's established not-found exit status.
365
+ if (command.command === 'sites get' && error instanceof CliError && error.status === 404)
366
+ throw new CliError(error.code, error.message, 3, error.status, error.retryAfter);
367
+ throw error;
368
+ }
369
+ }
370
+ export async function readSchema(operation) {
371
+ const schema = BUNDLED_SCHEMA;
372
+ if (!operation)
373
+ return schema;
374
+ if (isObject(schema) && isObject(schema.paths)) {
375
+ for (const [path, raw] of Object.entries(schema.paths)) {
376
+ if (!isObject(raw))
377
+ continue;
378
+ for (const [method, detail] of Object.entries(raw)) {
379
+ if (isObject(detail) && detail.operationId === operation)
380
+ return { path, method: method.toUpperCase(), ...detail, components: schema.components };
381
+ }
382
+ }
383
+ }
384
+ throw new CliError('NOT_FOUND', 'Operation was not found in the bundled API contract.', 3);
385
+ }