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 +11 -3
- package/mcp/tools.mjs +253 -9
- package/package.json +2 -2
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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.
|
|
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
|
|
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"
|