@martini-film/cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +165 -44
  2. package/dist/api.js +57 -17
  3. package/dist/api.js.map +1 -1
  4. package/dist/approve.js +2 -1
  5. package/dist/approve.js.map +1 -1
  6. package/dist/cli.js +429 -144
  7. package/dist/cli.js.map +1 -1
  8. package/dist/commands/assets.js +141 -25
  9. package/dist/commands/assets.js.map +1 -1
  10. package/dist/commands/call.js +99 -0
  11. package/dist/commands/call.js.map +1 -0
  12. package/dist/commands/context.js +15 -2
  13. package/dist/commands/context.js.map +1 -1
  14. package/dist/commands/files.js +100 -7
  15. package/dist/commands/files.js.map +1 -1
  16. package/dist/commands/guide.js +67 -0
  17. package/dist/commands/guide.js.map +1 -0
  18. package/dist/commands/init.js +196 -0
  19. package/dist/commands/init.js.map +1 -0
  20. package/dist/commands/login.js +37 -6
  21. package/dist/commands/login.js.map +1 -1
  22. package/dist/commands/tools.js +63 -78
  23. package/dist/commands/tools.js.map +1 -1
  24. package/dist/commands/use.js +143 -0
  25. package/dist/commands/use.js.map +1 -0
  26. package/dist/config.js +82 -6
  27. package/dist/config.js.map +1 -1
  28. package/dist/generated/guide.json +104 -0
  29. package/dist/generated/manifest.json +10 -2
  30. package/dist/guideCache.js +204 -0
  31. package/dist/guideCache.js.map +1 -0
  32. package/dist/help.js +375 -32
  33. package/dist/help.js.map +1 -1
  34. package/dist/manifest.js +27 -0
  35. package/dist/manifest.js.map +1 -1
  36. package/dist/manifestCache.js +196 -0
  37. package/dist/manifestCache.js.map +1 -0
  38. package/dist/output.js +210 -28
  39. package/dist/output.js.map +1 -1
  40. package/dist/schema.js +99 -14
  41. package/dist/schema.js.map +1 -1
  42. package/dist/wait.js +31 -1
  43. package/dist/wait.js.map +1 -1
  44. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -1,24 +1,33 @@
