hermoso 0.1.366 → 0.1.372
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 +15 -2
- package/mcp/client.mjs +36 -8
- package/mcp/roster-scope.mjs +1 -1
- package/mcp/tools.mjs +452 -170
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate finished image &
|
|
|
5
5
|
composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
|
|
6
6
|
campaigns behind them, all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
|
|
7
7
|
|
|
8
|
-
**
|
|
8
|
+
**874 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
|
|
9
9
|
catalog with exact per-render credit costs plus the full capability map.
|
|
10
10
|
|
|
11
11
|
**Most of it costs nothing.** Publishing and scheduling posts, building and managing paid campaigns, analytics
|
|
@@ -59,6 +59,19 @@ codex plugin marketplace add hermoso-ai/hermoso && codex plugin add hermoso@herm
|
|
|
59
59
|
gemini extensions install https://github.com/hermoso-ai/hermoso
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
**Grok** (xAI) comes in three shapes, and each one connects differently:
|
|
63
|
+
|
|
64
|
+
- **grok.com and the Grok apps** take the hosted connector: open **Plugins → Connectors → New Connector → Custom**
|
|
65
|
+
(or go straight to grok.com/connectors), paste `https://app.hermoso.ai/mcp` and sign in with your Hermoso account.
|
|
66
|
+
- **Grok Bot**: add the [Hermoso Marketing Bot template](https://x.ai/bot/THQqogUKuhn1whWU0hrmb). It arrives wired
|
|
67
|
+
to Hermoso and asks for a key, which you create in the app under **MCP & CLI → Terminal & API keys**.
|
|
68
|
+
- **Grok Build**, xAI's terminal agent, runs a local MCP server. Sign in once, then add it:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npx -y hermoso auth login
|
|
72
|
+
grok mcp add hermoso -- npx -y hermoso mcp
|
|
73
|
+
```
|
|
74
|
+
|
|
62
75
|
**Cursor, OpenCode, GitHub Copilot, Windsurf and about 75 more agents**, through the open skills installer:
|
|
63
76
|
|
|
64
77
|
```bash
|
|
@@ -217,7 +230,7 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
217
230
|
|
|
218
231
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
219
232
|
|
|
220
|
-
### What the
|
|
233
|
+
### What the 874 tools cover
|
|
221
234
|
|
|
222
235
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
223
236
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
package/mcp/client.mjs
CHANGED
|
@@ -70,6 +70,26 @@ export const ENV_PREFIX = 'HERMOSO';
|
|
|
70
70
|
// twice. Deliberately a per-call AsyncLocalStorage and not a module variable — concurrent tool calls interleave.
|
|
71
71
|
export const toolCtx = new AsyncLocalStorage();
|
|
72
72
|
|
|
73
|
+
// ── A BRAND NAMED ON ONE TOOL CALL SCOPES EVERY REQUEST THAT CALL MAKES (2026-09-30) ────────────────────────────
|
|
74
|
+
// Measured on the hosted connector: `update_brand {brand:'pmuojpxnfrx0', voice:…}` wrote the voice onto the
|
|
75
|
+
// connection's PINNED brand, a different one, because the store tools read and write through a key namespace resolved
|
|
76
|
+
// from the pin and nothing on the wire named the brand the caller asked for. A store write is three requests (resolve
|
|
77
|
+
// the workspace, read the store, write it back) plus whatever the tool calls on the way, and every one of them has to
|
|
78
|
+
// land in the same brand — so the scope is set ONCE around the handler (tools.mjs `inBrand`) and applied HERE, at the
|
|
79
|
+
// one seam every request passes through, rather than threaded by hand into each call where one can be forgotten.
|
|
80
|
+
// It rides as `?brandId=`, the spelling the server belt (`brandRefOf`) reads on every method, which re-scopes that
|
|
81
|
+
// request only and REFUSES a name that matches no brand or two. Never on /api/apple-ads/*, where `brandId` is Apple's.
|
|
82
|
+
export const brandScope = new AsyncLocalStorage();
|
|
83
|
+
const scopedBrand = (p) => {
|
|
84
|
+
const b = brandScope.getStore()?.brand;
|
|
85
|
+
return typeof b === 'string' && b.trim() && !/^\/api\/apple-ads\//.test(String(p || '')) ? b.trim() : '';
|
|
86
|
+
};
|
|
87
|
+
export const withBrandScope = (p) => {
|
|
88
|
+
const b = scopedBrand(p);
|
|
89
|
+
if (!b || /[?&]brandId=/.test(String(p))) return p;
|
|
90
|
+
return `${p}${String(p).includes('?') ? '&' : '?'}brandId=${encodeURIComponent(b)}`;
|
|
91
|
+
};
|
|
92
|
+
|
|
73
93
|
function headers(extra = {}) {
|
|
74
94
|
const ctx = mcpCtx.getStore();
|
|
75
95
|
// A HOSTED-CONNECTOR request (mcp/http.mjs) is a DIFFERENT TENANT from the process serving it, so its ctx is the
|
|
@@ -184,7 +204,7 @@ export async function apiGet(p, query) {
|
|
|
184
204
|
// digit-strip reduced them to '' → GAQL "segments.date BETWEEN '' and ''". Drop empties before building the qs.
|
|
185
205
|
const clean = query && Object.fromEntries(Object.entries(query).filter(([, v]) => v !== undefined && v !== null && v !== ''));
|
|
186
206
|
const qs = clean && Object.keys(clean).length ? '?' + new URLSearchParams(clean).toString() : '';
|
|
187
|
-
const res = await fetchRead(`${API_BASE}${p}${qs}`, { headers: headers() });
|
|
207
|
+
const res = await fetchRead(`${API_BASE}${withBrandScope(`${p}${qs}`)}`, { headers: headers() });
|
|
188
208
|
return unwrap(res);
|
|
189
209
|
}
|
|
190
210
|
|
|
@@ -230,7 +250,7 @@ async function fetchWrite(url, init) {
|
|
|
230
250
|
}
|
|
231
251
|
|
|
232
252
|
export async function apiPost(p, body = {}) {
|
|
233
|
-
const res = await fetchWrite(`${API_BASE}${p}`, { method: 'POST', headers: headers(), body: JSON.stringify(body) });
|
|
253
|
+
const res = await fetchWrite(`${API_BASE}${withBrandScope(p)}`, { method: 'POST', headers: headers(), body: JSON.stringify(body) });
|
|
234
254
|
return unwrap(res);
|
|
235
255
|
}
|
|
236
256
|
|
|
@@ -238,17 +258,17 @@ export async function apiPost(p, body = {}) {
|
|
|
238
258
|
// "leave that field exactly as it was", so sending a whole object where a patch was meant would blank the fields
|
|
239
259
|
// the caller never mentioned. Only ever send the keys that are actually changing.
|
|
240
260
|
export async function apiPatch(p, body = {}) {
|
|
241
|
-
const res = await fetchWrite(`${API_BASE}${p}`, { method: 'PATCH', headers: headers(), body: JSON.stringify(body) });
|
|
261
|
+
const res = await fetchWrite(`${API_BASE}${withBrandScope(p)}`, { method: 'PATCH', headers: headers(), body: JSON.stringify(body) });
|
|
242
262
|
return unwrap(res);
|
|
243
263
|
}
|
|
244
264
|
|
|
245
265
|
export async function apiDelete(p) {
|
|
246
|
-
const res = await fetchWrite(`${API_BASE}${p}`, { method: 'DELETE', headers: headers() });
|
|
266
|
+
const res = await fetchWrite(`${API_BASE}${withBrandScope(p)}`, { method: 'DELETE', headers: headers() });
|
|
247
267
|
return unwrap(res);
|
|
248
268
|
}
|
|
249
269
|
|
|
250
270
|
export async function apiPut(p, body = {}) {
|
|
251
|
-
const res = await fetchWrite(`${API_BASE}${p}`, { method: 'PUT', headers: headers(), body: JSON.stringify(body) });
|
|
271
|
+
const res = await fetchWrite(`${API_BASE}${withBrandScope(p)}`, { method: 'PUT', headers: headers(), body: JSON.stringify(body) });
|
|
252
272
|
return unwrap(res);
|
|
253
273
|
}
|
|
254
274
|
|
|
@@ -281,6 +301,14 @@ async function fetchStoreSuffix() {
|
|
|
281
301
|
return w.storeSuffix;
|
|
282
302
|
}
|
|
283
303
|
export async function storeSuffix() {
|
|
304
|
+
// A BRAND NAMED ON THIS CALL resolves its OWN suffix (the request carries `?brandId=` via withBrandScope) and is
|
|
305
|
+
// memoised on the per-call scope object only. Reading the pin's memo here is exactly how a named brand's write
|
|
306
|
+
// landed in the pinned brand's keys; falling back to the pin on a failed read would repeat it, so a failure throws.
|
|
307
|
+
const scope = brandScope.getStore();
|
|
308
|
+
if (scope && scopedBrand('/api/workspace')) {
|
|
309
|
+
if (scope._storeSuffix === undefined) scope._storeSuffix = await fetchStoreSuffix();
|
|
310
|
+
return scope._storeSuffix;
|
|
311
|
+
}
|
|
284
312
|
const ctx = mcpCtx.getStore();
|
|
285
313
|
if (ctx) {
|
|
286
314
|
// HOSTED: memoize on the PER-REQUEST ctx object only. A module-level cache here would serve one customer's
|
|
@@ -347,7 +375,7 @@ export async function apiUpload(p, buf, { contentType = 'application/octet-strea
|
|
|
347
375
|
if (buf && buf.length > UPLOAD_SINGLE_MAX && p === '/api/upload') return apiUploadInParts(buf, { contentType, fileName });
|
|
348
376
|
const h = headers({ 'Content-Type': contentType });
|
|
349
377
|
if (fileName) h['x-file-name'] = encodeURIComponent(fileName);
|
|
350
|
-
const res = await fetchWrite(`${API_BASE}${p}`, { method: 'POST', headers: h, body: buf });
|
|
378
|
+
const res = await fetchWrite(`${API_BASE}${withBrandScope(p)}`, { method: 'POST', headers: h, body: buf });
|
|
351
379
|
return unwrap(res);
|
|
352
380
|
}
|
|
353
381
|
export async function apiUploadInParts(buf, { contentType = 'application/octet-stream', fileName = '' } = {}) {
|
|
@@ -384,13 +412,13 @@ export async function apiUploadUrl(p, url, { fileName = '' } = {}) {
|
|
|
384
412
|
const h = headers({});
|
|
385
413
|
delete h['Content-Type']; // a body-less POST must not claim one; the server sniffs the FETCHED bytes
|
|
386
414
|
if (fileName) h['x-file-name'] = encodeURIComponent(fileName);
|
|
387
|
-
const res = await fetchWrite(`${API_BASE}${p}?url=${encodeURIComponent(url)}`, { method: 'POST', headers: h });
|
|
415
|
+
const res = await fetchWrite(`${API_BASE}${withBrandScope(`${p}?url=${encodeURIComponent(url)}`)}`, { method: 'POST', headers: h });
|
|
388
416
|
return unwrap(res);
|
|
389
417
|
}
|
|
390
418
|
|
|
391
419
|
// /api/explore/chat streams Server-Sent-Events; collect to the terminal `done` payload {reply, results, actions}.
|
|
392
420
|
export async function apiSSE(p, body = {}) {
|
|
393
|
-
const res = await fetchWrite(`${API_BASE}${p}`, { method: 'POST', headers: headers({ Accept: 'text/event-stream' }), body: JSON.stringify(body) });
|
|
421
|
+
const res = await fetchWrite(`${API_BASE}${withBrandScope(p)}`, { method: 'POST', headers: headers({ Accept: 'text/event-stream' }), body: JSON.stringify(body) });
|
|
394
422
|
if (!res.ok) { let e; try { e = (await res.json()).error; } catch {} throw Object.assign(new Error(e || `HTTP ${res.status}`), { status: res.status }); }
|
|
395
423
|
const reader = res.body.getReader(); const dec = new TextDecoder();
|
|
396
424
|
let buf = '', done = null, error = null; const progress = [];
|
package/mcp/roster-scope.mjs
CHANGED
|
@@ -102,7 +102,7 @@ export const TOOL_PROVIDER_RULES = [
|
|
|
102
102
|
[/bluesky/, 'bluesky'],
|
|
103
103
|
[/telegram/, 'telegram'],
|
|
104
104
|
[/^post_to_tiktok$|^tiktok_|_tiktok_/, 'tiktok'],
|
|
105
|
-
[/^post_to_x$|^delete_x_post$|^edit_x_post$|^post_x_article$|^send_x_dm$|^list_x_dms$|^search_x$|^list_x_blocks$|_x_broadcast|^x_(mentions|post|account|trends|user|search_counts|follows)/, 'x'],
|
|
105
|
+
[/^post_to_x$|^delete_x_post$|^edit_x_post$|^post_x_article$|^send_x_dm$|^list_x_dms$|^search_x$|^list_x_blocks$|_x_broadcast|_x_chat|^x_chat_settings$|^x_(mentions|post|account|trends|user|search_counts|follows)/, 'x'],
|
|
106
106
|
[/pinterest/, 'pinterest'],
|
|
107
107
|
[/reddit/, 'reddit'],
|
|
108
108
|
[/snapchat/, 'snapchat'],
|