hermoso 0.1.303 → 0.1.305

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
@@ -85,7 +85,7 @@ async function main() {
85
85
  return console.log(`✓ Saved. API: ${apiBase}${cfg.token ? ' · token stored' : ''}`);
86
86
  }
87
87
  if (group === 'version' || flags.version) {
88
- console.log(`hermoso-cli ${CLI_VERSION} · API ${cfg.apiBase || process.env.HERMOSO_API_BASE || 'https://app.hermoso.ai'} · ${cfg.token ? 'authed' : 'no token — run: hermoso auth login --token <key>'}`);
88
+ console.log(`hermoso-cli ${CLI_VERSION} · API ${cfg.apiBase || process.env.HERMOSO_API_BASE || 'https://app.hermoso.ai'} · ${cfg.token || process.env.HERMOSO_TOKEN ? 'authed' : 'not signed in — run: hermoso auth login'}`);
89
89
  return;
90
90
  }
91
91
 
@@ -120,9 +120,16 @@ async function main() {
120
120
  // pill. Reading only `balance` printed "Balance: undefined credits" against prod for every signed-in user
121
121
  // (measured 2026-08-19). Same expression the hermoso_credits MCP tool uses, so the two cannot disagree.
122
122
  const bal = d.accountBalance ?? d.balance;
123
+ // No key, no balance: /api/credits answers an anonymous caller 200 with a null balance, and "Balance: — credits"
124
+ // read as an empty account rather than a missing sign-in (2026-09-25).
125
+ if (bal == null && api.signedOut()) return die(api.SIGN_IN_HINT);
123
126
  return out(`Balance: ${bal ?? '—'} credits`, d); }
