@gavana.ai/cli 0.3.0 → 0.3.3

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 CHANGED
@@ -1,5 +1,63 @@
1
1
  # Changelog — @gavana.ai/cli
2
2
 
3
+ ## 0.3.3
4
+
5
+ ### Fixed
6
+
7
+ - **`auth login` stops handing out an instruction it already carried out.** It
8
+ opened the browser and then printed a line that read as a request to open the
9
+ URL, so a coding agent running the command opened it a second time and the
10
+ user was left on two tabs of one authorization request — same `client_id`,
11
+ same `state`, same `code_challenge`. The browser path now says the URL is
12
+ already open and prints it only to be shown. The one case that still asks is a
13
+ machine where the open actually failed.
14
+ - **The login wait is announced, and settable.** It blocked for an unannounced
15
+ 180 seconds, longer than the command timeout a caller is likely to assume, so
16
+ an agent-run login was killed mid-sign-in and took the local callback server —
17
+ and the user's open tab — with it. The wait is now printed, `--timeout
18
+ SECONDS` sets it, and the timeout error says the open page is dead and that
19
+ re-running prints a new URL.
20
+ - **`canvas_validate` no longer excuses a run that referenced nothing.** The
21
+ product-fidelity review began by filtering to outputs that already carried
22
+ reference evidence, so a prompt-only run emptied that filter and was graded
23
+ `not-applicable` — the worst case was the one case it excused. An imported
24
+ product reference that no generated output uses now raises
25
+ `unused_product_reference` and reports `needs-review`, naming both the ignored
26
+ product and the outputs that ignored it. A canvas holding an imported product
27
+ and no generated output stays `not-applicable`.
28
+
29
+ ## 0.3.2
30
+
31
+ ### Changed
32
+
33
+ - **`mcp install` says what is left to do.** It answered `installed: true` and
34
+ stopped, so agents reported the tools as ready while the server sat
35
+ unauthenticated. The result now carries `authenticated: false` and the
36
+ remaining steps: restart the client, sign that server in (its login is its
37
+ own, separate from the CLI's), and confirm with a real read rather than a
38
+ status line.
39
+
40
+ ### Fixed
41
+
42
+ - **`bin/gavana.mjs` ships executable.** It was published as `0644` while its
43
+ `craftboard` twin was `0755`, so the `gavana` command depended on npm
44
+ repairing the mode while linking. Where it did not, the first command a new
45
+ user ran answered `permission denied` (exit 126).
46
+
47
+ ## 0.3.1
48
+
49
+ ### Fixed
50
+
51
+ - **`auth login` no longer stacks a new delegation on every login.** The CLI
52
+ registered a fresh OAuth client each time — a new client id against a new
53
+ loopback port — so Gavana could not tell one login from the next, and the
54
+ delegations it minted never expire. They accumulated until the account hit
55
+ its token cap, at which point the exchange failed and the CLI answered with
56
+ another login that failed the same way. The CLI now registers under a stable
57
+ per-installation id (`installId` in the agent config), so Gavana returns the
58
+ client id this machine already had and replaces the delegation it holds.
59
+ Other machines are untouched: each install keeps its own id.
60
+
3
61
  ## 0.3.0
4
62
 
5
63
  ### Added
package/bin/gavana.mjs CHANGED
File without changes
@@ -33,6 +33,14 @@ order: 6
33
33
  proves more than one variant, pass the one the user named rather than letting
34
34
  the import pick. If no durable references can be imported, say exact product
35
35
  fidelity is unverified.
36
+ - When the user supplied the product image themselves and there is no product
37
+ page, call `product_photoshoot_generate` with `references` set to the durable
38
+ `node:` handles already on the canvas, one direction object per requested
39
+ output. It verifies those nodes before any paid work and stops when one is
40
+ not a durable image. Save an asset to the canvas first: a workflow input
41
+ binds to a node, not to an `asset:` handle. This is the route for several
42
+ distinct directions from one uploaded photo; one image call with `count > 1`
43
+ is not.
36
44
  {{/hosted}}{{#local}}- This surface has no product-page importer. Ask the user for the images
37
45
  themselves, bring them in through the supported asset flow, and pass the
38
46
  resulting handles as references. Do not fetch the page and describe it in a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gavana.ai/cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.3",
4
4
  "description": "JSON-first command-line client for the Gavana Canvas API",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@ import { renderForSurface } from "./tools/surface-names.mjs";
8
8
  export const GAVANA_CANVAS_GUIDE_VERSION = "1.6.1";
9
9
  export const GAVANA_CANVAS_GUIDE_INDEX_URI = `gavana://guides/canvas/v${GAVANA_CANVAS_GUIDE_VERSION.split(".")[0]}/index`;
10
10
  export const GAVANA_CANVAS_GUIDE_WORKFLOW_INSTRUCTION =
