shotops-mcp 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/local.js CHANGED
@@ -68,9 +68,9 @@ function readBundle(name) {
68
68
  function widgetHtml(bundle) {
69
69
  return `<div id="shotops-root"></div><script type="module">${bundle}</script>`;
70
70
  }
71
- function registerWidgets(server2) {
71
+ function registerWidgets(server) {
72
72
  const origin = supabaseOrigin();
73
- registerWidget(server2, {
73
+ registerWidget(server, {
74
74
  bundleFile: "panels.js",
75
75
  canonicalUri: PANELS_WIDGET_URI,
76
76
  uris: PANELS_WIDGET_URIS,
@@ -79,7 +79,7 @@ function registerWidgets(server2) {
79
79
  description: "Shows the rendered App Store panels in a swipeable ShotOps gallery with per-panel download actions and render details.",
80
80
  origin
81
81
  });
82
- registerWidget(server2, {
82
+ registerWidget(server, {
83
83
  bundleFile: "bundle.js",
84
84
  canonicalUri: BUNDLE_WIDGET_URI,
85
85
  uris: BUNDLE_WIDGET_URIS,
@@ -89,11 +89,11 @@ function registerWidgets(server2) {
89
89
  origin
90
90
  });
91
91
  }
92
- function registerWidget(server2, spec) {
92
+ function registerWidget(server, spec) {
93
93
  const bundle = readBundle(spec.bundleFile);
94
94
  if (!bundle) return;
95
95
  for (const uri of spec.uris) {
96
- server2.registerResource(
96
+ server.registerResource(
97
97
  uri === spec.canonicalUri ? spec.slug : `${spec.slug}-${uri.split("/").at(-1)}`,
98
98
  uri,
99
99
  {
@@ -143,8 +143,8 @@ var PROJECT_SCHEMA_VERSION = 2;
143
143
  var IPHONE_MODEL_ID = "iphone-17-pro";
144
144
  var PIXEL_MODEL_ID = "pixel-10-pro";
145
145
  var DEVICES = [
146
- { id: "iphone-6-9", label: "iPhone 6.9\u2033", panelPresetId: "r69", ascDeviceSet: "APP_IPHONE_67", modelId: IPHONE_MODEL_ID, screenFamily: "iphone" },
147
- { id: "iphone-6-5", label: "iPhone 6.5\u2033", panelPresetId: "r65", ascDeviceSet: "APP_IPHONE_65", modelId: IPHONE_MODEL_ID, screenFamily: "iphone" },
146
+ { id: "iphone-6-9", label: "iPhone 17 Pro", panelPresetId: "r69", ascDeviceSet: "APP_IPHONE_67", modelId: IPHONE_MODEL_ID, screenFamily: "iphone" },
147
+ { id: "iphone-6-5", label: "iPhone 17", panelPresetId: "r65", ascDeviceSet: "APP_IPHONE_65", modelId: IPHONE_MODEL_ID, screenFamily: "iphone" },
148
148
  { id: "pixel-10-pro", label: "Pixel 10 Pro", panelPresetId: "pixel69", ascDeviceSet: "ANDROID_PHONE", modelId: PIXEL_MODEL_ID, screenFamily: "android" },
149
149
  { id: "ipad-13", label: "iPad 13\u2033", panelPresetId: "ipad13", ascDeviceSet: "APP_IPAD_PRO_129", modelId: "ipad-pro", screenFamily: "ipad", comingSoon: true },
150
150
  { id: "ipad-12-9", label: "iPad 12.9\u2033", panelPresetId: "ipad129", ascDeviceSet: "APP_IPAD_PRO_3GEN_129", modelId: "ipad-pro", screenFamily: "ipad", comingSoon: true }
@@ -868,8 +868,10 @@ var SERVER_INSTRUCTIONS = [
868
868
  " position, glare, reflections, lighting, flatScreen. Those three PLACEMENT fields are the ones",
869
869
  " style.layout sets for you \u2014 prefer it and use these to adjust the result. Every numeric field",
870
870
  " here is RANGE-CHECKED: out of range is rejected with the range and the fix, not clamped.",
871
- "- style.background (the strip): mode gradient|perPanel; gradientFrom/gradientTo (hex),",
872
- " gradientDir vertical|horizontal, shadow, floorReflection; or panelColors[] (one hex per slot) for perPanel.",
871
+ "- style.background (the strip): mode gradient|perPanel; stops [{color,at}] (hex colour + 0\u20131",
872
+ " position along the gradient line \u2014 one stop is a solid, two is a classic top/bottom gradient,",
873
+ ' more is a multi-colour sweep) and angle (degrees clockwise from "to top", CSS convention:',
874
+ " 90 = right, 180 = down); shadow, floorReflection; or panelColors[] (one hex per slot) for perPanel.",
873
875
  "- style.captions[] (ONE entry per panel, in slot order; null = no caption). An entry is EITHER a",
874
876
  " single caption object OR an array of layers stacked on that panel: [{text,\u2026},{text,\u2026}]. Fields:",
875
877
  " text (this layer\u2019s words), subtitle (single-caption shape only \u2014 in the array shape add another",
@@ -1036,6 +1038,20 @@ var SERVER_INSTRUCTIONS = [
1036
1038
  "reproduces the human-tuned design with zero manual step. Zero-custody: source screenshots are",
1037
1039
  "ephemeral (one render, then discarded); only the styling look is persisted, never the pixels."
1038
1040
  ].join("\n");
1041
+ var ANONYMOUS_TOOLS = /* @__PURE__ */ new Set(["import_screenshot", "render_strip"]);
1042
+ var ANONYMOUS_MODE_INSTRUCTIONS = [
1043
+ "",
1044
+ "## This connection is NOT signed in",
1045
+ "The user has not connected a ShotOps account yet, so this connection can do exactly one thing,",
1046
+ "and it is worth doing: import their screenshots (import_screenshot) and render ONE preview strip",
1047
+ "of up to 5 panels (render_strip with `preview: true`). Do that first \u2014 showing them their own",
1048
+ "app as a store strip is the point. Pick their best 5 screens if they give you more.",
1049
+ "Every other tool \u2014 save_project, render_project, emit_bundle, the look tools, upload slots,",
1050
+ "delete_assets \u2014 needs them to connect ShotOps, and answers saying so. Do not pretend otherwise,",
1051
+ "and do not ask them to sign in BEFORE the free preview: render it, show it, and then offer the",
1052
+ "account for the things it unlocks (keeping the project, full-resolution panels, the fastlane",
1053
+ "bundle, more renders)."
1054
+ ].join("\n");
1039
1055
 
1040
1056
  // src/pngOptimizer.ts
1041
1057
  import sharp from "sharp";
@@ -1069,8 +1085,15 @@ async function optimizeScreenshotPng(bytes, what = "Screenshot") {
1069
1085
 
1070
1086
  // src/uploads.ts
1071
1087
  var DOWNLOAD_URL_TTL_SECONDS = 60 * 60;
1072
- function uploadDirFor(userId) {
1073
- return `uploads/${userId}/${randomBytes(12).toString("base64url")}`;
1088
+ var ANONYMOUS_USER_PREFIX = "anon-";
1089
+ var ANONYMOUS_ASSET_TTL_MS = 60 * 60 * 1e3;
1090
+ var ANONYMOUS_DIR_EXPIRY_RE = /^t(\d{13,})-/;
1091
+ function uploadDirFor(userId, now = Date.now()) {
1092
+ const random = randomBytes(12).toString("base64url");
1093
+ if (userId.startsWith(ANONYMOUS_USER_PREFIX)) {
1094
+ return `uploads/${userId}/t${now + ANONYMOUS_ASSET_TTL_MS}-${random}`;
1095
+ }
1096
+ return `uploads/${userId}/${random}`;
1074
1097
  }
1075
1098
  var NAME_SEGMENT_RE = /^\d+__([A-Za-z0-9_-]+)\.png$/;
1076
1099
  function decodeNameFromRef(ref) {
@@ -1083,8 +1106,13 @@ function decodeNameFromRef(ref) {
1083
1106
  return null;
1084
1107
  }
1085
1108
  }
1086
- function refBelongsToUser(ref, userId) {
1087
- return ref.startsWith(`uploads/${userId}/`) && !ref.includes("..");
1109
+ function refBelongsToUser(ref, userId, now = Date.now()) {
1110
+ const prefix = `uploads/${userId}/`;
1111
+ if (!ref.startsWith(prefix) || ref.includes("..")) return false;
1112
+ if (!userId.startsWith(ANONYMOUS_USER_PREFIX)) return true;
1113
+ const dir = ref.slice(prefix.length).split("/")[0];
1114
+ const expiry = ANONYMOUS_DIR_EXPIRY_RE.exec(dir);
1115
+ return expiry ? Number(expiry[1]) > now : false;
1088
1116
  }
1089
1117
  async function fetchUploadedRef(ref, userId) {
1090
1118
  if (!refBelongsToUser(ref, userId)) {
@@ -1432,8 +1460,8 @@ import { z as z4 } from "zod";
1432
1460
  var RENDERABLE_PANEL_PRESET_IDS = ["r69", "r65", "r55"];
1433
1461
  var OUTPUT_DEVICE_IDS = ["iphone-6-9", "iphone-6-5"];
1434
1462
  var OUTPUT_DEVICES = [
1435
- { id: "iphone-6-9", label: "iPhone 6.9\u2033", panelPresetId: "r69", ascDeviceSet: "APP_IPHONE_67" },
1436
- { id: "iphone-6-5", label: "iPhone 6.5\u2033", panelPresetId: "r65", ascDeviceSet: "APP_IPHONE_65" }
1463
+ { id: "iphone-6-9", label: "iPhone 17 Pro", panelPresetId: "r69", ascDeviceSet: "APP_IPHONE_67" },
1464
+ { id: "iphone-6-5", label: "iPhone 17", panelPresetId: "r65", ascDeviceSet: "APP_IPHONE_65" }
1437
1465
  ];
1438
1466
  function outputDeviceFor(id) {
1439
1467
  return OUTPUT_DEVICES.find((device) => device.id === id);
@@ -1551,6 +1579,58 @@ var SHOT_LOOK_RANGES = {
1551
1579
  clearcoat: { min: 0, max: 1 }
1552
1580
  };
1553
1581
 
1582
+ // ../mockup-engine/gradient.ts
1583
+ var HORIZONTAL_ANGLE = 90;
1584
+ var VERTICAL_ANGLE = 180;
1585
+ function angleFromDirection(dir) {
1586
+ return dir === "horizontal" ? HORIZONTAL_ANGLE : VERTICAL_ANGLE;
1587
+ }
1588
+ function directionFromAngle(angle) {
1589
+ const t = (angle % 180 + 180) % 180;
1590
+ return t >= 45 && t < 135 ? "horizontal" : "vertical";
1591
+ }
1592
+ function gradientFromLegacy(from, to, dir) {
1593
+ return {
1594
+ stops: [
1595
+ { color: from, at: 0 },
1596
+ { color: to, at: 1 }
1597
+ ],
1598
+ angle: angleFromDirection(dir)
1599
+ };
1600
+ }
1601
+ function mirrorsOf(gradient) {
1602
+ const first = gradient.stops[0];
1603
+ const last = gradient.stops[gradient.stops.length - 1];
1604
+ return {
1605
+ gradientFrom: first?.color ?? "#000000",
1606
+ gradientTo: last?.color ?? first?.color ?? "#000000",
1607
+ gradientDir: directionFromAngle(gradient.angle)
1608
+ };
1609
+ }
1610
+ function normalizeGradient(rawStops, rawAngle) {
1611
+ if (!Array.isArray(rawStops) || rawStops.length === 0) return null;
1612
+ const stops = [];
1613
+ for (const entry of rawStops) {
1614
+ if (!entry || typeof entry !== "object") return null;
1615
+ const e = entry;
1616
+ if (typeof e.color !== "string" || typeof e.at !== "number" || !Number.isFinite(e.at)) return null;
1617
+ stops.push({ color: e.color, at: Math.min(1, Math.max(0, e.at)) });
1618
+ }
1619
+ const angle = typeof rawAngle === "number" && Number.isFinite(rawAngle) ? normalizeAngle(rawAngle) : VERTICAL_ANGLE;
1620
+ return { stops, angle };
1621
+ }
1622
+ function stripGradientOf(fields) {
1623
+ return normalizeGradient(fields.gradientStops, fields.gradientAngle) ?? gradientFromLegacy(fields.gradientFrom, fields.gradientTo, fields.gradientDir);
1624
+ }
1625
+ function normalizeAngle(angle) {
1626
+ return (angle % 360 + 360) % 360;
1627
+ }
1628
+ function gradientsMatch(a, b) {
1629
+ if (normalizeAngle(a.angle) !== normalizeAngle(b.angle)) return false;
1630
+ if (a.stops.length !== b.stops.length) return false;
1631
+ return a.stops.every((s, i) => s.color === b.stops[i].color && s.at === b.stops[i].at);
1632
+ }
1633
+
1554
1634
  // ../mockup-engine/appstore/designPresets.ts
1555
1635
  var DESIGN_PRESETS = [
1556
1636
  {
@@ -1604,7 +1684,16 @@ var DESIGN_PRESET_IDS = DESIGN_PRESETS.map((p) => p.id);
1604
1684
  function designPresetToStyle(preset, captionCount = 0) {
1605
1685
  return {
1606
1686
  shotLook: { ...preset.shotLook },
1607
- background: { mode: "gradient", ...preset.background },
1687
+ background: {
1688
+ mode: "gradient",
1689
+ stops: [
1690
+ { color: preset.background.gradientFrom, at: 0 },
1691
+ { color: preset.background.gradientTo, at: 1 }
1692
+ ],
1693
+ angle: angleFromDirection(preset.background.gradientDir),
1694
+ shadow: preset.background.shadow,
1695
+ floorReflection: preset.background.floorReflection
1696
+ },
1608
1697
  ...captionCount > 0 ? { captions: Array.from({ length: captionCount }, () => ({ ...preset.caption })) } : {}
1609
1698
  };
1610
1699
  }
@@ -1772,9 +1861,20 @@ var DESCRIBE_LOOK_CATALOG = {
1772
1861
  },
1773
1862
  background: {
1774
1863
  description: "The strip background.",
1775
- defaults: { mode: "gradient", gradientFrom: "#1b1b2e", gradientTo: "#0a0a14", gradientDir: "vertical", shadow: true, floorReflection: false },
1864
+ defaults: {
1865
+ mode: "gradient",
1866
+ stops: [
1867
+ { color: "#1b1b2e", at: 0 },
1868
+ { color: "#0a0a14", at: 1 }
1869
+ ],
1870
+ angle: 180,
1871
+ shadow: true,
1872
+ floorReflection: false
1873
+ },
1776
1874
  fields: {
1777
1875
  mode: "gradient | perPanel",
1876
+ stops: "gradient colour stops, in order along the line \u2014 { color (hex), at (0\u20131 position) }. Two stops is the classic top/bottom gradient, more make a multi-colour sweep, one stop is a solid fill.",
1877
+ angle: 'gradient angle in degrees, clockwise from "to top" (CSS linear-gradient convention): 90 = to right, 180 = to bottom (default), 270 = to left. Any value folds into 0\u2013360.',
1778
1878
  panelColors: 'per-panel hex colours (mode "perPanel"), one entry per slot in order',
1779
1879
  floorReflection: "boolean \u2014 a flipped, faded floor reflection under each phone"
1780
1880
  }
@@ -1801,9 +1901,11 @@ var DESCRIBE_LOOK_CATALOG = {
1801
1901
  shots: "The devices: one { panelId, look } per SCREENSHOT, flattened in panel order. Each `look` takes the SAME fields as style.shotLook. Repeat a panelId when several screenshots share one panel; each entry still has independent colour/material/angle.",
1802
1902
  panelPresetId: "canvas size preset id (see panelPresets); the top-level panelPresetId param overrides it",
1803
1903
  bgMode: "gradient | perPanel",
1804
- gradientFrom: "gradient start (hex). Default #1b1b2e",
1805
- gradientTo: "gradient end (hex). Default #0a0a14",
1806
- gradientDir: "vertical | horizontal. Default vertical",
1904
+ gradientStops: "canonical gradient stops \u2014 [{ color (hex), at (0\u20131 position) }, \u2026] \u2014 wins over gradientFrom/gradientTo/gradientDir when present. This is where a Studio-authored multi-stop backdrop comes back; the trio below is only ever a two-stop summary of it.",
1905
+ gradientAngle: 'canonical gradient angle in degrees, clockwise from "to top" (CSS convention: 90 = right, 180 = down, the gradientDir:"vertical" equivalent). Paired with gradientStops.',
1906
+ gradientFrom: "gradient start (hex) \u2014 a derived mirror of gradientStops[0]. Default #1b1b2e",
1907
+ gradientTo: "gradient end (hex) \u2014 a derived mirror of the last gradientStops entry. Default #0a0a14",
1908
+ gradientDir: "vertical | horizontal \u2014 a derived mirror of gradientAngle. Default vertical",
1807
1909
  panelColors: 'per-panel flat colours keyed by panel id (mode perPanel), e.g. { "panel-1": "#123456" }',
1808
1910
  shadow: "boolean \u2014 drop shadow under the phones. Default true",
1809
1911
  floorReflection: "boolean \u2014 flipped, faded floor reflection. Default false",
@@ -1902,11 +2004,19 @@ var makeShotLook = (num) => z2.object({
1902
2004
  }).strict();
1903
2005
  var shotLook = makeShotLook(boundedNumeric);
1904
2006
  var advertisedShotLook = makeShotLook(() => numeric);
2007
+ var gradientStop = z2.object({ color: z2.string(), at: z2.number().min(0).max(1) }).strict();
1905
2008
  var background = z2.object({
1906
2009
  mode: z2.enum(["gradient", "perPanel"]).optional().describe("One gradient across the strip, or a flat colour per panel."),
1907
- gradientFrom: z2.string().optional().describe("Gradient start (hex). Default #1b1b2e."),
1908
- gradientTo: z2.string().optional().describe("Gradient end (hex). Default #0a0a14."),
1909
- gradientDir: z2.enum(["vertical", "horizontal"]).optional().describe("Default vertical."),
2010
+ // #463/#460 decision 7 — the render_strip INPUT drops the old two-stop `gradientFrom`/
2011
+ // `gradientTo`/`gradientDir` shorthand entirely (removed, not deprecated): `stops` + `angle`
2012
+ // are the only accepted form here. The persisted-look shape below (makeLookObject) is
2013
+ // DIFFERENT — it keeps the trio as a derived mirror, per decision 6.
2014
+ stops: z2.array(gradientStop).min(1).optional().describe(
2015
+ 'Gradient colour stops, in order along the gradient line \u2014 { color (hex), at (0\u20131 position; 0 = line start, 1 = line end) }. Two stops is the classic top/bottom gradient, more make a multi-colour sweep, one stop is a solid fill. Default a two-stop navy gradient: [{color:"#1b1b2e",at:0},{color:"#0a0a14",at:1}].'
2016
+ ),
2017
+ angle: z2.number().optional().describe(
2018
+ 'Gradient angle in degrees, clockwise from "to top" \u2014 CSS `linear-gradient()` convention: 90 = to right, 180 = to bottom (the old "vertical" default, and this field\u2019s own default), 270 = to left. Any value folds into 0\u2013360.'
2019
+ ),
1910
2020
  panelColors: z2.array(z2.string().nullable()).max(10).optional().describe('Per-panel flat colours (mode "perPanel"), one entry per slot in order; null = default.'),
1911
2021
  shadow: z2.boolean().optional().describe("Drop shadow under the phones. Default true."),
1912
2022
  floorReflection: z2.boolean().optional().describe("Flipped, faded floor reflection under each phone. Default false.")
@@ -1995,9 +2105,21 @@ var makeLookObject = (lookShot) => z2.object({
1995
2105
  schema: z2.number().optional().describe("Look schema version. The server stamps it on every save (save_look); read_look returns it. Any input value is ignored \u2014 never set it yourself."),
1996
2106
  panelPresetId: z2.string().optional().describe("Canvas size preset, e.g. r69 = 1290\xD72796. Default r69."),
1997
2107
  bgMode: z2.enum(["gradient", "perPanel"]).optional().describe("One gradient across the strip, or a flat colour per panel. Default gradient."),
1998
- gradientFrom: z2.string().optional().describe("Gradient start (hex). Default #1b1b2e."),
1999
- gradientTo: z2.string().optional().describe("Gradient end (hex). Default #0a0a14."),
2000
- gradientDir: z2.enum(["horizontal", "vertical"]).optional().describe("Default vertical."),
2108
+ // #460 decision 6 — UNLIKE render_strip's `style.background` above, the PERSISTED look keeps
2109
+ // both forms: `gradientStops`/`gradientAngle` are canonical (win when present), and
2110
+ // gradientFrom/gradientTo/gradientDir stay as a derived MIRROR the server writes alongside
2111
+ // them, so a reader that predates multi-stop gradients still gets a plausible two-stop
2112
+ // backdrop. This is why LOOK_SCHEMA_VERSION does not have to move — see docs/reference/
2113
+ // studio-look-system.md on what bumping it would discard.
2114
+ gradientStops: z2.array(gradientStop).min(1).optional().describe(
2115
+ "Canonical gradient stops \u2014 { color (hex), at (0\u20131 position) } \u2014 wins over gradientFrom/gradientTo/gradientDir when present. Read this back for the full stop list a Studio-authored look may carry; the trio below is only ever a two-stop summary."
2116
+ ),
2117
+ gradientAngle: z2.number().optional().describe(
2118
+ 'Canonical gradient angle in degrees, clockwise from "to top" (CSS convention: 90 = right, 180 = down, the gradientDir:"vertical" equivalent). Paired with gradientStops.'
2119
+ ),
2120
+ gradientFrom: z2.string().optional().describe("Gradient start (hex) \u2014 a derived mirror of gradientStops[0]. Default #1b1b2e."),
2121
+ gradientTo: z2.string().optional().describe("Gradient end (hex) \u2014 a derived mirror of the last gradientStops entry. Default #0a0a14."),
2122
+ gradientDir: z2.enum(["horizontal", "vertical"]).optional().describe("A derived mirror of gradientAngle (nearest axis). Default vertical."),
2001
2123
  panelColors: z2.record(z2.string(), z2.string()).optional().describe("Per-panel flat colours (mode perPanel), keyed by panel id."),
2002
2124
  shadow: z2.boolean().optional().describe("Drop shadow under the phones. Default true."),
2003
2125
  floorReflection: z2.boolean().optional().describe("Flipped, faded floor reflection under each phone. Default false."),
@@ -2029,6 +2151,13 @@ var lookHistoryAccess = z3.object({
2029
2151
  var noteField = z3.string().optional().describe(
2030
2152
  "Free-text advisories about THIS call, joined into one string. Written when the server did something you did not literally ask for or could not have known: a look/version fallback, an output-mode downgrade, a device this server cannot render, a `style.shotLook` a `look` overrode, or placement set by hand where a `style.layout` template would have produced the same composition. Never an error \u2014 the call succeeded. Read it, and act on it next call."
2031
2153
  );
2154
+ var creditsField = z3.object({
2155
+ debited: z3.number(),
2156
+ remaining: z3.number(),
2157
+ refillAt: z3.string().optional()
2158
+ }).passthrough().optional().describe(
2159
+ "Credits this call spent from the account wallet, the balance left afterwards, and when that wallet is next topped up (ISO 8601; absent when no further refill is scheduled). Renders cost per panel; reads, saves, uploads and deletes are free, as is bundling panels that were already rendered."
2160
+ );
2032
2161
  var renderStripOutputSchema = z3.object({
2033
2162
  ok: z3.boolean(),
2034
2163
  count: z3.number(),
@@ -2049,6 +2178,7 @@ var renderStripOutputSchema = z3.object({
2049
2178
  tool: z3.literal("save_project"),
2050
2179
  reason: z3.string()
2051
2180
  }).optional(),
2181
+ credits: creditsField,
2052
2182
  note: noteField,
2053
2183
  panels: z3.array(panelOutput)
2054
2184
  }).passthrough();
@@ -2058,6 +2188,7 @@ var emitBundleOutputSchema = z3.object({
2058
2188
  bundleId: z3.string(),
2059
2189
  panelCount: z3.number(),
2060
2190
  output: z3.enum(["inline", "urls"]),
2191
+ credits: creditsField,
2061
2192
  note: noteField
2062
2193
  }).passthrough();
2063
2194
  var saveProjectOutputSchema = z3.object({
@@ -2290,7 +2421,7 @@ var clip = z4.enum(["strip", "panel"]).optional().describe(
2290
2421
  "How the strip handles a device that overflows its panel. 'strip' (default) = one continuous canvas \u2014 a phone can straddle the seam into the next panel (swipe-through scenes; also what hOffset \xB11 opts into). 'panel' = clip each phone to its OWN panel so it can't bleed a foreign edge into the neighbouring App Store screenshot (independent images). A render option, not part of the saved look."
2291
2422
  );
2292
2423
  var preview = z4.boolean().optional().describe(
2293
- "Render at ~25% resolution for a fast, cheap styling preview \u2014 small enough to always come back inline. Not for final delivery: re-render without `preview` (or use emit_bundle directly) once the look is right."
2424
+ "Render at ~25% resolution for a fast styling preview at a quarter the render cost \u2014 small enough to always come back inline. Not for final delivery: re-render without `preview` (or use emit_bundle directly) once the look is right."
2294
2425
  );
2295
2426
  var panelPresetId = z4.enum(RENDERABLE_PANEL_PRESET_IDS).optional().describe("App Store screenshot size. Default r69 (6.9\u2033 iPhone, 1290\xD72796). iPhone sizes only \u2014 iPad is not rendered yet.");
2296
2427
  var useSavedLook = z4.boolean().optional().describe("If true, style the strip with the project's saved look (ignored when `look`/`style` is given).");
@@ -2987,6 +3118,9 @@ function buildMultiOutputFastlaneBundleZip(targets, opts) {
2987
3118
  }
2988
3119
 
2989
3120
  // ../mockup-engine/appstore/panel.ts
3121
+ function panelGradientOf(bg) {
3122
+ return normalizeGradient(bg.stops, bg.angle) ?? gradientFromLegacy(bg.from, bg.to, bg.dir);
3123
+ }
2990
3124
  function normalizePanelBackgrounds(raw) {
2991
3125
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
2992
3126
  const out = {};
@@ -2996,7 +3130,8 @@ function normalizePanelBackgrounds(raw) {
2996
3130
  if (e.kind === "solid" && typeof e.color === "string") {
2997
3131
  out[panelId] = { kind: "solid", color: e.color };
2998
3132
  } else if (e.kind === "gradient" && typeof e.from === "string" && typeof e.to === "string" && (e.dir === "horizontal" || e.dir === "vertical")) {
2999
- out[panelId] = { kind: "gradient", from: e.from, to: e.to, dir: e.dir };
3133
+ const canonical = normalizeGradient(e.stops, e.angle);
3134
+ out[panelId] = canonical ? { kind: "gradient", stops: canonical.stops, angle: canonical.angle, from: e.from, to: e.to, dir: e.dir } : { kind: "gradient", from: e.from, to: e.to, dir: e.dir };
3000
3135
  }
3001
3136
  }
3002
3137
  return out;
@@ -3087,18 +3222,22 @@ function shotsInPanelOrder(shots, panels) {
3087
3222
  }
3088
3223
 
3089
3224
  // ../mockup-engine/appstore/serialize.ts
3090
- function extractStripStyle(state, captionText) {
3225
+ function extractStripStyle(state, captionText, baseLocale = DEFAULT_LOCALE) {
3226
+ const gradient = stripGradientOf(state);
3091
3227
  return {
3092
3228
  panelPresetId: state.panelPresetId,
3093
3229
  bgMode: state.bgMode,
3094
- gradientFrom: state.gradientFrom,
3095
- gradientTo: state.gradientTo,
3096
- gradientDir: state.gradientDir,
3230
+ gradientStops: gradient.stops,
3231
+ gradientAngle: gradient.angle,
3232
+ ...mirrorsOf(gradient),
3097
3233
  panelColors: state.panelColors,
3098
3234
  shadow: state.shadow,
3099
3235
  floorReflection: state.floorReflection ?? false,
3100
3236
  panelBackgrounds: state.panelBackgrounds ?? {},
3101
- captionStyles: { ...normalizeCaptionLayerStyles(state.captionStyles), ...captionStylesFromLayers(captionText) }
3237
+ captionStyles: {
3238
+ ...normalizeCaptionLayerStyles(state.captionStyles),
3239
+ ...captionStylesFromLayers(captionText, baseLocale)
3240
+ }
3102
3241
  };
3103
3242
  }
3104
3243
 
@@ -3146,7 +3285,10 @@ function serializeProject(state) {
3146
3285
  // extractStripStyle (invariant 2/3). Normalized so a malformed/base-family key can't ride through.
3147
3286
  // {} drops to an empty object (a pre-#88 project stays effectively unchanged).
3148
3287
  outputCaptions: normalizeOutputCaptionText(state.outputCaptions),
3149
- ...extractStripStyle(state, captionText)
3288
+ // #94 follow-through — thread the project's real base locale into the caption-style projection
3289
+ // so `captionStylesFromLayers` picks THIS project's base first (deterministic), not en-US-first
3290
+ // falling through to Object.keys() insertion order for any project not based on en-US.
3291
+ ...extractStripStyle(state, captionText, baseLocale)
3150
3292
  };
3151
3293
  }
3152
3294
 
@@ -3184,15 +3326,21 @@ function panelColorsByOrdinal(look2) {
3184
3326
  function captionStylesByOrdinal(look2) {
3185
3327
  return byPanelOrdinal(look2, look2.captionStyles);
3186
3328
  }
3329
+ function comparablePanelBackground(bg) {
3330
+ return bg.kind === "solid" ? bg : { kind: "gradient", gradient: panelGradientOf(bg) };
3331
+ }
3187
3332
  function panelBackgroundsByOrdinal(look2) {
3188
- return byPanelOrdinal(look2, look2.panelBackgrounds);
3333
+ const byId = byPanelOrdinal(look2, look2.panelBackgrounds);
3334
+ const out = {};
3335
+ for (const [ordinal, bg] of Object.entries(byId)) {
3336
+ out[Number(ordinal)] = comparablePanelBackground(bg);
3337
+ }
3338
+ return out;
3189
3339
  }
3190
3340
  function looksMatch(a, b) {
3191
3341
  if (a.bgMode !== b.bgMode) return false;
3192
3342
  if (a.panelPresetId !== b.panelPresetId) return false;
3193
- if (a.gradientFrom !== b.gradientFrom) return false;
3194
- if (a.gradientTo !== b.gradientTo) return false;
3195
- if (a.gradientDir !== b.gradientDir) return false;
3343
+ if (!gradientsMatch(stripGradientOf(a), stripGradientOf(b))) return false;
3196
3344
  if (a.shadow !== b.shadow) return false;
3197
3345
  if (!!a.floorReflection !== !!b.floorReflection) return false;
3198
3346
  if (a.shots.length !== b.shots.length) return false;
@@ -3203,9 +3351,67 @@ function looksMatch(a, b) {
3203
3351
  return deepEqual(panelColorsByOrdinal(a), panelColorsByOrdinal(b));
3204
3352
  }
3205
3353
 
3354
+ // src/renderCredits.ts
3355
+ import { randomUUID } from "node:crypto";
3356
+ function openChargeScope(deps) {
3357
+ const charged = [];
3358
+ let block;
3359
+ let refunded = false;
3360
+ return {
3361
+ charge: async (plan) => {
3362
+ if (deps.anonymous) {
3363
+ if (plan.panels <= 0) return null;
3364
+ return deps.anonymous.admit(plan);
3365
+ }
3366
+ if (!deps.chargeRender || plan.panels <= 0) return null;
3367
+ const renderId = randomUUID();
3368
+ const outcome = await deps.chargeRender(deps.userId, { renderId, ...plan });
3369
+ if (!outcome.ok) return { error: outcome.message };
3370
+ const { debited, remaining, refillAt } = outcome.receipt;
3371
+ if (debited > 0) charged.push({ renderId, debited });
3372
+ block = {
3373
+ debited: (block?.debited ?? 0) + debited,
3374
+ remaining,
3375
+ ...refillAt !== null ? { refillAt: new Date(refillAt).toISOString() } : {}
3376
+ };
3377
+ return null;
3378
+ },
3379
+ credits: () => block,
3380
+ refund: async (reason) => {
3381
+ if (deps.anonymous) return;
3382
+ if (refunded || !deps.refundRender) return;
3383
+ refunded = true;
3384
+ for (const entry of charged) {
3385
+ await deps.refundRender(deps.userId, entry.renderId, entry.debited, reason).catch(() => {
3386
+ });
3387
+ }
3388
+ }
3389
+ };
3390
+ }
3391
+ async function runCharged(scope, run) {
3392
+ let result;
3393
+ try {
3394
+ result = await run();
3395
+ } catch (err) {
3396
+ await scope.refund(`render failed: ${err instanceof Error ? err.message : String(err)}`);
3397
+ throw err;
3398
+ }
3399
+ if (result.isError === true) await scope.refund(errorReason(result));
3400
+ return result;
3401
+ }
3402
+ function errorReason(result) {
3403
+ const first = result.content?.[0];
3404
+ const text = first && first.type === "text" ? first.text : "";
3405
+ return `render did not complete: ${(text || "unknown error").slice(0, 160)}`;
3406
+ }
3407
+
3206
3408
  // src/toolsRender.ts
3207
- async function handleRenderStrip(deps2, args) {
3208
- const { look: look2, style: style2, note: resolveNote } = await resolveLook(deps2, args);
3409
+ function handleRenderStrip(deps, args) {
3410
+ const scope = openChargeScope(deps);
3411
+ return runCharged(scope, () => renderStrip(deps, args, scope));
3412
+ }
3413
+ async function renderStrip(deps, args, scope) {
3414
+ const { look: look2, style: style2, note: resolveNote } = await resolveLook(deps, args);
3209
3415
  const note = [resolveNote, composeConflictNote(look2, style2)].filter(Boolean).join(" ") || void 0;
3210
3416
  const locale2 = args.locale?.trim() || "en-US";
3211
3417
  const screenshotSlots = normalizeScreenshotSlots(args.screenshots);
@@ -3216,13 +3422,18 @@ async function handleRenderStrip(deps2, args) {
3216
3422
  } catch (err) {
3217
3423
  return errorResult(err.message);
3218
3424
  }
3425
+ const refused = await scope.charge({
3426
+ panels: entries.length,
3427
+ resolution: args.preview ? "preview" : "full"
3428
+ });
3429
+ if (refused) return errorResult(refused.error);
3219
3430
  let screenshots2;
3220
3431
  try {
3221
- screenshots2 = await resolveScreenshots(entries, deps2.userId, deps2.allowLocalScreenshots ?? false);
3432
+ screenshots2 = await resolveScreenshots(entries, deps.userId, deps.allowLocalScreenshots ?? false);
3222
3433
  } catch (err) {
3223
3434
  return errorResult(err.message);
3224
3435
  }
3225
- const result = await deps2.renderer.render({
3436
+ const result = await deps.renderer.render({
3226
3437
  screenshots: screenshots2,
3227
3438
  panelPresetId: args.panelPresetId,
3228
3439
  look: look2,
@@ -3238,11 +3449,11 @@ async function handleRenderStrip(deps2, args) {
3238
3449
  return errorResult(`render failed: ${result.error ?? "unknown error"}`);
3239
3450
  }
3240
3451
  const totalBytes = result.panels.reduce((sum, p) => sum + estimateDecodedBytes(p), 0);
3241
- const { mode: outputMode, note: outputNote } = resolveOutputMode(args.output, totalBytes, deps2.forceOutput);
3452
+ const { mode: outputMode, note: outputNote } = resolveOutputMode(args.output, totalBytes, deps.forceOutput);
3242
3453
  const noteWithOutput = [note, outputNote].filter(Boolean).join(" ") || void 0;
3243
3454
  let panels;
3244
3455
  try {
3245
- panels = outputMode === "urls" ? await uploadPanels(deps2.userId, result.panels) : result.panels;
3456
+ panels = outputMode === "urls" ? await uploadPanels(deps.userId, result.panels) : result.panels;
3246
3457
  } catch (err) {
3247
3458
  return errorResult(panelUploadFailure(err, []));
3248
3459
  }
@@ -3258,6 +3469,9 @@ async function handleRenderStrip(deps2, args) {
3258
3469
  // Only when the payload actually carried per-locale entries — a legacy payload's
3259
3470
  // response stays byte-identical (D6).
3260
3471
  ...screenshotLocales.length > 0 ? { screenshotLocales } : {},
3472
+ // #437 — what this render actually cost, what is left, and when the wallet refills. Absent on
3473
+ // the local tier, which charges nothing.
3474
+ ...scope.credits() ? { credits: scope.credits() } : {},
3261
3475
  ...noteWithOutput ? { note: noteWithOutput } : {},
3262
3476
  // #213 — tell the model, in the output, to turn this render into a real project. The prose
3263
3477
  // contract already requires it; this is the copy a client cannot drop (toolContract.ts).
@@ -3317,11 +3531,11 @@ function outputDevicesFromProjectFile(projectFile) {
3317
3531
  if (!Array.isArray(outputs)) return { devices: [], dropped: [] };
3318
3532
  return resolveOutputDevices(outputs.filter((id) => typeof id === "string"));
3319
3533
  }
3320
- async function loadProjectRecord(deps2, projectId) {
3321
- const resolution = await deps2.resolveProject(deps2.userId, projectId);
3534
+ async function loadProjectRecord(deps, projectId) {
3535
+ const resolution = await deps.resolveProject(deps.userId, projectId);
3322
3536
  const project2 = resolvedProject(resolution);
3323
3537
  if (!project2) return { error: describeMiss(resolution, projectId) };
3324
- const rec = await deps2.readProjectRecord(project2.id);
3538
+ const rec = await deps.readProjectRecord(project2.id);
3325
3539
  if (!rec) {
3326
3540
  return {
3327
3541
  error: `Project "${project2.name}" has no saved record yet \u2014 open it in ShotOps and save a strip first.`
@@ -3342,12 +3556,12 @@ function projectRenderVariant(projectFile, locale2, panelPresetId2) {
3342
3556
  const baseLocale = projectBaseLocale(projectFile);
3343
3557
  return canonicalVariant({ family, locale: locale2?.trim() || baseLocale }, baseLocale);
3344
3558
  }
3345
- async function resolveProjectScreens(deps2, loaded, supplied) {
3559
+ async function resolveProjectScreens(deps, loaded, supplied) {
3346
3560
  const baseLocale = projectBaseLocale(loaded.projectFile);
3347
3561
  const nested = supplied.map((e) => [e]);
3348
3562
  let resolvedBytes;
3349
3563
  try {
3350
- resolvedBytes = await resolveScreenshots(nested, deps2.userId, deps2.allowLocalScreenshots ?? false);
3564
+ resolvedBytes = await resolveScreenshots(nested, deps.userId, deps.allowLocalScreenshots ?? false);
3351
3565
  } catch (err) {
3352
3566
  return { error: err.message };
3353
3567
  }
@@ -3377,11 +3591,11 @@ async function resolveProjectScreens(deps2, loaded, supplied) {
3377
3591
  baseLocale
3378
3592
  );
3379
3593
  const storedManifest = parsed ? allManifestEntries(parsed) : [];
3380
- let canReadStored = Boolean(deps2.fetchHandoffBytes || deps2.readProjectScreens);
3594
+ let canReadStored = Boolean(deps.fetchHandoffBytes || deps.readProjectScreens);
3381
3595
  const storedSources = [];
3382
- if (deps2.readProjectScreens) {
3596
+ if (deps.readProjectScreens) {
3383
3597
  try {
3384
- const listed = await deps2.readProjectScreens(loaded.project.id);
3598
+ const listed = await deps.readProjectScreens(loaded.project.id);
3385
3599
  for (const screen of listed.screens) {
3386
3600
  const variant = canonicalVariant(screen.variant, baseLocale);
3387
3601
  let pending;
@@ -3396,9 +3610,9 @@ async function resolveProjectScreens(deps2, loaded, supplied) {
3396
3610
  });
3397
3611
  }
3398
3612
  } catch {
3399
- canReadStored = Boolean(deps2.fetchHandoffBytes);
3613
+ canReadStored = Boolean(deps.fetchHandoffBytes);
3400
3614
  }
3401
- } else if (deps2.fetchHandoffBytes) {
3615
+ } else if (deps.fetchHandoffBytes) {
3402
3616
  for (const entry of storedManifest) {
3403
3617
  let pending;
3404
3618
  storedSources.push({
@@ -3406,7 +3620,7 @@ async function resolveProjectScreens(deps2, loaded, supplied) {
3406
3620
  variant: entry.variant,
3407
3621
  source: "project",
3408
3622
  read: () => {
3409
- pending ??= deps2.fetchHandoffBytes(entry.ref).then((bytes) => Buffer.from(bytes).toString("base64"));
3623
+ pending ??= deps.fetchHandoffBytes(entry.ref).then((bytes) => Buffer.from(bytes).toString("base64"));
3410
3624
  return pending;
3411
3625
  }
3412
3626
  });
@@ -3499,7 +3713,7 @@ function projectScreenUsageNote(shots) {
3499
3713
  projectScreenResolutionNote(shots)
3500
3714
  ].filter(Boolean).join(" ") || void 0;
3501
3715
  }
3502
- async function renderProjectWithScreens(deps2, projectFile, screens, options = {}) {
3716
+ async function renderProjectWithScreens(deps, projectFile, screens, options = {}) {
3503
3717
  const variant = projectRenderVariant(projectFile, options.locale, options.panelPresetId);
3504
3718
  const resolved = await screens.resolve(variant);
3505
3719
  if (screens.expected.length > 0 && resolved.screenManifest.length === 0) {
@@ -3508,7 +3722,7 @@ async function renderProjectWithScreens(deps2, projectFile, screens, options = {
3508
3722
  error: noProjectScreensMessage(screens, options.suppliedCount ?? 0, options.toolName ?? "render_project")
3509
3723
  };
3510
3724
  }
3511
- return deps2.renderer.renderProject({
3725
+ return deps.renderer.renderProject({
3512
3726
  projectFile,
3513
3727
  screenManifest: resolved.screenManifest,
3514
3728
  variant,
@@ -3517,12 +3731,16 @@ async function renderProjectWithScreens(deps2, projectFile, screens, options = {
3517
3731
  ...options.panelPresetId ? { panelPresetId: options.panelPresetId } : {}
3518
3732
  });
3519
3733
  }
3520
- async function handleRenderProject(deps2, args) {
3521
- const loaded = await loadProjectRecord(deps2, args.project);
3734
+ function handleRenderProject(deps, args) {
3735
+ const scope = openChargeScope(deps);
3736
+ return runCharged(scope, () => renderProject(deps, args, scope));
3737
+ }
3738
+ async function renderProject(deps, args, scope) {
3739
+ const loaded = await loadProjectRecord(deps, args.project);
3522
3740
  if ("error" in loaded) return errorResult(loaded.error);
3523
3741
  const { project: project2, projectFile } = loaded;
3524
3742
  const supplied = args.screenshots ?? [];
3525
- const screens = await resolveProjectScreens(deps2, loaded, supplied);
3743
+ const screens = await resolveProjectScreens(deps, loaded, supplied);
3526
3744
  if ("error" in screens) return errorResult(screens.error);
3527
3745
  const { unnamed } = screens;
3528
3746
  const requested = resolveOutputDevices(args.outputs);
@@ -3535,7 +3753,7 @@ async function handleRenderProject(deps2, args) {
3535
3753
  const locale2 = args.locale?.trim() || void 0;
3536
3754
  const renderOne = async (device) => {
3537
3755
  const panelPresetId2 = device && mayOverridePreset ? device.panelPresetId : void 0;
3538
- return renderProjectWithScreens(deps2, projectFile, screens, {
3756
+ return renderProjectWithScreens(deps, projectFile, screens, {
3539
3757
  locale: locale2,
3540
3758
  preview: args.preview,
3541
3759
  panelPresetId: panelPresetId2,
@@ -3543,6 +3761,11 @@ async function handleRenderProject(deps2, args) {
3543
3761
  toolName: "render_project"
3544
3762
  });
3545
3763
  };
3764
+ const refused = await scope.charge({
3765
+ panels: screens.expected.length * Math.max(1, ordered.length),
3766
+ resolution: args.preview ? "preview" : "full"
3767
+ });
3768
+ if (refused) return errorResult(refused.error);
3546
3769
  const result = await renderOne(active);
3547
3770
  if (!result.ok || !result.panels) {
3548
3771
  return errorResult(`render failed: ${result.error ?? "unknown error"}`);
@@ -3572,12 +3795,12 @@ async function handleRenderProject(deps2, args) {
3572
3795
  new Set(allShots.filter((shot) => shot.matched && shot.source === "project").map((shot) => shot.frameName))
3573
3796
  );
3574
3797
  const totalBytes = [result.panels, ...extraResults.map((e) => e.panels)].flat().reduce((sum, p) => sum + estimateDecodedBytes(p), 0);
3575
- const { mode: outputMode, note: outputNote } = resolveOutputMode(args.output, totalBytes, deps2.forceOutput);
3798
+ const { mode: outputMode, note: outputNote } = resolveOutputMode(args.output, totalBytes, deps.forceOutput);
3576
3799
  const multi = extraResults.length > 0;
3577
3800
  const storedPanelRefs = [];
3578
3801
  let panels;
3579
3802
  try {
3580
- panels = outputMode === "urls" ? await uploadPanels(deps2.userId, result.panels, multi && active ? `${active.id}-` : "") : result.panels;
3803
+ panels = outputMode === "urls" ? await uploadPanels(deps.userId, result.panels, multi && active ? `${active.id}-` : "") : result.panels;
3581
3804
  } catch (err) {
3582
3805
  return errorResult(panelUploadFailure(err, storedPanelRefs));
3583
3806
  }
@@ -3598,7 +3821,7 @@ async function handleRenderProject(deps2, args) {
3598
3821
  for (const extra of extraResults) {
3599
3822
  let extraPanels;
3600
3823
  try {
3601
- extraPanels = outputMode === "urls" ? await uploadPanels(deps2.userId, extra.panels, `${extra.device.id}-`) : extra.panels;
3824
+ extraPanels = outputMode === "urls" ? await uploadPanels(deps.userId, extra.panels, `${extra.device.id}-`) : extra.panels;
3602
3825
  } catch (err) {
3603
3826
  return errorResult(panelUploadFailure(err, storedPanelRefs));
3604
3827
  }
@@ -3642,7 +3865,7 @@ async function handleRenderProject(deps2, args) {
3642
3865
  `${unnamed} supplied screenshot(s) had no filename (name) and could not be matched \u2014 pass each screenshot's original filename via \`name\`.`
3643
3866
  );
3644
3867
  }
3645
- const openUrl = `${deps2.studioOrigin}/?project=${project2.id}`;
3868
+ const openUrl = `${deps.studioOrigin}/?project=${project2.id}`;
3646
3869
  const deepLink = URL.canParse(openUrl) ? { openUrl } : {};
3647
3870
  return textResult({
3648
3871
  ok: true,
@@ -3657,6 +3880,8 @@ async function handleRenderProject(deps2, args) {
3657
3880
  ...deepLink,
3658
3881
  heldVersion: project2.heldVersion,
3659
3882
  // echoed for transparency; the render uses the record's saved look
3883
+ // #437 — the exact debit for this render, the balance after it, and the next refill.
3884
+ ...scope.credits() ? { credits: scope.credits() } : {},
3660
3885
  shots,
3661
3886
  // [{ frameName, matched }] — which of the project's shots got developer bytes
3662
3887
  ...handoffUsed.length > 0 ? { handoffUsed } : {},
@@ -3705,7 +3930,11 @@ function projectStylingIgnoredNote(args) {
3705
3930
  if (ignored.length === 0) return void 0;
3706
3931
  return `${ignored.join(" + ")} ${ignored.length > 1 ? "were" : "was"} ignored \u2014 this bundle rendered a saved PROJECT, which carries its own styling and caption words. To change how it looks, restyle it with save_project (or in ShotOps) and bundle again; to style a one-off strip instead, pass \`screenshots\` rather than relying on the project.`;
3707
3932
  }
3708
- async function handleEmitBundle(deps2, args) {
3933
+ function handleEmitBundle(deps, args) {
3934
+ const scope = openChargeScope(deps);
3935
+ return runCharged(scope, () => emitBundle(deps, args, scope));
3936
+ }
3937
+ async function emitBundle(deps, args, scope) {
3709
3938
  if (!isValidBundleId(args.bundleId)) {
3710
3939
  return errorResult(`invalid bundleId "${args.bundleId}" \u2014 expected reverse-DNS, e.g. com.acme.app`);
3711
3940
  }
@@ -3717,7 +3946,7 @@ async function handleEmitBundle(deps2, args) {
3717
3946
  const requestedOutputs = resolveOutputDevices(args.outputs).devices;
3718
3947
  let loaded = null;
3719
3948
  if (args.project && !hasPanels) {
3720
- const found = await loadProjectRecord(deps2, args.project).catch((err) => ({
3949
+ const found = await loadProjectRecord(deps, args.project).catch((err) => ({
3721
3950
  error: `could not read project "${args.project}": ${err.message}`
3722
3951
  }));
3723
3952
  if ("error" in found) {
@@ -3753,22 +3982,32 @@ async function handleEmitBundle(deps2, args) {
3753
3982
  );
3754
3983
  }
3755
3984
  if (args.createProject) return errorResult(PROJECT_SOURCE_CREATE_PROJECT_ERROR);
3756
- const screens = await resolveProjectScreens(deps2, loaded, []);
3985
+ const screens = await resolveProjectScreens(deps, loaded, []);
3757
3986
  if ("error" in screens) return errorResult(screens.error);
3758
3987
  const projectFile = loaded.projectFile;
3759
- return emitMultiOutputBundle(deps2, args, outputDevices, locales, projectName2, {
3988
+ const refused2 = await scope.charge({
3989
+ panels: screens.expected.length * outputDevices.length * locales.length,
3990
+ resolution: "full"
3991
+ });
3992
+ if (refused2) return errorResult(refused2.error);
3993
+ return emitMultiOutputBundle(deps, args, outputDevices, locales, projectName2, scope, {
3760
3994
  // The handoff/missing report is the SAME one the single-output path gives (#40 F7) —
3761
3995
  // a shot with no screenshot is a shot with no screenshot at either device count.
3762
3996
  note: joinNotes(savedNote, projectStylingIgnoredNote(args)),
3763
- renderOne: async (device, locale3) => renderProjectWithScreens(deps2, projectFile, screens, {
3997
+ renderOne: async (device, locale3) => renderProjectWithScreens(deps, projectFile, screens, {
3764
3998
  locale: locale3,
3765
3999
  panelPresetId: device.panelPresetId,
3766
4000
  toolName: "emit_bundle"
3767
4001
  })
3768
4002
  });
3769
4003
  }
3770
- const resolved = await resolveLook(deps2, args);
3771
- return emitMultiOutputBundle(deps2, args, outputDevices, locales, projectName2, {
4004
+ const resolved = await resolveLook(deps, args);
4005
+ const refused = await scope.charge({
4006
+ panels: screenshotSlots.length * outputDevices.length * locales.length,
4007
+ resolution: "full"
4008
+ });
4009
+ if (refused) return errorResult(refused.error);
4010
+ return emitMultiOutputBundle(deps, args, outputDevices, locales, projectName2, scope, {
3772
4011
  // #224 — composeConflictNote had been on the single-output path only, so the SAME payload
3773
4012
  // (a look plus a redundant style.shotLook) was explained at one device count and silent at
3774
4013
  // two. It is the resolution that decides the conflict, not the device loop.
@@ -3782,11 +4021,11 @@ async function handleEmitBundle(deps2, args) {
3782
4021
  }
3783
4022
  let screenshots2;
3784
4023
  try {
3785
- screenshots2 = await resolveScreenshots(entries, deps2.userId, deps2.allowLocalScreenshots ?? false);
4024
+ screenshots2 = await resolveScreenshots(entries, deps.userId, deps.allowLocalScreenshots ?? false);
3786
4025
  } catch (err) {
3787
4026
  return { ok: false, error: err.message };
3788
4027
  }
3789
- return deps2.renderer.render({
4028
+ return deps.renderer.render({
3790
4029
  screenshots: screenshots2,
3791
4030
  panelPresetId: device.panelPresetId,
3792
4031
  look: resolved.look,
@@ -3801,7 +4040,7 @@ async function handleEmitBundle(deps2, args) {
3801
4040
  // buildRecord — the multi-device loop never builds a record per pass) carrying the full
3802
4041
  // device set. The `panels` and multi-LOCALE rejections stand: their reasons still hold.
3803
4042
  persist: args.createProject ? async () => {
3804
- const record = await deps2.renderer.buildRecord({
4043
+ const record = await deps.renderer.buildRecord({
3805
4044
  slotSizes: screenshotSlots.map((slot2) => slot2.length),
3806
4045
  panelPresetId: outputDevices[0].panelPresetId,
3807
4046
  look: resolved.look,
@@ -3813,7 +4052,7 @@ async function handleEmitBundle(deps2, args) {
3813
4052
  outputs: outputDevices.map((d) => d.id)
3814
4053
  });
3815
4054
  if (record == null) return { error: "no project record was produced \u2014 not saved." };
3816
- return persistProject(deps2, args, record);
4055
+ return persistProject(deps, args, record);
3817
4056
  } : void 0
3818
4057
  });
3819
4058
  }
@@ -3824,19 +4063,29 @@ async function handleEmitBundle(deps2, args) {
3824
4063
  );
3825
4064
  }
3826
4065
  if (loaded && !hasScreenshots && !hasPanels) {
3827
- const screens = await resolveProjectScreens(deps2, loaded, []);
4066
+ const screens = await resolveProjectScreens(deps, loaded, []);
3828
4067
  if ("error" in screens) return errorResult(screens.error);
3829
4068
  const projectFile = loaded.projectFile;
3830
- return emitMultiLocaleBundle(deps2, args, requestedLocales, projectName2, {
4069
+ const refused2 = await scope.charge({
4070
+ panels: screens.expected.length * requestedLocales.length,
4071
+ resolution: "full"
4072
+ });
4073
+ if (refused2) return errorResult(refused2.error);
4074
+ return emitMultiLocaleBundle(deps, args, requestedLocales, projectName2, scope, {
3831
4075
  note: joinNotes(unexpandedSavedNote(projectSizeCovered), projectStylingIgnoredNote(args)),
3832
- renderOne: (locale3) => renderProjectWithScreens(deps2, projectFile, screens, {
4076
+ renderOne: (locale3) => renderProjectWithScreens(deps, projectFile, screens, {
3833
4077
  locale: locale3,
3834
4078
  panelPresetId: args.panelPresetId,
3835
4079
  toolName: "emit_bundle"
3836
4080
  })
3837
4081
  });
3838
4082
  }
3839
- return emitMultiLocaleBundle(deps2, args, requestedLocales, projectName2, {
4083
+ const refused = await scope.charge({
4084
+ panels: hasPanels ? 0 : (screenshotSlots?.length ?? 0) * requestedLocales.length,
4085
+ resolution: "full"
4086
+ });
4087
+ if (refused) return errorResult(refused.error);
4088
+ return emitMultiLocaleBundle(deps, args, requestedLocales, projectName2, scope, {
3840
4089
  note: unexpandedSavedNote(NOT_COVERED_BY_THIS_CALL)
3841
4090
  });
3842
4091
  }
@@ -3861,7 +4110,7 @@ async function handleEmitBundle(deps2, args) {
3861
4110
  }
3862
4111
  let bytesList;
3863
4112
  try {
3864
- bytesList = await Promise.all(panelEntries.map((p) => resolvePanelBytes(p, deps2.userId, deps2.allowLocalScreenshots ?? false)));
4113
+ bytesList = await Promise.all(panelEntries.map((p) => resolvePanelBytes(p, deps.userId, deps.allowLocalScreenshots ?? false)));
3865
4114
  } catch (err) {
3866
4115
  return errorResult(`failed to read panel: ${err.message}`);
3867
4116
  }
@@ -3869,9 +4118,11 @@ async function handleEmitBundle(deps2, args) {
3869
4118
  previewPng = bytesList[0] ? Buffer.from(bytesList[0]) : null;
3870
4119
  } else if (!hasScreenshots && loaded) {
3871
4120
  if (args.createProject) return errorResult(PROJECT_SOURCE_CREATE_PROJECT_ERROR);
3872
- const screens = await resolveProjectScreens(deps2, loaded, []);
4121
+ const screens = await resolveProjectScreens(deps, loaded, []);
3873
4122
  if ("error" in screens) return errorResult(screens.error);
3874
- const result = await renderProjectWithScreens(deps2, loaded.projectFile, screens, {
4123
+ const refused = await scope.charge({ panels: screens.expected.length, resolution: "full" });
4124
+ if (refused) return errorResult(refused.error);
4125
+ const result = await renderProjectWithScreens(deps, loaded.projectFile, screens, {
3875
4126
  locale: locale2,
3876
4127
  panelPresetId: args.panelPresetId,
3877
4128
  toolName: "emit_bundle"
@@ -3899,17 +4150,19 @@ async function handleEmitBundle(deps2, args) {
3899
4150
  }
3900
4151
  let screenshots2;
3901
4152
  try {
3902
- screenshots2 = await resolveScreenshots(entries, deps2.userId, deps2.allowLocalScreenshots ?? false);
4153
+ screenshots2 = await resolveScreenshots(entries, deps.userId, deps.allowLocalScreenshots ?? false);
3903
4154
  } catch (err) {
3904
4155
  return errorResult(err.message);
3905
4156
  }
3906
- const resolved = await resolveLook(deps2, args);
4157
+ const resolved = await resolveLook(deps, args);
3907
4158
  note = joinNotes(
3908
4159
  resolved.note,
3909
4160
  composeConflictNote(resolved.look, resolved.style),
3910
4161
  unexpandedSavedNote(NOT_COVERED_BY_THIS_CALL)
3911
4162
  );
3912
- const result = await deps2.renderer.render({
4163
+ const refused = await scope.charge({ panels: screenshotSlots.length, resolution: "full" });
4164
+ if (refused) return errorResult(refused.error);
4165
+ const result = await deps.renderer.render({
3913
4166
  screenshots: screenshots2,
3914
4167
  panelPresetId: args.panelPresetId,
3915
4168
  look: resolved.look,
@@ -3934,8 +4187,8 @@ async function handleEmitBundle(deps2, args) {
3934
4187
  }
3935
4188
  const zip = buildFastlaneBundleZip(slots, { bundleId: args.bundleId, locale: locale2, panelLabel });
3936
4189
  const filename2 = bundleZipFilename(projectName2);
3937
- const { mode: outputMode, note: outputNote } = resolveOutputMode(args.output, zip.byteLength, deps2.forceOutput);
3938
- const zipOut = outputMode === "urls" ? await storeAsset(uploadDirFor(deps2.userId), filename2, zip, "application/zip").then((a) => ({
4190
+ const { mode: outputMode, note: outputNote } = resolveOutputMode(args.output, zip.byteLength, deps.forceOutput);
4191
+ const zipOut = outputMode === "urls" ? await storeAsset(uploadDirFor(deps.userId), filename2, zip, "application/zip").then((a) => ({
3939
4192
  zipUrl: a.url,
3940
4193
  zipRef: a.ref
3941
4194
  })) : { zipBase64: Buffer.from(zip).toString("base64") };
@@ -3943,15 +4196,15 @@ async function handleEmitBundle(deps2, args) {
3943
4196
  if (args.share) {
3944
4197
  if (!previewPng) return errorResult(shareFailure("no preview image available", zipOut));
3945
4198
  try {
3946
- shareLink = await deps2.createShareLink({
3947
- userId: deps2.userId,
4199
+ shareLink = await deps.createShareLink({
4200
+ userId: deps.userId,
3948
4201
  zip,
3949
4202
  previewPng,
3950
4203
  projectName: projectName2,
3951
4204
  panelCount: slots.length,
3952
4205
  bundleId: args.bundleId,
3953
4206
  locale: locale2,
3954
- origin: deps2.studioOrigin
4207
+ origin: deps.studioOrigin
3955
4208
  });
3956
4209
  } catch (err) {
3957
4210
  return errorResult(shareFailure(err.message, zipOut));
@@ -3963,7 +4216,7 @@ async function handleEmitBundle(deps2, args) {
3963
4216
  if (projectRecord == null) {
3964
4217
  persistNote = "bundle succeeded but no project record was produced \u2014 not saved.";
3965
4218
  } else {
3966
- const p = await persistProject(deps2, args, projectRecord);
4219
+ const p = await persistProject(deps, args, projectRecord);
3967
4220
  if ("error" in p) persistNote = `not saved: ${p.error}`;
3968
4221
  else persisted = p;
3969
4222
  }
@@ -3975,6 +4228,8 @@ async function handleEmitBundle(deps2, args) {
3975
4228
  locale: locale2,
3976
4229
  panelCount: slots.length,
3977
4230
  output: outputMode,
4231
+ // #437 — absent on the compose path (nothing was rendered) and on the local tier.
4232
+ ...scope.credits() ? { credits: scope.credits() } : {},
3978
4233
  ...joinNotes(note, outputNote) ? { note: joinNotes(note, outputNote) } : {},
3979
4234
  ...shareLink ? { shareLink } : {},
3980
4235
  ...persisted ? { projectId: persisted.projectId, openUrl: persisted.openUrl } : {},
@@ -3982,7 +4237,7 @@ async function handleEmitBundle(deps2, args) {
3982
4237
  ...zipOut
3983
4238
  });
3984
4239
  }
3985
- async function emitMultiOutputBundle(deps2, args, outputs, locales, projectName2, plan) {
4240
+ async function emitMultiOutputBundle(deps, args, outputs, locales, projectName2, scope, plan) {
3986
4241
  const targets = [];
3987
4242
  let previewPng = null;
3988
4243
  const projectShots = [];
@@ -4015,8 +4270,8 @@ async function emitMultiOutputBundle(deps2, args, outputs, locales, projectName2
4015
4270
  const zip = buildMultiOutputFastlaneBundleZip(targets, { bundleId: args.bundleId });
4016
4271
  const filename2 = bundleZipFilename(projectName2);
4017
4272
  const panelCount = targets[0]?.localeSlots[0]?.slots.length ?? 0;
4018
- const { mode: outputMode, note: outputNote } = resolveOutputMode(args.output, zip.byteLength, deps2.forceOutput);
4019
- const zipOut = outputMode === "urls" ? await storeAsset(uploadDirFor(deps2.userId), filename2, zip, "application/zip").then((asset) => ({
4273
+ const { mode: outputMode, note: outputNote } = resolveOutputMode(args.output, zip.byteLength, deps.forceOutput);
4274
+ const zipOut = outputMode === "urls" ? await storeAsset(uploadDirFor(deps.userId), filename2, zip, "application/zip").then((asset) => ({
4020
4275
  zipUrl: asset.url,
4021
4276
  zipRef: asset.ref
4022
4277
  })) : { zipBase64: Buffer.from(zip).toString("base64") };
@@ -4024,8 +4279,8 @@ async function emitMultiOutputBundle(deps2, args, outputs, locales, projectName2
4024
4279
  if (args.share) {
4025
4280
  if (!previewPng) return errorResult(shareFailure("no preview image available", zipOut));
4026
4281
  try {
4027
- shareLink = await deps2.createShareLink({
4028
- userId: deps2.userId,
4282
+ shareLink = await deps.createShareLink({
4283
+ userId: deps.userId,
4029
4284
  zip,
4030
4285
  previewPng,
4031
4286
  projectName: projectName2,
@@ -4034,7 +4289,7 @@ async function emitMultiOutputBundle(deps2, args, outputs, locales, projectName2
4034
4289
  panelCount: panelCount * outputs.length * locales.length,
4035
4290
  bundleId: args.bundleId,
4036
4291
  locale: locales.join(", "),
4037
- origin: deps2.studioOrigin
4292
+ origin: deps.studioOrigin
4038
4293
  });
4039
4294
  } catch (err) {
4040
4295
  return errorResult(shareFailure(err.message, zipOut));
@@ -4061,6 +4316,7 @@ async function emitMultiOutputBundle(deps2, args, outputs, locales, projectName2
4061
4316
  panelCount,
4062
4317
  totalPanelCount: panelCount * outputs.length * locales.length,
4063
4318
  output: outputMode,
4319
+ ...scope.credits() ? { credits: scope.credits() } : {},
4064
4320
  ...note ? { note } : {},
4065
4321
  ...shareLink ? { shareLink } : {},
4066
4322
  ...persisted ? { projectId: persisted.projectId, openUrl: persisted.openUrl } : {},
@@ -4068,7 +4324,7 @@ async function emitMultiOutputBundle(deps2, args, outputs, locales, projectName2
4068
4324
  ...zipOut
4069
4325
  });
4070
4326
  }
4071
- async function emitMultiLocaleBundle(deps2, args, locales, projectName2, plan = {}) {
4327
+ async function emitMultiLocaleBundle(deps, args, locales, projectName2, scope, plan = {}) {
4072
4328
  const screenshotSlots = args.screenshots ? normalizeScreenshotSlots(args.screenshots) : void 0;
4073
4329
  let localeSlots;
4074
4330
  let panelLabel;
@@ -4099,7 +4355,7 @@ async function emitMultiLocaleBundle(deps2, args, locales, projectName2, plan =
4099
4355
  const key = "path" in p ? `path:${p.path}` : `ref:${p.ref}`;
4100
4356
  let pending = bytesCache.get(key);
4101
4357
  if (!pending) {
4102
- pending = resolvePanelBytes(p, deps2.userId, deps2.allowLocalScreenshots ?? false);
4358
+ pending = resolvePanelBytes(p, deps.userId, deps.allowLocalScreenshots ?? false);
4103
4359
  bytesCache.set(key, pending);
4104
4360
  }
4105
4361
  return pending;
@@ -4123,7 +4379,7 @@ async function emitMultiLocaleBundle(deps2, args, locales, projectName2, plan =
4123
4379
  if (!screenshotSlots || screenshotSlots.length === 0) {
4124
4380
  return errorResult("emit_bundle needs either `screenshots` or pre-rendered `panels` refs.");
4125
4381
  }
4126
- const resolved = await resolveLook(deps2, args);
4382
+ const resolved = await resolveLook(deps, args);
4127
4383
  note = joinNotes(note, resolved.note, composeConflictNote(resolved.look, resolved.style));
4128
4384
  localeSlots = [];
4129
4385
  panelLabel = "";
@@ -4136,11 +4392,11 @@ async function emitMultiLocaleBundle(deps2, args, locales, projectName2, plan =
4136
4392
  }
4137
4393
  let screenshots2;
4138
4394
  try {
4139
- screenshots2 = await resolveScreenshots(entries, deps2.userId, deps2.allowLocalScreenshots ?? false);
4395
+ screenshots2 = await resolveScreenshots(entries, deps.userId, deps.allowLocalScreenshots ?? false);
4140
4396
  } catch (err) {
4141
4397
  return errorResult(err.message);
4142
4398
  }
4143
- const result = await deps2.renderer.render({
4399
+ const result = await deps.renderer.render({
4144
4400
  screenshots: screenshots2,
4145
4401
  panelPresetId: args.panelPresetId,
4146
4402
  look: resolved.look,
@@ -4162,8 +4418,8 @@ async function emitMultiLocaleBundle(deps2, args, locales, projectName2, plan =
4162
4418
  const zip = buildMultiLocaleFastlaneBundleZip(localeSlots, { bundleId: args.bundleId, panelLabel });
4163
4419
  note = joinNotes(note, projectScreenUsageNote(projectShots));
4164
4420
  const filename2 = bundleZipFilename(projectName2);
4165
- const { mode: outputMode, note: outputNote } = resolveOutputMode(args.output, zip.byteLength, deps2.forceOutput);
4166
- const zipOut = outputMode === "urls" ? await storeAsset(uploadDirFor(deps2.userId), filename2, zip, "application/zip").then((a) => ({
4421
+ const { mode: outputMode, note: outputNote } = resolveOutputMode(args.output, zip.byteLength, deps.forceOutput);
4422
+ const zipOut = outputMode === "urls" ? await storeAsset(uploadDirFor(deps.userId), filename2, zip, "application/zip").then((a) => ({
4167
4423
  zipUrl: a.url,
4168
4424
  zipRef: a.ref
4169
4425
  })) : { zipBase64: Buffer.from(zip).toString("base64") };
@@ -4171,8 +4427,8 @@ async function emitMultiLocaleBundle(deps2, args, locales, projectName2, plan =
4171
4427
  if (args.share) {
4172
4428
  if (!previewPng) return errorResult(shareFailure("no preview image available", zipOut));
4173
4429
  try {
4174
- shareLink = await deps2.createShareLink({
4175
- userId: deps2.userId,
4430
+ shareLink = await deps.createShareLink({
4431
+ userId: deps.userId,
4176
4432
  zip,
4177
4433
  previewPng,
4178
4434
  projectName: projectName2,
@@ -4180,7 +4436,7 @@ async function emitMultiLocaleBundle(deps2, args, locales, projectName2, plan =
4180
4436
  bundleId: args.bundleId,
4181
4437
  // The share row's locale is display metadata — name every locale in the bundle.
4182
4438
  locale: locales.join(", "),
4183
- origin: deps2.studioOrigin
4439
+ origin: deps.studioOrigin
4184
4440
  });
4185
4441
  } catch (err) {
4186
4442
  return errorResult(shareFailure(err.message, zipOut));
@@ -4194,6 +4450,7 @@ async function emitMultiLocaleBundle(deps2, args, locales, projectName2, plan =
4194
4450
  panelCount: localeSlots[0]?.slots.length ?? 0,
4195
4451
  // per locale — total files = panelCount × locales.length
4196
4452
  output: outputMode,
4453
+ ...scope.credits() ? { credits: scope.credits() } : {},
4197
4454
  ...joinNotes(note, outputNote) ? { note: joinNotes(note, outputNote) } : {},
4198
4455
  ...shareLink ? { shareLink } : {},
4199
4456
  ...zipOut
@@ -4249,10 +4506,10 @@ function logImportFailure(entry, reason) {
4249
4506
  fileName: "file" in entry && entry.file.file_name || entry.name || "(none)"
4250
4507
  });
4251
4508
  }
4252
- async function importOne(deps2, entry) {
4509
+ async function importOne(deps, entry) {
4253
4510
  const source = sourceKind(entry);
4254
4511
  if ("path" in entry) {
4255
- if (!deps2.allowLocalScreenshots) return { ok: false, source, error: hostedPathRefusal(entry.path) };
4512
+ if (!deps.allowLocalScreenshots) return { ok: false, source, error: hostedPathRefusal(entry.path) };
4256
4513
  try {
4257
4514
  const { bytes, name } = await readLocalScreenshotFile(entry.path);
4258
4515
  return { ok: true, source, path: entry.path, name: entry.name?.trim() || name, size: bytes.byteLength };
@@ -4260,12 +4517,12 @@ async function importOne(deps2, entry) {
4260
4517
  return { ok: false, source, error: err.message };
4261
4518
  }
4262
4519
  }
4263
- if (!deps2.importScreenshot) {
4520
+ if (!deps.importScreenshot) {
4264
4521
  return { ok: false, source, error: localSourceRefusal("url" in entry ? "url" : "file") };
4265
4522
  }
4266
4523
  try {
4267
- const imported = await deps2.importScreenshot(
4268
- deps2.userId,
4524
+ const imported = await deps.importScreenshot(
4525
+ deps.userId,
4269
4526
  "file" in entry ? { file: entry.file } : { url: entry.url },
4270
4527
  entry.name
4271
4528
  );
@@ -4282,7 +4539,7 @@ function instructionsFor(results) {
4282
4539
  const handle = local ? `Pass each result's { "path": "\u2026" } as that screenshot's entry` : `Pass each result's { "ref": "\u2026" } as that screenshot's entry`;
4283
4540
  return `${handle} in render_strip or emit_bundle, in this same order. For render_project, also pass { "name": "THE_FILENAME" } so it matches the saved frame by filename.` + (failed > 0 ? ` ${failed} of ${results.length} entries did NOT import \u2014 read that entry's \`error\` and re-import only those; the successful refs above are already usable.` : "");
4284
4541
  }
4285
- async function handleImportScreenshot(deps2, args) {
4542
+ async function handleImportScreenshot(deps, args) {
4286
4543
  const entries = [
4287
4544
  ...args.file ? [{ file: args.file, ...args.name ? { name: args.name } : {} }] : [],
4288
4545
  ...args.screenshots ?? []
@@ -4292,7 +4549,7 @@ async function handleImportScreenshot(deps2, args) {
4292
4549
  'import_screenshot needs something to import. Pass `screenshots`: an array whose entries each name EXACTLY ONE source \u2014 { "url": "https://\u2026" }, { "file": { "download_url": "\u2026", "file_id": "\u2026" } } (a ChatGPT attachment), or (local stdio only) { "path": "/abs/shot.png" }. In ChatGPT the attachment arrives as the top-level `file` parameter instead.'
4293
4550
  );
4294
4551
  }
4295
- const results = await Promise.all(entries.map((entry) => importOne(deps2, entry)));
4552
+ const results = await Promise.all(entries.map((entry) => importOne(deps, entry)));
4296
4553
  const imported = results.filter((r) => r.ok);
4297
4554
  if (imported.length === 0) {
4298
4555
  return errorResult(results.map((r) => r.error).filter(Boolean).join("\n\n"));
@@ -4308,10 +4565,10 @@ async function handleImportScreenshot(deps2, args) {
4308
4565
  }
4309
4566
 
4310
4567
  // src/toolsUploads.ts
4311
- async function handleRequestScreenshotUpload(deps2, args) {
4568
+ async function handleRequestScreenshotUpload(deps, args) {
4312
4569
  let slots;
4313
4570
  try {
4314
- slots = await deps2.requestScreenshotUpload(deps2.userId, args.count, args.names);
4571
+ slots = await deps.requestScreenshotUpload(deps.userId, args.count, args.names);
4315
4572
  } catch (err) {
4316
4573
  return errorResult(err.message);
4317
4574
  }
@@ -4326,14 +4583,14 @@ async function handleRequestScreenshotUpload(deps2, args) {
4326
4583
  instructions: 'PUT each screenshot\'s raw PNG bytes to its uploadUrl, e.g.: curl -T screenshot.png "$UPLOAD_URL" \u2014 then pass { "ref": "THE_MATCHING_REF" } as that screenshot\'s entry in render_strip/emit_bundle\'s `screenshots` array. If you passed `names`, that ref already carries its filename \u2014 no need to also set `name` on the screenshot entry. Uploads are not auto-purged yet \u2014 treat refs as short-lived and single-use.' + (locale2 ? ` For render_strip/emit_bundle, place each ref under the matching per-locale key, e.g. { "locales": { "${locale2}": { "ref": "THE_REF" } } }.` : "") + (locale2 || family ? " For render_project, pass each slot's `variant` through with its ref so that exact device-family + locale cell overrides the project instead of its base screenshot." : "")
4327
4584
  });
4328
4585
  }
4329
- async function handleDeleteAssets(deps2, args) {
4330
- if (!deps2.deleteAssets) {
4586
+ async function handleDeleteAssets(deps, args) {
4587
+ if (!deps.deleteAssets) {
4331
4588
  return errorResult(
4332
4589
  "delete_assets is only available on the hosted ShotOps connection. Local mode does not upload or retain screenshot files."
4333
4590
  );
4334
4591
  }
4335
4592
  try {
4336
- const deletedRefs = await deps2.deleteAssets(deps2.userId, args.refs);
4593
+ const deletedRefs = await deps.deleteAssets(deps.userId, args.refs);
4337
4594
  return textResult({
4338
4595
  ok: true,
4339
4596
  deletedCount: deletedRefs.length,
@@ -4466,8 +4723,8 @@ function deriveLayoutReport(source) {
4466
4723
  }
4467
4724
 
4468
4725
  // src/toolsProject.ts
4469
- async function handleSaveProject(deps2, args) {
4470
- const { look: look2, style: style2, note: resolveNote } = await resolveLook(deps2, args);
4726
+ async function handleSaveProject(deps, args) {
4727
+ const { look: look2, style: style2, note: resolveNote } = await resolveLook(deps, args);
4471
4728
  const note = [resolveNote, composeConflictNote(look2, style2)].filter(Boolean).join(" ") || void 0;
4472
4729
  const locale2 = args.locale?.trim() || "en-US";
4473
4730
  if (!Array.isArray(args.screenshots) || args.screenshots.length === 0) {
@@ -4497,7 +4754,7 @@ async function handleSaveProject(deps2, args) {
4497
4754
  }
4498
4755
  let record;
4499
4756
  try {
4500
- record = await deps2.renderer.buildRecord({
4757
+ record = await deps.renderer.buildRecord({
4501
4758
  slotSizes,
4502
4759
  panelPresetId: panelPresetId2,
4503
4760
  look: look2,
@@ -4514,7 +4771,7 @@ async function handleSaveProject(deps2, args) {
4514
4771
  if (record == null) {
4515
4772
  return errorResult("no project record was produced \u2014 not saved.");
4516
4773
  }
4517
- const p = await persistProject(deps2, args, record, "unsigned-new");
4774
+ const p = await persistProject(deps, args, record, "unsigned-new");
4518
4775
  if ("error" in p) return errorResult(p.error);
4519
4776
  if ("status" in p) {
4520
4777
  return textResult({
@@ -4549,7 +4806,7 @@ function reachFromRecord(record) {
4549
4806
  const reach = record?.reach;
4550
4807
  return typeof reach === "string" && reach.length > 0 ? reach : null;
4551
4808
  }
4552
- function storedScreenshotReport(deps2, record, projectFile) {
4809
+ function storedScreenshotReport(deps, record, projectFile) {
4553
4810
  const frames = frameNamesFromProjectFile(projectFile);
4554
4811
  const file = projectFile;
4555
4812
  const baseLocale = typeof file.baseLocale === "string" && file.baseLocale ? file.baseLocale : Array.isArray(file.locales) && typeof file.locales[0] === "string" ? file.locales[0] : "en-US";
@@ -4591,7 +4848,7 @@ function storedScreenshotReport(deps2, record, projectFile) {
4591
4848
  }
4592
4849
  const names = frames.filter((name) => cells.some((cell) => cell.name === name && cell.available));
4593
4850
  return {
4594
- available: cells.some((cell) => cell.available) && Boolean(deps2.fetchHandoffBytes || deps2.readProjectScreens),
4851
+ available: cells.some((cell) => cell.available) && Boolean(deps.fetchHandoffBytes || deps.readProjectScreens),
4595
4852
  count: cells.filter((cell) => cell.available).length,
4596
4853
  total: cells.length,
4597
4854
  names,
@@ -4613,8 +4870,8 @@ function storedScreenshotSentence(report, projectId) {
4613
4870
  }
4614
4871
  return `This project holds no screenshots of its own, so render_project needs them. ${BROWSER_FIRST_ROUTE}`;
4615
4872
  }
4616
- async function handleReadProject(deps2, args) {
4617
- const resolution = await deps2.resolveProject(deps2.userId, args.project);
4873
+ async function handleReadProject(deps, args) {
4874
+ const resolution = await deps.resolveProject(deps.userId, args.project);
4618
4875
  const project2 = resolvedProject(resolution);
4619
4876
  if (!project2) {
4620
4877
  const miss = projectMiss(resolution);
@@ -4624,7 +4881,7 @@ async function handleReadProject(deps2, args) {
4624
4881
  message: describeMiss(resolution, args.project)
4625
4882
  });
4626
4883
  }
4627
- const rec = await deps2.readProjectRecord(project2.id);
4884
+ const rec = await deps.readProjectRecord(project2.id);
4628
4885
  if (!rec) {
4629
4886
  return textResult({
4630
4887
  saved: false,
@@ -4634,9 +4891,9 @@ async function handleReadProject(deps2, args) {
4634
4891
  }
4635
4892
  const projectFile = projectFileFromRecord(rec.record);
4636
4893
  const layout = deriveLayoutReport(projectFile);
4637
- const screenshots2 = storedScreenshotReport(deps2, rec.record, projectFile);
4638
- const historyDecision = await deps2.authorizeCapability({
4639
- subject: { kind: "account", userId: deps2.userId, mode: "api_token" },
4894
+ const screenshots2 = storedScreenshotReport(deps, rec.record, projectFile);
4895
+ const historyDecision = await deps.authorizeCapability({
4896
+ subject: { kind: "account", userId: deps.userId, mode: "api_token" },
4640
4897
  capability: CAPABILITIES.LOOK_HISTORY_READ,
4641
4898
  resource: { kind: "project", id: project2.id }
4642
4899
  });
@@ -4644,7 +4901,7 @@ async function handleReadProject(deps2, args) {
4644
4901
  let versions = [];
4645
4902
  if (!historyDenial) {
4646
4903
  try {
4647
- versions = await deps2.listVersions(project2.id);
4904
+ versions = await deps.listVersions(project2.id);
4648
4905
  } catch (error) {
4649
4906
  historyDenial = capabilityDenialFromError(error);
4650
4907
  if (!historyDenial) throw error;
@@ -4664,13 +4921,13 @@ async function handleReadProject(deps2, args) {
4664
4921
  }
4665
4922
 
4666
4923
  // src/toolsLook.ts
4667
- async function handleReadLook(deps2, args) {
4668
- const resolution = await deps2.resolveProject(deps2.userId, args.project);
4924
+ async function handleReadLook(deps, args) {
4925
+ const resolution = await deps.resolveProject(deps.userId, args.project);
4669
4926
  const project2 = resolvedProject(resolution);
4670
4927
  if (!project2) {
4671
4928
  return textResult({ saved: false, message: describeMiss(resolution, args.project) });
4672
4929
  }
4673
- const saved = await deps2.readLook(project2.id);
4930
+ const saved = await deps.readLook(project2.id);
4674
4931
  if (!saved) {
4675
4932
  return textResult({
4676
4933
  saved: false,
@@ -4679,8 +4936,8 @@ async function handleReadLook(deps2, args) {
4679
4936
  });
4680
4937
  }
4681
4938
  const layout = deriveLayoutReport(saved.look);
4682
- const historyDecision = await deps2.authorizeCapability({
4683
- subject: { kind: "account", userId: deps2.userId, mode: "api_token" },
4939
+ const historyDecision = await deps.authorizeCapability({
4940
+ subject: { kind: "account", userId: deps.userId, mode: "api_token" },
4684
4941
  capability: CAPABILITIES.LOOK_HISTORY_READ,
4685
4942
  resource: { kind: "project", id: project2.id }
4686
4943
  });
@@ -4688,7 +4945,7 @@ async function handleReadLook(deps2, args) {
4688
4945
  let versions = [];
4689
4946
  if (!historyDenial) {
4690
4947
  try {
4691
- versions = await deps2.listVersions(project2.id);
4948
+ versions = await deps.listVersions(project2.id);
4692
4949
  } catch (error) {
4693
4950
  historyDenial = capabilityDenialFromError(error);
4694
4951
  if (!historyDenial) throw error;
@@ -4717,19 +4974,19 @@ async function handleReadLook(deps2, args) {
4717
4974
  ...layout ? { layout } : {}
4718
4975
  });
4719
4976
  }
4720
- async function handleSaveLook(deps2, args) {
4721
- const resolution = await deps2.resolveProject(deps2.userId, args.project);
4977
+ async function handleSaveLook(deps, args) {
4978
+ const resolution = await deps.resolveProject(deps.userId, args.project);
4722
4979
  const project2 = resolvedProject(resolution);
4723
4980
  if (!project2) return errorResult(describeMiss(resolution, args.project));
4724
- const edit = await deps2.authorizeCapability({
4725
- subject: { kind: "account", userId: deps2.userId, mode: "api_token" },
4981
+ const edit = await deps.authorizeCapability({
4982
+ subject: { kind: "account", userId: deps.userId, mode: "api_token" },
4726
4983
  capability: CAPABILITIES.PROJECT_EDIT_SYNCED,
4727
4984
  resource: { kind: "project", id: project2.id }
4728
4985
  });
4729
4986
  const editDenial = authorizationMessage(edit);
4730
4987
  if (editDenial) return errorResult(editDenial);
4731
- const hold = await deps2.authorizeCapability({
4732
- subject: { kind: "account", userId: deps2.userId, mode: "api_token" },
4988
+ const hold = await deps.authorizeCapability({
4989
+ subject: { kind: "account", userId: deps.userId, mode: "api_token" },
4733
4990
  capability: CAPABILITIES.LOOK_VERSION_HOLD,
4734
4991
  resource: { kind: "project", id: project2.id }
4735
4992
  });
@@ -4740,7 +4997,7 @@ async function handleSaveLook(deps2, args) {
4740
4997
  if (JSON.stringify(args.look).length > 256 * 1024) {
4741
4998
  return errorResult("`look` is too large \u2014 a look is styling only (no screenshots, no image bytes).");
4742
4999
  }
4743
- const latest = await deps2.readLook(project2.id);
5000
+ const latest = await deps.readLook(project2.id);
4744
5001
  if (latest && looksMatch(args.look, latest.look)) {
4745
5002
  return textResult({
4746
5003
  ok: true,
@@ -4751,7 +5008,7 @@ async function handleSaveLook(deps2, args) {
4751
5008
  }
4752
5009
  let version2;
4753
5010
  try {
4754
- const saved = await deps2.saveLook(
5011
+ const saved = await deps.saveLook(
4755
5012
  project2.id,
4756
5013
  args.look,
4757
5014
  args.sourceName?.trim() || void 0,
@@ -4769,12 +5026,12 @@ async function handleSaveLook(deps2, args) {
4769
5026
  message: hold.allowed ? `Saved as version ${version2} on "${project2.name}" and advanced Hold to it for default renders.` : `Saved as version ${version2} on "${project2.name}" \u2014 render it with useSavedLook.`
4770
5027
  });
4771
5028
  }
4772
- async function handleHoldLook(deps2, args) {
4773
- const resolution = await deps2.resolveProject(deps2.userId, args.project);
5029
+ async function handleHoldLook(deps, args) {
5030
+ const resolution = await deps.resolveProject(deps.userId, args.project);
4774
5031
  const project2 = resolvedProject(resolution);
4775
5032
  if (!project2) return errorResult(describeMiss(resolution, args.project));
4776
- const decision = await deps2.authorizeCapability({
4777
- subject: { kind: "account", userId: deps2.userId, mode: "api_token" },
5033
+ const decision = await deps.authorizeCapability({
5034
+ subject: { kind: "account", userId: deps.userId, mode: "api_token" },
4778
5035
  capability: CAPABILITIES.LOOK_VERSION_HOLD,
4779
5036
  resource: { kind: "project", id: project2.id }
4780
5037
  });
@@ -4782,7 +5039,7 @@ async function handleHoldLook(deps2, args) {
4782
5039
  if (denial) return errorResult(denial);
4783
5040
  let versions;
4784
5041
  try {
4785
- versions = await deps2.listVersions(project2.id);
5042
+ versions = await deps.listVersions(project2.id);
4786
5043
  } catch (error) {
4787
5044
  const structured = capabilityDenialFromError(error);
4788
5045
  return errorResult(structured ? capabilityDenialMessage(structured) : error.message);
@@ -4792,7 +5049,7 @@ async function handleHoldLook(deps2, args) {
4792
5049
  return errorResult(`Version ${args.version} not found on project "${project2.name}" (saved: ${available}).`);
4793
5050
  }
4794
5051
  try {
4795
- await deps2.setHeldVersion(project2.id, args.version);
5052
+ await deps.setHeldVersion(project2.id, args.version);
4796
5053
  } catch (err) {
4797
5054
  const structured = capabilityDenialFromError(err);
4798
5055
  return errorResult(structured ? capabilityDenialMessage(structured) : err.message);
@@ -4804,19 +5061,19 @@ async function handleHoldLook(deps2, args) {
4804
5061
  message: `Holding v${args.version} on "${project2.name}" \u2014 agents render it by default until release_look (Follow latest).`
4805
5062
  });
4806
5063
  }
4807
- async function handleReleaseLook(deps2, args) {
4808
- const resolution = await deps2.resolveProject(deps2.userId, args.project);
5064
+ async function handleReleaseLook(deps, args) {
5065
+ const resolution = await deps.resolveProject(deps.userId, args.project);
4809
5066
  const project2 = resolvedProject(resolution);
4810
5067
  if (!project2) return errorResult(describeMiss(resolution, args.project));
4811
- const decision = await deps2.authorizeCapability({
4812
- subject: { kind: "account", userId: deps2.userId, mode: "api_token" },
5068
+ const decision = await deps.authorizeCapability({
5069
+ subject: { kind: "account", userId: deps.userId, mode: "api_token" },
4813
5070
  capability: CAPABILITIES.LOOK_VERSION_HOLD,
4814
5071
  resource: { kind: "project", id: project2.id }
4815
5072
  });
4816
5073
  const denial = authorizationMessage(decision);
4817
5074
  if (denial) return errorResult(denial);
4818
5075
  try {
4819
- await deps2.setHeldVersion(project2.id, null);
5076
+ await deps.setHeldVersion(project2.id, null);
4820
5077
  } catch (err) {
4821
5078
  const structured = capabilityDenialFromError(err);
4822
5079
  return errorResult(structured ? capabilityDenialMessage(structured) : err.message);
@@ -4884,24 +5141,24 @@ function resolveOutputMode(requested, totalBytes, forced) {
4884
5141
  }
4885
5142
  return { mode: totalBytes > AUTO_INLINE_THRESHOLD_BYTES ? "urls" : "inline" };
4886
5143
  }
4887
- async function resolveLook(deps2, args) {
4888
- const resolved = await resolveLookInner(deps2, args);
5144
+ async function resolveLook(deps, args) {
5145
+ const resolved = await resolveLookInner(deps, args);
4889
5146
  const nudge = layoutNudgeNote(args.style, args.look, resolved.look);
4890
5147
  if (!nudge) return resolved;
4891
5148
  return { ...resolved, note: [resolved.note, nudge].filter(Boolean).join(" ") };
4892
5149
  }
4893
- async function resolveLookInner(deps2, args) {
5150
+ async function resolveLookInner(deps, args) {
4894
5151
  const style2 = expandStyleLayout(args.style) ?? null;
4895
5152
  if (args.look != null) return { look: args.look, style: style2 };
4896
5153
  if (args.useSavedLook || args.version != null) {
4897
- const project2 = resolvedProject(await deps2.resolveProject(deps2.userId, args.project));
5154
+ const project2 = resolvedProject(await deps.resolveProject(deps.userId, args.project));
4898
5155
  if (!project2) {
4899
5156
  return { look: null, style: style2, note: "no matching ShotOps project found for this account \u2014 used default styling." };
4900
5157
  }
4901
- const saved = await deps2.readLook(project2.id);
5158
+ const saved = await deps.readLook(project2.id);
4902
5159
  if (args.version != null) {
4903
- const decision = await deps2.authorizeCapability({
4904
- subject: { kind: "account", userId: deps2.userId, mode: "api_token" },
5160
+ const decision = await deps.authorizeCapability({
5161
+ subject: { kind: "account", userId: deps.userId, mode: "api_token" },
4905
5162
  capability: CAPABILITIES.LOOK_HISTORY_READ,
4906
5163
  resource: { kind: "project", id: project2.id }
4907
5164
  });
@@ -4912,7 +5169,7 @@ async function resolveLookInner(deps2, args) {
4912
5169
  if (saved && args.version === saved.version) return { look: saved.look, style: style2 };
4913
5170
  let v;
4914
5171
  try {
4915
- v = await deps2.readVersion(project2.id, args.version);
5172
+ v = await deps.readVersion(project2.id, args.version);
4916
5173
  } catch (error) {
4917
5174
  const structured = capabilityDenialFromError(error);
4918
5175
  if (!structured) throw error;
@@ -4938,8 +5195,8 @@ async function resolveLookInner(deps2, args) {
4938
5195
  };
4939
5196
  }
4940
5197
  if (project2.heldVersion != null && project2.heldVersion !== saved?.version) {
4941
- const decision = await deps2.authorizeCapability({
4942
- subject: { kind: "account", userId: deps2.userId, mode: "api_token" },
5198
+ const decision = await deps.authorizeCapability({
5199
+ subject: { kind: "account", userId: deps.userId, mode: "api_token" },
4943
5200
  capability: CAPABILITIES.LOOK_VERSION_HOLD,
4944
5201
  resource: { kind: "project", id: project2.id }
4945
5202
  });
@@ -4957,7 +5214,7 @@ async function resolveLookInner(deps2, args) {
4957
5214
  }
4958
5215
  let held;
4959
5216
  try {
4960
- held = await deps2.readVersion(project2.id, project2.heldVersion);
5217
+ held = await deps.readVersion(project2.id, project2.heldVersion);
4961
5218
  } catch (error) {
4962
5219
  const structured = capabilityDenialFromError(error);
4963
5220
  if (!structured) throw error;
@@ -5093,10 +5350,10 @@ function allLiveScreenRefs(record) {
5093
5350
  const parsed = parseScreenManifest(record?.handoffRefs);
5094
5351
  return parsed ? allManifestEntries(parsed) : [];
5095
5352
  }
5096
- async function persistProject(deps2, args, record, claimPolicy = "none") {
5353
+ async function persistProject(deps, args, record, claimPolicy = "none") {
5097
5354
  const names = screenNamesFromRecord(record);
5098
5355
  const source = names.length === 0 ? void 0 : args.sourceDir ? { kind: "local-path", ref: { dir: args.sourceDir, names } } : { kind: "agent", ref: { names } };
5099
- const introducedRefs = handoffRefsFromScreenshots(args.screenshots, names, deps2.userId);
5356
+ const introducedRefs = handoffRefsFromScreenshots(args.screenshots, names, deps.userId);
5100
5357
  const wrapWith = (handoffRefs, existingRecord) => {
5101
5358
  const manifest = mergeManifest(
5102
5359
  preservedNonBaseCells(existingRecord, names),
@@ -5128,6 +5385,7 @@ async function persistProject(deps2, args, record, claimPolicy = "none") {
5128
5385
  };
5129
5386
  };
5130
5387
  const ctx = {
5388
+ actorUserId: deps.userId,
5131
5389
  screenshots: args.screenshots,
5132
5390
  look: args.look,
5133
5391
  // #217 — expanded here too, not just in resolveLook. Nothing reads ctx.style TODAY (the
@@ -5142,47 +5400,47 @@ async function persistProject(deps2, args, record, claimPolicy = "none") {
5142
5400
  sourceDir: args.sourceDir
5143
5401
  };
5144
5402
  try {
5145
- if (claimPolicy === "unsigned-new" && deps2.createPendingProjectClaim) {
5403
+ if (claimPolicy === "unsigned-new" && deps.createPendingProjectClaim) {
5146
5404
  if (args.project) {
5147
5405
  return {
5148
5406
  error: "Updating an existing ShotOps project requires the account token that owns or can access it. Remove `project` to create a new pending claim, or configure SHOTOPS_TOKEN to update this project."
5149
5407
  };
5150
5408
  }
5151
5409
  const name = args.projectName?.trim() || "ShotOps render";
5152
- return await deps2.createPendingProjectClaim(name, wrapWith(introducedRefs), ctx);
5410
+ return await deps.createPendingProjectClaim(name, wrapWith(introducedRefs), ctx);
5153
5411
  }
5154
5412
  let projectId;
5155
5413
  if (args.project) {
5156
- const resolution = await deps2.resolveProject(deps2.userId, args.project);
5414
+ const resolution = await deps.resolveProject(deps.userId, args.project);
5157
5415
  const project2 = resolvedProject(resolution);
5158
5416
  if (!project2) return { error: describeMiss(resolution, args.project) };
5159
- const decision = await deps2.authorizeCapability({
5160
- subject: { kind: "account", userId: deps2.userId, mode: "api_token" },
5417
+ const decision = await deps.authorizeCapability({
5418
+ subject: { kind: "account", userId: deps.userId, mode: "api_token" },
5161
5419
  capability: CAPABILITIES.PROJECT_EDIT_SYNCED,
5162
5420
  resource: { kind: "project", id: project2.id }
5163
5421
  });
5164
5422
  const denial = authorizationMessage(decision);
5165
5423
  if (denial) return { error: denial };
5166
- const existing = await deps2.readProjectRecord(project2.id);
5424
+ const existing = await deps.readProjectRecord(project2.id);
5167
5425
  const merged = mergeHandoffRefs(handoffRefsFromRecord(existing?.record), introducedRefs, names);
5168
5426
  if (!existing) return { error: `Project "${project2.name}" has no readable saved record.` };
5169
- await deps2.updateProjectRecord(project2.id, wrapWith(merged, existing.record), existing.recordVersion ?? 1, ctx);
5427
+ await deps.updateProjectRecord(project2.id, wrapWith(merged, existing.record), existing.recordVersion ?? 1, ctx);
5170
5428
  projectId = project2.id;
5171
5429
  } else {
5172
5430
  const name = args.projectName?.trim() || "ShotOps render";
5173
- if (deps2.authorizeAccountCapability) {
5174
- const decision = await deps2.authorizeAccountCapability({
5175
- subject: { kind: "account", userId: deps2.userId, mode: "api_token" },
5431
+ if (deps.authorizeAccountCapability) {
5432
+ const decision = await deps.authorizeAccountCapability({
5433
+ subject: { kind: "account", userId: deps.userId, mode: "api_token" },
5176
5434
  capability: CAPABILITIES.PROJECT_CREATE_SYNCED,
5177
- resource: { kind: "account", id: deps2.userId }
5435
+ resource: { kind: "account", id: deps.userId }
5178
5436
  });
5179
5437
  const denial = authorizationMessage(decision);
5180
5438
  if (denial) return { error: denial };
5181
5439
  }
5182
- const created = await deps2.createProject(deps2.userId, name, wrapWith(introducedRefs), ctx);
5440
+ const created = await deps.createProject(deps.userId, name, wrapWith(introducedRefs), ctx);
5183
5441
  projectId = created.id;
5184
5442
  }
5185
- return { projectId, openUrl: `${deps2.studioOrigin}/?project=${projectId}` };
5443
+ return { projectId, openUrl: `${deps.studioOrigin}/?project=${projectId}` };
5186
5444
  } catch (err) {
5187
5445
  return { error: err.message };
5188
5446
  }
@@ -5227,23 +5485,28 @@ var IDEMPOTENT_DESTRUCTIVE_WRITE = {
5227
5485
  idempotentHint: true,
5228
5486
  openWorldHint: false
5229
5487
  };
5230
- function toolMeta(deps2, invoking, invoked, extra = {}) {
5488
+ function securitySchemesFor(deps, toolName) {
5489
+ if (deps.allowLocalScreenshots) return [{ type: "noauth" }];
5490
+ if (deps.anonymous && ANONYMOUS_TOOLS.has(toolName)) {
5491
+ return [{ type: "noauth" }, { type: "oauth2", scopes: [] }];
5492
+ }
5493
+ return [{ type: "oauth2", scopes: [] }];
5494
+ }
5495
+ function toolMeta(deps, toolName, invoking, invoked, extra = {}) {
5231
5496
  return {
5232
- // Current MCP SDK exposes Apps authentication policy through `_meta`; `noauth` keeps the
5233
- // same registrar accurate for the local stdio server.
5234
- securitySchemes: deps2.allowLocalScreenshots ? [{ type: "noauth" }] : [{ type: "oauth2", scopes: [] }],
5497
+ securitySchemes: securitySchemesFor(deps, toolName),
5235
5498
  "openai/toolInvocation/invoking": invoking,
5236
5499
  "openai/toolInvocation/invoked": invoked,
5237
5500
  ...extra
5238
5501
  };
5239
5502
  }
5240
- function registerTools(server2, deps2) {
5241
- registerWidgets(server2);
5242
- server2.registerTool(
5503
+ function registerTools(server, deps) {
5504
+ registerWidgets(server);
5505
+ server.registerTool(
5243
5506
  "render_strip",
5244
5507
  {
5245
5508
  title: "Render App Store screenshots",
5246
- description: 'Make the store screenshots: raw app screen captures in, finished marketing panels out. Each screenshot is composited into a styled 3D device mockup, laid out as an App Store screenshot strip with the headlines you pass, and returned as per-panel PNGs at the App Store panel size `panelPresetId` names. This is the tool for a strip built from screenshots you have in hand; to reproduce one a designer already saved, use render_project. In ChatGPT, call import_screenshot first and pass the returned refs in the preferred flat form: { "screenshots": [{ "ref": "\u2026" }] } \u2014 each flat entry is one panel, and nested slots only when ONE panel should hold several phones. Art-direct it with `style`/`look` or a project\'s saved look, iterate with a cheap low-res `preview`, and take results `inline` or as `urls` for large payloads. Screenshots can vary per store locale via { "locales": \u2026 } entries \u2014 `locale` picks which variant renders (missing variants fall back to en-US). For real, full-resolution screenshots call request_screenshot_upload first \u2014 inline base64 is for small payloads only. This tool ONLY renders images: it never creates or updates a project (use save_project for that) and no store credential is involved.',
5509
+ description: 'Make the store screenshots: raw app screen captures in, finished marketing panels out. Each screenshot is composited into a styled 3D device mockup, laid out as an App Store screenshot strip with the headlines you pass, and returned as per-panel PNGs at the App Store panel size `panelPresetId` names. This is the tool for a strip built from screenshots you have in hand; to reproduce one a designer already saved, use render_project. In ChatGPT, call import_screenshot first and pass the returned refs in the preferred flat form: { "screenshots": [{ "ref": "\u2026" }] } \u2014 each flat entry is one panel, and nested slots only when ONE panel should hold several phones. Art-direct it with `style`/`look` or a project\'s saved look, iterate with a low-res `preview` at a quarter the render cost, and take results `inline` or as `urls` for large payloads. Screenshots can vary per store locale via { "locales": \u2026 } entries \u2014 `locale` picks which variant renders (missing variants fall back to en-US). For real, full-resolution screenshots call request_screenshot_upload first \u2014 inline base64 is for small payloads only. This tool ONLY renders images: it never creates or updates a project (use save_project for that) and no store credential is involved.',
5247
5510
  inputSchema: renderStripAdvertisedShape,
5248
5511
  outputSchema: renderStripOutputSchema,
5249
5512
  // ADDITIVE, not destructive: this can mint private generated assets but cannot change a
@@ -5251,15 +5514,16 @@ function registerTools(server2, deps2) {
5251
5514
  // `createProject` flag, which made ChatGPT prompt for permission just to LOOK at a render.
5252
5515
  annotations: ADDITIVE_WRITE,
5253
5516
  _meta: toolMeta(
5254
- deps2,
5517
+ deps,
5518
+ "render_strip",
5255
5519
  "Rendering ShotOps strip\u2026",
5256
5520
  "ShotOps strip rendered",
5257
5521
  widgetBinding(PANELS_WIDGET_URI)
5258
5522
  )
5259
5523
  },
5260
- shapedInput("render_strip", renderStripShape, (args) => handleRenderStrip(deps2, args))
5524
+ shapedInput("render_strip", renderStripShape, (args) => handleRenderStrip(deps, args))
5261
5525
  );
5262
- server2.registerTool(
5526
+ server.registerTool(
5263
5527
  "emit_bundle",
5264
5528
  {
5265
5529
  title: "Export fastlane bundle",
@@ -5271,15 +5535,16 @@ function registerTools(server2, deps2) {
5271
5535
  // than the gallery's — a zip download, what is inside it, the share link's expiry, and the
5272
5536
  // zero-custody line stated where someone might otherwise assume we submit for them.
5273
5537
  _meta: toolMeta(
5274
- deps2,
5538
+ deps,
5539
+ "emit_bundle",
5275
5540
  "Building fastlane bundle\u2026",
5276
5541
  "Fastlane bundle built",
5277
5542
  widgetBinding(BUNDLE_WIDGET_URI)
5278
5543
  )
5279
5544
  },
5280
- shapedInput("emit_bundle", emitBundleShape, (args) => handleEmitBundle(deps2, args))
5545
+ shapedInput("emit_bundle", emitBundleShape, (args) => handleEmitBundle(deps, args))
5281
5546
  );
5282
- server2.registerTool(
5547
+ server.registerTool(
5283
5548
  "save_project",
5284
5549
  {
5285
5550
  title: "Save as editable project",
@@ -5287,11 +5552,11 @@ function registerTools(server2, deps2) {
5287
5552
  inputSchema: saveProjectAdvertisedShape,
5288
5553
  outputSchema: saveProjectOutputSchema,
5289
5554
  annotations: DESTRUCTIVE_WRITE,
5290
- _meta: toolMeta(deps2, "Saving ShotOps project\u2026", "ShotOps project saved")
5555
+ _meta: toolMeta(deps, "save_project", "Saving ShotOps project\u2026", "ShotOps project saved")
5291
5556
  },
5292
- shapedInput("save_project", saveProjectShape, (args) => handleSaveProject(deps2, args))
5557
+ shapedInput("save_project", saveProjectShape, (args) => handleSaveProject(deps, args))
5293
5558
  );
5294
- server2.registerTool(
5559
+ server.registerTool(
5295
5560
  "read_look",
5296
5561
  {
5297
5562
  title: "Read saved look",
@@ -5299,11 +5564,11 @@ function registerTools(server2, deps2) {
5299
5564
  inputSchema: readLookShape,
5300
5565
  outputSchema: readLookOutputSchema,
5301
5566
  annotations: READ_ONLY,
5302
- _meta: toolMeta(deps2, "Reading saved look\u2026", "Saved look read")
5567
+ _meta: toolMeta(deps, "read_look", "Reading saved look\u2026", "Saved look read")
5303
5568
  },
5304
- shapedInput("read_look", readLookShape, (args) => handleReadLook(deps2, args))
5569
+ shapedInput("read_look", readLookShape, (args) => handleReadLook(deps, args))
5305
5570
  );
5306
- server2.registerTool(
5571
+ server.registerTool(
5307
5572
  "read_project",
5308
5573
  {
5309
5574
  title: "Read full project",
@@ -5311,11 +5576,11 @@ function registerTools(server2, deps2) {
5311
5576
  inputSchema: readProjectShape,
5312
5577
  outputSchema: readProjectOutputSchema,
5313
5578
  annotations: READ_ONLY,
5314
- _meta: toolMeta(deps2, "Reading ShotOps project\u2026", "ShotOps project read")
5579
+ _meta: toolMeta(deps, "read_project", "Reading ShotOps project\u2026", "ShotOps project read")
5315
5580
  },
5316
- shapedInput("read_project", readProjectShape, (args) => handleReadProject(deps2, args))
5581
+ shapedInput("read_project", readProjectShape, (args) => handleReadProject(deps, args))
5317
5582
  );
5318
- server2.registerTool(
5583
+ server.registerTool(
5319
5584
  "render_project",
5320
5585
  {
5321
5586
  title: "Render a saved project",
@@ -5329,15 +5594,16 @@ function registerTools(server2, deps2) {
5329
5594
  // agent on ChatGPT mobile actually calls, so leaving it unbound was leaving raw JSON in
5330
5595
  // front of the users most likely to see it.
5331
5596
  _meta: toolMeta(
5332
- deps2,
5597
+ deps,
5598
+ "render_project",
5333
5599
  "Rendering saved project\u2026",
5334
5600
  "Saved project rendered",
5335
5601
  widgetBinding(PANELS_WIDGET_URI)
5336
5602
  )
5337
5603
  },
5338
- shapedInput("render_project", renderProjectShape, (args) => handleRenderProject(deps2, args))
5604
+ shapedInput("render_project", renderProjectShape, (args) => handleRenderProject(deps, args))
5339
5605
  );
5340
- server2.registerTool(
5606
+ server.registerTool(
5341
5607
  "save_look",
5342
5608
  {
5343
5609
  title: "Save look",
@@ -5345,11 +5611,11 @@ function registerTools(server2, deps2) {
5345
5611
  inputSchema: saveLookAdvertisedShape,
5346
5612
  outputSchema: saveLookOutputSchema,
5347
5613
  annotations: ADDITIVE_WRITE,
5348
- _meta: toolMeta(deps2, "Saving ShotOps look\u2026", "ShotOps look saved")
5614
+ _meta: toolMeta(deps, "save_look", "Saving ShotOps look\u2026", "ShotOps look saved")
5349
5615
  },
5350
- shapedInput("save_look", saveLookShape, (args) => handleSaveLook(deps2, args))
5616
+ shapedInput("save_look", saveLookShape, (args) => handleSaveLook(deps, args))
5351
5617
  );
5352
- server2.registerTool(
5618
+ server.registerTool(
5353
5619
  "hold_look",
5354
5620
  {
5355
5621
  title: "Hold a look version",
@@ -5357,11 +5623,11 @@ function registerTools(server2, deps2) {
5357
5623
  inputSchema: holdLookShape,
5358
5624
  outputSchema: holdLookOutputSchema,
5359
5625
  annotations: IDEMPOTENT_WRITE,
5360
- _meta: toolMeta(deps2, "Holding look version\u2026", "Look version held")
5626
+ _meta: toolMeta(deps, "hold_look", "Holding look version\u2026", "Look version held")
5361
5627
  },
5362
- shapedInput("hold_look", holdLookShape, (args) => handleHoldLook(deps2, args))
5628
+ shapedInput("hold_look", holdLookShape, (args) => handleHoldLook(deps, args))
5363
5629
  );
5364
- server2.registerTool(
5630
+ server.registerTool(
5365
5631
  "release_look",
5366
5632
  {
5367
5633
  title: "Release the held look",
@@ -5369,11 +5635,11 @@ function registerTools(server2, deps2) {
5369
5635
  inputSchema: releaseLookShape,
5370
5636
  outputSchema: releaseLookOutputSchema,
5371
5637
  annotations: IDEMPOTENT_WRITE,
5372
- _meta: toolMeta(deps2, "Releasing look version\u2026", "Look version released")
5638
+ _meta: toolMeta(deps, "release_look", "Releasing look version\u2026", "Look version released")
5373
5639
  },
5374
- shapedInput("release_look", releaseLookShape, (args) => handleReleaseLook(deps2, args))
5640
+ shapedInput("release_look", releaseLookShape, (args) => handleReleaseLook(deps, args))
5375
5641
  );
5376
- server2.registerTool(
5642
+ server.registerTool(
5377
5643
  "describe_look",
5378
5644
  {
5379
5645
  title: "Design presets & style catalog",
@@ -5381,11 +5647,11 @@ function registerTools(server2, deps2) {
5381
5647
  inputSchema: describeLookShape,
5382
5648
  outputSchema: describeLookOutputSchema,
5383
5649
  annotations: READ_ONLY,
5384
- _meta: toolMeta(deps2, "Reading look field catalog\u2026", "Look field catalog read")
5650
+ _meta: toolMeta(deps, "describe_look", "Reading look field catalog\u2026", "Look field catalog read")
5385
5651
  },
5386
5652
  shapedInput("describe_look", describeLookShape, () => handleDescribeLook())
5387
5653
  );
5388
- server2.registerTool(
5654
+ server.registerTool(
5389
5655
  "request_screenshot_upload",
5390
5656
  {
5391
5657
  title: "Upload full-size screenshots",
@@ -5393,15 +5659,15 @@ function registerTools(server2, deps2) {
5393
5659
  inputSchema: requestScreenshotUploadShape,
5394
5660
  outputSchema: requestScreenshotUploadOutputSchema,
5395
5661
  annotations: ADDITIVE_WRITE,
5396
- _meta: toolMeta(deps2, "Preparing screenshot uploads\u2026", "Screenshot uploads ready")
5662
+ _meta: toolMeta(deps, "request_screenshot_upload", "Preparing screenshot uploads\u2026", "Screenshot uploads ready")
5397
5663
  },
5398
5664
  shapedInput(
5399
5665
  "request_screenshot_upload",
5400
5666
  requestScreenshotUploadShape,
5401
- (args) => handleRequestScreenshotUpload(deps2, args)
5667
+ (args) => handleRequestScreenshotUpload(deps, args)
5402
5668
  )
5403
5669
  );
5404
- server2.registerTool(
5670
+ server.registerTool(
5405
5671
  "import_screenshot",
5406
5672
  {
5407
5673
  title: "Import screenshots",
@@ -5409,13 +5675,13 @@ function registerTools(server2, deps2) {
5409
5675
  inputSchema: importScreenshotAdvertisedShape,
5410
5676
  outputSchema: importScreenshotOutputSchema,
5411
5677
  annotations: ADDITIVE_WRITE,
5412
- _meta: toolMeta(deps2, "Importing attached screenshot\u2026", "Attached screenshot imported", {
5678
+ _meta: toolMeta(deps, "import_screenshot", "Importing attached screenshot\u2026", "Attached screenshot imported", {
5413
5679
  "openai/fileParams": ["file"]
5414
5680
  })
5415
5681
  },
5416
- shapedInput("import_screenshot", importScreenshotShape, (args) => handleImportScreenshot(deps2, args))
5682
+ shapedInput("import_screenshot", importScreenshotShape, (args) => handleImportScreenshot(deps, args))
5417
5683
  );
5418
- server2.registerTool(
5684
+ server.registerTool(
5419
5685
  "delete_assets",
5420
5686
  {
5421
5687
  title: "Delete uploaded assets",
@@ -5423,9 +5689,9 @@ function registerTools(server2, deps2) {
5423
5689
  inputSchema: deleteAssetsShape,
5424
5690
  outputSchema: deleteAssetsOutputSchema,
5425
5691
  annotations: IDEMPOTENT_DESTRUCTIVE_WRITE,
5426
- _meta: toolMeta(deps2, "Deleting ShotOps assets\u2026", "ShotOps assets deleted")
5692
+ _meta: toolMeta(deps, "delete_assets", "Deleting ShotOps assets\u2026", "ShotOps assets deleted")
5427
5693
  },
5428
- shapedInput("delete_assets", deleteAssetsShape, (args) => handleDeleteAssets(deps2, args))
5694
+ shapedInput("delete_assets", deleteAssetsShape, (args) => handleDeleteAssets(deps, args))
5429
5695
  );
5430
5696
  }
5431
5697
 
@@ -5490,13 +5756,22 @@ function stripFromSlots(input) {
5490
5756
  let gradientFrom = FALLBACK_GRADIENT_FROM;
5491
5757
  let gradientTo = FALLBACK_GRADIENT_TO;
5492
5758
  let gradientDir = "vertical";
5759
+ let gradientStops;
5760
+ let gradientAngle;
5493
5761
  let shadow = true;
5494
5762
  let floorReflection = false;
5495
5763
  if (opaque) {
5496
5764
  bgMode = opaque.bgMode === "perPanel" ? "perPanel" : "gradient";
5497
- if (typeof opaque.gradientFrom === "string") gradientFrom = opaque.gradientFrom;
5498
- if (typeof opaque.gradientTo === "string") gradientTo = opaque.gradientTo;
5499
- if (opaque.gradientDir === "horizontal") gradientDir = "horizontal";
5765
+ const gradient = stripGradientOf({
5766
+ gradientStops: Array.isArray(opaque.gradientStops) ? opaque.gradientStops : void 0,
5767
+ gradientAngle: typeof opaque.gradientAngle === "number" ? opaque.gradientAngle : void 0,
5768
+ gradientFrom: typeof opaque.gradientFrom === "string" ? opaque.gradientFrom : gradientFrom,
5769
+ gradientTo: typeof opaque.gradientTo === "string" ? opaque.gradientTo : gradientTo,
5770
+ gradientDir: opaque.gradientDir === "horizontal" ? "horizontal" : gradientDir
5771
+ });
5772
+ gradientStops = gradient.stops;
5773
+ gradientAngle = gradient.angle;
5774
+ ({ gradientFrom, gradientTo, gradientDir } = mirrorsOf(gradient));
5500
5775
  if (opaque.shadow === false) shadow = false;
5501
5776
  if (opaque.floorReflection === true) floorReflection = true;
5502
5777
  const sourceOrder = [];
@@ -5510,9 +5785,16 @@ function stripFromSlots(input) {
5510
5785
  } else if (style2) {
5511
5786
  const bg = style2.background ?? {};
5512
5787
  bgMode = bg.mode === "perPanel" ? "perPanel" : "gradient";
5513
- if (typeof bg.gradientFrom === "string") gradientFrom = bg.gradientFrom;
5514
- if (typeof bg.gradientTo === "string") gradientTo = bg.gradientTo;
5515
- if (bg.gradientDir === "horizontal") gradientDir = "horizontal";
5788
+ const gradient = stripGradientOf({
5789
+ gradientStops: bg.stops,
5790
+ gradientAngle: bg.angle,
5791
+ gradientFrom,
5792
+ gradientTo,
5793
+ gradientDir
5794
+ });
5795
+ gradientStops = gradient.stops;
5796
+ gradientAngle = gradient.angle;
5797
+ ({ gradientFrom, gradientTo, gradientDir } = mirrorsOf(gradient));
5516
5798
  if (bg.shadow === false) shadow = false;
5517
5799
  if (bg.floorReflection === true) floorReflection = true;
5518
5800
  if (Array.isArray(bg.panelColors)) {
@@ -5551,6 +5833,8 @@ function stripFromSlots(input) {
5551
5833
  look: {
5552
5834
  panelPresetId: panelPresetId2,
5553
5835
  bgMode,
5836
+ gradientStops,
5837
+ gradientAngle,
5554
5838
  gradientFrom,
5555
5839
  gradientTo,
5556
5840
  gradientDir,
@@ -5640,7 +5924,7 @@ var MIME = {
5640
5924
  ".wasm": "application/wasm"
5641
5925
  };
5642
5926
  async function startStaticServer(rootDir) {
5643
- const server2 = createServer((req, res) => {
5927
+ const server = createServer((req, res) => {
5644
5928
  const reqPath = decodeURIComponent((req.url ?? "/").split("?")[0]);
5645
5929
  const rel = reqPath === "/" ? "index.html" : reqPath.replace(/^\/+/, "");
5646
5930
  const filePath = join2(rootDir, rel);
@@ -5663,14 +5947,14 @@ async function startStaticServer(rootDir) {
5663
5947
  );
5664
5948
  });
5665
5949
  await new Promise((resolve, reject) => {
5666
- server2.once("error", reject);
5667
- server2.listen(0, "127.0.0.1", resolve);
5950
+ server.once("error", reject);
5951
+ server.listen(0, "127.0.0.1", resolve);
5668
5952
  });
5669
- const addr = server2.address();
5953
+ const addr = server.address();
5670
5954
  return {
5671
5955
  url: `http://127.0.0.1:${addr.port}/`,
5672
5956
  close: () => new Promise((resolve) => {
5673
- server2.close(() => resolve());
5957
+ server.close(() => resolve());
5674
5958
  })
5675
5959
  };
5676
5960
  }
@@ -5711,13 +5995,13 @@ var StripRenderer = class {
5711
5995
  if (!this.harnessUrl) this.harnessUrl = await this.startHarnessServer();
5712
5996
  const { chromium } = await import("playwright");
5713
5997
  this.browser = await chromium.launch({ headless: true });
5714
- const page = await this.browser.newPage();
5715
- page.on("console", (m) => {
5998
+ const page2 = await this.browser.newPage();
5999
+ page2.on("console", (m) => {
5716
6000
  if (/CONTEXT_LOST|Context Lost/i.test(m.text())) this.sawContextLost = true;
5717
6001
  });
5718
- await page.goto(this.harnessUrl, { waitUntil: "load" });
5719
- await page.waitForFunction(() => window.__RENDER_READY === true, { timeout: 9e4 });
5720
- this.page = page;
6002
+ await page2.goto(this.harnessUrl, { waitUntil: "load" });
6003
+ await page2.waitForFunction(() => window.__RENDER_READY === true, { timeout: 9e4 });
6004
+ this.page = page2;
5721
6005
  }
5722
6006
  // The one line that forks between hosted and local. Everything downstream (browser launch,
5723
6007
  // the __RENDER_READY handshake, window.renderStrip, boot-retry, CONTEXT_LOST) is identical
@@ -5729,8 +6013,8 @@ var StripRenderer = class {
5729
6013
  return this.staticServer.url;
5730
6014
  }
5731
6015
  const configFile = fileURLToPath3(new URL("../vite.config.mjs", import.meta.url));
5732
- const { createServer: createServer2 } = await import("vite");
5733
- this.vite = await createServer2({ configFile });
6016
+ const { createServer: createServer3 } = await import("vite");
6017
+ this.vite = await createServer3({ configFile });
5734
6018
  await this.vite.listen();
5735
6019
  const addr = this.vite.httpServer?.address();
5736
6020
  const port = this.vite.config.server.port || (addr && typeof addr === "object" ? addr.port : 0);
@@ -5948,11 +6232,11 @@ function parseControlPlaneResponse(op, value) {
5948
6232
  // package.json
5949
6233
  var package_default = {
5950
6234
  name: "shotops-mcp",
5951
- version: "0.6.1",
6235
+ version: "0.7.0",
5952
6236
  private: false,
5953
6237
  type: "module",
5954
6238
  description: "The bundle-emitting MCP server over the @engine/@sync spine. Exposes ShotOps tools to ChatGPT, Claude Code, Cursor, and CI over Streamable HTTP with OAuth or personal API tokens. Zero-custody: no code path accepts, stores, or forwards a store-signing credential \u2014 upload stays the user's own fastlane. Renders via headless Playwright Chromium, the engine's native non-browser habitat (same path as mockup-mcp). Also ships as a free LOCAL stdio server (`npx shotops-mcp`) \u2014 same render, on your own machine, no account needed.",
5955
- license: "UNLICENSED",
6239
+ license: "SEE LICENSE IN LICENSE",
5956
6240
  homepage: "https://shotops.dev",
5957
6241
  repository: {
5958
6242
  type: "git",
@@ -5969,7 +6253,8 @@ var package_default = {
5969
6253
  "dist",
5970
6254
  "harness-dist",
5971
6255
  "widget-dist",
5972
- "README.md"
6256
+ "README.md",
6257
+ "THIRD_PARTY_NOTICES"
5973
6258
  ],
5974
6259
  engines: {
5975
6260
  node: ">=20.12"
@@ -6027,12 +6312,12 @@ var ControlPlaneOperationError = class extends Error {
6027
6312
  this.requiredPlan = typeof body?.requiredPlan === "string" ? body.requiredPlan : null;
6028
6313
  }
6029
6314
  };
6030
- async function callOp(token2, op, args = {}) {
6315
+ async function callOp(token, op, args = {}) {
6031
6316
  const res = await fetch(controlPlaneUrl(), {
6032
6317
  method: "POST",
6033
6318
  headers: {
6034
6319
  "content-type": "application/json",
6035
- Authorization: `Bearer ${token2}`,
6320
+ Authorization: `Bearer ${token}`,
6036
6321
  [SHOTOPS_CLIENT_HEADER]: `shotops-mcp/${VERSION}`
6037
6322
  },
6038
6323
  body: JSON.stringify({ op, ...args })
@@ -6066,58 +6351,58 @@ function localScreenPaths(screenshots2) {
6066
6351
  }
6067
6352
  return out;
6068
6353
  }
6069
- function connectHostedBridge(token2) {
6354
+ function connectHostedBridge(token) {
6070
6355
  return {
6071
6356
  async resolveProject(_userId, projectId) {
6072
- const res = await callOp(token2, "resolve_project", projectId ? { project: projectId } : {});
6357
+ const res = await callOp(token, "resolve_project", projectId ? { project: projectId } : {});
6073
6358
  if (res.project) return res.project;
6074
6359
  return typeof res.reason === "string" ? res : null;
6075
6360
  },
6076
6361
  async readLook(projectId) {
6077
- const res = await callOp(token2, "read_look", { project: projectId });
6362
+ const res = await callOp(token, "read_look", { project: projectId });
6078
6363
  return res.look;
6079
6364
  },
6080
6365
  async readVersion(projectId, version2) {
6081
- const res = await callOp(token2, "read_version", { project: projectId, version: version2 });
6366
+ const res = await callOp(token, "read_version", { project: projectId, version: version2 });
6082
6367
  return res.version;
6083
6368
  },
6084
6369
  async listVersions(projectId) {
6085
- const res = await callOp(token2, "list_versions", { project: projectId });
6370
+ const res = await callOp(token, "list_versions", { project: projectId });
6086
6371
  return res.versions;
6087
6372
  },
6088
6373
  async setHeldVersion(projectId, version2) {
6089
- await callOp(token2, "set_held_version", { project: projectId, version: version2 });
6374
+ await callOp(token, "set_held_version", { project: projectId, version: version2 });
6090
6375
  },
6091
6376
  async readProjectRecord(projectId) {
6092
- const res = await callOp(token2, "read_project_record", { project: projectId });
6377
+ const res = await callOp(token, "read_project_record", { project: projectId });
6093
6378
  return res.record;
6094
6379
  },
6095
6380
  async readProjectScreens(projectId) {
6096
- const res = await callOp(token2, "read_project_screens", { project: projectId });
6381
+ const res = await callOp(token, "read_project_screens", { project: projectId });
6097
6382
  const screens = res.screens;
6098
6383
  const untrusted = res.untrusted;
6099
6384
  return { screens, ...untrusted && untrusted.length > 0 ? { untrusted } : {} };
6100
6385
  },
6101
6386
  async saveLook(projectId, look2, sourceName) {
6102
- const res = await callOp(token2, "save_look", { project: projectId, look: look2, ...sourceName ? { sourceName } : {} });
6387
+ const res = await callOp(token, "save_look", { project: projectId, look: look2, ...sourceName ? { sourceName } : {} });
6103
6388
  return { version: res.version };
6104
6389
  },
6105
6390
  async createProject(_userId, name, record) {
6106
- const res = await callOp(token2, "create_project", { name, record });
6391
+ const res = await callOp(token, "create_project", { name, record });
6107
6392
  return { id: res.id, recordVersion: res.recordVersion };
6108
6393
  },
6109
6394
  async updateProjectRecord(projectId, record, expectedVersion) {
6110
6395
  let version2 = expectedVersion;
6111
6396
  if (version2 === void 0) {
6112
- const current = await callOp(token2, "read_project_record", { project: projectId });
6397
+ const current = await callOp(token, "read_project_record", { project: projectId });
6113
6398
  version2 = current.record?.recordVersion;
6114
6399
  }
6115
6400
  if (version2 === void 0) throw new Error("project record version is unavailable");
6116
- const res = await callOp(token2, "update_project_record", { project: projectId, record, expectedVersion: version2 });
6401
+ const res = await callOp(token, "update_project_record", { project: projectId, record, expectedVersion: version2 });
6117
6402
  return { recordVersion: res.recordVersion };
6118
6403
  },
6119
6404
  async mintScreenUploads(projectId, names) {
6120
- const res = await callOp(token2, "mint_screen_uploads", { project: projectId, names });
6405
+ const res = await callOp(token, "mint_screen_uploads", { project: projectId, names });
6121
6406
  return res.slots;
6122
6407
  },
6123
6408
  async close() {
@@ -6263,7 +6548,7 @@ async function createAnonymousProjectClaim(projectName2, record, screenshots2) {
6263
6548
  import { readFile as readFile4 } from "node:fs/promises";
6264
6549
  var SIGN_IN = "not available in local (free) mode \u2014 connect the hosted ShotOps MCP and sign in for saved looks, editable projects, and share links.";
6265
6550
  var SHARE_LINK_NOT_BRIDGED = `Creating a share link is ${SIGN_IN} (bridging this needs a hosted rendering step, which local mode deliberately avoids \u2014 connect directly to the hosted MCP for share links.)`;
6266
- function localToolDeps(token2) {
6551
+ function localToolDeps(token) {
6267
6552
  const base = {
6268
6553
  renderer: new StripRenderer({ serve: "static" }),
6269
6554
  // Token-backed cloud writes cross the same authority in the control plane. This local handler
@@ -6286,7 +6571,7 @@ function localToolDeps(token2) {
6286
6571
  // no Supabase storage locally to upload an `output: "urls"` result to
6287
6572
  studioOrigin: studioOrigin()
6288
6573
  };
6289
- if (!token2) {
6574
+ if (!token) {
6290
6575
  return {
6291
6576
  ...base,
6292
6577
  resolveProject: async () => null,
@@ -6312,7 +6597,7 @@ function localToolDeps(token2) {
6312
6597
  }
6313
6598
  };
6314
6599
  }
6315
- const bridge = connectHostedBridge(token2);
6600
+ const bridge = connectHostedBridge(token);
6316
6601
  return {
6317
6602
  ...base,
6318
6603
  resolveProject: (userId, projectId) => bridge.resolveProject(userId, projectId),
@@ -6417,39 +6702,421 @@ function custodyUnion(...manifests) {
6417
6702
  }));
6418
6703
  }
6419
6704
 
6705
+ // src/localAuth.ts
6706
+ import { createHash, randomBytes as randomBytes2, timingSafeEqual } from "node:crypto";
6707
+ import { spawn } from "node:child_process";
6708
+ import { createServer as createServer2 } from "node:http";
6709
+
6710
+ // src/localCredentials.ts
6711
+ import { chmod, mkdir, readFile as readFile5, rename, unlink, writeFile } from "node:fs/promises";
6712
+ import { homedir } from "node:os";
6713
+ import { dirname as dirname2, join as join3 } from "node:path";
6714
+ var EMPTY2 = { version: 1, origins: {} };
6715
+ function credentialsPath() {
6716
+ const dir = process.env.SHOTOPS_CONFIG_DIR || join3(homedir(), ".shotops");
6717
+ return join3(dir, "credentials.json");
6718
+ }
6719
+ async function readFileJson() {
6720
+ let raw;
6721
+ try {
6722
+ raw = await readFile5(credentialsPath(), "utf8");
6723
+ } catch {
6724
+ return { version: 1, origins: {} };
6725
+ }
6726
+ try {
6727
+ const parsed = JSON.parse(raw);
6728
+ if (!parsed || typeof parsed !== "object" || !parsed.origins) return { ...EMPTY2 };
6729
+ return { version: 1, origins: parsed.origins };
6730
+ } catch {
6731
+ return { ...EMPTY2 };
6732
+ }
6733
+ }
6734
+ async function writeFileJson(next) {
6735
+ const target = credentialsPath();
6736
+ await mkdir(dirname2(target), { recursive: true, mode: 448 });
6737
+ const tmp = `${target}.${process.pid}.tmp`;
6738
+ await writeFile(tmp, `${JSON.stringify(next, null, 2)}
6739
+ `, { mode: 384 });
6740
+ await chmod(tmp, 384);
6741
+ await rename(tmp, target);
6742
+ }
6743
+ async function readCredentials(origin) {
6744
+ const file = await readFileJson();
6745
+ return file.origins[origin] ?? {};
6746
+ }
6747
+ async function saveClient(origin, client) {
6748
+ const file = await readFileJson();
6749
+ file.origins[origin] = { ...file.origins[origin], client };
6750
+ await writeFileJson(file);
6751
+ }
6752
+ async function saveLogin(origin, login) {
6753
+ const file = await readFileJson();
6754
+ file.origins[origin] = { ...file.origins[origin], login };
6755
+ await writeFileJson(file);
6756
+ }
6757
+ async function clearLogin(origin) {
6758
+ const file = await readFileJson();
6759
+ const existing = file.origins[origin]?.login;
6760
+ if (!existing) return void 0;
6761
+ file.origins[origin] = { ...file.origins[origin], login: void 0 };
6762
+ await writeFileJson(file);
6763
+ return existing;
6764
+ }
6765
+ async function resolveToken(origin, explicit) {
6766
+ if (explicit) return explicit;
6767
+ if (process.env.SHOTOPS_TOKEN) return process.env.SHOTOPS_TOKEN;
6768
+ const { login } = await readCredentials(origin);
6769
+ return login?.token;
6770
+ }
6771
+
6772
+ // src/localAuth.ts
6773
+ var LOOPBACK_HOST = "127.0.0.1";
6774
+ var CALLBACK_PATH = "/shotops/callback";
6775
+ var PLACEHOLDER_PORT = 8765;
6776
+ var CLIENT_NAME = "ShotOps CLI";
6777
+ var LOGIN_TIMEOUT_MS = 5 * 60 * 1e3;
6778
+ function authOrigin() {
6779
+ return studioOrigin();
6780
+ }
6781
+ function base64url(buf) {
6782
+ return buf.toString("base64url");
6783
+ }
6784
+ async function discoverEndpoints(origin) {
6785
+ const url = `${origin}/.well-known/oauth-authorization-server`;
6786
+ let res;
6787
+ try {
6788
+ res = await fetch(url);
6789
+ } catch (err) {
6790
+ throw new Error(`could not reach ${origin} (${err.message})`);
6791
+ }
6792
+ if (!res.ok) throw new Error(`${url} answered HTTP ${res.status} \u2014 is ${origin} a ShotOps server?`);
6793
+ const doc = await res.json().catch(() => null);
6794
+ const authorization = typeof doc?.authorization_endpoint === "string" ? doc.authorization_endpoint : void 0;
6795
+ const token = typeof doc?.token_endpoint === "string" ? doc.token_endpoint : void 0;
6796
+ const registration = typeof doc?.registration_endpoint === "string" ? doc.registration_endpoint : void 0;
6797
+ const revocation = typeof doc?.revocation_endpoint === "string" ? doc.revocation_endpoint : void 0;
6798
+ if (!authorization || !token || !registration) {
6799
+ throw new Error(`${url} is missing an authorization, token or registration endpoint`);
6800
+ }
6801
+ const methods = doc?.code_challenge_methods_supported;
6802
+ if (Array.isArray(methods) && !methods.includes("S256")) {
6803
+ throw new Error(`${origin} does not support PKCE S256, which this client requires`);
6804
+ }
6805
+ return { authorization, token, registration, revocation };
6806
+ }
6807
+ async function ensureClient(origin, endpoints) {
6808
+ const existing = (await readCredentials(origin)).client;
6809
+ if (existing && existing.redirectHost === LOOPBACK_HOST && existing.redirectPath === CALLBACK_PATH) {
6810
+ return existing;
6811
+ }
6812
+ const res = await fetch(endpoints.registration, {
6813
+ method: "POST",
6814
+ headers: { "content-type": "application/json" },
6815
+ body: JSON.stringify({
6816
+ client_name: CLIENT_NAME,
6817
+ redirect_uris: [`http://${LOOPBACK_HOST}:${PLACEHOLDER_PORT}${CALLBACK_PATH}`],
6818
+ grant_types: ["authorization_code"],
6819
+ response_types: ["code"],
6820
+ token_endpoint_auth_method: "none"
6821
+ })
6822
+ });
6823
+ const body = await res.json().catch(() => null);
6824
+ if (!res.ok || typeof body?.client_id !== "string") {
6825
+ throw new Error(`registration failed (HTTP ${res.status}${describeOAuthError(body)})`);
6826
+ }
6827
+ const client = {
6828
+ clientId: body.client_id,
6829
+ clientSecret: typeof body.client_secret === "string" ? body.client_secret : void 0,
6830
+ redirectHost: LOOPBACK_HOST,
6831
+ redirectPath: CALLBACK_PATH,
6832
+ registeredAt: (/* @__PURE__ */ new Date()).toISOString()
6833
+ };
6834
+ await saveClient(origin, client);
6835
+ return client;
6836
+ }
6837
+ function describeOAuthError(body) {
6838
+ const code = typeof body?.error === "string" ? body.error : void 0;
6839
+ const detail = typeof body?.error_description === "string" ? body.error_description : void 0;
6840
+ if (!code && !detail) return "";
6841
+ return ` \u2014 ${[code, detail].filter(Boolean).join(": ")}`;
6842
+ }
6843
+ function awaitCallback(expectedState) {
6844
+ return new Promise((resolveServer, rejectServer) => {
6845
+ let settled = false;
6846
+ let resolveResult;
6847
+ let rejectResult;
6848
+ const result = new Promise((res, rej) => {
6849
+ resolveResult = res;
6850
+ rejectResult = rej;
6851
+ });
6852
+ const server = createServer2((req, res) => {
6853
+ const url = new URL(req.url ?? "/", `http://${LOOPBACK_HOST}`);
6854
+ if (url.pathname !== CALLBACK_PATH || settled) {
6855
+ res.writeHead(404, { "content-type": "text/plain" });
6856
+ res.end("not found\n");
6857
+ return;
6858
+ }
6859
+ settled = true;
6860
+ const state = url.searchParams.get("state") ?? "";
6861
+ const code = url.searchParams.get("code");
6862
+ const error = url.searchParams.get("error");
6863
+ const finish = (status, heading, detail) => {
6864
+ res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
6865
+ res.end(page(heading, detail));
6866
+ server.close();
6867
+ };
6868
+ if (!statesMatch(state, expectedState)) {
6869
+ finish(400, "Sign-in rejected", "This response does not belong to the sign-in you started.");
6870
+ rejectResult(new Error("the authorization response did not match this sign-in (state mismatch)"));
6871
+ return;
6872
+ }
6873
+ if (error || !code) {
6874
+ finish(400, "Sign-in failed", error === "access_denied" ? "Access was denied." : "No authorization code came back.");
6875
+ rejectResult(new Error(error ? `authorization failed: ${error}` : "no authorization code was returned"));
6876
+ return;
6877
+ }
6878
+ finish(200, "Signed in to ShotOps", "You can close this tab and go back to your terminal.");
6879
+ resolveResult({ code });
6880
+ });
6881
+ const timer = setTimeout(() => {
6882
+ if (settled) return;
6883
+ settled = true;
6884
+ server.close();
6885
+ rejectResult(new Error(`timed out after ${LOGIN_TIMEOUT_MS / 6e4} minutes waiting for the browser`));
6886
+ }, LOGIN_TIMEOUT_MS);
6887
+ timer.unref?.();
6888
+ void result.catch(() => {
6889
+ }).finally(() => clearTimeout(timer));
6890
+ server.on("error", (err) => rejectServer(err));
6891
+ server.listen(0, LOOPBACK_HOST, () => {
6892
+ const address = server.address();
6893
+ resolveServer({ port: address.port, result });
6894
+ });
6895
+ });
6896
+ }
6897
+ function statesMatch(a, b) {
6898
+ const left = Buffer.from(a);
6899
+ const right = Buffer.from(b);
6900
+ if (left.length !== right.length) return false;
6901
+ return timingSafeEqual(left, right);
6902
+ }
6903
+ function page(heading, detail) {
6904
+ return `<!doctype html><meta charset="utf-8"><title>${heading}</title>
6905
+ <body style="font:16px/1.5 system-ui,sans-serif;margin:0;display:grid;place-items:center;height:100vh">
6906
+ <main style="max-width:28rem;padding:2rem;text-align:center">
6907
+ <h1 style="font-size:1.25rem;margin:0 0 .5rem">${heading}</h1><p style="margin:0;opacity:.7">${detail}</p>
6908
+ </main></body>`;
6909
+ }
6910
+ function openBrowser(url) {
6911
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
6912
+ try {
6913
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
6914
+ child.on("error", () => {
6915
+ });
6916
+ child.unref();
6917
+ } catch {
6918
+ }
6919
+ }
6920
+ async function runLogin(origin = authOrigin(), open = openBrowser) {
6921
+ const endpoints = await discoverEndpoints(origin);
6922
+ const client = await ensureClient(origin, endpoints);
6923
+ const verifier = base64url(randomBytes2(32));
6924
+ const challenge = base64url(createHash("sha256").update(verifier).digest());
6925
+ const state = base64url(randomBytes2(32));
6926
+ const { port, result } = await awaitCallback(state);
6927
+ const redirectUri = `http://${LOOPBACK_HOST}:${port}${CALLBACK_PATH}`;
6928
+ const authorizeUrl = new URL(endpoints.authorization);
6929
+ authorizeUrl.searchParams.set("response_type", "code");
6930
+ authorizeUrl.searchParams.set("client_id", client.clientId);
6931
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
6932
+ authorizeUrl.searchParams.set("code_challenge", challenge);
6933
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
6934
+ authorizeUrl.searchParams.set("state", state);
6935
+ console.log(`Opening your browser to sign in to ShotOps (${origin}).`);
6936
+ console.log(`If nothing opens, paste this into a browser on this machine:
6937
+
6938
+ ${authorizeUrl}
6939
+ `);
6940
+ open(authorizeUrl.href);
6941
+ const { code } = await result;
6942
+ const res = await fetch(endpoints.token, {
6943
+ method: "POST",
6944
+ headers: { "content-type": "application/x-www-form-urlencoded" },
6945
+ body: new URLSearchParams({
6946
+ grant_type: "authorization_code",
6947
+ code,
6948
+ code_verifier: verifier,
6949
+ client_id: client.clientId,
6950
+ redirect_uri: redirectUri
6951
+ })
6952
+ });
6953
+ const body = await res.json().catch(() => null);
6954
+ if (!res.ok || typeof body?.access_token !== "string") {
6955
+ throw new Error(`token exchange failed (HTTP ${res.status}${describeOAuthError(body)})`);
6956
+ }
6957
+ const token = body.access_token;
6958
+ await saveLogin(origin, {
6959
+ token,
6960
+ tokenPrefix: token.slice(0, 16),
6961
+ signedInAt: (/* @__PURE__ */ new Date()).toISOString()
6962
+ });
6963
+ console.log(`Signed in. Token ${token.slice(0, 16)}\u2026 saved for ${origin}.`);
6964
+ console.log("Run `npx shotops-mcp whoami` to see your plan, or start the MCP server as usual.");
6965
+ return 0;
6966
+ }
6967
+ async function runLogout(origin = authOrigin()) {
6968
+ const { client, login } = await readCredentials(origin);
6969
+ if (!login) {
6970
+ console.log(`Not signed in to ${origin}. Nothing to do.`);
6971
+ return 0;
6972
+ }
6973
+ let revoked = false;
6974
+ let revokeDetail = "";
6975
+ try {
6976
+ const endpoints = await discoverEndpoints(origin);
6977
+ if (!endpoints.revocation) throw new Error("this server publishes no revocation endpoint");
6978
+ if (!client) throw new Error("the registration that minted this token is no longer on disk");
6979
+ const params = new URLSearchParams({ token: login.token, client_id: client.clientId });
6980
+ if (client.clientSecret) params.set("client_secret", client.clientSecret);
6981
+ const res = await fetch(endpoints.revocation, {
6982
+ method: "POST",
6983
+ headers: { "content-type": "application/x-www-form-urlencoded" },
6984
+ body: params
6985
+ });
6986
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
6987
+ revoked = true;
6988
+ } catch (err) {
6989
+ revokeDetail = err.message;
6990
+ }
6991
+ await clearLogin(origin);
6992
+ if (revoked) {
6993
+ console.log(`Signed out of ${origin}. The token is revoked server-side and removed from this machine.`);
6994
+ return 0;
6995
+ }
6996
+ console.log(`Removed the saved sign-in for ${origin}, but could NOT revoke it server-side (${revokeDetail}).`);
6997
+ console.log(`That token ${login.tokenPrefix}\u2026 may still work. Revoke it in ShotOps \u2192 Settings \u2192 API tokens.`);
6998
+ return 0;
6999
+ }
7000
+ async function runWhoami(origin = authOrigin(), explicitToken2) {
7001
+ const token = await resolveToken(origin, explicitToken2);
7002
+ const source = explicitToken2 ? "--token argument" : process.env.SHOTOPS_TOKEN ? "SHOTOPS_TOKEN" : "saved browser login";
7003
+ if (!token) {
7004
+ console.log(`Not signed in to ${origin}.`);
7005
+ console.log("Run `npx shotops-mcp login`, or set SHOTOPS_TOKEN for CI.");
7006
+ return 1;
7007
+ }
7008
+ let res;
7009
+ try {
7010
+ res = await fetch(`${origin}/api/billing-entitlement`, {
7011
+ headers: { authorization: `Bearer ${token}` }
7012
+ });
7013
+ } catch (err) {
7014
+ console.error(`Could not reach ${origin} (${err.message}). Your plan is unknown, not Free.`);
7015
+ return 1;
7016
+ }
7017
+ if (res.status === 401) {
7018
+ console.log(`Token ${token.slice(0, 16)}\u2026 (${source}) is not valid \u2014 it was revoked, or it belongs to another server.`);
7019
+ console.log("Run `npx shotops-mcp login` to sign in again.");
7020
+ return 1;
7021
+ }
7022
+ if (!res.ok) {
7023
+ console.error(`${origin} answered HTTP ${res.status}. Your plan is unknown, not Free.`);
7024
+ return 1;
7025
+ }
7026
+ const body = await res.json().catch(() => null);
7027
+ if (!body) {
7028
+ console.error("The server returned an unexpected response. Your plan is unknown, not Free.");
7029
+ return 1;
7030
+ }
7031
+ console.log(`Signed in to ${origin}`);
7032
+ console.log(` token ${token.slice(0, 16)}\u2026 (${source})`);
7033
+ if (typeof body.userId === "string") console.log(` account ${body.userId}`);
7034
+ console.log(` plan ${String(body.plan ?? "unknown")}${body.status ? ` (${String(body.status)})` : ""}`);
7035
+ if (body.reason) console.log(` because ${String(body.reason)}`);
7036
+ if (body.trial?.state) {
7037
+ const ends = typeof body.trial.endsAt === "number" ? ` until ${new Date(body.trial.endsAt).toISOString().slice(0, 10)}` : "";
7038
+ console.log(` trial ${String(body.trial.state)}${ends}`);
7039
+ }
7040
+ if (body.credits && typeof body.credits === "object") {
7041
+ console.log(` credits ${String(body.credits.remaining ?? "?")} of ${String(body.credits.cap ?? "?")}`);
7042
+ } else {
7043
+ console.log(" credits unavailable");
7044
+ }
7045
+ return 0;
7046
+ }
7047
+ var USAGE = `shotops-mcp \u2014 App Store screenshot mockups, rendered on this machine.
7048
+
7049
+ npx shotops-mcp start the MCP server over stdio (what an MCP client runs)
7050
+ npx shotops-mcp login sign in through the browser and save the token
7051
+ npx shotops-mcp logout revoke that token and remove it from this machine
7052
+ npx shotops-mcp whoami show which account is signed in and its plan
7053
+
7054
+ --token <t> use this token instead of a saved login (also SHOTOPS_TOKEN)
7055
+ `;
7056
+ async function dispatchLocalCommand(argv2, explicitToken2) {
7057
+ const [command] = argv2;
7058
+ switch (command) {
7059
+ case "login":
7060
+ return await runLogin();
7061
+ case "logout":
7062
+ return await runLogout();
7063
+ case "whoami":
7064
+ return await runWhoami(authOrigin(), explicitToken2);
7065
+ case "help":
7066
+ case "--help":
7067
+ case "-h":
7068
+ console.log(USAGE);
7069
+ return 0;
7070
+ default:
7071
+ return null;
7072
+ }
7073
+ }
7074
+
6420
7075
  // src/local.ts
6421
- function tokenFromArgv(argv) {
6422
- const eq = argv.find((a) => a.startsWith("--token="));
7076
+ function tokenFromArgv(argv2) {
7077
+ const eq = argv2.find((a) => a.startsWith("--token="));
6423
7078
  if (eq) return eq.slice("--token=".length);
6424
- const idx = argv.indexOf("--token");
6425
- return idx !== -1 ? argv[idx + 1] : void 0;
7079
+ const idx = argv2.indexOf("--token");
7080
+ return idx !== -1 ? argv2[idx + 1] : void 0;
6426
7081
  }
6427
- var token = tokenFromArgv(process.argv.slice(2)) || process.env.SHOTOPS_TOKEN || void 0;
6428
- var deps = localToolDeps(token);
6429
7082
  var LOCAL_INTAKE = 'request_screenshot_upload is hosted-only; import_screenshot accepts { "path": "/abs/shot.png" } entries here (its { "url" } / { "file" } sources need a hosted account). Either way you can pass local files straight to render_strip / emit_bundle as { "path": "..." } screenshot entries.';
6430
- var localInstructions = token ? `${SERVER_INSTRUCTIONS}
7083
+ async function startStdioServer(token) {
7084
+ const deps = localToolDeps(token);
7085
+ const localInstructions = token ? `${SERVER_INSTRUCTIONS}
6431
7086
 
6432
7087
  ## Local mode (this connection)
6433
7088
  Rendering runs on THIS machine \u2014 free, no timeout ceiling. A hosted token is configured, so save_project / read_look / save_look also work (they read/write your hosted ShotOps account); ${LOCAL_INTAKE}` : `${SERVER_INSTRUCTIONS}
6434
7089
 
6435
7090
  ## Local mode (this connection)
6436
7091
  Rendering runs on THIS machine \u2014 free, no timeout ceiling. No hosted token is configured. A NEW save_project with local { "path": "..." } PNGs deliberately uploads one temporary private handoff and returns status:"pending_claim" + an openUrl: ALWAYS give that URL to the user. The user owns the project only after opening it and signing in within 7 days; no projectId exists before then. Passing an existing project requires SHOTOPS_TOKEN and is refused here. Ordinary render_strip / emit_bundle calls upload NOTHING, and an explicit request not to save means do not call save_project. read_look / save_look / read_project / render_project / share links still require an account connection. ${LOCAL_INTAKE}`;
6437
- var server = new McpServer(
6438
- // #335 — the PACKAGE version, deliberately, not app.ts's deployed-build identity: this tier IS
6439
- // the npm package a user installed with `npx shotops-mcp`, and it has no deploy. Its contract
6440
- // moves only when the version they installed moves, so VERSION is already the honest answer.
6441
- { name: "shotops-mcp-local", version: VERSION },
6442
- { instructions: localInstructions }
6443
- );
6444
- registerTools(server, { userId: "local", ...deps });
6445
- async function shutdown() {
6446
- await deps.renderer.close();
6447
- await deps.closeBridge?.();
6448
- process.exit(0);
6449
- }
6450
- process.on("SIGINT", () => void shutdown());
6451
- process.on("SIGTERM", () => void shutdown());
6452
- await server.connect(new StdioServerTransport());
6453
- console.error(
6454
- `[shotops-mcp-local] connected over stdio \u2014 renders run on this machine.${token ? " (hosted bridge configured)" : ""}`
6455
- );
7092
+ const server = new McpServer(
7093
+ // #335 — the PACKAGE version, deliberately, not app.ts's deployed-build identity: this tier IS
7094
+ // the npm package a user installed with `npx shotops-mcp`, and it has no deploy. Its contract
7095
+ // moves only when the version they installed moves, so VERSION is already the honest answer.
7096
+ { name: "shotops-mcp-local", version: VERSION },
7097
+ { instructions: localInstructions }
7098
+ );
7099
+ registerTools(server, { userId: "local", ...deps });
7100
+ async function shutdown() {
7101
+ await deps.renderer.close();
7102
+ await deps.closeBridge?.();
7103
+ process.exit(0);
7104
+ }
7105
+ process.on("SIGINT", () => void shutdown());
7106
+ process.on("SIGTERM", () => void shutdown());
7107
+ await server.connect(new StdioServerTransport());
7108
+ console.error(
7109
+ `[shotops-mcp-local] connected over stdio \u2014 renders run on this machine.${token ? " (hosted bridge configured)" : ""}`
7110
+ );
7111
+ }
7112
+ var argv = process.argv.slice(2);
7113
+ var explicitToken = tokenFromArgv(argv);
7114
+ var commandExit = await dispatchLocalCommand(argv, explicitToken).catch((err) => {
7115
+ console.error(`shotops-mcp: ${err.message}`);
7116
+ return 1;
7117
+ });
7118
+ if (commandExit !== null) {
7119
+ process.exitCode = commandExit;
7120
+ } else {
7121
+ await startStdioServer(await resolveToken(authOrigin(), explicitToken));
7122
+ }