@gavana.ai/cli 0.2.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.
- package/CHANGELOG.md +54 -0
- package/LICENSE.md +7 -0
- package/README.md +237 -0
- package/bin/craftboard.mjs +5 -0
- package/bin/gavana.mjs +5 -0
- package/guides/connections.md +35 -0
- package/guides/examples-common-mistakes.md +29 -0
- package/guides/existing-canvases.md +19 -0
- package/guides/generated-assets.md +29 -0
- package/guides/getting-started.md +26 -0
- package/guides/notes-text-sections.md +44 -0
- package/guides/paid-action-safety.md +22 -0
- package/guides/prompt-lists.md +20 -0
- package/guides/sections-layout.md +43 -0
- package/guides/validation-recovery.md +33 -0
- package/package.json +44 -0
- package/src/canvas-agent-guide.mjs +133 -0
- package/src/canvas-agent-validation.mjs +554 -0
- package/src/canvas-layout.mjs +287 -0
- package/src/capabilities.mjs +61 -0
- package/src/client.mjs +1141 -0
- package/src/commands.mjs +259 -0
- package/src/config.mjs +197 -0
- package/src/guide-sources.mjs +86 -0
- package/src/runner.mjs +1968 -0
- package/src/tools/action_get.mjs +16 -0
- package/src/tools/action_list.mjs +21 -0
- package/src/tools/action_run.mjs +60 -0
- package/src/tools/agent_canvas_get.mjs +15 -0
- package/src/tools/asset_get.mjs +16 -0
- package/src/tools/asset_list.mjs +17 -0
- package/src/tools/asset_upload.mjs +24 -0
- package/src/tools/campaign_cancel.mjs +16 -0
- package/src/tools/campaign_get.mjs +16 -0
- package/src/tools/campaign_plan.mjs +31 -0
- package/src/tools/campaign_review.mjs +24 -0
- package/src/tools/campaign_start.mjs +19 -0
- package/src/tools/canvas_apply_batch.mjs +35 -0
- package/src/tools/canvas_create.mjs +15 -0
- package/src/tools/canvas_get.mjs +16 -0
- package/src/tools/canvas_list.mjs +17 -0
- package/src/tools/canvas_render.mjs +34 -0
- package/src/tools/canvas_validate.mjs +34 -0
- package/src/tools/connection_create.mjs +38 -0
- package/src/tools/connection_delete.mjs +31 -0
- package/src/tools/definitions.mjs +111 -0
- package/src/tools/guide_get.mjs +16 -0
- package/src/tools/guide_search.mjs +16 -0
- package/src/tools/helpers.mjs +66 -0
- package/src/tools/image_edit.mjs +8 -0
- package/src/tools/image_generate.mjs +8 -0
- package/src/tools/image_tool.mjs +56 -0
- package/src/tools/image_variations.mjs +8 -0
- package/src/tools/job_cancel.mjs +16 -0
- package/src/tools/job_get.mjs +17 -0
- package/src/tools/job_wait.mjs +18 -0
- package/src/tools/model_get.mjs +16 -0
- package/src/tools/model_list.mjs +23 -0
- package/src/tools/node_create.mjs +36 -0
- package/src/tools/node_delete.mjs +31 -0
- package/src/tools/node_get.mjs +16 -0
- package/src/tools/node_move.mjs +37 -0
- package/src/tools/node_resize.mjs +37 -0
- package/src/tools/node_update.mjs +36 -0
- package/src/tools/progress.mjs +101 -0
- package/src/tools/provider_list.mjs +17 -0
- package/src/tools/recipe_fork.mjs +32 -0
- package/src/tools/recipe_get.mjs +19 -0
- package/src/tools/recipe_run.mjs +61 -0
- package/src/tools/recipe_search.mjs +17 -0
- package/src/tools/registry.mjs +550 -0
- package/src/tools/run_cancel.mjs +16 -0
- package/src/tools/run_get.mjs +17 -0
- package/src/tools/run_wait.mjs +18 -0
- package/src/tools/schemas.mjs +165 -0
- package/src/tools/video_generate.mjs +37 -0
- package/src/version.mjs +12 -0
package/src/runner.mjs
ADDED
|
@@ -0,0 +1,1968 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
import { CanvasAgentApiError, createCanvasAgentClient, markdownForResult } from "./client.mjs";
|
|
9
|
+
import { GAVANA_CLI_VERSION, gavanaCapabilitySummary } from "./capabilities.mjs";
|
|
10
|
+
import { GAVANA_CLI_GROUP_HELP, gavanaCommandActions, gavanaCommandGroupUsageLines, gavanaCommandGroups, gavanaCommandIsKnown, gavanaCommandUsageLines } from "./commands.mjs";
|
|
11
|
+
import { agentConfigFilePath, listAgentProfiles, readAgentConfig, readAgentConfigMetadata, removeAgentProfile, setActiveAgentProfile, writeAgentConfig } from "./config.mjs";
|
|
12
|
+
|
|
13
|
+
const BOOLEAN_OPTIONS = new Set(["help", "version", "pretty", "raw", "yes", "wait", "no-wait", "progress", "token-stdin", "no-verify", "no-browser", "audio", "no-audio", "read-only"]);
|
|
14
|
+
const REPEATABLE_OPTIONS = new Set(["reference", "reference-role", "input", "param", "target", "ratio", "approved", "field"]);
|
|
15
|
+
const MAX_REFERENCE_IMAGES = 16;
|
|
16
|
+
const MAX_VIDEO_REFERENCE_IMAGES = 9;
|
|
17
|
+
const MAX_REFERENCE_IMAGE_BYTES = 50 * 1024 * 1024;
|
|
18
|
+
const execFile = promisify(execFileCallback);
|
|
19
|
+
|
|
20
|
+
export async function runGavanaCli(argv = process.argv.slice(2), runtime = {}) {
|
|
21
|
+
const stdout = runtime.stdout || process.stdout;
|
|
22
|
+
const stderr = runtime.stderr || process.stderr;
|
|
23
|
+
const env = runtime.env || process.env;
|
|
24
|
+
const parsed = parseArguments(argv);
|
|
25
|
+
if (parsed.options.help || (!parsed.positionals.length && parsed.options.version !== true)) {
|
|
26
|
+
stdout.write(`${helpText(parsed.positionals[0], parsed.positionals[1])}\n`);
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const [group, action, ...positionals] = parsed.positionals;
|
|
31
|
+
try {
|
|
32
|
+
validateOutputOptions(parsed.options);
|
|
33
|
+
|
|
34
|
+
// The command table gates dispatch. Checking here rather than scanning the
|
|
35
|
+
// dispatch branches from a test is what makes an undeclared command
|
|
36
|
+
// unreachable instead of merely detectable, and it holds however the branches
|
|
37
|
+
// below are written.
|
|
38
|
+
//
|
|
39
|
+
// It runs before every command handler, including `version` and
|
|
40
|
+
// `capabilities`. Those two answer without reading their action, so a guard
|
|
41
|
+
// placed after them let `gavana version bogus` print a version and exit 0 —
|
|
42
|
+
// the table said the command did not exist and the CLI accepted it anyway.
|
|
43
|
+
//
|
|
44
|
+
// `gavana --version` carries no positionals, so group is undefined and the
|
|
45
|
+
// guard passes it through to the flag handler below; `gavana bogus --version`
|
|
46
|
+
// is now rejected, because a global flag should not resurrect an unknown
|
|
47
|
+
// command.
|
|
48
|
+
if (group !== undefined && !gavanaCommandIsKnown(group, action)) {
|
|
49
|
+
throw usageError(`Unknown command: ${[group, action].filter(Boolean).join(" ")}. Run with --help.`);
|
|
50
|
+
}
|
|
51
|
+
if (parsed.options.version === true) {
|
|
52
|
+
writeResult(stdout, { name: "@gavana.ai/cli", version: GAVANA_CLI_VERSION, node: process.version }, parsed.options);
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
if (group === "version") {
|
|
56
|
+
writeResult(stdout, { name: "@gavana.ai/cli", version: GAVANA_CLI_VERSION, node: process.version }, parsed.options);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
if (group === "capabilities") {
|
|
60
|
+
writeResult(stdout, gavanaCapabilitySummary(), parsed.options);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
if (group === "completion") {
|
|
64
|
+
const shell = action || firstString(parsed.options.shell) || path.basename(env.SHELL || "zsh");
|
|
65
|
+
stdout.write(`${completionScript(shell)}\n`);
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
if (group === "config") {
|
|
69
|
+
const result = await runConfigCommand(action, positionals, parsed.options, env);
|
|
70
|
+
writeResult(stdout, result, parsed.options);
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
if (group === "auth") {
|
|
74
|
+
const result = await runAuthCommand(action, positionals, parsed.options, env, runtime);
|
|
75
|
+
writeResult(stdout, result, parsed.options);
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const selectedProfile = firstString(parsed.options.profile) || env.GAVANA_PROFILE || env.CRAFTBOARD_PROFILE || "";
|
|
80
|
+
const profileEnv = selectedProfile ? { ...env, GAVANA_PROFILE: selectedProfile } : env;
|
|
81
|
+
const config = group === "mcp" ? await readAgentConfigMetadata(profileEnv) : await readAgentConfig(profileEnv);
|
|
82
|
+
const token = firstString(parsed.options.token) || env.GAVANA_AGENT_TOKEN || env.CRAFTBOARD_AGENT_TOKEN || config.token;
|
|
83
|
+
const baseUrl = firstString(parsed.options["base-url"]) || env.GAVANA_BASE_URL || env.CRAFTBOARD_BASE_URL || config.baseUrl || "https://app.gavana.ai";
|
|
84
|
+
if (group === "mcp") {
|
|
85
|
+
writeResult(stdout, await runMcpCommand(action, positionals, parsed.options, baseUrl.replace(/\/+$/, ""), runtime), parsed.options);
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
if (group === "doctor") {
|
|
89
|
+
const credentialOverride = Boolean(firstString(parsed.options.token) || env.GAVANA_AGENT_TOKEN || env.CRAFTBOARD_AGENT_TOKEN);
|
|
90
|
+
const result = await runDoctor(token ? createCanvasAgentClient({ token, baseUrl, surface: "cli", fetchImpl: runtime.fetchImpl }) : null, config, env, baseUrl, credentialOverride ? "Environment or command-line override" : "");
|
|
91
|
+
writeResult(stdout, result, parsed.options);
|
|
92
|
+
return result.ok ? 0 : 1;
|
|
93
|
+
}
|
|
94
|
+
const client = createCanvasAgentClient({ token, baseUrl, surface: "cli", fetchImpl: runtime.fetchImpl });
|
|
95
|
+
return await runTrackedRemoteCommand(client, `${group}.${String(action || "default").toLowerCase()}`, runtime.analyticsEnabled ?? !runtime.fetchImpl, async () => {
|
|
96
|
+
if (group === "api") {
|
|
97
|
+
writeResult(stdout, await runApiCommand(client, action, positionals, parsed.options), parsed.options);
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
const result = await executeCommand(client, group, action, positionals, parsed.options, {
|
|
101
|
+
env,
|
|
102
|
+
stderr,
|
|
103
|
+
readClipboardImage: runtime.readClipboardImage,
|
|
104
|
+
legacyCampaignCommandsEnabled:
|
|
105
|
+
runtime.legacyCampaignCommandsEnabled ??
|
|
106
|
+
(env.GAVANA_ENABLE_LEGACY_CAMPAIGN_COMMANDS === "true" ||
|
|
107
|
+
env.CRAFTBOARD_ENABLE_LEGACY_CAMPAIGN_COMMANDS === "true" ||
|
|
108
|
+
process.env.GAVANA_ENABLE_LEGACY_CAMPAIGN_COMMANDS === "true" ||
|
|
109
|
+
process.env.CRAFTBOARD_ENABLE_LEGACY_CAMPAIGN_COMMANDS === "true"),
|
|
110
|
+
});
|
|
111
|
+
writeResult(stdout, result, parsed.options);
|
|
112
|
+
return exitCodeForResult(group, action, result);
|
|
113
|
+
});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
const normalized = normalizeCliError(error);
|
|
116
|
+
stderr.write(`${JSON.stringify({ ok: false, error: normalized })}\n`);
|
|
117
|
+
return exitCodeForError(normalized);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function runTrackedRemoteCommand(client, name, enabled, handler) {
|
|
122
|
+
if (!enabled || typeof client.reportAnalyticsEvent !== "function" || typeof client.runWithAnalyticsContext !== "function") return handler();
|
|
123
|
+
const invocationId = crypto.randomUUID();
|
|
124
|
+
const traceId = crypto.randomUUID();
|
|
125
|
+
const startedAt = Date.now();
|
|
126
|
+
void client.reportAnalyticsEvent({ name, status: "started", invocationId, traceId });
|
|
127
|
+
const context = { invocationId, traceId };
|
|
128
|
+
return client.runWithAnalyticsContext(context, async () => {
|
|
129
|
+
try {
|
|
130
|
+
const exitCode = await handler();
|
|
131
|
+
void client.reportAnalyticsEvent({
|
|
132
|
+
name,
|
|
133
|
+
status: exitCode === 0 ? "succeeded" : "failed",
|
|
134
|
+
invocationId,
|
|
135
|
+
traceId,
|
|
136
|
+
durationMs: Date.now() - startedAt,
|
|
137
|
+
});
|
|
138
|
+
return exitCode;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
void client.reportAnalyticsEvent({
|
|
141
|
+
name,
|
|
142
|
+
status: error?.code === "timeout" ? "timed_out" : error?.code === "canceled" ? "canceled" : "failed",
|
|
143
|
+
invocationId,
|
|
144
|
+
traceId,
|
|
145
|
+
errorCode: String(error?.code || "command_failed").slice(0, 120),
|
|
146
|
+
durationMs: Date.now() - startedAt,
|
|
147
|
+
});
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const runCraftboardAgentCli = runGavanaCli;
|
|
154
|
+
|
|
155
|
+
function exitCodeForResult(group, action, result) {
|
|
156
|
+
const waitedImage = group === "image" && (action === "generate" || action === "edit" || action === "variations");
|
|
157
|
+
const waitedVideo = group === "video" && action === "generate";
|
|
158
|
+
const waitedAction = group === "action" && action === "run";
|
|
159
|
+
const waitedJob = group === "job" && action === "wait";
|
|
160
|
+
const waitedRun = group === "run" && action === "wait";
|
|
161
|
+
const waitedRecipe = group === "recipe" && action === "run";
|
|
162
|
+
if ((waitedImage || waitedVideo || waitedAction || waitedJob || waitedRun || waitedRecipe) && (result?.status === "failed" || result?.status === "canceled" || result?.status === "expired")) return 9;
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function executeCommand(client, group, action, args, options, runtime) {
|
|
167
|
+
if (group === "campaign" && !runtime.legacyCampaignCommandsEnabled) {
|
|
168
|
+
throw usageError("The retired campaign command surface is disabled. Use Recipe Library forks and explicit canvas/image commands instead.");
|
|
169
|
+
}
|
|
170
|
+
if (group === "recipe") {
|
|
171
|
+
if (action === "search") return client.searchRecipes(firstString(options.query) || args.join(" "), paginationOptions(options, 100));
|
|
172
|
+
if (action === "get") {
|
|
173
|
+
return client.getRecipe(firstString(options.recipe) || requiredArg(args, 0, "recipe get requires recipe:<id>."), firstString(options.version));
|
|
174
|
+
}
|
|
175
|
+
if (action === "fork") {
|
|
176
|
+
const supplied = await optionalJsonInput(options);
|
|
177
|
+
const recipeId = firstString(options.recipe) || supplied?.recipeId || requiredArg(args, 0, "recipe fork requires recipe:<id>.");
|
|
178
|
+
return client.forkRecipe(
|
|
179
|
+
recipeId,
|
|
180
|
+
supplied || {
|
|
181
|
+
canvasId: firstString(options.canvas) || requiredArg(args, 1, "recipe fork requires --canvas canvas:<id>."),
|
|
182
|
+
...(firstString(options.version) ? { version: firstString(options.version) } : {}),
|
|
183
|
+
...(firstString(options["base-revision"]) ? { baseRevision: firstString(options["base-revision"]) } : {}),
|
|
184
|
+
...(options.x !== undefined ? { x: requiredNumber(options.x, "--x must be a number.") } : {}),
|
|
185
|
+
...(options.y !== undefined ? { y: requiredNumber(options.y, "--y must be a number.") } : {}),
|
|
186
|
+
idempotencyKey: firstString(options["idempotency-key"]),
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (action === "run") {
|
|
191
|
+
const prepared = await recipeRunInput(client, args, options, runtime);
|
|
192
|
+
const { __destination, __recipe, ...input } = prepared;
|
|
193
|
+
const queued = await client.startRecipeRun(__recipe, input);
|
|
194
|
+
if (options["no-wait"] === true) return { ...queued, destination: __destination };
|
|
195
|
+
const result = await client.waitForRun(queued.run || queued.handle || queued.id, {
|
|
196
|
+
timeoutMs: secondsOption(options.timeout, 15 * 60) * 1000,
|
|
197
|
+
intervalMs: secondsOption(options.interval, 1.5, true) * 1000,
|
|
198
|
+
onProgress:
|
|
199
|
+
options.progress === true
|
|
200
|
+
? (progress) => {
|
|
201
|
+
runtime.stderr.write(`${JSON.stringify({ ok: true, progress: { run: progress.run || progress.handle || progress.id, status: progress.status } })}\n`);
|
|
202
|
+
}
|
|
203
|
+
: undefined,
|
|
204
|
+
});
|
|
205
|
+
return { ...result, destination: __destination };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (group === "campaign") {
|
|
210
|
+
if (action === "plan") {
|
|
211
|
+
const supplied = await optionalJsonInput(options);
|
|
212
|
+
if (supplied) return client.planCampaign(supplied);
|
|
213
|
+
const product = firstString(options.product);
|
|
214
|
+
const brandKitId = firstString(options["brand-kit"]);
|
|
215
|
+
const brandKitRevision = options["brand-kit-revision"] !== undefined ? requiredNumber(options["brand-kit-revision"], "--brand-kit-revision must be an integer.") : undefined;
|
|
216
|
+
const brief = firstString(options.brief);
|
|
217
|
+
const finalOutputCount = options.count !== undefined ? requiredNumber(options.count, "--count must be an integer.") : undefined;
|
|
218
|
+
const aspectRatios = repeatableStrings(options.ratio);
|
|
219
|
+
return client.planCampaign({
|
|
220
|
+
canvasId: firstString(options.canvas),
|
|
221
|
+
recipeId: firstString(options.recipe),
|
|
222
|
+
...(firstString(options["recipe-version"]) ? { recipeVersion: firstString(options["recipe-version"]) } : {}),
|
|
223
|
+
...(product ? { product } : {}),
|
|
224
|
+
...(brandKitId ? { brandKitId } : {}),
|
|
225
|
+
...(brandKitRevision !== undefined ? { brandKitRevision } : {}),
|
|
226
|
+
...(brief ? { brief } : {}),
|
|
227
|
+
...(finalOutputCount !== undefined ? { finalOutputCount } : {}),
|
|
228
|
+
...(aspectRatios.length ? { aspectRatios } : {}),
|
|
229
|
+
idempotencyKey: firstString(options["idempotency-key"]),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
if (action === "start") {
|
|
233
|
+
const supplied = await optionalJsonInput(options);
|
|
234
|
+
return client.startCampaign(
|
|
235
|
+
supplied || {
|
|
236
|
+
campaignId: firstString(options.campaign) || requiredArg(args, 0, "campaign start requires campaign:<id>."),
|
|
237
|
+
idempotencyKey: firstString(options["idempotency-key"]),
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if (action === "get") {
|
|
242
|
+
return client.getCampaign(firstString(options.run) || requiredArg(args, 0, "campaign get requires run:<id>."));
|
|
243
|
+
}
|
|
244
|
+
if (action === "review") {
|
|
245
|
+
const supplied = await optionalJsonInput(options);
|
|
246
|
+
const runId = supplied?.runId || firstString(options.run) || requiredArg(args, 0, "campaign review requires run:<id>.");
|
|
247
|
+
return client.reviewCampaign(
|
|
248
|
+
runId,
|
|
249
|
+
supplied || {
|
|
250
|
+
approvedOutputNodeIds: repeatableStrings(options.approved),
|
|
251
|
+
idempotencyKey: firstString(options["idempotency-key"]),
|
|
252
|
+
},
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (action === "cancel") {
|
|
256
|
+
requireConfirmation(options, "campaign cancel stops the campaign run and pending image work. Re-run with --yes.");
|
|
257
|
+
return client.cancelCampaign(firstString(options.run) || requiredArg(args, 0, "campaign cancel requires run:<id>."));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (group === "canvas") {
|
|
262
|
+
if (action === "list") return client.listCanvases(paginationOptions(options, 25));
|
|
263
|
+
if (action === "agent") return client.getOrCreateAgentCanvas();
|
|
264
|
+
if (action === "create") {
|
|
265
|
+
const input = (await optionalJsonInput(options)) || {
|
|
266
|
+
...(firstString(options.id) ? { id: firstString(options.id) } : {}),
|
|
267
|
+
title: firstString(options.title) || args.join(" ") || "Untitled canvas",
|
|
268
|
+
};
|
|
269
|
+
return client.createCanvas(input);
|
|
270
|
+
}
|
|
271
|
+
if (action === "get") return client.getCanvas(requiredArg(args, 0, "canvas get requires canvas:<id>."));
|
|
272
|
+
if (action === "render") {
|
|
273
|
+
const canvas = requiredArg(args, 0, "canvas render requires canvas:<id>.");
|
|
274
|
+
const svg = await client.renderCanvas(canvas);
|
|
275
|
+
const outputFile = firstString(options.file);
|
|
276
|
+
if (outputFile && outputFile !== "-") {
|
|
277
|
+
const filePath = path.resolve(outputFile);
|
|
278
|
+
await fs.writeFile(filePath, svg, "utf8");
|
|
279
|
+
return { mediaType: "image/svg+xml", file: filePath, bytes: Buffer.byteLength(svg) };
|
|
280
|
+
}
|
|
281
|
+
return { mediaType: "image/svg+xml", svg };
|
|
282
|
+
}
|
|
283
|
+
if (action === "apply") {
|
|
284
|
+
const canvas = requiredArg(args, 0, "canvas apply requires canvas:<id>.");
|
|
285
|
+
const input = (await optionalJsonInput(options)) || {};
|
|
286
|
+
const operations = input.operations || parseJsonOption(options.operations, "--operations must be a JSON array.");
|
|
287
|
+
if (!Array.isArray(operations)) throw usageError("canvas apply requires --json/--file or --operations with an operation array.");
|
|
288
|
+
if (operations.some((operation) => operation?.type === "node.delete" || operation?.type === "connection.delete")) {
|
|
289
|
+
requireConfirmation(options, "canvas apply contains destructive operations. Re-run with --yes.");
|
|
290
|
+
}
|
|
291
|
+
const baseRevision = input.baseRevision || firstString(options["base-revision"]) || (await client.getCanvas(canvas)).canvas.revision;
|
|
292
|
+
return client.applyOperations(canvas, {
|
|
293
|
+
baseRevision,
|
|
294
|
+
idempotencyKey: input.idempotencyKey || firstString(options["idempotency-key"]) || crypto.randomUUID(),
|
|
295
|
+
operations,
|
|
296
|
+
...(input.force === true || options.force === true ? { force: true } : {}),
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (group === "node") {
|
|
302
|
+
if (action === "get") {
|
|
303
|
+
return client.getNode(requiredArg(args, 0, "node get requires a canvas reference."), requiredArg(args, 1, "node get requires node:<id>."));
|
|
304
|
+
}
|
|
305
|
+
if (action === "create") {
|
|
306
|
+
const canvas = requiredArg(args, 0, "node create requires a canvas reference.");
|
|
307
|
+
const nodeInput = (await optionalJsonInput(options)) || nodeFromOptions(options);
|
|
308
|
+
return applySingleOperation(client, canvas, options, {
|
|
309
|
+
type: "node.create",
|
|
310
|
+
...(firstString(options["client-id"]) ? { clientId: firstString(options["client-id"]) } : {}),
|
|
311
|
+
node: nodeInput,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
if (action === "update") {
|
|
315
|
+
const canvas = requiredArg(args, 0, "node update requires a canvas reference.");
|
|
316
|
+
const nodeId = requiredArg(args, 1, "node update requires node:<id>.");
|
|
317
|
+
const patch = (await optionalJsonInput(options)) || nodePatchFromOptions(options);
|
|
318
|
+
if (!Object.keys(patch).length) throw usageError("node update needs --json/--file or a title, content, prompt, position, or size option.");
|
|
319
|
+
return applySingleOperation(client, canvas, options, { type: "node.update", nodeId, patch });
|
|
320
|
+
}
|
|
321
|
+
if (action === "move") {
|
|
322
|
+
const canvas = requiredArg(args, 0, "node move requires a canvas reference.");
|
|
323
|
+
const nodeId = requiredArg(args, 1, "node move requires node:<id>.");
|
|
324
|
+
return applySingleOperation(client, canvas, options, {
|
|
325
|
+
type: "node.move",
|
|
326
|
+
nodeId,
|
|
327
|
+
position: { x: requiredNumber(options.x, "--x is required."), y: requiredNumber(options.y, "--y is required.") },
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
if (action === "resize") {
|
|
331
|
+
const canvas = requiredArg(args, 0, "node resize requires a canvas reference.");
|
|
332
|
+
const nodeId = requiredArg(args, 1, "node resize requires node:<id>.");
|
|
333
|
+
return applySingleOperation(client, canvas, options, {
|
|
334
|
+
type: "node.resize",
|
|
335
|
+
nodeId,
|
|
336
|
+
width: requiredNumber(options.width, "--width is required."),
|
|
337
|
+
height: requiredNumber(options.height, "--height is required."),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
if (action === "delete") {
|
|
341
|
+
requireConfirmation(options, "node delete changes existing canvas work. Re-run with --yes.");
|
|
342
|
+
const canvas = requiredArg(args, 0, "node delete requires a canvas reference.");
|
|
343
|
+
const nodeId = requiredArg(args, 1, "node delete requires node:<id>.");
|
|
344
|
+
return applySingleOperation(client, canvas, options, { type: "node.delete", nodeId });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (group === "connection") {
|
|
349
|
+
if (action === "list") {
|
|
350
|
+
const canvas = await client.getCanvas(requiredArg(args, 0, "connection list requires a canvas reference."));
|
|
351
|
+
return { canvas: canvas.canvas.handle, revision: canvas.canvas.revision, connections: canvas.canvas.connections };
|
|
352
|
+
}
|
|
353
|
+
if (action === "create") {
|
|
354
|
+
const canvas = requiredArg(args, 0, "connection create requires a canvas reference.");
|
|
355
|
+
return applySingleOperation(client, canvas, options, {
|
|
356
|
+
type: "connection.create",
|
|
357
|
+
...(firstString(options["client-id"]) ? { clientId: firstString(options["client-id"]) } : {}),
|
|
358
|
+
from: firstString(options.from) || requiredArg(args, 1, "connection create requires --from node:<id>."),
|
|
359
|
+
to: firstString(options.to) || requiredArg(args, 2, "connection create requires --to node:<id>."),
|
|
360
|
+
...(firstString(options.mode) ? { mode: firstString(options.mode) } : {}),
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
if (action === "delete") {
|
|
364
|
+
requireConfirmation(options, "connection delete changes existing canvas work. Re-run with --yes.");
|
|
365
|
+
const canvas = requiredArg(args, 0, "connection delete requires a canvas reference.");
|
|
366
|
+
const connectionId = requiredArg(args, 1, "connection delete requires connection:<id>.");
|
|
367
|
+
return applySingleOperation(client, canvas, options, { type: "connection.delete", connectionId });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (group === "asset") {
|
|
372
|
+
if (action === "list") return client.listAssets(firstString(options.canvas) || args[0], paginationOptions(options, 200));
|
|
373
|
+
if (action === "get") return client.getAsset(requiredArg(args, 0, "asset get requires asset:<id> or asset:<ownerUid>:<id>."));
|
|
374
|
+
if (action === "upload") {
|
|
375
|
+
const reference = firstString(options.file) || requiredArg(args, 0, "asset upload requires an image path or clipboard.");
|
|
376
|
+
const [prepared] = await prepareReferenceInputs([reference], runtime);
|
|
377
|
+
if (prepared.kind !== "local") throw usageError("asset upload requires an image path, -, or clipboard.");
|
|
378
|
+
return client.uploadAsset(prepared);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (group === "ai-connection" || group === "provider") {
|
|
383
|
+
if (action === "list") return client.listConnections(paginationOptions(options, 100));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (group === "model") {
|
|
387
|
+
if (action === "list") {
|
|
388
|
+
return client.listModels(
|
|
389
|
+
{
|
|
390
|
+
query: firstString(options.query) || args.join(" "),
|
|
391
|
+
provider: firstString(options.provider),
|
|
392
|
+
capability: firstString(options.capability),
|
|
393
|
+
},
|
|
394
|
+
paginationOptions(options, 100),
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
if (action === "get") return client.getModel(firstString(options.model) || requiredArg(args, 0, "model get requires model:<id>."));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (group === "action") {
|
|
401
|
+
if (action === "list") return client.listActions(firstString(options.query) || args.join(" "), paginationOptions(options, 100));
|
|
402
|
+
if (action === "get") return client.getAction(firstString(options.action) || requiredArg(args, 0, "action get requires action:<id>."));
|
|
403
|
+
if (action === "run") {
|
|
404
|
+
const prepared = await actionInput(client, args, options, runtime);
|
|
405
|
+
const { __destination, __action, ...input } = prepared;
|
|
406
|
+
const queued = await client.startAction(__action, input);
|
|
407
|
+
if (options["no-wait"] === true) return { ...queued, destination: __destination };
|
|
408
|
+
const result = await client.waitForRun(queued.run || queued.id, {
|
|
409
|
+
timeoutMs: secondsOption(options.timeout, 15 * 60) * 1000,
|
|
410
|
+
intervalMs: secondsOption(options.interval, 1.5, true) * 1000,
|
|
411
|
+
onProgress:
|
|
412
|
+
options.progress === true
|
|
413
|
+
? (progress) => {
|
|
414
|
+
runtime.stderr.write(
|
|
415
|
+
`${JSON.stringify({
|
|
416
|
+
ok: true,
|
|
417
|
+
progress: {
|
|
418
|
+
run: progress.run || progress.handle || (progress.id ? progress.id.replace(/^job:/, "run:") : undefined),
|
|
419
|
+
id: progress.id,
|
|
420
|
+
status: progress.status,
|
|
421
|
+
},
|
|
422
|
+
})}\n`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
: undefined,
|
|
426
|
+
});
|
|
427
|
+
return { ...result, destination: __destination };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (group === "image" && (action === "generate" || action === "edit" || action === "variations")) {
|
|
432
|
+
const prepared = await imageInput(client, action, args, options, runtime);
|
|
433
|
+
const { __destination, ...input } = prepared;
|
|
434
|
+
const queued = await client.startImage(action, input);
|
|
435
|
+
const shouldWait = options["no-wait"] !== true;
|
|
436
|
+
if (!shouldWait) return { ...queued, destination: __destination };
|
|
437
|
+
const result = await client.waitForRun(queued.run || queued.id, {
|
|
438
|
+
timeoutMs: secondsOption(options.timeout, 15 * 60) * 1000,
|
|
439
|
+
intervalMs: secondsOption(options.interval, 1.5, true) * 1000,
|
|
440
|
+
onProgress:
|
|
441
|
+
options.progress === true
|
|
442
|
+
? (progress) => {
|
|
443
|
+
runtime.stderr.write(
|
|
444
|
+
`${JSON.stringify({
|
|
445
|
+
ok: true,
|
|
446
|
+
progress: {
|
|
447
|
+
run: progress.run || progress.handle || (progress.id ? progress.id.replace(/^job:/, "run:") : undefined),
|
|
448
|
+
id: progress.id,
|
|
449
|
+
status: progress.status,
|
|
450
|
+
},
|
|
451
|
+
})}\n`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
: undefined,
|
|
455
|
+
});
|
|
456
|
+
return { ...result, destination: __destination };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (group === "video" && action === "generate") {
|
|
460
|
+
const downloadPath = requestedVideoDownloadPath(options.download, "--download");
|
|
461
|
+
if (downloadPath && options["no-wait"] === true) throw usageError("--download cannot be combined with --no-wait. Use video download after the job succeeds.");
|
|
462
|
+
const input = await videoInput(client, options, runtime);
|
|
463
|
+
const queued = await client.startVideo(input);
|
|
464
|
+
if (options["no-wait"] === true) return queued;
|
|
465
|
+
const result = await client.waitForJob(queued.id, {
|
|
466
|
+
timeoutMs: secondsOption(options.timeout, 30 * 60) * 1000,
|
|
467
|
+
intervalMs: secondsOption(options.interval, 1.5, true) * 1000,
|
|
468
|
+
onProgress:
|
|
469
|
+
options.progress === true
|
|
470
|
+
? (progress) => {
|
|
471
|
+
runtime.stderr.write(
|
|
472
|
+
`${JSON.stringify({
|
|
473
|
+
ok: true,
|
|
474
|
+
progress: {
|
|
475
|
+
id: progress.id,
|
|
476
|
+
status: progress.status,
|
|
477
|
+
...(progress.progress === undefined ? {} : { percent: progress.progress }),
|
|
478
|
+
},
|
|
479
|
+
})}\n`,
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
: undefined,
|
|
483
|
+
});
|
|
484
|
+
if (!downloadPath || result?.status !== "succeeded") return result;
|
|
485
|
+
return { ...result, download: await saveVideoJobOutput(client, result.id || queued.id, downloadPath, options.yes === true) };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (group === "video" && action === "download") {
|
|
489
|
+
const job = requiredArg(args, 0, "video download requires job:<id>.");
|
|
490
|
+
const outputFile = requestedVideoDownloadPath(options.file, "--file");
|
|
491
|
+
if (!outputFile) throw usageError("video download requires --file path/to/video.mp4.");
|
|
492
|
+
return saveVideoJobOutput(client, job, outputFile, options.yes === true);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (group === "job") {
|
|
496
|
+
if (action === "get") return client.getJob(requiredArg(args, 0, "job get requires job:<id>."));
|
|
497
|
+
if (action === "wait") {
|
|
498
|
+
const job = requiredArg(args, 0, "job wait requires job:<id>.");
|
|
499
|
+
return client.waitForJob(job, {
|
|
500
|
+
timeoutMs: secondsOption(options.timeout, 15 * 60) * 1000,
|
|
501
|
+
intervalMs: secondsOption(options.interval, 1.5, true) * 1000,
|
|
502
|
+
onProgress:
|
|
503
|
+
options.progress === true
|
|
504
|
+
? (progress) => {
|
|
505
|
+
runtime.stderr.write(
|
|
506
|
+
`${JSON.stringify({
|
|
507
|
+
ok: true,
|
|
508
|
+
progress: {
|
|
509
|
+
run: progress.run || progress.handle || (progress.id ? progress.id.replace(/^job:/, "run:") : undefined),
|
|
510
|
+
id: progress.id,
|
|
511
|
+
status: progress.status,
|
|
512
|
+
},
|
|
513
|
+
})}\n`,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
: undefined,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
if (action === "cancel") {
|
|
520
|
+
requireConfirmation(options, "job cancel stops image, video, or Action work. Re-run with --yes.");
|
|
521
|
+
return client.cancelJob(requiredArg(args, 0, "job cancel requires job:<id>."));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (group === "run") {
|
|
526
|
+
if (action === "get") return client.getRun(requiredArg(args, 0, "run get requires run:<id>."));
|
|
527
|
+
if (action === "wait") {
|
|
528
|
+
const run = requiredArg(args, 0, "run wait requires run:<id>.");
|
|
529
|
+
return client.waitForRun(run, {
|
|
530
|
+
timeoutMs: secondsOption(options.timeout, 15 * 60) * 1000,
|
|
531
|
+
intervalMs: secondsOption(options.interval, 1.5, true) * 1000,
|
|
532
|
+
onProgress:
|
|
533
|
+
options.progress === true
|
|
534
|
+
? (progress) => {
|
|
535
|
+
runtime.stderr.write(`${JSON.stringify({ ok: true, progress: { run: progress.run || progress.handle || progress.id, status: progress.status } })}\n`);
|
|
536
|
+
}
|
|
537
|
+
: undefined,
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
if (action === "cancel") {
|
|
541
|
+
requireConfirmation(options, "run cancel stops active Recipe, image, or Action work. Re-run with --yes.");
|
|
542
|
+
return client.cancelRun(requiredArg(args, 0, "run cancel requires run:<id>."));
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
throw usageError(`Unknown command: ${[group, action].filter(Boolean).join(" ")}. Run with --help.`);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function runAuthCommand(action, _args, options, env, runtime) {
|
|
550
|
+
const fetchImpl = runtime.fetchImpl;
|
|
551
|
+
if (action === "login") {
|
|
552
|
+
const profile = firstString(options.profile) || env.GAVANA_PROFILE || env.CRAFTBOARD_PROFILE || "";
|
|
553
|
+
const profileEnv = profile ? { ...env, GAVANA_PROFILE: profile } : env;
|
|
554
|
+
const current = await readAgentConfig(profileEnv);
|
|
555
|
+
let token = firstString(options.token) || env.GAVANA_AGENT_TOKEN || env.CRAFTBOARD_AGENT_TOKEN || (options["token-stdin"] === true ? (await readAllStdin()).trim() : "");
|
|
556
|
+
const baseUrl = createCanvasAgentClient({
|
|
557
|
+
token: "configuration-validation",
|
|
558
|
+
baseUrl: firstString(options["base-url"]) || env.GAVANA_BASE_URL || env.CRAFTBOARD_BASE_URL || current.baseUrl || "https://app.gavana.ai",
|
|
559
|
+
fetchImpl: runtime.fetchImpl,
|
|
560
|
+
}).baseUrl;
|
|
561
|
+
let loginMethod = "token";
|
|
562
|
+
if (!token) {
|
|
563
|
+
const login = runtime.oauthLogin
|
|
564
|
+
? await runtime.oauthLogin({ baseUrl, readOnly: options["read-only"] === true, profile: profile || current.profile })
|
|
565
|
+
: await performBrowserOAuthLogin({ baseUrl, readOnly: options["read-only"] === true, noBrowser: options["no-browser"] === true, runtime });
|
|
566
|
+
token = login.accessToken;
|
|
567
|
+
loginMethod = "browser";
|
|
568
|
+
}
|
|
569
|
+
let verification;
|
|
570
|
+
let configPath;
|
|
571
|
+
try {
|
|
572
|
+
if (options["no-verify"] !== true) {
|
|
573
|
+
const client = createCanvasAgentClient({ token, baseUrl, surface: "cli", fetchImpl });
|
|
574
|
+
verification = await verifyAuthentication(client);
|
|
575
|
+
}
|
|
576
|
+
configPath = await writeAgentConfig({ token, baseUrl, authMode: loginMethod === "browser" ? "oauth" : "token", ...(profile ? { profile } : {}) }, env);
|
|
577
|
+
} catch (error) {
|
|
578
|
+
if (loginMethod === "browser") await revokeBrowserOAuthToken(baseUrl, token, fetchImpl || globalThis.fetch).catch(() => undefined);
|
|
579
|
+
throw error;
|
|
580
|
+
}
|
|
581
|
+
return {
|
|
582
|
+
authenticated: true,
|
|
583
|
+
profile: profile || current.profile,
|
|
584
|
+
baseUrl,
|
|
585
|
+
configPath,
|
|
586
|
+
token: maskToken(token),
|
|
587
|
+
verified: options["no-verify"] !== true,
|
|
588
|
+
loginMethod,
|
|
589
|
+
...(verification?.authType ? { authType: verification.authType } : {}),
|
|
590
|
+
...(Array.isArray(verification?.scopes) ? { scopes: verification.scopes } : {}),
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
if (action === "status") {
|
|
594
|
+
const profile = firstString(options.profile) || env.GAVANA_PROFILE || env.CRAFTBOARD_PROFILE || "";
|
|
595
|
+
const profileEnv = profile ? { ...env, GAVANA_PROFILE: profile } : env;
|
|
596
|
+
const config = await readAgentConfig(profileEnv);
|
|
597
|
+
const token = firstString(options.token) || env.GAVANA_AGENT_TOKEN || env.CRAFTBOARD_AGENT_TOKEN || config.token;
|
|
598
|
+
const baseUrl = firstString(options["base-url"]) || env.GAVANA_BASE_URL || env.CRAFTBOARD_BASE_URL || config.baseUrl;
|
|
599
|
+
if (!token || !baseUrl) return { authenticated: false, configured: false, configPath: agentConfigFilePath(env) };
|
|
600
|
+
const client = createCanvasAgentClient({ token, baseUrl, surface: "cli", fetchImpl });
|
|
601
|
+
const verification = await verifyAuthentication(client);
|
|
602
|
+
let canvasCount = verification.canvasCount;
|
|
603
|
+
if (canvasCount === undefined && (verification.authType !== "agent" || verification.scopes?.includes("canvas:read"))) {
|
|
604
|
+
try {
|
|
605
|
+
const canvases = await client.listCanvases();
|
|
606
|
+
canvasCount = Array.isArray(canvases?.canvases) ? canvases.canvases.length : 0;
|
|
607
|
+
} catch {
|
|
608
|
+
// Authentication already succeeded. Canvas availability is only
|
|
609
|
+
// optional status context and must not redefine token validity.
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return {
|
|
613
|
+
...verification,
|
|
614
|
+
profile: config.profile,
|
|
615
|
+
configured: Boolean(config.token && config.baseUrl),
|
|
616
|
+
baseUrl: client.baseUrl,
|
|
617
|
+
token: maskToken(token),
|
|
618
|
+
...(canvasCount === undefined ? {} : { canvasCount }),
|
|
619
|
+
configPath: agentConfigFilePath(env),
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
if (action === "logout") {
|
|
623
|
+
const profile = firstString(options.profile) || env.GAVANA_PROFILE || env.CRAFTBOARD_PROFILE || "";
|
|
624
|
+
const profileEnv = profile ? { ...env, GAVANA_PROFILE: profile } : env;
|
|
625
|
+
const config = await readAgentConfig(profileEnv);
|
|
626
|
+
if (config.authMode === "oauth" && config.token && config.baseUrl) {
|
|
627
|
+
await revokeBrowserOAuthToken(config.baseUrl, config.token, fetchImpl || globalThis.fetch);
|
|
628
|
+
}
|
|
629
|
+
return { authenticated: false, ...(await removeAgentProfile(profile, env)) };
|
|
630
|
+
}
|
|
631
|
+
throw usageError("auth supports login, status, and logout.");
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function revokeBrowserOAuthToken(baseUrl, token, fetchImpl) {
|
|
635
|
+
if (typeof fetchImpl !== "function") throw new CanvasAgentApiError("This runtime cannot revoke the browser login.", { code: "logout_failed" });
|
|
636
|
+
const response = await fetchImpl(`${String(baseUrl).replace(/\/+$/, "")}/oauth/revoke`, {
|
|
637
|
+
method: "POST",
|
|
638
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
639
|
+
body: new URLSearchParams({ token }),
|
|
640
|
+
});
|
|
641
|
+
if (!response.ok) {
|
|
642
|
+
const payload = await response.json().catch(() => null);
|
|
643
|
+
throw new CanvasAgentApiError(payload?.error_description || "Gavana could not revoke this browser login.", { status: response.status, code: "logout_failed" });
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export async function performBrowserOAuthLogin({ baseUrl, readOnly = false, noBrowser = false, runtime = {} }) {
|
|
648
|
+
const fetchImpl = runtime.fetchImpl || globalThis.fetch;
|
|
649
|
+
if (typeof fetchImpl !== "function") throw usageError("This runtime does not provide fetch for browser login.");
|
|
650
|
+
const origin = new URL(createCanvasAgentClient({ token: "configuration-validation", baseUrl, fetchImpl }).baseUrl).origin;
|
|
651
|
+
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
652
|
+
const challenge = crypto.createHash("sha256").update(verifier, "ascii").digest("base64url");
|
|
653
|
+
const state = crypto.randomBytes(24).toString("base64url");
|
|
654
|
+
const callback = runtime.createOAuthCallbackServer ? await runtime.createOAuthCallbackServer(state) : await createOAuthCallbackServer(state);
|
|
655
|
+
try {
|
|
656
|
+
const scopes = readOnly ? "canvas:read asset:read" : "canvas:read canvas:write asset:read image:generate video:generate job:manage";
|
|
657
|
+
const registrationResponse = await fetchImpl(`${origin}/oauth/register`, {
|
|
658
|
+
method: "POST",
|
|
659
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
660
|
+
body: JSON.stringify({
|
|
661
|
+
client_name: "Gavana CLI",
|
|
662
|
+
redirect_uris: [callback.redirectUri],
|
|
663
|
+
token_endpoint_auth_method: "none",
|
|
664
|
+
grant_types: ["authorization_code"],
|
|
665
|
+
response_types: ["code"],
|
|
666
|
+
scope: scopes,
|
|
667
|
+
}),
|
|
668
|
+
});
|
|
669
|
+
const registration = await registrationResponse.json().catch(() => null);
|
|
670
|
+
if (!registrationResponse.ok || !registration?.client_id) throw usageError(registration?.error_description || "Gavana could not register the CLI OAuth client.");
|
|
671
|
+
|
|
672
|
+
const authorize = new URL(`${origin}/oauth/authorize`);
|
|
673
|
+
authorize.search = new URLSearchParams({
|
|
674
|
+
response_type: "code",
|
|
675
|
+
client_id: registration.client_id,
|
|
676
|
+
redirect_uri: callback.redirectUri,
|
|
677
|
+
code_challenge: challenge,
|
|
678
|
+
code_challenge_method: "S256",
|
|
679
|
+
resource: `${origin}/api/canvas-agent/v1`,
|
|
680
|
+
scope: scopes,
|
|
681
|
+
state,
|
|
682
|
+
}).toString();
|
|
683
|
+
(runtime.stderr || process.stderr).write(`Open this URL to connect Gavana:\n${authorize}\n`);
|
|
684
|
+
if (!noBrowser) await (runtime.openBrowser ? runtime.openBrowser(authorize.toString()) : openBrowser(authorize.toString()));
|
|
685
|
+
const { code } = await callback.wait(runtime.oauthTimeoutMs || 180_000);
|
|
686
|
+
const tokenResponse = await fetchImpl(`${origin}/oauth/token`, {
|
|
687
|
+
method: "POST",
|
|
688
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
689
|
+
body: new URLSearchParams({
|
|
690
|
+
grant_type: "authorization_code",
|
|
691
|
+
code,
|
|
692
|
+
client_id: registration.client_id,
|
|
693
|
+
redirect_uri: callback.redirectUri,
|
|
694
|
+
code_verifier: verifier,
|
|
695
|
+
resource: `${origin}/api/canvas-agent/v1`,
|
|
696
|
+
}),
|
|
697
|
+
});
|
|
698
|
+
const payload = await tokenResponse.json().catch(() => null);
|
|
699
|
+
if (!tokenResponse.ok || !payload?.access_token) throw usageError(payload?.error_description || "Gavana could not finish CLI browser login.");
|
|
700
|
+
return { accessToken: payload.access_token, scope: payload.scope || scopes };
|
|
701
|
+
} finally {
|
|
702
|
+
await callback.close();
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async function createOAuthCallbackServer(expectedState) {
|
|
707
|
+
let settle;
|
|
708
|
+
let reject;
|
|
709
|
+
const result = new Promise((resolve, rejectResult) => {
|
|
710
|
+
settle = resolve;
|
|
711
|
+
reject = rejectResult;
|
|
712
|
+
});
|
|
713
|
+
const server = http.createServer((request, response) => {
|
|
714
|
+
const url = new URL(request.url || "/", "http://127.0.0.1");
|
|
715
|
+
if (url.pathname !== "/oauth/callback") {
|
|
716
|
+
response.writeHead(404).end("Not found");
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (url.searchParams.get("state") !== expectedState) {
|
|
720
|
+
response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }).end("Gavana login state did not match. Return to the terminal and retry.");
|
|
721
|
+
reject(usageError("Gavana browser login state did not match."));
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const error = url.searchParams.get("error");
|
|
725
|
+
const code = url.searchParams.get("code");
|
|
726
|
+
if (error || !code) {
|
|
727
|
+
response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }).end("Gavana login was not approved. You can close this window.");
|
|
728
|
+
reject(usageError(url.searchParams.get("error_description") || error || "Gavana browser login was not approved."));
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end("<!doctype html><title>Gavana connected</title><p>Gavana CLI is connected. You can close this window.</p>");
|
|
732
|
+
settle({ code });
|
|
733
|
+
});
|
|
734
|
+
await new Promise((resolve, rejectListen) => {
|
|
735
|
+
server.once("error", rejectListen);
|
|
736
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
737
|
+
});
|
|
738
|
+
const address = server.address();
|
|
739
|
+
if (!address || typeof address === "string") throw usageError("Gavana could not start the local OAuth callback.");
|
|
740
|
+
return {
|
|
741
|
+
redirectUri: `http://127.0.0.1:${address.port}/oauth/callback`,
|
|
742
|
+
wait: (timeoutMs) =>
|
|
743
|
+
Promise.race([
|
|
744
|
+
result,
|
|
745
|
+
new Promise((_, rejectTimeout) => {
|
|
746
|
+
const timer = setTimeout(() => rejectTimeout(usageError("Gavana browser login timed out. Run auth login again.")), timeoutMs);
|
|
747
|
+
timer.unref?.();
|
|
748
|
+
}),
|
|
749
|
+
]),
|
|
750
|
+
close: () => new Promise((resolve) => server.close(() => resolve())),
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async function openBrowser(url) {
|
|
755
|
+
if (process.platform === "darwin") return execFile("open", [url]);
|
|
756
|
+
if (process.platform === "win32") return execFile("cmd", ["/c", "start", "", url]);
|
|
757
|
+
return execFile("xdg-open", [url]);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
async function runConfigCommand(action, args, options, env) {
|
|
761
|
+
if (action === "list" || action === "profiles" || !action) return listAgentProfiles(env);
|
|
762
|
+
if (action === "use") {
|
|
763
|
+
const profile = firstString(options.profile) || requiredArg(args, 0, "config use requires a profile name.");
|
|
764
|
+
return { active: true, ...(await setActiveAgentProfile(profile, env)) };
|
|
765
|
+
}
|
|
766
|
+
if (action === "get") {
|
|
767
|
+
const profile = firstString(options.profile) || args[0] || "";
|
|
768
|
+
const config = await readAgentConfig(profile ? { ...env, GAVANA_PROFILE: profile } : env);
|
|
769
|
+
return {
|
|
770
|
+
profile: config.profile,
|
|
771
|
+
baseUrl: config.baseUrl || "",
|
|
772
|
+
configured: Boolean(config.token && config.baseUrl),
|
|
773
|
+
token: config.token ? maskToken(config.token) : "",
|
|
774
|
+
configPath: agentConfigFilePath(env),
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
throw usageError("config supports list, get, and use.");
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async function runDoctor(client, config, env, baseUrl, credentialSource = "") {
|
|
781
|
+
const checks = [
|
|
782
|
+
{ name: "node", ok: Number(process.versions.node.split(".")[0]) >= 20, detail: process.version },
|
|
783
|
+
{
|
|
784
|
+
name: "credentials",
|
|
785
|
+
ok: Boolean(client),
|
|
786
|
+
detail: credentialSource || (config.token && config.baseUrl ? agentConfigFilePath(env) : client ? "Environment or command-line override" : "Run gavana auth login or set GAVANA_AGENT_TOKEN."),
|
|
787
|
+
},
|
|
788
|
+
{ name: "base_url", ok: /^https:\/\//.test(baseUrl) || /^http:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?(?:\/|$)/.test(baseUrl), detail: baseUrl },
|
|
789
|
+
];
|
|
790
|
+
if (!client) {
|
|
791
|
+
checks.push({ name: "authentication", ok: false, detail: "No token is configured. Run gavana auth login." });
|
|
792
|
+
return { ok: false, profile: config.profile, checks };
|
|
793
|
+
}
|
|
794
|
+
try {
|
|
795
|
+
const status = await verifyAuthentication(client);
|
|
796
|
+
checks.push({ name: "authentication", ok: Boolean(status?.authenticated !== false), detail: status?.authType || "verified" });
|
|
797
|
+
} catch (error) {
|
|
798
|
+
const normalized = normalizeCliError(error);
|
|
799
|
+
checks.push({ name: "authentication", ok: false, detail: normalized.message, code: normalized.code, requestId: normalized.requestId });
|
|
800
|
+
}
|
|
801
|
+
return { ok: checks.every((check) => check.ok), profile: config.profile, checks };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
async function runApiCommand(client, methodOrPath, args, options) {
|
|
805
|
+
const knownMethod = /^(get|post|put|patch|delete)$/i.test(methodOrPath || "");
|
|
806
|
+
const method = knownMethod ? methodOrPath.toUpperCase() : "GET";
|
|
807
|
+
const pathname = knownMethod ? requiredArg(args, 0, "api requires a versioned path such as /canvases.") : methodOrPath;
|
|
808
|
+
if (!pathname || !/^\/[A-Za-z0-9_./-]*$/.test(pathname) || pathname.includes("..")) throw usageError("api path must stay inside /api/canvas-agent/v1.");
|
|
809
|
+
const fields = Object.fromEntries(
|
|
810
|
+
repeatableStrings(options.field).map((field) => {
|
|
811
|
+
const separator = field.indexOf("=");
|
|
812
|
+
if (separator < 1) throw usageError("--field requires NAME=VALUE.");
|
|
813
|
+
return [field.slice(0, separator), field.slice(separator + 1)];
|
|
814
|
+
}),
|
|
815
|
+
);
|
|
816
|
+
const supplied = await optionalJsonInput(options);
|
|
817
|
+
const input = supplied || (Object.keys(fields).length ? fields : undefined);
|
|
818
|
+
if (method === "GET" && input) return client.request(pathname, { query: input });
|
|
819
|
+
return client.request(pathname, { method, ...(input === undefined ? {} : { body: input }) });
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function runMcpCommand(action, args, options, baseUrl, runtime) {
|
|
823
|
+
if (action !== "install" && action !== "config") throw usageError("mcp supports install and config.");
|
|
824
|
+
const clientName = (firstString(options.client) || args[0] || "codex").toLowerCase();
|
|
825
|
+
if (action === "install" && clientName !== "codex" && clientName !== "claude") {
|
|
826
|
+
throw usageError("mcp install supports codex and claude. Use mcp config for cursor, chatgpt, or local clients.");
|
|
827
|
+
}
|
|
828
|
+
const endpoint = `${baseUrl}${options["read-only"] === true ? "/mcp/readonly" : "/mcp"}`;
|
|
829
|
+
const definition = mcpClientDefinition(clientName, endpoint, options["read-only"] === true);
|
|
830
|
+
if (action === "config" || !definition.command) return definition;
|
|
831
|
+
const execute = runtime.execFile || execFile;
|
|
832
|
+
await execute(definition.command, definition.args);
|
|
833
|
+
return { ...definition, installed: true };
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function mcpClientDefinition(clientName, endpoint, readOnly = false) {
|
|
837
|
+
if (clientName === "codex") return { client: "codex", transport: "streamable-http", endpoint, command: "codex", args: ["mcp", "add", "gavana", "--url", endpoint] };
|
|
838
|
+
if (clientName === "claude") return { client: "claude", transport: "streamable-http", endpoint, command: "claude", args: ["mcp", "add", "--transport", "http", "gavana", endpoint] };
|
|
839
|
+
if (clientName === "cursor") return { client: "cursor", transport: "streamable-http", endpoint, config: { mcpServers: { gavana: { url: endpoint } } } };
|
|
840
|
+
if (clientName === "chatgpt") return { client: "chatgpt", transport: "streamable-http", endpoint, instructions: "Add the endpoint as a custom MCP connector, then complete Gavana OAuth in the browser." };
|
|
841
|
+
if (clientName === "stdio" || clientName === "local") {
|
|
842
|
+
return {
|
|
843
|
+
client: "local",
|
|
844
|
+
transport: "stdio",
|
|
845
|
+
command: "npx",
|
|
846
|
+
args: ["-y", "@gavana.ai/mcp@0.2.0"],
|
|
847
|
+
env: readOnly ? { GAVANA_MCP_READ_ONLY: "true" } : {},
|
|
848
|
+
credentialSource: "Reads the active Gavana CLI profile or inherited GAVANA_BASE_URL and GAVANA_AGENT_TOKEN environment variables.",
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
throw usageError("mcp client must be codex, claude, cursor, chatgpt, or local.");
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function completionScript(shell) {
|
|
855
|
+
// Generated from the command table, so a new command is completable the moment
|
|
856
|
+
// it is declared. This list used to be a third hand-maintained copy of the
|
|
857
|
+
// catalog, beside dispatch and help.
|
|
858
|
+
// Hidden groups and hidden actions fall out inside the table, so there is no
|
|
859
|
+
// second list here to drift against it.
|
|
860
|
+
const groups = gavanaCommandGroups();
|
|
861
|
+
const commands = groups.join(" ");
|
|
862
|
+
// Second level: the actions under each group. The table knows them, so the
|
|
863
|
+
// shells may as well.
|
|
864
|
+
const actionsByGroup = groups.map((group) => [group, gavanaCommandActions(group)]).filter(([, actions]) => actions.length);
|
|
865
|
+
|
|
866
|
+
if (shell === "zsh") {
|
|
867
|
+
const cases = actionsByGroup.map(([group, actions]) => ` ${group}) _values 'action' ${actions.join(" ")} ;;`).join("\n");
|
|
868
|
+
// The case has to sit inside the ->args state. `*::arg:->args` re-slices
|
|
869
|
+
// $words so $words[1] is the group, but only once _arguments has set $state;
|
|
870
|
+
// running the case unconditionally reads $words[1] as "gavana" and matches
|
|
871
|
+
// nothing, which is how the previous version silently completed no actions.
|
|
872
|
+
// `(( CURRENT == 2 ))` keeps actions to the action position. Inside ->args
|
|
873
|
+
// the words array is re-sliced so the group is $words[1] and the action is
|
|
874
|
+
// $words[2]; without the check the same action list is offered for every
|
|
875
|
+
// later argument, so `gavana canvas list <TAB>` re-offered the canvas
|
|
876
|
+
// actions where a canvas handle belongs.
|
|
877
|
+
return `#compdef gavana\n_arguments '1:command:(${commands})' '*::arg:->args'\ncase $state in\n args)\n if (( CURRENT == 2 )); then\n case $words[1] in\n${cases}\n esac\n fi\n ;;\nesac`;
|
|
878
|
+
}
|
|
879
|
+
if (shell === "bash") {
|
|
880
|
+
const cases = actionsByGroup.map(([group, actions]) => ` ${group}) words='${actions.join(" ")}' ;;`).join("\n");
|
|
881
|
+
// Groups complete at position 1 and actions at position 2, and nowhere else.
|
|
882
|
+
// Falling back to the root list meant `gavana doctor <TAB>` offered every
|
|
883
|
+
// group as a doctor action, and a group with no actions offered them forever.
|
|
884
|
+
return `_gavana_complete() {\n local words=''\n if [ "\${COMP_CWORD}" -eq 1 ]; then\n words='${commands}'\n elif [ "\${COMP_CWORD}" -eq 2 ]; then\n case "\${COMP_WORDS[1]}" in\n${cases}\n esac\n fi\n COMPREPLY=( $(compgen -W "\${words}" -- "\${COMP_WORDS[COMP_CWORD]}") )\n}\ncomplete -F _gavana_complete gavana`;
|
|
885
|
+
}
|
|
886
|
+
if (shell === "fish") {
|
|
887
|
+
return [
|
|
888
|
+
...groups.map((command) => `complete -c gavana -f -n '__fish_use_subcommand' -a '${command}'`),
|
|
889
|
+
// `not __fish_seen_subcommand_from <actions>` is fish's equivalent of the
|
|
890
|
+
// zsh position check: __fish_seen_subcommand_from stays true for the rest
|
|
891
|
+
// of the line, so without it the group's actions were offered again after
|
|
892
|
+
// one had already been chosen.
|
|
893
|
+
...actionsByGroup.flatMap(([group, actions]) =>
|
|
894
|
+
actions.map((action) => `complete -c gavana -f -n '__fish_seen_subcommand_from ${group}; and not __fish_seen_subcommand_from ${actions.join(" ")}' -a '${action}'`),
|
|
895
|
+
),
|
|
896
|
+
].join("\n");
|
|
897
|
+
}
|
|
898
|
+
throw usageError("completion supports zsh, bash, and fish.");
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async function verifyAuthentication(client) {
|
|
902
|
+
try {
|
|
903
|
+
return await client.getAuthStatus();
|
|
904
|
+
} catch (error) {
|
|
905
|
+
if (!(error instanceof CanvasAgentApiError) || error.status !== 404) throw error;
|
|
906
|
+
const canvases = await client.listCanvases();
|
|
907
|
+
return {
|
|
908
|
+
authenticated: true,
|
|
909
|
+
authType: "legacy",
|
|
910
|
+
canvasCount: Array.isArray(canvases?.canvases) ? canvases.canvases.length : 0,
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
async function applySingleOperation(client, canvas, options, operation) {
|
|
916
|
+
const baseRevision = firstString(options["base-revision"]) || (await client.getCanvas(canvas)).canvas.revision;
|
|
917
|
+
return client.applyOperations(canvas, {
|
|
918
|
+
baseRevision,
|
|
919
|
+
idempotencyKey: firstString(options["idempotency-key"]) || crypto.randomUUID(),
|
|
920
|
+
operations: [operation],
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
async function recipeRunInput(client, args, options, runtime) {
|
|
925
|
+
const supplied = (await optionalJsonInput(options)) || {};
|
|
926
|
+
const recipeReference = supplied.recipeId || firstString(options.recipe) || requiredArg(args, 0, "recipe run requires recipe:<id>.");
|
|
927
|
+
const definition = (await client.getRecipe(recipeReference, supplied.version || firstString(options.version))).recipe;
|
|
928
|
+
const destination = supplied.destination || firstString(options.destination) || supplied.canvasId || firstString(options.canvas);
|
|
929
|
+
if (!destination) throw usageError("recipe run requires --destination agent-canvas, new-canvas, or canvas:<id>.");
|
|
930
|
+
const canvasTitle = supplied.canvasTitle || firstString(options["canvas-title"]);
|
|
931
|
+
if (destination === "new-canvas" && !canvasTitle) throw usageError("recipe run with --destination new-canvas requires --canvas-title.");
|
|
932
|
+
const idempotencyKey = supplied.idempotencyKey || firstString(options["idempotency-key"]) || crypto.randomUUID();
|
|
933
|
+
const canvasResult = await client.resolveCanvasDestination(destination, {
|
|
934
|
+
idempotencyKey,
|
|
935
|
+
title: canvasTitle,
|
|
936
|
+
});
|
|
937
|
+
const canvasId = canvasResult.canvas.handle;
|
|
938
|
+
const suppliedOptionInputs = repeatableStrings(options.input);
|
|
939
|
+
if (supplied.inputs && suppliedOptionInputs.length) throw usageError("Provide Recipe inputs in JSON or with --input KEY=VALUE, not both.");
|
|
940
|
+
const requestedInputs = supplied.inputs || recipeInputPairs(suppliedOptionInputs);
|
|
941
|
+
const instanceNodeId = supplied.instanceNodeId || firstString(options["instance-node"]);
|
|
942
|
+
if ((!requestedInputs || !Object.keys(requestedInputs).length) && !instanceNodeId) {
|
|
943
|
+
throw usageError("recipe run requires one --input KEY=VALUE for every declared input, or --instance-node for a connected private Recipe.");
|
|
944
|
+
}
|
|
945
|
+
const inputs = await prepareRecipeInputs(client, definition, requestedInputs || {}, canvasId, runtime);
|
|
946
|
+
const webhook = cliWebhookInput(supplied, options, runtime.env);
|
|
947
|
+
return {
|
|
948
|
+
canvasId,
|
|
949
|
+
baseRevision: supplied.baseRevision || firstString(options["base-revision"]) || canvasResult.canvas.revision,
|
|
950
|
+
idempotencyKey,
|
|
951
|
+
...(supplied.version || firstString(options.version) ? { version: supplied.version || firstString(options.version) } : {}),
|
|
952
|
+
...(instanceNodeId ? { instanceNodeId } : {}),
|
|
953
|
+
...(Object.keys(inputs).length ? { inputs } : {}),
|
|
954
|
+
...(supplied.model || firstString(options.model) ? { model: supplied.model || firstString(options.model) } : {}),
|
|
955
|
+
...(supplied.size || firstString(options.size) ? { size: supplied.size || firstString(options.size) } : {}),
|
|
956
|
+
...(supplied.quality || firstString(options.quality) ? { quality: supplied.quality || firstString(options.quality) } : {}),
|
|
957
|
+
...(webhook ? { webhook } : {}),
|
|
958
|
+
__recipe: definition.handle || recipeReference,
|
|
959
|
+
__destination: {
|
|
960
|
+
requested: String(destination),
|
|
961
|
+
canvasId,
|
|
962
|
+
},
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function recipeInputPairs(entries) {
|
|
967
|
+
const inputs = {};
|
|
968
|
+
for (const entry of entries) {
|
|
969
|
+
const separator = entry.indexOf("=");
|
|
970
|
+
if (separator < 1) throw usageError("--input requires KEY=VALUE for Recipe runs.");
|
|
971
|
+
const key = entry.slice(0, separator).trim();
|
|
972
|
+
const value = entry.slice(separator + 1).trim();
|
|
973
|
+
if (!key || !value) throw usageError("--input requires a non-empty KEY=VALUE.");
|
|
974
|
+
if (inputs[key] !== undefined) throw usageError(`Recipe input ${key} was provided more than once.`);
|
|
975
|
+
inputs[key] = value;
|
|
976
|
+
}
|
|
977
|
+
return inputs;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
async function prepareRecipeInputs(client, definition, requested, canvasReference, runtime) {
|
|
981
|
+
if (!requested || typeof requested !== "object" || Array.isArray(requested)) throw usageError("Recipe inputs must be KEY=VALUE pairs.");
|
|
982
|
+
const aliases = new Map();
|
|
983
|
+
const firstWords = new Map();
|
|
984
|
+
for (const input of definition.inputs || []) {
|
|
985
|
+
aliases.set(input.nodeId, input);
|
|
986
|
+
aliases.set(normalizeRecipeInputKey(input.label), input);
|
|
987
|
+
const firstWord = normalizeRecipeInputKey(input.label).split("-")[0];
|
|
988
|
+
firstWords.set(firstWord, firstWords.has(firstWord) ? null : input);
|
|
989
|
+
}
|
|
990
|
+
for (const [key, input] of firstWords) {
|
|
991
|
+
if (input) aliases.set(key, input);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const normalized = {};
|
|
995
|
+
for (const [key, rawValue] of Object.entries(requested)) {
|
|
996
|
+
const port = aliases.get(key) || aliases.get(normalizeRecipeInputKey(key));
|
|
997
|
+
if (!port) throw usageError(`Unknown Recipe input ${key}. Run recipe get ${definition.handle || `recipe:${definition.id}`} to see its input keys.`);
|
|
998
|
+
if (normalized[port.nodeId] !== undefined) throw usageError(`Recipe input ${port.label} was provided more than once.`);
|
|
999
|
+
if (typeof rawValue !== "string" || !rawValue.trim()) throw usageError(`Recipe input ${port.label} must be a non-empty string.`);
|
|
1000
|
+
const value = rawValue.trim();
|
|
1001
|
+
if (port.nodeType === "image") {
|
|
1002
|
+
const imageValue = value.startsWith("@") ? value.slice(1) : value;
|
|
1003
|
+
const prepared = await prepareReferenceInput(imageValue, runtime);
|
|
1004
|
+
normalized[port.nodeId] = (await uploadLocalReferenceInputs(client, [prepared], canvasReference))[0];
|
|
1005
|
+
} else if (/^(?:node:|asset:)/.test(value)) {
|
|
1006
|
+
normalized[port.nodeId] = value;
|
|
1007
|
+
} else if (value.startsWith("@@")) {
|
|
1008
|
+
normalized[port.nodeId] = value.slice(1);
|
|
1009
|
+
} else if (value.startsWith("@")) {
|
|
1010
|
+
const file = value.slice(1);
|
|
1011
|
+
if (!file) throw usageError(`Recipe input ${port.label} uses @path/to/file for text files.`);
|
|
1012
|
+
normalized[port.nodeId] = file === "-" ? await readAllStdin() : await fs.readFile(path.resolve(file), "utf8");
|
|
1013
|
+
} else {
|
|
1014
|
+
normalized[port.nodeId] = value;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
return normalized;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function normalizeRecipeInputKey(value) {
|
|
1021
|
+
return String(value || "")
|
|
1022
|
+
.trim()
|
|
1023
|
+
.toLowerCase()
|
|
1024
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1025
|
+
.replace(/^-|-$/g, "");
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
async function actionInput(client, args, options, runtime) {
|
|
1029
|
+
const supplied = (await optionalJsonInput(options)) || {};
|
|
1030
|
+
const webhook = cliWebhookInput(supplied, options, runtime.env);
|
|
1031
|
+
const actionReference = supplied.actionId || firstString(options.action) || requiredArg(args, 0, "action run requires action:<id>.");
|
|
1032
|
+
const definition = (await client.getAction(actionReference)).action;
|
|
1033
|
+
const destination = supplied.destination || firstString(options.destination) || supplied.canvasId || firstString(options.canvas);
|
|
1034
|
+
if (!destination) throw usageError("action run requires --destination agent-canvas, new-canvas, or canvas:<id>.");
|
|
1035
|
+
const canvasTitle = supplied.canvasTitle || firstString(options["canvas-title"]);
|
|
1036
|
+
if (destination === "new-canvas" && !canvasTitle) throw usageError("action run with --destination new-canvas requires --canvas-title.");
|
|
1037
|
+
const idempotencyKey = supplied.idempotencyKey || firstString(options["idempotency-key"]) || crypto.randomUUID();
|
|
1038
|
+
const rawInputs = repeatableStrings(supplied.inputs || options.input);
|
|
1039
|
+
if (rawInputs.length !== definition.inputs.length) {
|
|
1040
|
+
throw usageError(`${definition.title} requires exactly ${definition.inputs.length} --input value${definition.inputs.length === 1 ? "" : "s"} in the order shown by action get.`);
|
|
1041
|
+
}
|
|
1042
|
+
const preparedInputs = await prepareReferenceInputs(rawInputs, runtime);
|
|
1043
|
+
const canvasResult = await client.resolveCanvasDestination(destination, {
|
|
1044
|
+
idempotencyKey,
|
|
1045
|
+
title: canvasTitle,
|
|
1046
|
+
});
|
|
1047
|
+
const canvasId = canvasResult.canvas.handle;
|
|
1048
|
+
const inputs = await uploadLocalReferenceInputs(client, preparedInputs, canvasId);
|
|
1049
|
+
const params = actionParameters(definition, supplied.params, options);
|
|
1050
|
+
const targetNodeId = supplied.targetNodeId || firstString(options.target) || firstString(options["target-node"]);
|
|
1051
|
+
const preparedDestination = await client.prepareImageDestination({
|
|
1052
|
+
canvasResult,
|
|
1053
|
+
destination,
|
|
1054
|
+
operation: "action",
|
|
1055
|
+
idempotencyKey,
|
|
1056
|
+
baseRevision: supplied.baseRevision || firstString(options["base-revision"]),
|
|
1057
|
+
targetNodeId,
|
|
1058
|
+
count: 1,
|
|
1059
|
+
targetTitle: supplied.targetTitle || firstString(options.title) || `${definition.title} result`,
|
|
1060
|
+
});
|
|
1061
|
+
return {
|
|
1062
|
+
canvasId,
|
|
1063
|
+
baseRevision: preparedDestination.baseRevision,
|
|
1064
|
+
idempotencyKey,
|
|
1065
|
+
targetNodeId: preparedDestination.targetNodeIds[0],
|
|
1066
|
+
inputs,
|
|
1067
|
+
params,
|
|
1068
|
+
...(webhook ? { webhook } : {}),
|
|
1069
|
+
__action: definition.id,
|
|
1070
|
+
__destination: {
|
|
1071
|
+
requested: String(destination),
|
|
1072
|
+
canvasId,
|
|
1073
|
+
targetNodeIds: preparedDestination.targetNodeIds,
|
|
1074
|
+
},
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function actionParameters(definition, suppliedParams, options) {
|
|
1079
|
+
const params = suppliedParams && typeof suppliedParams === "object" && !Array.isArray(suppliedParams) ? { ...suppliedParams } : {};
|
|
1080
|
+
for (const entry of repeatableStrings(options.param)) {
|
|
1081
|
+
const separator = entry.indexOf("=");
|
|
1082
|
+
if (separator < 1) throw usageError("--param requires NAME=VALUE.");
|
|
1083
|
+
params[entry.slice(0, separator)] = entry.slice(separator + 1);
|
|
1084
|
+
}
|
|
1085
|
+
for (const parameter of definition?.parameters || []) {
|
|
1086
|
+
const optionName = String(parameter.id).replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
1087
|
+
if (options[optionName] === undefined) continue;
|
|
1088
|
+
const raw = firstString(options[optionName]);
|
|
1089
|
+
if (parameter.type === "number" || parameter.valueType === "number") {
|
|
1090
|
+
params[parameter.id] = requiredNumber(raw, `--${optionName} must be a number.`);
|
|
1091
|
+
} else {
|
|
1092
|
+
params[parameter.id] = raw;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
return params;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
async function imageInput(client, operation, args, options, runtime) {
|
|
1099
|
+
const supplied = (await optionalJsonInput(options)) || {};
|
|
1100
|
+
const webhook = cliWebhookInput(supplied, options, runtime.env);
|
|
1101
|
+
const destination = supplied.destination || firstString(options.destination) || supplied.canvasId || firstString(options.canvas) || args[0];
|
|
1102
|
+
if (!destination) throw usageError(`image ${operation} requires --destination agent-canvas, new-canvas, or canvas:<id>.`);
|
|
1103
|
+
const canvasTitle = supplied.canvasTitle || firstString(options["canvas-title"]);
|
|
1104
|
+
if (destination === "new-canvas" && !canvasTitle) throw usageError(`image ${operation} with --destination new-canvas requires --canvas-title.`);
|
|
1105
|
+
const idempotencyKey = supplied.idempotencyKey || firstString(options["idempotency-key"]) || crypto.randomUUID();
|
|
1106
|
+
const targetNodeIds = supplied.targetNodeIds || repeatableStrings(options.target);
|
|
1107
|
+
const singleTarget = supplied.targetNodeId || firstString(options["target-node"]);
|
|
1108
|
+
const count = supplied.count !== undefined || options.count !== undefined ? (supplied.count ?? requiredNumber(options.count, "--count must be a number.")) : undefined;
|
|
1109
|
+
if (count !== undefined && (!Number.isInteger(Number(count)) || Number(count) < 1 || Number(count) > 4)) throw usageError("--count must be an integer between 1 and 4.");
|
|
1110
|
+
const referenceInputs = imageReferenceInputs(supplied.references, options.reference, supplied.referenceRoles, options["reference-role"]);
|
|
1111
|
+
const preparedReferences = await prepareReferenceInputs(referenceInputs.map((reference) => reference.value), runtime);
|
|
1112
|
+
const canvasResult = await client.resolveCanvasDestination(destination, {
|
|
1113
|
+
idempotencyKey,
|
|
1114
|
+
title: canvasTitle,
|
|
1115
|
+
});
|
|
1116
|
+
const canvasId = canvasResult.canvas.handle;
|
|
1117
|
+
const referenceHandles = await uploadLocalReferenceInputs(client, preparedReferences, canvasId);
|
|
1118
|
+
const references = referenceHandles.map((handle, index) => ({ handle, ...(referenceInputs[index]?.role ? { role: referenceInputs[index].role } : {}) }));
|
|
1119
|
+
const source = supplied.source || firstString(options.source);
|
|
1120
|
+
const prompt = supplied.prompt || firstString(options.prompt);
|
|
1121
|
+
const preparedDestination = await client.prepareImageDestination({
|
|
1122
|
+
canvasResult,
|
|
1123
|
+
destination,
|
|
1124
|
+
operation,
|
|
1125
|
+
idempotencyKey,
|
|
1126
|
+
baseRevision: supplied.baseRevision || firstString(options["base-revision"]),
|
|
1127
|
+
targetNodeIds,
|
|
1128
|
+
targetNodeId: singleTarget,
|
|
1129
|
+
count,
|
|
1130
|
+
prompt,
|
|
1131
|
+
size: supplied.size || firstString(options.size),
|
|
1132
|
+
targetTitle: supplied.targetTitle || firstString(options.title),
|
|
1133
|
+
targetX: supplied.targetX ?? (options.x === undefined ? undefined : requiredNumber(options.x, "--x must be a number.")),
|
|
1134
|
+
targetY: supplied.targetY ?? (options.y === undefined ? undefined : requiredNumber(options.y, "--y must be a number.")),
|
|
1135
|
+
targetWidth: supplied.targetWidth ?? (options.width === undefined ? undefined : requiredNumber(options.width, "--width must be a number.")),
|
|
1136
|
+
targetHeight: supplied.targetHeight ?? (options.height === undefined ? undefined : requiredNumber(options.height, "--height must be a number.")),
|
|
1137
|
+
});
|
|
1138
|
+
const {
|
|
1139
|
+
destination: _destination,
|
|
1140
|
+
canvasTitle: _canvasTitle,
|
|
1141
|
+
targetTitle: _targetTitle,
|
|
1142
|
+
targetNodeId: _targetNodeId,
|
|
1143
|
+
targetNodeIds: _targetNodeIds,
|
|
1144
|
+
targetX: _targetX,
|
|
1145
|
+
targetY: _targetY,
|
|
1146
|
+
targetWidth: _targetWidth,
|
|
1147
|
+
targetHeight: _targetHeight,
|
|
1148
|
+
...jobSupplied
|
|
1149
|
+
} = supplied;
|
|
1150
|
+
return {
|
|
1151
|
+
...jobSupplied,
|
|
1152
|
+
canvasId,
|
|
1153
|
+
baseRevision: preparedDestination.baseRevision,
|
|
1154
|
+
idempotencyKey,
|
|
1155
|
+
targetNodeIds: preparedDestination.targetNodeIds,
|
|
1156
|
+
...(prompt ? { prompt } : {}),
|
|
1157
|
+
...(supplied.promptNodeId || firstString(options["prompt-node"]) ? { promptNodeId: supplied.promptNodeId || firstString(options["prompt-node"]) } : {}),
|
|
1158
|
+
...(references.length ? { references: references.some((reference) => reference.role) ? references : referenceHandles } : {}),
|
|
1159
|
+
...(source ? { source } : {}),
|
|
1160
|
+
...(supplied.connectionId || firstString(options.connection) ? { connectionId: supplied.connectionId || firstString(options.connection) } : {}),
|
|
1161
|
+
...(supplied.model || firstString(options.model) ? { model: supplied.model || firstString(options.model) } : {}),
|
|
1162
|
+
...(supplied.size || firstString(options.size) ? { size: supplied.size || firstString(options.size) } : {}),
|
|
1163
|
+
...(supplied.quality || firstString(options.quality) ? { quality: supplied.quality || firstString(options.quality) } : {}),
|
|
1164
|
+
...(count !== undefined ? { count } : {}),
|
|
1165
|
+
...(webhook ? { webhook } : {}),
|
|
1166
|
+
__destination: {
|
|
1167
|
+
requested: String(destination),
|
|
1168
|
+
canvasId,
|
|
1169
|
+
targetNodeIds: preparedDestination.targetNodeIds,
|
|
1170
|
+
},
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function imageReferenceInputs(supplied, optionReferences, suppliedRoles, optionRoles) {
|
|
1175
|
+
const rawReferences = supplied === undefined ? repeatableStrings(optionReferences) : Array.isArray(supplied) ? supplied : (() => { throw usageError("references must be an array."); })();
|
|
1176
|
+
const roles = suppliedRoles === undefined ? repeatableStrings(optionRoles) : Array.isArray(suppliedRoles) ? suppliedRoles.map(String) : (() => { throw usageError("referenceRoles must be an array."); })();
|
|
1177
|
+
if (roles.length && roles.length !== rawReferences.length) throw usageError("Provide one --reference-role for every --reference, in the same order.");
|
|
1178
|
+
return rawReferences.map((entry, index) => {
|
|
1179
|
+
const typed = entry && typeof entry === "object" && !Array.isArray(entry) ? entry : null;
|
|
1180
|
+
if (typed && Object.keys(typed).some((key) => key !== "handle" && key !== "role")) throw usageError('A typed reference may contain only "handle" and "role".');
|
|
1181
|
+
const value = typeof entry === "string" ? entry.trim() : typeof typed?.handle === "string" ? typed.handle.trim() : "";
|
|
1182
|
+
if (!value) throw usageError("Each image reference needs a handle or local image path.");
|
|
1183
|
+
const suppliedRole = typed?.role;
|
|
1184
|
+
if (suppliedRole !== undefined && typeof suppliedRole !== "string") throw usageError("A typed reference role must be a string.");
|
|
1185
|
+
if (suppliedRole !== undefined && roles.length) throw usageError("Set a reference role in JSON or with --reference-role, not both.");
|
|
1186
|
+
const role = roles.length ? roles[index]?.trim() : typeof suppliedRole === "string" ? suppliedRole.trim() : "";
|
|
1187
|
+
if (role && !["identity", "construction", "texture", "fit", "style"].includes(role)) {
|
|
1188
|
+
throw usageError("Reference roles must be identity, construction, texture, fit, or style.");
|
|
1189
|
+
}
|
|
1190
|
+
return { value, ...(role ? { role } : {}) };
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
async function videoInput(client, options, runtime) {
|
|
1195
|
+
const supplied = (await optionalJsonInput(options)) || {};
|
|
1196
|
+
const modelReference = videoStringValue(supplied.model, options.model, "--model", true);
|
|
1197
|
+
if (!modelReference) throw usageError("video generate requires --model model:<id> or a connected provider model id.");
|
|
1198
|
+
const connectionId = videoStringValue(supplied.connectionId, options.connection, "--connection");
|
|
1199
|
+
const prompt = videoStringValue(supplied.prompt, options.prompt, "--prompt", true);
|
|
1200
|
+
if (!prompt) throw usageError("video generate requires --prompt.");
|
|
1201
|
+
if (prompt.length > 8_000) throw usageError("--prompt must contain 8,000 characters or fewer.");
|
|
1202
|
+
|
|
1203
|
+
const firstFrameInput = videoStringValue(supplied.firstFrame, options["first-frame"], "--first-frame");
|
|
1204
|
+
const lastFrameInput = videoStringValue(supplied.lastFrame, options["last-frame"], "--last-frame");
|
|
1205
|
+
if (lastFrameInput && !firstFrameInput) throw usageError("--last-frame requires --first-frame.");
|
|
1206
|
+
const rawReferences = repeatableStrings(supplied.references || options.reference);
|
|
1207
|
+
if (rawReferences.length > MAX_VIDEO_REFERENCE_IMAGES) {
|
|
1208
|
+
throw usageError(`Video generation accepts up to ${MAX_VIDEO_REFERENCE_IMAGES} --reference values.`);
|
|
1209
|
+
}
|
|
1210
|
+
const canvasId = videoStringValue(supplied.canvasId, options.canvas, "--canvas");
|
|
1211
|
+
const nodeInputs = [firstFrameInput, lastFrameInput, ...rawReferences].filter((value) => String(value || "").startsWith("node:"));
|
|
1212
|
+
if (nodeInputs.length && !canvasId) throw usageError("--canvas canvas:<id> is required when a video frame or reference uses node:<id>.");
|
|
1213
|
+
|
|
1214
|
+
if (options.duration !== undefined && typeof options.duration !== "string") throw usageError("--duration requires a whole number of seconds.");
|
|
1215
|
+
const durationSeconds = supplied.durationSeconds !== undefined || options.duration !== undefined ? (supplied.durationSeconds ?? requiredNumber(options.duration, "--duration must be a whole number of seconds.")) : undefined;
|
|
1216
|
+
if (durationSeconds !== undefined && (!Number.isInteger(Number(durationSeconds)) || Number(durationSeconds) < 1 || Number(durationSeconds) > 120)) {
|
|
1217
|
+
throw usageError("--duration must be a whole number from 1 through 120.");
|
|
1218
|
+
}
|
|
1219
|
+
const aspectRatio = videoStringValue(supplied.aspectRatio, options["aspect-ratio"], "--aspect-ratio");
|
|
1220
|
+
const resolution = videoStringValue(supplied.resolution, options.resolution, "--resolution");
|
|
1221
|
+
const generateAudio = videoAudioOption(supplied, options);
|
|
1222
|
+
const idempotencyKey = videoStringValue(supplied.idempotencyKey, options["idempotency-key"], "--idempotency-key") || crypto.randomUUID();
|
|
1223
|
+
if (idempotencyKey.length < 8 || idempotencyKey.length > 200) {
|
|
1224
|
+
throw usageError("--idempotency-key must contain 8 through 200 characters.");
|
|
1225
|
+
}
|
|
1226
|
+
const ownerUid = videoStringValue(supplied.ownerUid, undefined, "ownerUid");
|
|
1227
|
+
|
|
1228
|
+
const model = await resolveCliVideoModel(client, modelReference, connectionId);
|
|
1229
|
+
validateVideoModelInput(model, {
|
|
1230
|
+
firstFrame: firstFrameInput,
|
|
1231
|
+
lastFrame: lastFrameInput,
|
|
1232
|
+
references: rawReferences,
|
|
1233
|
+
durationSeconds,
|
|
1234
|
+
aspectRatio,
|
|
1235
|
+
resolution,
|
|
1236
|
+
generateAudio,
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
const preparedFirstFrame = firstFrameInput ? await prepareVideoReferenceInput(String(firstFrameInput), runtime) : undefined;
|
|
1240
|
+
const preparedLastFrame = lastFrameInput ? await prepareVideoReferenceInput(String(lastFrameInput), runtime) : undefined;
|
|
1241
|
+
const preparedReferences = await Promise.all(rawReferences.map((value) => prepareVideoReferenceInput(value, runtime)));
|
|
1242
|
+
ensureUniqueVideoReferences(preparedReferences);
|
|
1243
|
+
const uploadedFrames = await uploadLocalReferenceInputs(client, [preparedFirstFrame, preparedLastFrame, ...preparedReferences].filter(Boolean));
|
|
1244
|
+
const firstFrame = preparedFirstFrame ? uploadedFrames.shift() : undefined;
|
|
1245
|
+
const lastFrame = preparedLastFrame ? uploadedFrames.shift() : undefined;
|
|
1246
|
+
|
|
1247
|
+
return {
|
|
1248
|
+
model: model.handle || modelReference,
|
|
1249
|
+
...(connectionId ? { connectionId } : {}),
|
|
1250
|
+
prompt,
|
|
1251
|
+
idempotencyKey,
|
|
1252
|
+
...(canvasId ? { canvasId } : {}),
|
|
1253
|
+
...(ownerUid ? { ownerUid } : {}),
|
|
1254
|
+
...(aspectRatio ? { aspectRatio } : {}),
|
|
1255
|
+
...(durationSeconds !== undefined ? { durationSeconds: Number(durationSeconds) } : {}),
|
|
1256
|
+
...(resolution ? { resolution } : {}),
|
|
1257
|
+
...(generateAudio !== undefined ? { generateAudio } : {}),
|
|
1258
|
+
...(firstFrame ? { firstFrame } : {}),
|
|
1259
|
+
...(lastFrame ? { lastFrame } : {}),
|
|
1260
|
+
...(uploadedFrames.length ? { references: uploadedFrames } : {}),
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function requestedVideoDownloadPath(value, label) {
|
|
1265
|
+
if (value === undefined) return "";
|
|
1266
|
+
const outputPath = firstString(value).trim();
|
|
1267
|
+
if (!outputPath) throw usageError(`${label} requires an output file path.`);
|
|
1268
|
+
return outputPath;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
async function saveVideoJobOutput(client, jobReference, requestedPath, overwrite) {
|
|
1272
|
+
const filePath = path.resolve(requestedPath);
|
|
1273
|
+
const temporaryPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.gavana-${process.pid}-${crypto.randomUUID()}.part`);
|
|
1274
|
+
if (!overwrite) {
|
|
1275
|
+
try {
|
|
1276
|
+
await fs.stat(filePath);
|
|
1277
|
+
throw usageError(`Output file already exists: ${filePath}. Re-run with --yes to replace it.`);
|
|
1278
|
+
} catch (error) {
|
|
1279
|
+
if (error instanceof CanvasAgentApiError) throw error;
|
|
1280
|
+
if (error?.code !== "ENOENT") throw error;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
const response = await client.downloadJobOutput(jobReference);
|
|
1284
|
+
if (!response?.body) throw new CanvasAgentApiError("Gavana returned an empty video download.", { code: "invalid_response" });
|
|
1285
|
+
let file;
|
|
1286
|
+
let bytes = 0;
|
|
1287
|
+
try {
|
|
1288
|
+
file = await fs.open(temporaryPath, "wx", 0o600);
|
|
1289
|
+
for await (const chunk of response.body) {
|
|
1290
|
+
const buffer = Buffer.from(chunk);
|
|
1291
|
+
bytes += buffer.byteLength;
|
|
1292
|
+
await file.write(buffer);
|
|
1293
|
+
}
|
|
1294
|
+
await file.close();
|
|
1295
|
+
file = undefined;
|
|
1296
|
+
if (overwrite) {
|
|
1297
|
+
await fs.rename(temporaryPath, filePath);
|
|
1298
|
+
} else {
|
|
1299
|
+
try {
|
|
1300
|
+
await fs.link(temporaryPath, filePath);
|
|
1301
|
+
} catch (error) {
|
|
1302
|
+
if (error?.code === "EEXIST") throw usageError(`Output file already exists: ${filePath}. Re-run with --yes to replace it.`);
|
|
1303
|
+
throw error;
|
|
1304
|
+
}
|
|
1305
|
+
await fs.unlink(temporaryPath).catch(() => undefined);
|
|
1306
|
+
}
|
|
1307
|
+
return {
|
|
1308
|
+
job: jobReference,
|
|
1309
|
+
file: filePath,
|
|
1310
|
+
bytes,
|
|
1311
|
+
contentType: response.headers.get("content-type") || "video/mp4",
|
|
1312
|
+
};
|
|
1313
|
+
} catch (error) {
|
|
1314
|
+
await file?.close().catch(() => undefined);
|
|
1315
|
+
await fs.unlink(temporaryPath).catch(() => undefined);
|
|
1316
|
+
throw error;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function videoStringValue(jsonValue, optionValue, label, required = false) {
|
|
1321
|
+
if (jsonValue !== undefined && optionValue !== undefined) throw usageError(`Configure ${label.replace(/^--/, "")} in JSON or with ${label}, not both.`);
|
|
1322
|
+
const value = jsonValue !== undefined ? jsonValue : optionValue;
|
|
1323
|
+
if (value === undefined || value === null) return "";
|
|
1324
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
1325
|
+
if (required) return "";
|
|
1326
|
+
throw usageError(`${label} requires a non-empty value.`);
|
|
1327
|
+
}
|
|
1328
|
+
return value.trim();
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function videoAudioOption(supplied, options) {
|
|
1332
|
+
if (options.audio !== undefined && options.audio !== true) throw usageError("--audio does not take a value. Use --no-audio to disable it.");
|
|
1333
|
+
if (options["no-audio"] !== undefined && options["no-audio"] !== true) throw usageError("--no-audio does not take a value.");
|
|
1334
|
+
if (options.audio === true && options["no-audio"] === true) throw usageError("Use either --audio or --no-audio, not both.");
|
|
1335
|
+
if (supplied.generateAudio !== undefined && (options.audio === true || options["no-audio"] === true)) {
|
|
1336
|
+
throw usageError("Configure generateAudio in JSON or with --audio/--no-audio, not both.");
|
|
1337
|
+
}
|
|
1338
|
+
const value = options.audio === true ? true : options["no-audio"] === true ? false : supplied.generateAudio;
|
|
1339
|
+
if (value !== undefined && typeof value !== "boolean") throw usageError("generateAudio must be true or false.");
|
|
1340
|
+
return value;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
async function resolveCliVideoModel(client, reference, connectionId) {
|
|
1344
|
+
if (reference.startsWith("model:")) {
|
|
1345
|
+
const result = await client.getModel(reference);
|
|
1346
|
+
const model = result?.model;
|
|
1347
|
+
if (!model) throw usageError(`Gavana did not return ${reference}.`);
|
|
1348
|
+
if (connectionId && model.connectionId !== connectionId) throw usageError("--connection does not match the selected model handle.");
|
|
1349
|
+
return model;
|
|
1350
|
+
}
|
|
1351
|
+
const result = await client.listModels({ query: reference, capability: "video.generate" }, { limit: 100 });
|
|
1352
|
+
const matches = (result?.models || []).filter((model) => model.type === "video" && model.modelId === reference && (!connectionId || model.connectionId === connectionId));
|
|
1353
|
+
if (!matches.length) throw usageError(`Video model ${reference} was not found. Run gavana model list --capability video.generate.`);
|
|
1354
|
+
if (matches.length > 1) throw usageError(`Video model ${reference} exists on more than one connection. Use its model:<id> handle from gavana model list.`);
|
|
1355
|
+
return matches[0];
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function validateVideoModelInput(model, input) {
|
|
1359
|
+
if (model.type !== "video" || !Array.isArray(model.capabilities) || !model.capabilities.includes("video.generate")) {
|
|
1360
|
+
throw usageError(`${model.handle || model.modelId || "The selected model"} cannot generate video.`);
|
|
1361
|
+
}
|
|
1362
|
+
if (input.firstFrame && !model.capabilities.includes("video.generate.fromImage")) {
|
|
1363
|
+
throw usageError(`${model.name || model.modelId} does not support --first-frame.`);
|
|
1364
|
+
}
|
|
1365
|
+
if (input.lastFrame && !model.capabilities.includes("video.generate.fromFrames")) {
|
|
1366
|
+
throw usageError(`${model.name || model.modelId} does not support --last-frame.`);
|
|
1367
|
+
}
|
|
1368
|
+
if (input.references.length && !model.capabilities.includes("video.generate.fromReferences")) {
|
|
1369
|
+
throw usageError(`${model.name || model.modelId} does not support --reference.`);
|
|
1370
|
+
}
|
|
1371
|
+
const parameters = Array.isArray(model.parameters) ? model.parameters : [];
|
|
1372
|
+
const firstFrameParameter = parameters.find((parameter) => parameter.name === "first_frame");
|
|
1373
|
+
if (firstFrameParameter?.required && !input.firstFrame) {
|
|
1374
|
+
throw usageError(`${model.name || model.modelId} requires --first-frame.`);
|
|
1375
|
+
}
|
|
1376
|
+
validateVideoModelOption(model, parameters, ["duration", "duration_seconds"], input.durationSeconds, "--duration");
|
|
1377
|
+
validateVideoModelOption(model, parameters, ["aspect_ratio", "aspectRatio"], input.aspectRatio, "--aspect-ratio");
|
|
1378
|
+
validateVideoModelOption(model, parameters, ["resolution"], input.resolution, "--resolution");
|
|
1379
|
+
if (input.generateAudio !== undefined && !parameters.some((parameter) => parameter.name === "generate_audio")) {
|
|
1380
|
+
throw usageError(`${model.name || model.modelId} does not support --audio/--no-audio.`);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
function validateVideoModelOption(model, parameters, names, value, flag) {
|
|
1385
|
+
if (value === undefined || value === "") return;
|
|
1386
|
+
const parameter = parameters.find((candidate) => names.includes(candidate.name));
|
|
1387
|
+
if (!parameter) throw usageError(`${model.name || model.modelId} does not support ${flag}.`);
|
|
1388
|
+
if (Array.isArray(parameter.options) && parameter.options.length) {
|
|
1389
|
+
const supported = parameter.options.map((option) => String(option.value));
|
|
1390
|
+
if (!supported.includes(String(value))) {
|
|
1391
|
+
throw usageError(`${model.name || model.modelId} does not support ${flag} ${value}. Supported values: ${supported.join(", ")}.`);
|
|
1392
|
+
}
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
const numeric = Number(value);
|
|
1396
|
+
if (parameter.min !== undefined && numeric < Number(parameter.min)) throw usageError(`${flag} must be at least ${parameter.min} for ${model.name || model.modelId}.`);
|
|
1397
|
+
if (parameter.max !== undefined && numeric > Number(parameter.max)) throw usageError(`${flag} must be at most ${parameter.max} for ${model.name || model.modelId}.`);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function cliWebhookInput(supplied, options, env) {
|
|
1401
|
+
const optionUrl = firstString(options["webhook-url"]);
|
|
1402
|
+
if (options["webhook-url"] !== undefined && !optionUrl) throw usageError("--webhook-url requires a public HTTPS URL.");
|
|
1403
|
+
if (supplied.webhook && optionUrl) throw usageError("Configure webhook in the JSON input or with --webhook-url, not both.");
|
|
1404
|
+
if (supplied.webhook) return supplied.webhook;
|
|
1405
|
+
|
|
1406
|
+
const requestedSecretEnv = firstString(options["webhook-secret-env"]);
|
|
1407
|
+
if (options["webhook-secret-env"] !== undefined && !requestedSecretEnv) throw usageError("--webhook-secret-env requires an environment variable name.");
|
|
1408
|
+
if (!optionUrl && requestedSecretEnv) throw usageError("--webhook-secret-env requires --webhook-url.");
|
|
1409
|
+
if (!optionUrl) return undefined;
|
|
1410
|
+
|
|
1411
|
+
const secretEnv = requestedSecretEnv || (env?.GAVANA_WEBHOOK_SECRET ? "GAVANA_WEBHOOK_SECRET" : env?.CRAFTBOARD_WEBHOOK_SECRET ? "CRAFTBOARD_WEBHOOK_SECRET" : "GAVANA_WEBHOOK_SECRET");
|
|
1412
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(secretEnv)) throw usageError("--webhook-secret-env must name a valid environment variable.");
|
|
1413
|
+
const secret = String(env?.[secretEnv] || "");
|
|
1414
|
+
if (secret.length < 32 || secret.length > 512) {
|
|
1415
|
+
throw usageError(`${secretEnv} must contain a webhook secret between 32 and 512 characters.`);
|
|
1416
|
+
}
|
|
1417
|
+
return { url: optionUrl, secret };
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
async function prepareReferenceInputs(values, runtime) {
|
|
1421
|
+
if (values.length > MAX_REFERENCE_IMAGES) throw usageError(`An image job can use up to ${MAX_REFERENCE_IMAGES} references.`);
|
|
1422
|
+
return Promise.all(values.map((value) => prepareReferenceInput(value, runtime)));
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
async function prepareReferenceInput(value, runtime) {
|
|
1426
|
+
if (/^(?:node:[A-Za-z0-9_-]{1,180}|asset:(?:[A-Za-z0-9_-]{1,180}:)?[A-Za-z0-9_-]{1,180})$/.test(value)) return { kind: "handle", handle: value };
|
|
1427
|
+
|
|
1428
|
+
let bytes;
|
|
1429
|
+
let fileName;
|
|
1430
|
+
if (value === "clipboard") {
|
|
1431
|
+
const readClipboardImage = runtime.readClipboardImage || readMacClipboardImage;
|
|
1432
|
+
({ bytes, fileName } = await readClipboardImage());
|
|
1433
|
+
} else if (value === "-") {
|
|
1434
|
+
bytes = await readBinaryStdin();
|
|
1435
|
+
fileName = "stdin-image";
|
|
1436
|
+
} else {
|
|
1437
|
+
const filePath = path.resolve(value);
|
|
1438
|
+
let stat;
|
|
1439
|
+
try {
|
|
1440
|
+
stat = await fs.stat(filePath);
|
|
1441
|
+
} catch {
|
|
1442
|
+
throw usageError(`Reference must be node:<id>, asset:<id>, an image path, -, or clipboard: ${value}`);
|
|
1443
|
+
}
|
|
1444
|
+
if (!stat.isFile()) throw usageError(`Reference image path is not a file: ${value}`);
|
|
1445
|
+
if (stat.size > MAX_REFERENCE_IMAGE_BYTES) throw usageError(`Reference image is too large: ${value}`);
|
|
1446
|
+
bytes = await fs.readFile(filePath);
|
|
1447
|
+
fileName = path.basename(filePath);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
const imageBytes = Buffer.from(bytes || []);
|
|
1451
|
+
if (!imageBytes.byteLength) throw usageError(`Reference image is empty: ${value}`);
|
|
1452
|
+
if (imageBytes.byteLength > MAX_REFERENCE_IMAGE_BYTES) throw usageError(`Reference image is too large: ${value}`);
|
|
1453
|
+
if (!sniffRasterImageContentType(imageBytes)) throw usageError(`Reference image must be a PNG, JPEG, WebP, or GIF: ${value}`);
|
|
1454
|
+
return { kind: "local", bytes: imageBytes, fileName: cleanUploadFileName(fileName) };
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
async function prepareVideoReferenceInput(value, runtime) {
|
|
1458
|
+
if (/^https:\/\//i.test(value)) {
|
|
1459
|
+
let url;
|
|
1460
|
+
try {
|
|
1461
|
+
url = new URL(value);
|
|
1462
|
+
} catch {
|
|
1463
|
+
throw usageError(`Video image URL is invalid: ${value}`);
|
|
1464
|
+
}
|
|
1465
|
+
if (url.username || url.password) throw usageError("Video image URLs cannot contain credentials.");
|
|
1466
|
+
return { kind: "url", url: url.toString() };
|
|
1467
|
+
}
|
|
1468
|
+
return prepareReferenceInput(value, runtime);
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function ensureUniqueVideoReferences(references) {
|
|
1472
|
+
const keys = references.map((reference) => (reference.kind === "local" ? `local:${crypto.createHash("sha256").update(reference.bytes).digest("base64url")}` : `${reference.kind}:${reference.handle || reference.url}`));
|
|
1473
|
+
if (new Set(keys).size !== keys.length) throw usageError("Video --reference values must be unique.");
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
async function uploadLocalReferenceInputs(client, references, canvasReference) {
|
|
1477
|
+
const handles = [];
|
|
1478
|
+
const uploads = new Map();
|
|
1479
|
+
for (const reference of references) {
|
|
1480
|
+
if (reference.kind === "handle") {
|
|
1481
|
+
handles.push(reference.handle);
|
|
1482
|
+
continue;
|
|
1483
|
+
}
|
|
1484
|
+
if (reference.kind === "url") {
|
|
1485
|
+
handles.push(reference.url);
|
|
1486
|
+
continue;
|
|
1487
|
+
}
|
|
1488
|
+
const fingerprint = crypto.createHash("sha256").update(reference.bytes).digest("base64url");
|
|
1489
|
+
if (!uploads.has(fingerprint)) uploads.set(fingerprint, client.uploadAsset({ ...reference, canvasReference }));
|
|
1490
|
+
const uploaded = await uploads.get(fingerprint);
|
|
1491
|
+
handles.push(uploaded.asset.handle);
|
|
1492
|
+
}
|
|
1493
|
+
return handles;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
async function readMacClipboardImage() {
|
|
1497
|
+
if (process.platform !== "darwin") throw usageError("clipboard references are currently available on macOS. Pass an image path instead.");
|
|
1498
|
+
const script = [
|
|
1499
|
+
"ObjC.import('AppKit');",
|
|
1500
|
+
"ObjC.import('stdlib');",
|
|
1501
|
+
"const pasteboard = $.NSPasteboard.generalPasteboard;",
|
|
1502
|
+
"const types = ['public.png', 'public.jpeg', 'org.webmproject.webp', 'com.compuserve.gif'];",
|
|
1503
|
+
"let data = null; let selectedType = '';",
|
|
1504
|
+
"for (const type of types) { const candidate = pasteboard.dataForType($(type)); if (candidate) { data = candidate; selectedType = type; break; } }",
|
|
1505
|
+
"if (!data) $.exit(2);",
|
|
1506
|
+
"$.puts(ObjC.unwrap(data.base64EncodedStringWithOptions(0)));",
|
|
1507
|
+
"$.puts(selectedType);",
|
|
1508
|
+
].join("\n");
|
|
1509
|
+
try {
|
|
1510
|
+
const { stdout } = await execFile("osascript", ["-l", "JavaScript", "-e", script], { encoding: "utf8", maxBuffer: Math.ceil(MAX_REFERENCE_IMAGE_BYTES * 1.4) });
|
|
1511
|
+
const [encoded, mediaType] = stdout.trim().split(/\r?\n/);
|
|
1512
|
+
const bytes = Buffer.from(encoded || "", "base64");
|
|
1513
|
+
if (!bytes.byteLength) throw new Error("empty clipboard");
|
|
1514
|
+
const extension = mediaType === "public.jpeg" ? "jpg" : mediaType === "org.webmproject.webp" ? "webp" : mediaType === "com.compuserve.gif" ? "gif" : "png";
|
|
1515
|
+
return { bytes, fileName: `clipboard.${extension}` };
|
|
1516
|
+
} catch {
|
|
1517
|
+
throw usageError("Clipboard does not contain a PNG, JPEG, WebP, or GIF image. Copy an image or pass its file path.");
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function sniffRasterImageContentType(bytes) {
|
|
1522
|
+
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return "image/png";
|
|
1523
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
|
|
1524
|
+
if (bytes.length >= 12 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") return "image/webp";
|
|
1525
|
+
if (bytes.length >= 6 && (bytes.toString("ascii", 0, 6) === "GIF87a" || bytes.toString("ascii", 0, 6) === "GIF89a")) return "image/gif";
|
|
1526
|
+
return "";
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
function cleanUploadFileName(value) {
|
|
1530
|
+
const name = path.basename(String(value || "reference-image"));
|
|
1531
|
+
return name.replace(/[\r\n]/g, " ").slice(0, 160) || "reference-image";
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
function nodeFromOptions(options) {
|
|
1535
|
+
const metadata = parseJsonOption(options.metadata, "--metadata must be a JSON object.") || {};
|
|
1536
|
+
if (firstString(options.content)) metadata.content = firstString(options.content);
|
|
1537
|
+
if (firstString(options.prompt)) metadata.prompt = firstString(options.prompt);
|
|
1538
|
+
return {
|
|
1539
|
+
type: firstString(options.type) || "text",
|
|
1540
|
+
title: firstString(options.title) || "Agent Node",
|
|
1541
|
+
position: { x: numberOption(options.x, 0), y: numberOption(options.y, 0) },
|
|
1542
|
+
...(options.width !== undefined ? { width: requiredNumber(options.width, "--width must be a number.") } : {}),
|
|
1543
|
+
...(options.height !== undefined ? { height: requiredNumber(options.height, "--height must be a number.") } : {}),
|
|
1544
|
+
...(Object.keys(metadata).length ? { metadata } : {}),
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function nodePatchFromOptions(options) {
|
|
1549
|
+
const patch = {};
|
|
1550
|
+
if (firstString(options.title)) patch.title = firstString(options.title);
|
|
1551
|
+
if (options.x !== undefined || options.y !== undefined) {
|
|
1552
|
+
if (options.x === undefined || options.y === undefined) throw usageError("Pass both --x and --y when updating a node position.");
|
|
1553
|
+
patch.position = { x: requiredNumber(options.x, "--x must be a number."), y: requiredNumber(options.y, "--y must be a number.") };
|
|
1554
|
+
}
|
|
1555
|
+
if (options.width !== undefined) patch.width = requiredNumber(options.width, "--width must be a number.");
|
|
1556
|
+
if (options.height !== undefined) patch.height = requiredNumber(options.height, "--height must be a number.");
|
|
1557
|
+
const metadata = parseJsonOption(options.metadata, "--metadata must be a JSON object.") || {};
|
|
1558
|
+
if (options.content !== undefined) metadata.content = firstString(options.content);
|
|
1559
|
+
if (options.prompt !== undefined) metadata.prompt = firstString(options.prompt);
|
|
1560
|
+
if (Object.keys(metadata).length) patch.metadata = metadata;
|
|
1561
|
+
return patch;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
async function optionalJsonInput(options) {
|
|
1565
|
+
const inline = firstString(options.json);
|
|
1566
|
+
const file = firstString(options.file);
|
|
1567
|
+
if (inline && file) throw usageError("Use either --json or --file, not both.");
|
|
1568
|
+
if (inline) return parseJson(inline, "--json contains invalid JSON.");
|
|
1569
|
+
if (file) {
|
|
1570
|
+
const text = file === "-" ? await readAllStdin() : await fs.readFile(path.resolve(file), "utf8");
|
|
1571
|
+
return parseJson(text, `${file} contains invalid JSON.`);
|
|
1572
|
+
}
|
|
1573
|
+
return null;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
function parseArguments(argv) {
|
|
1577
|
+
const positionals = [];
|
|
1578
|
+
const options = {};
|
|
1579
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1580
|
+
const argument = argv[index];
|
|
1581
|
+
if (argument === "-r") {
|
|
1582
|
+
options.raw = true;
|
|
1583
|
+
continue;
|
|
1584
|
+
}
|
|
1585
|
+
if (argument === "-h") {
|
|
1586
|
+
options.help = true;
|
|
1587
|
+
continue;
|
|
1588
|
+
}
|
|
1589
|
+
if (argument === "-v" || argument === "-V") {
|
|
1590
|
+
options.version = true;
|
|
1591
|
+
continue;
|
|
1592
|
+
}
|
|
1593
|
+
if (argument === "--") {
|
|
1594
|
+
positionals.push(...argv.slice(index + 1));
|
|
1595
|
+
break;
|
|
1596
|
+
}
|
|
1597
|
+
if (!argument.startsWith("--")) {
|
|
1598
|
+
positionals.push(argument);
|
|
1599
|
+
continue;
|
|
1600
|
+
}
|
|
1601
|
+
const equalsAt = argument.indexOf("=");
|
|
1602
|
+
const key = argument.slice(2, equalsAt >= 0 ? equalsAt : undefined);
|
|
1603
|
+
let value;
|
|
1604
|
+
if (equalsAt >= 0) {
|
|
1605
|
+
value = argument.slice(equalsAt + 1);
|
|
1606
|
+
} else if (BOOLEAN_OPTIONS.has(key)) {
|
|
1607
|
+
value = true;
|
|
1608
|
+
} else if (argv[index + 1] !== undefined && !argv[index + 1].startsWith("--")) {
|
|
1609
|
+
value = argv[index + 1];
|
|
1610
|
+
index += 1;
|
|
1611
|
+
} else {
|
|
1612
|
+
value = true;
|
|
1613
|
+
}
|
|
1614
|
+
if (REPEATABLE_OPTIONS.has(key)) {
|
|
1615
|
+
options[key] = [...(Array.isArray(options[key]) ? options[key] : options[key] === undefined ? [] : [options[key]]), value];
|
|
1616
|
+
} else {
|
|
1617
|
+
options[key] = value;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
return { positionals, options };
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function writeResult(stdout, result, options) {
|
|
1624
|
+
const output = firstString(options.output) || "json";
|
|
1625
|
+
const jq = firstString(options.jq);
|
|
1626
|
+
const selected = jq ? selectJsonPath(result, jq) : result;
|
|
1627
|
+
const raw = options.raw === true || output === "raw";
|
|
1628
|
+
|
|
1629
|
+
if (output === "markdown") {
|
|
1630
|
+
stdout.write(`${markdownForResult(selected)}\n`);
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
if (output === "human") {
|
|
1634
|
+
stdout.write(`${humanOutput(selected)}\n`);
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
if (raw) {
|
|
1638
|
+
if (Array.isArray(selected) && selected.length === 0) return;
|
|
1639
|
+
stdout.write(`${rawOutput(selected)}\n`);
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
if (output === "jsonl") {
|
|
1643
|
+
const rows = Array.isArray(selected) ? selected : [selected];
|
|
1644
|
+
if (!rows.length) return;
|
|
1645
|
+
stdout.write(`${rows.map((row) => JSON.stringify(row)).join("\n")}\n`);
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
const payload = jq ? selected : { ok: true, result };
|
|
1649
|
+
stdout.write(`${JSON.stringify(payload, null, options.pretty === true ? 2 : 0)}\n`);
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
export function selectJsonPath(value, expression) {
|
|
1653
|
+
const query = validateJsonPathExpression(expression);
|
|
1654
|
+
if (query === ".") return value;
|
|
1655
|
+
|
|
1656
|
+
const tokens = parseJsonPathTokens(query);
|
|
1657
|
+
let values = [value];
|
|
1658
|
+
let projected = false;
|
|
1659
|
+
for (const token of tokens) {
|
|
1660
|
+
if (token.type === "property") {
|
|
1661
|
+
values = values.map((item) => (item !== null && typeof item === "object" ? item[token.name] : undefined));
|
|
1662
|
+
continue;
|
|
1663
|
+
}
|
|
1664
|
+
if (token.type === "index") {
|
|
1665
|
+
values = values.map((item) => (Array.isArray(item) ? item[token.index] : undefined));
|
|
1666
|
+
continue;
|
|
1667
|
+
}
|
|
1668
|
+
projected = true;
|
|
1669
|
+
values = values.flatMap((item) => (Array.isArray(item) ? item : []));
|
|
1670
|
+
}
|
|
1671
|
+
const normalized = values.map((item) => (item === undefined ? null : item));
|
|
1672
|
+
return projected ? normalized : normalized[0];
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
function validateOutputOptions(options) {
|
|
1676
|
+
if (options.output !== undefined && !firstString(options.output)) throw usageError("--output requires human, json, jsonl, markdown, or raw.");
|
|
1677
|
+
if (options.jq !== undefined && !firstString(options.jq)) throw usageError("--jq requires a path such as .canvases[].handle.");
|
|
1678
|
+
const output = firstString(options.output) || "json";
|
|
1679
|
+
if (!["human", "json", "jsonl", "markdown", "raw"].includes(output)) throw usageError("--output must be human, json, jsonl, markdown, or raw.");
|
|
1680
|
+
if (options.raw === true && output !== "json" && output !== "raw") throw usageError("-r/--raw cannot be combined with --output human, jsonl, or markdown.");
|
|
1681
|
+
const jq = firstString(options.jq);
|
|
1682
|
+
if (jq) validateJsonPathExpression(jq);
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
function validateJsonPathExpression(expression) {
|
|
1686
|
+
const query = String(expression || "").trim();
|
|
1687
|
+
if (!query.startsWith(".") || query.length > 500) throw usageError("--jq must be a jq-style path such as .canvases[].handle.");
|
|
1688
|
+
if (query !== ".") parseJsonPathTokens(query);
|
|
1689
|
+
return query;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
function parseJsonPathTokens(query) {
|
|
1693
|
+
const tokens = [];
|
|
1694
|
+
let index = 1;
|
|
1695
|
+
while (index < query.length) {
|
|
1696
|
+
if (query[index] === ".") {
|
|
1697
|
+
index += 1;
|
|
1698
|
+
if (index >= query.length) throw usageError("--jq path cannot end with a dot.");
|
|
1699
|
+
}
|
|
1700
|
+
if (query[index] === "[") {
|
|
1701
|
+
const close = query.indexOf("]", index);
|
|
1702
|
+
if (close < 0) throw usageError("--jq contains an unterminated array selector.");
|
|
1703
|
+
const selector = query.slice(index + 1, close);
|
|
1704
|
+
if (!selector) tokens.push({ type: "projection" });
|
|
1705
|
+
else if (/^(?:0|[1-9]\d*)$/u.test(selector)) tokens.push({ type: "index", index: Number(selector) });
|
|
1706
|
+
else throw usageError("--jq supports [] and non-negative array indexes only.");
|
|
1707
|
+
index = close + 1;
|
|
1708
|
+
continue;
|
|
1709
|
+
}
|
|
1710
|
+
const match = query.slice(index).match(/^[A-Za-z_][A-Za-z0-9_-]*/u);
|
|
1711
|
+
if (!match) throw usageError("--jq supports property paths, array indexes, and [] projections only.");
|
|
1712
|
+
const name = match[0];
|
|
1713
|
+
if (name === "__proto__" || name === "prototype" || name === "constructor") throw usageError("--jq property is not allowed.");
|
|
1714
|
+
tokens.push({ type: "property", name });
|
|
1715
|
+
index += name.length;
|
|
1716
|
+
if (index < query.length && query[index] !== "." && query[index] !== "[") throw usageError("--jq path is invalid.");
|
|
1717
|
+
}
|
|
1718
|
+
return tokens;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
function rawOutput(value) {
|
|
1722
|
+
const values = Array.isArray(value) ? value : [value];
|
|
1723
|
+
return values
|
|
1724
|
+
.map((item) => {
|
|
1725
|
+
if (item === null) return "null";
|
|
1726
|
+
if (typeof item === "string") return item;
|
|
1727
|
+
if (typeof item === "number" || typeof item === "boolean") return String(item);
|
|
1728
|
+
return JSON.stringify(item);
|
|
1729
|
+
})
|
|
1730
|
+
.join("\n");
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
function humanOutput(value, indent = "") {
|
|
1734
|
+
if (value === null || value === undefined) return `${value}`;
|
|
1735
|
+
if (typeof value !== "object") return String(value);
|
|
1736
|
+
if (Array.isArray(value)) return value.map((item, index) => `${indent}${index + 1}. ${typeof item === "object" ? `\n${humanOutput(item, `${indent} `)}` : String(item)}`).join("\n");
|
|
1737
|
+
return Object.entries(value)
|
|
1738
|
+
.map(([key, item]) => {
|
|
1739
|
+
const label = key.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/_/g, " ");
|
|
1740
|
+
if (item && typeof item === "object") return `${indent}${label}:\n${humanOutput(item, `${indent} `)}`;
|
|
1741
|
+
return `${indent}${label}: ${item ?? ""}`;
|
|
1742
|
+
})
|
|
1743
|
+
.join("\n");
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
function normalizeCliError(error) {
|
|
1747
|
+
if (error instanceof CanvasAgentApiError) {
|
|
1748
|
+
return {
|
|
1749
|
+
code: error.code || "request_failed",
|
|
1750
|
+
message: error.message,
|
|
1751
|
+
...(error.status ? { status: error.status } : {}),
|
|
1752
|
+
...(error.fields ? { fields: error.fields } : {}),
|
|
1753
|
+
...(error.details ? { details: error.details } : {}),
|
|
1754
|
+
...(error.requestId ? { requestId: error.requestId } : {}),
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
if (error?.code === "usage") return { code: "usage", message: error instanceof Error ? error.message : "The Gavana command is invalid." };
|
|
1758
|
+
if (error?.code === "ENOENT") return { code: "not_found", message: error.message };
|
|
1759
|
+
return { code: "internal", message: error instanceof Error ? error.message : "Gavana CLI failed." };
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
function exitCodeForError(error) {
|
|
1763
|
+
if (
|
|
1764
|
+
error.code === "usage" ||
|
|
1765
|
+
error.code === "configuration" ||
|
|
1766
|
+
error.code === "validation" ||
|
|
1767
|
+
error.code === "input_validation_error" ||
|
|
1768
|
+
error.code === "invalid_json" ||
|
|
1769
|
+
error.status === 400 ||
|
|
1770
|
+
error.status === 413 ||
|
|
1771
|
+
error.status === 415 ||
|
|
1772
|
+
error.status === 422
|
|
1773
|
+
)
|
|
1774
|
+
return 2;
|
|
1775
|
+
if (error.code === "unauthorized" || error.code === "forbidden" || error.status === 401 || error.status === 403) return 3;
|
|
1776
|
+
if (error.code === "conflict" || error.status === 409) return 4;
|
|
1777
|
+
if (error.code === "not_found" || error.status === 404) return 5;
|
|
1778
|
+
if (error.code === "network") return 7;
|
|
1779
|
+
if (error.code === "timeout") return 8;
|
|
1780
|
+
return 1;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
function requiredArg(args, index, message) {
|
|
1784
|
+
const value = args[index];
|
|
1785
|
+
if (!value) throw usageError(message);
|
|
1786
|
+
return value;
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
function requireConfirmation(options, message) {
|
|
1790
|
+
if (options.yes !== true) throw usageError(message);
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
function parseJsonOption(value, message) {
|
|
1794
|
+
const text = firstString(value);
|
|
1795
|
+
return text ? parseJson(text, message) : null;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function parseJson(value, message) {
|
|
1799
|
+
try {
|
|
1800
|
+
return JSON.parse(value);
|
|
1801
|
+
} catch {
|
|
1802
|
+
throw usageError(message);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
function firstString(value) {
|
|
1807
|
+
if (Array.isArray(value)) return value.length ? String(value[value.length - 1]) : "";
|
|
1808
|
+
return typeof value === "string" ? value : "";
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
function repeatableStrings(value) {
|
|
1812
|
+
if (value === undefined) return [];
|
|
1813
|
+
return (Array.isArray(value) ? value : [value])
|
|
1814
|
+
.map(String)
|
|
1815
|
+
.map((item) => item.trim())
|
|
1816
|
+
.filter(Boolean);
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
function requiredNumber(value, message) {
|
|
1820
|
+
const number = Number(Array.isArray(value) ? value[value.length - 1] : value);
|
|
1821
|
+
if (!Number.isFinite(number)) throw usageError(message);
|
|
1822
|
+
return number;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
function numberOption(value, fallback) {
|
|
1826
|
+
return value === undefined ? fallback : requiredNumber(value, "Expected a number.");
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
function paginationOptions(options, maximum) {
|
|
1830
|
+
const message = `--limit must be an integer from 1 through ${maximum}.`;
|
|
1831
|
+
if (options.limit !== undefined && typeof options.limit !== "string") throw usageError(message);
|
|
1832
|
+
const limit = options.limit === undefined ? undefined : requiredNumber(options.limit, message);
|
|
1833
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > maximum)) throw usageError(message);
|
|
1834
|
+
if (options.cursor !== undefined && !firstString(options.cursor)) throw usageError("--cursor requires page.nextCursor from the previous response.");
|
|
1835
|
+
const cursor = firstString(options.cursor).trim();
|
|
1836
|
+
if (cursor.length > 8_192) throw usageError("--cursor is invalid.");
|
|
1837
|
+
return {
|
|
1838
|
+
...(limit !== undefined ? { limit } : {}),
|
|
1839
|
+
...(cursor ? { cursor } : {}),
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
function secondsOption(value, fallback, allowZero = false) {
|
|
1844
|
+
if (value === undefined) return fallback;
|
|
1845
|
+
const seconds = requiredNumber(value, "Expected seconds as a number.");
|
|
1846
|
+
if (seconds < (allowZero ? 0 : 0.05)) throw usageError("Seconds must be positive.");
|
|
1847
|
+
return seconds;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
function usageError(message) {
|
|
1851
|
+
return new CanvasAgentApiError(message, { code: "usage" });
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
function maskToken(token) {
|
|
1855
|
+
const value = String(token || "");
|
|
1856
|
+
return value.length > 12 ? `${value.slice(0, 7)}…${value.slice(-4)}` : "configured";
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
async function readAllStdin() {
|
|
1860
|
+
const chunks = [];
|
|
1861
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
1862
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
async function readBinaryStdin() {
|
|
1866
|
+
const chunks = [];
|
|
1867
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
1868
|
+
return Buffer.concat(chunks);
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function helpText(group, action) {
|
|
1872
|
+
const commandHelp = commandHelpText(group, action);
|
|
1873
|
+
if (commandHelp) return commandHelp;
|
|
1874
|
+
return `Gavana Canvas CLI — JSON-first agent control
|
|
1875
|
+
|
|
1876
|
+
Usage:
|
|
1877
|
+
${gavanaCommandUsageLines()
|
|
1878
|
+
.map((line) => ` ${line}`)
|
|
1879
|
+
.join("\n")}
|
|
1880
|
+
|
|
1881
|
+
Global options:
|
|
1882
|
+
--output FORMAT human, json, jsonl, markdown, or raw; JSON is the default
|
|
1883
|
+
--pretty Pretty-print JSON
|
|
1884
|
+
--jq PATH Select a jq-style path, for example .canvases[].handle
|
|
1885
|
+
-r, --raw Print selected strings and numbers without JSON quotes
|
|
1886
|
+
--limit N Maximum list items to return
|
|
1887
|
+
--cursor VALUE Continue a list from page.nextCursor
|
|
1888
|
+
--base-url URL Overrides saved configuration
|
|
1889
|
+
--profile NAME Use one named account/environment profile
|
|
1890
|
+
--token-stdin Read a login token from stdin
|
|
1891
|
+
--base-revision VALUE Enforce a previously read canvas revision
|
|
1892
|
+
--idempotency-key VALUE Make retries deterministic
|
|
1893
|
+
--destination VALUE agent-canvas, new-canvas, or an existing canvas handle
|
|
1894
|
+
--model VALUE Provider model id or model: handle from model list
|
|
1895
|
+
--connection VALUE Disambiguate a provider model id by connection
|
|
1896
|
+
--reference VALUE Image reference; repeat for multiple visuals
|
|
1897
|
+
--reference-role ROLE Optional role for the matching --reference: identity, construction, texture, fit, or style
|
|
1898
|
+
--input VALUE Recipe KEY=VALUE or Action input; repeat as needed
|
|
1899
|
+
--param NAME=VALUE Action parameter; repeat for multiple parameters
|
|
1900
|
+
--webhook-url URL Send one signed callback when a Run finishes
|
|
1901
|
+
--webhook-secret-env VAR Read the signing secret from VAR (default: GAVANA_WEBHOOK_SECRET)
|
|
1902
|
+
--canvas-title VALUE Title used when --destination is new-canvas
|
|
1903
|
+
--no-wait Return immediately after queueing Recipe, image, video, or Action work
|
|
1904
|
+
--progress Write Run or video Job state transitions to stderr
|
|
1905
|
+
|
|
1906
|
+
Video options:
|
|
1907
|
+
--prompt VALUE Video instruction (required)
|
|
1908
|
+
--duration SECONDS Exact model-supported duration from 1 through 120
|
|
1909
|
+
--aspect-ratio VALUE Exact model-supported aspect ratio
|
|
1910
|
+
--resolution VALUE Exact model-supported resolution
|
|
1911
|
+
--first-frame VALUE node:, asset:, public HTTPS URL, local image, -, or clipboard
|
|
1912
|
+
--last-frame VALUE Ending frame; requires --first-frame
|
|
1913
|
+
--reference VALUE Model reference image; repeat up to nine times
|
|
1914
|
+
--canvas VALUE Canvas containing any node: frame or reference
|
|
1915
|
+
--audio | --no-audio Request or disable generated audio when supported
|
|
1916
|
+
--download VALUE Stream a successful generated video to this file
|
|
1917
|
+
|
|
1918
|
+
Image --reference values, Action inputs, and Recipe Image ports accept node:<id>,
|
|
1919
|
+
asset:<id>, shared asset:<ownerUid>:<id>, a local PNG/JPEG/WebP/GIF path, -, or
|
|
1920
|
+
clipboard (macOS). Local visuals upload privately before the Run. Recipe written
|
|
1921
|
+
ports treat @path as text; upload a visual first and pass its asset: handle when
|
|
1922
|
+
that written port explicitly accepts an image reference. For image generation,
|
|
1923
|
+
repeat --reference-role in the same order as --reference to preserve each
|
|
1924
|
+
visual's identity, construction, texture, fit, or style role.
|
|
1925
|
+
Image and Action commands create target nodes automatically when --target is omitted.
|
|
1926
|
+
|
|
1927
|
+
For history-safe token entry in zsh (macOS):
|
|
1928
|
+
read -rs 'GAVANA_AGENT_TOKEN?Paste Agent Access token: '; printf '\\n'; printf '%s' "$GAVANA_AGENT_TOKEN" | gavana auth login --base-url URL --token-stdin; unset GAVANA_AGENT_TOKEN
|
|
1929
|
+
|
|
1930
|
+
Compatibility: craftboard commands and CRAFTBOARD_* environment variables remain supported.`;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// The page every group without its own prose closes with.
|
|
1934
|
+
const GROUP_HELP_COMMON_OPTIONS = "Common options:\n --profile NAME Select a saved profile\n --output FORMAT human, json, jsonl, markdown, or raw\n --help Show command-specific help";
|
|
1935
|
+
|
|
1936
|
+
/**
|
|
1937
|
+
* One group's help page, generated from the command table.
|
|
1938
|
+
*
|
|
1939
|
+
* This used to be a second hand-maintained catalog covering twelve groups, with
|
|
1940
|
+
* five more written out longhand in helpText. The two catalogs had drifted in both
|
|
1941
|
+
* directions. The group pages were behind on `asset list --cursor`,
|
|
1942
|
+
* `asset:<ownerUid>`, and every worked example. The root list was behind on
|
|
1943
|
+
* `--yes` for `canvas apply` and `video download`, and on `--progress` for `job
|
|
1944
|
+
* wait` and `run wait` — all four real flags that only the group pages mentioned.
|
|
1945
|
+
* Generating from one table means a command declared once is documented
|
|
1946
|
+
* everywhere, including on the pages nobody remembered to update.
|
|
1947
|
+
*/
|
|
1948
|
+
function commandHelpText(group, action) {
|
|
1949
|
+
if (!group || !gavanaCommandGroups({ includeHidden: true }).includes(group)) return "";
|
|
1950
|
+
const page = GAVANA_CLI_GROUP_HELP[group];
|
|
1951
|
+
// A group with no sub-command and no prose of its own gets no page: `gavana
|
|
1952
|
+
// version --help` and `gavana doctor --help` answer with the full manual,
|
|
1953
|
+
// because a page repeating their single root usage line would say less than
|
|
1954
|
+
// the root help they replaced.
|
|
1955
|
+
if (!page && !gavanaCommandActions(group, { includeHidden: true }).length) return "";
|
|
1956
|
+
const selected = gavanaCommandGroupUsageLines(group, action);
|
|
1957
|
+
if (!selected.length) {
|
|
1958
|
+
// A group whose every command is hidden has no page at all, and falling
|
|
1959
|
+
// through to the root help is the honest answer. An unknown action inside a
|
|
1960
|
+
// real group is a mistake worth naming.
|
|
1961
|
+
if (!gavanaCommandGroupUsageLines(group).length) return "";
|
|
1962
|
+
return `Unknown ${group} action: ${action}.\n\n${commandHelpText(group)}`;
|
|
1963
|
+
}
|
|
1964
|
+
const title = page?.title || `Gavana ${group} commands`;
|
|
1965
|
+
const footer = page ? page.note : GROUP_HELP_COMMON_OPTIONS;
|
|
1966
|
+
const usage = `${title}\n\nUsage:\n${selected.map((line) => ` ${line}`).join("\n")}`;
|
|
1967
|
+
return footer ? `${usage}\n\n${footer}` : usage;
|
|
1968
|
+
}
|