hermoso 0.1.537 → 0.1.541

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
@@ -133,8 +133,9 @@ just download it. Use the one piece you need, or all of it together.
133
133
  ## Install in one command
134
134
 
135
135
  This repo is a plugin marketplace, a Gemini CLI extension and a skills package at once, so a coding agent takes
136
- Hermoso in one line. Every install brings the same six skills (`hermoso-research`, `hermoso-generate`,
137
- `hermoso-ad-from-brand`, `hermoso-product-photoshoot`, `hermoso-marketing`, `hermoso-edit-timeline`), and the skills drive the `hermoso`
136
+ Hermoso in one line. Every install brings the same seven skills (`hermoso-research`, `hermoso-generate`,
137
+ `hermoso-ad-from-brand`, `hermoso-product-photoshoot`, `hermoso-marketing`, `hermoso-edit-timeline`,
138
+ `hermoso-launch-app`), and the skills drive the `hermoso`
138
139
  CLI through `npx`. No
139
140
  tool list is loaded into your session: a CLI command costs nothing until it runs, and it reaches every tool.
140
141
  The first time, your agent runs `npx -y hermoso auth login`, which opens a browser to sign in.
@@ -286,7 +287,7 @@ Connect, approve with your Hermoso account, and you are done: the full toolset w
286
287
  1. **Get an account** at [app.hermoso.ai](https://app.hermoso.ai). The free tier is included; plans and credits
287
288
  are the same ones the web Studio uses. Or skip the browser entirely and let your agent sign itself up on a paid
288
289
  plan with `POST /v1/signup` (above).
289
- 2. **Install the plugin.** It adds the six Hermoso skills, which drive the `hermoso` CLI through `npx`:
290
+ 2. **Install the plugin.** It adds the seven Hermoso skills, which drive the `hermoso` CLI through `npx`:
290
291
 
291
292
  ```bash
292
293
  claude plugin marketplace add hermoso-ai/hermoso && claude plugin install hermoso@hermoso
@@ -514,7 +515,7 @@ the package, so they need no key, no network and no sign-in.
514
515
 
515
516
  ## 3. Skills: what a coding agent installs
516
517
 
517
- `skills/` holds six installable skills:
518
+ `skills/` holds seven installable skills:
518
519
 
519
520
  | Skill | What it does |
520
521
  | --- | --- |
@@ -524,6 +525,7 @@ the package, so they need no key, no network and no sign-in.
524
525
  | `hermoso-product-photoshoot` | A real product photo composited into studio, lifestyle or hero scenes |
525
526
  | `hermoso-marketing` | The whole loop: research, create, publish and schedule, paid campaigns, measure |
526
527
  | `hermoso-edit-timeline` | The manual for composing any video edit or transition with `edit_timeline` |
528
+ | `hermoso-launch-app` | Launch and market an app or site you just built: brand, launch ads, a week of scheduled posts |
527
529
 
528
530
  They are what every one-command install at the top of this page adds, in Claude Code, Codex, Gemini CLI and
529
531
  through `npx skills add`. From a clone, copying works too:
@@ -1276,9 +1276,10 @@ export const neutralNote = (t) => {
1276
1276
  // FAILED read is reported as a note and never as an empty list ([[failed-read-is-not-empty]]).
1277
1277
  // OUR OWN HOUSEKEEPING IS NOT A RENDER. /api/jobs lists every job on the account, including idle nudges, retention
1278
1278
  // sweeps and drift watches queued for later (measured on a fresh local account: one `agentidle` "nudge", queued for
1279
- // tomorrow, would have shown as "1 render in progress"). Mirrors server.js JOB_TYPES_HIDDEN_FROM_V1, and the check
1280
- // fails if that list gains a type this one lacks. A job queued for LATER is not in progress either.
1281
- export const PANEL_HIDDEN_JOB_TYPES = Object.freeze(['shopifykeepalive', 'shopredact', 'fallate', 'storysweep', 'ttpublicurl', 'whatsappcoexsync', 'purge', 'agentidle', 'welcomewait', 'agentsetupmail', 'uploadretention', 'miniappretention', 'connectordrift', 'falwatch', 'faceassetsweep', 'searchvis', 'mcpcanary', 'welcomeads', 'lifecycleedu', 'connhealth', 'chargednothing', 'badoutcome', 'reviewask', 'emailfeedback', 'weeklyposts', 'winback']);
1279
+ // tomorrow, would have shown as "1 render in progress"). Mirrors server.js JOB_TYPES_HIDDEN_FROM_V1 by each type's PUBLIC
1280
+ // name (the one /api/jobs returns, lib/job-types.mjs publicJobType), and the check fails if that list gains a type this
1281
+ // one lacks. A job queued for LATER is not in progress either.
1282
+ export const PANEL_HIDDEN_JOB_TYPES = Object.freeze(['shopifykeepalive', 'shopredact', 'latelanding', 'storysweep', 'ttpublicurl', 'whatsappcoexsync', 'purge', 'agentidle', 'welcomewait', 'agentsetupmail', 'uploadretention', 'miniappretention', 'connectordrift', 'modelwatch', 'faceassetsweep', 'searchvis', 'mcpcanary', 'welcomeads', 'lifecycleedu', 'connhealth', 'chargednothing', 'badoutcome', 'reviewask', 'emailfeedback', 'weeklyposts', 'winback']);
1282
1283
  const ageText = (h) => (h == null || !Number.isFinite(+h) ? '' : +h < 1 ? 'just now' : +h < 48 ? `${Math.round(+h)}h ago` : `${Math.round(+h / 24)}d ago`);
1283
1284
  const VIDEO_RE = /\.(mp4|webm|mov|m4v)([?#]|$)/i;
1284
1285
  const clip = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
package/mcp/tools.mjs CHANGED
@@ -435,7 +435,7 @@ export const MCP_INSTRUCTIONS_WIDGET = [
435
435
  "Hermoso runs a brand's marketing: research competitors' ads, make image and video ads, post to their accounts, run paid campaigns, read results. Each area works on its own.",
436
436
  'Asked to make something, make it; render tools pick a sound default model (hermoso_capabilities lists models and costs).',
437
437
  '• RESEARCH: find_competitors, pull_competitor_ads, research_ads, competitor_teardown, mine_angles.',
438
- '• CREATE: get_brand / draft_brand, plan_ad, render_ad, generate_image, generate_video, make_template_ad, clone_static, remake_video_for_brand; variants: edit_image, headline_variants, hook_variants, multiply_ad. Renders run in background; poll get_job.',
438
+ '• CREATE: get_brand / draft_brand, plan_ad, render_ad, generate_image, generate_video, make_template_ad, clone_static, plan_video_remake; variants: edit_image, headline_variants, hook_variants, multiply_ad. Renders run in background; poll get_job.',
439
439
  "• FILES & SCHEDULING: upload_file turns a file into a URL; schedule_post queues a post; list_scheduled shows the queue; list_connectors shows what is connected; list_library lists past creations.",
440
440
  '• PUBLISH: post_to_* (post_to_meta, post_to_tiktok...) post now; list_inbox and reply_to_inbox_item answer comments and messages.',
441
441
  '• PAID ADS (Meta, Google, LinkedIn, TikTok, Reddit, Snapchat, ChatGPT): create_* builds campaigns paused; set_*_status and set_*_budget control spend.',
@@ -674,7 +674,13 @@ export function noteBrandOf(tool, args) {
674
674
  for (const k of HERMOSO_BRAND_KEYS.get(tool) || []) { const v = args?.[k]; if (typeof v === 'string' && v.trim()) return v.trim().slice(0, 120); }
675
675
  return '';
676
676
  }
677
- export async function withConnectPrompt(tool, r, post = apiPost, brand = '') {
677
+ // …AND AT MOST ONE NEXT STEP, WHICHEVER IT IS (2026-11-14, lib/agent-nudge.mjs). `nudge: 1` tells the server this twin reads
678
+ // `next` off the answer: the one step it chose for this account (save a brand first, then connect a channel), with the
679
+ // sentence to say, each offered at most once in seven days and never once done. The server words the text, the step and
680
+ // the hint, so a step added later needs no new twin; a server that predates this answers the connect prompt as before.
681
+ // `raw` marks a call on the raw model lane, which gets no note of ours about a brand; `asked` says this session's
682
+ // first-run note already asked for their website, so the same session is not asked twice.
683
+ export async function withConnectPrompt(tool, r, post = apiPost, brand = '', opts = {}) {
678
684
  try {
679
685
  const kind = finishedRenderKind(tool, r);
680
686
  if (!kind) return r;
@@ -683,10 +689,16 @@ export async function withConnectPrompt(tool, r, post = apiPost, brand = '') {
683
689
  const sc = r.structuredContent || {};
684
690
  const job = String(sc.jobId || sc.id || '').slice(0, 80);
685
691
  const client = store ? String(store.client || '') : 'hermoso';
686
- const d = await Promise.race([Promise.resolve(post('/api/mcp/connect-prompt', { tool, kind, job, client, ...(brand ? { brand } : {}) })).catch(() => null), new Promise((res) => { const t = setTimeout(() => res(null), CONNECT_PROMPT_WAIT_MS); if (t && typeof t.unref === 'function') t.unref(); })]);
692
+ const d = await Promise.race([Promise.resolve(post('/api/mcp/connect-prompt', { tool, kind, job, client, nudge: 1, ...(opts && opts.raw ? { raw: true } : {}), ...(opts && opts.asked ? { asked: true } : {}), ...(brand ? { brand } : {}) })).catch(() => null), new Promise((res) => { const t = setTimeout(() => res(null), CONNECT_PROMPT_WAIT_MS); if (t && typeof t.unref === 'function') t.unref(); })]);
687
693
  if (!d || d.show !== true || typeof d.text !== 'string' || !d.text) return r;
688
- const out = { ...r, content: [...r.content, { type: 'text', text: d.text }], structuredContent: { ...sc, connectPrompt: { channels: Array.isArray(d.channels) ? d.channels : [] } } };
689
- return withHints(out, [...hintsOf(r), { do: 'offer to post this; hand the user one of the connect links in this reply', why: 'no publishing channel is connected, and connecting needs a browser sign-in on the platform' }]);
694
+ const next = d.next && typeof d.next === 'object' && typeof d.next.step === 'string' && d.next.step ? d.next : null;
695
+ const hint = d.hint && typeof d.hint === 'object' && typeof d.hint.do === 'string' && d.hint.do ? d.hint : null;
696
+ if (next && next.step !== 'connect_channel') {
697
+ const out = { ...r, content: [...r.content, { type: 'text', text: d.text }], structuredContent: { ...sc, next } };
698
+ return hint ? withHints(out, [...hintsOf(r), hint]) : out;
699
+ }
700
+ const out = { ...r, content: [...r.content, { type: 'text', text: d.text }], structuredContent: { ...sc, connectPrompt: { channels: Array.isArray(d.channels) ? d.channels : [] }, ...(next ? { next } : {}) } };
701
+ return withHints(out, [...hintsOf(r), hint || { do: 'offer to post this; hand the user one of the connect links in this reply', why: 'no publishing channel is connected, and connecting needs a browser sign-in on the platform' }]);
690
702
  } catch { return r; }
691
703
  }
692
704
  // THE WEEK-PLAN NOTE (2026-11-02, lib/week-plan.mjs): the offer after a first connection, and "your week is finished". The
@@ -699,6 +711,7 @@ const WEEK_PLAN_NOTE_SKIP = new Set(['plan_week', 'get_job', 'list_jobs']);
699
711
  export async function withWeekPlanNote(tool, r, post = apiPost, brand = '') {
700
712
  try {
701
713
  if (!r || r.isError || !Array.isArray(r.content) || WEEK_PLAN_NOTE_SKIP.has(tool)) return r;
714
+ if (r.structuredContent && r.structuredContent.next && typeof r.structuredContent.next.step === 'string') return r; // one next step a reply (a paging cursor called `next` is not one): this note is not asked for, so it is not spent, and the next reply carries it
702
715
  const store = mcpCtx.getStore();
703
716
  if (store && (store.connectPromptQuiet || (store.remote && store.client === undefined))) return r;
704
717
  const client = store ? String(store.client || '') : 'hermoso';
@@ -872,6 +885,7 @@ const wrap = (fn) => {
872
885
  _fr.said = true;
873
886
  const _n = ((_st.apiCharged || 0) + (_st.jobCharged || 0)) > 0 ? null : firstRunNote(_tool, _fr, r);
874
887
  if (_n) {
888
+ if (/draft_brand/.test(String(_n.hint?.do || ''))) _fr.brandAsked = true; // this session has asked for their website: its next finished creation does not ask again (withConnectPrompt `asked`)
875
889
  const _last = [...r.content].reverse().find((b) => b && b.type === 'text' && typeof b.text === 'string');
876
890
  if (_last) _last.text += `\n\n${_n.text}`; else r.content.push({ type: 'text', text: _n.text });
877
891
  return noPitchOnWidget(withHints(r, [_n.hint]));
@@ -879,7 +893,7 @@ const wrap = (fn) => {
879
893
  }
880
894
  } catch {}
881
895
  const _noteBrand = noteBrandOf(_tool, args); // the brand this call acted on, so both notes are about it
882
- return noPitchOnWidget(await withWeekPlanNote(_tool, await withConnectPrompt(_tool, withWidgetResultNote(_tool, r, hostRendersWidgets()), apiPost, _noteBrand), apiPost, _noteBrand));
896
+ return noPitchOnWidget(await withWeekPlanNote(_tool, await withConnectPrompt(_tool, withWidgetResultNote(_tool, r, hostRendersWidgets()), apiPost, _noteBrand, { raw: args?.raw === true, asked: mcpCtx.getStore()?.firstRun?.brandAsked === true }), apiPost, _noteBrand));
883
897
  }
884
898
  catch (e) {
885
899
  try { recordToolOutcome(_tool, { ok: false, ms: Date.now() - _t0 }); } catch {}
@@ -3059,7 +3073,9 @@ const executorLike = (name) => EXECUTOR_TOOLS.has(name) || WIDGET_GENERIC_PROXIE
3059
3073
  // After publication OpenAI rescans daily, so a tool ChatGPT should have is added to its list by name instead.
3060
3074
  // 2. "Tool names should be … specific, and descriptive of what the tool actually does." The scan found `clone_video`
3061
3075
  // unclear. Renaming it everywhere would break every agent that calls it, so a widget host lists the SAME tool
3062
- // (same definition, same handler) as `remake_video_for_brand`, and `clone_video` stays the name everywhere else.
3076
+ // (same definition, same handler) as `plan_video_remake`, and `clone_video` stays the name everywhere else.
3077
+ // (It was `remake_video_for_brand` until the 2026-11-14 scan read that name as promising a finished video, which
3078
+ // the tool does not make: it plans one. The old name stays registered, unlisted and callable: WIDGET_HOST_RETIRED_NAMES.)
3063
3079
  // A direct call to `clone_video` from a widget host still answers (an older scan holds that name).
3064
3080
  // 3. ANNOTATIONS FOLLOW THE DOC'S DEFINITIONS (developers.openai.com/plugins/deploy/app-review.md). openWorldHint is TRUE
3065
3081
  // on every generation and post-production tool: each sends the caller's prompt or media to an independently
@@ -3069,9 +3085,13 @@ const executorLike = (name) => EXECUTOR_TOOLS.has(name) || WIDGET_GENERIC_PROXIE
3069
3085
  // caller's own credits is metering, not a change to the environment the tool acts on, so a research read that
3070
3086
  // bills stays read-only. destructiveHint is TRUE on draft_brand: save:true replaces the saved brand profile.
3071
3087
  export const EXECUTOR_TOOLS = new Set(['find_tools', 'call_tool', 'enable_tools']);
3072
- export const WIDGET_HOST_RENAMES = Object.freeze({ clone_video: 'remake_video_for_brand', click_to_ad: 'plan_video_ad_from_product_link', delete_openai_ads_object: 'archive_openai_ads_object', make_from_preset: 'plan_video_from_preset', make_short: 'plan_short_video' }); // make_from_preset + make_short (2026-11-09 scan, "name appears promotional, exaggerated, or inconsistent with its behavior"): both PLAN and render nothing, render_ad makes the video // delete_openai_ads_object (2026-11-03 scan, "name … inconsistent with its behavior"): OpenAI has no delete, the tool ARCHIVES // click_to_ad: the scan (2026-10-27) read the name as promotional / not what it does — it PLANS the ad, render_ad makes it
3088
+ export const WIDGET_HOST_RENAMES = Object.freeze({ clone_video: 'plan_video_remake', click_to_ad: 'plan_video_ad_from_product_link', delete_openai_ads_object: 'archive_openai_ads_object', make_from_preset: 'plan_video_from_preset', make_short: 'plan_short_video' }); // make_from_preset + make_short (2026-11-09 scan, "name appears promotional, exaggerated, or inconsistent with its behavior"): both PLAN and render nothing, render_ad makes the video // delete_openai_ads_object (2026-11-03 scan, "name … inconsistent with its behavior"): OpenAI has no delete, the tool ARCHIVES // click_to_ad: the scan (2026-10-27) read the name as promotional / not what it does — it PLANS the ad, render_ad makes it
3073
3089
  export const WIDGET_HOST_ALIAS_OF = Object.freeze(Object.fromEntries(Object.entries(WIDGET_HOST_RENAMES).map(([from, to]) => [to, from])));
3074
- export const WIDGET_HOST_ALIAS_TITLES = Object.freeze({ remake_video_for_brand: 'Remake a video for your brand', plan_video_ad_from_product_link: 'Plan a video ad from a product link', archive_openai_ads_object: 'Archive a ChatGPT Ads campaign, ad group or ad', plan_video_from_preset: 'Plan a video from a saved preset', plan_short_video: 'Plan a short video from a format' });
3090
+ // A WIDGET-HOST NAME THAT WAS REPLACED, and the canonical tool it still runs. ChatGPT keeps the roster it last published
3091
+ // until a scan publishes the new one, so the old name stays registered on every widget host, never listed, and a call to
3092
+ // it runs the same handler.
3093
+ export const WIDGET_HOST_RETIRED_NAMES = Object.freeze({ remake_video_for_brand: 'clone_video' });
3094
+ export const WIDGET_HOST_ALIAS_TITLES = Object.freeze({ plan_video_remake: 'Plan a video remake for your brand', plan_video_ad_from_product_link: 'Plan a video ad from a product link', archive_openai_ads_object: 'Archive a ChatGPT Ads campaign, ad group or ad', plan_video_from_preset: 'Plan a video from a saved preset', plan_short_video: 'Plan a short video from a format' });
3075
3095
  // ── ONE OPERATION, ONE TOOL, ON A WIDGET HOST (2026-10-18) ─────────────────────────────────────────────────────
3076
3096
  // OpenAI's daily scan HELD 18 tools that take an `action` enum and do different things per value: "This tool appears
3077
3097
  // to execute operations that are not individually exposed. Please expose each model-callable operation upfront as a
@@ -3319,7 +3339,7 @@ export const WIDGET_SPLIT_OF = Object.freeze(Object.fromEntries(Object.entries(W
3319
3339
  // Every name a widget host is never LISTED, whatever its scope. Pure, so the checks run it.
3320
3340
  // `app` (optional) is the focused app of the session: a tool that app offers (WIDGET_APP_OFFERS) is not hidden there.
3321
3341
  export const widgetHostHides = (name, widgetHost, app = null) => (widgetHost
3322
- ? ((WITHHELD_FROM_WIDGET_HOSTS.has(name) && !widgetAppOffers(name, app)) || EXECUTOR_TOOLS.has(name) || Object.prototype.hasOwnProperty.call(WIDGET_HOST_RENAMES, name) || Object.prototype.hasOwnProperty.call(WIDGET_HOST_SPLITS, name))
3342
+ ? ((WITHHELD_FROM_WIDGET_HOSTS.has(name) && !widgetAppOffers(name, app)) || EXECUTOR_TOOLS.has(name) || Object.prototype.hasOwnProperty.call(WIDGET_HOST_RENAMES, name) || Object.prototype.hasOwnProperty.call(WIDGET_HOST_RETIRED_NAMES, name) || Object.prototype.hasOwnProperty.call(WIDGET_HOST_SPLITS, name))
3323
3343
  : (Object.prototype.hasOwnProperty.call(WIDGET_HOST_ALIAS_OF, name) || Object.prototype.hasOwnProperty.call(WIDGET_SPLIT_OF, name)));
3324
3344
  // HELD OUT OF THE DEFAULT LIST ON SIZE, ALWAYS CALLABLE (2026-09-24): the open edit primitive carries a full
3325
3345
  // self-describing schema, and the default roster sits at its token ceiling. find_tools finds these, call_tool or a
@@ -3755,16 +3775,13 @@ const APP_ADS_ALSO = /_post_ad_authorization$|^tiktok_spark_recommendations$/;
3755
3775
  // Manager, and the read-only post-performance tools written in `create` stay reachable in Social (they generate
3756
3776
  // nothing; DIRECTORY_BANNED_GROUPS would otherwise hold them by group alone).
3757
3777
  export const APP_DIRECTORY_CREATE_ALLOW = new Set([...APP_SOCIAL_FROM_CREATE].filter((n) => n !== 'plan_week').concat(['list_library', 'fetch_asset', 'get_job', 'list_skills', 'get_skill']));
3758
- // ON THE CHATGPT APPS ONE WIDGET-HOST NAME IS RENAMED AGAIN (2026-11-06 scan: remake_video_for_brand "appears promotional,
3759
- // exaggerated, or inconsistent with its behavior"). It PLANS a remake and renders nothing, so on a ChatGPT app it is
3760
- // listed as plan_video_remake with a factual description; the unscoped ChatGPT roster and every other host keep their
3761
- // names (click_to_ad -> plan_video_ad_from_product_link is the precedent).
3762
- export const APP_WIDGET_RENAMES = Object.freeze({ remake_video_for_brand: 'plan_video_remake' });
3763
- export const APP_WIDGET_ALIAS_OF = Object.freeze({ plan_video_remake: 'clone_video' });
3764
- const APP_WIDGET_TITLES = Object.freeze({ plan_video_remake: 'Plan a video remake for your brand' });
3765
- const APP_WIDGET_DESCRIPTIONS = Object.freeze({
3766
- plan_video_remake: "Plans a new video ad for this brand modelled on a video the user links to: a TikTok, Instagram Reel, Facebook video or reel, X post, or a direct video file URL. YouTube does not let us download videos, so a YouTube link is refused up front and nothing is charged; the user downloads it themselves and uploads the file (upload_file). It reads the video first (frames across the clip plus a transcript of the voiceover, on-screen text and cut map), then writes a storyboard that keeps its hook, structure, cuts and pacing while using this brand's product, cast, setting and words, not the original's words, face or brand. The plan matches the original's length (up to 120s) unless durationSeconds is given. It renders nothing: render_ad turns the returned plan into the video. Uses credits for the plan plus about 2 credits to read the link. The reply says what was read; when a platform does not hand over the footage, the plan rests on the captions and thumbnail only. For a local file, use upload_file first and pass the URL.",
3767
- });
3778
+ // ON THE CHATGPT APPS A WIDGET-HOST NAME CAN BE RENAMED AGAIN (APP_WIDGET_RENAMES; empty today). The one it held,
3779
+ // remake_video_for_brand -> plan_video_remake (2026-11-06 scan), became the widget-host name on every ChatGPT roster after
3780
+ // the 2026-11-14 scan flagged the all-in-one app's name too (WIDGET_HOST_RENAMES, WIDGET_HOST_DESCRIPTIONS).
3781
+ export const APP_WIDGET_RENAMES = Object.freeze({});
3782
+ export const APP_WIDGET_ALIAS_OF = Object.freeze({});
3783
+ const APP_WIDGET_TITLES = Object.freeze({});
3784
+ const APP_WIDGET_DESCRIPTIONS = Object.freeze({});
3768
3785
  // OPENAI'S FIRST SCAN OF THE TWO NEW APPS (2026-11-06) gave stated reasons on tools ChatGPT had never listed before. On a
3769
3786
  // ChatGPT app (widget host) only: the hint OpenAI read as wrong is corrected, a sentence that compares us with other
3770
3787
  // products is dropped, and the three bulk tools that take an operations list ("operations that are not individually
@@ -3806,7 +3823,7 @@ export const APP_DESC_LEADS = Object.freeze({
3806
3823
  });
3807
3824
  export const APP_WIDGET_UNLISTED = new Set(['microsoft_ads_bulk_download', 'microsoft_ads_bulk_upload', 'pinterest_ads_bulk']);
3808
3825
  const appWidgetUnlisted = (name) => Object.prototype.hasOwnProperty.call(APP_WIDGET_RENAMES, name) || APP_WIDGET_UNLISTED.has(name);
3809
- const appBaseName = (name) => APP_WIDGET_ALIAS_OF[name] || WIDGET_HOST_ALIAS_OF[name] || WIDGET_SPLIT_OF[name] || name;
3826
+ const appBaseName = (name) => APP_WIDGET_ALIAS_OF[name] || WIDGET_HOST_ALIAS_OF[name] || WIDGET_HOST_RETIRED_NAMES[name] || WIDGET_SPLIT_OF[name] || name;
3810
3827
  export function appScopeHas(app, name, group, directory = false) {
3811
3828
  if (!app) return true;
3812
3829
  const n = appBaseName(name);
@@ -3948,6 +3965,9 @@ export function stripOutputSchema(def) {
3948
3965
  // unclear. Every other host keeps the longer text, which carries real operating detail; ChatGPT gets the same behaviour
3949
3966
  // described plainly. Each override is a complete description: what the tool does, its inputs, its cost.
3950
3967
  export const WIDGET_HOST_DESCRIPTIONS = Object.freeze({
3968
+ // 2026-11-14 scan (all-in-one app): "name appears promotional, exaggerated, or inconsistent with its behavior". It plans and
3969
+ // renders nothing, so the name and the first words both say so.
3970
+ plan_video_remake: "Plans a new video ad for this brand modelled on a video the user links to: a TikTok, Instagram Reel, Facebook video or reel, X post, or a direct video file URL. YouTube does not let us download videos, so a YouTube link is refused up front and nothing is charged; the user downloads it themselves and uploads the file (upload_file). It reads the video first (frames across the clip plus a transcript of the voiceover, on-screen text and cut map), then writes a storyboard that keeps its hook, structure, cuts and pacing while using this brand's product, cast, setting and words, not the original's words, face or brand. The plan matches the original's length (up to 120s) unless durationSeconds is given. It renders nothing: render_ad turns the returned plan into the video. Uses credits for the plan plus about 2 credits to read the link. The reply says what was read; when a platform does not hand over the footage, the plan rests on the captions and thumbnail only. For a local file, use upload_file first and pass the URL.",
3951
3971
  search_console_performance: "Google Search Console's performance report for the brand's site: the search queries people used, with impressions, clicks, click-through rate and average position, by query, page, country, device, search appearance, date or hour. startDate and endDate are required, as YYYY-MM-DD (Pacific time). Rows are the top ones Google returns; anonymised queries are withheld, so per-query clicks will not add up to the site total; the default 'final' data lags 2 to 3 days and 'all' adds fresh partial data. Up to 16 months of history. Read-only, 0 credits.",
3952
3972
  list_linkedin_events: "The events a LinkedIn company Page organizes: name, link, start and end times, online or in person, visibility and state. Only Pages shared with this profile can be read. It needs LinkedIn's Events permission; if a connection made before that permission was enabled is refused, the user reconnects LinkedIn once. Read-only, 0 credits. Needs LinkedIn connected.",
3953
3973
  edit_timeline: "Render one edited video from a timeline the caller describes: segments placed on an output timeline (later segments are drawn on top, and overlapping segments form the transition), each with keyframed position, scale, rotation, speed and opacity, plus optional HTML overlays and masks. Sources are a videoUrl, a Library or render URL, a direct file, or a public TikTok, Reel, Facebook or X post link (not YouTube: YouTube does not let us download videos, so a YouTube link is refused up front and nothing is charged; the user downloads it themselves and uploads the file (upload_file).); in and out are source seconds. Audio per segment: gain, mute, and J-cut or L-cut timing. video_frames shows exact frames for free, to find a cut point before the edit. Costs the flat post_edit price (about 2 credits); a 'generate' segment, which renders a new transition shot with a video model, costs more and is used only when the user asks for one.",
@@ -4085,7 +4105,7 @@ const widgetProxyHit = (d) => WIDGET_PROXY_REFS.some(([re]) => { re.lastIndex =
4085
4105
  // definition goes back through OpenAI's scan review: remake_video_for_brand's live text names make_short, and renaming
4086
4106
  // that one word to plan_short_video put it back in review, where it was held ("name appears promotional"). Its text
4087
4107
  // stays byte-identical to the published one; the make_short handler still answers that name.
4088
- export const WIDGET_RENAME_KEEP = Object.freeze({ remake_video_for_brand: Object.freeze(['make_short']) });
4108
+ export const WIDGET_RENAME_KEEP = Object.freeze({}); // remake_video_for_brand's entry went with its name (2026-11-14): plan_video_remake carries its own WIDGET_HOST_DESCRIPTIONS text
4089
4109
  function renamedForWidgetHost(def, name) {
4090
4110
  const keep = (name && WIDGET_RENAME_KEEP[name]) || null;
4091
4111
  const d = def && typeof def.description === 'string' ? def.description : '';
@@ -4610,6 +4630,15 @@ function registerWidgetHostAliases(rawServer, ctx, opts) {
4610
4630
  ctx.handleOf[to] = h; ctx.groupOf[to] = ctx.groupOf[from]; ctx.srcOf[to] = { def, handler: src.handler };
4611
4631
  applyToolGates(h, to, ctx.groupOf[to], ctx, opts);
4612
4632
  }
4633
+ for (const [old, from] of Object.entries(WIDGET_HOST_RETIRED_NAMES)) {
4634
+ const src = ctx.srcOf[from];
4635
+ if (!src || !src.def || typeof src.handler !== 'function' || ctx.handleOf[old]) continue;
4636
+ const to = WIDGET_HOST_RENAMES[from]; const title = (to && WIDGET_HOST_ALIAS_TITLES[to]) || src.def.title;
4637
+ const def = { ...src.def, title, ...(src.def.annotations ? { annotations: { ...src.def.annotations, title } } : {}) };
4638
+ const h = rawServer.registerTool(old, defForApp(defForHost(old, def, true), ctx.app, ctx.directory, old, true), src.handler);
4639
+ ctx.handleOf[old] = h; ctx.groupOf[old] = ctx.groupOf[from]; ctx.srcOf[old] = { def, handler: src.handler };
4640
+ applyToolGates(h, old, ctx.groupOf[old], ctx, opts); // widgetHostHides keeps it unlisted; a direct call runs it
4641
+ }
4613
4642
  if (!ctx.app) return;
4614
4643
  // A ChatGPT app renames once more (APP_WIDGET_RENAMES); the name it replaces stays registered and callable but unlisted.
4615
4644
  for (const [alias, to] of Object.entries(APP_WIDGET_RENAMES)) {
@@ -6203,7 +6232,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
6203
6232
  text: z.string().describe('The reply, exactly as it should appear publicly.'),
6204
6233
  },
6205
6234
  outputSchema: { ok: z.boolean(), source: z.string().optional(), id: z.string().optional(), note: z.string() },
6206
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
6235
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
6207
6236
  }, wrap(async ({ id, text }) => {
6208
6237
  const body = String(text || '').trim();
6209
6238
  if (!body) return { content: [{ type: 'text', text: 'Nothing to post — pass the reply text.' }], isError: true };
@@ -21513,7 +21542,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
21513
21542
  maxBudget: z.number().nullable().optional().describe("no rule may raise a budget above this, account currency; null = no cap"),
21514
21543
  confirm: z.boolean().optional().describe("required when this LOOSENS a cap (tightening never needs it)"),
21515
21544
  },
21516
- annotations: {"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": true},
21545
+ annotations: {"readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": true},
21517
21546
  }, wrap(async (a) => {
21518
21547
  const d = await apiPost('/api/meta/rules/limits', a);
21519
21548
  return ok(adRuleText(d), d);
@@ -21651,7 +21680,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
21651
21680
  maxBudget: z.number().nullable().optional().describe("no rule may raise a budget above this, account currency; null = no cap"),
21652
21681
  confirm: z.boolean().optional().describe("required when this LOOSENS a cap (tightening never needs it)"),
21653
21682
  },
21654
- annotations: {"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false},
21683
+ annotations: {"readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": false},
21655
21684
  }, wrap(async (a) => {
21656
21685
  const d = await apiPost('/api/google-ads/rules/limits', a);
21657
21686
  return ok(adRuleText(d), d);
@@ -25667,7 +25696,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
25667
25696
  creative: z.object({}).passthrough().optional().describe('an edited screenplay (the creative this tool returned, changed) to use instead'),
25668
25697
  lengthSeconds: z.number().optional().describe('re-outline at this length (throws away the screenplay and boards)'),
25669
25698
  },
25670
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
25699
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
25671
25700
  }, wrap(async (a) => {
25672
25701
  const id = encodeURIComponent(a.runId);
25673
25702
  let d = (a.creative || +a.lengthSeconds > 0) ? await apiPost(`/api/story/runs/${id}/edit`, { ...(a.creative ? { creative: a.creative } : {}), ...(+a.lengthSeconds > 0 ? { lengthSeconds: +a.lengthSeconds } : {}) }) : await apiGet(`/api/story/runs/${id}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.537",
3
+ "version": "0.1.541",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "AI marketing agent and AI ad generator, run from your own AI agent. 1233 tools. No markup on models on Starter and up. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on any plan; Free includes 5 posts a month and every paid plan posts without a limit. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky, Telegram, WordPress, Discord, Slack, Whop and Google Business Profile. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. 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",
@@ -0,0 +1,62 @@
1
+ ---
2
+ name: hermoso-launch-app
3
+ description: >-
4
+ Launch and market an app or site you just built, using Hermoso. Sets up the brand from the app, makes launch
5
+ ads and posts, schedules them on the owner's own social channels and, if asked, builds paused ad campaigns.
6
+ Use when the user says "launch this", "market my app", "get me users", "make launch posts", "promote what we
7
+ just built", or finishes a build in an app builder (Lovable, Replit, Bolt, v0, Base44) and asks what is next.
8
+ NOT for: building the app itself, or a single image from a prompt (use hermoso-generate).
9
+ argument-hint: "[the app's URL or one line about what it does, e.g. 'https://myapp.com, a habit tracker for runners']"
10
+ ---
11
+
12
+ # Hermoso: launch the app you just built
13
+
14
+ You built something. This gets it in front of people: a saved brand, launch creative, a week of scheduled posts
15
+ and, only if the user asks, paid campaigns created paused.
16
+
17
+ ## Before you start
18
+ - Hermoso has to be connected to this host as an MCP server (URL `https://app.hermoso.ai/mcp`, sign in once).
19
+ If its tools are not available, send the user to https://hermoso.ai/connect/ and pick their host
20
+ (Lovable, Replit, Bolt, v0 and Base44 each have a page with the exact clicks).
21
+ - In a terminal there is no MCP server to connect: the same tools run through the Hermoso CLI. Sign in once with
22
+ `npx -y hermoso auth login` (opens a browser; nothing to paste). On a machine with no browser:
23
+ `npx -y hermoso auth login --token <your key>`, using a key from app.hermoso.ai under **MCP & CLI**. Then
24
+ `hermoso tools --search <what you want>` finds a tool, `hermoso tools <name>` prints its arguments and
25
+ `hermoso call <name> --json '{...}'` runs it (after `npm install -g hermoso`, or prefix each with `npx -y`).
26
+ No account at all? An agent can sign itself up on a paid plan with `POST /v1/signup` at app.hermoso.ai; see
27
+ the Hermoso README.
28
+ - A tool you do not see in your list still exists: `find_tools` searches every Hermoso tool and `call_tool`
29
+ runs any of them by name.
30
+ - The connector works while you build. It is not part of the published app, so never wire Hermoso calls into
31
+ the app's own code unless the user asks for an API integration.
32
+
33
+ ## The launch, in order
34
+ Do only the steps the user wants. Say what you are about to do before anything that spends credits or posts.
35
+
36
+ 1. **Know the product.** Use what you already know from the build: what the app does, who it is for, its name,
37
+ its live URL. Ask only for what is missing. One line is enough.
38
+ 2. **Save the brand.** If the app has a public URL, call `draft_brand` with it so every ad matches the app's
39
+ look and voice. No URL yet? Call `draft_brand` with a short description instead. `get_brand` shows what is
40
+ already saved; do not draft twice.
41
+ 3. **See what works in this market (optional, costs credits).** `find_competitors` then `research_ads`, and
42
+ report the two or three hooks that keep showing up. Skip this if the user just wants posts.
43
+ 4. **Make the launch creative.** Three is a good first set: a static launch ad, a carousel or second static
44
+ with a different angle, and one short vertical video if the user wants video. `generate_image` and
45
+ `render_ad` pick sound defaults when you do not name a model. Video takes a few minutes: read the job with
46
+ `get_job` and never call it done before it finishes. Show the user each result.
47
+ 5. **Check the channels.** `list_connectors` says which social accounts are connected. None connected is not
48
+ an error: give the user the connect link from the tool result, or tell them to open Hermoso and connect
49
+ the channels they want under Connectors. Do not post anywhere the user did not name.
50
+ 6. **Schedule the week.** Show the exact caption and the exact account for each post and get a yes. Then
51
+ `schedule_post`, one a day, at the times the user picks or mornings in their time zone. `list_scheduled`
52
+ reads the queue back. Never rewrite or shorten a caption the user wrote.
53
+ 7. **Paid ads, only if asked.** Campaigns are created paused on the user's own ad accounts and read back
54
+ before you describe them. Turning one on is the user's decision, stated in their own words.
55
+ 8. **Report.** What was made (with links), what is scheduled and when, what is waiting on the user (a channel
56
+ to connect, a campaign to turn on), and the one thing to do next week: read what performed with
57
+ `post_performance` and make more of that.
58
+
59
+ ## Rules
60
+ - Nothing is published, scheduled or spent on ads without the user saying so in this conversation.
61
+ - If the free credits run out, say so plainly and stop. Do not swap in a cheaper version of what was asked.
62
+ - Use the app's real name, screens and URL. Do not invent features, prices, reviews or numbers for the ads.