11
- "For the first canvas task in a session, and before any unfamiliar canvas operation, inspect the relevant guide through MCP resources or call guide_search then guide_get. Before changing an existing canvas, call canvas_get. For spatial, multi-node, or destructive work, call canvas_validate with the proposed operations, apply one revision-safe atomic batch, then call canvas_validate again. Campaign work must read creative-canvas and use a reference-led concept before image output. {{#local}}Before image or video generation, call model_list for the required capability and use its exact model: handle; a bare model name does not select a saved connection. If no matching model is returned, report that the agent account cannot access the connection instead of asking the user to add a key again.{{/local}}{{#hosted}}Before video generation, call video_model_find for the required capability and use its exact model: handle; a bare model name does not select a saved connection. If no matching model is returned, report that the agent account cannot access the connection instead of asking the user to add a key again. Image generation has no model-discovery tool here: omit model unless the user named an exact handle.{{/hosted}} A product page URL is source input, never prompt prose. {{#hosted}}When asked to generate one or more exact-product directions from a product page, call product_photoshoot_generate once with one explicit direction object per requested output; it imports verified references before any paid generation and stops if none are available. Use product_reference_pack_import only for a standalone import with no generation. When the page proves more than one variant, pass the variantSelector the user named.{{/hosted}}{{#local}}This surface has no product-page importer: ask for the images themselves and bring them in through the supported asset flow rather than describing the page in a prompt.{{/local}} When a standalone image fallback uses visual references, forward every exact node:/asset: handle (and each known role) in the image request; never reduce that work to a prompt-only generation. Image tools return durable queued progress by default; report it immediately and {{#local}}call run_wait{{/local}}{{#hosted}}set wait true{{/hosted}} only when the current user explicitly needs the completed asset in the same interaction. Otherwise run_get is authoritative; use the exact Canvas URL for navigation, but remember Canvas activity may lag. After an image or workflow finishes, read completionReview. Never claim Done while completionReview.doneClaimAllowed is false, delivery is pending, failed, or non-durable, or blocking findings remain. Render campaign compositions for visual inspection. Treat legacy warnings as review items, not permission to rewrite unrelated work. For N separate images, set count on one image call and never ask one prompt for N panels, frames, or a collage.";
11
+ "For the first canvas task in a session, and before any unfamiliar canvas operation, inspect the relevant guide through MCP resources or call guide_search then guide_get. Before changing an existing canvas, call canvas_get. For spatial, multi-node, or destructive work, call canvas_validate with the proposed operations, apply one revision-safe atomic batch, then call canvas_validate again. Campaign work must read creative-canvas and use a reference-led concept before image output. {{#local}}Before image or video generation, call model_list for the required capability and use its exact model: handle; a bare model name does not select a saved connection. If no matching model is returned, report that the agent account cannot access the connection instead of asking the user to add a key again.{{/local}}{{#hosted}}Before video generation, call video_model_find for the required capability and use its exact model: handle; a bare model name does not select a saved connection. If no matching model is returned, report that the agent account cannot access the connection instead of asking the user to add a key again. Image generation has no model-discovery tool here: omit model unless the user named an exact handle.{{/hosted}} A product page URL is source input, never prompt prose. {{#hosted}}When asked to generate one or more exact-product directions from a product page, call product_photoshoot_generate once with one explicit direction object per requested output; it imports verified references before any paid generation and stops if none are available. Use product_reference_pack_import only for a standalone import with no generation. When the page proves more than one variant, pass the variantSelector the user named. When the product image is already on the canvas and there is no product page, call the same tool with references set to its durable node: handles and one direction object per requested output; it verifies those nodes before any paid generation. Several distinct directions are never one image call with count > 1.{{/hosted}}{{#local}}This surface has no product-page importer: ask for the images themselves and bring them in through the supported asset flow rather than describing the page in a prompt.{{/local}} When a standalone image fallback uses visual references, forward every exact node:/asset: handle (and each known role) in the image request; never reduce that work to a prompt-only generation. Image tools return durable queued progress by default; report it immediately and {{#local}}call run_wait{{/local}}{{#hosted}}set wait true{{/hosted}} only when the current user explicitly needs the completed asset in the same interaction. Otherwise run_get is authoritative; use the exact Canvas URL for navigation, but remember Canvas activity may lag. After an image or workflow finishes, read completionReview. Never claim Done while completionReview.doneClaimAllowed is false, delivery is pending, failed, or non-durable, or blocking findings remain. Render campaign compositions for visual inspection. Treat legacy warnings as review items, not permission to rewrite unrelated work. For N separate images, set count on one image call and never ask one prompt for N panels, frames, or a collage.";
12
12
  export const GAVANA_CANVAS_GUIDE_READ_ONLY_INSTRUCTION =
13
13
  "For the first canvas task in a session, and before any unfamiliar canvas review, inspect the relevant guide through MCP resources or call guide_search then guide_get. Call canvas_get before reasoning about an existing canvas, and use canvas_validate to audit its current graph. Treat validation warnings as review items and never claim to mutate the canvas.";
14
14
 
@@ -324,9 +324,65 @@ function graphFindings(canvas, validationOptions = {}) {
324
324
  }
325
325
  }
326
326
  }
327
+
328
+ // Canvas-level, because the failure is an absence: no single output is
329
+ // malformed, they simply all ignored the product the person imported.
330
+ // Reported against the outputs so the standard scoping decides whether it
331
+ // blocks — this task's work is pinned, somebody else's older run is not.
332
+ const unusedProducts = unusedProductReferenceNodes(canvas);
333
+ if (unusedProducts.length) {
334
+ const outputs = canvas.nodes.filter(isGeneratedOutput);
335
+ if (outputs.length) {
336
+ findings.push(
337
+ finding(
338
+ "unused_product_reference",
339
+ "warning",
340
+ `${unusedProducts.map((node) => nodeHandle(node.id)).join(", ")} was imported as an exact product reference, but no generated output on this canvas references it. Prompt-only output is not the exact product.`,
341
+ { nodeIds: [...outputs.map((node) => cleanId(node.id)), ...unusedProducts.map((node) => cleanId(node.id))], guideId: "generated-assets" },
342
+ ),
343
+ );
344
+ }
345
+ }
327
346
  return { findings, truncatedCodes: Array.from(truncatedCodes) };
