hermoso 0.1.4 → 0.1.5

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/README.md CHANGED
@@ -8,18 +8,17 @@ image & video ads (your real product composited in, copy + CTA included) — all
8
8
  ## Instant: the hosted Claude.ai connector
9
9
 
10
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.
11
+ your Hermoso account, done — the full studio toolset with your saved brand context, billed to your plan.
12
12
 
13
13
  ## Quickstart for Claude Code / Cursor / scripts (2 minutes)
14
14
 
15
15
  1. **Get an account** at [app.hermoso.ai](https://app.hermoso.ai) — free tier included; plans & credits are the
16
16
  same ones the web Studio uses.
17
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):
18
+ 3. **Connect** — no clone needed, `npx` runs the published `hermoso` package (Claude Code shown; any MCP client works):
19
19
 
20
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"
21
+ claude mcp add hermoso -e HERMOSO_TOKEN=hmk_… -- npx -y hermoso mcp
23
22
  ```
24
23
 
25
24
  Your agent now has the full studio **with your workspace's context**: the brand profile, products, logos and
@@ -28,28 +27,33 @@ learned memory you set up in the web app apply automatically (`get_brand` shows
28
27
 
29
28
  ## 1. MCP server (stdio) — Claude Code / Cursor / Codex
30
29
 
31
- `mcp/hermoso-mcp.mjs` is a stdio MCP server exposing 22 tools.
30
+ `hermoso mcp` runs a stdio MCP server exposing the full studio toolset (40+ tools). The published `hermoso`
31
+ package means no clone — `npx -y hermoso mcp` fetches and runs it:
32
32
 
33
33
  ```bash
34
- npm install
35
- claude mcp add hermoso -- node "$(pwd)/mcp/hermoso-mcp.mjs"
34
+ claude mcp add hermoso -e HERMOSO_TOKEN=hmk_… -- npx -y hermoso mcp
36
35
  ```
37
36
 
38
37
  Cursor / Codex — add to `mcp.json` (Codex uses the TOML equivalent):
39
38
 
40
39
  ```json
41
- { "mcpServers": { "hermoso": { "command": "node", "args": ["<repo>/mcp/hermoso-mcp.mjs"],
40
+ { "mcpServers": { "hermoso": { "command": "npx", "args": ["-y", "hermoso", "mcp"],
42
41
  "env": { "HERMOSO_API_BASE": "https://app.hermoso.ai", "HERMOSO_TOKEN": "<your token>" } } } }
43
42
  ```
44
43
 
45
44
  Then ask your agent: *“Generate an image ad with Hermoso.”*
46
45
 
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.
46
+ **Tools (40+):** research/ad-spy (`find_competitors`, `pull_competitor_ads`, `research_ads`, `search_meta_ads`,
47
+ `search_google_ads`, `search_linkedin_ads`, `search_tiktok`, `search_instagram`, `search_youtube`, `search_reddit`,
48
+ `search_threads`, `scrapecreators_fetch`), plan → generate → finish (`plan_ad`, `plan_variations`, `generate_image`,
49
+ `generate_video`, `generate_avatar`, `render_ad`, `make_template_ad`, `stitch_video`, `reframe_video`,
50
+ `upscale_video`, `dub_video`, `change_voice`, `recast_motion`, `remix_static`, `finish_video`, `fix_beat`),
51
+ brand + account (`get_brand`, `list_brands`, `use_brand`, `draft_brand`, `list_product_photos`, `set_product_image`,
52
+ `hermoso_capabilities`, `hermoso_credits`, `buy_credits`), and analysis/jobs (`analyze_video`, `score_ad`,
53
+ `check_ad_policy`, `competitor_teardown`, `mine_angles`, `get_job`, `list_jobs`, `get_skill`, `list_skills`,
54
+ `fetch_asset`). Call `hermoso_capabilities` first — it returns valid model ids and per-render credit costs;
55
+ `tools/list` is the authoritative current set. Render jobs queue server-side and poll to completion, returning a
56
+ served URL.
53
57
 
54
58
  ## 2. CLI — the token-cheap path for terminal agents
55
59
 
package/bin/hermoso.mjs CHANGED
@@ -70,6 +70,12 @@ async function main() {
70
70
  process.env.HERMOSO_API_BASE = process.env.HERMOSO_API_BASE || cfg.apiBase || 'https://app.hermoso.ai';
71
71
  if (cfg.token && !process.env.HERMOSO_TOKEN) process.env.HERMOSO_TOKEN = cfg.token;
72
72
  if (cfg.profile && !process.env.HERMOSO_PROFILE) process.env.HERMOSO_PROFILE = cfg.profile;
73
+
74
+ // `hermoso mcp` → run the stdio MCP server (Claude Code / Cursor / Codex spawn this, e.g. `npx -y hermoso mcp`).
75
+ // It OWNS stdout as the JSON-RPC channel, so hand off immediately and print nothing to stdout here. The
76
+ // config-resolved API base + token (set just above) ride into the server's client via env; all logs go to stderr.
77
+ if (group === 'mcp') { await import('../mcp/hermoso-mcp.mjs'); return; }
78
+
73
79
  const api = await import('../mcp/client.mjs');
74
80
  const out = (label, data) => { if (flags.json) console.log(JSON.stringify(data, null, 2)); else console.log(label); };
75
81
  const absUrl = (u) => (u && u.startsWith('/') ? api.API_BASE + u : u);
@@ -149,7 +155,8 @@ async function main() {
149
155
  generate image --prompt [--ref] [--model] [--aspect] generate video|avatar|stitch … [--wait]
150
156
  jobs list | jobs get <id> [--wait] competitors <domain>
151
157
  ads pull (--company|--domain) research "<request>"
152
- fetch <url> [--out] version
158
+ fetch <url> [--out] mcp (run the stdio MCP server)
159
+ version
153
160
  add --json to any command for machine output.`);
154
161
  }
155
162
  } catch (e) { die(e?.message || String(e)); }
