hermoso 0.1.365 → 0.1.371

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
@@ -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
- **864 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ **868 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 864 tools cover
233
+ ### What the 868 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 = [];