328
347
  }
329
348
 
349
+ /**
350
+ * Imported product references that nothing generated on this canvas used.
351
+ *
352
+ * `productFidelityReview` cannot see this on its own: it starts by filtering to
353
+ * outputs that already carry reference evidence, so the run that used no
354
+ * reference at all empties its input and grades itself not-applicable. The
355
+ * worst case was the one case it excused.
356
+ */
357
+ function unusedProductReferenceNodes(canvas) {
358
+ const products = canvas.nodes.filter((node) => isRecord(node.metadata) && node.metadata.productReference === true);
359
+ if (!products.length) return [];
360
+ const outputs = canvas.nodes.filter(isGeneratedOutput);
361
+ if (!outputs.length) return [];
362
+ const usedNodeIds = new Set();
363
+ const usedStorageKeys = new Set();
364
+ for (const output of outputs) {
365
+ const metadata = isRecord(output.metadata) ? output.metadata : {};
366
+ for (const value of Array.isArray(metadata.referenceNodeIds) ? metadata.referenceNodeIds : []) usedNodeIds.add(cleanId(value));
367
+ for (const value of Array.isArray(metadata.referenceHandles) ? metadata.referenceHandles : []) {
368
+ const handle = String(value || "");
369
+ if (handle.startsWith("node:")) usedNodeIds.add(cleanId(handle.slice("node:".length)));
370
+ }
371
+ for (const value of Array.isArray(metadata.references) ? metadata.references : []) usedStorageKeys.add(String(value || ""));
372
+ for (const connection of canvas.connections) {
373
+ if (connection.mode !== "reference") continue;
374
+ if (cleanId(connection.toNodeId || connection.to) !== cleanId(output.id)) continue;
375
+ usedNodeIds.add(cleanId(connection.fromNodeId || connection.from));
376
+ }
377
+ }
378
+ return products.filter((node) => {
379
+ if (usedNodeIds.has(cleanId(node.id))) return false;
380
+ const storageKey = isRecord(node.metadata) ? node.metadata.storageKey : undefined;
381
+ const content = isRecord(node.metadata) ? node.metadata.content : undefined;
382
+ return !(storageKey && usedStorageKeys.has(String(storageKey))) && !(content && usedStorageKeys.has(String(content)));
383
+ });
384
+ }
385
+
330
386
  /**
331
387
  * Detect the most specific candidate Section by center point so validation can
332
388
  * diagnose a frame that visibly escapes it. Persisted section membership uses
@@ -482,11 +538,29 @@ function reviewArea(findings, ...codes) {
482
538
  }
483
539
 
484
540
  function productFidelityReview(canvas, generatedOutputs) {
541
+ const unusedProductHandles = unusedProductReferenceNodes(canvas)
542
+ .map((node) => nodeHandle(node.id))
543
+ .sort();
485
544
  const referenceTargets = generatedOutputs.filter((node) => outputHasReferenceEvidence(node, canvas.connections));
486
545
  if (!referenceTargets.length) {
546
+ // An unused imported product is the whole reason this review exists.
547
+ // Standing down here graded the one run that ignored every reference as
548
+ // if fidelity were never in question.
549
+ if (unusedProductHandles.length && generatedOutputs.length) {
550
+ const outputHandles = generatedOutputs.map((node) => nodeHandle(node.id)).sort();
551
+ return {
552
+ status: "needs-review",
553
+ reviewedOutputCount: generatedOutputs.length,
554
+ unusedProductReferenceHandles: unusedProductHandles,
555
+ needsReviewOutputHandles: outputHandles,
556
+ evidenceMissingOutputHandles: [],
557
+ reasons: outputHandles.map((handle) => ({ nodeHandle: handle, reason: `Generated without referencing the imported product ${unusedProductHandles.join(", ")}; exact product fidelity is unverified.` })),
558
+ };
559
+ }
487
560
  return {
488
561
  status: "not-applicable",
489
562
  reviewedOutputCount: 0,
563
+ unusedProductReferenceHandles: unusedProductHandles,
490
564
  needsReviewOutputHandles: [],
491
565
  evidenceMissingOutputHandles: [],
492
566
  };
@@ -501,9 +575,10 @@ function productFidelityReview(canvas, generatedOutputs) {
501
575
  // References preserve origin and intent. They never turn a retained
502
576
  // creative output into a completion gate. Exact composition is the
503
577
  // exception: it must carry a verified finalized-image hash.
504
- status: evidenceMissing.length ? "needs-review" : "passed",
578
+ status: evidenceMissing.length || unusedProductHandles.length ? "needs-review" : "passed",
505
579
  reviewedOutputCount: referenceTargets.length,
506
- needsReviewOutputHandles: evidenceMissingOutputHandles,
580
+ unusedProductReferenceHandles: unusedProductHandles,
581
+ needsReviewOutputHandles: uniqueSorted([...evidenceMissingOutputHandles, ...(unusedProductHandles.length ? generatedOutputs.filter((node) => !outputHasReferenceEvidence(node, canvas.connections)).map((node) => nodeHandle(node.id)) : [])]),
507
582
  evidenceMissingOutputHandles,
508
583
  reasons: evidenceMissing.map((node) => ({ nodeHandle: nodeHandle(node.id), reason: "Exact composition is missing its verified output hash." })),
509
584
  };
package/src/commands.mjs CHANGED
@@ -41,8 +41,8 @@ export const GAVANA_CLI_COMMANDS = Object.freeze([
41
41
  {
42
42
  group: "auth",
43
43
  action: "login",
44
- usage: ["gavana auth login [--profile NAME] [--read-only]", "gavana auth login --base-url URL --token-stdin"],
45
- groupUsage: ["gavana auth login [--profile NAME] [--read-only] [--no-browser]", "gavana auth login --token-stdin [--profile NAME]"],
44
+ usage: ["gavana auth login [--profile NAME] [--read-only] [--timeout SECONDS]", "gavana auth login --base-url URL --token-stdin"],
45
+ groupUsage: ["gavana auth login [--profile NAME] [--read-only] [--no-browser] [--timeout SECONDS]", "gavana auth login --token-stdin [--profile NAME]"],
46
46
  },
47
47
  { group: "auth", action: "status", usage: ["gavana auth status"], groupUsage: ["gavana auth status [--profile NAME]"] },
48
48
  { group: "config", action: "list", usage: ["gavana config list | get [PROFILE] | use PROFILE"], groupUsage: ["gavana config list"] },
package/src/config.mjs CHANGED
@@ -2,10 +2,12 @@ import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { execFile as execFileCallback } from "node:child_process";
5
+ import { randomBytes } from "node:crypto";
5
6
  import { promisify } from "node:util";
6
7
 
7
8
  const execFile = promisify(execFileCallback);
8
9
  const KEYCHAIN_SERVICE = "ai.gavana.cli";
10
+ const INSTALL_ID_PATTERN = /^[A-Za-z0-9_-]{16,64}$/;
9
11
 
10
12
  export async function readAgentConfig(env = process.env, dependencies = {}) {
11
13
  const store = await readAgentConfigStore(env);
@@ -68,6 +70,7 @@ export async function writeAgentConfig(config, env = process.env) {
68
70
  if (credentialStore === "macos-keychain") delete profileConfig.token;
69
71
  const next = {
70
72
  version: 1,
73
+ ...(current.installId ? { installId: current.installId } : {}),
71
74
  activeProfile: profile,
72
75
  profiles: { ...(current.profiles || {}), [profile]: profileConfig },
73
76
  };
@@ -90,6 +93,20 @@ export async function writeAgentConfigStore(config, env = process.env) {
90
93
  return filePath;
91
94
  }
92
95
 
96
+ /**
97
+ * The id this installation registers its OAuth client under. It is not a
98
+ * credential: it exists so a repeat `auth login` is recognised as the same
99
+ * install and replaces the delegation it already holds, instead of leaving a
100
+ * new one behind on every login.
101
+ */
102
+ export async function readOAuthInstallId(env = process.env) {
103
+ const store = await readAgentConfigStore(env);
104
+ if (store.installId) return store.installId;
105
+ const installId = randomBytes(24).toString("base64url");
106
+ await writeAgentConfigStore({ ...store, installId }, env);
107
+ return installId;
108
+ }
109
+
93
110
  export async function removeAgentConfig(env = process.env) {
94
111
  const filePaths = agentConfigFileCandidates(env);
95
112
  await Promise.all(filePaths.map((filePath) => fs.rm(filePath, { force: true })));
@@ -164,14 +181,15 @@ function agentConfigFileCandidates(env) {
164
181
 
165
182
  function normalizeConfigStore(value) {
166
183
  if (!value || typeof value !== "object" || Array.isArray(value)) return { version: 1, activeProfile: "default", profiles: {} };
184
+ const install = typeof value.installId === "string" && INSTALL_ID_PATTERN.test(value.installId) ? { installId: value.installId } : {};
167
185
  if (value.profiles && typeof value.profiles === "object" && !Array.isArray(value.profiles)) {
168
- return { version: 1, activeProfile: cleanProfileName(value.activeProfile || "default"), profiles: value.profiles };
186
+ return { version: 1, ...install, activeProfile: cleanProfileName(value.activeProfile || "default"), profiles: value.profiles };
169
187
  }
170
188
  const legacy = {
171
189
  ...(typeof value.baseUrl === "string" && value.baseUrl ? { baseUrl: value.baseUrl } : {}),
172
190
  ...(typeof value.token === "string" && value.token ? { token: value.token } : {}),
173
191
  };
174
- return { version: 1, activeProfile: "default", profiles: Object.keys(legacy).length ? { default: legacy } : {} };
192
+ return { version: 1, ...install, activeProfile: "default", profiles: Object.keys(legacy).length ? { default: legacy } : {} };
175
193
  }
176
194
 
177
195
  function selectedProfileName(store, env) {
@@ -49,7 +49,7 @@ export const GAVANA_CANVAS_GUIDE_SOURCES = Object.freeze([
49
49
  description: "Prepare media nodes, start only explicit generation, and preserve output lineage.",
50
50
  keywords: Object.freeze(["generated","generation","image","video","output","asset","durable","lineage","placeholder","job"]),
51
51
  order: 6,
52
- markdown: "\n## Before generation\n\n- Read the destination canvas and relevant source nodes.\n- Use exact source `node:` or `asset:` handles.\n{{#local}}- Before image or video generation, call `model_list` for the required capability and pass its exact `model:` handle. A bare model name does not select a saved connection. If no matching model is returned, report that the agent account cannot access that connection; do not ask the user to add a key again.\n{{/local}}{{#hosted}}- Before video generation, call `video_model_find` for the required capability and pass its exact `model:` handle. A bare model name does not select a saved connection. If no matching model is returned, report that the agent account cannot access that connection; do not ask the user to add a key again.\n- Image generation has no model-discovery tool on this surface. Omit `model` and Gavana uses the account's configured default; pass `model` only when the user named an exact handle. Do not go looking for a tool that lists image models here.\n{{/hosted}}- For a standalone image request, pass every visual source in `references`.\n Use `{ \"handle\": \"node:...\", \"role\": \"identity\" }` when its\n responsibility is known; valid roles are `identity`, `construction`,\n `texture`, `fit`, and `style`. Do not flatten multi-reference work\n into prompt prose or omit a source during fallback.\n- Reuse existing Canvas `node:` or `asset:` handles directly. Do not download\n and re-upload a generated Canvas image merely to use it as the next\n generation's reference. State whether a style reference establishes the\n brand-world or typography/layout direction in the prompt.\n- A product page URL is source input, not prompt prose. Never paste it into a\n generation prompt, and never describe prompt-only output as the exact product.\n{{#hosted}}- When asked to generate one or more exact-product directions from a product\n page, call `product_photoshoot_generate` once. Supply one explicit direction\n object per requested output; the tool imports durable gallery references\n first and runs each direction as its own reference-guided image job. Use\n `product_reference_pack_import` only when the user wants a standalone import\n with no generation. Both take an optional `variantSelector`; when the page\n proves more than one variant, pass the one the user named rather than letting\n the import pick. If no durable references can be imported, say exact product\n fidelity is unverified.\n{{/hosted}}{{#local}}- This surface has no product-page importer. Ask the user for the images\n themselves, bring them in through the supported asset flow, and pass the\n resulting handles as references. Do not fetch the page and describe it in a\n prompt: that produces a generic product, not theirs.\n{{/local}}- Create an empty image or video target only through supported operations. Do not write media bytes, storage keys, or arbitrary output URLs into metadata.\n- Connect prompts, products, references, Lists, and frame inputs to their target with the correct direction and mode.\n\n## Paid execution\n\nGeneration is allowed only after explicit current-turn user intent. Start one run with one caller-stable idempotency key. When the user asks for N separate images, pass `count: N` (1-4) on one image call: Gavana reserves one image node per output and writes one image into each. Never encode N as a prompt describing N panels, frames, or a collage — that returns one composite image in one node. When you pass `targetNodeIds`, pass exactly one target node per output; a mismatch is rejected. For a multi-direction product photoshoot, never encode the directions as one shared prompt with `count > 1`; the product photoshoot tool owns one `count: 1` child per direction under one durable Recipe Run. Image tools return durable queued progress by default; report that progress immediately. {{#local}}Do not automatically call `run_wait`; call it only when the current user explicitly needs the completed asset in this same interaction.{{/local}}{{#hosted}}Do not block on the result: leave `wait` false and let the call return its queued run. Set `wait` true only when the current user explicitly needs the completed asset in this same interaction.{{/hosted}} Otherwise, use the returned exact Canvas URL for navigation, but treat `run_get` as the authoritative status; Canvas activity is a presentation surface and may lag. Do not start another run while one is pending. A terminal failure must be reported without automatic retry.\n\n## Sizes, formats, and limits\n\nThere is no size discovery tool. What holds today:\n\n- `size` accepts an exact `WIDTHxHEIGHT` or an aspect shorthand like `4:5`.\n gpt-image models render `1024x1024`, `1024x1536`, and `1536x1024`; anything\n else resolves to the closest of those. The run's `settings` reports both\n `requestedSize` and `effectiveSize`, and a resolved request also returns a\n `parameterAdjustments` entry — read them before telling the person what they\n got, and never assume the size you asked for is the size that was made.\n- Imports accept PNG, JPEG, WebP, and GIF up to 50 MB. A larger or undecodable\n source is refused without writing anything; that refusal will not succeed on\n retry, so shrink the image or link a smaller rendition instead.\n\n## Completion\n\n`run_get` is what you call while a run is going, and it answers about state, not pixels. Its response always links the finished image; {{#hosted}}pass `includeImage: true` {{/hosted}}{{#local}}read the linked asset {{/local}}only when a person is about to look at the picture, because inlining one costs a couple of megabytes on every read. A run that is still going also dates its own answer: `timing.observedAt` is when the record was read, `timing.elapsedMs` runs off the wall clock, and `timing.stale` with `timing.staleNote` means nothing has written to the run in a long while — the work may already be finished, so read the destination canvas before starting anything new.\n\nDo not claim a generated image is durable until the result returns a target `node:`, durable `asset:`, and the final canvas read shows server-owned media fields. `run_get` is the authoritative status when a user asks whether a run is done; a queued Canvas activity row is not evidence that the provider never started. A video Job may return a protected download without materializing a native video node; report exactly what the server returned and do not invent durability.\n\nKeep generated output spatially near its input stage and connected to its source, prompt, List, or workflow. After finalization, run `canvas_validate` and read `completionReview`: it reports overlap, full-frame Section containment, reference lineage, and delivery state. Do not claim Done while it says `doneClaimAllowed: false`, including when delivery is pending, failed, or non-durable. Render the Canvas for visual inspection when the request includes a campaign, poster, banner, or multi-direction composition. Reference provenance is not a user workflow state and never requires a Keep action. Never create another paid provider call automatically.\n",
52
+ markdown: "\n## Before generation\n\n- Read the destination canvas and relevant source nodes.\n- Use exact source `node:` or `asset:` handles.\n{{#local}}- Before image or video generation, call `model_list` for the required capability and pass its exact `model:` handle. A bare model name does not select a saved connection. If no matching model is returned, report that the agent account cannot access that connection; do not ask the user to add a key again.\n{{/local}}{{#hosted}}- Before video generation, call `video_model_find` for the required capability and pass its exact `model:` handle. A bare model name does not select a saved connection. If no matching model is returned, report that the agent account cannot access that connection; do not ask the user to add a key again.\n- Image generation has no model-discovery tool on this surface. Omit `model` and Gavana uses the account's configured default; pass `model` only when the user named an exact handle. Do not go looking for a tool that lists image models here.\n{{/hosted}}- For a standalone image request, pass every visual source in `references`.\n Use `{ \"handle\": \"node:...\", \"role\": \"identity\" }` when its\n responsibility is known; valid roles are `identity`, `construction`,\n `texture`, `fit`, and `style`. Do not flatten multi-reference work\n into prompt prose or omit a source during fallback.\n- Reuse existing Canvas `node:` or `asset:` handles directly. Do not download\n and re-upload a generated Canvas image merely to use it as the next\n generation's reference. State whether a style reference establishes the\n brand-world or typography/layout direction in the prompt.\n- A product page URL is source input, not prompt prose. Never paste it into a\n generation prompt, and never describe prompt-only output as the exact product.\n{{#hosted}}- When asked to generate one or more exact-product directions from a product\n page, call `product_photoshoot_generate` once. Supply one explicit direction\n object per requested output; the tool imports durable gallery references\n first and runs each direction as its own reference-guided image job. Use\n `product_reference_pack_import` only when the user wants a standalone import\n with no generation. Both take an optional `variantSelector`; when the page\n proves more than one variant, pass the one the user named rather than letting\n the import pick. If no durable references can be imported, say exact product\n fidelity is unverified.\n- When the user supplied the product image themselves and there is no product\n page, call `product_photoshoot_generate` with `references` set to the durable\n `node:` handles already on the canvas, one direction object per requested\n output. It verifies those nodes before any paid work and stops when one is\n not a durable image. Save an asset to the canvas first: a workflow input\n binds to a node, not to an `asset:` handle. This is the route for several\n distinct directions from one uploaded photo; one image call with `count > 1`\n is not.\n{{/hosted}}{{#local}}- This surface has no product-page importer. Ask the user for the images\n themselves, bring them in through the supported asset flow, and pass the\n resulting handles as references. Do not fetch the page and describe it in a\n prompt: that produces a generic product, not theirs.\n{{/local}}- Create an empty image or video target only through supported operations. Do not write media bytes, storage keys, or arbitrary output URLs into metadata.\n- Connect prompts, products, references, Lists, and frame inputs to their target with the correct direction and mode.\n\n## Paid execution\n\nGeneration is allowed only after explicit current-turn user intent. Start one run with one caller-stable idempotency key. When the user asks for N separate images, pass `count: N` (1-4) on one image call: Gavana reserves one image node per output and writes one image into each. Never encode N as a prompt describing N panels, frames, or a collage — that returns one composite image in one node. When you pass `targetNodeIds`, pass exactly one target node per output; a mismatch is rejected. For a multi-direction product photoshoot, never encode the directions as one shared prompt with `count > 1`; the product photoshoot tool owns one `count: 1` child per direction under one durable Recipe Run. Image tools return durable queued progress by default; report that progress immediately. {{#local}}Do not automatically call `run_wait`; call it only when the current user explicitly needs the completed asset in this same interaction.{{/local}}{{#hosted}}Do not block on the result: leave `wait` false and let the call return its queued run. Set `wait` true only when the current user explicitly needs the completed asset in this same interaction.{{/hosted}} Otherwise, use the returned exact Canvas URL for navigation, but treat `run_get` as the authoritative status; Canvas activity is a presentation surface and may lag. Do not start another run while one is pending. A terminal failure must be reported without automatic retry.\n\n## Sizes, formats, and limits\n\nThere is no size discovery tool. What holds today:\n\n- `size` accepts an exact `WIDTHxHEIGHT` or an aspect shorthand like `4:5`.\n gpt-image models render `1024x1024`, `1024x1536`, and `1536x1024`; anything\n else resolves to the closest of those. The run's `settings` reports both\n `requestedSize` and `effectiveSize`, and a resolved request also returns a\n `parameterAdjustments` entry — read them before telling the person what they\n got, and never assume the size you asked for is the size that was made.\n- Imports accept PNG, JPEG, WebP, and GIF up to 50 MB. A larger or undecodable\n source is refused without writing anything; that refusal will not succeed on\n retry, so shrink the image or link a smaller rendition instead.\n\n## Completion\n\n`run_get` is what you call while a run is going, and it answers about state, not pixels. Its response always links the finished image; {{#hosted}}pass `includeImage: true` {{/hosted}}{{#local}}read the linked asset {{/local}}only when a person is about to look at the picture, because inlining one costs a couple of megabytes on every read. A run that is still going also dates its own answer: `timing.observedAt` is when the record was read, `timing.elapsedMs` runs off the wall clock, and `timing.stale` with `timing.staleNote` means nothing has written to the run in a long while — the work may already be finished, so read the destination canvas before starting anything new.\n\nDo not claim a generated image is durable until the result returns a target `node:`, durable `asset:`, and the final canvas read shows server-owned media fields. `run_get` is the authoritative status when a user asks whether a run is done; a queued Canvas activity row is not evidence that the provider never started. A video Job may return a protected download without materializing a native video node; report exactly what the server returned and do not invent durability.\n\nKeep generated output spatially near its input stage and connected to its source, prompt, List, or workflow. After finalization, run `canvas_validate` and read `completionReview`: it reports overlap, full-frame Section containment, reference lineage, and delivery state. Do not claim Done while it says `doneClaimAllowed: false`, including when delivery is pending, failed, or non-durable. Render the Canvas for visual inspection when the request includes a campaign, poster, banner, or multi-direction composition. Reference provenance is not a user workflow state and never requires a Keep action. Never create another paid provider call automatically.\n",
53
53
  }),
54
54
  Object.freeze({
55
55
  id: "existing-canvases",
package/src/runner.mjs CHANGED
@@ -9,7 +9,7 @@ import { promisify } from "node:util";
9
9
  import { CanvasAgentApiError, createCanvasAgentClient, markdownForResult, parseElementHandle } from "./client.mjs";
10
10
  import { GAVANA_CLI_VERSION, gavanaCapabilitySummary } from "./capabilities.mjs";
11
11
  import { GAVANA_CLI_GROUP_HELP, gavanaCommandActions, gavanaCommandGroupUsageLines, gavanaCommandGroups, gavanaCommandIsKnown, gavanaCommandUsageLines } from "./commands.mjs";
12
- import { agentConfigFilePath, listAgentProfiles, readAgentConfig, readAgentConfigMetadata, removeAgentProfile, setActiveAgentProfile, writeAgentConfig } from "./config.mjs";
12
+ import { agentConfigFilePath, listAgentProfiles, readAgentConfig, readAgentConfigMetadata, readOAuthInstallId, removeAgentProfile, setActiveAgentProfile, writeAgentConfig } from "./config.mjs";
13
13
  import { gavanaMcpClientDefinition } from "./mcp-targets.mjs";
14
14
 
15
15
  const BOOLEAN_OPTIONS = new Set(["help", "version", "pretty", "raw", "yes", "confirm", "rebase", "wait", "no-wait", "progress", "preflight", "token-stdin", "no-verify", "no-browser", "audio", "no-audio", "read-only"]);
@@ -643,7 +643,7 @@ async function runAuthCommand(action, _args, options, env, runtime) {
643
643
  if (!token) {
644
644
  const login = runtime.oauthLogin
645
645
  ? await runtime.oauthLogin({ baseUrl, readOnly: options["read-only"] === true, profile: profile || current.profile })
646
- : await performBrowserOAuthLogin({ baseUrl, readOnly: options["read-only"] === true, noBrowser: options["no-browser"] === true, runtime });
646
+ : await performBrowserOAuthLogin({ baseUrl, readOnly: options["read-only"] === true, noBrowser: options["no-browser"] === true, timeoutMs: secondsOption(options.timeout, OAUTH_LOGIN_DEFAULT_TIMEOUT_MS / 1000) * 1000, env, runtime });
647
647
  token = login.accessToken;
648
648
  loginMethod = "browser";
649
649
  }
@@ -738,7 +738,10 @@ async function revokeBrowserOAuthToken(baseUrl, token, fetchImpl) {
738
738
  }
739
739
  }
740
740
 
741
- export async function performBrowserOAuthLogin({ baseUrl, readOnly = false, noBrowser = false, runtime = {} }) {
741
+ export const OAUTH_LOGIN_DEFAULT_TIMEOUT_MS = 180_000;
742
+
743
+ export async function performBrowserOAuthLogin({ baseUrl, readOnly = false, noBrowser = false, timeoutMs, env = process.env, runtime = {} }) {
744
+ const waitMs = timeoutMs || runtime.oauthTimeoutMs || OAUTH_LOGIN_DEFAULT_TIMEOUT_MS;
742
745
  const fetchImpl = runtime.fetchImpl || globalThis.fetch;
743
746
  if (typeof fetchImpl !== "function") throw usageError("This runtime does not provide fetch for browser login.");
744
747
  const origin = new URL(createCanvasAgentClient({ token: "configuration-validation", baseUrl, fetchImpl }).baseUrl).origin;
@@ -748,11 +751,17 @@ export async function performBrowserOAuthLogin({ baseUrl, readOnly = false, noBr
748
751
  const callback = runtime.createOAuthCallbackServer ? await runtime.createOAuthCallbackServer(state) : await createOAuthCallbackServer(state);
749
752
  try {
750
753
  const scopes = readOnly ? "canvas:read asset:read element:read" : "canvas:read canvas:write asset:read element:read element:write image:generate video:generate job:manage";
754
+ // Registering under a stable install id is what makes this login a
755
+ // reconnect: Gavana returns the client id this machine already had and
756
+ // replaces the delegation it issued last time, instead of stacking a
757
+ // new one behind a client it has never seen.
758
+ const softwareId = runtime.installId || (await readOAuthInstallId(env));
751
759
  const registrationResponse = await fetchImpl(`${origin}/oauth/register`, {
752
760
  method: "POST",
753
761
  headers: { "Content-Type": "application/json", Accept: "application/json" },
754
762
  body: JSON.stringify({
755
763
  client_name: "Gavana CLI",
764
+ software_id: softwareId,
756
765
  redirect_uris: [callback.redirectUri],
757
766
  token_endpoint_auth_method: "none",
758
767
  grant_types: ["authorization_code"],
@@ -774,9 +783,37 @@ export async function performBrowserOAuthLogin({ baseUrl, readOnly = false, noBr
774
783
  scope: scopes,
775
784
  state,
776
785
  }).toString();
777
- (runtime.stderr || process.stderr).write(`Open this URL to connect Gavana:\n${authorize}\n`);
778
- if (!noBrowser) await (runtime.openBrowser ? runtime.openBrowser(authorize.toString()) : openBrowser(authorize.toString()));
779
- const { code } = await callback.wait(runtime.oauthTimeoutMs || 180_000);
786
+ // Whoever reads this line acts on it, and under a coding agent that
787
+ // reader is the agent. Reporting instead of ordering was not enough:
788
+ // "if no window opens, this is the URL" states a condition the reader
789
+ // cannot evaluate — it never sees the window — so it assumes the worst
790
+ // and opens the URL that is already open, and one login costs the user
791
+ // two tabs on the same authorization request. The prohibition has to be
792
+ // said outright, and only the branch where nothing opened may ask for
793
+ // the URL to be opened.
794
+ //
795
+ // The wait is announced because the caller has to survive it. An agent
796
+ // running this as a command sizes its own timeout, and every default
797
+ // worth guessing at is shorter than this one.
798
+ const stderrStream = runtime.stderr || process.stderr;
799
+ const askToOpen = () => stderrStream.write(`Open this URL to connect Gavana:\n${authorize}\n`);
800
+ const announceWait = () => stderrStream.write(`This waits up to ${Math.round(waitMs / 1000)} seconds for the sign-in to finish.\n`);
801
+ if (noBrowser) askToOpen();
802
+ else {
803
+ let opened = true;
804
+ try {
805
+ await (runtime.openBrowser ? runtime.openBrowser(authorize.toString()) : openBrowser(authorize.toString()));
806
+ } catch {
807
+ // No window came up, so a prohibition would strand the user on a
808
+ // page nobody opened. A machine with no browser is the one case
809
+ // where asking is the right instruction.
810
+ opened = false;
811
+ }
812
+ if (opened) stderrStream.write(`Your browser is opening the Gavana sign-in page. Do not open it again — this URL is already open, and is printed only so you can show it to me:\n${authorize}\n`);
813
+ else askToOpen();
814
+ }
815
+ announceWait();
816
+ const { code } = await callback.wait(waitMs);
780
817
  const tokenResponse = await fetchImpl(`${origin}/oauth/token`, {
781
818
  method: "POST",
782
819
  headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
@@ -837,7 +874,7 @@ async function createOAuthCallbackServer(expectedState) {
837
874
  Promise.race([
838
875
  result,
839
876
  new Promise((_, rejectTimeout) => {
840
- const timer = setTimeout(() => rejectTimeout(usageError("Gavana browser login timed out. Run auth login again.")), timeoutMs);
877
+ const timer = setTimeout(() => rejectTimeout(usageError("Gavana browser login timed out. The page that was open is now dead. Run auth login again, use the new URL it prints, and pass --timeout SECONDS if you need longer than this.")), timeoutMs);
841
878
  timer.unref?.();
842
879
  }),
843
880
  ]),
@@ -944,7 +981,20 @@ async function runMcpCommand(action, args, options, baseUrl, runtime) {
944
981
  if (action === "config" || !definition.command) return definition;
945
982
  const execute = runtime.execFile || execFile;
946
983
  await execute(definition.command, definition.args);
947
- return { ...definition, installed: true };
984
+ // Registering is not signing in, and `installed: true` was the last thing
985
+ // this command said. Agents read that as the end of the job and told people
986
+ // their tools were ready while the server sat unauthenticated. The steps
987
+ // that remain are known here, so they ship with the result.
988
+ return {
989
+ ...definition,
990
+ installed: true,
991
+ authenticated: false,
992
+ nextSteps: [
993
+ `Restart your client so it loads the ${definition.serverName} server.`,
994
+ `Sign this server in from inside your client — it has its own login, separate from the CLI's. In Claude Code: /mcp, choose ${definition.serverName}.`,
995
+ "Confirm with a read-only call, such as listing canvases. A status line saying connected is not proof.",
996
+ ],
997
+ };
948
998
  }
949
999
 
950
1000
  function completionScript(shell) {
package/src/version.mjs CHANGED
@@ -7,4 +7,4 @@
7
7
  //
8
8
  // scripts/gavana-mcp-tool-contract.test.mjs asserts this equals
9
9
  // packages/cli/package.json, so the two cannot drift.
10
- export const GAVANA_CLI_VERSION = "0.3.0";
10
+ export const GAVANA_CLI_VERSION = "0.3.3";