hermoso 0.1.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/LICENSE +21 -0
- package/README.md +95 -0
- package/bin/hermoso.mjs +148 -0
- package/mcp/client.mjs +108 -0
- package/mcp/hermoso-mcp.mjs +22 -0
- package/mcp/http.mjs +77 -0
- package/mcp/tools.mjs +477 -0
- package/package.json +14 -0
- package/skills/hermoso-ad-from-brand/SKILL.md +35 -0
- package/skills/hermoso-generate/SKILL.md +35 -0
- package/skills/hermoso-product-photoshoot/SKILL.md +35 -0
- package/skills/hermoso-research/SKILL.md +31 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hermoso (hermoso.ai)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Hermoso — MCP, CLI & Skills
|
|
2
|
+
|
|
3
|
+
Drive [Hermoso](https://hermoso.ai) — the AI ad studio — from **any AI agent**: Claude Code, Claude.ai, Cursor,
|
|
4
|
+
Codex, or your own scripts. Research the ads already winning in a market, plan a creative, and generate finished
|
|
5
|
+
image & video ads (your real product composited in, copy + CTA included) — all over
|
|
6
|
+
[MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
|
|
7
|
+
|
|
8
|
+
## Instant: the hosted Claude.ai connector
|
|
9
|
+
|
|
10
|
+
Paste **`https://app.hermoso.ai/mcp`** into Claude → Settings → Connectors → *Add custom connector*, approve with
|
|
11
|
+
your Hermoso account, done — all 22 tools with your saved brand context, billed to your plan.
|
|
12
|
+
|
|
13
|
+
## Quickstart for Claude Code / Cursor / scripts (2 minutes)
|
|
14
|
+
|
|
15
|
+
1. **Get an account** at [app.hermoso.ai](https://app.hermoso.ai) — free tier included; plans & credits are the
|
|
16
|
+
same ones the web Studio uses.
|
|
17
|
+
2. **Create an agent key**: app.hermoso.ai → **Settings → Agents & API** → Create API key (`hmk_…`).
|
|
18
|
+
3. **Connect** (Claude Code shown; any MCP client works):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
git clone https://github.com/hermoso-ai/hermoso.git && cd hermoso && npm install
|
|
22
|
+
claude mcp add hermoso -e HERMOSO_TOKEN=hmk_… -- node "$(pwd)/mcp/hermoso-mcp.mjs"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Your agent now has the full studio **with your workspace's context**: the brand profile, products, logos and
|
|
26
|
+
learned memory you set up in the web app apply automatically (`get_brand` shows what's saved; omit `brand` in
|
|
27
|
+
`plan_ad`/`plan_variations` to use it). Renders bill your Hermoso credits — same prices as the Studio.
|
|
28
|
+
|
|
29
|
+
## 1. MCP server (stdio) — Claude Code / Cursor / Codex
|
|
30
|
+
|
|
31
|
+
`mcp/hermoso-mcp.mjs` is a stdio MCP server exposing 22 tools.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install
|
|
35
|
+
claude mcp add hermoso -- node "$(pwd)/mcp/hermoso-mcp.mjs"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Cursor / Codex — add to `mcp.json` (Codex uses the TOML equivalent):
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{ "mcpServers": { "hermoso": { "command": "node", "args": ["<repo>/mcp/hermoso-mcp.mjs"],
|
|
42
|
+
"env": { "HERMOSO_API_BASE": "https://app.hermoso.ai", "HERMOSO_TOKEN": "<your token>" } } } }
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
46
|
+
|
|
47
|
+
**Tools (22):** `hermoso_capabilities`, `hermoso_credits`, `get_brand`, `plan_ad`, `plan_variations`, `generate_image`,
|
|
48
|
+
`generate_video`, `generate_avatar`, `stitch_video`, `reframe_video`, `upscale_video`, `dub_video`,
|
|
49
|
+
`recast_motion`, `analyze_video`, `score_ad`, `get_job`, `list_jobs`, `find_competitors`,
|
|
50
|
+
`pull_competitor_ads`, `research_ads`, `draft_brand`, `fetch_asset`. Call `hermoso_capabilities` first — it
|
|
51
|
+
returns valid model ids and per-render credit costs. Render jobs queue server-side and poll to completion,
|
|
52
|
+
returning a served URL.
|
|
53
|
+
|
|
54
|
+
## 2. CLI — the token-cheap path for terminal agents
|
|
55
|
+
|
|
56
|
+
`bin/hermoso.mjs` mirrors the tools as subprocess commands, so an agent can shell out instead of carrying a fat
|
|
57
|
+
tool manifest.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npm install -g . # installs `hermoso`
|
|
61
|
+
hermoso capabilities # valid model ids + costs (run first)
|
|
62
|
+
hermoso create --brand "YourBrand" --product "your best-selling product" --format image
|
|
63
|
+
hermoso generate image --prompt "…" --ref ./product.png --wait
|
|
64
|
+
hermoso generate video --prompt "…" --duration 8 --wait
|
|
65
|
+
hermoso competitors yourbrand.com
|
|
66
|
+
hermoso research "Liquid Death’s longest-running ads"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Add `--json` to any command for machine output.
|
|
70
|
+
|
|
71
|
+
## 3. Claude skills — slash commands that wrap the CLI
|
|
72
|
+
|
|
73
|
+
`skills/` holds four installable skills: `hermoso-generate`, `hermoso-ad-from-brand`,
|
|
74
|
+
`hermoso-product-photoshoot`, `hermoso-research`.
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
cp -r skills/* ~/.claude/skills/
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Then invoke `/hermoso-ad-from-brand an ad for yourbrand.com — our hero product`.
|
|
81
|
+
|
|
82
|
+
## Configuration
|
|
83
|
+
|
|
84
|
+
| Env | Meaning |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| `HERMOSO_API_BASE` | The Hermoso API origin (default `https://app.hermoso.ai` — set `http://localhost:3000` if you run the app yourself) |
|
|
87
|
+
| `HERMOSO_TOKEN` | Bearer token — required against the hosted app (rolling out) |
|
|
88
|
+
| `HERMOSO_PROFILE` | Brand-workspace id, for accounts with multiple brand profiles |
|
|
89
|
+
|
|
90
|
+
`mcp/http.mjs` is the hosted remote-connector transport (paste-a-URL into Claude.ai → Connectors). It ships in
|
|
91
|
+
this repo for transparency and refuses to mount without authenticated identity — no anonymous spend, ever.
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT © Hermoso
|
package/bin/hermoso.mjs
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Hermoso CLI — drive Hermoso from any terminal agent (Claude Code, Codex, Cursor, OpenClaw…) by shelling out.
|
|
3
|
+
// This is the token-cheap path (the agent runs a command instead of carrying a fat tool manifest); the skills
|
|
4
|
+
// in skills/ wrap these commands. Same /api as the MCP server.
|
|
5
|
+
//
|
|
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
|
|
8
|
+
// hermoso capabilities # learn valid model ids + costs (run first)
|
|
9
|
+
// hermoso create --brand Flourish --product "protein pancakes" --format image
|
|
10
|
+
// hermoso generate image --prompt "…" --ref ./bag.png --wait
|
|
11
|
+
//
|
|
12
|
+
// Auth today: none locally (the server resolves the dev account). `hermoso auth login --token <t>` stores a Bearer
|
|
13
|
+
// for when real auth lands — the seam, not a requirement.
|
|
14
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
const CONFIG_DIR = path.join(os.homedir(), '.hermoso');
|
|
19
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
20
|
+
|
|
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)); }
|
|
23
|
+
|
|
24
|
+
// ---- minimal arg parser: positionals + --flags (--flag value | --flag=value | boolean --flag) ----
|
|
25
|
+
function parse(argv) {
|
|
26
|
+
const pos = [], flags = {};
|
|
27
|
+
for (let i = 0; i < argv.length; i++) {
|
|
28
|
+
const a = argv[i];
|
|
29
|
+
if (a.startsWith('--')) {
|
|
30
|
+
const eq = a.indexOf('=');
|
|
31
|
+
if (eq >= 0) { flags[a.slice(2, eq)] = a.slice(eq + 1); }
|
|
32
|
+
else if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) { flags[a.slice(2)] = argv[++i]; }
|
|
33
|
+
else { flags[a.slice(2)] = true; }
|
|
34
|
+
} else pos.push(a);
|
|
35
|
+
}
|
|
36
|
+
return { pos, flags };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const die = (msg) => { console.error('✗ ' + msg); process.exit(1); };
|
|
40
|
+
|
|
41
|
+
async function main() {
|
|
42
|
+
const { pos, flags } = parse(process.argv.slice(2));
|
|
43
|
+
const [group, sub] = pos;
|
|
44
|
+
const cfg = await loadConfig();
|
|
45
|
+
|
|
46
|
+
// ---- auth: no network call today; records base URL + optional token (the OAuth seam) ----
|
|
47
|
+
if (group === 'auth') {
|
|
48
|
+
if (sub === 'logout') { await saveConfig({}); return console.log('✓ Logged out (cleared ~/.hermoso/config.json)'); }
|
|
49
|
+
const next = { apiBase: flags.url || cfg.apiBase || 'https://app.hermoso.ai', token: flags.token || cfg.token || '', profile: flags.profile || cfg.profile || 'default' };
|
|
50
|
+
await saveConfig(next);
|
|
51
|
+
console.log(`✓ Saved. API: ${next.apiBase}${next.token ? ' · token stored' : ''}`);
|
|
52
|
+
if (!next.token) console.log(' Next: create an agent key at app.hermoso.ai → Settings → Agents & API, then run: hermoso auth login --token <key>');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (group === 'version' || flags.version) {
|
|
56
|
+
console.log(`hermoso-cli 1.0.0 · API ${cfg.apiBase || process.env.HERMOSO_API_BASE || 'https://app.hermoso.ai'} · ${cfg.token ? 'authed' : 'no token — run: hermoso auth login --token <key>'}`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// resolve API base + token from config (env overrides), then load the shared client
|
|
61
|
+
process.env.HERMOSO_API_BASE = process.env.HERMOSO_API_BASE || cfg.apiBase || 'https://app.hermoso.ai';
|
|
62
|
+
if (cfg.token && !process.env.HERMOSO_TOKEN) process.env.HERMOSO_TOKEN = cfg.token;
|
|
63
|
+
if (cfg.profile && !process.env.HERMOSO_PROFILE) process.env.HERMOSO_PROFILE = cfg.profile;
|
|
64
|
+
const api = await import('../mcp/client.mjs');
|
|
65
|
+
const out = (label, data) => { if (flags.json) console.log(JSON.stringify(data, null, 2)); else console.log(label); };
|
|
66
|
+
const absUrl = (u) => (u && u.startsWith('/') ? api.API_BASE + u : u);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
switch (group) {
|
|
70
|
+
case 'capabilities': case 'caps': {
|
|
71
|
+
const d = await api.apiGet('/api/generate/status');
|
|
72
|
+
if (flags.json) return console.log(JSON.stringify(d, null, 2));
|
|
73
|
+
console.log('IMAGE models:'); (d.options?.image?.models || []).forEach(m => console.log(` ${m.id.padEnd(18)} ${m.label} · ${m.credits}cr${m.best ? ' ★best' : ''}`));
|
|
74
|
+
console.log('VIDEO models:'); (d.options?.video?.models || []).forEach(m => console.log(` ${m.id.padEnd(18)} ${m.label} · ${(m.durations || []).join('/')}s`));
|
|
75
|
+
console.log(`flags: canEdit=${d.canEdit} canAvatar=${d.canAvatar} canPublish=${d.canPublish}`);
|
|
76
|
+
console.log(`recipes: ${(d.recipes || []).map(r => r.id).join(', ')}`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
case 'credits': { const d = await api.apiGet('/api/credits'); return out(`Balance: ${d.balance} credits · session used: ${d.sessionUsed ?? 0}`, d); }
|
|
80
|
+
case 'brand': {
|
|
81
|
+
if (sub !== 'draft') return die('usage: hermoso brand draft (--domain <d> | --description <t> | --social <h> --platform <p>)');
|
|
82
|
+
const body = flags.domain ? { domain: flags.domain } : flags.description ? { description: flags.description } : flags.social ? { socialHandle: flags.social, platform: flags.platform || 'instagram' } : null;
|
|
83
|
+
if (!body) return die('give --domain, --description, or --social');
|
|
84
|
+
const d = await api.apiPost('/api/brand/draft', body); const p = d.profile || d;
|
|
85
|
+
return out(`✓ ${p.name || '—'}${p.category ? ' · ' + p.category : ''}${p.domain ? ' · ' + p.domain : ''}`, p);
|
|
86
|
+
}
|
|
87
|
+
case 'create': {
|
|
88
|
+
if (!flags.product) return die('usage: hermoso create --brand <b> --product <p> [--format auto|image|video] [--recipe <id>]');
|
|
89
|
+
const d = await api.apiPost('/api/create', { brand: { name: flags.brand || '' }, product: flags.product, format: flags.format || 'auto', recipe: flags.recipe || '', reference: flags.reference ? { url: flags.reference } : null, language: flags.language || '' });
|
|
90
|
+
const c = d.creative || d;
|
|
91
|
+
if (flags.json) return console.log(JSON.stringify(c, null, 2));
|
|
92
|
+
console.log(`Concept (${c.format}${c.recipe_label ? ' · ' + c.recipe_label : ''}): ${c.concept}`);
|
|
93
|
+
console.log(`Headline: ${c.copy?.[0]?.headline || ''}`);
|
|
94
|
+
console.log(`Render with: ${c.format === 'video' ? c.vmodel : c.imodel || 'default'} → hermoso generate ${c.format === 'video' ? 'video' : 'image'} --prompt "…"`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
case 'generate': case 'gen': {
|
|
98
|
+
const wait = flags.wait !== false && flags.wait !== 'false';
|
|
99
|
+
if (sub === 'image') {
|
|
100
|
+
if (!flags.prompt) return die('--prompt required');
|
|
101
|
+
const refs = []; for (const k of ['ref', 'refs']) if (flags[k]) refs.push(...String(flags[k]).split(','));
|
|
102
|
+
const refImages = refs.length ? (await Promise.all(refs.map(api.toRef))).filter(Boolean) : undefined;
|
|
103
|
+
const d = await api.apiPost('/api/generate/image', { prompt: flags.prompt, refImages, aspectRatio: flags.aspect, model: flags.model });
|
|
104
|
+
return out(`✓ Image: ${absUrl(d.image)}${d.model ? ` (${d.model})` : ''}`, { ...d, image: absUrl(d.image) });
|
|
105
|
+
}
|
|
106
|
+
// video | avatar | stitch → job + poll
|
|
107
|
+
const type = sub;
|
|
108
|
+
if (!['video', 'avatar', 'stitch'].includes(type)) return die('usage: hermoso generate image|video|avatar|stitch …');
|
|
109
|
+
let input = {};
|
|
110
|
+
if (type === 'video') { if (!flags.prompt) return die('--prompt required'); input = { prompt: flags.prompt, refImage: flags.ref ? await api.toRef(flags.ref) : undefined, durationSeconds: flags.duration ? +flags.duration : undefined, aspectRatio: flags.aspect, model: flags.model, resolution: flags.resolution, ttsScript: flags.tts, ttsVoice: flags.voice, musicMood: flags.music }; }
|
|
111
|
+
else if (type === 'avatar') { if (!flags.image || !flags.script) return die('--image and --script required'); input = { image: await api.toRef(flags.image), script: flags.script, voice: flags.voice, resolution: flags.resolution }; }
|
|
112
|
+
else if (type === 'stitch') { if (!flags.scenes) return die('--scenes <file.json> required'); input = { scenes: JSON.parse(await readFile(flags.scenes, 'utf8')), aspectRatio: flags.aspect, voiceover: flags.voiceover, voice: flags.voice, resolution: flags.resolution }; }
|
|
113
|
+
const job = await api.submitJob(type, input, { label: 'cli ' + type });
|
|
114
|
+
if (!wait) return out(`Queued job ${job.id} — poll with: hermoso jobs get ${job.id} --wait`, job);
|
|
115
|
+
process.stderr.write(`rendering (job ${job.id})…`);
|
|
116
|
+
const { result } = await api.pollJob(job.id, { timeoutMs: +(flags['wait-timeout'] || 600) * 1000, intervalMs: +(flags['wait-interval'] || 3) * 1000, onTick: () => process.stderr.write('.') });
|
|
117
|
+
process.stderr.write('\n');
|
|
118
|
+
const url = absUrl(result?.video || result?.image || result?.url);
|
|
119
|
+
return out(`✓ ${type}: ${url}${result?.model ? ` (${result.model})` : ''}`, { ...result, url });
|
|
120
|
+
}
|
|
121
|
+
case 'jobs': {
|
|
122
|
+
if (sub === 'get') { const id = pos[2]; if (!id) return die('usage: hermoso jobs get <id> [--wait]'); if (flags.wait) { const { result } = await api.pollJob(id, { timeoutMs: 600000 }); return out(`✓ ${absUrl(result?.video || result?.image || result?.url)}`, result); } const j = await api.getJob(id); return out(`${j.id} ${j.type} ${j.status}${j.progress ? ` ${Math.round(j.progress * 100)}%` : ''}`, j); }
|
|
123
|
+
const d = await api.apiGet('/api/jobs');
|
|
124
|
+
if (flags.json) return console.log(JSON.stringify(d, null, 2));
|
|
125
|
+
console.log(`${d.running} running.`); (d.jobs || []).slice(0, 15).forEach(j => console.log(` ${j.id} ${j.type.padEnd(7)} ${j.status}`));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
case 'competitors': { const domain = sub; if (!domain) return die('usage: hermoso competitors <domain> [--mode competitors|inspiration|company]'); const d = await api.apiPost('/api/inspire/competitors', { domain, mode: flags.mode || 'competitors' }); if (flags.json) return console.log(JSON.stringify(d, null, 2)); (d.candidates || []).forEach(c => console.log(` ${c.name} (${c.domain || '—'}, ${c.kind})`)); return; }
|
|
129
|
+
case 'ads': {
|
|
130
|
+
if (sub !== 'pull') return die('usage: hermoso ads pull (--company <n> | --domain <d>) [--platforms facebook,google,linkedin]');
|
|
131
|
+
const d = await api.apiPost('/api/inspire/fanout', { companyName: flags.company, domain: flags.domain, platforms: flags.platforms ? String(flags.platforms).split(',') : ['facebook'], country: flags.country || 'US', limit: +flags.limit || 30, sort: flags.sort || 'longest_running' });
|
|
132
|
+
return out('✓ pulled (use --json to see the ads)', d);
|
|
133
|
+
}
|
|
134
|
+
case 'research': { const q = sub || flags.query; if (!q) return die('usage: hermoso research "<request>"'); const d = await api.apiSSE('/api/explore/chat', { messages: [{ role: 'user', content: q }] }); if (flags.json) return console.log(JSON.stringify(d, null, 2)); console.log(d.reply || ''); console.log(`\n(${(d.results || []).length} ads found)`); return; }
|
|
135
|
+
case 'fetch': { const url = sub; if (!url) return die('usage: hermoso fetch <url> [--out <name>]'); const r = await fetch(`${api.API_BASE}/api/download?url=${encodeURIComponent(url)}`); if (!r.ok) return die(`download failed (HTTP ${r.status})`); const buf = Buffer.from(await r.arrayBuffer()); const name = flags.out || path.basename(url.split(/[?#]/)[0]) || 'asset'; await writeFile(name, buf); return console.log(`✓ saved ${name} (${buf.length} bytes)`); }
|
|
136
|
+
default:
|
|
137
|
+
console.log(`hermoso <command>
|
|
138
|
+
auth login [--url <base>] [--token <t>] credits capabilities
|
|
139
|
+
brand draft (--domain|--description|--social …) create --brand --product [--format]
|
|
140
|
+
generate image --prompt [--ref] [--model] [--aspect] generate video|avatar|stitch … [--wait]
|
|
141
|
+
jobs list | jobs get <id> [--wait] competitors <domain>
|
|
142
|
+
ads pull (--company|--domain) research "<request>"
|
|
143
|
+
fetch <url> [--out] version
|
|
144
|
+
add --json to any command for machine output.`);
|
|
145
|
+
}
|
|
146
|
+
} catch (e) { die(e?.message || String(e)); }
|
|
147
|
+
}
|
|
148
|
+
main();
|
package/mcp/client.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Tiny fetch wrapper around the Hermoso HTTP API, shared by the MCP server (mcp/tools.mjs) and the CLI (bin/hermoso.mjs).
|
|
2
|
+
// LOCAL today: no auth needed — the server's local auth adapter resolves the fixed dev account, so requireAuth/
|
|
3
|
+
// gateSpend pass. When real auth lands, set HERMOSO_TOKEN (a Bearer) and the SAME calls become authoritative — no
|
|
4
|
+
// changes here. We attach the x-hermoso-plan / x-hermoso-user fallbacks the browser also sends, purely for parity;
|
|
5
|
+
// the server treats them as non-authoritative (identity comes from the verified token / local dev user).
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
9
|
+
|
|
10
|
+
// Remote-connector identity: mcp/http.mjs wraps each request in mcpCtx.run({ token }) so every /api call a tool
|
|
11
|
+
// makes carries THAT caller's bearer (bills their account). stdio keeps using the env token — ctx is simply unset.
|
|
12
|
+
export const mcpCtx = new AsyncLocalStorage();
|
|
13
|
+
|
|
14
|
+
export const API_BASE = (process.env.HERMOSO_API_BASE || 'https://app.hermoso.ai').replace(/\/+$/, '');
|
|
15
|
+
const TOKEN = process.env.HERMOSO_TOKEN || '';
|
|
16
|
+
export const PROFILE = process.env.HERMOSO_PROFILE || 'default';
|
|
17
|
+
|
|
18
|
+
function headers(extra = {}) {
|
|
19
|
+
const ctx = mcpCtx.getStore();
|
|
20
|
+
const h = { 'Content-Type': 'application/json', 'x-hermoso-user': ctx?.profile || PROFILE, ...extra };
|
|
21
|
+
const tok = ctx?.token || TOKEN;
|
|
22
|
+
if (tok) h.Authorization = `Bearer ${tok}`;
|
|
23
|
+
return h;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// unwrap the {data}|{error} envelope; throw a clean Error (with .status) on failure
|
|
27
|
+
async function unwrap(res) {
|
|
28
|
+
let body = null;
|
|
29
|
+
try { body = await res.json(); } catch {}
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
const msg = (body && (body.error || body.message)) || `HTTP ${res.status}`;
|
|
32
|
+
throw Object.assign(new Error(msg), { status: res.status });
|
|
33
|
+
}
|
|
34
|
+
return body && Object.prototype.hasOwnProperty.call(body, 'data') ? body.data : body;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function apiGet(p, query) {
|
|
38
|
+
const qs = query ? '?' + new URLSearchParams(query).toString() : '';
|
|
39
|
+
const res = await fetch(`${API_BASE}${p}${qs}`, { headers: headers() });
|
|
40
|
+
return unwrap(res);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function apiPost(p, body = {}) {
|
|
44
|
+
const res = await fetch(`${API_BASE}${p}`, { method: 'POST', headers: headers(), body: JSON.stringify(body) });
|
|
45
|
+
return unwrap(res);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function apiPut(p, body = {}) {
|
|
49
|
+
const res = await fetch(`${API_BASE}${p}`, { method: 'PUT', headers: headers(), body: JSON.stringify(body) });
|
|
50
|
+
return unwrap(res);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// /api/explore/chat streams Server-Sent-Events; collect to the terminal `done` payload {reply, results, actions}.
|
|
54
|
+
export async function apiSSE(p, body = {}) {
|
|
55
|
+
const res = await fetch(`${API_BASE}${p}`, { method: 'POST', headers: headers({ Accept: 'text/event-stream' }), body: JSON.stringify(body) });
|
|
56
|
+
if (!res.ok) { let e; try { e = (await res.json()).error; } catch {} throw Object.assign(new Error(e || `HTTP ${res.status}`), { status: res.status }); }
|
|
57
|
+
const reader = res.body.getReader(); const dec = new TextDecoder();
|
|
58
|
+
let buf = '', done = null, error = null; const progress = [];
|
|
59
|
+
for (;;) {
|
|
60
|
+
const { value, done: fin } = await reader.read(); if (fin) break;
|
|
61
|
+
buf += dec.decode(value, { stream: true });
|
|
62
|
+
const frames = buf.split('\n\n'); buf = frames.pop() || '';
|
|
63
|
+
for (const f of frames) {
|
|
64
|
+
const em = /event:\s*(.+)/.exec(f), dm = /data:\s*([\s\S]+)/.exec(f);
|
|
65
|
+
if (!em || !dm) continue;
|
|
66
|
+
let d; try { d = JSON.parse(dm[1]); } catch { continue; }
|
|
67
|
+
const ev = em[1].trim();
|
|
68
|
+
if (ev === 'progress') progress.push(d.label);
|
|
69
|
+
else if (ev === 'done') done = d;
|
|
70
|
+
else if (ev === 'error') error = d.error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (error) throw new Error(error);
|
|
74
|
+
if (!done) throw new Error('Stream closed before a response');
|
|
75
|
+
return { ...done, progress };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Submit a render to the job queue and (optionally) poll it to completion. Returns the UNWRAPPED worker result
|
|
79
|
+
// (the {image|video, model} object) on success — the job's `result` is itself a {data} envelope, so we peel it.
|
|
80
|
+
export async function submitJob(type, input, { label = '' } = {}) {
|
|
81
|
+
return apiPost('/api/jobs', { type, input, label }); // → publicJob {id, status, ...}
|
|
82
|
+
}
|
|
83
|
+
export async function getJob(id) { return apiGet(`/api/jobs/${encodeURIComponent(id)}`); } // → publicJob
|
|
84
|
+
export function jobResult(job) { const r = job?.result; return r && Object.prototype.hasOwnProperty.call(r, 'data') ? r.data : r; }
|
|
85
|
+
|
|
86
|
+
export async function pollJob(id, { intervalMs = 3000, timeoutMs = 10 * 60 * 1000, onTick } = {}) {
|
|
87
|
+
const deadline = Date.now() + timeoutMs;
|
|
88
|
+
for (;;) {
|
|
89
|
+
const job = await getJob(id);
|
|
90
|
+
onTick?.(job);
|
|
91
|
+
if (job.status === 'done') return { job, result: jobResult(job) };
|
|
92
|
+
if (job.status === 'error') throw new Error(job.error || 'Render failed');
|
|
93
|
+
if (Date.now() > deadline) throw Object.assign(new Error('Render timed out — check `hermoso jobs get ' + id + '`'), { jobId: id });
|
|
94
|
+
await new Promise(r => setTimeout(r, intervalMs));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Read a local image path → data URI (so --ref local files force Nano-Banana compositing); pass http(s) URLs through.
|
|
99
|
+
export async function toRef(srcOrPath) {
|
|
100
|
+
if (!srcOrPath) return null;
|
|
101
|
+
if (/^(https?:|data:)/.test(srcOrPath)) return srcOrPath;
|
|
102
|
+
const buf = await readFile(srcOrPath);
|
|
103
|
+
const ext = path.extname(srcOrPath).toLowerCase().replace('.', '');
|
|
104
|
+
const mime = ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : ext === 'gif' ? 'image/gif' : 'image/jpeg';
|
|
105
|
+
return `data:${mime};base64,${buf.toString('base64')}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const authState = () => ({ apiBase: API_BASE, hasToken: !!TOKEN, profile: PROFILE });
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Hermoso MCP server (stdio transport) — lets Claude Code / Cursor / Codex (and any stdio MCP client) drive Hermoso:
|
|
3
|
+
// research competitors, plan ads, and generate images/videos/avatars, all against the running Hermoso server.
|
|
4
|
+
//
|
|
5
|
+
// Local (today): node mcp/hermoso-mcp.mjs # talks to https://app.hermoso.ai (HERMOSO_API_BASE to override, e.g. http://localhost:3000 when self-running)
|
|
6
|
+
// Auth (today): none — the local server resolves the dev account. Set HERMOSO_TOKEN once real auth lands.
|
|
7
|
+
//
|
|
8
|
+
// stdout is the JSON-RPC channel — NEVER print to it. All logging goes to stderr (console.error).
|
|
9
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
|
+
import { registerTools } from './tools.mjs';
|
|
12
|
+
import { API_BASE } from './client.mjs';
|
|
13
|
+
|
|
14
|
+
const server = new McpServer({ name: 'hermoso-mcp', version: '1.0.0' }, {
|
|
15
|
+
instructions: 'Hermoso generates copyable, on-brand ad creative. Typical flow: hermoso_capabilities (learn valid model ids + costs) → optionally draft_brand → plan_ad (concept + copy) → generate_image / generate_video (returns a served URL). Use find_competitors / pull_competitor_ads / research_ads to gather proven ads to remix first. Always report the final media URL to the user.',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
registerTools(server);
|
|
19
|
+
|
|
20
|
+
const transport = new StdioServerTransport();
|
|
21
|
+
await server.connect(transport);
|
|
22
|
+
console.error(`[hermoso-mcp] ready · API ${API_BASE}`);
|
package/mcp/http.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// ───────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// REMOTE MCP CONNECTOR — DEFERRED. This is the Claude.ai "custom connector" surface (https://<host>/mcp):
|
|
3
|
+
// a Streamable-HTTP MCP transport + OAuth so any Claude.ai / Cursor user can connect Hermoso by URL and sign in,
|
|
4
|
+
// exactly like Higgsfield's mcp.higgsfield.ai/mcp.
|
|
5
|
+
//
|
|
6
|
+
// It is written so the cloud step is a CONFIG FLIP, not a rewrite — but it is intentionally OFF and will REFUSE
|
|
7
|
+
// to mount until BOTH are true:
|
|
8
|
+
// (1) HERMOSO_MCP_REMOTE=1, and
|
|
9
|
+
// (2) a real token verifier is wired (verifyBearer) — i.e. Firebase Auth (or equivalent) is configured.
|
|
10
|
+
// Why it must stay off locally: a public money-spending endpoint cannot exist without authenticated identity
|
|
11
|
+
// (the no-anon-spend rule), there is no hosted origin yet, and per the rollout plan cloud is provisioned
|
|
12
|
+
// COLLABORATIVELY, never solo. Until then, use the local stdio server (mcp/hermoso-mcp.mjs) + the CLI + skills.
|
|
13
|
+
//
|
|
14
|
+
// When the cloud step happens, the remaining work is small and explicit (see ENABLE CHECKLIST at the bottom).
|
|
15
|
+
// ───────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
17
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
18
|
+
import { registerTools } from './tools.mjs';
|
|
19
|
+
import { mcpCtx } from './client.mjs';
|
|
20
|
+
|
|
21
|
+
// Mount the remote connector onto the Express app. No-op unless explicitly enabled + auth-backed.
|
|
22
|
+
// `verifyBearer(token) -> {userId, accountId, email} | null` MUST be supplied by the caller (the real auth seam).
|
|
23
|
+
export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
24
|
+
if (process.env.HERMOSO_MCP_REMOTE !== '1') return false; // gate 1: off by default
|
|
25
|
+
if (typeof verifyBearer !== 'function') { // gate 2: refuse without real auth
|
|
26
|
+
console.error('[mcp-remote] REFUSING to mount: no token verifier wired. A remote, money-spending MCP must authenticate every caller (no-anon-spend). Wire Firebase Auth → verifyBearer first.');
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
const BASE = (publicBaseUrl || process.env.HERMOSO_PUBLIC_URL || '').replace(/\/+$/, '');
|
|
30
|
+
|
|
31
|
+
// RFC 9728 protected-resource metadata — tells Claude.ai where to get a token. (Authorization-server metadata
|
|
32
|
+
// is served by the auth provider itself, e.g. Firebase/your IdP.)
|
|
33
|
+
app.get('/.well-known/oauth-protected-resource', (req, res) => res.json({
|
|
34
|
+
resource: `${BASE}/mcp`,
|
|
35
|
+
authorization_servers: [process.env.HERMOSO_OAUTH_ISSUER].filter(Boolean),
|
|
36
|
+
scopes_supported: ['hermoso.generate', 'hermoso.research'],
|
|
37
|
+
bearer_methods_supported: ['header'],
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
// Per-session Streamable-HTTP transports. Each authenticated session gets its own McpServer with the same tools.
|
|
41
|
+
const sessions = new Map(); // mcp-session-id -> { transport, server }
|
|
42
|
+
const challenge = (res) => res.status(401).set('WWW-Authenticate', `Bearer resource_metadata="${BASE}/.well-known/oauth-protected-resource"`).json({ error: 'Authentication required' });
|
|
43
|
+
|
|
44
|
+
app.all('/mcp', async (req, res) => {
|
|
45
|
+
// EVERY call must carry a valid bearer — fail CLOSED (Claude.ai has historically made tokenless probe calls).
|
|
46
|
+
const auth = req.headers.authorization || '';
|
|
47
|
+
const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
|
|
48
|
+
const user = token ? await verifyBearer(token).catch(() => null) : null;
|
|
49
|
+
if (!user) return challenge(res);
|
|
50
|
+
|
|
51
|
+
const sid = req.headers['mcp-session-id'];
|
|
52
|
+
let entry = sid && sessions.get(sid);
|
|
53
|
+
if (!entry) {
|
|
54
|
+
const server = new McpServer({ name: 'hermoso', version: '1.0.0' });
|
|
55
|
+
registerTools(server); // the SAME tools as stdio — but here every /api call they make carries this user's token
|
|
56
|
+
const transport = new StreamableHTTPServerTransport({
|
|
57
|
+
sessionIdGenerator: () => 'sess_' + Math.random().toString(36).slice(2),
|
|
58
|
+
onsessioninitialized: (id) => sessions.set(id, entry),
|
|
59
|
+
});
|
|
60
|
+
transport.onclose = () => { if (transport.sessionId) sessions.delete(transport.sessionId); };
|
|
61
|
+
entry = { transport, server, user };
|
|
62
|
+
await server.connect(transport);
|
|
63
|
+
}
|
|
64
|
+
await mcpCtx.run({ token }, () => entry.transport.handleRequest(req, res, req.body)); // the caller's bearer rides into every /api call the tools make — spend bills THEIR account
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
console.error(`[mcp-remote] mounted at ${BASE || '(set HERMOSO_PUBLIC_URL)'}/mcp`);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── ENABLE CHECKLIST (cloud step, collaborative) ──────────────────────────────────────────────────────────
|
|
72
|
+
// 1. Provision a hosted origin (Cloud Run) + Firebase Auth; set HERMOSO_PUBLIC_URL + HERMOSO_OAUTH_ISSUER.
|
|
73
|
+
// 2. Implement verifyBearer(token) via the Firebase auth adapter (adapters/auth/firebase.js) and pass it here.
|
|
74
|
+
// 3. Thread the authenticated user into mcp/client.mjs's outbound /api calls (AsyncLocalStorage) so reserve()/
|
|
75
|
+
// gateSpend bill the right account — the server-side enforcement is already authoritative once req.user is real.
|
|
76
|
+
// 4. Set HERMOSO_MCP_REMOTE=1. Then in server.js: `import { mountRemoteMcp } from './mcp/http.mjs'; mountRemoteMcp(app, { verifyBearer, publicBaseUrl })`.
|
|
77
|
+
// 5. The published connector URL becomes `${HERMOSO_PUBLIC_URL}/mcp` — paste into Claude.ai → Settings → Connectors.
|
package/mcp/tools.mjs
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
// All Hermoso MCP tools, each a thin wrapper over a real /api route (see mcp/client.mjs). Job-based renders
|
|
2
|
+
// (video/avatar/stitch) submit to the queue and POLL TO COMPLETION inside the tool — works in every MCP client
|
|
3
|
+
// (no experimental Tasks dependency) and returns the final served URL. get_job/list_jobs cover resume/inspection.
|
|
4
|
+
// Spend tools hit routes guarded by gateSpend → requireAuth; locally the dev account always resolves (no auth
|
|
5
|
+
// needed today), and the SAME guard becomes authoritative under real auth — so this honors no-anon-spend as-is.
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { apiGet, apiPost, apiPut, apiSSE, submitJob, getJob, jobResult, pollJob, toRef, API_BASE, PROFILE, mcpCtx } from './client.mjs';
|
|
8
|
+
|
|
9
|
+
const JOB_TIMEOUT = +(process.env.HERMOSO_JOB_TIMEOUT_MS || 10 * 60 * 1000);
|
|
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 ?? undefined });
|
|
12
|
+
// Video-return variant: attaches the clip's first frame as an inline image block (Claude can't play mp4 in chat,
|
|
13
|
+
// but a poster makes the result VISIBLE, mirroring generate_image). Falls back to plain ok() when frames fail.
|
|
14
|
+
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.`;
|
|
15
|
+
const okVideo = async (text, r) => {
|
|
16
|
+
if (r?.stillRendering) return ok(stillMsg(r), r); const p = r?.url ? await videoPosterBlock(r.url) : null; return { content: [{ type: 'text', text: p ? text + '\n(first frame attached — open the URL for the full video)' : text }, ...(p ? [p] : [])], structuredContent: r ?? undefined }; };
|
|
17
|
+
// Inline the finished image so Claude RENDERS it in chat instead of just linking it (MCP image content block).
|
|
18
|
+
// Skipped silently for huge files / fetch errors — the URL in the text always works.
|
|
19
|
+
// Claude can't play video inline — attach the FIRST FRAME as an image block next to the link so the spot is
|
|
20
|
+
// visible in chat (0 credits; ffmpeg still via /api/video/frames).
|
|
21
|
+
async function videoPosterBlock(videoUrl) {
|
|
22
|
+
try {
|
|
23
|
+
const d = await apiGet('/api/video/frames', { url: videoUrl, n: 1 });
|
|
24
|
+
const f = (d.frames || [])[0]; if (!f || !/^data:image\//.test(f)) return null;
|
|
25
|
+
const [head, b64] = f.split(',');
|
|
26
|
+
return { type: 'image', data: b64, mimeType: head.slice(5).split(';')[0] };
|
|
27
|
+
} catch (e) { console.error('[mcp] video poster failed:', String(e?.message || e).slice(0, 160)); return null; } // silent-null keeps the link usable; log so a missing poster is diagnosable (Dave hit this on Claude.ai)
|
|
28
|
+
}
|
|
29
|
+
async function imageBlock(url) {
|
|
30
|
+
try {
|
|
31
|
+
const r = await fetch(url); if (!r.ok) return null;
|
|
32
|
+
const ct = (r.headers.get('content-type') || 'image/jpeg').split(';')[0];
|
|
33
|
+
if (!/^image\//.test(ct)) return null;
|
|
34
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
35
|
+
if (buf.length > 1_400_000) return null;
|
|
36
|
+
return { type: 'image', data: buf.toString('base64'), mimeType: ct };
|
|
37
|
+
} catch { return null; }
|
|
38
|
+
}
|
|
39
|
+
const wrap = (fn) => async (args, extra) => {
|
|
40
|
+
try { return await fn(args, extra); }
|
|
41
|
+
catch (e) {
|
|
42
|
+
let msg = `Error: ${e?.message || e}`;
|
|
43
|
+
// credit outages need an actionable path the agent can relay — the web app has a top-up gate; here the URL is it
|
|
44
|
+
if (/not enough credits/i.test(msg)) msg += `\nTop up or upgrade at https://app.hermoso.ai (Settings → Billing), then retry — nothing was charged. hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.`;
|
|
45
|
+
return { content: [{ type: 'text', text: msg }], isError: true };
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// run a job to completion, surfacing the served media URL. Under the HOSTED connector (Claude.ai/ChatGPT) the
|
|
50
|
+
// client kills long tool calls before a 1-3 min render finishes — so cap the in-call wait there and return a
|
|
51
|
+
// RESUMABLE handle instead of dying (the agent polls get_job, which now attaches the poster on done).
|
|
52
|
+
async function renderJob(type, input, label) {
|
|
53
|
+
const job = await submitJob(type, input, { label });
|
|
54
|
+
const remote = !!mcpCtx.getStore(); // AsyncLocalStorage ctx only exists on the remote transport
|
|
55
|
+
try {
|
|
56
|
+
const { result } = await pollJob(job.id, { timeoutMs: remote ? 45_000 : JOB_TIMEOUT });
|
|
57
|
+
const url = abs(result?.video || result?.image || result?.url);
|
|
58
|
+
return { jobId: job.id, url, model: result?.model || null, raw: result };
|
|
59
|
+
} catch (e) {
|
|
60
|
+
if (remote && e?.jobId) return { jobId: job.id, url: null, stillRendering: true, raw: null }; // not an error — resume via get_job
|
|
61
|
+
throw e;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function registerTools(server) {
|
|
66
|
+
// ---------- read-only / discovery ----------
|
|
67
|
+
server.registerTool('hermoso_capabilities', {
|
|
68
|
+
description: 'Probe what this Hermoso account can do RIGHT NOW: available image/video model ids + their exact credit costs, aspect ratios, video durations, the recipe ids, and the canEdit/canAvatar/canPublish flags. Call this FIRST so you generate with valid model ids and known costs. Read-only, free.',
|
|
69
|
+
inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
|
|
70
|
+
}, wrap(async () => {
|
|
71
|
+
const d = await apiGet('/api/generate/status');
|
|
72
|
+
const img = (d.options?.image?.models || []).map(m => `${m.id} (${m.label}, ${m.credits}cr${m.best ? ', best' : ''})`).join('; ');
|
|
73
|
+
// durations + per-duration credits MATTER: without them agents assume the generic "AI video caps at 8-10s"
|
|
74
|
+
// prior and wrongly steer users to stitching (a real Claude.ai session did exactly that on a 15s ad)
|
|
75
|
+
const vid = (d.options?.video?.models || []).map(m => `${m.id} (${m.label}: one continuous clip of ${(m.durations || []).map(x => `${x}s=${m.credits?.[x] ?? '?'}cr`).join(' ')}${m.audio ? ', native audio' : ', silent'}${m.best ? ', best' : ''})`).join('; ');
|
|
76
|
+
const text = `Image: ${d.image ? img : 'unavailable'}\nVideo: ${d.video ? vid : 'unavailable'}\nIMPORTANT: durations above are SINGLE-PASS — e.g. seedance-2 renders a full multi-beat 15s ad in ONE generation (do NOT assume a generic 8–10s cap, and do NOT stitch for ≤15s spots; stitching is only for longer). durationSeconds must be one of the model's listed values.\ncanEdit:${d.canEdit} canAvatar:${d.canAvatar} canPublish:${d.canPublish}\nRecipes (${(d.recipes || []).length}): ${(d.recipes || []).slice(0, 20).map(r => r.id).join(', ')}…`;
|
|
77
|
+
return ok(text, d);
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
server.registerTool('hermoso_credits', {
|
|
81
|
+
description: 'Return the account credit balance, credits used this session, and recent priced calls. Check before kicking off paid generation.',
|
|
82
|
+
inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
|
|
83
|
+
}, wrap(async () => {
|
|
84
|
+
const d = await apiGet('/api/credits');
|
|
85
|
+
const bal = d.accountBalance ?? d.balance; // accountBalance = the caller's Hermoso credits (authed); balance = the local-dev usage pill
|
|
86
|
+
return ok(`Balance: ${bal} credits${d.sessionUsed != null ? ` · session used: ${d.sessionUsed}` : ''}`, d);
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
server.registerTool('list_brands', {
|
|
90
|
+
description: "List every brand on this account (id + name) and which one this connection currently acts on. Multi-brand accounts: call this, then use_brand to switch. Read-only, free.",
|
|
91
|
+
inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
|
|
92
|
+
}, wrap(async () => {
|
|
93
|
+
const d = await apiGet('/api/brands');
|
|
94
|
+
const lines = (d.brands || []).map(b => `• ${b.name} (id: ${b.id})${b.active ? ' ← active' : ''}`).join('\n');
|
|
95
|
+
return ok(`Brands on this account:\n${lines}\n\nSwitch with use_brand.`, d);
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
server.registerTool('use_brand', {
|
|
99
|
+
description: "Pin which brand this connection generates for (multi-brand accounts). Pass the brand id or exact name from list_brands. Persists for this API key until changed.",
|
|
100
|
+
inputSchema: { brand: z.string().describe('brand id (e.g. default / p_xxx) or its exact name from list_brands') },
|
|
101
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
102
|
+
}, wrap(async ({ brand }) => {
|
|
103
|
+
const d = await apiGet('/api/brands');
|
|
104
|
+
const want = String(brand || '').trim().toLowerCase();
|
|
105
|
+
const hit = (d.brands || []).find(b => b.id.toLowerCase() === want || String(b.name || '').toLowerCase() === want);
|
|
106
|
+
if (!hit) return { content: [{ type: 'text', text: `No brand matching "${brand}". Available:\n${(d.brands || []).map(b => `• ${b.name} (id: ${b.id})`).join('\n')}` }], isError: true };
|
|
107
|
+
await apiPost('/api/keys/brand', { profileId: hit.id });
|
|
108
|
+
return ok(`Now acting on ${hit.name} (${hit.id}) — brand, memory, renders and Library all scope to it.`, { ok: true, brand: hit });
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
// ---------- planning (LLM, 0 SC credits) ----------
|
|
112
|
+
server.registerTool('plan_ad', {
|
|
113
|
+
description: 'Creative director: turn a brand + product/brief into a finished ad CONCEPT — copy variants (headline/primary/cta) plus an image_concept.prompt OR a video_storyboard, with the resolved recipe + the model ids to render with. Renders nothing; chain its output into generate_image / generate_video. Spends LLM tokens, 0 ScrapeCreators credits.',
|
|
114
|
+
inputSchema: {
|
|
115
|
+
brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name, or a brand profile object {name,domain,category,palette,products,…}. OMIT to use the workspace’s SAVED brand + memory automatically (see get_brand); use draft_brand to onboard a new one'),
|
|
116
|
+
product: z.string().describe('what to advertise + any angle/offer the user specified'),
|
|
117
|
+
format: z.enum(['auto', 'image', 'video']).optional().describe("'image', 'video', or 'auto' when unspecified"),
|
|
118
|
+
recipe: z.string().optional().describe('a recipe id from hermoso_capabilities to force an archetype'),
|
|
119
|
+
reference: z.string().optional().describe('a reference ad URL to remix the angle from — Facebook Ad Library, LinkedIn Ad Library or Google Ads Transparency links (the real ad’s copy/advertiser are fetched and fed into the concept)'),
|
|
120
|
+
language: z.string().optional(),
|
|
121
|
+
},
|
|
122
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
123
|
+
}, wrap(async ({ brand, product, format = 'auto', recipe, reference, language }) => {
|
|
124
|
+
const brandObj = brand ? (typeof brand === 'string' ? { name: brand } : brand) : null; // null → the server hydrates the workspace's saved brand/memory/taste
|
|
125
|
+
const d = await apiPost('/api/create', { brand: brandObj, product, format, recipe: recipe || '', reference: reference ? { url: reference } : null, language: language || '' });
|
|
126
|
+
const c = d.creative || d;
|
|
127
|
+
const text = `Concept (${c.format}${c.recipe_label ? ' · ' + c.recipe_label : ''}): "${c.concept}"\nHeadline: ${c.copy?.[0]?.headline || ''}\nRender model: ${c.format === 'video' ? c.vmodel : c.imodel || '—'}. Next: ${c.format === 'video' ? 'call render_ad with THIS ENTIRE creative object (Studio quality pipeline; a ≤15s storyboard renders as ONE single-pass clip — don’t stitch)' : 'generate_image with the image_concept.prompt'}.`;
|
|
128
|
+
return ok(text, c);
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
// ---------- image (synchronous) ----------
|
|
132
|
+
server.registerTool('generate_image', {
|
|
133
|
+
description: 'Render a finished ad IMAGE and return its served URL. refImages (local paths or URLs) force product-accurate compositing (drops a real product into the scene). model = a catalog id from hermoso_capabilities (omit for the default). Fast (seconds). Spends credits.',
|
|
134
|
+
inputSchema: {
|
|
135
|
+
prompt: z.string().describe('the full image prompt — subject, composition, lighting, and any on-image ad text'),
|
|
136
|
+
refImages: z.array(z.string()).optional().describe('local file paths or URLs of product/logo references to composite in'),
|
|
137
|
+
aspectRatio: z.string().optional().describe("e.g. '1:1', '9:16', '16:9'"),
|
|
138
|
+
model: z.string().optional().describe('image model id from hermoso_capabilities'),
|
|
139
|
+
imageSize: z.string().optional(),
|
|
140
|
+
},
|
|
141
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
142
|
+
}, wrap(async ({ prompt, refImages, aspectRatio, model, imageSize }) => {
|
|
143
|
+
const refs = refImages?.length ? (await Promise.all(refImages.map(toRef))).filter(Boolean) : undefined;
|
|
144
|
+
const d = await apiPost('/api/generate/image', { prompt, refImages: refs, aspectRatio, model, imageSize });
|
|
145
|
+
const img = await imageBlock(abs(d.image)); // show the actual creative inline in Claude, not just a URL
|
|
146
|
+
return { content: [{ type: 'text', text: `Image ready: ${abs(d.image)}${d.model ? ` (${d.model})` : ''}` }, ...(img ? [img] : [])], structuredContent: { ...d, image: abs(d.image) } };
|
|
147
|
+
}));
|
|
148
|
+
|
|
149
|
+
// ---------- video / avatar / stitch (job-based, polled to completion) ----------
|
|
150
|
+
server.registerTool('render_ad', {
|
|
151
|
+
description: 'RECOMMENDED for finished video ADS: render a plan_ad concept through the SAME quality pipeline as the Hermoso web Studio — timed shot list, exact/clean speech (no garbled words), text composited in post (never model-painted), brand end card, licensed music bed, real product references. Pass plan_ad’s full structured output as `creative`. Renders take 1–3 min; keep polling get_job if it returns still-rendering. Spends credits.',
|
|
152
|
+
inputSchema: {
|
|
153
|
+
creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
|
|
154
|
+
model: z.string().optional().describe('video model id from hermoso_capabilities (default: the plan’s pick)'),
|
|
155
|
+
durationSeconds: z.number().optional(),
|
|
156
|
+
aspectRatio: z.string().optional(),
|
|
157
|
+
resolution: z.string().optional().describe("'720p' (default) or '1080p'"),
|
|
158
|
+
},
|
|
159
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
160
|
+
}, wrap(async (a) => {
|
|
161
|
+
const { input, notes } = await apiPost('/api/render/assemble', a);
|
|
162
|
+
const r = await renderJob('video', input, 'MCP ad render');
|
|
163
|
+
return okVideo(`Ad video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]\n${notes || ''}`, r);
|
|
164
|
+
}));
|
|
165
|
+
|
|
166
|
+
server.registerTool('generate_video', {
|
|
167
|
+
description: 'Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration (seedance-2 goes to 15s single-pass with a full multi-beat arc — never assume a generic 8–10s cap); durationSeconds must be one of the model’s durations from hermoso_capabilities. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. Spends credits (Starter plan is video-blocked server-side).',
|
|
168
|
+
inputSchema: {
|
|
169
|
+
prompt: z.string().describe('the video prompt / shot description'),
|
|
170
|
+
refImage: z.string().optional().describe('local path or URL to anchor the first frame'),
|
|
171
|
+
durationSeconds: z.number().optional().describe('clip length in seconds'),
|
|
172
|
+
aspectRatio: z.string().optional().describe("default '9:16'"),
|
|
173
|
+
model: z.string().optional(),
|
|
174
|
+
resolution: z.string().optional().describe("'720p' (default) or '1080p'"),
|
|
175
|
+
ttsScript: z.string().optional().describe('voiceover script to speak'),
|
|
176
|
+
ttsVoice: z.string().optional().describe('voice name, e.g. Rachel / George'),
|
|
177
|
+
musicMood: z.string().optional(),
|
|
178
|
+
},
|
|
179
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
180
|
+
}, wrap(async (a) => {
|
|
181
|
+
const refImage = a.refImage ? await toRef(a.refImage) : undefined;
|
|
182
|
+
const r = await renderJob('video', { ...a, refImage }, 'MCP video');
|
|
183
|
+
return okVideo(`Video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]`, r);
|
|
184
|
+
}));
|
|
185
|
+
|
|
186
|
+
server.registerTool('generate_avatar', {
|
|
187
|
+
description: 'Render a TALKING-AVATAR / creator lip-sync clip from a portrait image + a script. Blocks until done (1–3 min). Requires the avatar capability (canAvatar in hermoso_capabilities). Spends credits.',
|
|
188
|
+
inputSchema: {
|
|
189
|
+
image: z.string().describe('local path or URL of the presenter portrait'),
|
|
190
|
+
script: z.string().describe('the words the avatar speaks'),
|
|
191
|
+
voice: z.string().optional().describe('voice name (Rachel/Sarah/George/Adam)'),
|
|
192
|
+
resolution: z.string().optional().describe("'720p' (default) or '480p' draft"),
|
|
193
|
+
},
|
|
194
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
195
|
+
}, wrap(async (a) => {
|
|
196
|
+
const image = await toRef(a.image);
|
|
197
|
+
const r = await renderJob('avatar', { ...a, image }, 'MCP avatar');
|
|
198
|
+
return okVideo(`Avatar clip ready: ${r.url} [job ${r.jobId}]`, r);
|
|
199
|
+
}));
|
|
200
|
+
|
|
201
|
+
server.registerTool('stitch_video', {
|
|
202
|
+
description: 'Render a multi-scene STITCHED video (≥2 scenes) — ONLY for spots LONGER than one model clip (>15s). A ≤15s multi-beat ad renders better and cheaper as ONE single-pass generate_video/render_ad on seedance-2 (it handles the full hook→demo→payoff arc in one take) — never stitch those. Blocks until done. Spends credits.',
|
|
203
|
+
inputSchema: {
|
|
204
|
+
scenes: z.array(z.object({}).passthrough()).min(2).describe('array of scene objects (visual + optional voiceover/seconds)'),
|
|
205
|
+
aspectRatio: z.string().optional(),
|
|
206
|
+
voiceover: z.string().optional(),
|
|
207
|
+
voice: z.string().optional(),
|
|
208
|
+
resolution: z.string().optional(),
|
|
209
|
+
model: z.string().optional(),
|
|
210
|
+
durationSeconds: z.number().optional(),
|
|
211
|
+
},
|
|
212
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
213
|
+
}, wrap(async (a) => {
|
|
214
|
+
// HARD GUARD (Dave watched an agent stitch a 15s ad into 4 separate renders): a spot that fits ONE Seedance
|
|
215
|
+
// clip renders single-pass through the Studio assembly instead — no seams, exact multi-beat arc, ~1/4 the cost.
|
|
216
|
+
// The agent's scene list becomes the storyboard; its voiceover lines ride the same exactness rails.
|
|
217
|
+
const total = +a.durationSeconds || (a.scenes || []).reduce((s, x) => s + (+x.seconds || 4), 0);
|
|
218
|
+
if (total <= 15) {
|
|
219
|
+
try {
|
|
220
|
+
const { input } = await apiPost('/api/render/assemble', {
|
|
221
|
+
creative: { recipe: '', copy: [], video_storyboard: { scenes: a.scenes, cta: '', music: '' } },
|
|
222
|
+
durationSeconds: total, aspectRatio: a.aspectRatio, resolution: a.resolution,
|
|
223
|
+
});
|
|
224
|
+
if (a.voiceover && !input.ttsScript) { input.ttsScript = String(a.voiceover); if (a.voice) input.ttsVoice = String(a.voice); }
|
|
225
|
+
const r = await renderJob('video', input, 'MCP ad render (single-pass)');
|
|
226
|
+
return okVideo(`Rendered as ONE single-pass ${input.durationSeconds}s clip instead of stitching (this length fits a single generation — cleaner cuts, exact script, far fewer credits): ${r.url} [job ${r.jobId}]`, r);
|
|
227
|
+
} catch (e) { console.error('[mcp] single-pass collapse failed, falling back to stitch:', String(e?.message || e).slice(0, 140)); }
|
|
228
|
+
}
|
|
229
|
+
const r = await renderJob('stitch', a, 'MCP stitch');
|
|
230
|
+
return okVideo(`Stitched video ready: ${r.url} [job ${r.jobId}]`, r);
|
|
231
|
+
}));
|
|
232
|
+
|
|
233
|
+
server.registerTool('get_job', {
|
|
234
|
+
description: 'Poll a render job by id. Returns status (queued|running|done|error), progress, and on done the served media URL. Renders take 1–3 minutes: keep calling this until done/error without asking the user — several calls is normal, not a stall.',
|
|
235
|
+
inputSchema: { id: z.string().describe('the job id, e.g. job_xxx') },
|
|
236
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
237
|
+
}, wrap(async ({ id }) => {
|
|
238
|
+
const j = await getJob(id);
|
|
239
|
+
const res = jobResult(j);
|
|
240
|
+
const url = abs(res?.video || res?.image || res?.url);
|
|
241
|
+
const text = `Job ${id}: ${j.status}${j.progress ? ` (${Math.round(j.progress * 100)}%)` : ''}${url ? ` → ${url}` : ''}${j.error ? ` — ${j.error}` : ''}`;
|
|
242
|
+
if (j.status === 'done' && res?.video) return okVideo(text, { ...j, url }); // resumed video → same inline poster as a direct return
|
|
243
|
+
if (j.status === 'done' && res?.image) { const img = await imageBlock(url); return { content: [{ type: 'text', text }, ...(img ? [img] : [])], structuredContent: { ...j, url } }; }
|
|
244
|
+
return ok(text, { ...j, url });
|
|
245
|
+
}));
|
|
246
|
+
|
|
247
|
+
// ---------- skills (Higgsfield get_workflow_instructions parity: workflows ship as SKILL.md bundles) ----------
|
|
248
|
+
server.registerTool('list_skills', {
|
|
249
|
+
description: 'List the bundled Hermoso SKILLS — multi-step workflow instructions (SKILL.md) that orchestrate the other tools (research an ad space, plan+render a finished ad, product photoshoot, raw generation) — plus the in-app strategy skills and creative recipes. Call get_skill to load a bundle. Read-only, free.',
|
|
250
|
+
inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
|
|
251
|
+
}, wrap(async () => {
|
|
252
|
+
const { readdir, readFile } = await import('node:fs/promises');
|
|
253
|
+
const dir = new URL('../skills/', import.meta.url);
|
|
254
|
+
let bundles = [];
|
|
255
|
+
try {
|
|
256
|
+
const names = await readdir(dir);
|
|
257
|
+
bundles = (await Promise.all(names.map(async (n) => {
|
|
258
|
+
try {
|
|
259
|
+
const md = await readFile(new URL(`../skills/${n}/SKILL.md`, import.meta.url), 'utf8');
|
|
260
|
+
const desc = (/description:\s*>?-?\s*\n?([\s\S]*?)\n[a-z_-]+:/.exec(md)?.[1] || '').replace(/\s+/g, ' ').trim().slice(0, 220);
|
|
261
|
+
return { name: n, description: desc };
|
|
262
|
+
} catch { return null; }
|
|
263
|
+
}))).filter(Boolean);
|
|
264
|
+
} catch {}
|
|
265
|
+
const d = await apiGet('/api/skills').catch(() => ({ skills: [] }));
|
|
266
|
+
const inApp = (d.skills || []).map(s => `${s.id} (${s.kind || s.group})`).join(', ');
|
|
267
|
+
const text = `Skill bundles (call get_skill with the name):\n${bundles.map(b => `- ${b.name}: ${b.description}`).join('\n') || '(none bundled)'}\n\nIn-app strategy skills + creative recipes (pass as plan_ad's recipe / create's skill): ${inApp}`;
|
|
268
|
+
return ok(text, { bundles, inApp: d.skills || [] });
|
|
269
|
+
}));
|
|
270
|
+
|
|
271
|
+
server.registerTool('get_skill', {
|
|
272
|
+
description: 'Load a bundled skill’s full SKILL.md workflow instructions by name (from list_skills). Follow the loaded instructions to run that workflow with the other tools. Read-only, free.',
|
|
273
|
+
inputSchema: { name: z.string().describe('bundle name from list_skills, e.g. hermoso-generate') },
|
|
274
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
275
|
+
}, wrap(async ({ name }) => {
|
|
276
|
+
const safe = String(name).replace(/[^a-z0-9-]/gi, '');
|
|
277
|
+
const { readFile } = await import('node:fs/promises');
|
|
278
|
+
const md = await readFile(new URL(`../skills/${safe}/SKILL.md`, import.meta.url), 'utf8').catch(() => null);
|
|
279
|
+
if (!md) return { content: [{ type: 'text', text: `No skill bundle named "${safe}" — call list_skills for the catalog.` }], isError: true };
|
|
280
|
+
return ok(md.slice(0, 24000), { name: safe });
|
|
281
|
+
}));
|
|
282
|
+
|
|
283
|
+
server.registerTool('list_jobs', {
|
|
284
|
+
description: 'List the most recent render jobs + how many are currently running, so you can report on or resume in-flight work.',
|
|
285
|
+
inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
|
|
286
|
+
}, wrap(async () => {
|
|
287
|
+
const d = await apiGet('/api/jobs');
|
|
288
|
+
const lines = (d.jobs || []).slice(0, 12).map(j => `${j.id} ${j.type} ${j.status}`).join('\n');
|
|
289
|
+
return ok(`${d.running} running. Recent:\n${lines}`, d);
|
|
290
|
+
}));
|
|
291
|
+
|
|
292
|
+
// ---------- research / discovery ----------
|
|
293
|
+
server.registerTool('find_competitors', {
|
|
294
|
+
description: "Discover a brand's competitor / similar / adjacent brands from its domain (Claude grounded by web search). mode=competitors (default, excludes the searched company), inspiration (best relevant ads incl. it), or company. 0 ScrapeCreators credits.",
|
|
295
|
+
inputSchema: {
|
|
296
|
+
domain: z.string().describe('the brand domain, e.g. yourbrand.com'),
|
|
297
|
+
mode: z.enum(['competitors', 'inspiration', 'company']).optional(),
|
|
298
|
+
},
|
|
299
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
300
|
+
}, wrap(async ({ domain, mode = 'competitors' }) => {
|
|
301
|
+
const d = await apiPost('/api/inspire/competitors', { domain, mode });
|
|
302
|
+
const list = (d.candidates || []).map(c => `${c.name} (${c.domain || '—'}, ${c.kind})`).join('; ');
|
|
303
|
+
return ok(`Found ${d.candidates?.length || 0}: ${list}`, d);
|
|
304
|
+
}));
|
|
305
|
+
|
|
306
|
+
server.registerTool('pull_competitor_ads', {
|
|
307
|
+
description: 'Pull a brand\'s real running ads across Meta / Google / LinkedIn ad libraries (deduped, sorted, right page resolved). Spends ScrapeCreators credits.',
|
|
308
|
+
inputSchema: {
|
|
309
|
+
companyName: z.string().optional().describe('the advertiser name'),
|
|
310
|
+
domain: z.string().optional().describe('the advertiser domain'),
|
|
311
|
+
platforms: z.array(z.string()).optional().describe("default ['facebook']; add 'google','linkedin'"),
|
|
312
|
+
country: z.string().optional().describe("2-letter, default 'US'"),
|
|
313
|
+
limit: z.number().optional(),
|
|
314
|
+
sort: z.string().optional().describe("'longest_running' (default) etc."),
|
|
315
|
+
},
|
|
316
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
317
|
+
}, wrap(async (a) => {
|
|
318
|
+
const d = await apiPost('/api/inspire/fanout', { platforms: ['facebook'], country: 'US', limit: 30, sort: 'longest_running', ...a });
|
|
319
|
+
return ok(`Pulled ads for ${a.companyName || a.domain}.`, d);
|
|
320
|
+
}));
|
|
321
|
+
|
|
322
|
+
server.registerTool('research_ads', {
|
|
323
|
+
description: 'Natural-language ad research: a Claude tool-use loop over Meta/Google/LinkedIn ad libraries + organic TikTok. Returns a summary + the found ads (with their served URLs). Spends LLM tokens + ScrapeCreators credits.',
|
|
324
|
+
inputSchema: {
|
|
325
|
+
query: z.string().describe('what to research, e.g. "the longest-running protein-pancake ads on Meta"'),
|
|
326
|
+
brand: z.union([z.string(), z.object({}).passthrough()]).optional(),
|
|
327
|
+
},
|
|
328
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
329
|
+
}, wrap(async ({ query, brand }) => {
|
|
330
|
+
const brandObj = typeof brand === 'string' ? { name: brand } : brand || null;
|
|
331
|
+
const d = await apiSSE('/api/explore/chat', { messages: [{ role: 'user', content: query }], brand: brandObj });
|
|
332
|
+
return ok(`${d.reply || ''}\n\n(${(d.results || []).length} ads found)`, { reply: d.reply, results: d.results, actions: d.actions });
|
|
333
|
+
}));
|
|
334
|
+
|
|
335
|
+
// ---------- brand onboarding ----------
|
|
336
|
+
server.registerTool('get_brand', {
|
|
337
|
+
description: 'What Hermoso ALREADY KNOWS for this account/workspace — the same saved brand profile (products, logos, palette, positioning) + learned memory the web Studio uses. Call this FIRST: if hasBrand is true you can omit brand everywhere; if false, onboard with draft_brand. 0 credits.',
|
|
338
|
+
inputSchema: {},
|
|
339
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
340
|
+
}, wrap(async () => {
|
|
341
|
+
const d = await apiGet('/api/brand/current');
|
|
342
|
+
const text = d?.hasBrand
|
|
343
|
+
? `Saved brand: ${d.brand.name || d.brand.domain}${d.brand.category ? ' · ' + d.brand.category : ''} · ${d.memoryCount} learned memory notes. plan_ad / plan_variations / create use it automatically when you omit brand.`
|
|
344
|
+
: 'No saved brand for this workspace yet — onboard one with draft_brand (it saves automatically), or the user can onboard in the web Studio.';
|
|
345
|
+
return ok(text, d);
|
|
346
|
+
}));
|
|
347
|
+
|
|
348
|
+
server.registerTool('draft_brand', {
|
|
349
|
+
description: 'Onboard a brand profile — from a website domain, a free-text description, or a social handle — into a {name, products, logo, …} object you can pass to plan_ad / generate. 0 ScrapeCreators credits.',
|
|
350
|
+
inputSchema: {
|
|
351
|
+
domain: z.string().optional().describe('a website to scrape'),
|
|
352
|
+
description: z.string().optional().describe('a free-text brand description (no website)'),
|
|
353
|
+
socialHandle: z.string().optional(),
|
|
354
|
+
platform: z.string().optional().describe('platform for socialHandle (instagram/tiktok/…)'),
|
|
355
|
+
save: z.boolean().optional().describe('save as the workspace’s brand (like Studio onboarding) so plan_ad/create use it automatically. Default: saves only when NO brand is saved yet; pass true to overwrite, false to never save'),
|
|
356
|
+
},
|
|
357
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
358
|
+
}, wrap(async ({ save, ...a }) => {
|
|
359
|
+
const d = await apiPost('/api/brand/draft', a);
|
|
360
|
+
const p = d.profile || d;
|
|
361
|
+
let saved = false;
|
|
362
|
+
if (save !== false) {
|
|
363
|
+
try {
|
|
364
|
+
const cur = save === true ? null : await apiGet('/api/brand/current').catch(() => null);
|
|
365
|
+
if (save === true || !cur?.hasBrand) {
|
|
366
|
+
const bk = PROFILE !== 'default' ? `heist.brand.v1.${PROFILE}` : 'heist.brand.v1'; // mirror the webapp's per-profile key namespacing
|
|
367
|
+
await apiPut(`/api/store/${encodeURIComponent(bk)}`, { value: JSON.stringify(p) });
|
|
368
|
+
saved = true;
|
|
369
|
+
}
|
|
370
|
+
} catch {} // saving is best-effort — the drafted profile is still returned either way
|
|
371
|
+
}
|
|
372
|
+
return ok(`Drafted brand: ${p.name || '—'}${p.category ? ' · ' + p.category : ''}.${saved ? ' Saved as the workspace brand — plan_ad/create now use it automatically.' : ' Pass this object to plan_ad.'}`, p);
|
|
373
|
+
}));
|
|
374
|
+
|
|
375
|
+
// ---------- assets ----------
|
|
376
|
+
server.registerTool('fetch_asset', {
|
|
377
|
+
description: 'Resolve a generated asset reference (a /generated/… path or any URL) to a clickable absolute URL + a direct download URL.',
|
|
378
|
+
inputSchema: { url: z.string().describe('the asset url or /generated/ path'), name: z.string().optional() },
|
|
379
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
380
|
+
}, wrap(async ({ url, name }) => {
|
|
381
|
+
const absolute = abs(url);
|
|
382
|
+
const dl = `${API_BASE}/api/download?url=${encodeURIComponent(url)}${name ? `&name=${encodeURIComponent(name)}` : ''}`;
|
|
383
|
+
return ok(`Asset: ${absolute}\nDownload: ${dl}`, { url: absolute, downloadUrl: dl });
|
|
384
|
+
}));
|
|
385
|
+
|
|
386
|
+
// ---------- post-production & analysis (Higgsfield-parity wave: each wraps an EXISTING worker/route) ----------
|
|
387
|
+
server.registerTool('analyze_video', {
|
|
388
|
+
description: "Break a video ad down into its structure: the verbatim transcript (voiceover + on-screen text) with a beat list, plus duration and sampled frame timestamps. Use to study a reference/competitor ad before remixing its structure. Costs ~a transcription call; no ScrapeCreators credits.",
|
|
389
|
+
inputSchema: { url: z.string().describe('the video URL (a served /generated/ path or a public http(s) video)') },
|
|
390
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
391
|
+
}, wrap(async ({ url }) => {
|
|
392
|
+
const [fr, tr] = await Promise.all([
|
|
393
|
+
apiGet(`/api/video/frames?n=auto&url=${encodeURIComponent(url)}`).catch(() => null),
|
|
394
|
+
apiGet(`/api/video/transcript?url=${encodeURIComponent(url)}`).catch(() => null),
|
|
395
|
+
]);
|
|
396
|
+
const dur = fr?.durationSeconds, times = fr?.times || [];
|
|
397
|
+
const transcript = tr?.transcript || '(no transcript — the video may be silent or unreachable)';
|
|
398
|
+
return ok(`Duration: ${dur ? Math.round(dur) + 's' : 'unknown'} · frames sampled at: ${times.map(t => Math.round(t * 10) / 10 + 's').join(', ') || 'n/a'}\n\nTranscript & beats:\n${transcript}`, { durationSeconds: dur, frameTimes: times, transcript: tr?.transcript || null });
|
|
399
|
+
}));
|
|
400
|
+
|
|
401
|
+
server.registerTool('score_ad', {
|
|
402
|
+
description: "Virality/performance prediction for a finished ad (image or video URL): overall score, per-dimension breakdown (scroll-stop, hook, clarity, brand/product, CTA, retention, goal fit), strengths, and the single biggest fix. Use BEFORE spending on distribution, or to rank variants.",
|
|
403
|
+
inputSchema: {
|
|
404
|
+
url: z.string().describe('the ad asset URL (a /generated/ path or public URL)'),
|
|
405
|
+
kind: z.enum(['image', 'video']).optional(),
|
|
406
|
+
intent: z.string().optional().describe('what the ad is trying to achieve, for goal-fit scoring'),
|
|
407
|
+
},
|
|
408
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
409
|
+
}, wrap(async ({ url, kind = 'image', intent = '' }) => {
|
|
410
|
+
const d = await apiPost('/api/score/ad', { url, kind, intent, format: kind });
|
|
411
|
+
if (!d) return ok('Could not score that ad.');
|
|
412
|
+
const dims = (d.dimensions || []).map(x => `${x.name}: ${x.score}`).join(' · ');
|
|
413
|
+
return ok(`Overall ${d.overall}/100 (${d.tier || ''})\n${dims}\nBiggest lever: ${d.top_fix || '—'}`, d);
|
|
414
|
+
}));
|
|
415
|
+
|
|
416
|
+
server.registerTool('reframe_video', {
|
|
417
|
+
description: "Reframe a video to a different aspect ratio (e.g. 16:9 master → 9:16 vertical) with smart subject tracking. Paid render; returns the served URL of the reframed video.",
|
|
418
|
+
inputSchema: { video: z.string().describe('the source video URL'), aspectRatio: z.enum(['9:16', '1:1', '16:9']).describe('the target aspect ratio') },
|
|
419
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
420
|
+
}, wrap(async ({ video, aspectRatio }) => {
|
|
421
|
+
const r = await renderJob('reframe', { video, aspectRatio }, `Reframe → ${aspectRatio}`);
|
|
422
|
+
return okVideo(`Reframed video (${aspectRatio}): ${r.url}`, r);
|
|
423
|
+
}));
|
|
424
|
+
|
|
425
|
+
server.registerTool('upscale_video', {
|
|
426
|
+
description: "Upscale a video to higher resolution (2x) for final delivery. Paid render; returns the served URL.",
|
|
427
|
+
inputSchema: { video: z.string().describe('the source video URL') },
|
|
428
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
429
|
+
}, wrap(async ({ video }) => {
|
|
430
|
+
const r = await renderJob('upscale', { video, factor: 2 }, 'Upscale 2x');
|
|
431
|
+
return okVideo(`Upscaled video: ${r.url}`, r);
|
|
432
|
+
}));
|
|
433
|
+
|
|
434
|
+
server.registerTool('dub_video', {
|
|
435
|
+
description: "Remake a finished video ad's voiceover in another language (translated script, re-voiced, re-muxed). Paid; returns the served URL of the localized video.",
|
|
436
|
+
inputSchema: {
|
|
437
|
+
video: z.string().describe('the source video URL'),
|
|
438
|
+
language: z.string().describe("target language, e.g. 'Spanish', 'de', 'French (Canada)'"),
|
|
439
|
+
script: z.string().optional().describe('the original spoken script if known — improves translation fidelity'),
|
|
440
|
+
},
|
|
441
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
442
|
+
}, wrap(async ({ video, language, script }) => {
|
|
443
|
+
const r = await renderJob('dub', { video, language, script: script || '' }, `Dub → ${language}`);
|
|
444
|
+
return okVideo(`Localized video (${language}): ${r.url}`, r);
|
|
445
|
+
}));
|
|
446
|
+
|
|
447
|
+
server.registerTool('recast_motion', {
|
|
448
|
+
description: "Motion transfer: re-perform a reference video's motion with a different person/character (supply their image). The reference clip drives the movement; the image supplies the identity. Paid render.",
|
|
449
|
+
inputSchema: {
|
|
450
|
+
image: z.string().describe("the actor/character image URL (who should appear)"),
|
|
451
|
+
video: z.string().describe('the reference video whose motion to re-perform'),
|
|
452
|
+
prompt: z.string().optional().describe('optional scene/style guidance'),
|
|
453
|
+
orientation: z.enum(['video', 'image']).optional().describe("which aspect to keep: the video's (default) or the image's"),
|
|
454
|
+
},
|
|
455
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
456
|
+
}, wrap(async ({ image, video, prompt = '', orientation = 'video' }) => {
|
|
457
|
+
const r = await renderJob('motion', { image, video, prompt, orientation }, 'Motion recast');
|
|
458
|
+
return okVideo(`Recast video: ${r.url}`, r);
|
|
459
|
+
}));
|
|
460
|
+
|
|
461
|
+
server.registerTool('plan_variations', {
|
|
462
|
+
description: "Fan a brief into N DISTINCT ad angles (different hooks/mechanics/audiences), each with its own headline + visual brief — then render each with generate_image and rank with score_ad. LLM planning only; renders nothing itself.",
|
|
463
|
+
inputSchema: {
|
|
464
|
+
brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name or profile object; OMIT to use the workspace’s saved brand'),
|
|
465
|
+
product: z.string().describe('what to advertise'),
|
|
466
|
+
count: z.number().int().min(2).max(8).optional().describe('how many distinct variants (default 6)'),
|
|
467
|
+
language: z.string().optional(),
|
|
468
|
+
},
|
|
469
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
470
|
+
}, wrap(async ({ brand, product, count = 6, language }) => {
|
|
471
|
+
const brandObj = brand ? (typeof brand === 'string' ? { name: brand } : brand) : null;
|
|
472
|
+
const d = await apiPost('/api/batch/plan', { brand: brandObj, product, count, language: language || '' });
|
|
473
|
+
const vars = d?.variants || d?.angles || [];
|
|
474
|
+
const text = vars.map((v, i) => `${i + 1}. ${v.name || v.angle || 'Variant'} — ${v.hook || v.headline || ''}`).join('\n') || 'No variants returned.';
|
|
475
|
+
return ok(text, d);
|
|
476
|
+
}));
|
|
477
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hermoso",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Drive Hermoso — the AI ad studio — from any AI agent: MCP server, CLI, and Claude skills for researching winning ads and generating finished image & video ads.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "hermoso": "bin/hermoso.mjs" },
|
|
7
|
+
"scripts": { "mcp": "node mcp/hermoso-mcp.mjs" },
|
|
8
|
+
"dependencies": { "@modelcontextprotocol/sdk": "^1.12.0" },
|
|
9
|
+
"keywords": ["mcp", "model-context-protocol", "ai-ads", "ad-generator", "ugc", "claude", "cli", "video-ads"],
|
|
10
|
+
"homepage": "https://hermoso.ai",
|
|
11
|
+
"repository": { "type": "git", "url": "https://github.com/hermoso-ai/hermoso.git" },
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "Hermoso <hello@hermoso.ai>"
|
|
14
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hermoso-ad-from-brand
|
|
3
|
+
description: >-
|
|
4
|
+
Make a finished, on-brand ad for a company end-to-end with Hermoso: onboard the brand, write the concept +
|
|
5
|
+
copy, then render the image or video. Use when the user says "make an ad for <company/website>", "build a
|
|
6
|
+
launch ad for my brand", or gives a domain/brand and wants a complete ad, not just research. NOT for: just
|
|
7
|
+
pulling competitor ads (use hermoso-research) or rendering from a prompt you already have (use hermoso-generate).
|
|
8
|
+
argument-hint: "[brand or website + what to advertise — e.g. 'an ad for yourbrand.com — our best-selling olive oil']"
|
|
9
|
+
allowed-tools: Bash
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Hermoso — full ad from a brand
|
|
13
|
+
|
|
14
|
+
Drive the **Hermoso CLI** to go from a brand to a finished ad in three steps. Report the final media URL.
|
|
15
|
+
|
|
16
|
+
## Setup
|
|
17
|
+
- `hermoso version` to confirm the CLI; `hermoso auth login --url https://app.hermoso.ai --token <your key>` (create one under MCP & CLI).
|
|
18
|
+
|
|
19
|
+
## Procedure
|
|
20
|
+
1. **Onboard the brand** (skip if the user already gave full brand details):
|
|
21
|
+
- From a website: `hermoso brand draft --domain <domain> --json`
|
|
22
|
+
- No website: `hermoso brand draft --description "<what they sell, audience, voice>" --json`
|
|
23
|
+
- Influencer/social: `hermoso brand draft --social <handle> --platform instagram --json`
|
|
24
|
+
Keep the returned brand name/category for the next steps.
|
|
25
|
+
2. **Plan the concept + copy:** `hermoso create --brand "<name>" --product "<what to advertise + angle>" --format <auto|image|video> --json`
|
|
26
|
+
- Read the result: it has the `concept`, `copy[]` (headline/primary/cta), and the resolved **render model id** (`imodel` for images, `vmodel` for video). Tell the user the concept + headline.
|
|
27
|
+
3. **Render**, using the model the create step resolved (or run `hermoso capabilities` for options):
|
|
28
|
+
- Image: `hermoso generate image --prompt "<image_concept.prompt from step 2 — it already bakes in the copy>" --model <imodel> --aspect 1:1`
|
|
29
|
+
- Video: `hermoso generate video --prompt "<from the storyboard>" --model <vmodel> --aspect 9:16 --duration 8 --wait`
|
|
30
|
+
4. **Report the served URL.** Offer one concrete next step (a punchier hook, a different aspect ratio, or a variation).
|
|
31
|
+
|
|
32
|
+
## Notes
|
|
33
|
+
- If the brand has a real product photo and the ad features that product, pass it with `--ref ./product.png` on the image step so the packaging is accurate.
|
|
34
|
+
- Quality over cost: prefer the resolved/best model unless the user asks for cheap/fast.
|
|
35
|
+
- Everything runs against the local Hermoso server; no asset leaves the machine unless you `hermoso fetch` it.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hermoso-generate
|
|
3
|
+
description: >-
|
|
4
|
+
Generate on-brand ad creative with Hermoso — images, videos, talking-avatar clips, and multi-scene
|
|
5
|
+
stitched videos — from a prompt, returning a served media URL. Use when the user asks to "generate /
|
|
6
|
+
make / render an image or video ad", "create an ad", "make a product shot", "make a UGC video", or to
|
|
7
|
+
turn a concept into finished creative with Hermoso. NOT for: pure research/competitor lookups (use
|
|
8
|
+
hermoso-research), or editing an existing local image with a non-Hermoso tool.
|
|
9
|
+
argument-hint: "[what to generate — e.g. 'a 9:16 video ad for our protein pancakes']"
|
|
10
|
+
allowed-tools: Bash
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Hermoso — generate ad creative
|
|
14
|
+
|
|
15
|
+
You drive the **Hermoso CLI** (`hermoso`) to render images and videos. Always report the final **media URL**.
|
|
16
|
+
|
|
17
|
+
## Setup (once)
|
|
18
|
+
1. Ensure the CLI is available. From the Hermoso repo: `node bin/hermoso.mjs version` (or `hermoso version` if globally installed via `npm i -g`).
|
|
19
|
+
2. `hermoso auth login --url https://app.hermoso.ai --token <your agent key>` — create a key in the app under **MCP & CLI**. (Self-running the app? Use `--url http://localhost:3000`, no token.)
|
|
20
|
+
|
|
21
|
+
## Procedure
|
|
22
|
+
1. **Always run `hermoso capabilities` first.** It lists the valid image/video **model ids**, their credit costs, aspect ratios, video durations, and the recipe ids. Never guess a model id.
|
|
23
|
+
2. Pick a **high-quality default** (quality over cost is the house rule): for images prefer the model marked `★best` (e.g. `nano-banana-pro`); for product composites pass the real product image with `--ref`. For video, use a featured model and a sensible duration.
|
|
24
|
+
3. Generate:
|
|
25
|
+
- Image: `hermoso generate image --prompt "<full prompt incl. any on-image text>" [--ref ./product.png] [--model <id>] [--aspect 1:1]`
|
|
26
|
+
- Video: `hermoso generate video --prompt "<shot description>" [--ref ./frame.png] [--duration 8] [--aspect 9:16] [--model <id>] [--tts "<voiceover>"] [--voice Rachel] --wait`
|
|
27
|
+
- Avatar (lip-sync): `hermoso generate avatar --image ./face.png --script "<words>" [--voice George] --wait`
|
|
28
|
+
- Stitch (≥2 scenes): `hermoso generate stitch --scenes scenes.json --wait`
|
|
29
|
+
4. Video/avatar/stitch are **job-based** — keep `--wait` (default) so the command blocks and prints the final URL. If you don't wait, poll with `hermoso jobs get <id> --wait`.
|
|
30
|
+
5. **Report the served URL** (e.g. `https://assets.hermoso.ai/…`), never a raw job id. If the user wants the file, `hermoso fetch <url> --out name.png`.
|
|
31
|
+
|
|
32
|
+
## Notes
|
|
33
|
+
- `--ref` accepts local file paths (read + sent as data) or URLs; a real product/logo ref makes the output product-accurate.
|
|
34
|
+
- Add `--json` to any command for machine-readable output.
|
|
35
|
+
- If a render fails or times out, the error explains why; surface it plainly and offer to retry or pick a cheaper/faster model from `hermoso capabilities`.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hermoso-product-photoshoot
|
|
3
|
+
description: >-
|
|
4
|
+
Brand-quality product photography with Hermoso: drop a real product photo into studio, lifestyle, or hero-banner
|
|
5
|
+
scenes via reference-image compositing, so the packaging/label stays accurate. Use when the user wants
|
|
6
|
+
"product shots", "a photoshoot for my product", "lifestyle images of <product>", "hero banner", or "a pack of
|
|
7
|
+
ad images" from a real product image. NOT for: video (use hermoso-generate) or research (use hermoso-research).
|
|
8
|
+
argument-hint: "[product image + scene — e.g. './bag.png as a sunlit kitchen hero shot']"
|
|
9
|
+
allowed-tools: Bash
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Hermoso — product photoshoot
|
|
13
|
+
|
|
14
|
+
Use Hermoso's reference-image compositing so the real product (label, colours, shape) is preserved while the
|
|
15
|
+
scene around it is generated. Drive the **Hermoso CLI**.
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
- `hermoso auth login --url https://app.hermoso.ai --token <your agent key>` (key from the app’s MCP & CLI page); run `hermoso capabilities` once to see image model ids + recipes.
|
|
19
|
+
|
|
20
|
+
## Procedure
|
|
21
|
+
1. Get the **product image** path/URL from the user. This is the `--ref` — it forces product-accurate compositing.
|
|
22
|
+
2. Pick a **mode** and write the prompt accordingly (compose for ads — off-center hero, depth, directional light, negative space for copy):
|
|
23
|
+
- `product_shot` — clean studio packshot on a seamless backdrop.
|
|
24
|
+
- `lifestyle_scene` — the product in a real in-use setting (kitchen, desk, outdoors).
|
|
25
|
+
- `hero_banner` — wide banner composition with room for a headline.
|
|
26
|
+
- `social_carousel` — several angles/scenes of the same product (run the command N times with varied prompts).
|
|
27
|
+
- `ad_creative_pack` — a few finished ad images with on-image copy.
|
|
28
|
+
3. Render: `hermoso generate image --prompt "<scene + composition + any on-image text>" --ref <product image> [--model <id>] [--aspect 1:1|4:5|16:9]`
|
|
29
|
+
- Prefer the `★best` image model for hero work; a faster model is fine for bulk variations.
|
|
30
|
+
4. For a **pack**, loop: vary the scene/angle/aspect per call, collect the URLs, and present them together.
|
|
31
|
+
5. Report each served image URL; offer to `hermoso fetch` them to disk or to tweak a scene.
|
|
32
|
+
|
|
33
|
+
## Notes
|
|
34
|
+
- Keep the product as the hero; never let generated text garble the real label — describe the product as "the exact product in the reference, label unchanged."
|
|
35
|
+
- `--ref` takes a local path (read + sent) or a URL.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hermoso-research
|
|
3
|
+
description: >-
|
|
4
|
+
Competitor + ad-library intelligence with Hermoso: find a brand's competitors, pull their real running ads
|
|
5
|
+
across Meta/Google/LinkedIn, and surface the winning hooks/angles worth copying. Use when the user asks to
|
|
6
|
+
"find my competitors", "pull <brand>'s ads", "what ads are working in my niche", "research the longest-running
|
|
7
|
+
ads", or wants proven creative to remix. NOT for: generating creative (use hermoso-generate / hermoso-ad-from-brand).
|
|
8
|
+
argument-hint: "[a brand/domain or a research question — e.g. 'longest-running protein-pancake ads']"
|
|
9
|
+
allowed-tools: Bash
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Hermoso — competitor & ad research
|
|
13
|
+
|
|
14
|
+
This is Hermoso's discovery half (which most generators don't have). Drive the **Hermoso CLI**.
|
|
15
|
+
|
|
16
|
+
## Setup
|
|
17
|
+
- `hermoso auth login --url https://app.hermoso.ai --token <your agent key>` (key from the app’s MCP & CLI page).
|
|
18
|
+
|
|
19
|
+
## Procedure
|
|
20
|
+
Pick the tool that fits the ask:
|
|
21
|
+
1. **Find competitors** for a domain: `hermoso competitors <domain> [--mode competitors|inspiration|company] --json`
|
|
22
|
+
- `competitors` = head-to-head rivals; `inspiration` = best relevant ads incl. the brand itself; `company` = the company's own.
|
|
23
|
+
2. **Pull a brand's real ads** across ad libraries: `hermoso ads pull --company "<name>" [--domain <d>] [--platforms facebook,google,linkedin] [--country US] --json`
|
|
24
|
+
- Defaults to Meta (richest library). Add google/linkedin only if asked (Google detailed pulls cost more).
|
|
25
|
+
3. **Natural-language research** (Claude tool-use over ad libraries + organic TikTok): `hermoso research "<request>"`
|
|
26
|
+
- e.g. `hermoso research "the longest-running protein-pancake ads on Meta and what hooks they use"`. Prints a summary + the found ads with their URLs.
|
|
27
|
+
4. **Synthesize**: report the strongest hooks, angles, formats, and what's worth copying — be specific (quote the actual headlines/angles). If the user then wants to build one, hand off to `hermoso-ad-from-brand` / `hermoso-generate`.
|
|
28
|
+
|
|
29
|
+
## Notes
|
|
30
|
+
- Research spends ScrapeCreators credits (ad-library calls) + LLM tokens; keep platform scope to what's asked.
|
|
31
|
+
- Add `--json` for the raw ad objects (URLs, copy, run dates) when the user wants the data, not a summary.
|