hermoso 0.1.7 → 0.1.9

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/bin/hermoso.mjs CHANGED
@@ -4,14 +4,14 @@
4
4
  // in skills/ wrap these commands. Same /api as the MCP server.
5
5
  //
6
6
  // npm i -g (from this repo) OR node bin/hermoso.mjs <cmd>
7
- // hermoso auth login --token <key> # key from app.hermoso.ai Settings Agents & API
7
+ // hermoso auth login # browser sign-in (loopback, like gh/heroku); --token <key> for CI
8
8
  // hermoso capabilities # learn valid model ids + costs (run first)
9
9
  // hermoso create --brand Flourish --product "protein pancakes" --format image
10
10
  // hermoso generate image --prompt "…" --ref ./bag.png --wait
11
11
  //
12
12
  // Auth today: none locally (the server resolves the dev account). `hermoso auth login --token <t>` stores a Bearer
13
13
  // for when real auth lands — the seam, not a requirement.
14
- import { readFile, writeFile, mkdir } from 'node:fs/promises';
14
+ import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises';
15
15
  import os from 'node:os';
16
16
  import path from 'node:path';
17
17
 
@@ -19,7 +19,15 @@ const CONFIG_DIR = path.join(os.homedir(), '.hermoso');
19
19
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
20
20
 
21
21
  async function loadConfig() { try { return JSON.parse(await readFile(CONFIG_FILE, 'utf8')); } catch { return {}; } }
22
- async function saveConfig(c) { await mkdir(CONFIG_DIR, { recursive: true }); await writeFile(CONFIG_FILE, JSON.stringify(c, null, 2)); }
22
+ // config.json holds a long-lived hmk_ account bearer (full billing/spend authority) write it OWNER-ONLY (dir 0700,
23
+ // file 0600) so a co-tenant on a shared machine can't read the key and spend the victim's credits (aws/gh/docker do the
24
+ // same). mode on mkdir/writeFile is pre-umask, so also chmod after in case an existing dir/file kept looser perms.
25
+ async function saveConfig(c) {
26
+ await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
27
+ try { await chmod(CONFIG_DIR, 0o700); } catch {}
28
+ await writeFile(CONFIG_FILE, JSON.stringify(c, null, 2), { mode: 0o600 });
29
+ try { await chmod(CONFIG_FILE, 0o600); } catch {}
30
+ }
23
31
 
24
32
  // ---- minimal arg parser: positionals + --flags (--flag value | --flag=value | boolean --flag) ----
