@gavana.ai/cli 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +42 -2
  3. package/guides/creative-canvas.md +52 -0
  4. package/guides/generated-assets.md +7 -2
  5. package/guides/paid-action-safety.md +1 -1
  6. package/guides/sections-layout.md +3 -3
  7. package/guides/validation-recovery.md +9 -2
  8. package/package.json +1 -1
  9. package/src/canvas-agent-guide.mjs +3 -3
  10. package/src/canvas-agent-validation.mjs +30 -15
  11. package/src/capabilities.mjs +3 -1
  12. package/src/client.mjs +252 -0
  13. package/src/commands.mjs +25 -3
  14. package/src/config.mjs +50 -17
  15. package/src/guide-sources.mjs +12 -4
  16. package/src/mcp-targets.mjs +72 -0
  17. package/src/runner.mjs +205 -58
  18. package/src/tools/campaign_plan.mjs +2 -2
  19. package/src/tools/campaign_review.mjs +2 -2
  20. package/src/tools/campaign_start.mjs +2 -2
  21. package/src/tools/definitions.mjs +34 -0
  22. package/src/tools/element_archive.mjs +12 -0
  23. package/src/tools/element_collection_create.mjs +11 -0
  24. package/src/tools/element_collection_delete.mjs +12 -0
  25. package/src/tools/element_collection_list.mjs +13 -0
  26. package/src/tools/element_collection_update.mjs +12 -0
  27. package/src/tools/element_create.mjs +11 -0
  28. package/src/tools/element_get.mjs +12 -0
  29. package/src/tools/element_history.mjs +13 -0
  30. package/src/tools/element_list.mjs +13 -0
  31. package/src/tools/element_restore.mjs +12 -0
  32. package/src/tools/element_update.mjs +21 -0
  33. package/src/tools/element_update_collections.mjs +12 -0
  34. package/src/tools/image_tool.mjs +3 -3
  35. package/src/tools/registry.mjs +202 -0
  36. package/src/tools/schemas.mjs +63 -1
  37. package/src/tools/work_continue.mjs +42 -0
  38. package/src/tools/work_execute.mjs +12 -0
  39. package/src/tools/work_get.mjs +12 -0
  40. package/src/tools/work_prepare.mjs +21 -0
  41. package/src/tools/work_refresh.mjs +12 -0
  42. package/src/version.mjs +5 -7
package/src/client.mjs CHANGED
@@ -519,6 +519,36 @@ export function createCanvasAgentClient(options = {}) {
519
519
  signal: requestOptions.signal,
520
520
  });
521
521
  },