1
1
  import { mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { resolve, join } from 'node:path';
3
- import { CliApiError, createApiClient } from './api.js';
3
+ import { CliApiError, createApiClient, isMediaBlock, isTextBlock } from './api.js';
4
4
  import { approvalSupport, runWithApproval } from './approve.js';
5
5
  import { flagBoolean, flagString, parseArgs } from './args.js';
6
6
  import { download, upload, wait } from './commands/assets.js';
7
+ import { call } from './commands/call.js';
7
8
  import { assertKnownFlags, compoundBooleanFlags, CONTEXT_FLAG_ALIASES, needsLocalProjection, selectedFields, timeoutMs, wantsWait, } from './commands/context.js';
8
9
  import { files } from './commands/files.js';
10
+ import { guide } from './commands/guide.js';
9
11
  import { login, logout, whoami } from './commands/login.js';
10
- import { init, models, probe, tools, version } from './commands/tools.js';
12
+ import { init } from './commands/init.js';
13
+ import { models, probe, tools, version } from './commands/tools.js';
14
+ import { use } from './commands/use.js';
11
15
  import { resolveConfig } from './config.js';
12
- import { renderCompoundHelp, renderHelp, renderToolHelp } from './help.js';
13
- import { findTool, loadManifest } from './manifest.js';
14
- import { EXIT, assertRawDestination, printBytes, printError, printResult, printWarning, selectFields, warnNoFieldsMatched, } from './output.js';
15
- import { ArgumentError, buildArguments, flagSpecs, GLOBAL_FLAG_NAMES } from './schema.js';
16
- import { assetsOutcome, handleOutcome, producedAssetIds, waitForAssets, waitForDelivery, waitForJob, waitForRun, WaitTimeoutError, } from './wait.js';
16
+ import { GUIDE_HASH_HEADER, guideFetcher, guideHashNotice, loadBakedGuide, openGuide, } from './guideCache.js';
17
+ import { renderCompoundHelp, renderHelp, renderToolHelp, schemaKeyLines } from './help.js';
18
+ import { findTool, loadManifest, TOOL_NAME_PATTERN } from './manifest.js';
19
+ import { openManifest } from './manifestCache.js';
20
+ import { EXIT, MEDIA_EXTENSIONS, assertRawDestination, expandMapPaths, hasUndeclaredMapStep, isDeclaredPath, isEmptyProjection, isMediaOutPath, noFieldsMatchedMessage, noteMediaStubs, printBytes, printError, printResult, printWarning, resolveMapPaths, selectFields, unmatchedFields, warnUnmatchedFields, } from './output.js';
21
+ import { ArgumentError, argumentSchema, buildArguments, flagSpecs, GLOBAL_FLAG_NAMES, unknownToolFlags, } from './schema.js';
22
+ import { assetsOutcome, handleOutcome, notCompleted, producedAssetIds, waitForAssets, waitForDelivery, waitForJob, waitForRun, WaitTimeoutError, } from './wait.js';
17
23
  export const COMPOUND_COMMANDS = [
18
24
  login,
19
25
  logout,
20
26
  whoami,
27
+ use,
28
+ guide,
21
29
  tools,
30
+ call,
22
31
  models,
23
32
  upload,
24
33
  download,
@@ -29,7 +38,14 @@ export const COMPOUND_COMMANDS = [
29
38
  version,
30
39
  ];
31
40
  export { GLOBAL_FLAG_NAMES };
32
- const TOOL_ONLY_GLOBAL_FLAGS = new Set(['approve', 'max-olives', 'out-dir']);
41
+ const TOOL_ONLY_GLOBAL_FLAGS = new Set([
42
+ 'approve',
43
+ 'max-olives',
44
+ 'out-dir',
45
+ 'schema',
46
+ 'all',
47
+ 'keys',
48
+ ]);
33
49
  const GLOBAL_FLAGS_FOR_COMPOUNDS = new Set([
34
50
  ...[...GLOBAL_FLAG_NAMES].filter(flag => !TOOL_ONLY_GLOBAL_FLAGS.has(flag)),
35
51
  ...CONTEXT_FLAG_ALIASES,
@@ -42,20 +58,13 @@ const WAIT_BY_DEFAULT = new Set([
42
58
  'upscale_assets',
43
59
  'render_blender_take',
44
60
  ]);
45
- const BOOLEAN_GLOBAL_FLAGS = new Set(['help', 'version', 'wait', 'quiet', 'raw', 'context', 'approve']);
46
- export async function main(options) {
47
- const env = options.env ?? process.env;
48
- const cwd = options.cwd ?? process.cwd();
49
- const output = {
50
- ...(options.stdout ? { stdout: options.stdout } : {}),
51
- ...(options.stderr ? { stderr: options.stderr } : {}),
52
- ...(options.isTTY !== undefined ? { isTTY: options.isTTY } : {}),
53
- };
54
- const manifest = options.manifest ?? loadManifest();
61
+ const ORDERED_RESULT_TOOLS = new Set(['inspect_video']);
62
+ const BOOLEAN_GLOBAL_FLAGS = new Set(['help', 'version', 'wait', 'quiet', 'raw', 'context', 'approve', 'all', 'keys']);
63
+ function parseCommandLine(argv, manifest) {
55
64
  const toolFor = (command) => command && !COMPOUND_COMMANDS.some(candidate => candidate.name === command)
56
65
  ? findTool(command, manifest)
57
66
  : undefined;
58
- const parsed = parseArgs(options.argv, (flag, command) => {
67
+ return parseArgs(argv, (flag, command) => {
59
68
  if (BOOLEAN_GLOBAL_FLAGS.has(flag))
60
69
  return true;
61
70
  const compound = COMPOUND_COMMANDS.find(candidate => candidate.name === command);
@@ -69,81 +78,225 @@ export async function main(options) {
69
78
  const tool = toolFor(command);
70
79
  return tool ? flagSpecs(tool).some(spec => spec.flag === flag && spec.kind === 'array') : false;
71
80
  });
81
+ }
82
+ export async function main(options) {
83
+ const env = options.env ?? process.env;
84
+ const cwd = options.cwd ?? process.cwd();
85
+ const output = {
86
+ ...(options.stdout ? { stdout: options.stdout } : {}),
87
+ ...(options.stderr ? { stderr: options.stderr } : {}),
88
+ ...(options.isTTY !== undefined ? { isTTY: options.isTTY } : {}),
89
+ };
90
+ let session;
72
91
  try {
73
- if (flagBoolean(parsed.flags, 'version') && parsed.command === undefined)
74
- parsed.command = 'version';
75
- if (parsed.command === undefined ||
76
- parsed.command === 'help' ||
77
- (flagBoolean(parsed.flags, 'help') && !parsed.command)) {
78
- printResult(renderHelp(manifest, COMPOUND_COMMANDS), output);
79
- return EXIT.ok;
80
- }
81
- const compound = COMPOUND_COMMANDS.find(command => command.name === parsed.command);
82
- const tool = compound ? undefined : findTool(parsed.command, manifest);
83
- if (!compound && !tool) {
84
- throw new ArgumentError(`Unknown command "${parsed.command}". Run martini --help for the list.`);
85
- }
86
- if (flagBoolean(parsed.flags, 'help')) {
87
- printResult(tool ? renderToolHelp(tool) : renderCompoundHelp(compound), output);
88
- return EXIT.ok;
89
- }
90
- if (compound)
91
- assertKnownFlags(compound, parsed.flags, GLOBAL_FLAGS_FOR_COMPOUNDS);
92
- else if (parsed.positionals.length > 0)
93
- throw strayPositionalError(tool, parsed.positionals);
94
- const config = resolveConfig(parsed.flags, { env, cwd });
95
- for (const warning of config.warnings)
96
- printWarning('CONFIG_WARNING', warning, output);
97
- const clientFor = options.createClient ??
98
- (resolved => createApiClient(resolved, { ...(options.fetch ? { fetch: options.fetch } : {}), env }));
99
- const out = flagString(parsed.flags, 'out');
100
- if (out)
101
- output.out = out;
102
- output.inRun = Boolean(config.runId);
103
- output.quiet = flagBoolean(parsed.flags, 'quiet') ?? false;
104
- const context = {
105
- client: clientFor(config),
106
- clientFor,
107
- config,
108
- manifest,
109
- flags: parsed.flags,
110
- positionals: parsed.positionals,
111
- output,
112
- env,
113
- cwd,
114
- now: options.now ?? Date.now,
115
- sleep: options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))),
116
- };
117
- const select = selectedFields(context);
118
- timeoutMs(context);
119
- if (compound) {
120
- const result = await compound.run(context);
121
- const projected = select.length && result && typeof result === 'object' ? selectFields(result, select) : result;
122
- warnNoFieldsMatched(projected, select, output);
123
- printResult(projected, output);
124
- return EXIT.ok;
125
- }
126
- const result = await runTool(tool, context, parsed.flags);
127
- warnNoFieldsMatched(result, select, output, knownResultFields(tool));
128
- printResult(result, output);
129
- return EXIT.ok;
92
+ session = openSession(options, env, cwd, output);
130
93
  }
131
94
  catch (error) {
132
- if (error instanceof CliApiError && error.result !== undefined)
133
- printResult(error.result, output);
134
95
  return printError(error, output);
135
96
  }
97
+ const { config, clientFor, client, manifests } = session;
98
+ let refreshed;
99
+ for (;;) {
100
+ const manifest = manifests.state.manifest;
101
+ const parsed = parseCommandLine(options.argv, manifest);
102
+ if (parsed.command)
103
+ output.command = parsed.command;
104
+ try {
105
+ if (flagBoolean(parsed.flags, 'version') && parsed.command === undefined)
106
+ parsed.command = 'version';
107
+ if (parsed.command === undefined && parsed.flags.has('schema')) {
108
+ throw new ArgumentError("--schema describes one tool's arguments: martini <tool> --schema [flag].");
109
+ }
110
+ if (parsed.command === undefined ||
111
+ parsed.command === 'help' ||
112
+ (flagBoolean(parsed.flags, 'help') && !parsed.command)) {
113
+ printResult(renderHelp(manifest, COMPOUND_COMMANDS), output);
114
+ return EXIT.ok;
115
+ }
116
+ const compound = COMPOUND_COMMANDS.find(command => command.name === parsed.command);
117
+ const tool = compound ? undefined : findTool(parsed.command, manifest);
118
+ if (!compound && !tool) {
119
+ if (refreshed === undefined && TOOL_NAME_PATTERN.test(parsed.command)) {
120
+ refreshed = await manifests.refresh();
121
+ if (refreshed === 'updated')
122
+ continue;
123
+ }
124
+ throw unknownCommandError(parsed.command, refreshed);
125
+ }
126
+ if (flagBoolean(parsed.flags, 'help')) {
127
+ printResult(tool
128
+ ? renderToolHelp(tool, { all: flagBoolean(parsed.flags, 'all') === true })
129
+ : renderCompoundHelp(compound), output);
130
+ return EXIT.ok;
131
+ }
132
+ if (parsed.flags.has('schema')) {
133
+ if (compound) {
134
+ throw new ArgumentError(`--schema describes a tool's arguments; ${compound.name} is a compound command (martini ${compound.name} --help lists its flags).`);
135
+ }
136
+ const target = schemaTarget(options.argv, parsed.flags);
137
+ const schema = argumentSchema(tool, target);
138
+ printResult(flagBoolean(parsed.flags, 'keys') === true
139
+ ? `${schemaKeyLines(schema, target ? `--${target.replace(/^--/, '')}` : `${tool.name} arguments`).join('\n')}\n`
140
+ : `${JSON.stringify(schema, null, 2)}\n`, output);
141
+ return EXIT.ok;
142
+ }
143
+ if (parsed.flags.has('keys'))
144
+ throw new ArgumentError('--keys goes with --schema: martini <tool> --schema <flag> --keys.');
145
+ if (parsed.flags.has('all'))
146
+ throw new ArgumentError('--all goes with --help: martini <tool> --help --all.');
147
+ if (compound)
148
+ assertKnownFlags(compound, parsed.flags, GLOBAL_FLAGS_FOR_COMPOUNDS);
149
+ else {
150
+ if (refreshed === undefined && unknownToolFlags(tool, parsed.flags, nonArgumentFlags(tool)).length > 0) {
151
+ refreshed = await manifests.refresh();
152
+ if (refreshed === 'updated')
153
+ continue;
154
+ }
155
+ if (parsed.positionals.length > 0)
156
+ throw strayPositionalError(tool, parsed.positionals);
157
+ }
158
+ for (const warning of config.warnings)
159
+ printWarning('CONFIG_WARNING', warning, output);
160
+ const out = flagString(parsed.flags, 'out');
161
+ if (out)
162
+ output.out = out;
163
+ output.inRun = Boolean(config.runId);
164
+ output.quiet = flagBoolean(parsed.flags, 'quiet') ?? false;
165
+ return await runCommand(compound, tool, {
166
+ client,
167
+ clientFor,
168
+ config,
169
+ manifest,
170
+ manifests,
171
+ guide: lazyGuide(options, config, env, output),
172
+ flags: parsed.flags,
173
+ positionals: parsed.positionals,
174
+ output,
175
+ env,
176
+ cwd,
177
+ now: options.now ?? Date.now,
178
+ sleep: options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))),
179
+ });
180
+ }
181
+ catch (error) {
182
+ if (error instanceof CliApiError && error.result !== undefined)
183
+ printResult(error.result, output);
184
+ return printError(error, output);
185
+ }
186
+ }
136
187
  }
137
- async function runTool(tool, context, flags) {
188
+ function openSession(options, env, cwd, output) {
189
+ const baked = options.manifest ?? loadManifest();
190
+ const preliminary = parseCommandLine(options.argv, baked);
191
+ const config = resolveConfig(preliminary.flags, { env, cwd });
192
+ const warnings = { ...output, quiet: flagBoolean(preliminary.flags, 'quiet') ?? false };
193
+ const noticeGuideHash = guideHashNotice({
194
+ baked: loadBakedGuide(),
195
+ apiUrl: config.apiUrl,
196
+ apiKey: config.apiKey,
197
+ env,
198
+ quiet: warnings.quiet === true,
199
+ warn: (code, message) => printWarning(code, message, warnings),
200
+ });
201
+ const clientFor = options.createClient ??
202
+ ((resolved) => createApiClient(resolved, {
203
+ ...(options.fetch ? { fetch: options.fetch } : {}),
204
+ env,
205
+ onResponse: response => {
206
+ const hash = response.headers.get(GUIDE_HASH_HEADER);
207
+ if (hash)
208
+ noticeGuideHash(hash);
209
+ },
210
+ }));
211
+ const client = clientFor(config);
212
+ const manifests = openManifest({
213
+ baked,
214
+ apiUrl: config.apiUrl,
215
+ apiKey: config.apiKey,
216
+ env,
217
+ fetchManifest: ifNoneMatch => client.fetchManifest(ifNoneMatch),
218
+ warn: (code, message) => printWarning(code, message, warnings),
219
+ ...(options.now ? { now: options.now } : {}),
220
+ });
221
+ return { config, clientFor, client, manifests };
222
+ }
223
+ function lazyGuide(options, config, env, output) {
224
+ let runtime;
225
+ return () => (runtime ??= openGuide({
226
+ baked: loadBakedGuide(),
227
+ apiUrl: config.apiUrl,
228
+ apiKey: config.apiKey,
229
+ env,
230
+ fetchGuide: guideFetcher({ fetch: options.fetch ?? fetch, apiUrl: config.apiUrl, apiKey: config.apiKey, env }),
231
+ warn: (code, message) => printWarning(code, message, output),
232
+ ...(options.now ? { now: options.now } : {}),
233
+ }));
234
+ }
235
+ function schemaTarget(argv, flags) {
236
+ const value = flagString(flags, 'schema');
237
+ if (value)
238
+ return value;
239
+ const index = argv.indexOf('--schema');
240
+ const next = index >= 0 ? argv[index + 1] : undefined;
241
+ if (!next?.startsWith('--') || next === '--')
242
+ return undefined;
243
+ const name = next.slice(2).split('=')[0];
244
+ return GLOBAL_FLAG_NAMES.has(name) ? undefined : name;
245
+ }
246
+ function unknownCommandError(name, refreshed) {
247
+ const message = `Unknown command "${name}". Run martini --help for the list.`;
248
+ if (!TOOL_NAME_PATTERN.test(name))
249
+ return new ArgumentError(message);
250
+ const checked = refreshed === 'updated' || refreshed === 'unchanged';
251
+ return new ArgumentError(message, {
252
+ hint: checked
253
+ ? `The server's tool list for this key has no "${name}" either (martini tools compares it with this build's). To send the call by name anyway: martini call ${name} --args '{…}'.`
254
+ : `The server's tool list was not checked, so a tool added since this build may be missing: martini call ${name} --args '{…}' sends the call by name.`,
255
+ });
256
+ }
257
+ function nonArgumentFlags(tool) {
138
258
  const properties = tool.inputSchema.properties ?? {};
139
259
  const ignore = new Set(GLOBAL_FLAG_NAMES);
140
260
  for (const alias of CONTEXT_FLAG_ALIASES) {
141
261
  if (!(alias.replace(/-id$/, 'Id') in properties))
142
262
  ignore.add(alias);
143
263
  }
264
+ return ignore;
265
+ }
266
+ async function runCommand(compound, tool, context) {
267
+ const { output } = context;
268
+ const select = selectedFields(context);
269
+ timeoutMs(context);
270
+ if (compound) {
271
+ const result = await compound.run(context);
272
+ printResult(result && typeof result === 'object'
273
+ ? projectResult(result, select, { readOnly: compound.readOnly === true, known: [], command: compound.name }, output)
274
+ : result, output);
275
+ return EXIT.ok;
276
+ }
277
+ printResult(await runTool(tool, context, context.flags), output);
278
+ return EXIT.ok;
279
+ }
280
+ function projectResult(result, select, what, output) {
281
+ if (select.length === 0)
282
+ return result;
283
+ const projected = selectFields(result, select);
284
+ if (!isEmptyProjection(projected) || isEmptyProjection(result)) {
285
+ warnUnmatchedFields(result, select, output, what.schema);
286
+ return projected;
287
+ }
288
+ if (allDeclared(select, what.schema))
289
+ return projected;
290
+ const message = noFieldsMatchedMessage(what.typed ?? select, what.known);
291
+ if (what.readOnly)
292
+ throw new CliApiError(message, { code: 'NO_FIELDS_MATCHED', status: 0 });
293
+ printWarning('NO_FIELDS_MATCHED', `${message} ${what.command} did its work, so its whole result is printed instead; do not run it again for this.`, output);
294
+ return result;
295
+ }
296
+ async function runTool(tool, context, flags) {
144
297
  const useContext = flagBoolean(flags, 'context') !== false;
145
298
  const args = buildArguments(tool, flags, {
146
- ignore,
299
+ ignore: nonArgumentFlags(tool),
147
300
  context: useContext
148
301
  ? {
149
302
  ...(context.config.projectId ? { projectId: context.config.projectId } : {}),
@@ -152,91 +305,108 @@ async function runTool(tool, context, flags) {
152
305
  }
153
306
  : {},
154
307
  });
308
+ const readOnly = tool.annotations.readOnlyHint === true;
309
+ const out = flagString(flags, 'out');
155
310
  const outDir = flagString(flags, 'out-dir');
156
311
  if (outDir) {
157
- if (flagString(flags, 'out'))
312
+ if (out)
158
313
  throw new ArgumentError('Use either --out or --out-dir.');
159
- if (tool.annotations.readOnlyHint !== true) {
314
+ if (!readOnly) {
160
315
  throw new ArgumentError('--out-dir requires a read-only tool. Run this command without --out-dir.');
161
316
  }
162
- const result = await context.client.callTool(tool.name, args);
163
- const media = result.media ?? [];
164
- if (media.length === 0)
165
- return result;
166
- const headers = [...(result.text ?? '').matchAll(/^Asset ([A-Za-z0-9_-]+) \((\d+)\/\d+\)/gm)];
317
+ const result = await context.client.callToolBlocks(tool.name, args, { media: 'inline' });
318
+ const files = mediaFiles(result.blocks, tool.name);
319
+ if (files.length === 0)
320
+ return readableBlocks(result.blocks);
167
321
  const directory = resolve(context.cwd, outDir);
168
- const files = media.map((block, index) => {
169
- const header = headers[index];
170
- const assetId = header?.[1] ?? (media.length === 1 ? result.assetId : undefined);
171
- if (!assetId) {
172
- throw new CliApiError(`Media block ${index}: missing Asset header or single-media result.assetId.`, {
173
- code: 'INVALID_RESPONSE',
174
- status: 0,
175
- tool: tool.name,
176
- });
177
- }
178
- const extensions = {
179
- 'image/jpeg': 'jpg',
180
- 'image/png': 'png',
181
- 'image/webp': 'webp',
182
- 'audio/mpeg': 'mp3',
183
- 'audio/wav': 'wav',
184
- };
185
- if (!block.mimeType || !Object.hasOwn(extensions, block.mimeType)) {
186
- throw new CliApiError(`Media block ${index}: missing or unknown mimeType (${block.mimeType ?? 'missing'}).`, {
187
- code: 'INVALID_RESPONSE',
188
- status: 0,
189
- tool: tool.name,
190
- });
191
- }
192
- const ext = extensions[block.mimeType];
193
- const name = `${header ? `${header[2]}-` : ''}${assetId.replace(/[^A-Za-z0-9_-]/g, '_')}.${ext}`;
194
- return { path: join(directory, name), bytes: Buffer.from(block.data, 'base64') };
195
- });
196
322
  mkdirSync(directory, { recursive: true });
197
- files.forEach(({ path, bytes }) => {
198
- writeFileSync(path, bytes);
199
- if (!context.output.quiet) {
200
- ;
201
- (context.output.stdout ?? (text => process.stdout.write(text)))(`${path}\n`);
202
- }
203
- });
204
- context.output.quiet = true;
205
- return undefined;
323
+ for (const file of files)
324
+ writeFileSync(join(directory, file.name), file.bytes);
325
+ const receipt = {
326
+ dir: directory,
327
+ files: files.map(file => ({
328
+ path: join(directory, file.name),
329
+ mimeType: file.mimeType,
330
+ bytes: file.bytes.byteLength,
331
+ ...(file.about !== undefined ? { about: file.about } : {}),
332
+ })),
333
+ ...(unpairedTexts(result.blocks).length ? { notes: unpairedTexts(result.blocks) } : {}),
334
+ };
335
+ return projectResult(receipt, selectedFields(context), { readOnly: false, known: Object.keys(receipt), command: tool.name }, context.output);
206
336
  }
207
- if (flagBoolean(flags, 'raw')) {
337
+ const mediaOut = out !== undefined && isMediaOutPath(out);
338
+ const raw = flagBoolean(flags, 'raw') === true;
339
+ const mediaFromResult = mediaOut && !raw && !readOnly;
340
+ if (raw || (mediaOut && readOnly)) {
208
341
  assertRawDestination(context.output);
209
- let raw;
342
+ let bytes;
210
343
  try {
211
- raw = await context.client.callToolRaw(tool.name, args);
344
+ bytes = await context.client.callToolRaw(tool.name, args);
212
345
  }
213
346
  catch (error) {
214
347
  if (error instanceof CliApiError && error.code === 'NOT_RAW_ELIGIBLE') {
215
- throw new ArgumentError('This result cannot use --raw --out. Use --out-dir <dir> to write multiple media files.');
348
+ throw new ArgumentError(raw
349
+ ? 'This result cannot use --raw: it does not hold exactly one media block. Use --out-dir <dir> to write several media files.'
350
+ : `--out ${out} names a media file, but ${tool.name} did not answer exactly one image or audio block. Use --out-dir <dir> for several media files, or name a .json file for the JSON result.`);
216
351
  }
217
352
  throw error;
218
353
  }
219
- printBytes(raw.bytes, { ...context.output, contentType: raw.contentType });
354
+ printBytes(bytes.bytes, {
355
+ ...context.output,
356
+ contentType: bytes.contentType,
357
+ meta: bytes.meta,
358
+ fields: selectedFields(context),
359
+ });
220
360
  delete context.output.out;
221
361
  context.output.quiet = true;
222
- return raw.meta;
362
+ return undefined;
223
363
  }
224
- const select = selectedFields(context);
364
+ const requested = mediaFromResult ? [] : selectedFields(context);
365
+ const finish = (result) => (mediaFromResult ? saveMediaFromResult(result, tool, context, out) : result);
366
+ if (ORDERED_RESULT_TOOLS.has(tool.name)) {
367
+ approvalFlags(tool, flags);
368
+ const media = context.output.out ? 'inline' : 'stub';
369
+ const result = await context.client.callToolBlocks(tool.name, args, { media });
370
+ if (media === 'stub')
371
+ noteMediaStubs(result.blocks, context.output);
372
+ return projectResult(readableBlocks(result.blocks), requested, { readOnly: true, known: ['blocks'], command: tool.name }, context.output);
373
+ }
374
+ const select = expandMapPaths(requested, tool.outputSchema);
375
+ const known = Object.keys(tool.outputSchema?.properties ?? {});
225
376
  const shouldWait = wantsWait(context, WAIT_BY_DEFAULT.has(tool.name));
226
377
  const approve = approvalFlags(tool, flags);
227
- const local = shouldWait || approve !== null || needsLocalProjection(select);
378
+ const local = shouldWait ||
379
+ approve !== null ||
380
+ needsLocalProjection(select) ||
381
+ hasUndeclaredMapStep(requested, tool.outputSchema) ||
382
+ !readOnly ||
383
+ tool.outputSchema === undefined;
384
+ const what = { readOnly, known, command: tool.name, typed: requested, schema: tool.outputSchema };
385
+ const selectIn = (value) => resolveMapPaths(value, requested, tool.outputSchema);
228
386
  const result = approve
229
387
  ? await runWithApproval(tool, context.client, args, approve.support, approve.maxOlives === undefined ? {} : { maxOlives: approve.maxOlives })
230
388
  : await context.client.callTool(tool.name, args, !local && select.length ? { select } : {});
231
- if (!shouldWait)
232
- return local ? selectFields(result, select) : result;
389
+ if (!shouldWait) {
390
+ if (local)
391
+ return finish(projectResult(result, selectIn(result), what, context.output));
392
+ if (select.length && unmatchedFields(result, select).length === select.length) {
393
+ if (allDeclared(select, tool.outputSchema))
394
+ return result;
395
+ throw new CliApiError(noFieldsMatchedMessage(requested, known), { code: 'NO_FIELDS_MATCHED', status: 0 });
396
+ }
397
+ warnUnmatchedFields(result, select, context.output, tool.outputSchema);
398
+ return result;
399
+ }
233
400
  const projectId = typeof args.projectId === 'string' ? args.projectId : context.config.projectId;
234
401
  try {
235
- return selectFields(await waitForToolResult(result, context, projectId), select);
402
+ const waited = await waitForToolResult(result, context, projectId);
403
+ return finish(projectResult(waited, selectIn(waited), what, context.output));
236
404
  }
237
405
  catch (error) {
238
- if (error instanceof CliApiError) {
239
- const kept = selectFields(result, select);
406
+ if (error instanceof WaitTimeoutError && projectId)
407
+ error.project = projectId;
408
+ if (error instanceof CliApiError && error.code !== 'NO_FIELDS_MATCHED' && error.result === undefined) {
409
+ const kept = selectFields(result, selectIn(result));
240
410
  const base = kept && typeof kept === 'object' && !Array.isArray(kept) ? kept : { result: kept };
241
411
  error.result =
242
412
  error instanceof WaitTimeoutError
@@ -246,6 +416,117 @@ async function runTool(tool, context, flags) {
246
416
  throw error;
247
417
  }
248
418
  }
419
+ function saveMediaFromResult(result, tool, context, out) {
420
+ const record = result && typeof result === 'object' && !Array.isArray(result) ? result : {};
421
+ const media = (Array.isArray(record.media) ? record.media : []).filter((block) => block !== null && typeof block === 'object' && typeof block.data === 'string');
422
+ delete context.output.out;
423
+ if (media.length !== 1) {
424
+ printWarning('OUT_NOT_MEDIA', `${tool.name} answered ${media.length === 0 ? 'no image or audio block' : `${media.length} media blocks`}, so nothing was written to ${out}; its result is printed instead. ${tool.name} did its work: do not run it again for this. Name a .json file to save a result, or use --out-dir on a read-only tool for several media files.`, context.output);
425
+ return result;
426
+ }
427
+ const { media: _media, ...rest } = record;
428
+ printBytes(Buffer.from(media[0].data, 'base64'), {
429
+ ...context.output,
430
+ out,
431
+ ...(typeof media[0].mimeType === 'string' ? { contentType: media[0].mimeType } : {}),
432
+ meta: rest,
433
+ fields: selectedFields(context),
434
+ });
435
+ context.output.quiet = true;
436
+ return undefined;
437
+ }
438
+ function allDeclared(paths, schema) {
439
+ return schema !== undefined && paths.every(path => isDeclaredPath(path, schema));
440
+ }
441
+ function readableBlocks(blocks) {
442
+ return {
443
+ blocks: blocks.map(block => {
444
+ if (!isTextBlock(block))
445
+ return block;
446
+ const parsed = parseLabelledJson(block.text);
447
+ return parsed ? { type: 'text', ...(parsed.label ? { label: parsed.label } : {}), json: parsed.json } : block;
448
+ }),
449
+ };
450
+ }
451
+ function parseLabelledJson(text) {
452
+ const header = /^(Asset [A-Za-z0-9_-]+ \(\d+\/\d+\))\s*\n/.exec(text);
453
+ const body = header ? text.slice(header[0].length) : text;
454
+ if (!body.trimStart().startsWith('{'))
455
+ return null;
456
+ try {
457
+ const json = JSON.parse(body);
458
+ if (!json || typeof json !== 'object' || Array.isArray(json))
459
+ return null;
460
+ return { ...(header ? { label: header[1] } : {}), json: json };
461
+ }
462
+ catch {
463
+ return null;
464
+ }
465
+ }
466
+ function unpairedTexts(blocks) {
467
+ const notes = [];
468
+ blocks.forEach((block, index) => {
469
+ if (!isTextBlock(block))
470
+ return;
471
+ const previous = blocks[index - 1];
472
+ const next = blocks[index + 1];
473
+ const paired = (next !== undefined && isMediaBlock(next)) ||
474
+ (previous !== undefined && isMediaBlock(previous) && (index < 2 || !isTextBlock(blocks[index - 2])));
475
+ if (!paired)
476
+ notes.push(block.text);
477
+ });
478
+ return notes;
479
+ }
480
+ function mediaFiles(blocks, toolName) {
481
+ const invalid = (message) => new CliApiError(message, { code: 'INVALID_RESPONSE', status: 0, tool: toolName });
482
+ const taken = new Set();
483
+ const files = [];
484
+ let text;
485
+ blocks.forEach((block, index) => {
486
+ if (isTextBlock(block)) {
487
+ text = block.text;
488
+ return;
489
+ }
490
+ if (!isMediaBlock(block))
491
+ return;
492
+ const mimeType = typeof block.mimeType === 'string' ? block.mimeType : undefined;
493
+ if (!mimeType || !Object.hasOwn(MEDIA_EXTENSIONS, mimeType)) {
494
+ throw invalid(`Block ${index} (${block.type}): missing or unknown mimeType (${mimeType ?? 'missing'}).`);
495
+ }
496
+ if (!('data' in block) || typeof block.data !== 'string')
497
+ throw invalid(`Block ${index} (${block.type}): no data.`);
498
+ const before = stemFrom(text);
499
+ const describing = before ? text : textAfter(blocks, index);
500
+ const stem = before ?? stemFrom(describing) ?? `block-${index}`;
501
+ let name = `${stem}.${MEDIA_EXTENSIONS[mimeType]}`;
502
+ for (let copy = 2; taken.has(name); copy++)
503
+ name = `${stem}-${copy}.${MEDIA_EXTENSIONS[mimeType]}`;
504
+ taken.add(name);
505
+ const about = describing === undefined ? null : parseLabelledJson(describing);
506
+ files.push({ name, mimeType, bytes: Buffer.from(block.data, 'base64'), ...(about ? { about: about.json } : {}) });
507
+ });
508
+ return files;
509
+ }
510
+ function stemFrom(text) {
511
+ if (text === undefined)
512
+ return null;
513
+ const header = /^Asset ([A-Za-z0-9_-]+) \((\d+)\/\d+\)/.exec(text);
514
+ if (header)
515
+ return `${header[2]}-${header[1]}`;
516
+ try {
517
+ const parsed = JSON.parse(text);
518
+ const assetId = parsed && typeof parsed === 'object' ? parsed.assetId : undefined;
519
+ if (typeof assetId === 'string' && assetId)
520
+ return assetId.replace(/[^A-Za-z0-9_-]/g, '_');
521
+ }
522
+ catch {
523
+ }
524
+ return null;
525
+ }
526
+ function textAfter(blocks, index) {
527
+ const next = blocks[index + 1];
528
+ return next && isTextBlock(next) ? next.text : undefined;
529
+ }
249
530
  async function waitForToolResult(result, context, projectId) {
250
531
  if (!result || typeof result !== 'object')
251
532
  return result;
@@ -253,25 +534,32 @@ async function waitForToolResult(result, context, projectId) {
253
534
  if (record.approvalNeeded === true || record.generated === false)
254
535
  return result;
255
536
  const waitOptions = { timeoutMs: timeoutMs(context), now: context.now, sleep: context.sleep };
537
+ const settled = (waited) => {
538
+ const failure = notCompleted(waited);
539
+ if (!failure)
540
+ return { ...record, waited };
541
+ failure.result = { ...record, waited };
542
+ throw failure;
543
+ };
256
544
  if (typeof record.runId === 'string') {
257
- return { ...record, waited: handleOutcome('run', await waitForRun(context.client, record.runId, waitOptions)) };
545
+ return settled(handleOutcome('run', await waitForRun(context.client, record.runId, waitOptions)));
258
546
  }
259
547
  if (typeof record.renderId === 'string' || typeof record.exportId === 'string') {
260
548
  const ids = {
261
549
  ...(typeof record.renderId === 'string' ? { renderId: record.renderId } : {}),
262
550
  ...(typeof record.exportId === 'string' ? { exportId: record.exportId } : {}),
263
551
  };
264
- return { ...record, waited: handleOutcome('render', await waitForDelivery(context.client, ids, waitOptions)) };
552
+ return settled(handleOutcome('render', await waitForDelivery(context.client, ids, waitOptions)));
265
553
  }
266
554
  if (typeof record.jobId === 'string') {
267
- return { ...record, waited: handleOutcome('job', await waitForJob(context.client, record.jobId, waitOptions)) };
555
+ return settled(handleOutcome('job', await waitForJob(context.client, record.jobId, waitOptions)));
268
556
  }
269
557
  if (!projectId)
270
558
  return result;
271
559
  const ids = producedAssetIds(result);
272
560
  if (ids.length === 0)
273
561
  return result;
274
- return { ...record, waited: assetsOutcome(await waitForAssets(context.client, projectId, ids, waitOptions)) };
562
+ return settled(assetsOutcome(await waitForAssets(context.client, projectId, ids, waitOptions)));
275
563
  }
276
564
  function strayPositionalError(tool, tokens) {
277
565
  const list = flagSpecs(tool).find(spec => spec.kind === 'array');
@@ -295,7 +583,4 @@ function approvalFlags(tool, flags) {
295
583
  throw new ArgumentError('--max-olives must be a number of olives.');
296
584
  return { support, maxOlives };
297
585
  }
298
- function knownResultFields(tool) {
299
- return Object.keys(tool.outputSchema?.properties ?? {});
300
- }
301
586
  //# sourceMappingURL=cli.js.map