25
33
  function parse(argv) {
package/mcp/tools.mjs CHANGED
@@ -8,7 +8,16 @@ import { apiGet, apiPost, apiPut, apiSSE, submitJob, getJob, jobResult, pollJob,
8
8
 
9
9
  const JOB_TIMEOUT = +(process.env.HERMOSO_JOB_TIMEOUT_MS || 10 * 60 * 1000);
10
10
  const abs = (u) => (u && u.startsWith('/') ? API_BASE + u : u); // /generated/x.mp4 → clickable absolute URL
11
- const ok = (text, data) => ({ content: [{ type: 'text', text }], structuredContent: data ?? {} });
11
+ // Null-valued keys are STRIPPED from structuredContent (2026-07-20): the SDK validates results against outputSchema
12
+ // server-side, and zod .optional() rejects null — a single null field (e.g. editCredits:null on a key-less deploy)
13
+ // bricked the whole tool result with a protocol-level validation error. Every field in our schemas is optional, so
14
+ // absent is always valid; array ELEMENTS are kept as-is (dropping them would shift indices).
15
+ // Quote tokens MINTED THIS PROCESS (buy_credits): possession of a well-formed string must not authorize a charge —
16
+ // only a token this server actually issued in a quote turn does. Process-local by design (a restart invalidates
17
+ // outstanding quotes → the agent simply re-quotes; no charge can slip through).
18
+ const _mintedQuotes = new Set();
19
+ const stripNulls = (v) => { if (Array.isArray(v)) return v.map(stripNulls); if (v && typeof v === 'object') { const o = {}; for (const [k, x] of Object.entries(v)) { if (x !== null) o[k] = stripNulls(x); } return o; } return v; };
20
+ const ok = (text, data) => ({ content: [{ type: 'text', text }], structuredContent: data == null ? {} : stripNulls(data) });
12
21
  // Video-return variant: attaches the clip's first frame as an inline image block (Claude can't play mp4 in chat,
13
22
  // but a poster makes the result VISIBLE, mirroring generate_image). Falls back to plain ok() when frames fail.
14
23
  const stillMsg = (r) => `Still rendering — job ${r.jobId}. This is NORMAL: video renders take 1–3 minutes and each get_job call waits up to ~45s, so it can take several calls. Keep calling get_job with this id until status is done or error — do NOT ask the user whether to keep waiting, and do NOT re-fire the render on another model (that double-charges). Only surface a problem after ~6 minutes of polling.`;
@@ -36,7 +45,7 @@ export const MCP_INSTRUCTIONS = [
36
45
  '• CREATE (finished ads): draft_brand → plan_ad → render_ad (Studio quality pipeline) or generate_image / generate_video / generate_avatar; make_template_ad (native HTML formats); remix_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / stitch_video; plan_variations + score_ad.',
37
46
  '• RAW MODEL PLAYGROUND: generate_image / generate_video (useBrand:false) for prompt-only renders, generate_voice for text-to-speech, generate_text for the writing models — against any of 30+ image / video / voice / writing model ids (exact costs in hermoso_capabilities), no ad framing.',
38
47
  '• ACCOUNT: hermoso_credits, billing_status, buy_credits (one-click top-up / first-purchase link), upgrade_plan / set_auto_reload (admin), list_jobs / get_job.',
39
- 'No anonymous spend — tools/call needs a bearer. Out of credits → buy_credits: with a saved card + admin rights it one-click charges after an explicit confirm:true (state the exact price first); the FIRST purchase is a Stripe link your human pays, which saves the card. Always report the final media URL to the user.',
48
+ 'No anonymous spend — tools/call needs a bearer. Out of credits → buy_credits: with a saved card + admin rights it one-click charges after an explicit confirm:true + the returned quote_token (state the exact price first); the FIRST purchase is a Stripe link your human pays, which saves the card. Always report the final media URL to the user.',
40
49
  ].join('\n');
41
50
  // Inline the finished image so Claude RENDERS it in chat instead of just linking it (MCP image content block).
42
51
  // Skipped silently for huge files / fetch errors — the URL in the text always works.
@@ -65,7 +74,7 @@ const wrap = (fn) => async (args, extra) => {
65
74
  catch (e) {
66
75
  let msg = `Error: ${e?.message || e}`;
67
76
  // credit outages need an actionable path the agent can relay — the web app has a top-up gate; here the URL is it
68
- if (/not enough credits/i.test(msg)) msg += `\nRun buy_credits to top up (credit packs): with a saved card it quotes then one-click charges on confirm:true; with no card yet it returns a checkout link your human pays once (the card saves for one-click after). billing_status shows your balance, plan + billing role; if you're an admin, upgrade_plan moves to a bigger monthly plan (a person pays on Stripe). hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.`;
77
+ if (/not enough credits/i.test(msg)) msg += `\nRun buy_credits to top up (credit packs): with a saved card it quotes (quoteToken included) then one-click charges on confirm:true + quote_token; with no card yet it returns a checkout link your human pays once (the card saves for one-click after). billing_status shows your balance, plan + billing role; if you're an admin, upgrade_plan moves to a bigger monthly plan (a person pays on Stripe). hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.`;
69
78
  return { content: [{ type: 'text', text: msg }], isError: true };
70
79
  }
71
80
  };
@@ -96,7 +105,160 @@ const JOB_OUT = {
96
105
  stillRendering: z.boolean().optional().describe('true when the render is still in progress — keep polling get_job with jobId'),
97
106
  };
98
107
 
108
+ // ── ChatGPT Apps SDK components (ADDITIVE — Claude/Cursor/other clients ignore extra _meta + ui:// resources) ──
109
+ // Contract pinned from developers.openai.com/apps-sdk on 2026-07-19 (see docs/apps-sdk-notes.md):
110
+ // • a tool declares its widget via tool _meta['openai/outputTemplate'] = 'ui://widget/<name>.html'
111
+ // • that URI is a normal MCP resource with mimeType 'text/html+skybridge' (self-contained HTML+inline JS,
112
+ // runs in ChatGPT's sandboxed skybridge iframe)
113
+ // • the widget reads the tool's structuredContent from window.openai.toolOutput and re-renders on the
114
+ // 'openai:set_globals' window event; setWidgetState persists small UI state across re-renders
115
+ // • every host the iframe loads media from must be allowlisted in resource _meta['openai/widgetCSP']
116
+ const UI_MIME = 'text/html+skybridge';
117
+ const AD_RESULT_URI = 'ui://widget/ad-result.html';
118
+ const CAPABILITIES_URI = 'ui://widget/capabilities.html';
119
+ // Where the widgets' <img>/<video> srcs live: served app media + the R2 asset origins (GEN_PUBLIC_BASE/R2_PUBLIC_BASE).
120
+ const WIDGET_CSP = { connect_domains: [], resource_domains: ['https://app.hermoso.ai', 'https://assets.hermoso.ai', 'https://*.r2.dev'] };
121
+ const openaiMeta = (template, invoking, invoked) => ({ 'openai/outputTemplate': template, 'openai/toolInvocation/invoking': invoking, 'openai/toolInvocation/invoked': invoked }); // status strings ≤64 chars
122
+
123
+ // String.raw so regex backslashes inside the inline widget JS survive the template literal (no ${} used).
124
+ const AD_RESULT_HTML = String.raw`<div id="root"></div>
125
+ <style>
126
+ :root { color-scheme: light dark; }
127
+ * { box-sizing: border-box; margin: 0; padding: 0; }
128
+ #root { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; color: #16181c; }
129
+ @media (prefers-color-scheme: dark) { #root { color: #ececf1; } }
130
+ .card { max-width: 520px; border: 1px solid rgba(128,128,128,.28); border-radius: 14px; overflow: hidden; background: rgba(128,128,128,.05); }
131
+ .media img, .media video { display: block; width: 100%; height: auto; max-height: 72vh; object-fit: contain; background: rgba(0,0,0,.85); }
132
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 4px; padding: 4px; background: rgba(0,0,0,.85); }
133
+ .grid img { width: 100%; height: auto; display: block; border-radius: 8px; }
134
+ .meta { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 9px 12px; font-size: 12.5px; }
135
+ .pill { border: 1px solid rgba(128,128,128,.35); border-radius: 999px; padding: 2px 9px; opacity: .85; }
136
+ .spacer { flex: 1; }
137
+ .wordmark { font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; opacity: .4; }
138
+ .empty { padding: 18px 16px; font-size: 13px; opacity: .75; }
139
+ </style>
140
+ <script>
141
+ (function () {
142
+ var root = document.getElementById('root');
143
+ function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; }); }
144
+ function looksVideo(u) { return /\.(mp4|webm|mov|m4v)([?#]|$)/i.test(String(u || '')); }
145
+ function render() {
146
+ var out = (window.openai && window.openai.toolOutput) || {};
147
+ var raw = out.raw || {};
148
+ var pills = '';
149
+ if (out.model) pills += '<span class="pill">' + esc(out.model) + '</span>';
150
+ var credits = out.creditsUsed != null ? out.creditsUsed : (out.credits != null ? out.credits : raw.creditsUsed);
151
+ if (credits != null) pills += '<span class="pill">' + esc(credits) + ' credits</span>';
152
+ var footer = '<div class="meta">' + pills + '<span class="spacer"></span><span class="wordmark">Hermoso</span></div>';
153
+ var slides = Array.isArray(raw.images) && raw.images.length ? raw.images : null;
154
+ var vid = out.video || raw.video || null;
155
+ var img = out.image || raw.image || null;
156
+ var any = out.url || raw.url || null;
157
+ if (!vid && !img && any) { if (looksVideo(any)) { vid = any; } else { img = any; } }
158
+ var body;
159
+ if (out.stillRendering) body = '<div class="empty">Still rendering' + (out.jobId ? ' — job ' + esc(out.jobId) : '') + '. Video renders take 1–3 minutes; the finished ad appears here.</div>';
160
+ else if (slides) body = '<div class="grid">' + slides.map(function (u) { return '<img src="' + esc(u) + '" alt="carousel slide" loading="lazy">'; }).join('') + '</div>';
161
+ else if (vid) body = '<div class="media"><video controls muted autoplay loop playsinline preload="metadata" src="' + esc(vid) + '"></video></div>';
162
+ else if (img) body = '<div class="media"><img src="' + esc(img) + '" alt="generated ad"></div>';
163
+ else body = '<div class="empty">No media in this result yet.</div>';
164
+ root.innerHTML = '<div class="card">' + body + footer + '</div>';
165
+ }
166
+ render();
167
+ window.addEventListener('openai:set_globals', render);
168
+ })();
169
+ </script>`;
170
+
171
+ const CAPABILITIES_HTML = String.raw`<div id="root"></div>
172
+ <style>
173
+ :root { color-scheme: light dark; }
174
+ * { box-sizing: border-box; margin: 0; padding: 0; }
175
+ #root { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; color: #16181c; font-size: 13px; max-width: 560px; }
176
+ @media (prefers-color-scheme: dark) { #root { color: #ececf1; } }
177
+ .bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; padding: 4px 0 10px; }
178
+ .chip { font: inherit; color: inherit; background: transparent; border: 1px solid rgba(128,128,128,.4); border-radius: 999px; padding: 3px 11px; cursor: pointer; opacity: .75; }
179
+ .chip.on { opacity: 1; border-color: currentColor; font-weight: 600; }
180
+ .q { font: inherit; color: inherit; background: rgba(128,128,128,.1); border: 1px solid rgba(128,128,128,.3); border-radius: 8px; padding: 4px 9px; flex: 1; min-width: 120px; }
181
+ .list { border: 1px solid rgba(128,128,128,.25); border-radius: 12px; overflow: hidden; }
182
+ .row { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; padding: 8px 12px; }
183
+ .row + .row { border-top: 1px solid rgba(128,128,128,.18); }
184
+ .mid { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; font-weight: 600; }
185
+ .lbl { opacity: .65; font-size: 12px; }
186
+ .right { margin-left: auto; display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: baseline; }
187
+ .kind { font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; opacity: .55; }
188
+ .badge { font-size: 10.5px; border: 1px solid rgba(128,128,128,.35); border-radius: 999px; padding: 1px 7px; opacity: .8; }
189
+ .cost { font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
190
+ .none { padding: 16px; opacity: .7; }
191
+ .foot { display: flex; justify-content: space-between; padding: 8px 2px 2px; font-size: 11.5px; opacity: .55; }
192
+ .wordmark { font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
193
+ </style>
194
+ <script>
195
+ (function () {
196
+ var root = document.getElementById('root');
197
+ var saved = (window.openai && window.openai.widgetState) || {};
198
+ var filter = saved.filter || 'all';
199
+ var q = saved.q || '';
200
+ var KINDS = ['all', 'image', 'video', 'voice', 'writing'];
201
+ function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; }); }
202
+ function items() {
203
+ var out = (window.openai && window.openai.toolOutput) || {};
204
+ var opt = out.options || {};
205
+ var list = [];
206
+ ((opt.image && opt.image.models) || []).forEach(function (m) {
207
+ list.push({ kind: 'image', id: m.id, label: m.label || '', cost: m.credits != null ? m.credits + ' cr' : '', badges: [m.best ? 'best' : '', m.hiRes ? '2K' : '', m.refs && m.refs.max ? 'refs ≤' + m.refs.max : ''] });
208
+ });
209
+ ((opt.video && opt.video.models) || []).forEach(function (m) {
210
+ var cost = (m.durations || []).map(function (d) { var c = m.credits && m.credits[d]; return d + 's=' + (c == null ? '?' : c) + 'cr'; }).join(' · ');
211
+ list.push({ kind: 'video', id: m.id, label: m.label || '', cost: cost, badges: [m.best ? 'best' : '', m.audio ? 'audio' : 'silent', m.refs && m.refs.required ? 'image-to-video' : ''] });
212
+ });
213
+ ((opt.voice && opt.voice.engines) || []).forEach(function (e) {
214
+ list.push({ kind: 'voice', id: e.id, label: e.label || '', cost: e.creditsPer1k != null ? e.creditsPer1k + ' cr/1k chars' : '', badges: [(e.voices || []).length ? e.voices.length + ' voices' : ''] });
215
+ });
216
+ ((opt.llm && opt.llm.models) || []).forEach(function (m) {
217
+ list.push({ kind: 'writing', id: m.id, label: m.label || '', cost: m.credits != null ? m.credits + ' cr' : '', badges: [] });
218
+ });
219
+ return list;
220
+ }
221
+ function row(it) {
222
+ var badges = it.badges.filter(Boolean).map(function (b) { return '<span class="badge">' + esc(b) + '</span>'; }).join('');
223
+ return '<div class="row"><span class="mid">' + esc(it.id) + '</span><span class="lbl">' + esc(it.label) + '</span><span class="right"><span class="kind">' + esc(it.kind) + '</span>' + badges + '<span class="cost">' + esc(it.cost) + '</span></span></div>';
224
+ }
225
+ function persist() { try { if (window.openai && window.openai.setWidgetState) window.openai.setWidgetState({ filter: filter, q: q }); } catch (e) {} }
226
+ function list() {
227
+ Array.prototype.forEach.call(root.querySelectorAll('.chip'), function (b) { b.classList.toggle('on', b.getAttribute('data-k') === filter); });
228
+ var all = items();
229
+ var needle = q.toLowerCase();
230
+ var vis = all.filter(function (it) { return (filter === 'all' || it.kind === filter) && (!needle || (it.id + ' ' + it.label).toLowerCase().indexOf(needle) >= 0); });
231
+ document.getElementById('list').innerHTML = vis.map(row).join('') || '<div class="none">No matching models.</div>';
232
+ document.getElementById('count').textContent = vis.length + ' of ' + all.length + ' models · costs in Hermoso credits';
233
+ }
234
+ function shell() {
235
+ var chips = KINDS.map(function (k) { return '<button type="button" class="chip" data-k="' + k + '">' + k.charAt(0).toUpperCase() + k.slice(1) + '</button>'; }).join('');
236
+ root.innerHTML = '<div class="bar">' + chips + '<input id="q" class="q" type="search" placeholder="Filter models…"></div><div id="list" class="list"></div><div class="foot"><span id="count"></span><span class="wordmark">Hermoso</span></div>';
237
+ var inp = document.getElementById('q');
238
+ inp.value = q;
239
+ inp.addEventListener('input', function () { q = inp.value; persist(); list(); });
240
+ Array.prototype.forEach.call(root.querySelectorAll('.chip'), function (b) {
241
+ b.addEventListener('click', function () { filter = b.getAttribute('data-k'); persist(); list(); });
242
+ });
243
+ list();
244
+ }
245
+ shell();
246
+ window.addEventListener('openai:set_globals', list);
247
+ })();
248
+ </script>`;
249
+
250
+ function registerAppResources(server) {
251
+ const reg = (name, uri, description, html) => {
252
+ const meta = { 'openai/widgetDescription': description, 'openai/widgetPrefersBorder': true, 'openai/widgetCSP': WIDGET_CSP };
253
+ server.registerResource(name, uri, { description, mimeType: UI_MIME, _meta: meta },
254
+ async () => ({ contents: [{ uri, mimeType: UI_MIME, text: html, _meta: meta }] }));
255
+ };
256
+ reg('hermoso-ad-result', AD_RESULT_URI, 'Shows the finished Hermoso ad — the image, auto-playing video, or carousel — with the model that rendered it and credits spent.', AD_RESULT_HTML);
257
+ reg('hermoso-capabilities', CAPABILITIES_URI, 'Browsable Hermoso model catalog: image/video/voice/writing models with exact per-render credit costs and a filter row.', CAPABILITIES_HTML);
258
+ }
259
+
99
260
  export function registerTools(server) {
261
+ registerAppResources(server); // ChatGPT Apps SDK widget templates — inert decoration for every other client
100
262
  // ---------- read-only / discovery ----------
101
263
  server.registerTool('hermoso_capabilities', {
102
264
  title: 'Hermoso capabilities',
@@ -107,11 +269,12 @@ export function registerTools(server) {
107
269
  canEdit: z.boolean().optional().describe('whether image editing is enabled on this account'),
108
270
  canAvatar: z.boolean().optional().describe('whether talking-avatar generation is enabled'),
109
271
  canPublish: z.boolean().optional().describe('whether ad publishing is enabled'),
110
- editCredits: z.number().optional().describe('credit cost of one image edit'),
272
+ editCredits: z.number().nullable().optional().describe('credit cost of one image edit (null when image editing is not configured)'),
111
273
  options: z.any().optional().describe('the live model catalog — image/video/voice/llm model lists with per-model credit costs'),
112
274
  recipes: z.array(z.any()).optional().describe('the creative recipe catalog (id + label per recipe)'),
113
275
  },
114
276
  annotations: { readOnlyHint: true, openWorldHint: false },
277
+ _meta: openaiMeta(CAPABILITIES_URI, 'Loading the model catalog…', 'Model catalog ready'),
115
278
  }, wrap(async () => {
116
279
  const d = await apiGet('/api/generate/status');
117
280
  const img = (d.options?.image?.models || []).map(m => `${m.id} (${m.label}, ${m.credits}cr${m.refs ? `, ≤${m.refs.max} reference images` : ''}${m.hiRes ? ', 2K' : ''}${m.best ? ', best' : ''})`).join('; ');
@@ -148,10 +311,11 @@ export function registerTools(server) {
148
311
  // the human pays once — that card then saves for one-click forever. Packs only — subscriptions are in-app.
149
312
  server.registerTool('buy_credits', {
150
313
  title: 'Buy credits',
151
- description: "Out of credits? Top up with a credit PACK. Call with no argument to list the available packs (id · credits · price). If the account has a saved card and you have billing-admin rights, calling with `pack` quotes the exact charge and calling again with confirm:true charges the saved card instantly (same one-click top-up as the app — no redirect). If there's no saved card yet, you get a Stripe checkout URL to hand your human for the FIRST purchase; their card saves for one-click after that. Packs only; subscriptions are managed by a person in Settings → Billing.",
314
+ description: "Out of credits? Top up with a credit PACK. Call with no argument to list the available packs (id · credits · price). If the account has a saved card and you have billing-admin rights, calling with `pack` quotes the exact charge and calling again with confirm:true AND the quote's quote_token charges the saved card instantly (same one-click top-up as the app — no redirect). If there's no saved card yet, you get a Stripe checkout URL to hand your human for the FIRST purchase; their card saves for one-click after that. Packs only; subscriptions are managed by a person in Settings → Billing.",
152
315
  inputSchema: {
153
316
  pack: z.string().optional().describe('the pack id to buy (e.g. pack-2k) — omit to list the available packs first'),
154
317
  confirm: z.boolean().optional().describe('set true to actually charge the saved card for `pack` (required for the one-click charge; ignored on the checkout-link path)'),
318
+ quote_token: z.string().optional().describe('the quoteToken returned by the quote step — REQUIRED (with confirm:true) to charge; it binds the exact pack + price you quoted (10-minute validity) and makes a retried confirm idempotent'),
155
319
  },
156
320
  outputSchema: {
157
321
  packs: z.array(z.any()).optional().describe('available credit packs ({id, credits, priceUsd}) when listing'),
@@ -163,7 +327,7 @@ export function registerTools(server) {
163
327
  packId: z.string().optional().describe('the pack id the checkout link buys'),
164
328
  },
165
329
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, // confirm:true charges the saved card (one-click top-up); link path charges nothing
166
- }, wrap(async ({ pack, confirm }) => {
330
+ }, wrap(async ({ pack, confirm, quote_token }) => {
167
331
  const cfg = await apiGet('/api/billing/config');
168
332
  const packs = (cfg.packs || []).map(p => ({ id: p.id, credits: p.credits, priceUsd: p.priceUsd }));
169
333
  if (!pack) {
@@ -176,8 +340,28 @@ export function registerTools(server) {
176
340
  try { st = await apiGet('/api/billing/status'); } catch {}
177
341
  if (st?.paymentMethodOnFile && st?.isAdmin) {
178
342
  const card = st.card ? `${st.card.brand} ····${st.card.last4}` : 'the saved card';
179
- if (!confirm) return ok(`Ready to charge ${card} $${match.priceUsd} for ${match.credits.toLocaleString()} credits (one-click, no redirect — same as the app's Add credits button). Confirm with your human if they haven't already asked for this, then call buy_credits again with pack="${match.id}" and confirm:true.`, { quote: { packId: match.id, credits: match.credits, priceUsd: match.priceUsd, card: st.card || null } });
180
- const d = await apiPost('/api/billing/topup', { packId: match.id, idempotencyKey: (globalThis.crypto?.randomUUID?.() || String(Date.now())) });
343
+ // QUOTE-TOKEN CONTRACT (2026-07-20): the quote mints a token binding {pack, price, 10-min expiry}; the charge
344
+ // REQUIRES it, enforced here in tool code where the agent can't route around it. (1) confirm:true on a FIRST
345
+ // call can never move money — a prompt-injected agent is forced through a user-visible quote turn; (2) the token
346
+ // doubles as the Stripe idempotency key (server builds tp:<account>:<key>), so a lost-response retry of the SAME
347
+ // confirm dedups at Stripe instead of double-charging (the app's fix #15 class — the old fresh-UUID-per-call
348
+ // re-introduced it); (3) the LIVE price is re-checked at charge time — a catalog change between quote and
349
+ // confirm re-quotes instead of silently charging a price the human never saw. '|' separator: pack prices can
350
+ // carry decimals, so '.' would split wrong.
351
+ const mintQuote = () => {
352
+ const t = `qt1|${match.id}|${match.priceUsd}|${Math.floor(Date.now() / 1000) + 600}|${(globalThis.crypto?.randomUUID?.() || String(Date.now())).slice(0, 8)}`;
353
+ _mintedQuotes.add(t); if (_mintedQuotes.size > 50) _mintedQuotes.delete(_mintedQuotes.values().next().value); // bounded
354
+ return ok(`Ready to charge ${card} $${match.priceUsd} for ${match.credits.toLocaleString()} credits (one-click, no redirect — same as the app's Add credits button). Confirm with your human if they haven't already asked for this, then call buy_credits again with pack="${match.id}", confirm:true and quote_token="${t}" (valid 10 minutes).`, { quote: { packId: match.id, credits: match.credits, priceUsd: match.priceUsd, card: st.card || null, quoteToken: t, expiresInSeconds: 600 } });
355
+ };
356
+ if (!confirm || !quote_token) return mintQuote();
357
+ const qp = String(quote_token).split('|');
358
+ if (!_mintedQuotes.has(String(quote_token)) || qp[0] !== 'qt1' || qp.length < 5 || qp[1] !== match.id || Number(qp[3]) < Math.floor(Date.now() / 1000) || Number(qp[2]) !== match.priceUsd) return mintQuote(); // UNMINTED (forged/other-process — c3d6081 review: format-only validation was trivially forgeable, the token MUST come from a real quote in THIS process; a restart just re-quotes) / expired / wrong-pack / price-moved → fresh quote, never a surprise charge
359
+ let d;
360
+ try { d = await apiPost('/api/billing/topup', { packId: match.id, idempotencyKey: String(quote_token), expectedPriceUsd: match.priceUsd }); } // expectedPriceUsd: server-side price binding (409s if the catalog moved under the quote)
361
+ catch (e) {
362
+ if (e?.status === 403) return ok(`This key doesn't have billing-admin rights on the account, so it can't charge the saved card. Ask a workspace admin to top up (app Settings → Billing → Add credits, or their own buy_credits call).`, { packs });
363
+ throw e;
364
+ }
181
365
  return ok(`Done — charged ${card} $${match.priceUsd}; ${match.credits.toLocaleString()} credits are on the account now. (Receipt lands in Settings → Billing → invoice history.)`, d);
182
366
  }
183
367
  const d = await apiPost('/api/billing/checkout-link', { packId: pack });
@@ -191,7 +375,7 @@ export function registerTools(server) {
191
375
  description: "Show this account's billing at a glance: current plan (id + label + monthly price), credit balance, whether auto-reload is on, whether a card is on file, and whether YOU (this key) have ADMIN rights to change billing. Read-only, free. Call it before upgrade_plan / set_auto_reload to know what's possible — members have read-only billing.",
192
376
  inputSchema: {}, outputSchema: {
193
377
  plan: z.any().optional().describe('the current plan ({id, label, monthlyUsd})'),
194
- balanceCredits: z.number().optional().describe('the current credit balance'),
378
+ balanceCredits: z.number().nullable().optional().describe('the current credit balance'),
195
379
  autoReload: z.any().optional().describe('auto-reload config ({enabled, thresholdCredits, reloadCredits, available})'),
196
380
  paymentMethodOnFile: z.boolean().optional().describe('whether a card is saved for one-click charges'),
197
381
  card: z.any().optional().describe('the saved card ({brand, last4}) when present'),
@@ -356,6 +540,7 @@ export function registerTools(server) {
356
540
  model: z.string().optional().describe('the product-facing label of the model that rendered it'),
357
541
  },
358
542
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
543
+ _meta: openaiMeta(AD_RESULT_URI, 'Rendering your ad image…', 'Ad image ready'),
359
544
  }, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize }) => {
360
545
  const refs = refImages?.length ? (await Promise.all(refImages.map(toRef))).filter(Boolean) : undefined;
361
546
  const d = await apiPost('/api/generate/image', { prompt, refImages: refs, useBrand: useBrand !== false, aspectRatio, model, imageSize }); // explicit boolean so the server's saved-brand hydration default is unambiguous
@@ -426,6 +611,7 @@ export function registerTools(server) {
426
611
  input: z.any().optional().describe('the assembled render input (dry run only — resolved model, duration, scenes)'),
427
612
  },
428
613
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
614
+ _meta: openaiMeta(AD_RESULT_URI, 'Rendering your video ad…', 'Video ad ready'),
429
615
  }, wrap(async (a) => {
430
616
  const { input, jobType, notes } = await apiPost('/api/render/assemble', a); // a passes wholesale — resolution/captions/endCard/music/lockup/ttsVoice ride the body
431
617
  // LAW 8: render_ad honors render_plan.structure/duration — a >single-clip creative assembles as stitched ACTS
@@ -445,6 +631,7 @@ export function registerTools(server) {
445
631
  },
446
632
  outputSchema: { ...JOB_OUT },
447
633
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
634
+ _meta: openaiMeta(AD_RESULT_URI, 'Building your template ad…', 'Template ad ready'),
448
635
  }, wrap(async (a) => {
449
636
  const r = await renderJob('templatead', { config: a.config }, 'MCP template ad');
450
637
  if (Array.isArray(r?.raw?.images) && r.raw.images.length) { // carousel: one PNG per slide → list every URL + inline the first slide
@@ -475,6 +662,38 @@ export function registerTools(server) {
475
662
  return okVideo(`Finished video ready: ${r.url} [job ${r.jobId}]`, r);
476
663
  }));
477
664
 
665
+
666
+ server.registerTool('post_edit', {
667
+ title: 'Post-production edit',
668
+ description: "MECHANICAL post-production on an EXISTING rendered video (its served mp4 URL) — an ordered plan of whitelisted primitives executed by ffmpeg (+ Chrome for typeset cards) in seconds for ~2 credits flat, NO AI model, the original untouched (returns a NEW video). The lane for: append a branded end card ('add an end card with our logo and website' — ADDS its seconds, never re-renders), trim, speed (0.5-2x), mute (whole or a window), audio_gain (-20..+6 dB), fade_out, corner logo watermark, anti-AI film grain. Up to 6 ops per plan, applied in order. Brand assets (name/domain/logo/accent) load from the workspace brand automatically; override per-call if needed. NEVER use generate_video/render_ad for these mechanical asks.",
669
+ inputSchema: {
670
+ videoUrl: z.string().describe('the served URL of the video to edit'),
671
+ ops: z.array(z.object({
672
+ op: z.enum(['trim', 'speed', 'mute', 'audio_gain', 'fade_out', 'append_card', 'watermark', 'grain']),
673
+ start: z.number().optional().describe('trim/mute window start (s)'),
674
+ end: z.number().optional().describe('trim/mute window end (s)'),
675
+ factor: z.number().optional().describe('speed 0.5-2'),
676
+ db: z.number().optional().describe('audio_gain -20..+6 dB'),
677
+ seconds: z.number().optional().describe('fade_out 0.3-3s / append_card 2-5s'),
678
+ headline: z.string().optional().describe('append_card: line instead of the brand name'),
679
+ sub: z.string().optional().describe('append_card: small line under it (defaults to the brand website)'),
680
+ corner: z.enum(['tl', 'tr', 'bl', 'br']).optional().describe('watermark corner (default br)'),
681
+ intensity: z.enum(['default', 'strong']).optional().describe('grain look'),
682
+ })).describe('the ordered edit plan (max 6 ops)'),
683
+ brandName: z.string().optional().describe('override the workspace brand name'),
684
+ domain: z.string().optional().describe('override the brand website'),
685
+ accent: z.string().optional().describe('override the brand accent hex'),
686
+ },
687
+ outputSchema: { ...JOB_OUT },
688
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
689
+ }, wrap(async (a) => {
690
+ let b = {};
691
+ try { const bk = PROFILE !== 'default' ? `heist.brand.v1.${PROFILE}` : 'heist.brand.v1'; b = JSON.parse((await apiGet(`/api/store/${encodeURIComponent(bk)}`))?.value || 'null') || {}; } catch {}
692
+ const pal = (Array.isArray(b.palette) ? b.palette : []).filter(c => /^#[0-9a-f]{6}$/i.test(String(c || '')));
693
+ const r = await renderJob('postedit', { videoUrl: a.videoUrl, ops: (a.ops || []).slice(0, 6), brandName: a.brandName || b.name || '', domain: a.domain || b.domain || '', logo: b.logo || '', accent: a.accent || pal[0] || '' }, 'MCP post edit');
694
+ return okVideo(`Edited video ready: ${r.url}${Array.isArray(r?.raw?.applied) ? ` (${r.raw.applied.join(', ')})` : ''} [job ${r.jobId}]`, r);
695
+ }));
696
+
478
697
  server.registerTool('fix_beat', {
479
698
  title: 'Fix a video beat',
480
699
  description: "Surgically re-render ONE time window (1.5-8s) of an existing rendered video and splice it back on the VIDEO TRACK ONLY — the rest of the video and ALL audio stay byte-identical. Use when one beat/shot is broken ('the shot at 8 seconds glitches') and a full re-render would waste the parts that worked; bills only the replacement clip's seconds (~1/3 of a full render). Do NOT pick a window covering spoken dialogue (a video-only splice under speech breaks lip-sync) — pass speechWindows to enforce this.",
@@ -510,6 +729,7 @@ export function registerTools(server) {
510
729
  },
511
730
  outputSchema: { ...JOB_OUT },
512
731
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
732
+ _meta: openaiMeta(AD_RESULT_URI, 'Rendering your video…', 'Video ready'),
513
733
  }, wrap(async (a) => {
514
734
  const refImage = a.refImage ? await toRef(a.refImage) : undefined;
515
735
  // an agent that NAMES a model made a deliberate pick — modelExplicit gives it the server-side ask-don't-swap
@@ -529,6 +749,7 @@ export function registerTools(server) {
529
749
  },
530
750
  outputSchema: { ...JOB_OUT },
531
751
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
752
+ _meta: openaiMeta(AD_RESULT_URI, 'Rendering your avatar clip…', 'Avatar clip ready'),
532
753
  }, wrap(async (a) => {
533
754
  const image = await toRef(a.image);
534
755
  const r = await renderJob('avatar', { ...a, image }, 'MCP avatar');
@@ -998,6 +1219,29 @@ export function registerTools(server) {
998
1219
  }));
999
1220
 
1000
1221
  // ---------- assets ----------
1222
+
1223
+ server.registerTool('list_library', {
1224
+ title: 'List library',
1225
+ description: "Browse this workspace's Library — every image/video generated in the Studio, newest first (the same Library the web app shows). Returns served URLs you can open directly or hand to fetch_asset for a download link, plus each asset's kind, model, and age. Free, read-only.",
1226
+ inputSchema: {
1227
+ kind: z.enum(['image', 'video', 'all']).optional().describe("filter by asset kind (default 'all')"),
1228
+ limit: z.number().optional().describe('max assets to return (default 20, max 60)'),
1229
+ },
1230
+ outputSchema: { assets: z.array(z.object({ url: z.string(), kind: z.string().optional(), model: z.string().optional(), ageHours: z.number().optional() })).optional() },
1231
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1232
+ }, wrap(async (a) => {
1233
+ const ak = PROFILE !== 'default' ? `heist.assets.v1.${PROFILE}` : 'heist.assets.v1';
1234
+ let list = [];
1235
+ try { list = JSON.parse((await apiGet(`/api/store/${encodeURIComponent(ak)}`))?.value || 'null') || []; } catch {}
1236
+ if (!Array.isArray(list)) list = [];
1237
+ const kind = a.kind && a.kind !== 'all' ? a.kind : null;
1238
+ const lim = Math.min(60, Math.max(1, +a.limit || 20));
1239
+ const assets = list.filter(x => x && x.url && (!kind || x.kind === kind)).slice(0, lim)
1240
+ .map(x => ({ url: /^https?:/.test(x.url) ? x.url : `${API_BASE}${x.url}`, kind: x.kind || '', model: x.model || '', ageHours: x.at ? Math.round((Date.now() - x.at) / 36e5) : undefined }));
1241
+ if (!assets.length) return ok('The Library is empty for this workspace — render something first.', { assets: [] });
1242
+ return ok(`${assets.length} asset${assets.length === 1 ? '' : 's'} (newest first):\n` + assets.map((x, i) => ` ${i + 1}. [${x.kind || '?'}${x.model ? ' · ' + x.model : ''}${x.ageHours != null ? ' · ' + x.ageHours + 'h ago' : ''}] ${x.url}`).join('\n'), { assets });
1243
+ }));
1244
+
1001
1245
  server.registerTool('fetch_asset', {
1002
1246
  title: 'Fetch asset',
1003
1247
  description: 'Resolve a generated asset reference (a /generated/… path or any URL) to a clickable absolute URL + a direct download URL.',
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "description": "Generate finished VIDEO ADS, image ads and UGC avatar ads for any brand with AI \u2014 and spy on competitor ads across the Meta, Google and LinkedIn ad libraries plus TikTok/Instagram/YouTube organic. MCP server, CLI and Claude skills for Hermoso, the AI ad studio: brand onboarding, 30+ image/video models, finished-ad pipeline (script, voiceover, music, brand end card), ad scoring and competitor teardowns.",
5
+ "description": "Generate finished VIDEO ADS, image ads and UGC avatar ads for any brand with AI and spy on competitor ads across the Meta, Google and LinkedIn ad libraries plus TikTok/Instagram/YouTube organic. MCP server, CLI and Claude skills for Hermoso, the AI ad studio: brand onboarding, 30+ image/video models, finished-ad pipeline (script, voiceover, music, brand end card), ad scoring and competitor teardowns.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "hermoso": "bin/hermoso.mjs"