522
+ prepareWork: (input, requestOptions = {}) =>
523
+ request("/works", {
524
+ method: "POST",
525
+ body: normalizeWorkPrepareInput(input),
526
+ signal: requestOptions.signal,
527
+ }),
528
+ getWork: (workReference, requestOptions = {}) => {
529
+ const work = parseRequiredStableHandle(workReference, "work");
530
+ return request(`/works/${encodeURIComponent(work.id)}`, { signal: requestOptions.signal });
531
+ },
532
+ refreshWork: (workReference, requestOptions = {}) => {
533
+ const work = parseRequiredStableHandle(workReference, "work");
534
+ return request(`/works/${encodeURIComponent(work.id)}/refresh`, { method: "POST", signal: requestOptions.signal });
535
+ },
536
+ continueWork: (workReference, input, requestOptions = {}) => {
537
+ const work = parseRequiredStableHandle(workReference, "work");
538
+ return request(`/works/${encodeURIComponent(work.id)}/continue`, {
539
+ method: "POST",
540
+ body: normalizeWorkContinueInput(input),
541
+ signal: requestOptions.signal,
542
+ });
543
+ },
544
+ executeWork: (workReference, input, requestOptions = {}) => {
545
+ const work = parseRequiredStableHandle(workReference, "work");
546
+ return request(`/works/${encodeURIComponent(work.id)}/execute`, {
547
+ method: "POST",
548
+ body: normalizeWorkExecuteInput(input),
549
+ signal: requestOptions.signal,
550
+ });
551
+ },
522
552
  cancelCampaign: (runReference, requestOptions = {}) => {
523
553
  const run = parseRequiredStableHandle(runReference, "run");
524
554
  return request(`/campaigns/${encodeURIComponent(run.id)}`, {
@@ -558,6 +588,94 @@ export function createCanvasAgentClient(options = {}) {
558
588
  signal: requestOptions.signal,
559
589
  });
560
590
  },
591
+ listElements: (filters = {}, requestOptions = {}) =>
592
+ request("/elements", {
593
+ query: {
594
+ search: cleanOptionalText(filters.query, "Element search query", 240),
595
+ state: filters.state === "archived" ? "archived" : filters.state === "active" ? "active" : undefined,
596
+ ...paginationQuery(requestOptions, 100),
597
+ },
598
+ signal: requestOptions.signal,
599
+ }).then(decorateElementListResponse),
600
+ getElement: (elementReference, requestOptions = {}) => {
601
+ const element = parseElementHandle(elementReference);
602
+ return request(`/elements/${encodeURIComponent(element.id)}`, {
603
+ query: element.version ? { version: element.version } : undefined,
604
+ signal: requestOptions.signal,
605
+ }).then((result) => decorateElementGetResponse(result, element));
606
+ },
607
+ listElementHistory: (elementReference, requestOptions = {}) => {
608
+ const element = parseMutableElementHandle(elementReference);
609
+ return request(`/elements/${encodeURIComponent(element.id)}/versions`, {
610
+ query: paginationQuery(requestOptions, 100),
611
+ signal: requestOptions.signal,
612
+ }).then(decorateElementHistoryResponse);
613
+ },
614
+ createElement: (input, requestOptions = {}) =>
615
+ request("/elements", {
616
+ method: "POST",
617
+ body: normalizeElementInput(input, { includeCollections: true }),
618
+ signal: requestOptions.signal,
619
+ }),
620
+ updateElement: (elementReference, input, requestOptions = {}) => {
621
+ const element = parseMutableElementHandle(elementReference);
622
+ return request(`/elements/${encodeURIComponent(element.id)}`, {
623
+ method: "PATCH",
624
+ body: { operation: "update", element: normalizeElementInput(input) },
625
+ signal: requestOptions.signal,
626
+ });
627
+ },
628
+ updateElementCollections: (elementReference, collectionIds, requestOptions = {}) => {
629
+ const element = parseMutableElementHandle(elementReference);
630
+ return request(`/elements/${encodeURIComponent(element.id)}`, {
631
+ method: "PATCH",
632
+ body: { operation: "collections", collectionIds: normalizeElementCollectionIds(collectionIds) },
633
+ signal: requestOptions.signal,
634
+ });
635
+ },
636
+ archiveElement: (elementReference, requestOptions = {}) => {
637
+ const element = parseMutableElementHandle(elementReference);
638
+ return request(`/elements/${encodeURIComponent(element.id)}`, {
639
+ method: "PATCH",
640
+ body: { operation: "archive", confirm: true },
641
+ signal: requestOptions.signal,
642
+ });
643
+ },
644
+ restoreElement: (elementReference, requestOptions = {}) => {
645
+ const element = parseMutableElementHandle(elementReference);
646
+ return request(`/elements/${encodeURIComponent(element.id)}`, {
647
+ method: "PATCH",
648
+ body: { operation: "restore" },
649
+ signal: requestOptions.signal,
650
+ });
651
+ },
652
+ listElementCollections: (requestOptions = {}) =>
653
+ request("/element-collections", {
654
+ query: paginationQuery(requestOptions, 100),
655
+ signal: requestOptions.signal,
656
+ }).then(decorateElementCollectionsResponse),
657
+ createElementCollection: (input, requestOptions = {}) =>
658
+ request("/element-collections", {
659
+ method: "POST",
660
+ body: normalizeElementCollectionInput(input),
661
+ signal: requestOptions.signal,
662
+ }),
663
+ updateElementCollection: (collectionReference, input, requestOptions = {}) => {
664
+ const collection = parseElementCollectionHandle(collectionReference);
665
+ return request(`/element-collections/${encodeURIComponent(collection.id)}`, {
666
+ method: "PATCH",
667
+ body: normalizeElementCollectionInput(input),
668
+ signal: requestOptions.signal,
669
+ });
670
+ },
671
+ deleteElementCollection: (collectionReference, requestOptions = {}) => {
672
+ const collection = parseElementCollectionHandle(collectionReference);
673
+ return request(`/element-collections/${encodeURIComponent(collection.id)}`, {
674
+ method: "DELETE",
675
+ body: { confirm: true },
676
+ signal: requestOptions.signal,
677
+ });
678
+ },
561
679
  uploadAsset: (input, requestOptions = {}) => {
562
680
  if (!input || !input.bytes) throw new CanvasAgentApiError("Image bytes are required.", { code: "usage" });
563
681
  const canvas = input.canvasReference ? parseStableHandle(input.canvasReference, "canvas") : undefined;
@@ -752,6 +870,92 @@ export function parseStableHandle(value, expectedKind) {
752
870
  return { kind: expectedKind, id: validateId(parts[1], capitalize(expectedKind)) };
753
871
  }
754
872
 
873
+ export function parseElementHandle(value) {
874
+ const raw = String(value || "").trim();
875
+ const match = /^element:([A-Za-z0-9_-]{1,180})(?:@v([1-9][0-9]*))?$/.exec(raw);
876
+ if (!match) throw new CanvasAgentApiError("Element reference must be element:<id> or element:<id>@v<n>.", { code: "usage" });
877
+ return { kind: "element", id: match[1], ...(match[2] ? { version: Number(match[2]) } : {}) };
878
+ }
879
+
880
+ export function parseMutableElementHandle(value) {
881
+ const element = parseElementHandle(value);
882
+ if (element.version) throw new CanvasAgentApiError("Element mutations require the current element:<id> handle, not a pinned version.", { code: "usage" });
883
+ return element;
884
+ }
885
+
886
+ export function parseElementCollectionHandle(value) {
887
+ const raw = String(value || "").trim();
888
+ const match = /^element-collection:([A-Za-z0-9_-]{1,180})$/.exec(raw);
889
+ if (!match) throw new CanvasAgentApiError("Element collection reference must be element-collection:<id>.", { code: "usage" });
890
+ return { kind: "element-collection", id: match[1] };
891
+ }
892
+
893
+ function normalizeElementInput(input, { includeCollections = false } = {}) {
894
+ const value = requireRecord(input, "Element");
895
+ const sourceAssetIds = Array.isArray(value.sourceAssetIds) ? value.sourceAssetIds.map((asset) => parseStableHandle(asset, "asset").id) : [];
896
+ if (!includeCollections && value.collectionIds !== undefined) throw new CanvasAgentApiError("Use the Element collections operation to change collection membership.", { code: "usage" });
897
+ const collectionIds = includeCollections && value.collectionIds !== undefined ? normalizeElementCollectionIds(value.collectionIds) : undefined;
898
+ return {
899
+ name: cleanRequiredText(value.name, "Element name", 160),
900
+ type: cleanRequiredText(value.type, "Element type", 80),
901
+ sourceAssetIds,
902
+ ...(cleanOptionalText(value.guidelines, "Element guidelines", 2_000) ? { guidelines: cleanOptionalText(value.guidelines, "Element guidelines", 2_000) } : {}),
903
+ ...(collectionIds !== undefined ? { collectionIds } : {}),
904
+ };
905
+ }
906
+
907
+ function cleanRequiredText(value, label, maximum) {
908
+ const cleaned = cleanOptionalText(value, label, maximum);
909
+ if (!cleaned) throw new CanvasAgentApiError(`${label} is required.`, { code: "usage" });
910
+ return cleaned;
911
+ }
912
+
913
+ function normalizeElementCollectionIds(value) {
914
+ if (!Array.isArray(value) || value.length > 24) throw new CanvasAgentApiError("Choose up to 24 element collections.", { code: "usage" });
915
+ const ids = value.map((collection) => parseElementCollectionHandle(collection).id);
916
+ if (new Set(ids).size !== ids.length) throw new CanvasAgentApiError("Element collections must be unique.", { code: "usage" });
917
+ return ids;
918
+ }
919
+
920
+ function normalizeElementCollectionInput(input) {
921
+ const value = requireRecord(input, "Element collection");
922
+ return { name: cleanRequiredText(value.name, "Element collection name", 120) };
923
+ }
924
+
925
+ function decorateElementListResponse(result) {
926
+ if (!isRecord(result) || !Array.isArray(result.elements)) return result;
927
+ return { ...result, elements: result.elements.map(decorateElement) };
928
+ }
929
+
930
+ function decorateElementGetResponse(result, reference) {
931
+ if (!isRecord(result)) return result;
932
+ if (reference.version && isRecord(result.version)) return { ...result, version: decorateElementVersion(result.version, reference.id) };
933
+ if (isRecord(result.element)) return { ...result, element: decorateElement(result.element) };
934
+ return result;
935
+ }
936
+
937
+ function decorateElementHistoryResponse(result) {
938
+ if (!isRecord(result) || !Array.isArray(result.versions)) return result;
939
+ return { ...result, versions: result.versions.map(decorateElementVersion) };
940
+ }
941
+
942
+ function decorateElementCollectionsResponse(result) {
943
+ if (!isRecord(result) || !Array.isArray(result.collections)) return result;
944
+ return { ...result, collections: result.collections.map((collection) => (isRecord(collection) && typeof collection.id === "string" ? { ...collection, handle: `element-collection:${collection.id}` } : collection)) };
945
+ }
946
+
947
+ function decorateElement(element) {
948
+ if (!isRecord(element) || typeof element.id !== "string" || !Number.isInteger(element.version) || element.version < 1) return element;
949
+ return { ...element, handle: `element:${element.id}`, versionHandle: `element:${element.id}@v${element.version}` };
950
+ }
951
+
952
+ function decorateElementVersion(version, fallbackElementId) {
953
+ if (!isRecord(version)) return version;
954
+ const elementId = typeof version.elementId === "string" ? version.elementId : fallbackElementId;
955
+ if (!elementId || !Number.isInteger(version.version) || version.version < 1) return version;
956
+ return { ...version, handle: `element:${elementId}@v${version.version}` };
957
+ }
958
+
755
959
  export function parseRequiredStableHandle(value, expectedKind) {
756
960
  const raw = String(value || "").trim();
757
961
  if (!raw.startsWith(`${expectedKind}:`)) throw invalidHandle(expectedKind);
@@ -842,6 +1046,54 @@ function normalizeCampaignReviewInput(input) {
842
1046
  };
843
1047
  }
844
1048
 
1049
+ function normalizeWorkPrepareInput(input) {
1050
+ const source = requireRecord(input, "work prepare");
1051
+ const references = source.references === undefined ? undefined : normalizeWorkReferences(source.references);
1052
+ const canvas = source.canvasId === undefined ? undefined : stableHandle(parseRequiredStableHandle(source.canvasId, "canvas"));
1053
+ return {
1054
+ request: cleanRequiredText(source.request, "Work request", 8_000),
1055
+ ...(references ? { references } : {}),
1056
+ ...(canvas ? { canvasId: canvas } : {}),
1057
+ idempotencyKey: requiredIdempotencyKey(source.idempotencyKey),
1058
+ };
1059
+ }
1060
+
1061
+ function normalizeWorkContinueInput(input) {
1062
+ const source = requireRecord(input, "work continuation");
1063
+ const idempotencyKey = requiredIdempotencyKey(source.idempotencyKey);
1064
+ const action = source.action === undefined ? "" : cleanRequiredText(source.action, "Work continuation action", 80);
1065
+ const rebase = source.rebase === true;
1066
+ if (rebase || action === "acknowledge_canvas") {
1067
+ if (action && action !== "acknowledge_canvas") throw new CanvasAgentApiError("rebase may only be used with acknowledge_canvas.", { code: "usage" });
1068
+ if (source.answer !== undefined || source.directionId !== undefined || source.adjustment !== undefined) throw new CanvasAgentApiError("Canvas acknowledgement cannot include an answer, selection, or adjustment.", { code: "usage" });
1069
+ return { ...(action ? { action } : {}), ...(rebase ? { rebase: true } : {}), idempotencyKey };
1070
+ }
1071
+ if (action === "answer") return { action, answer: cleanRequiredText(source.answer, "Work answer", 2_000), idempotencyKey };
1072
+ if (action === "select_direction") return { action, directionId: cleanRequiredText(source.directionId, "Work direction", 400), idempotencyKey };
1073
+ if (action === "adjust") return { action, adjustment: cleanRequiredText(source.adjustment, "Work adjustment", 2_000), idempotencyKey };
1074
+ throw new CanvasAgentApiError("Work continuation action must be answer, select_direction, adjust, or acknowledge_canvas.", { code: "usage" });
1075
+ }
1076
+
1077
+ function normalizeWorkExecuteInput(input) {
1078
+ const source = requireRecord(input, "work execute");
1079
+ if (source.confirm !== true) throw new CanvasAgentApiError("Work execution requires confirm: true.", { code: "usage" });
1080
+ return { confirm: true, idempotencyKey: requiredIdempotencyKey(source.idempotencyKey) };
1081
+ }
1082
+
1083
+ function normalizeWorkReferences(value) {
1084
+ if (!Array.isArray(value) || value.length > 12) throw new CanvasAgentApiError("Work references must contain at most 12 node: or asset: handles.", { code: "usage" });
1085
+ return value.map((reference) => {
1086
+ const source = isRecord(reference) ? reference : { handle: reference };
1087
+ const raw = String(source.handle || "").trim();
1088
+ const kind = raw.startsWith("node:") ? "node" : raw.startsWith("asset:") ? "asset" : "";
1089
+ if (!kind) throw new CanvasAgentApiError("Work references must be node: or asset: handles.", { code: "usage" });
1090
+ const role = source.role;
1091
+ if (role !== undefined && role !== "identity" && role !== "style" && role !== "product") throw new CanvasAgentApiError("Work reference role must be identity, style, or product.", { code: "usage" });
1092
+ const handle = stableHandle(parseRequiredStableHandle(raw, kind));
1093
+ return role ? { handle, role } : handle;
1094
+ });
1095
+ }
1096
+
845
1097
  function normalizeCampaignProductReference(value) {
846
1098
  if (value === undefined || value === null) return undefined;
847
1099
  const reference = isRecord(value) ? (value.handle ?? value.nodeId ?? value.assetId) : value;
package/src/commands.mjs CHANGED
@@ -84,6 +84,16 @@ export const GAVANA_CLI_COMMANDS = Object.freeze([
84
84
  "gavana recipe run recipe:social-creative-angles --input offer-brief=@brief.md --destination agent-canvas",
85
85
  ],
86
86
  },
87
+ {
88
+ group: "work",
89
+ action: "prepare",
90
+ usage: ["gavana work prepare --request \"Create a social campaign\" --reference asset:<id> [--canvas canvas:<id>] --idempotency-key KEY"],
91
+ notesBefore: ["Work commands are chat-first: prepare returns exactly three directions without paid generation; execute requires a selected direction and --confirm."],
92
+ },
93
+ { group: "work", action: "get", usage: ["gavana work get work:<id>"] },
94
+ { group: "work", action: "refresh", usage: ["gavana work refresh work:<id>"] },
95
+ { group: "work", action: "continue", usage: ["gavana work continue work:<id> --action answer|select_direction|adjust|acknowledge_canvas --idempotency-key KEY", "gavana work continue work:<id> --rebase --idempotency-key KEY"] },
96
+ { group: "work", action: "execute", usage: ["gavana work execute work:<id> --confirm --idempotency-key KEY"] },
87
97
  { group: "canvas", action: "list", usage: ["gavana canvas list [--limit N] [--cursor CURSOR]"] },
88
98
  { group: "canvas", action: "agent", usage: ["gavana canvas agent"] },
89
99
  { group: "canvas", action: "create", usage: ["gavana canvas create --title \"New canvas\""] },
@@ -107,6 +117,18 @@ export const GAVANA_CLI_COMMANDS = Object.freeze([
107
117
  { group: "asset", action: "list", usage: ["gavana asset list [--canvas canvas:<id>] [--limit N] [--cursor CURSOR]"] },
108
118
  { group: "asset", action: "get", usage: ["gavana asset get asset:<id>|asset:<ownerUid>:<id>"] },
109
119
  { group: "asset", action: "upload", usage: ["gavana asset upload path/to/image.png"] },
120
+ { group: "element", action: "list", usage: ["gavana element list [query] [--state active|archived] [--limit N] [--cursor CURSOR]"] },
121
+ { group: "element", action: "get", usage: ["gavana element get element:<id>@v<n>"] },
122
+ { group: "element", action: "history", usage: ["gavana element history element:<id> [--limit N] [--cursor CURSOR]"] },
123
+ { group: "element", action: "create", usage: ["gavana element create --name \"Soft window light\" --type lighting [--source-asset asset:<id>] [--guidelines \"...\"]"] },
124
+ { group: "element", action: "update", usage: ["gavana element update element:<id> --name \"...\" --type lighting [--source-asset asset:<id>] [--guidelines \"...\"]"] },
125
+ { group: "element", action: "collections", usage: ["gavana element collections element:<id> [--collection element-collection:<id>]"] },
126
+ { group: "element", action: "archive", usage: ["gavana element archive element:<id> [--yes]"] },
127
+ { group: "element", action: "restore", usage: ["gavana element restore element:<id>"] },
128
+ { group: "element", action: "collection-list", usage: ["gavana element collection-list [--limit N] [--cursor CURSOR]"] },
129
+ { group: "element", action: "collection-create", usage: ["gavana element collection-create --name \"Campaign assets\""] },
130
+ { group: "element", action: "collection-rename", usage: ["gavana element collection-rename element-collection:<id> --name \"...\""] },
131
+ { group: "element", action: "collection-delete", usage: ["gavana element collection-delete element-collection:<id> [--yes]"] },
110
132
  { group: "provider", action: "list", usage: ["gavana provider list [--limit N] [--cursor CURSOR]"] },
111
133
  { group: "model", action: "list", usage: ["gavana model list [query] [--provider PROVIDER] [--capability image.generate]"] },
112
134
  { group: "model", action: "get", usage: ["gavana model get model:<id>"] },
@@ -122,9 +144,9 @@ export const GAVANA_CLI_COMMANDS = Object.freeze([
122
144
  "gavana action run action:side-by-side-composite --input node:<id> --input asset:<id> --destination canvas:<id>",
123
145
  ],
124
146
  },
125
- { group: "image", action: "generate", usage: ["gavana image generate --destination agent-canvas --prompt \"...\""] },
126
- { group: "image", action: "edit", usage: ["gavana image edit --destination canvas:<id> --reference path/to/image.png --prompt \"...\""] },
127
- { group: "image", action: "variations", usage: ["gavana image variations --destination new-canvas --canvas-title \"Variations\" --reference path/to/source.png --prompt \"...\""] },
147
+ { group: "image", action: "generate", usage: ["gavana image generate --destination agent-canvas --prompt \"...\" [--element element:<id>@v<n>] [--wait]"] },
148
+ { group: "image", action: "edit", usage: ["gavana image edit --destination canvas:<id> --reference path/to/image.png --prompt \"...\" [--wait]"] },
149
+ { group: "image", action: "variations", usage: ["gavana image variations --destination new-canvas --canvas-title \"Variations\" --reference path/to/source.png --prompt \"...\" [--wait]"] },
128
150
  { group: "video", action: "generate", usage: ["gavana video generate --model model:<id> --prompt \"...\" --duration 15", "gavana video generate --model model:<id> --prompt \"...\" --first-frame path/to/start.png --no-wait", "gavana video generate --model model:<id> --prompt \"...\" --download output.mp4"] },
129
151
  { group: "video", action: "download", usage: ["gavana video download job:<id> --file output.mp4 [--yes]"] },
130
152
  { group: "job", action: "get", usage: ["gavana job get job:<id>"] },
package/src/config.mjs CHANGED
@@ -7,13 +7,18 @@ import { promisify } from "node:util";
7
7
  const execFile = promisify(execFileCallback);
8
8
  const KEYCHAIN_SERVICE = "ai.gavana.cli";
9
9
 
10
- export async function readAgentConfig(env = process.env) {
10
+ export async function readAgentConfig(env = process.env, dependencies = {}) {
11
11
  const store = await readAgentConfigStore(env);
12
12
  const profile = selectedProfileName(store, env);
13
13
  const selected = store.profiles?.[profile] || {};
14
14
  let token = selected.token || "";
15
- if (!token && selected.credentialStore === "macos-keychain") token = await readMacKeychainCredential(profile);
16
- return { ...store, ...selected, ...(token ? { token } : {}), profile };
15
+ let credentialState = token ? "available" : "missing";
16
+ if (!token && selected.credentialStore === "macos-keychain") {
17
+ const credential = await keychainCredentialReader(dependencies)(profile);
18
+ token = credential.token;
19
+ credentialState = credential.state;
20
+ }
21
+ return { ...store, ...selected, ...(token ? { token } : {}), credentialState, profile };
17
22
  }
18
23
 
19
24
  export async function readAgentConfigMetadata(env = process.env) {
@@ -115,18 +120,30 @@ export async function setActiveAgentProfile(profile, env = process.env) {
115
120
  return { profile: name, configPath: agentConfigFilePath(env) };
116
121
  }
117
122
 
118
- export async function listAgentProfiles(env = process.env) {
123
+ export async function listAgentProfiles(env = process.env, dependencies = {}) {
119
124
  const store = await readAgentConfigStore(env);
125
+ const readKeychainCredential = keychainCredentialReader(dependencies);
120
126
  return {
121
127
  activeProfile: selectedProfileName(store, env),
122
- profiles: Object.entries(store.profiles || {})
123
- .sort(([left], [right]) => left.localeCompare(right))
124
- .map(([name, value]) => ({
125
- name,
126
- baseUrl: value?.baseUrl || "",
127
- active: name === selectedProfileName(store, env),
128
- configured: Boolean(value?.baseUrl && (value?.token || value?.credentialStore === "macos-keychain")),
129
- })),
128
+ profiles: await Promise.all(
129
+ Object.entries(store.profiles || {})
130
+ .sort(([left], [right]) => left.localeCompare(right))
131
+ .map(async ([name, value]) => {
132
+ const credential = value?.token
133
+ ? { token: value.token, state: "available" }
134
+ : value?.credentialStore === "macos-keychain"
135
+ ? await readKeychainCredential(name)
136
+ : { token: "", state: "missing" };
137
+ return {
138
+ name,
139
+ baseUrl: value?.baseUrl || "",
140
+ active: name === selectedProfileName(store, env),
141
+ configured: Boolean(value?.baseUrl && credential.state === "available"),
142
+ credentialStore: value?.credentialStore || "",
143
+ credentialState: credential.state,
144
+ };
145
+ }),
146
+ ),
130
147
  configPath: agentConfigFilePath(env),
131
148
  };
132
149
  }
@@ -179,15 +196,31 @@ async function writeMacKeychainCredential(profile, token) {
179
196
  await execFile("security", ["add-generic-password", "-a", profile, "-s", KEYCHAIN_SERVICE, "-w", token, "-U"]);
180
197
  }
181
198
 
182
- async function readMacKeychainCredential(profile) {
199
+ function keychainCredentialReader(dependencies) {
200
+ if (dependencies.readKeychainCredential) return dependencies.readKeychainCredential;
201
+ return (profile) => readMacKeychainCredential(profile, dependencies.execFile || execFile);
202
+ }
203
+
204
+ async function readMacKeychainCredential(profile, execute = execFile) {
183
205
  try {
184
- const { stdout } = await execFile("security", ["find-generic-password", "-a", profile, "-s", KEYCHAIN_SERVICE, "-w"]);
185
- return stdout.trim();
186
- } catch {
187
- return "";
206
+ const { stdout } = await execute("security", ["find-generic-password", "-a", profile, "-s", KEYCHAIN_SERVICE, "-w"]);
207
+ const token = stdout.trim();
208
+ return { token, state: token ? "available" : "missing" };
209
+ } catch (error) {
210
+ if (!isMissingMacKeychainCredential(error)) return { token: "", state: "inaccessible" };
211
+ try {
212
+ await execute("security", ["show-keychain-info"]);
213
+ return { token: "", state: "missing" };
214
+ } catch {
215
+ return { token: "", state: "inaccessible" };
216
+ }
188
217
  }
189
218
  }
190
219
 
220
+ function isMissingMacKeychainCredential(error) {
221
+ return Number(error?.code) === 44 || /could not be found|item not found/i.test(String(error?.stderr || ""));
222
+ }
223
+
191
224
  async function removeMacKeychainCredential(profile) {
192
225
  try {
193
226
  await execFile("security", ["delete-generic-password", "-a", profile, "-s", KEYCHAIN_SERVICE]);
@@ -25,7 +25,7 @@ export const GAVANA_CANVAS_GUIDE_SOURCES = Object.freeze([
25
25
  description: "Place native objects in readable groups without hiding or rearranging existing work.",
26
26
  keywords: Object.freeze(["section","layout","position","overlap","spacing","grid","right","below","contain","navigation"]),
27
27
  order: 3,
28
- markdown: "\n## Layout rules\n\n- Treat the current canvas as user-owned. Preserve existing coordinates unless reorganization was explicitly requested.\n- For additions to an existing canvas, compute its visible bounding box and place the new Section to the right with at least 160 canvas units of outer spacing. If right-side placement would make the canvas excessively wide, place it below with the same spacing.\n- Use 48 units of inner Section padding, 32 units between sibling nodes, and at least 80 units between major stages.\n- Keep workflow direction consistent, normally left to right. Keep inputs before transformations and outputs after them.\n- Use compact rows or columns. Avoid extremely long, thin canvases that become unreadable at Fit Canvas.\n- For 2-8 generated image outputs, reserve each requested final aspect frame before work begins and pack the complete result cluster as a grid. The usual four-image photoshoot is a 2x2 grid; do not stack it as a tall output column that can overlap when images finalize.\n- Size Sections after their contents. Do not use Section overlap as a substitute for node placement.\n- Run `canvas_validate` before and after a multi-node layout change.\n\n## One task = one Section\n\nGroup each task's output in a titled Section sized to its contents plus 48\nunits of padding. Workflow creation and multi-output image generation wrap\ntheir clusters in a Section automatically (multi-output wrapping applies to\nautomatic placement; passing explicit target coordinates opts out and leaves\nplacement fully caller-controlled); do the same for hand-built\nclusters. Agent-created nodes left outside every Section raise an info-level\n`unsectioned_node` finding. Membership is stored server-side as\n`metadata.sectionId`, recomputed from complete-frame geometry after every\nbatch. Agent-created Sections receive an owned auto-fit contract; after a\ngenerated child changes size, Gavana refits only that still-owned Section to\nthe full child bounds plus 48 units. Do not use this as permission to move or\nresize a user-controlled Section.\n\nFor a newly proposed agent cluster, ordinary-node overlap and full-frame\nSection overflow are write-blocking errors. On an existing Canvas, historical\nuser-created overlap or overflow remains advisory so an unrelated scoped edit\ncan still proceed. Agent-owned task findings still block completion review\nuntil they are corrected.\n\n## Existing canvas rule\n\nWhen the user says \"add\", do not interpret it as \"reorganize\". New work should be distinguishable, spatially contained, and reversible without moving unrelated content.\n",
28
+ markdown: "\n## Layout rules\n\n- Treat the current canvas as user-owned. Preserve existing coordinates unless reorganization was explicitly requested.\n- For additions to an existing canvas, compute its visible bounding box and place the new Section to the right with at least 160 canvas units of outer spacing. If right-side placement would make the canvas excessively wide, place it below with the same spacing.\n- Use 48 units of inner Section padding, 32 units between sibling nodes, and at least 80 units between major clusters.\n- Keep a left-to-right direction only when the user asks for a linear workflow. For creative exploration, use Sections as optional spatial places rather than a required sequence.\n- Use compact rows or columns. Avoid extremely long, thin canvases that become unreadable at Fit Canvas.\n- For 2-8 generated image outputs, reserve each requested final aspect frame before work begins and pack the complete result cluster as a grid. A normal feed photoshoot uses 4:5 portrait frames in its own 2x2 Section. Put horizontal 16:9 campaign ads in a separate Section with their own targets; do not mix formats unless the user explicitly asks for a mixed-format deliverable.\n- Size Sections after their contents. Do not use Section overlap as a substitute for node placement.\n- Run `canvas_validate` before and after a multi-node layout change.\n\n## One task = one Section\n\nGroup each task's output in a titled Section sized to its contents plus 48\nunits of padding. Workflow creation and multi-output image generation wrap\ntheir clusters in a Section automatically (multi-output wrapping applies to\nautomatic placement; passing explicit target coordinates opts out and leaves\nplacement fully caller-controlled); do the same for hand-built\nclusters. Agent-created nodes left outside every Section raise an info-level\n`unsectioned_node` finding. Membership is stored server-side as\n`metadata.sectionId`, recomputed from complete-frame geometry after every\nbatch. Agent-created Sections receive an owned auto-fit contract; after a\ngenerated child changes size, Gavana refits only that still-owned Section to\nthe full child bounds plus 48 units. Do not use this as permission to move or\nresize a user-controlled Section.\n\nFor a newly proposed agent cluster, ordinary-node overlap and full-frame\nSection overflow are write-blocking errors. On an existing Canvas, historical\nuser-created overlap or overflow remains advisory so an unrelated scoped edit\ncan still proceed. Agent-owned task findings still block completion review\nuntil they are corrected.\n\n## Existing canvas rule\n\nWhen the user says \"add\", do not interpret it as \"reorganize\". New work should be distinguishable, spatially contained, and reversible without moving unrelated content.\n",
29
29
  }),
30
30
  Object.freeze({
31
31
  id: "connections",
@@ -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- 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- 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. Poll the returned Run or Job; do not start another run while waiting. A terminal failure must be reported without automatic retry.\n\n## Completion\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. 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, durable output count, and product-fidelity review state. Do not claim Done while it says `doneClaimAllowed: false`. Product-fidelity uncertainty requires human review; 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- 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- 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- 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. Image tools return durable queued progress by default; report that progress immediately and do not automatically call `run_wait`. Call `run_wait` only when the current user explicitly needs the completed asset in this same interaction. Otherwise, a later `run_get` or Canvas read can observe progress and the durable output. Do not start another run while one is pending. A terminal failure must be reported without automatic retry.\n\n## Completion\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. 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",
@@ -65,7 +65,7 @@ export const GAVANA_CANVAS_GUIDE_SOURCES = Object.freeze([
65
65
  description: "Separate preparation from execution and prevent accidental or repeated provider charges.",
66
66
  keywords: Object.freeze(["paid","credits","cost","generate","run","retry","failure","prepare","setup","explicit"]),
67
67
  order: 8,
68
- markdown: "\n## Intent boundary\n\n\"Build\", \"prepare\", \"set up\", \"connect\", \"draft\", and \"make ready\" authorize graph edits only. They do not authorize Recipe, image, video, or Action execution.\n\nStart paid work only when the current user message explicitly asks to run or generate it. Do not infer authorization from an older message, a node label, an unfinished placeholder, or nearby content.\n\n## Retry boundary\n\n- Use one stable idempotency key for one intended paid operation.\n- Poll the returned handle with status tools.\n- Never automatically retry a terminal failure, timeout, disconnect, or ambiguous provider response with a new key.\n- Ask for new user intent before any new paid attempt.\n\nDeterministic Actions may be described as credit-free only when `action_get` confirms that contract. Inspect an Action before running it.\n",
68
+ markdown: "\n## Intent boundary\n\n\"Build\", \"prepare\", \"set up\", \"connect\", \"draft\", and \"make ready\" authorize graph edits only. They do not authorize Recipe, image, video, or Action execution.\n\nStart paid work only when the current user message explicitly asks to run or generate it. Do not infer authorization from an older message, a node label, an unfinished placeholder, or nearby content.\n\n## Retry boundary\n\n- Use one stable idempotency key for one intended paid operation.\n- Return an image Run's durable queued progress immediately. Do not call `run_wait` unless the current user explicitly needs the completed asset in this same interaction; otherwise observe it later with `run_get` or a Canvas read.\n- Never automatically retry a terminal failure, timeout, disconnect, or ambiguous provider response with a new key.\n- Ask for new user intent before any new paid attempt.\n\nDeterministic Actions may be described as credit-free only when `action_get` confirms that contract. Inspect an Action before running it.\n",
69
69
  }),
70
70
  Object.freeze({
71
71
  id: "validation-recovery",
@@ -73,7 +73,7 @@ export const GAVANA_CANVAS_GUIDE_SOURCES = Object.freeze([
73
73
  description: "Interpret structural findings, correct invalid batches, and finish with an evidence-based review.",
74
74
  keywords: Object.freeze(["validate","lint","overlap","orphan","header","connection","destructive","recovery","error","atomic","blocked","force"]),
75
75
  order: 9,
76
- markdown: "\n## Use `canvas_validate`\n\nCall it with only `canvasId` to audit the current graph. Pass the proposed `operations` to inspect the planned post-batch graph and destructive impact before writing.\n\nWarning- and info-level findings are advisory. Error-severity findings caused by the proposed agent write have teeth: `canvas_apply_batch` rejects that batch and writes nothing. Historical findings remain visible for review but do not turn an unrelated scoped write into a forced cleanup. Validation never mutates the canvas or consumes an idempotency key.\n\n`summary.passed` means there is no structural error. `summary.reviewRequired` is true when errors, warnings, or truncated findings still require agent or human review. `completionReview` is the finalization-ready structured view: it names overlap, containment, reference lineage, output count, and product-fidelity state. Never claim Done unless `completionReview.doneClaimAllowed` is true.\n\n## Blocked writes\n\nWhen a batch is rejected with error-severity findings:\n\n- Nothing was written and the idempotency key was not consumed — the same key retries the corrected batch of the same intent.\n- The rejection lists the blocking findings. Fix the listed operations, then confirm with `canvas_validate` using the same `operations` before applying again.\n- `force: true` applies the batch despite error findings. Use it only after validating, and only when the user explicitly accepts the listed findings — never as a routine retry shortcut.\n\n## Recovery\n\n- If an operation is rejected, the atomic batch makes no partial change.\n- Read the finding's exact handles and linked guide topic.\n- Correct unsupported object types, metadata, endpoint modes, positions, or references instead of guessing repeatedly.\n- Use a new idempotency key if the corrected payload represents a changed intent.\n- Read and validate again after a successful write.\n\nCommon findings include ordinary-node overlap, Section content overflow, generated output without an incoming relationship, text imitating a Section header, broken lineage handles, unclear media connections, and proposed deletions. Product-fidelity uncertainty is a review state, not authorization to regenerate.\n",
76
+ markdown: "\n## Use `canvas_validate`\n\nCall it with only `canvasId` to audit the current graph. Pass the proposed `operations` to inspect the planned post-batch graph and destructive impact before writing.\n\nWarning- and info-level findings are advisory. Error-severity findings caused by the proposed agent write have teeth: `canvas_apply_batch` rejects that batch and writes nothing. Historical findings remain visible for review but do not turn an unrelated scoped write into a forced cleanup. Validation never mutates the canvas or consumes an idempotency key.\n\n`summary.passed` means there is no structural error. `summary.reviewRequired` is true when errors, warnings, or truncated findings still require agent or human review. `completionReview` is the finalization-ready structured view: it names overlap, containment, reference lineage, and delivery state. Never claim Done unless `completionReview.doneClaimAllowed` is true; pending, failed, or non-durable generated outputs block that claim even when the geometry is clean.\n\nFor a campaign, poster, banner, or multi-direction composition, structural\nvalidation is necessary but not visual proof. Render the completed Canvas and\ncheck the actual composition, distinctness of directions, visible product and\nlayout, and separation of 4:5 feed work from 16:9 ads. Report an unavailable\nrender or incomplete visual check as a limitation rather than claiming it was\nverified.\n\n## Blocked writes\n\nWhen a batch is rejected with error-severity findings:\n\n- Nothing was written and the idempotency key was not consumed — the same key retries the corrected batch of the same intent.\n- The rejection lists the blocking findings. Fix the listed operations, then confirm with `canvas_validate` using the same `operations` before applying again.\n- `force: true` applies the batch despite error findings. Use it only after validating, and only when the user explicitly accepts the listed findings — never as a routine retry shortcut.\n\n## Recovery\n\n- If an operation is rejected, the atomic batch makes no partial change.\n- Read the finding's exact handles and linked guide topic.\n- Correct unsupported object types, metadata, endpoint modes, positions, or references instead of guessing repeatedly.\n- Use a new idempotency key if the corrected payload represents a changed intent.\n- Read and validate again after a successful write.\n\nCommon findings include ordinary-node overlap, Section content overflow, generated output without an incoming relationship, text imitating a Section header, broken lineage handles, unclear media connections, and proposed deletions. Reference provenance is not authorization to regenerate.\n",
77
77
  }),
78
78
  Object.freeze({
79
79
  id: "examples-common-mistakes",
@@ -83,4 +83,12 @@ export const GAVANA_CANVAS_GUIDE_SOURCES = Object.freeze([
83
83
  order: 10,
84
84
  markdown: "\n## Good patterns\n\n- Research cluster: one Section, several Sticky observations, one Text synthesis, and directed observation -> synthesis connections.\n- Creative workflow: source image -> Prompt List -> empty generator -> generated outputs.\n- Video workflow: first image -[first-frame]-> video target and optional last image -[last-frame]-> video target.\n- Existing-canvas addition: new contained Section outside the current bounds, with no existing-node mutations.\n\n## Common mistakes\n\n- Using long Text or Sticky nodes as fake headers instead of Sections.\n- Creating every node at `{ \"x\": 0, \"y\": 0 }` or stacking nodes on top of one another.\n- Leaving generated outputs disconnected from their prompt, List, source, or stage.\n- Connecting a Section to workflow nodes.\n- Treating nearby objects as implicit inputs without explicit handles or connections.\n- Copying a style image name into prompt text instead of using a row-level reference binding.\n- Writing generated media content directly into node metadata.\n- Reorganizing or deleting existing work when the user asked only to add something.\n- Starting generation when the user asked only to prepare a workflow.\n- Retrying a failed paid operation automatically.\n- Reporting a temporary Job or Run handle as a durable Canvas output.\n- Passing `force: true` to push a batch past error-severity findings instead of fixing the operations. Force is for explicit, user-approved exceptions only.\n",
85
85
  }),
86
+ Object.freeze({
87
+ id: "creative-canvas",
88
+ title: "Creative Canvas",
89
+ description: "Keep creative work on the Canvas without lifecycle states.",
90
+ keywords: Object.freeze(["creative","ideas","directions","brainstorm","refine","compose","campaign"]),
91
+ order: 11,
92
+ markdown: "\n## The Canvas is the memory\n\nEvery generated image, written thought, reference, and experiment remains on the Canvas until a person deletes it. Do not assign creative output a draft, rejected, accepted, approved, or final lifecycle state. A retained node has value even when it is not currently selected.\n\nReference handles and connections are durable provenance. They explain where a creative result came from; they do not require a user to click Keep or clear a review gate.\n\n## Spatial structure, not stages\n\nUse an ordinary Section only when the person explicitly asks to organize work. Name it for their brief; it is never a required left-to-right workflow. Any node may move between Sections, appear in more than one discussion, become a reference for later work, or remain untouched. Do not move, rename, or create Sections unless the user requests structure.\n\n## Campaign assistance\n\nWhen a person supplies only a product and a broad ask, do not jump straight to a\ngeneric photoshoot, poster, or banner. First make a visible reference-led\nconcept cluster with three roles:\n\n- **Product identity:** the exact supplied product, logo, garment, or other\n identity reference when one exists.\n- **Brand-world:** the lighting, material, setting, and cultural visual world.\n- **Typography/layout:** the editorial hierarchy, copy placement, crop, and\n composition reference.\n\nConnect each source to the work it informs. If inspiration is missing and the\ncurrent request explicitly authorizes generation, autonomously create small,\nclearly labelled concept-reference images or boards for every missing role: a\nproduct-identity concept study, brand-world, and typography/layout. A\nproduct-identity study is provisional when no exact product source exists; none\nof these generated concept references may be claimed as product evidence,\nofficial assets, or real campaigns. If the request authorizes preparation only,\ncreate editable prompt and Text references without starting a paid image job.\n\nInfer a concise brief and create at least three materially different directions.\nVary the central idea, composition, setting, copy hierarchy, typography/layout\nconcept, or audience — not merely the pose or crop. Give every direction a\nshort rationale Text node and preserve all attempts on the Canvas. Add one\n**Recommended next move** Text node that explains the strongest direction; this\nis editorial advice, not approval.\n\nDo not create a default Section set. If a person asks for a structured campaign\narea, use only the ordinary Sections they request. Ask one short question only\nwhen a missing product-versus-style distinction would materially change the\nwork. Otherwise make a useful first pass and let the person point to, combine,\nor refine any result. Exporting or publishing is an explicit action from any\nselected node or Section, not a status transition.\n",
93
+ }),
86
94
  ]);
@@ -0,0 +1,72 @@
1
+ /**
2
+ * MCP server naming and per-client config for the CLI.
3
+ *
4
+ * This mirrors `src/lib/canvas-agent/connect-targets.ts`, which is the same
5
+ * contract the web hub emits. The CLI ships as its own package and cannot
6
+ * import the app's TypeScript, so the two are pinned against each other by
7
+ * `scripts/connect-targets-cli-drift.test.ts` — change both together.
8
+ *
9
+ * Before this existed the CLI hardcoded the name `gavana` for every
10
+ * environment and both scopes, so `gavana mcp install claude` from a local
11
+ * checkout silently replaced the user's production entry, and `--read-only`
12
+ * silently replaced their full-access one.
13
+ */
14
+
15
+ export function gavanaMcpEndpoint(baseUrl, readOnly = false) {
16
+ return `${normalizeOrigin(baseUrl)}/mcp${readOnly ? "/readonly" : ""}`;
17
+ }
18
+
19
+ export function gavanaMcpServerName(baseUrl, readOnly = false) {
20
+ return `${environmentServerName(baseUrl)}${readOnly ? "-readonly" : ""}`;
21
+ }
22
+
23
+ export function gavanaMcpEnvironment(baseUrl) {
24
+ let hostname;
25
+ try {
26
+ hostname = new URL(normalizeOrigin(baseUrl)).hostname.toLowerCase();
27
+ } catch {
28
+ return "production";
29
+ }
30
+ if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1" || hostname.endsWith(".localhost")) return "development";
31
+ if (hostname.includes("staging")) return "staging";
32
+ return "production";
33
+ }
34
+
35
+ export function gavanaMcpClientDefinition(clientName, baseUrl, readOnly = false) {
36
+ const endpoint = gavanaMcpEndpoint(baseUrl, readOnly);
37
+ const serverName = gavanaMcpServerName(baseUrl, readOnly);
38
+
39
+ if (clientName === "codex") return { client: "codex", transport: "streamable-http", endpoint, serverName, command: "codex", args: ["mcp", "add", serverName, "--url", endpoint] };
40
+ if (clientName === "claude") return { client: "claude", transport: "streamable-http", endpoint, serverName, command: "claude", args: ["mcp", "add", "--transport", "http", "--scope", "user", serverName, endpoint] };
41
+ if (clientName === "cursor") return { client: "cursor", transport: "streamable-http", endpoint, serverName, config: { mcpServers: { [serverName]: { url: endpoint } } } };
42
+ if (clientName === "vscode") return { client: "vscode", transport: "streamable-http", endpoint, serverName, config: { servers: { [serverName]: { type: "http", url: endpoint } } } };
43
+ if (clientName === "chatgpt") {
44
+ return { client: "chatgpt", transport: "streamable-http", endpoint, serverName, instructions: "Add the endpoint as a custom MCP connector, then complete Gavana OAuth in the browser. ChatGPT requires an https endpoint." };
45
+ }
46
+ if (clientName === "stdio" || clientName === "local") {
47
+ return {
48
+ client: "local",
49
+ transport: "stdio",
50
+ serverName,
51
+ command: "npx",
52
+ args: ["-y", "@gavana.ai/mcp@0.2.1"],
53
+ env: readOnly ? { GAVANA_MCP_READ_ONLY: "true" } : {},
54
+ credentialSource: "Reads the active Gavana CLI profile or inherited GAVANA_BASE_URL and GAVANA_AGENT_TOKEN environment variables.",
55
+ };
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function environmentServerName(baseUrl) {
61
+ const environment = gavanaMcpEnvironment(baseUrl);
62
+ return environment === "development" ? "gavana-dev" : environment === "staging" ? "gavana-staging" : "gavana";
63
+ }
64
+
65
+ function normalizeOrigin(baseUrl) {
66
+ const trimmed = String(baseUrl || "").trim();
67
+ try {
68
+ return new URL(trimmed).origin;
69
+ } catch {
70
+ return trimmed.replace(/\/+$/, "");
71
+ }
72
+ }