hermoso 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/hermoso.mjs CHANGED
@@ -43,14 +43,23 @@ async function main() {
43
43
  const [group, sub] = pos;
44
44
  const cfg = await loadConfig();
45
45
 
46
- // ---- auth: no network call today; records base URL + optional token (the OAuth seam) ----
46
+ // ---- auth: `login` opens the browser to mint + store an agent key (nothing to paste — matches the app's OAuth
47
+ // connectors); `--token <key>` is the manual fallback; a localhost base needs no auth (dev). ----
47
48
  if (group === 'auth') {
48
49
  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;
50
+ const apiBase = (flags.url || cfg.apiBase || 'https://app.hermoso.ai').replace(/\/$/, '');
51
+ const profile = flags.profile || cfg.profile || 'default';
52
+ const isLocal = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/.test(apiBase);
53
+ if (flags.token) { await saveConfig({ apiBase, token: String(flags.token), profile }); return console.log(`✓ Signed in key stored (~/.hermoso/config.json). API: ${apiBase}`); }
54
+ if (isLocal) { await saveConfig({ apiBase, token: '', profile }); return console.log(`✓ Local dev — no auth required. API: ${apiBase}`); }
55
+ if (sub === 'login') { // browser sign-in: spin a loopback server, open the app's /?cliauth page, receive a minted key
56
+ const key = await browserLogin(apiBase);
57
+ if (!key) return die(`Sign-in didn’t complete. Re-run "hermoso auth login", or paste a key: hermoso auth login --token <key> (create one in the app under Agents & API keys).`);
58
+ await saveConfig({ apiBase, token: key, profile });
59
+ return console.log(`✓ Signed in — key stored (~/.hermoso/config.json). API: ${apiBase}`);
60
+ }
61
+ await saveConfig({ apiBase, token: cfg.token || '', profile }); // bare `auth` / `auth --url …`: just record the base
62
+ return console.log(`✓ Saved. API: ${apiBase}${cfg.token ? ' · token stored' : ''}`);
54
63
  }
55
64
  if (group === 'version' || flags.version) {
56
65
  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>'}`);
@@ -145,4 +154,48 @@ add --json to any command for machine output.`);
145
154
  }
146
155
  } catch (e) { die(e?.message || String(e)); }
147
156
  }
157
+
158
+ // ---- browser sign-in (loopback OAuth, like gh/firebase): spin a 127.0.0.1 server, open the app's cli-auth page,
159
+ // which mints an agent key and redirects the browser back to our loopback with ?key=&state=. Nothing is pasted;
160
+ // the key transits only the user's own machine. Times out after 3 min. Returns the hmk_ key, or null. ----
161
+ async function browserLogin(apiBase) {
162
+ const http = await import('node:http');
163
+ const crypto = await import('node:crypto');
164
+ const state = crypto.randomBytes(16).toString('hex');
165
+ return await new Promise((resolve) => {
166
+ let done = false;
167
+ const finish = (val) => { if (done) return; done = true; try { server.close(); } catch {} resolve(val); };
168
+ const server = http.createServer((req, res) => {
169
+ try {
170
+ const u = new URL(req.url, 'http://127.0.0.1');
171
+ if (u.pathname === '/favicon.ico') { res.writeHead(204); return res.end(); }
172
+ const key = u.searchParams.get('key') || '';
173
+ const st = u.searchParams.get('state') || '';
174
+ const ok = st === state && /^hmk_[A-Za-z0-9_-]{20,}$/.test(key);
175
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
176
+ res.end(cliAuthPage(ok ? 'You’re signed in ✓' : 'Sign-in didn’t complete', ok ? 'Hermoso CLI is connected. You can close this tab and return to your terminal.' : 'Please close this tab and run the command again.'));
177
+ finish(ok ? key : null);
178
+ } catch { try { res.writeHead(500); res.end('error'); } catch {} finish(null); }
179
+ });
180
+ server.on('error', () => finish(null));
181
+ server.listen(0, '127.0.0.1', () => {
182
+ const port = server.address().port;
183
+ const authUrl = `${apiBase}/?cliauth=1&port=${port}&state=${state}`;
184
+ console.log(`\nOpening your browser to sign in…\nIf it doesn’t open, paste this into your browser:\n ${authUrl}\n`);
185
+ openBrowser(authUrl);
186
+ });
187
+ setTimeout(() => finish(null), 180000);
188
+ });
189
+ }
190
+ function openBrowser(url) {
191
+ import('node:child_process').then(({ spawn }) => {
192
+ const plat = process.platform;
193
+ const cmd = plat === 'darwin' ? 'open' : plat === 'win32' ? 'cmd' : 'xdg-open';
194
+ const args = plat === 'win32' ? ['/c', 'start', '', url] : [url];
195
+ try { const c = spawn(cmd, args, { stdio: 'ignore', detached: true }); c.on('error', () => {}); c.unref(); } catch {}
196
+ }).catch(() => {});
197
+ }
198
+ function cliAuthPage(title, body) {
199
+ return `<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>Hermoso CLI</title><body style="font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0e0e10;color:#f4f1ea;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0"><div style="text-align:center;max-width:420px;padding:32px"><div style="font-family:Georgia,'Times New Roman',serif;font-size:24px;font-weight:700;letter-spacing:-.5px;margin-bottom:18px">hermoso<span style="color:#d9714e">.ai</span></div><h1 style="font-size:20px;margin:14px 0 8px;font-weight:600">${title}</h1><p style="color:#a7a29a;line-height:1.55;font-size:15px">${body}</p></div></body>`;
200
+ }
148
201
  main();
@@ -12,7 +12,7 @@ import { registerTools } from './tools.mjs';
12
12
  import { API_BASE } from './client.mjs';
13
13
 
14
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.',
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. Structured ad-spy when you know exactly what to pull: search_meta_ads / search_google_ads / search_linkedin_ads (ad libraries), search_tiktok / search_instagram / search_youtube / search_reddit / search_threads (organic), scrapecreators_fetch (any allowlisted endpoint). Always report the final media URL to the user.',
16
16
  });
17
17
 
18
18
  registerTools(server);
package/mcp/tools.mjs CHANGED
@@ -134,14 +134,15 @@ export function registerTools(server) {
134
134
  inputSchema: {
135
135
  prompt: z.string().describe('the full image prompt — subject, composition, lighting, and any on-image ad text'),
136
136
  refImages: z.array(z.string()).optional().describe('local file paths or URLs of product/logo references to composite in'),
137
+ useBrand: z.boolean().optional().describe('default true: with no refImages, the server hydrates the SAVED brand’s product/logo references so the output lands on-brand; pass false for a pure prompt-only render'),
137
138
  aspectRatio: z.string().optional().describe("e.g. '1:1', '9:16', '16:9'"),
138
139
  model: z.string().optional().describe('image model id from hermoso_capabilities'),
139
140
  imageSize: z.string().optional(),
140
141
  },
141
142
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
142
- }, wrap(async ({ prompt, refImages, aspectRatio, model, imageSize }) => {
143
+ }, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize }) => {
143
144
  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 d = await apiPost('/api/generate/image', { prompt, refImages: refs, useBrand: useBrand !== false, aspectRatio, model, imageSize }); // explicit boolean so the server's saved-brand hydration default is unambiguous
145
146
  const img = await imageBlock(abs(d.image)); // show the actual creative inline in Claude, not just a URL
146
147
  return { content: [{ type: 'text', text: `Image ready: ${abs(d.image)}${d.model ? ` (${d.model})` : ''}` }, ...(img ? [img] : [])], structuredContent: { ...d, image: abs(d.image) } };
147
148
  }));
@@ -154,11 +155,16 @@ export function registerTools(server) {
154
155
  model: z.string().optional().describe('video model id from hermoso_capabilities (default: the plan’s pick)'),
155
156
  durationSeconds: z.number().optional(),
156
157
  aspectRatio: z.string().optional(),
157
- resolution: z.string().optional().describe("'720p' (default) or '1080p'"),
158
+ resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'720p' default; '480p' = cheap fast draft pass, '1080p'/'4k' = premium final delivery (more credits)"),
159
+ captions: z.boolean().optional().describe('composited caption pills on/off (default: the recipe decides)'),
160
+ endCard: z.boolean().optional().describe('branded end card on/off (default: on, except organic recipes)'),
161
+ music: z.boolean().optional().describe('licensed music bed on/off (default on)'),
162
+ lockup: z.boolean().optional().describe('persistent brand-logo lockup overlay on/off'),
163
+ ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
158
164
  },
159
165
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
160
166
  }, wrap(async (a) => {
161
- const { input, notes } = await apiPost('/api/render/assemble', a);
167
+ const { input, notes } = await apiPost('/api/render/assemble', a); // a passes wholesale — resolution/captions/endCard/music/lockup/ttsVoice ride the body
162
168
  const r = await renderJob('video', input, 'MCP ad render');
163
169
  return okVideo(`Ad video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]\n${notes || ''}`, r);
164
170
  }));
@@ -171,7 +177,7 @@ export function registerTools(server) {
171
177
  durationSeconds: z.number().optional().describe('clip length in seconds'),
172
178
  aspectRatio: z.string().optional().describe("default '9:16'"),
173
179
  model: z.string().optional(),
174
- resolution: z.string().optional().describe("'720p' (default) or '1080p'"),
180
+ resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'720p' default; '480p' = cheap fast draft pass, '1080p'/'4k' = premium final delivery (more credits)"),
175
181
  ttsScript: z.string().optional().describe('voiceover script to speak'),
176
182
  ttsVoice: z.string().optional().describe('voice name, e.g. Rachel / George'),
177
183
  musicMood: z.string().optional(),
@@ -332,6 +338,191 @@ export function registerTools(server) {
332
338
  return ok(`${d.reply || ''}\n\n(${(d.results || []).length} ads found)`, { reply: d.reply, results: d.results, actions: d.actions });
333
339
  }));
334
340
 
341
+ // ---------- structured ad-spy (webapp Explore-chat parity: direct library/social pulls, no LLM loop) ----------
342
+ // For when the agent KNOWS what to pull (one brand / keyword / platform): a single API call returning compact
343
+ // JSON — cheaper + faster than research_ads, which stays the right tool for open-ended cross-platform judgment.
344
+ const qp = (o) => Object.fromEntries(Object.entries(o || {}).filter(([, v]) => v != null && v !== '')); // URLSearchParams renders undefined as the literal string "undefined" — strip empties before they hit the API
345
+ const trunc = (s, n = 200) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
346
+ const nAds = (n) => Math.min(25, Math.max(1, Math.round(+n) || 8));
347
+ const adsOut = (label, total, items) => ok(JSON.stringify({ found: total, showing: items.length, [label]: items }), { found: total, [label]: items }); // compact JSON summary, never the raw firehose
348
+
349
+ server.registerTool('search_meta_ads', {
350
+ description: "Structured Meta (Facebook/Instagram) Ad Library pull — use when you know exactly WHAT to fetch: a keyword (query) OR one advertiser (companyName / pageId). Returns compact JSON {page_name, body, cta, link, dates, media} per ad. For open-ended research that needs judgment across platforms, use research_ads instead. Spends ScrapeCreators credits (~1–2).",
351
+ inputSchema: {
352
+ query: z.string().optional().describe('keyword search across ALL advertisers (use INSTEAD of companyName/pageId)'),
353
+ companyName: z.string().optional().describe('one advertiser’s ads by brand name'),
354
+ pageId: z.string().optional().describe('one advertiser’s ads by Facebook page id (most precise)'),
355
+ country: z.string().optional().describe("2-letter code or 'ALL' (default ALL)"),
356
+ status: z.enum(['ACTIVE', 'INACTIVE', 'ALL']).optional().describe("ACTIVE = currently running; default ALL (includes proven past winners)"),
357
+ mediaType: z.enum(['ALL', 'IMAGE', 'VIDEO', 'MEME', 'IMAGE_AND_MEME', 'NONE']).optional(),
358
+ limit: z.number().int().optional().describe('max ads returned (1–25, default 8)'),
359
+ },
360
+ annotations: { readOnlyHint: true, openWorldHint: true },
361
+ }, wrap(async (a) => {
362
+ if (!a.query && !a.companyName && !a.pageId) throw new Error('Pass query (keyword) OR companyName/pageId (one advertiser).');
363
+ const common = qp({ country: a.country, status: a.status, media_type: a.mediaType });
364
+ const d = a.query
365
+ ? await apiGet('/api/fb/search', { query: a.query, ...common })
366
+ : await apiGet('/api/fb/company-ads', qp({ companyName: a.companyName, pageId: a.pageId, ...common }));
367
+ const raw = d.results || d.searchResults || []; // company-ads → results[], keyword search → searchResults[]
368
+ const ads = raw.slice(0, nAds(a.limit)).map((x) => {
369
+ const s = x.snapshot || {};
370
+ return qp({
371
+ page_name: x.page_name, body: trunc(typeof s.body === 'string' ? s.body : s.body?.text), cta: s.cta_text, link: s.link_url,
372
+ dates: [x.start_date_string, x.end_date_string].filter(Boolean).join(' → '),
373
+ media: s.videos?.[0]?.video_sd_url || s.images?.[0]?.resized_image_url || s.cards?.[0]?.resized_image_url || s.cards?.[0]?.video_sd_url || s.videos?.[0]?.video_preview_image_url,
374
+ });
375
+ });
376
+ return adsOut('ads', d.searchResultsCount ?? raw.length, ads);
377
+ }));
378
+
379
+ server.registerTool('search_google_ads', {
380
+ description: "Structured Google Ads Transparency pull for ONE advertiser (by domain or advertiserId) — use when you know the brand; use research_ads for open-ended research. Deliberately fetches the cheap BASIC listing (get_ad_details=false, ~1 credit — the detailed variant with per-ad headlines costs 25 credits/call and is not exposed here). Returns compact JSON {advertiser, format, adUrl, image, firstShown, lastShown} per ad.",
381
+ inputSchema: {
382
+ domain: z.string().optional().describe("the advertiser's domain, e.g. nike.com"),
383
+ advertiserId: z.string().optional().describe('Google advertiser id (AR…) when the domain is ambiguous'),
384
+ region: z.string().optional().describe('2-letter region, default US'),
385
+ limit: z.number().int().optional().describe('max ads returned (1–25, default 8)'),
386
+ },
387
+ annotations: { readOnlyHint: true, openWorldHint: true },
388
+ }, wrap(async (a) => {
389
+ if (!a.domain && !a.advertiserId) throw new Error('Pass domain or advertiserId.');
390
+ const d = await apiGet('/api/google/company-ads', qp({ domain: a.domain, advertiser_id: a.advertiserId, region: a.region, get_ad_details: 'false' }));
391
+ const raw = d.ads || [];
392
+ const ads = raw.slice(0, nAds(a.limit)).map((g) => qp({ advertiser: g.advertiserName, format: g.format, adUrl: g.adUrl, image: g.imageUrl, firstShown: g.firstShown, lastShown: g.lastShown }));
393
+ return adsOut('ads', d.number_of_ads_estimate ?? raw.length, ads);
394
+ }));
395
+
396
+ server.registerTool('search_linkedin_ads', {
397
+ description: "Structured LinkedIn Ad Library search by company name, keyword, or companyId — use for a targeted B2B pull; use research_ads for open-ended research. Returns compact JSON {advertiser, headline, description, cta, link, media, dates, impressions} per ad — LinkedIn is the one library exposing real impression counts. Spends ScrapeCreators credits (~1).",
398
+ inputSchema: {
399
+ company: z.string().optional().describe('advertiser company name'),
400
+ keyword: z.string().optional().describe('keyword across all advertisers'),
401
+ companyId: z.string().optional(),
402
+ countries: z.string().optional().describe("CSV of 2-letter codes like 'US,CA'; omit or 'ALL' = worldwide"),
403
+ limit: z.number().int().optional().describe('max ads returned (1–25, default 8)'),
404
+ },
405
+ annotations: { readOnlyHint: true, openWorldHint: true },
406
+ }, wrap(async (a) => {
407
+ if (!a.company && !a.keyword && !a.companyId) throw new Error('Pass company, keyword, or companyId.');
408
+ const d = await apiGet('/api/linkedin/search', qp({ company: a.company, keyword: a.keyword, companyId: a.companyId, countries: a.countries }));
409
+ const raw = d.ads || [];
410
+ const ads = raw.slice(0, nAds(a.limit)).map((x) => qp({
411
+ advertiser: x.advertiser, headline: trunc(x.headline, 120), description: trunc(x.description), cta: x.cta,
412
+ link: x.destinationUrl, media: x.video || x.image, dates: [x.startDate, x.endDate].filter(Boolean).join(' → '), impressions: x.totalImpressions,
413
+ }));
414
+ return adsOut('ads', d.totalAds ?? raw.length, ads);
415
+ }));
416
+
417
+ server.registerTool('search_tiktok', {
418
+ description: "Organic TikTok keyword search (there is NO TikTok ad library) — top-performing videos to mine for hooks/trends/remixable creative. Returns compact JSON {desc, author, handle, plays, likes, link, cover} per video, ranked by plays. Use research_ads for open-ended research. Spends ScrapeCreators credits (~1).",
419
+ inputSchema: {
420
+ query: z.string().describe('keyword or hashtag (no # needed)'),
421
+ limit: z.number().int().optional().describe('max videos returned (1–25, default 8)'),
422
+ },
423
+ annotations: { readOnlyHint: true, openWorldHint: true },
424
+ }, wrap(async ({ query, limit }) => {
425
+ const d = await apiGet('/api/sc/run', { __path: '/v1/tiktok/search/keyword', query });
426
+ const all = (d.search_item_list || []).map((x) => x.aweme_info).filter(Boolean).map((v) => {
427
+ const au = v.author || {}, st = v.statistics || {}, vid = v.video || {};
428
+ return qp({
429
+ desc: trunc(v.desc), author: au.nickname || au.unique_id, handle: au.unique_id, plays: st.play_count, likes: st.digg_count,
430
+ link: au.unique_id && v.aweme_id ? `https://www.tiktok.com/@${au.unique_id}/video/${v.aweme_id}` : '',
431
+ cover: vid.cover?.url_list?.[0] || vid.origin_cover?.url_list?.[0],
432
+ });
433
+ }).sort((a, b) => (b.plays || b.likes || 0) - (a.plays || a.likes || 0)); // TOP by plays — "top-performing" means ranked, not API order
434
+ return adsOut('videos', all.length, all.slice(0, nAds(limit)));
435
+ }));
436
+
437
+ server.registerTool('search_instagram', {
438
+ description: "Organic Instagram REELS keyword search (/v2/instagram/reels/search — ScrapeCreators' only IG keyword surface; profile/hashtag pulls go through scrapecreators_fetch with a handle). Returns compact JSON {desc, author, handle, plays, likes, link, cover} per reel, ranked by plays. Spends ScrapeCreators credits (~1).",
439
+ inputSchema: {
440
+ query: z.string().describe('keyword to search reels for'),
441
+ limit: z.number().int().optional().describe('max reels returned (1–25, default 8)'),
442
+ },
443
+ annotations: { readOnlyHint: true, openWorldHint: true },
444
+ }, wrap(async ({ query, limit }) => {
445
+ const d = await apiGet('/api/sc/run', { __path: '/v2/instagram/reels/search', query });
446
+ const all = (d.reels || d.items || []).map((r) => {
447
+ const o = r.owner || r.user || {};
448
+ return qp({
449
+ desc: trunc(typeof r.caption === 'string' ? r.caption : (r.caption?.text || r.accessibility_caption)),
450
+ author: o.full_name || o.username, handle: o.username,
451
+ plays: r.video_play_count || r.video_view_count, likes: r.like_count || r.edge_liked_by?.count,
452
+ link: r.url || (r.shortcode ? `https://www.instagram.com/reel/${r.shortcode}/` : ''),
453
+ cover: r.thumbnail_src || r.display_url,
454
+ });
455
+ }).filter((x) => x.cover || x.link).sort((a, b) => (b.plays || b.likes || 0) - (a.plays || a.likes || 0));
456
+ return adsOut('reels', all.length, all.slice(0, nAds(limit)));
457
+ }));
458
+
459
+ server.registerTool('search_youtube', {
460
+ description: "Organic YouTube keyword search (/v1/youtube/search) — videos to mine for hooks/angles/long-form structure. Returns compact JSON {desc (title), author, handle, plays, link, cover} per video, ranked by views. Spends ScrapeCreators credits (~1).",
461
+ inputSchema: {
462
+ query: z.string().describe('keyword to search videos for'),
463
+ limit: z.number().int().optional().describe('max videos returned (1–25, default 8)'),
464
+ },
465
+ annotations: { readOnlyHint: true, openWorldHint: true },
466
+ }, wrap(async ({ query, limit }) => {
467
+ const d = await apiGet('/api/sc/run', { __path: '/v1/youtube/search', query });
468
+ const all = (d.videos || []).filter((v) => (v.type || 'video') === 'video').map((v) => {
469
+ const ch = v.channel || {};
470
+ return qp({ desc: trunc(v.title, 120), author: ch.title || ch.handle, handle: ch.handle, plays: v.viewCountInt, link: v.url || (v.id ? `https://www.youtube.com/watch?v=${v.id}` : ''), cover: v.thumbnail });
471
+ }).filter((x) => x.cover || x.link).sort((a, b) => (b.plays || 0) - (a.plays || 0));
472
+ return adsOut('videos', all.length, all.slice(0, nAds(limit)));
473
+ }));
474
+
475
+ server.registerTool('search_reddit', {
476
+ description: "Reddit keyword search (/v1/reddit/search, top-ranked) — a goldmine for the customer's OWN words (pain points, objections, language) to mine into ad hooks and copy. Returns compact JSON {desc (title+selftext), subreddit, upvotes, comments, link} per post. Spends ScrapeCreators credits (~1).",
477
+ inputSchema: {
478
+ query: z.string().describe('what to search Reddit for'),
479
+ limit: z.number().int().optional().describe('max posts returned (1–25, default 8)'),
480
+ },
481
+ annotations: { readOnlyHint: true, openWorldHint: true },
482
+ }, wrap(async ({ query, limit }) => {
483
+ const d = await apiGet('/api/sc/run', { __path: '/v1/reddit/search', query, sort: 'top' });
484
+ const all = (d.posts || d.results || []).map((p) => qp({
485
+ desc: trunc([p.title, p.selftext].filter(Boolean).join(' — '), 260),
486
+ subreddit: p.subreddit ? `r/${p.subreddit}` : '', upvotes: p.ups ?? p.score, comments: p.num_comments,
487
+ link: p.permalink ? (/^https?:/.test(p.permalink) ? p.permalink : `https://www.reddit.com${p.permalink}`) : p.url,
488
+ })).filter((x) => x.desc || x.link);
489
+ return adsOut('posts', all.length, all.slice(0, nAds(limit)));
490
+ }));
491
+
492
+ server.registerTool('search_threads', {
493
+ description: "Organic Threads keyword search (/v1/threads/search) — short-form text/social posts for trend + voice research. Returns compact JSON {desc, author, handle, likes, link, cover} per post. Spends ScrapeCreators credits (~1).",
494
+ inputSchema: {
495
+ query: z.string().describe('keyword to search Threads for'),
496
+ limit: z.number().int().optional().describe('max posts returned (1–25, default 8)'),
497
+ },
498
+ annotations: { readOnlyHint: true, openWorldHint: true },
499
+ }, wrap(async ({ query, limit }) => {
500
+ const d = await apiGet('/api/sc/run', { __path: '/v1/threads/search', query });
501
+ const all = (d.posts || d.results || []).map((p) => {
502
+ const u = p.user || {};
503
+ return qp({
504
+ desc: trunc((p.caption && (p.caption.text || (typeof p.caption === 'string' ? p.caption : ''))) || p.accessibility_caption),
505
+ author: u.full_name || u.username, handle: u.username, likes: p.like_count,
506
+ link: p.code && u.username ? `https://www.threads.net/@${u.username}/post/${p.code}` : '',
507
+ cover: p.image_versions2?.candidates?.[0]?.url,
508
+ });
509
+ }).filter((x) => x.desc || x.link || x.cover);
510
+ return adsOut('posts', all.length, all.slice(0, nAds(limit)));
511
+ }));
512
+
513
+ server.registerTool('scrapecreators_fetch', {
514
+ description: "Generic ScrapeCreators escape hatch for any ALLOWLISTED long-tail endpoint the dedicated search_* tools don't cover — e.g. {path:'/v1/instagram/profile', params:{handle:'nike'}}. Allowlisted platform families: TikTok (+ TikTok Shop), Instagram, YouTube, Facebook (organic profiles/posts/events/marketplace), LinkedIn (organic posts/companies), Twitter/X, Reddit, Threads, Snapchat, Pinterest, Twitch, Bluesky, Truth Social, Rumble, Spotify, SoundCloud, GitHub, Google search, link-in-bio pages (Linktree etc.). Param names vary per endpoint (profiles use `handle`, keyword searches use `query`, Reddit uses `subreddit`). WARNING: returns RAW provider JSON — large and messy; prefer the dedicated search_* tools. Spends ScrapeCreators credits.",
515
+ inputSchema: {
516
+ path: z.string().describe("exact SC endpoint path, e.g. '/v1/tiktok/profile' — non-allowlisted paths are rejected"),
517
+ params: z.object({}).passthrough().optional().describe("endpoint query params, e.g. {handle:'nike'}"),
518
+ },
519
+ annotations: { readOnlyHint: true, openWorldHint: true },
520
+ }, wrap(async ({ path, params }) => {
521
+ const d = await apiGet('/api/sc/run', { __path: path, ...qp(params || {}) });
522
+ const raw = JSON.stringify(d);
523
+ return ok(raw.length > 24000 ? raw.slice(0, 24000) + '\n… (truncated — narrow the query or use a dedicated search_* tool)' : raw); // no structuredContent: raw payloads can be huge, the text IS the result
524
+ }));
525
+
335
526
  // ---------- brand onboarding ----------
336
527
  server.registerTool('get_brand', {
337
528
  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.',
package/package.json CHANGED
@@ -1,14 +1,32 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
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
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"],
6
+ "bin": {
7
+ "hermoso": "bin/hermoso.mjs"
8
+ },
9
+ "scripts": {
10
+ "mcp": "node mcp/hermoso-mcp.mjs"
11
+ },
12
+ "dependencies": {
13
+ "@modelcontextprotocol/sdk": "^1.12.0"
14
+ },
15
+ "keywords": [
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "ai-ads",
19
+ "ad-generator",
20
+ "ugc",
21
+ "claude",
22
+ "cli",
23
+ "video-ads"
24
+ ],
10
25
  "homepage": "https://hermoso.ai",
11
- "repository": { "type": "git", "url": "https://github.com/hermoso-ai/hermoso.git" },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/hermoso-ai/hermoso.git"
29
+ },
12
30
  "license": "MIT",
13
31
  "author": "Hermoso <hello@hermoso.ai>"
14
32
  }