124
127
  case 'brand': {
125
- if (sub !== 'draft') return die('usage: hermoso brand draft (--domain <d> | --description <t> | --social <h> --platform <p>)');
128
+ // BARE `hermoso brand` SHOWS THE SAVED BRAND (2026-09-25). The docs have always listed it as "the saved brand
129
+ // profile" and it died with the `brand draft` usage line instead — the first command a new user copies off
130
+ // the CLI page. It is get_brand, run through the same handler the MCP twins use.
131
+ if (!sub) { const reg = await import('../mcp/registry.mjs'); return await runTool(reg, 'get_brand', flags, []); }
132
+ if (sub !== 'draft') return die('usage: hermoso brand the saved brand profile\n hermoso brand draft (--domain <d> | --description <t> | --social <h> --platform <p>)');
126
133
  const body = flags.domain ? { domain: flags.domain } : flags.description ? { description: flags.description } : flags.social ? { socialHandle: flags.social, platform: flags.platform || 'instagram' } : null;
127
134
  if (!body) return die('give --domain, --description, or --social');
128
135
  const d = await api.apiPost('/api/brand/draft', body); const p = d.profile || d;
@@ -262,7 +269,7 @@ async function main() {
262
269
  }
263
270
  console.log(`hermoso <command>
264
271
  auth login [--url <base>] [--token <t>] credits capabilities
265
- brand draft (--domain|--description|--social …) create --brand --product [--format]
272
+ brand · brand draft (--domain|--description|--social …) create --brand --product [--format]
266
273
  generate image --prompt [--ref] [--model] [--aspect] generate video|avatar|stitch … [--wait]
267
274
  jobs list | jobs get <id> [--wait] competitors <domain>
268
275
  ads pull (--company|--domain) research "<request>"
package/mcp/client.mjs CHANGED
@@ -101,12 +101,27 @@ function headers(extra = {}) {
101
101
  return h;
102
102
  }
103
103
 
104
+ // NOT SIGNED IN, SAID AS HOW TO FIX IT (2026-09-25). A stdio server or CLI started with no key (a new user who skipped
105
+ // `auth login`, a directory's "try this server", an IDE config missing HERMOSO_TOKEN) got the server's bare
106
+ // "Sign in to continue." on every account tool, and `hermoso_credits` printed "Balance: undefined credits" because
107
+ // /api/credits answers an anonymous caller 200 with a null balance. Neither says what to DO. Only when the process
108
+ // itself holds no credential: a hosted request (mcpCtx) always carries its caller's bearer, and a localhost base
109
+ // needs no auth at all. A caller who DOES send a key and still gets a 401 has a bad or revoked key, which the
110
+ // server's own message already says.
111
+ export const SIGN_IN_HINT = 'Not signed in. Run `npx -y hermoso auth login` (it opens a browser), or set HERMOSO_TOKEN to an agent key (hmk_…) created at app.hermoso.ai on the MCP & CLI tab, under Terminal & API keys.';
112
+ export const signedOut = () => !mcpCtx.getStore() && !TOKEN && !/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/.test(API_BASE);
113
+ /** The 401 sentence a caller with no credential reads. PURE (the signed-out verdict is an argument) so a check runs it. */
114
+ export function signInMessage(status, body, msg, isSignedOut = signedOut()) {
115
+ if (Number(status) !== 401 || body?.connector || !isSignedOut) return msg;
116
+ return /^sign in to continue\.?$/i.test(String(msg || '').trim()) ? SIGN_IN_HINT : `${msg} ${SIGN_IN_HINT}`;
117
+ }
118
+
104
119
  // unwrap the {data}|{error} envelope; throw a clean Error (with .status) on failure
105
120
  async function unwrap(res) {
106
121
  let body = null;
107
122
  try { body = await res.json(); } catch {}
108
123
  if (!res.ok) {
109
- const msg = (body && (body.error || body.message)) || `HTTP ${res.status}`;
124
+ const msg = signInMessage(res.status, body, (body && (body.error || body.message)) || `HTTP ${res.status}`);
110
125
  // `_viaApi` MARKS AN ERROR THAT ALREADY REACHED THE SERVER, so route() has already recorded it in the error
111
126
  // ledger with the tool name off x-hermoso-tool. wrap() reports ONLY the errors that lack this marker — a local
112
127
  // throw, a schema rejection, a socket reset — which is what stops the twins double-counting every 4xx.
@@ -13,7 +13,12 @@ import { API_BASE, connectedProviders } from './client.mjs';
13
13
 
14
14
  // instructions = the full capability map (ad spy · create · raw model playground · account) — one source of truth
15
15
  // in tools.mjs, shared with the hosted connector (http.mjs), so every surface tells agents the same breadth.
16
- const server = new McpServer({ name: 'hermoso-mcp', version: '1.0.0' }, {
16
+ // THE REAL VERSION IN serverInfo (2026-09-25). This said '1.0.0' — a version the package has never had — so a client's
17
+ // server list and a directory that reads serverInfo both reported it, while npm shipped 0.1.x. Read from the package
18
+ // this file ships in (cli/package.json in the npm package; the app's own package.json in the app repo).
19
+ import { createRequire } from 'node:module';
20
+ const PKG_VERSION = (() => { try { return createRequire(import.meta.url)('../package.json').version || '0.0.0'; } catch { return '0.0.0'; } })();
21
+ const server = new McpServer({ name: 'hermoso-mcp', version: PKG_VERSION }, {
17
22
  instructions: MCP_INSTRUCTIONS,
18
23
  });
19
24
 
package/mcp/http.mjs CHANGED
@@ -283,6 +283,14 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
283
283
  // a 22-tool server on ~430 directory pages. So an UNSTATED scope here resolves to the full pre-core-first
284
284
  // default rather than to the session default; an explicit `?tools=` still wins, exactly as it does below.
285
285
  registerTools(server, { only: scope?.groups || [...DEFAULT_TOOL_GROUPS], directory: scope?.directory || false, widgetHost: isWidgetHost(clientInfoOf(req.body), req) , hosted: true }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
286
+ // WHO PROBES US WITHOUT A TOKEN, BY NAME (2026-09-25). A host's add-connector dialog decides "sign-in needed or
287
+ // not" from THIS answer: ChatGPT probes with an empty body and gets the 401; claude.ai's dialog sends a real
288
+ // tokenless initialize (UA python-httpx) and our 200 made it pre-select "No sign-in". The UA alone cannot tell
289
+ // that probe from a registry crawler, so the clientInfo it names is logged, one line per anonymous initialize,
290
+ // so the next matcher is chosen from evidence (the Grok one was).
291
+ if (methodsOf(req.body).includes('initialize')) {
292
+ try { const m = (Array.isArray(req.body) ? req.body : [req.body]).find((x) => x && x.method === 'initialize'); const ci = m?.params?.clientInfo || {}; console.error(`[mcp-anon] initialize client=${JSON.stringify(String(ci.name || '').slice(0, 64))} v=${JSON.stringify(String(ci.version || '').slice(0, 24))} proto=${String(m?.params?.protocolVersion || '').slice(0, 16)} ua=${JSON.stringify(String(req.headers['user-agent'] || '').slice(0, 80))} src=${srcOf(req) || '-'}`); } catch {}
293
+ }
286
294
  if (typeof onAnonDiscovery === 'function' && methodsOf(req.body).includes('tools/list')) { try { onAnonDiscovery({ client: clientInfoOf(req.body), ua: String(req.headers['user-agent'] || '').slice(0, 120), src: srcOf(req) }); } catch {} }
287
295
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
288
296
  res.on('close', () => { try { transport.close(); server.close(); } catch {} });
@@ -334,6 +342,21 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
334
342
  // DELETE too. The one exception is an `initialize`, which IS a client starting over: strict in what we send,
335
343
  // liberal in what we accept, because the entire point of this fix is that nothing here bricks a client.
336
344
  if (sid && !init) return sessionGone(res);
345
+ // ── `server/discover` IS A HOST ASKING WHICH ERA WE SPEAK, AND THE 400 IS THE ANSWER (2026-09-25) ────────────
346
+ // ChatGPT (openai-mcp/1.0.0) and Claude Code (2.1.282) open EVERY connection with the MCP 2026-07-28 probe
347
+ // `server/discover` (headers MCP-Protocol-Version: 2026-07-28 + Mcp-Method, the version in params._meta) and
348
+ // only then fall back to `initialize`. That is the first POST after a token, and it is a 400 in the request log
349
+ // on every connect — which read like the connect failing. It is not: the spec's own compatibility matrix
350
+ // (modelcontextprotocol.io/specification/2026-07-28/basic/versioning, "Dual-era client / Legacy server:
351
+ // Works") says a dual-era host falls back when "the modern request returns a 4xx without a recognized modern
352
+ // error body". So this answer is deliberate and must stay LEGACY-shaped: a -32601 or any other modern code
353
+ // (-32020/-32021/-32022) would tell the host we are a modern server and it would stop falling back — every
354
+ // host would stall at connect. Serving the modern revision for real is a transport rewrite (no protocol-level
355
+ // sessions, per-request _meta, subscriptions/listen) that SDK 1.29 does not implement; this is the answer
356
+ // until then. Pinned by tools/mcp-connect-canary-check.mjs and replayed daily by the connect canary.
357
+ if (!init && req.method === 'POST' && methodsOf(req.body).includes('server/discover')) {
358
+ return res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: this server speaks the initialize-based MCP revisions (2025-11-25 and earlier). Send initialize to open a session.' }, id: null });
359
+ }
337
360
  // Only an `initialize` may mint a session. Anything else naming no session at all is the SDK's own 400 —
338
361
  // answered here so we never build a server whose only job would be to reject the request.
339
362
  if (!init) return needSession(res);
package/mcp/tools.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  // needed today), and the SAME guard becomes authoritative under real auth — so this honors no-anon-spend as-is.
6
6
  import { z } from 'zod';
7
7
  import { absolutizeAssetUrl, publicOrigin } from './public-url.mjs';
8
- import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, jobResult, pollJob, jobWaitMs, toRef, localRefVerdict, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, reportDeadEnd, hostRendersWidgets, connectedProviders, setPinnedProfile } from './client.mjs';
8
+ import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, jobResult, pollJob, jobWaitMs, toRef, localRefVerdict, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, reportDeadEnd, hostRendersWidgets, connectedProviders, setPinnedProfile, signedOut, SIGN_IN_HINT } from './client.mjs';
9
9
  import { readFile } from 'node:fs/promises';
10
10
  import { createHash } from 'node:crypto';
11
11
  import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
@@ -447,7 +447,7 @@ const wrap = (fn) => {
447
447
  }
448
448
  catch (e) {
449
449
  try { recordToolOutcome(_tool, { ok: false, ms: Date.now() - _t0 }); } catch {}
450
- if (!e?._viaApi) { try { reportToolError(_tool, e); } catch {} }
450
+ if (!e?._viaApi && !e?._signedOut) { try { reportToolError(_tool, e); } catch {} } // a caller with no key is not a defect, and its report would be anonymous noise
451
451
  let msg = `Error: ${e?.message || e}`;
452
452
  // THE SAME TWO PIECES OF ADVICE, NAMED (2026-09-17). Everything below stays exactly as it was — the prose is
453
453
  // what a model reads and what half the hosts show. `_hints` is the identical advice keyed as {do, why}, so a
@@ -3621,6 +3621,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
3621
3621
  }, wrap(async () => {
3622
3622
  const d = await apiGet('/api/credits');
3623
3623
  const bal = d.accountBalance ?? d.balance; // accountBalance = the caller's Hermoso credits (authed); balance = the local-dev usage pill
3624
+ // NO BALANCE IS NOT A NUMBER (2026-09-25): this printed "Balance: undefined credits" to every caller with no key,
3625
+ // because /api/credits answers an anonymous caller 200 with a null balance. Say why there is none instead.
3626
+ if (bal == null) throw Object.assign(new Error(signedOut() ? SIGN_IN_HINT : 'The balance could not be read just now. Try again in a moment; billing_status reads it too.'), { status: signedOut() ? 401 : 503, _signedOut: signedOut() });
3624
3627
  // THE CHARGES THEMSELVES (2026-09-12): the same ledger list as Billing ▸ Usage, so "where did my credits go" has an
3625
3628
  // answer here too. Best effort: a reply about the balance never fails on the history read.
3626
3629
  let recent = ''; try { const u = await apiGet('/api/billing/usage', { limit: 10 }); if (u?.items?.length) recent = `\nRecent charges (newest first):\n${u.items.map((r) => `• ${String(r.at).slice(0, 16).replace('T', ' ')} UTC · ${r.label} · ${r.credits} credits`).join('\n')}`; } catch {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.303",
3
+ "version": "0.1.305",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "Marketing on autopilot, run from your own AI agent. 863 tools. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
6
6
  "type": "module",