@postplus/cli 0.1.38 → 0.1.40

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.
@@ -1,10 +1,30 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { createReadStream } from 'node:fs';
3
+ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
3
4
  import path from 'node:path';
4
5
  import { resolveFreshRemoteAuth } from './auth-session.js';
5
6
  import { buildPostPlusClientCompatibilityHeaders, formatPostPlusCompatibilityError, } from './client-compatibility.js';
6
7
  import { buildHostedRequestSchemaReport, buildMediaGenerationRequestDimensions, } from './hosted-request-schemas.js';
8
+ import { buildVerbTargetIndex, } from './hosted-manifest-index.js';
7
9
  import { readLargeCreditQuoteConfirmationChallenge, } from './quote-confirmation.js';
10
+ // Manifest-driven verb grammar indexes (SSOT projected from apps/web +
11
+ // public-skill-metadata via the generated manifest). The verb/flag grammar,
12
+ // runner-managed set, and enum sets all come from the manifest so the CLI never
13
+ // hand-maintains a mirror of the Web hosted catalog.
14
+ const MEDIA_VERB_ENDPOINTS = buildVerbTargetIndex('media');
15
+ const RESEARCH_VERB_TARGETS = buildVerbTargetIndex('research');
16
+ const PUBLISH_VERB_OPERATIONS = buildPublishVerbIndex();
17
+ // Publish flattens to operation -> resolved target: the publish OPERATION is both
18
+ // the subcommand and the target (no separate positional), unlike media/research.
19
+ function buildPublishVerbIndex() {
20
+ const index = new Map();
21
+ for (const targets of buildVerbTargetIndex('publish').values()) {
22
+ for (const [operation, resolved] of targets) {
23
+ index.set(operation, resolved);
24
+ }
25
+ }
26
+ return index;
27
+ }
8
28
  class HostedQuoteConfirmationRequiredError extends Error {
9
29
  challenge;
10
30
  constructor(message, challenge) {
@@ -13,15 +33,14 @@ class HostedQuoteConfirmationRequiredError extends Error {
13
33
  this.name = 'HostedQuoteConfirmationRequiredError';
14
34
  }
15
35
  }
16
- const HOSTED_DOMAIN_CAPABILITIES = {
17
- media: new Set(['media-file', 'media-generation', 'video-analysis']),
18
- mobile: new Set(['mobile-automation']),
19
- publish: new Set(['social-publishing']),
20
- research: new Set([
21
- 'public-content-collection',
22
- 'public-content-discovery',
23
- ]),
24
- };
36
+ class HostedProductRequestError extends Error {
37
+ productError;
38
+ constructor(productError) {
39
+ super(formatHostedProductErrorMessage(productError));
40
+ this.productError = productError;
41
+ this.name = 'HostedProductRequestError';
42
+ }
43
+ }
25
44
  export async function runHostedDomainCommand(domain, args) {
26
45
  const [subcommand, ...rest] = args;
27
46
  if (domain === 'research') {
@@ -31,8 +50,8 @@ export async function runHostedDomainCommand(domain, args) {
31
50
  if (subcommand === 'collect') {
32
51
  return runResearchCollect(rest);
33
52
  }
34
- if (subcommand === 'capability') {
35
- return runHostedCapability(domain, rest);
53
+ if (subcommand === 'scrape') {
54
+ return runResearchScrape(rest);
36
55
  }
37
56
  printResearchHelp();
38
57
  return subcommand === undefined || isHelp(subcommand) ? 0 : 1;
@@ -40,51 +59,689 @@ export async function runHostedDomainCommand(domain, args) {
40
59
  if (subcommand === 'schema') {
41
60
  return runHostedSchema(domain, rest);
42
61
  }
43
- if (subcommand === 'capability') {
44
- return runHostedCapability(domain, rest);
62
+ if (domain === 'media' && subcommand && MEDIA_VERB_ENDPOINTS.has(subcommand)) {
63
+ return runMediaVerb(subcommand, rest);
64
+ }
65
+ // publish: the OPERATION is the subcommand (no separate target positional).
66
+ if (domain === 'publish' &&
67
+ subcommand &&
68
+ PUBLISH_VERB_OPERATIONS.has(subcommand)) {
69
+ return runPublishOperation(subcommand, rest);
45
70
  }
46
- printCapabilityHelp(domain);
71
+ printDomainVerbHelp(domain);
47
72
  return subcommand === undefined || isHelp(subcommand) ? 0 : 1;
48
73
  }
49
- async function runResearchCollect(args) {
74
+ // Manifest-driven verb grammar: `postplus media <verb> <endpointKey> ...`. The
75
+ // endpoint's executionSurface decides the input shape — a flags surface maps
76
+ // scalar intent/default fields to flags, a request-json surface reads the nested
77
+ // envelope from `--request <file>`. Either way runner-managed fields (billing
78
+ // dimensions, ids, tokens) are derived/minted by the runner, never agent-supplied.
79
+ async function runMediaVerb(verb, args) {
80
+ const targets = MEDIA_VERB_ENDPOINTS.get(verb);
81
+ if (!targets) {
82
+ throw new Error(`Unknown media verb ${verb}.`);
83
+ }
84
+ const [targetKey, ...rest] = args;
85
+ if (!targetKey || targetKey.startsWith('--')) {
86
+ throw new Error(`postplus media ${verb} requires a target key. Run \`postplus media schema --json\` to list targets.`);
87
+ }
88
+ const resolved = targets.get(targetKey);
89
+ if (!resolved) {
90
+ throw new Error(`Unknown ${verb} target ${targetKey}. Valid: ${[...targets.keys()].join(', ')}.`);
91
+ }
92
+ // `postplus media <verb> <endpoint> --help`: render the endpoint's field-level
93
+ // contract (intent / default / runner-managed) instead of dispatching a request.
94
+ if (rest.some(isHelp)) {
95
+ printMediaEndpointHelp('media', verb, targetKey, resolved);
96
+ return 0;
97
+ }
98
+ if (resolved.capability === 'video-analysis') {
99
+ return runVideoAnalysisVerb({
100
+ args: rest,
101
+ modelKey: targetKey,
102
+ resolved,
103
+ verb,
104
+ });
105
+ }
106
+ if (resolved.surface === 'request-json') {
107
+ return runMediaVerbRequestJson({
108
+ args: rest,
109
+ endpointKey: targetKey,
110
+ resolved,
111
+ verb,
112
+ });
113
+ }
114
+ return runMediaVerbFlags({
115
+ args: rest,
116
+ endpointKey: targetKey,
117
+ resolved,
118
+ verb,
119
+ });
120
+ }
121
+ // Flags surface (e.g. audio-transcription): scalar intent/default fields map to
122
+ // flags; runner-managed fields have no flag so the agent cannot pass them.
123
+ async function runMediaVerbFlags(args) {
124
+ const { endpointKey, resolved, verb } = args;
125
+ const endpoint = requireResolvedEndpoint(resolved, verb, endpointKey);
126
+ const fields = endpoint.fields;
127
+ const flagToField = new Map();
128
+ const booleanKeys = new Set(['json']);
129
+ const arrayKeys = new Set();
130
+ for (const field of fields) {
131
+ if (!field.flag) {
132
+ continue;
133
+ }
134
+ const key = field.flag.replace(/^--/u, '');
135
+ flagToField.set(key, field);
136
+ if (field.type === 'boolean') {
137
+ booleanKeys.add(key);
138
+ }
139
+ if (field.repeatable) {
140
+ arrayKeys.add(key);
141
+ }
142
+ }
143
+ const flags = parseFlags(args.args, booleanKeys, arrayKeys);
144
+ const outputPath = flags.values.get('output') ?? null;
145
+ const controlKeys = new Set([
146
+ 'hosted-operation-id',
147
+ 'json',
148
+ 'output',
149
+ 'quote-confirmation-token',
150
+ 'skill',
151
+ ]);
152
+ // Reject unknown flags. This is how runner-managed fields (no flag) and typos
153
+ // are caught locally before any hosted call.
154
+ for (const key of [
155
+ ...flags.values.keys(),
156
+ ...flags.booleans,
157
+ ...flags.arrays.keys(),
158
+ ]) {
159
+ if (!flagToField.has(key) && !controlKeys.has(key)) {
160
+ throw new Error(`Unknown option for media ${verb}: --${key}.`);
161
+ }
162
+ }
163
+ const input = buildMediaVerbInput({
164
+ endpointKey,
165
+ fields,
166
+ flags,
167
+ verb,
168
+ });
169
+ return submitMediaGenerationRequest({
170
+ capability: resolved.capability,
171
+ endpointKey,
172
+ errorInputLabel: `media-${verb}-${endpointKey}`,
173
+ input,
174
+ json: flags.booleans.has('json'),
175
+ operationId: flags.values.get('hosted-operation-id') ??
176
+ `postplus-cli:media:${resolved.capability}:request:${randomUUID()}`,
177
+ outputPath,
178
+ quoteConfirmationToken: flags.values.get('quote-confirmation-token'),
179
+ skillName: flags.values.get('skill') ?? resolved.skill,
180
+ });
181
+ }
182
+ // Request-json surface (e.g. seedance-submitter): the nested envelope is supplied
183
+ // via `--request <file>`. capability/endpointKey come from the verb + positional,
184
+ // so the body carries only the media-generation input. Runner-managed fields have
185
+ // no flag and must not appear in the body — the CLI fast-fails if they do.
186
+ async function runMediaVerbRequestJson(args) {
187
+ const { endpointKey, resolved, verb } = args;
188
+ const endpoint = requireResolvedEndpoint(resolved, verb, endpointKey);
189
+ const flags = parseFlags(args.args, new Set(['json']));
190
+ const allowedKeys = new Set([
191
+ 'hosted-operation-id',
192
+ 'json',
193
+ 'output',
194
+ 'quote-confirmation-token',
195
+ 'request',
196
+ 'skill',
197
+ ]);
198
+ for (const key of [...flags.values.keys(), ...flags.booleans]) {
199
+ if (!allowedKeys.has(key)) {
200
+ throw new Error(`Unknown option for media ${verb}: --${key}.`);
201
+ }
202
+ }
203
+ const requestPath = requireFlag(flags, 'request');
204
+ const outputPath = flags.values.get('output') ?? null;
205
+ const raw = await readJsonFile(requestPath);
206
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
207
+ throw new Error(`media ${verb} ${endpointKey} --request must be a JSON object of media-generation input.`);
208
+ }
209
+ const input = raw;
210
+ // Runner-managed fields are minted/derived by the CLI; reject them in the body so
211
+ // the agent cannot smuggle in ids, tokens, or billing dimensions.
212
+ for (const field of endpoint.fields) {
213
+ if (field.class === 'runner-managed' &&
214
+ Object.hasOwn(input, field.name)) {
215
+ throw new Error(`media ${verb} ${endpointKey} input must not include runner-managed field "${field.name}"; the CLI mints or derives it.`);
216
+ }
217
+ }
218
+ return submitMediaGenerationRequest({
219
+ capability: resolved.capability,
220
+ endpointKey,
221
+ errorInputLabel: requestPath,
222
+ input,
223
+ json: flags.booleans.has('json'),
224
+ operationId: flags.values.get('hosted-operation-id') ??
225
+ `postplus-cli:media:${resolved.capability}:request:${randomUUID()}`,
226
+ outputPath,
227
+ quoteConfirmationToken: flags.values.get('quote-confirmation-token'),
228
+ skillName: flags.values.get('skill') ?? resolved.skill,
229
+ });
230
+ }
231
+ function requireResolvedEndpoint(resolved, verb, endpointKey) {
232
+ if (!resolved.endpoint) {
233
+ throw new Error(`media ${verb} ${endpointKey} resolved to a non-endpoint target; this verb requires a media-generation endpoint.`);
234
+ }
235
+ return resolved.endpoint;
236
+ }
237
+ // video-analysis verb (request-json surface). The agent authors an opaque Gemini
238
+ // request object (contents + generationConfig) in `--request <file>`; capability,
239
+ // operation, and modelKey come from the verb + positional, so the body posts
240
+ // EXACTLY the locked Web contract. There is no field classification and no
241
+ // estimatedUsage — the payload is forwarded verbatim as the Gemini request.
242
+ async function runVideoAnalysisVerb(args) {
243
+ const { modelKey, resolved, verb } = args;
244
+ const flags = parseFlags(args.args, new Set(['json']));
245
+ const allowedKeys = new Set([
246
+ 'hosted-operation-id',
247
+ 'json',
248
+ 'output',
249
+ 'quote-confirmation-token',
250
+ 'request',
251
+ 'skill',
252
+ ]);
253
+ for (const key of [...flags.values.keys(), ...flags.booleans]) {
254
+ if (!allowedKeys.has(key)) {
255
+ throw new Error(`Unknown option for media ${verb}: --${key}.`);
256
+ }
257
+ }
258
+ const requestPath = requireFlag(flags, 'request');
259
+ const outputPath = flags.values.get('output') ?? null;
260
+ const raw = await readJsonFile(requestPath);
261
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
262
+ throw new Error(`media ${verb} ${modelKey} --request must be a JSON object of Gemini request payload.`);
263
+ }
264
+ const payload = raw;
265
+ const body = {
266
+ capability: 'video-analysis',
267
+ operation: 'analyze',
268
+ modelKey,
269
+ payload,
270
+ operationId: flags.values.get('hosted-operation-id') ??
271
+ `postplus-cli:media:video-analysis:analyze:${randomUUID()}`,
272
+ quoteConfirmationToken: flags.values.get('quote-confirmation-token') ?? undefined,
273
+ };
274
+ return runHostedCommand({
275
+ request: () => postHostedJson({
276
+ body,
277
+ pathName: '/api/postplus-cli/hosted/capability',
278
+ skillName: flags.values.get('skill') ?? resolved.skill,
279
+ }),
280
+ errorInputLabel: requestPath,
281
+ json: flags.booleans.has('json'),
282
+ outputPath,
283
+ });
284
+ }
285
+ // `media-file upload`: the generic local-file -> hosted storageReference verb.
286
+ // Released skills ship no scripts, so any skill that must place a local file
287
+ // behind a hosted reference before a hosted request (e.g. video-analysis before
288
+ // `media analyze`) drives it through this verb. It is capability-generic: it has
289
+ // no knowledge of any one skill's request payload, it only mints an opaque
290
+ // storageReference the agent then embeds in its own hosted request. The runner
291
+ // holds no provider keys — it asks the Web boundary for a signed upload target,
292
+ // PUTs the bytes, and returns the storageReference the Web boundary issued.
293
+ const MEDIA_FILE_MIME_BY_EXTENSION = {
294
+ '.gif': 'image/gif',
295
+ '.jpeg': 'image/jpeg',
296
+ '.jpg': 'image/jpeg',
297
+ '.m4a': 'audio/mp4',
298
+ '.m4v': 'video/mp4',
299
+ '.mov': 'video/quicktime',
300
+ '.mp3': 'audio/mpeg',
301
+ '.mp4': 'video/mp4',
302
+ '.png': 'image/png',
303
+ '.wav': 'audio/wav',
304
+ '.webm': 'video/webm',
305
+ '.webp': 'image/webp',
306
+ };
307
+ function inferUploadMimeType(filePath) {
308
+ return (MEDIA_FILE_MIME_BY_EXTENSION[path.extname(filePath).toLowerCase()] ??
309
+ 'application/octet-stream');
310
+ }
311
+ export async function runMediaFileCommand(args) {
312
+ const [subcommand, ...rest] = args;
313
+ if (subcommand === 'upload') {
314
+ return runMediaFileUpload(rest);
315
+ }
316
+ printMediaFileHelp();
317
+ return subcommand === undefined || isHelp(subcommand) ? 0 : 1;
318
+ }
319
+ async function runMediaFileUpload(args) {
50
320
  const flags = parseFlags(args, new Set(['json']));
51
- const runHandle = flags.values.get('run-handle');
321
+ const allowedKeys = new Set([
322
+ 'hosted-operation-id',
323
+ 'input-file',
324
+ 'json',
325
+ 'mime',
326
+ 'output',
327
+ 'quote-confirmation-token',
328
+ 'skill',
329
+ ]);
330
+ for (const key of [...flags.values.keys(), ...flags.booleans]) {
331
+ if (!allowedKeys.has(key)) {
332
+ throw new Error(`Unknown option for media-file upload: --${key}.`);
333
+ }
334
+ }
335
+ const inputFile = requireFlag(flags, 'input-file');
336
+ const absolutePath = path.resolve(inputFile);
337
+ const fileStat = await stat(absolutePath);
338
+ if (!fileStat.isFile()) {
339
+ throw new Error(`media-file upload source is not a file: ${absolutePath}`);
340
+ }
341
+ const mimeType = flags.values.get('mime') ?? inferUploadMimeType(absolutePath);
52
342
  const outputPath = flags.values.get('output') ?? null;
53
- if (runHandle) {
54
- const payload = await postHostedJson({
55
- body: { runHandle },
56
- pathName: '/api/postplus-cli/hosted/collection',
57
- skillName: null,
343
+ const body = {
344
+ capability: 'media-file',
345
+ operation: 'create-upload-url',
346
+ file: {
347
+ mimeType,
348
+ name: path.basename(absolutePath),
349
+ sizeBytes: fileStat.size,
350
+ },
351
+ operationId: flags.values.get('hosted-operation-id') ??
352
+ `postplus-cli:media-file:create-upload-url:${randomUUID()}`,
353
+ quoteConfirmationToken: flags.values.get('quote-confirmation-token') ?? undefined,
354
+ };
355
+ return runHostedCommand({
356
+ request: async () => {
357
+ const payload = await postHostedJson({
358
+ body,
359
+ pathName: '/api/postplus-cli/hosted/capability',
360
+ skillName: flags.values.get('skill') ?? null,
361
+ });
362
+ const output = readHostedUploadOutput(payload);
363
+ const signedUpload = readSignedUpload(output);
364
+ await putHostedMediaBytes(signedUpload, absolutePath);
365
+ return { storageReference: readStorageReferenceValue(output) };
366
+ },
367
+ errorInputLabel: inputFile,
368
+ json: flags.booleans.has('json'),
369
+ outputPath,
370
+ });
371
+ }
372
+ function readHostedUploadOutput(payload) {
373
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
374
+ const output = payload.output;
375
+ if (output && typeof output === 'object' && !Array.isArray(output)) {
376
+ return output;
377
+ }
378
+ }
379
+ throw new Error('Hosted media upload response is missing output.');
380
+ }
381
+ function readSignedUpload(output) {
382
+ const signedUpload = output.signedUpload;
383
+ if (!signedUpload ||
384
+ typeof signedUpload !== 'object' ||
385
+ Array.isArray(signedUpload)) {
386
+ throw new Error('Hosted media upload response is missing signedUpload.');
387
+ }
388
+ const record = signedUpload;
389
+ if (typeof record.url !== 'string' || !record.url.trim()) {
390
+ throw new Error('Hosted media upload signedUpload.url must be a string.');
391
+ }
392
+ if (record.method !== 'PUT') {
393
+ throw new Error(`Unsupported hosted media signed upload method: ${String(record.method)}.`);
394
+ }
395
+ const requiredHeaders = {};
396
+ if (record.requiredHeaders &&
397
+ typeof record.requiredHeaders === 'object' &&
398
+ !Array.isArray(record.requiredHeaders)) {
399
+ for (const [key, value] of Object.entries(record.requiredHeaders)) {
400
+ if (typeof value !== 'string') {
401
+ throw new Error(`Hosted media upload signedUpload.requiredHeaders.${key} must be a string.`);
402
+ }
403
+ requiredHeaders[key] = value;
404
+ }
405
+ }
406
+ return { method: record.method, requiredHeaders, url: record.url.trim() };
407
+ }
408
+ function readStorageReferenceValue(output) {
409
+ const storageReference = output.storageReference;
410
+ if (!storageReference ||
411
+ typeof storageReference !== 'object' ||
412
+ Array.isArray(storageReference)) {
413
+ throw new Error('Hosted media upload response is missing storageReference.');
414
+ }
415
+ return storageReference;
416
+ }
417
+ async function putHostedMediaBytes(signedUpload, absolutePath) {
418
+ const response = await fetch(signedUpload.url, {
419
+ body: createReadStream(absolutePath),
420
+ duplex: 'half',
421
+ headers: signedUpload.requiredHeaders,
422
+ method: 'PUT',
423
+ signal: AbortSignal.timeout(120000),
424
+ });
425
+ if (!response.ok) {
426
+ throw new Error(`Hosted media signed upload failed with status ${response.status}.`);
427
+ }
428
+ }
429
+ function printMediaFileHelp() {
430
+ process.stdout.write(`PostPlus CLI - media-file commands
431
+
432
+ Usage:
433
+ postplus media-file upload --input-file <path> [--mime <type>] [--skill <skill-id>] [--json] [--output <result.json>]
434
+ `);
435
+ }
436
+ // Shared submit path for both surfaces: wrap the media input, derive billing
437
+ // dimensions from endpointKey + input, and POST to the Web boundary.
438
+ function submitMediaGenerationRequest(params) {
439
+ const body = {
440
+ capability: params.capability,
441
+ endpointKey: params.endpointKey,
442
+ input: params.input,
443
+ operation: 'request',
444
+ operationId: params.operationId,
445
+ quoteConfirmationToken: params.quoteConfirmationToken ?? undefined,
446
+ requestDimensions: buildMediaGenerationRequestDimensions(params.endpointKey, params.input),
447
+ };
448
+ return runHostedCommand({
449
+ request: () => postHostedJson({
450
+ body,
451
+ pathName: '/api/postplus-cli/hosted/capability',
452
+ skillName: params.skillName,
453
+ }),
454
+ errorInputLabel: params.errorInputLabel,
455
+ json: params.json,
456
+ outputPath: params.outputPath,
457
+ });
458
+ }
459
+ function buildMediaVerbInput(input) {
460
+ const record = {};
461
+ for (const field of input.fields) {
462
+ if (field.class === 'runner-managed' || !field.flag) {
463
+ continue;
464
+ }
465
+ const key = field.flag.replace(/^--/u, '');
466
+ if (field.repeatable) {
467
+ const list = input.flags.arrays.get(key) ?? [];
468
+ if (list.length === 0) {
469
+ if (field.required) {
470
+ throw new Error(`Missing required option --${key} for media ${input.verb} ${input.endpointKey}.`);
471
+ }
472
+ continue;
473
+ }
474
+ record[field.name] = list;
475
+ continue;
476
+ }
477
+ if (field.type === 'boolean') {
478
+ if (input.flags.booleans.has(key)) {
479
+ record[field.name] = true;
480
+ }
481
+ else if (typeof field.default === 'boolean') {
482
+ record[field.name] = field.default;
483
+ }
484
+ continue;
485
+ }
486
+ const raw = input.flags.values.get(key);
487
+ if (raw === undefined) {
488
+ if (field.class === 'default' && field.default !== undefined) {
489
+ record[field.name] = field.default;
490
+ }
491
+ else if (field.required) {
492
+ throw new Error(`Missing required option --${key} for media ${input.verb} ${input.endpointKey}.`);
493
+ }
494
+ continue;
495
+ }
496
+ if (field.enumValues && !field.enumValues.includes(raw)) {
497
+ throw new Error(`--${key} must be one of ${field.enumValues.join(', ')}.`);
498
+ }
499
+ if (field.type === 'number') {
500
+ const parsed = Number(raw);
501
+ if (!Number.isFinite(parsed) || parsed <= 0) {
502
+ throw new Error(`--${key} must be a positive number.`);
503
+ }
504
+ record[field.name] = parsed;
505
+ }
506
+ else {
507
+ record[field.name] = raw;
508
+ }
509
+ }
510
+ return record;
511
+ }
512
+ // Manifest-driven hosted-collection verb (request-json surface). The polling path
513
+ // (`--run-handle`) resumes a pending run unchanged. The launch path resolves the
514
+ // positional `<collectionKey>` against the research verb index for verb `collect`,
515
+ // reads the collection input object directly from `--request <file>` (NOT a
516
+ // schemaVersion envelope), and posts to /hosted/collection. The resolved entry
517
+ // gives the default skillName (overridable by `--skill`); the actorId stays
518
+ // internal and is never placed on the public body.
519
+ async function runResearchCollect(args) {
520
+ const [first, ...rest] = args;
521
+ // Polling path: `research collect --run-handle <h>`. No positional collectionKey.
522
+ if (!first || first.startsWith('--')) {
523
+ const flags = parseFlags(args, new Set(['json']));
524
+ const runHandle = requireFlag(flags, 'run-handle');
525
+ const outputPath = flags.values.get('output') ?? null;
526
+ return runHostedCommand({
527
+ request: () => postHostedJson({
528
+ body: { runHandle },
529
+ pathName: '/api/postplus-cli/hosted/collection',
530
+ skillName: null,
531
+ }),
532
+ errorInputLabel: 'research-collect-run-handle',
533
+ json: flags.booleans.has('json'),
534
+ outputPath,
58
535
  });
59
- await writeResult(payload, outputPath, flags.booleans.has('json'));
536
+ }
537
+ const verb = 'collect';
538
+ const collectionKey = first;
539
+ const targets = RESEARCH_VERB_TARGETS.get(verb);
540
+ const resolved = targets?.get(collectionKey);
541
+ if (!resolved) {
542
+ const valid = targets ? [...targets.keys()].join(', ') : '';
543
+ throw new Error(`Unknown research collect collection ${collectionKey}. Valid: ${valid}.`);
544
+ }
545
+ // `postplus research collect <collection-key> --help`: opaque-input contract.
546
+ if (rest.some(isHelp)) {
547
+ printOpaqueTargetHelp('research', verb, collectionKey, resolved);
60
548
  return 0;
61
549
  }
62
- const skillName = requireFlag(flags, 'skill');
63
- const collectionKey = requireFlag(flags, 'collection-key');
64
- const inputPath = requireFlag(flags, 'input');
65
- const envelope = readHostedEnvelope(await readJsonFile(inputPath), inputPath);
550
+ const flags = parseFlags(rest, new Set(['json']));
551
+ const allowedKeys = new Set([
552
+ 'hosted-operation-id',
553
+ 'json',
554
+ 'max-charge-usd',
555
+ 'output',
556
+ 'quote-confirmation-token',
557
+ 'request',
558
+ 'skill',
559
+ ]);
560
+ for (const key of [...flags.values.keys(), ...flags.booleans]) {
561
+ if (!allowedKeys.has(key)) {
562
+ throw new Error(`Unknown option for research ${verb}: --${key}.`);
563
+ }
564
+ }
565
+ const requestPath = requireFlag(flags, 'request');
566
+ const outputPath = flags.values.get('output') ?? null;
567
+ const raw = await readJsonFile(requestPath);
568
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
569
+ throw new Error(`research ${verb} ${collectionKey} --request must be a JSON object of collection input.`);
570
+ }
571
+ const input = raw;
572
+ const skillName = flags.values.get('skill') ?? resolved.skill;
66
573
  const operationId = flags.values.get('hosted-operation-id') ??
67
- normalizeString(envelope.hostedOperationId) ??
68
- normalizeString(envelope.operationId) ??
69
- `postplus-cli:research:${collectionKey}:${randomUUID()}`;
70
- const quoteConfirmationToken = flags.values.get('quote-confirmation-token') ??
71
- normalizeString(envelope.quoteConfirmationToken);
72
- const payload = await postHostedJson({
73
- body: {
74
- collectionKey,
75
- input: envelope.input,
76
- operationId,
77
- quoteConfirmationToken: quoteConfirmationToken ?? undefined,
574
+ `postplus-cli:research:collect:${collectionKey}:${randomUUID()}`;
575
+ const quoteConfirmationToken = flags.values.get('quote-confirmation-token');
576
+ // Optional per-request cost ceiling (USD) overriding the hosted default.
577
+ const maxChargeFlag = flags.values.get('max-charge-usd');
578
+ let maxTotalChargeUsd;
579
+ if (maxChargeFlag !== undefined) {
580
+ const parsed = Number(maxChargeFlag);
581
+ if (!Number.isFinite(parsed) || parsed <= 0) {
582
+ throw new Error('--max-charge-usd must be a positive number of USD.');
583
+ }
584
+ maxTotalChargeUsd = parsed;
585
+ }
586
+ return runHostedCommand({
587
+ request: () => postHostedJson({
588
+ body: {
589
+ collectionKey,
590
+ input,
591
+ operationId,
592
+ quoteConfirmationToken: quoteConfirmationToken ?? undefined,
593
+ skillName,
594
+ maxTotalChargeUsd,
595
+ },
596
+ pathName: '/api/postplus-cli/hosted/collection',
78
597
  skillName,
79
- },
80
- pathName: '/api/postplus-cli/hosted/collection',
81
- skillName,
82
- }).catch((error) => buildHostedCommandError(error, {
83
- inputPath,
598
+ }),
599
+ errorInputLabel: requestPath,
600
+ json: flags.booleans.has('json'),
84
601
  outputPath,
85
- }));
86
- await writeResult(payload, outputPath, flags.booleans.has('json'));
87
- return 0;
602
+ });
603
+ }
604
+ // Manifest-driven public-content-collection verb (request-json surface). Resolves
605
+ // the positional `<sourceKey>` against the research verb index for verb `scrape`,
606
+ // reads the scrape input directly from `--request <file>` as a JSON ARRAY of input
607
+ // records (one per public URL/query), and posts to /hosted/capability with
608
+ // capability `public-content-collection` / operation `scrape`. The resolved entry
609
+ // gives the default skillName (overridable by `--skill`); the datasetId stays
610
+ // internal and is never placed on the public body.
611
+ async function runResearchScrape(args) {
612
+ const [first, ...rest] = args;
613
+ const verb = 'scrape';
614
+ const targets = RESEARCH_VERB_TARGETS.get(verb);
615
+ if (!first || first.startsWith('--')) {
616
+ const valid = targets ? [...targets.keys()].join(', ') : '';
617
+ throw new Error(`postplus research ${verb} requires a source key. Valid: ${valid}.`);
618
+ }
619
+ const sourceKey = first;
620
+ const resolved = targets?.get(sourceKey);
621
+ if (!resolved) {
622
+ const valid = targets ? [...targets.keys()].join(', ') : '';
623
+ throw new Error(`Unknown research scrape source ${sourceKey}. Valid: ${valid}.`);
624
+ }
625
+ // `postplus research scrape <source-key> --help`: opaque-array-input contract.
626
+ if (rest.some(isHelp)) {
627
+ printOpaqueTargetHelp('research', verb, sourceKey, resolved);
628
+ return 0;
629
+ }
630
+ const flags = parseFlags(rest, new Set(['json']));
631
+ const allowedKeys = new Set([
632
+ 'hosted-operation-id',
633
+ 'json',
634
+ 'max-charge-usd',
635
+ 'output',
636
+ 'quote-confirmation-token',
637
+ 'request',
638
+ 'skill',
639
+ ]);
640
+ for (const key of [...flags.values.keys(), ...flags.booleans]) {
641
+ if (!allowedKeys.has(key)) {
642
+ throw new Error(`Unknown option for research ${verb}: --${key}.`);
643
+ }
644
+ }
645
+ const requestPath = requireFlag(flags, 'request');
646
+ const outputPath = flags.values.get('output') ?? null;
647
+ const raw = await readJsonFile(requestPath);
648
+ if (!Array.isArray(raw) || raw.length === 0) {
649
+ throw new Error(`research ${verb} ${sourceKey} --request must be a non-empty JSON array of scrape input records.`);
650
+ }
651
+ const input = raw;
652
+ const skillName = flags.values.get('skill') ?? resolved.skill;
653
+ const operationId = flags.values.get('hosted-operation-id') ??
654
+ `postplus-cli:research:scrape:${sourceKey}:${randomUUID()}`;
655
+ const quoteConfirmationToken = flags.values.get('quote-confirmation-token');
656
+ // Optional per-request cost ceiling (USD) overriding the hosted default.
657
+ const maxChargeFlag = flags.values.get('max-charge-usd');
658
+ let maxTotalChargeUsd;
659
+ if (maxChargeFlag !== undefined) {
660
+ const parsed = Number(maxChargeFlag);
661
+ if (!Number.isFinite(parsed) || parsed <= 0) {
662
+ throw new Error('--max-charge-usd must be a positive number of USD.');
663
+ }
664
+ maxTotalChargeUsd = parsed;
665
+ }
666
+ // The Web /hosted/capability scrape contract is a strict object: skillName is
667
+ // carried as the compatibility header (postHostedJson), never on the public body.
668
+ return runHostedCommand({
669
+ request: () => postHostedJson({
670
+ body: {
671
+ capability: 'public-content-collection',
672
+ operation: 'scrape',
673
+ sourceKey,
674
+ input,
675
+ operationId,
676
+ quoteConfirmationToken: quoteConfirmationToken ?? undefined,
677
+ maxTotalChargeUsd,
678
+ },
679
+ pathName: '/api/postplus-cli/hosted/capability',
680
+ skillName,
681
+ }),
682
+ errorInputLabel: requestPath,
683
+ json: flags.booleans.has('json'),
684
+ outputPath,
685
+ });
686
+ }
687
+ // Manifest-driven publish operation (request-json surface). The OPERATION is the
688
+ // subcommand and the target: `postplus publish <operation> --request <file>`. The
689
+ // publishing input object is read directly from `--request <file>` and posted to
690
+ // /hosted/capability with capability `social-publishing` / the resolved operation.
691
+ // Side-effecting operations surface the Web quote-confirmation challenge; the
692
+ // shared runHostedCommand handles the challenge -> retry-with-token path. There is
693
+ // no requestDimensions/approval/execute — those were private-runtime concepts.
694
+ async function runPublishOperation(operation, args) {
695
+ const resolved = PUBLISH_VERB_OPERATIONS.get(operation);
696
+ if (!resolved) {
697
+ throw new Error(`Unknown publish operation ${operation}. Valid: ${[...PUBLISH_VERB_OPERATIONS.keys()].join(', ')}.`);
698
+ }
699
+ // `postplus publish <operation> --help`: opaque-input contract.
700
+ if (args.some(isHelp)) {
701
+ printOpaqueTargetHelp('publish', operation, operation, resolved);
702
+ return 0;
703
+ }
704
+ const flags = parseFlags(args, new Set(['json']));
705
+ const allowedKeys = new Set([
706
+ 'hosted-operation-id',
707
+ 'json',
708
+ 'output',
709
+ 'quote-confirmation-token',
710
+ 'request',
711
+ 'skill',
712
+ ]);
713
+ for (const key of [...flags.values.keys(), ...flags.booleans]) {
714
+ if (!allowedKeys.has(key)) {
715
+ throw new Error(`Unknown option for publish ${operation}: --${key}.`);
716
+ }
717
+ }
718
+ const requestPath = requireFlag(flags, 'request');
719
+ const outputPath = flags.values.get('output') ?? null;
720
+ const raw = await readJsonFile(requestPath);
721
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
722
+ throw new Error(`publish ${operation} --request must be a JSON object of publishing input.`);
723
+ }
724
+ const input = raw;
725
+ const skillName = flags.values.get('skill') ?? resolved.skill;
726
+ const operationId = flags.values.get('hosted-operation-id') ??
727
+ `postplus-cli:publish:social-publishing:request:${randomUUID()}`;
728
+ const quoteConfirmationToken = flags.values.get('quote-confirmation-token');
729
+ return runHostedCommand({
730
+ request: () => postHostedJson({
731
+ body: {
732
+ capability: 'social-publishing',
733
+ operation,
734
+ input,
735
+ operationId,
736
+ quoteConfirmationToken: quoteConfirmationToken ?? undefined,
737
+ },
738
+ pathName: '/api/postplus-cli/hosted/capability',
739
+ skillName,
740
+ }),
741
+ errorInputLabel: requestPath,
742
+ json: flags.booleans.has('json'),
743
+ outputPath,
744
+ });
88
745
  }
89
746
  async function runHostedSchema(domain, args) {
90
747
  const flags = parseFlags(args, new Set(['json']));
@@ -105,65 +762,6 @@ async function runHostedSchema(domain, args) {
105
762
  }));
106
763
  return 0;
107
764
  }
108
- async function runHostedCapability(domain, args) {
109
- const flags = parseFlags(args, new Set(['json']));
110
- const requestPath = requireFlag(flags, 'request');
111
- const outputPath = flags.values.get('output') ?? null;
112
- const request = await readJsonFile(requestPath);
113
- if (!request || typeof request !== 'object' || Array.isArray(request)) {
114
- throw new Error(`Hosted ${domain} capability request must be a JSON object.`);
115
- }
116
- const record = request;
117
- const capability = requireDomainCapability(record, domain);
118
- const operation = requireRecordString(record, 'operation');
119
- const operationId = flags.values.get('hosted-operation-id') ??
120
- normalizeString(record.operationId) ??
121
- `postplus-cli:${domain}:${capability}:${operation}:${randomUUID()}`;
122
- const quoteConfirmationToken = flags.values.get('quote-confirmation-token') ??
123
- normalizeString(record.quoteConfirmationToken);
124
- const publicRecord = { ...record };
125
- delete publicRecord.skillName;
126
- const derivedFields = buildDerivedHostedCapabilityFields({
127
- capability,
128
- domain,
129
- operation,
130
- record,
131
- });
132
- const body = {
133
- ...publicRecord,
134
- ...derivedFields,
135
- capability,
136
- operation,
137
- operationId,
138
- quoteConfirmationToken: quoteConfirmationToken ?? undefined,
139
- };
140
- const skillName = flags.values.get('skill') ?? normalizeString(record.skillName);
141
- const payload = await postHostedJson({
142
- body,
143
- pathName: '/api/postplus-cli/hosted/capability',
144
- skillName,
145
- }).catch((error) => buildHostedCommandError(error, {
146
- inputPath: requestPath,
147
- outputPath,
148
- }));
149
- await writeResult(payload, outputPath, flags.booleans.has('json'));
150
- return 0;
151
- }
152
- function buildDerivedHostedCapabilityFields(input) {
153
- if (input.domain !== 'media' ||
154
- input.capability !== 'media-generation' ||
155
- input.operation !== 'request') {
156
- return {};
157
- }
158
- if (Object.hasOwn(input.record, 'requestDimensions')) {
159
- throw new Error('Hosted media-generation request must not include requestDimensions. The CLI derives billing dimensions from endpointKey and input.');
160
- }
161
- const endpointKey = requireRecordString(input.record, 'endpointKey');
162
- const mediaInput = requireRecordObject(input.record, 'input');
163
- return {
164
- requestDimensions: buildMediaGenerationRequestDimensions(endpointKey, mediaInput),
165
- };
166
- }
167
765
  async function postHostedJson(input) {
168
766
  let auth = await resolveFreshRemoteAuth();
169
767
  let response = await postJson({
@@ -185,36 +783,61 @@ async function postHostedJson(input) {
185
783
  }
186
784
  const payload = await readJsonResponse(response);
187
785
  if (!response.ok) {
786
+ const productError = readHostedProductError(payload);
188
787
  const challenge = readLargeCreditQuoteConfirmationChallenge(payload);
189
788
  if (challenge) {
190
- throw new HostedQuoteConfirmationRequiredError(readProductError(payload), challenge);
789
+ throw new HostedQuoteConfirmationRequiredError(productError.message, challenge);
191
790
  }
192
791
  const compatibilityError = formatPostPlusCompatibilityError(payload);
193
792
  if (compatibilityError) {
194
793
  throw new Error(compatibilityError);
195
794
  }
196
- throw new Error(readProductError(payload));
795
+ throw new HostedProductRequestError(productError);
197
796
  }
198
797
  return payload;
199
798
  }
200
- async function buildHostedCommandError(error, input) {
201
- if (!(error instanceof HostedQuoteConfirmationRequiredError)) {
799
+ // Single exit path for every hosted command: success writes the result and
800
+ // returns 0; a quote challenge writes the challenge file and rethrows actionable
801
+ // guidance; a structured product error writes the full error envelope to the
802
+ // result JSON and surfaces code/layer/operationId on the terminal, exiting 1.
803
+ async function runHostedCommand(input) {
804
+ let payload;
805
+ try {
806
+ payload = await input.request();
807
+ }
808
+ catch (error) {
809
+ if (error instanceof HostedQuoteConfirmationRequiredError) {
810
+ const challengePath = await writeQuoteConfirmationChallenge(error, {
811
+ errorInputLabel: input.errorInputLabel,
812
+ outputPath: input.outputPath,
813
+ });
814
+ throw new Error([
815
+ error.message,
816
+ `Quote confirmation challenge: ${challengePath}`,
817
+ `Confirm: postplus quote confirm --json --challenge-file "${challengePath}"`,
818
+ 'Then rerun the hosted command with --quote-confirmation-token <token>.',
819
+ ].join('\n'));
820
+ }
821
+ if (error instanceof HostedProductRequestError) {
822
+ await writeResult({ error: error.productError }, input.outputPath, input.json);
823
+ process.stderr.write(`${error.message}\n`);
824
+ return 1;
825
+ }
202
826
  throw error;
203
827
  }
828
+ await writeResult(payload, input.outputPath, input.json);
829
+ return 0;
830
+ }
831
+ async function writeQuoteConfirmationChallenge(error, input) {
204
832
  const challengePath = path.resolve(input.outputPath
205
833
  ? `${input.outputPath}.quote-confirmation.json`
206
- : `${input.inputPath}.quote-confirmation.json`);
834
+ : `${input.errorInputLabel}.quote-confirmation.json`);
207
835
  await mkdir(path.dirname(challengePath), { recursive: true });
208
836
  await writeFile(challengePath, `${JSON.stringify(error.challenge, null, 2)}\n`, {
209
837
  encoding: 'utf8',
210
838
  mode: 0o600,
211
839
  });
212
- throw new Error([
213
- error.message,
214
- `Quote confirmation challenge: ${challengePath}`,
215
- `Confirm: postplus quote confirm --json --challenge-file "${challengePath}"`,
216
- 'Then rerun the hosted command with --quote-confirmation-token <token>.',
217
- ].join('\n'));
840
+ return challengePath;
218
841
  }
219
842
  async function postJson(input) {
220
843
  const headers = await buildPostPlusClientCompatibilityHeaders({
@@ -244,17 +867,31 @@ async function readJsonResponse(response) {
244
867
  throw new Error('PostPlus Cloud returned invalid JSON.');
245
868
  }
246
869
  }
247
- function readProductError(payload) {
248
- if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
249
- const record = payload;
250
- if (typeof record.error === 'string' && record.error.trim()) {
251
- return record.error.trim();
252
- }
253
- if (typeof record.message === 'string' && record.message.trim()) {
254
- return record.message.trim();
255
- }
256
- }
257
- return 'PostPlus hosted capability request failed.';
870
+ function readHostedProductError(payload) {
871
+ const record = payload && typeof payload === 'object' && !Array.isArray(payload)
872
+ ? payload
873
+ : {};
874
+ return {
875
+ message: normalizeString(record.error) ??
876
+ normalizeString(record.message) ??
877
+ 'PostPlus hosted capability request failed.',
878
+ code: normalizeString(record.code) ?? normalizeString(record.productErrorCode),
879
+ layer: normalizeString(record.layer),
880
+ operationId: normalizeString(record.operationId),
881
+ userMessageRule: normalizeString(record.userMessageRule),
882
+ };
883
+ }
884
+ // Terminal message that keeps the stable code, owning layer, and operation id
885
+ // visible next to the human-readable message so a failed run is locatable.
886
+ function formatHostedProductErrorMessage(productError) {
887
+ const locator = [
888
+ productError.code ? `code=${productError.code}` : null,
889
+ productError.layer ? `layer=${productError.layer}` : null,
890
+ productError.operationId ? `operationId=${productError.operationId}` : null,
891
+ ].filter((part) => part !== null);
892
+ return locator.length > 0
893
+ ? `${productError.message} (${locator.join(' ')})`
894
+ : productError.message;
258
895
  }
259
896
  async function readJsonFile(filePath) {
260
897
  try {
@@ -266,16 +903,6 @@ async function readJsonFile(filePath) {
266
903
  : `Failed to read JSON file ${filePath}.`);
267
904
  }
268
905
  }
269
- function readHostedEnvelope(value, filePath) {
270
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
271
- throw new Error(`${filePath} must be a schemaVersion 1 hosted envelope.`);
272
- }
273
- const envelope = value;
274
- if (envelope.schemaVersion !== 1 || !Object.hasOwn(envelope, 'input')) {
275
- throw new Error(`${filePath} must be a schemaVersion 1 hosted envelope.`);
276
- }
277
- return envelope;
278
- }
279
906
  async function writeResult(payload, outputPath, forceStdout) {
280
907
  const text = `${JSON.stringify(payload, null, 2)}\n`;
281
908
  if (!outputPath || forceStdout) {
@@ -286,9 +913,10 @@ async function writeResult(payload, outputPath, forceStdout) {
286
913
  await writeFile(outputPath, text);
287
914
  }
288
915
  }
289
- function parseFlags(args, booleanFlags) {
916
+ function parseFlags(args, booleanFlags, arrayFlags = new Set()) {
290
917
  const values = new Map();
291
918
  const booleans = new Set();
919
+ const arrays = new Map();
292
920
  for (let index = 0; index < args.length; index += 1) {
293
921
  const arg = args[index];
294
922
  if (!arg.startsWith('--')) {
@@ -303,10 +931,17 @@ function parseFlags(args, booleanFlags) {
303
931
  if (!value || value.startsWith('--')) {
304
932
  throw new Error(`Missing value for --${key}.`);
305
933
  }
306
- values.set(key, value);
934
+ if (arrayFlags.has(key)) {
935
+ const list = arrays.get(key) ?? [];
936
+ list.push(value);
937
+ arrays.set(key, list);
938
+ }
939
+ else {
940
+ values.set(key, value);
941
+ }
307
942
  index += 1;
308
943
  }
309
- return { booleans, values };
944
+ return { arrays, booleans, values };
310
945
  }
311
946
  function requireFlag(flags, key) {
312
947
  const value = flags.values.get(key);
@@ -315,28 +950,6 @@ function requireFlag(flags, key) {
315
950
  }
316
951
  return value;
317
952
  }
318
- function requireDomainCapability(record, domain) {
319
- const capability = requireRecordString(record, 'capability');
320
- const allowed = HOSTED_DOMAIN_CAPABILITIES[domain];
321
- if (!allowed.has(capability)) {
322
- throw new Error(`Hosted ${domain} capability request uses unsupported capability ${capability}.`);
323
- }
324
- return capability;
325
- }
326
- function requireRecordString(record, key) {
327
- const value = normalizeString(record[key]);
328
- if (!value) {
329
- throw new Error(`Hosted capability request must include string ${key}.`);
330
- }
331
- return value;
332
- }
333
- function requireRecordObject(record, key) {
334
- const value = record[key];
335
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
336
- throw new Error(`Hosted capability request must include object ${key}.`);
337
- }
338
- return value;
339
- }
340
953
  function normalizeString(value) {
341
954
  return typeof value === 'string' && value.trim() ? value.trim() : null;
342
955
  }
@@ -348,17 +961,136 @@ function printResearchHelp() {
348
961
 
349
962
  Usage:
350
963
  postplus research schema [--collection-key <key>] [--json]
351
- postplus research collect --skill <skill-id> --collection-key <key> --input <hosted-envelope.json> [--output <result.json>]
964
+ postplus research collect <collection-key> --request <input.json> [--skill <skill-id>] [--max-charge-usd <usd>] [--output <result.json>]
352
965
  postplus research collect --run-handle <runHandle> [--output <result.json>]
353
- postplus research capability --request <hosted-capability-request.json> [--output <result.json>]
966
+ postplus research scrape <source-key> --request <input-array.json> [--skill <skill-id>] [--max-charge-usd <usd>] [--output <result.json>]
354
967
  `);
355
968
  }
356
- function printCapabilityHelp(domain) {
969
+ function printDomainVerbHelp(domain) {
970
+ const verbUsage = domain === 'media'
971
+ ? [...MEDIA_VERB_ENDPOINTS.keys()]
972
+ .map((verb) => ` postplus media ${verb} <endpoint-key> --<intent/default flags> [--json] [--output <result.json>]\n`)
973
+ .join('')
974
+ : ' postplus publish <operation> --request <input.json> [--json] [--output <result.json>]\n';
357
975
  process.stdout.write(`PostPlus CLI - ${domain} commands
358
976
 
359
977
  Usage:
360
- postplus ${domain} schema${domain === 'media' ? ' [--endpoint <endpoint-key>]' : ''} [--json]
361
- postplus ${domain} capability --request <hosted-capability-request.json> [--output <result.json>]
978
+ ${verbUsage} postplus ${domain} schema${domain === 'media' ? ' [--endpoint <endpoint-key>]' : ''} [--json]
979
+ `);
980
+ }
981
+ // Per-endpoint `--help` for a media-generation endpoint (and the video-analysis
982
+ // model). Renders the endpoint's field-level contract grouped into the envelope's
983
+ // three classes — intent (you write it), default (manifest-defaulted; write only
984
+ // to deviate), runner-managed (minted by the CLI; never an input) — using the
985
+ // manifest as the SSOT for flags, enum sets, ranges, and defaults.
986
+ function printMediaEndpointHelp(domain, verb, targetKey, resolved) {
987
+ // video-analysis: opaque Gemini payload, no field classification to render.
988
+ if (resolved.capability === 'video-analysis') {
989
+ process.stdout.write(`PostPlus CLI - ${domain} ${verb} ${targetKey}
990
+
991
+ Surface: request-json (opaque Gemini request payload)
992
+ Usage:
993
+ postplus ${domain} ${verb} ${targetKey} --request <input.json> [--json] [--output <result.json>]
994
+
995
+ --request <file> A JSON object authored verbatim as the Gemini request
996
+ (contents + optional generationConfig) under "payload".
997
+ Runner-managed (minted by the CLI; never in the body): operationId, quoteConfirmationToken
998
+ `);
999
+ return;
1000
+ }
1001
+ if (!resolved.endpoint) {
1002
+ throw new Error(`media ${verb} ${targetKey} resolved to a non-endpoint target.`);
1003
+ }
1004
+ const fields = resolved.endpoint.fields;
1005
+ const isFlagsSurface = resolved.surface === 'flags';
1006
+ const intent = fields.filter((field) => field.class === 'intent');
1007
+ const defaulted = fields.filter((field) => field.class === 'default');
1008
+ const managed = fields.filter((field) => field.class === 'runner-managed');
1009
+ const lines = [
1010
+ `PostPlus CLI - ${domain} ${verb} ${targetKey}`,
1011
+ '',
1012
+ ` Surface: ${resolved.surface}`,
1013
+ ' Usage:',
1014
+ isFlagsSurface
1015
+ ? ` postplus ${domain} ${verb} ${targetKey} ${formatFlagsUsage(fields)} [--json] [--output <result.json>]`
1016
+ : ` postplus ${domain} ${verb} ${targetKey} --request <input.json> [--json] [--output <result.json>]`,
1017
+ '',
1018
+ ];
1019
+ appendFieldGroup(lines, 'Intent (you must / may write):', intent, isFlagsSurface);
1020
+ appendFieldGroup(lines, 'Default (manifest-defaulted; write only to deviate):', defaulted, isFlagsSurface);
1021
+ if (managed.length > 0) {
1022
+ lines.push(' Runner-managed (minted by the CLI; never an input):');
1023
+ for (const field of managed) {
1024
+ const derived = field.derivedFrom
1025
+ ? ` (derived from ${field.derivedFrom})`
1026
+ : '';
1027
+ lines.push(` ${field.name}${derived}`);
1028
+ }
1029
+ }
1030
+ process.stdout.write(`${lines.join('\n')}\n`);
1031
+ }
1032
+ function formatFlagsUsage(fields) {
1033
+ const parts = [];
1034
+ for (const field of fields) {
1035
+ if (field.class === 'runner-managed' || !field.flag) {
1036
+ continue;
1037
+ }
1038
+ const token = `${field.flag} <${field.name}>`;
1039
+ parts.push(field.required ? token : `[${token}]`);
1040
+ }
1041
+ return parts.join(' ');
1042
+ }
1043
+ function appendFieldGroup(lines, title, fields, isFlagsSurface) {
1044
+ if (fields.length === 0) {
1045
+ return;
1046
+ }
1047
+ lines.push(` ${title}`);
1048
+ for (const field of fields) {
1049
+ const label = isFlagsSurface && field.flag ? field.flag : `(json) ${field.name}`;
1050
+ lines.push(` ${label}${formatFieldDetail(field)}`);
1051
+ }
1052
+ lines.push('');
1053
+ }
1054
+ // Field detail: type, required/optional, enum set or numeric range, default, and
1055
+ // repeatable arity — all read from the manifest contract.
1056
+ function formatFieldDetail(field) {
1057
+ const detail = [
1058
+ field.repeatable ? `${field.type}[]` : field.type,
1059
+ field.required ? 'required' : 'optional',
1060
+ ];
1061
+ if (field.enumValues && field.enumValues.length > 0) {
1062
+ detail.push(`one of {${field.enumValues.join(', ')}}`);
1063
+ }
1064
+ else if (field.min !== undefined || field.max !== undefined) {
1065
+ detail.push(`range ${field.min ?? '-'}..${field.max ?? '-'}`);
1066
+ }
1067
+ if (field.default !== undefined) {
1068
+ detail.push(`default ${String(field.default)}`);
1069
+ }
1070
+ return ` [${detail.join('; ')}]`;
1071
+ }
1072
+ // Per-target `--help` for capabilities whose request body is an opaque JSON object
1073
+ // (research collect/scrape, publish): there is no field classification to render,
1074
+ // so the help states the input shape and the runner-managed protocol fields.
1075
+ function printOpaqueTargetHelp(domain, verb, targetKey, resolved) {
1076
+ const inputShape = resolved.capability === 'public-content-collection'
1077
+ ? 'a non-empty JSON array of provider-shaped scrape records'
1078
+ : 'a provider-shaped JSON object of input';
1079
+ // publish's operation is both the verb and the target, so the header/usage show
1080
+ // it once; research shows `<verb> <target>`.
1081
+ const header = domain === 'publish' ? `publish ${targetKey}` : `research ${verb} ${targetKey}`;
1082
+ const usage = domain === 'publish'
1083
+ ? ` postplus publish ${targetKey} --request <input.json> [--json] [--output <result.json>]`
1084
+ : ` postplus research ${verb} ${targetKey} --request <input.json> [--skill <skill-id>] [--max-charge-usd <usd>] [--json] [--output <result.json>]`;
1085
+ process.stdout.write(`PostPlus CLI - ${header}
1086
+
1087
+ Surface: request-json (opaque input authored by the agent)
1088
+ Capability: ${resolved.capability}
1089
+ Usage:
1090
+ ${usage}
1091
+
1092
+ --request <file> ${inputShape}.
1093
+ Runner-managed (minted by the CLI; never in the body): operationId, quoteConfirmationToken
362
1094
  `);
363
1095
  }
364
1096
  function writeJson(value) {