package/mcp/tools.mjs CHANGED
@@ -41,7 +41,7 @@ const wrap = (fn) => async (args, extra) => {
41
41
  catch (e) {
42
42
  let msg = `Error: ${e?.message || e}`;
43
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.`;
44
+ if (/not enough credits/i.test(msg)) msg += `\nRun buy_credits to get a ready-to-pay checkout link (credit packs only; your human pays on Stripe's secure page — nothing was charged here). Or top up / upgrade at https://app.hermoso.ai (Settings → Billing). hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.`;
45
45
  return { content: [{ type: 'text', text: msg }], isError: true };
46
46
  }
47
47
  };
@@ -69,10 +69,10 @@ export function registerTools(server) {
69
69
  inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
70
70
  }, wrap(async () => {
71
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('; ');
72
+ 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('; ');
73
73
  // durations + per-duration credits MATTER: without them agents assume the generic "AI video caps at 8-10s"
74
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('; ');
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.refs ? `, ${m.refs.max} reference image${m.refs.max === 1 ? '' : 's'}${m.refs.required ? ' (required — image-to-video only)' : ''}` : ''}${m.resolutions ? `, resolutions ${m.resolutions.join('/')}` : ''}${m.best ? ', best' : ''})`).join('; ');
76
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
77
  return ok(text, d);
78
78
  }));
@@ -86,6 +86,28 @@ export function registerTools(server) {
86
86
  return ok(`Balance: ${bal} credits${d.sessionUsed != null ? ` · session used: ${d.sessionUsed}` : ''}`, d);
87
87
  }));
88
88
 
89
+ // AGENT BILLING HANDOFF: out of credits → mint a ready-to-pay Stripe checkout link for a credit PACK and hand the
90
+ // URL to the human. The human pays on Stripe's hosted page (agents never spend money directly); credits post to
91
+ // this account automatically once payment completes. Packs only — subscriptions are managed by a person in-app.
92
+ server.registerTool('buy_credits', {
93
+ description: "Out of credits? Get a ready-to-pay checkout link for a credit PACK. Call with no argument to list the available packs (id · credits · price); call again with `pack` set to a pack id to get a Stripe checkout URL. Hand that URL to your human — THEY pay on Stripe's secure hosted page (agents never spend money directly), and the credits land on this account the moment payment completes. Packs only; subscriptions are managed by a person in Settings → Billing. Nothing is charged until your human pays.",
94
+ inputSchema: {
95
+ pack: z.string().optional().describe('the pack id to buy (e.g. pack-2k) — omit to list the available packs first'),
96
+ },
97
+ annotations: { readOnlyHint: true, openWorldHint: true }, // creates no server-side charge; the human pays on Stripe's page
98
+ }, wrap(async ({ pack }) => {
99
+ const cfg = await apiGet('/api/billing/config');
100
+ const packs = (cfg.packs || []).map(p => ({ id: p.id, credits: p.credits, priceUsd: p.priceUsd }));
101
+ if (!pack) {
102
+ const lines = packs.map(p => `• ${p.id} — ${p.credits.toLocaleString()} credits · $${p.priceUsd}`).join('\n') || '(no packs configured)';
103
+ return ok(`Credit packs you can buy:\n${lines}\n\nCall buy_credits again with pack="<id>" to get a checkout link for your human to pay.`, { packs });
104
+ }
105
+ const match = packs.find(p => p.id === pack);
106
+ if (!match) return ok(`No pack "${pack}". Available: ${packs.map(p => p.id).join(', ') || '(none)'}. Call buy_credits with no argument to see details.`, { packs });
107
+ const d = await apiPost('/api/billing/checkout-link', { packId: pack });
108
+ return ok(`Checkout link for ${match.credits.toLocaleString()} credits ($${d.amountUsd ?? match.priceUsd}):\n${d.url}\n\nGive this URL to your human to pay on Stripe's secure page — the credits post to this account automatically once payment completes. Nothing is charged until they pay.`, d);
109
+ }));
110
+
89
111
  server.registerTool('list_brands', {
90
112
  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
113
  inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
@@ -176,7 +198,7 @@ export function registerTools(server) {
176
198
 
177
199
 
178
200
  server.registerTool('make_template_ad', {
179
- description: "Render a NATIVE-STYLE TEMPLATE ad from pure HTML — no AI video/image model in the loop, renders in ~30 seconds for a couple of credits. Perfect for native-feel social ads at volume. YOU author the content (short, casual, believable — never marketing-speak). Templates (pass as config.template): 'imessage-chat' (VIDEO ~15s: a real-looking iMessage thread where a friend reveals the product as a rich-link card; config: { thread: { contactName, messages: [{from:'them'|'me', text?, product?:{image,title,domain}}] }, theme?:'dark'|'light', endCard:{headline,cta,domain,logo?,color} } — 4-6 short lowercase bubbles, product card mid-thread from 'me', 1-2 excited replies after); 'chatgpt-chat' (VIDEO: a ChatGPT answer streams the punchline; config: { question, answer (may **bold** the brand), productImage?, endCard }); 'apple-notes' (VIDEO: an iPhone note types itself out; config: { title, lines: string[], theme?, endCard }); 'value-prop' (VIDEO ~17s kinetic typography: config: { hook, claims: string[] (3-5, ≤4 words each), productImages: string[], palette: string[], endCard }); 'static-mockup' (IMAGE: config: { style:'imessage'|'notes'|'card', size?:{w,h}, ...style fields }); 'airdrop-carousel' (VIDEO ~10s: an iOS AirDrop share card springs up and cycles 3-16 REAL product photos to a full-lineup payoff; config: { brandName, products: [{image, title?}], contactLine?, endCard }); 'app-ui-tour' (VIDEO ~12-16s for APP brands: floating-iPhone mockup walks through REAL app screenshots with kinetic captions; config: { hook?, appName, iconImage?, beats: [{screenImage, caption}] (2-6), palette?, fontStack?, endCard }); 'imessage-cascade' (VIDEO ~12s: iOS notification banners spring in and stack over a blurred backdrop; config: { notifications: [{sender, text}] (4-8), backgroundImage?, endCard }); 'photo-grid' (VIDEO ~8s: collage assembles real photos one at a time; config: { title?, photos: [{image, label?}] (4-9), palette?, fontStack?, endCard }); 'vignette' (VIDEO ~12s: cinematic Ken-Burns hero film; config: { hook, lines: [2-4 ≤40ch], heroImage, palette?, fontStack?, endCard }); 'myth-vs-fact' (VIDEO ~15-26s VO-FIRST kinetic explainer with a real VOICEOVER — the family's ONE paid-audio format: a calm-authority read busts 2-4 myths, each MYTH line slamming in with a red per-line strike then the counter FACT line landing bold+affirmative, word-level KARAOKE lighting each word as the VO speaks it; config: { pairs: [{ myth (≤50ch, the common wrong belief), fact (≤60ch, the corrective truth — wrap its payoff phrase in [brackets] to accent it) }] (2-4), palette?, fontStack?, endCard }. Real product truths only — NEVER invent stats. Costs the flat template credits PLUS a small voiceover charge); 'carousel' (MULTI-IMAGE: 5-10 branded 1080×1080 PNG slides for Meta/LinkedIn/IG carousels — returns an images[] array, one PNG per slide; config: { cover: { hook?, title }, slides: [{ headline (≤8 words), support? (≤16 words), stat?: { value, label } }] (3-8; a stat slide is a REAL user-supplied number like '94%' or '40k+' + a label, never invented), cta: { headline, cta?, domain? }, productImage?, logo?, palette?, fontStack?, endCardColor? }). Image URLs may be any public URL — the server localizes them. Spends a couple of credits.",
201
+ description: "Render a NATIVE-STYLE TEMPLATE ad from pure HTML — no AI video/image model in the loop, renders in ~30 seconds for a couple of credits. Perfect for native-feel social ads at volume. YOU author the content (short, casual, believable — never marketing-speak). Templates (pass as config.template): 'imessage-chat' (VIDEO ~15s: a real-looking iMessage thread where a friend reveals the product as a rich-link card; config: { thread: { contactName, messages: [{from:'them'|'me', text?, product?:{image,title,domain}}] }, theme?:'dark'|'light', endCard:{headline,cta,domain,logo?,color} } — 4-6 short lowercase bubbles, product card mid-thread from 'me', 1-2 excited replies after); 'chatgpt-chat' (VIDEO: a ChatGPT answer streams the punchline; config: { question, answer (may **bold** the brand), productImage?, endCard }); 'apple-notes' (VIDEO: an iPhone note types itself out; config: { title, lines: string[], theme?, endCard }); 'value-prop' (VIDEO ~17s kinetic typography: config: { hook (≤40 chars), claims: string[] (3-5 COMPLETE phrases, ≤6 words / ≤34 chars each — a finished thought, NEVER a clipped clause like 'Looks good on any'), productImages: string[] (2-3 DISTINCT photos — one rotates per card), palette: string[], endCard }); 'static-mockup' (IMAGE: config: { style:'imessage'|'notes'|'card', size?:{w,h}, ...style fields }); 'airdrop-carousel' (VIDEO ~10s: an iOS AirDrop share card springs up and cycles 3-16 REAL product photos to a full-lineup payoff; config: { brandName, products: [{image, title?}], contactLine?, endCard }); 'app-ui-tour' (VIDEO ~12-16s for APP brands: floating-iPhone mockup walks through REAL app screenshots with kinetic captions; config: { hook?, appName, iconImage?, beats: [{screenImage, caption}] (2-6), palette?, fontStack?, endCard }); 'imessage-cascade' (VIDEO ~12s: iOS notification banners spring in and stack over a blurred backdrop; config: { notifications: [{sender, text}] (4-8), backgroundImage?, endCard }); 'photo-grid' (VIDEO ~8s: collage assembles real photos one at a time; config: { title?, photos: [{image, label?}] (4-9), palette?, fontStack?, endCard }); 'vignette' (VIDEO ~12s: cinematic Ken-Burns hero film; config: { hook, lines: [2-4 ≤40ch], heroImage, palette?, fontStack?, endCard }); 'myth-vs-fact' (VIDEO ~15-26s VO-FIRST kinetic explainer with a real VOICEOVER — the family's ONE paid-audio format: a calm-authority read busts 2-4 myths, each MYTH line slamming in with a red per-line strike then the counter FACT line landing bold+affirmative, word-level KARAOKE lighting each word as the VO speaks it; config: { pairs: [{ myth (≤50ch, the common wrong belief), fact (≤60ch, the corrective truth — wrap its payoff phrase in [brackets] to accent it) }] (2-4), palette?, fontStack?, endCard }. Real product truths only — NEVER invent stats. Costs the flat template credits PLUS a small voiceover charge); 'carousel' (MULTI-IMAGE: 5-10 branded 1080×1080 PNG slides for Meta/LinkedIn/IG carousels — returns an images[] array, one PNG per slide; config: { cover: { hook?, title }, slides: [{ headline (≤8 words), support? (≤16 words), stat?: { value, label } }] (3-8; a stat slide is a REAL user-supplied number like '94%' or '40k+' + a label, never invented), cta: { headline, cta?, domain? }, productImage?, logo?, palette?, fontStack?, endCardColor? }). Image URLs may be any public URL — the server localizes them. Spends a couple of credits.",
180
202
  inputSchema: {
181
203
  config: z.object({}).passthrough().describe("the template config — MUST include config.template (one of the template ids above) plus that template's fields"),
182
204
  },
@@ -595,7 +617,7 @@ export function registerTools(server) {
595
617
  }));
596
618
 
597
619
  server.registerTool('draft_brand', {
598
- 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.',
620
+ 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. IMPORTANT: a domain can resolve to a DIFFERENT company than intended (e.g. bala.com is an engineering firm, not the Bala fitness brand at shopbala.com). Before spending any credits on research or renders, VERIFY the returned `name` (and `summary`) match the brand the user meant; if it looks wrong, re-draft with the correct domain or a description (pass save:false until confirmed) — this tool cannot ask the user, so the caller owns that check.',
599
621
  inputSchema: {
600
622
  domain: z.string().optional().describe('a website to scrape'),
601
623
  description: z.string().optional().describe('a free-text brand description (no website)'),
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
+ "mcpName": "io.github.hermoso-ai/hermoso",
4
5
  "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
6
  "type": "module",
6
7
  "bin": {