@postplus/cli 0.1.39 → 0.1.41

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