@hubfluencer/mcp 0.1.0 → 0.3.0
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 +25 -1
- package/dist/index.js +194 -23
- package/package.json +3 -4
- package/src/client.ts +3 -3
- package/src/index.ts +286 -24
- package/src/login.ts +19 -17
- package/src/uploads.ts +25 -0
- package/dist/login.js +0 -377
package/src/index.ts
CHANGED
|
@@ -35,9 +35,11 @@ import {
|
|
|
35
35
|
normalizeStatus,
|
|
36
36
|
resolveSavePath,
|
|
37
37
|
} from "./core.js";
|
|
38
|
+
import { runLogin } from "./login.js";
|
|
38
39
|
import {
|
|
39
40
|
IMAGE_EXTS,
|
|
40
41
|
uploadImageFile,
|
|
42
|
+
uploadShortPoster,
|
|
41
43
|
uploadVideoFile,
|
|
42
44
|
VIDEO_EXTS,
|
|
43
45
|
} from "./uploads.js";
|
|
@@ -322,7 +324,7 @@ const WRITE = {
|
|
|
322
324
|
openWorldHint: true,
|
|
323
325
|
};
|
|
324
326
|
|
|
325
|
-
const server = new McpServer({ name: "hubfluencer", version: "0.
|
|
327
|
+
const server = new McpServer({ name: "hubfluencer", version: "0.3.0" });
|
|
326
328
|
|
|
327
329
|
// The SDK's `registerTool` is generic over the Zod input shape; with this many
|
|
328
330
|
// tools its conditional types hit TS2589 ("excessively deep"). Inputs are still
|
|
@@ -423,7 +425,12 @@ registerTool(
|
|
|
423
425
|
"WITHOUT charging (returns {estimated_credits, available_credits, slug}); pass max_credits to cap the " +
|
|
424
426
|
"spend. kind defaults to 'auto' — a multi-scene editor ad for ad/promo/story briefs, a single-clip short " +
|
|
425
427
|
"for simple/short ones; the chosen kind is reported back as kind_inferred. If it returns terminal=false " +
|
|
426
|
-
"the render is still running — call wait_for_completion with the returned slug to finish."
|
|
428
|
+
"the render is still running — call wait_for_completion with the returned slug to finish. " +
|
|
429
|
+
"BE PROACTIVE WITH BRANDING: pass headline (the on-screen TITLE) and subheadline (secondary title) plus " +
|
|
430
|
+
"music_vibe/theme so a one-shot short isn't bare — these apply to SHORTS (music_vibe/headline/subheadline " +
|
|
431
|
+
"are ignored for editor; theme applies to both). For richer branding — a product image, brand logo, or " +
|
|
432
|
+
"closing card — drive the granular path instead: create_short + set_short_product/set_short_poster, or " +
|
|
433
|
+
"create_editor_draft + set_product/set_logo/set_closing_image, then start_autopilot/generate_short.",
|
|
427
434
|
// Schema kept intentionally flat (no .min/.max/.int chains) — the SDK's
|
|
428
435
|
// generic inference on registerTool hits TS2589 ("excessively deep") on
|
|
429
436
|
// larger schemas with chained validators. Ranges are enforced in code.
|
|
@@ -451,6 +458,30 @@ registerTool(
|
|
|
451
458
|
.describe(
|
|
452
459
|
"Preferred narration voice id for editor ads (see list_voices); shorts have no voiceover",
|
|
453
460
|
),
|
|
461
|
+
headline: z
|
|
462
|
+
.string()
|
|
463
|
+
.optional()
|
|
464
|
+
.describe(
|
|
465
|
+
"SHORTS only: the on-screen TITLE overlay (≤160). Set this so the short isn't bare.",
|
|
466
|
+
),
|
|
467
|
+
subheadline: z
|
|
468
|
+
.string()
|
|
469
|
+
.optional()
|
|
470
|
+
.describe("SHORTS only: the secondary title / supporting line (≤200)"),
|
|
471
|
+
music_vibe: z
|
|
472
|
+
.string()
|
|
473
|
+
.optional()
|
|
474
|
+
.describe(
|
|
475
|
+
"SHORTS only: music mood — Upbeat (default), Cinematic, Minimal, Luxury, Playful, Jazz",
|
|
476
|
+
),
|
|
477
|
+
theme: z
|
|
478
|
+
.string()
|
|
479
|
+
.optional()
|
|
480
|
+
.describe(
|
|
481
|
+
"Visual theme (both kinds): none (literal — no imposed style), realistic (default), cinematic, " +
|
|
482
|
+
"anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, " +
|
|
483
|
+
"minimalist, cyberpunk",
|
|
484
|
+
),
|
|
454
485
|
save_path: z
|
|
455
486
|
.string()
|
|
456
487
|
.optional()
|
|
@@ -485,6 +516,10 @@ registerTool(
|
|
|
485
516
|
language?: string;
|
|
486
517
|
aspect?: string;
|
|
487
518
|
voice_id?: string;
|
|
519
|
+
headline?: string;
|
|
520
|
+
subheadline?: string;
|
|
521
|
+
music_vibe?: string;
|
|
522
|
+
theme?: string;
|
|
488
523
|
save_path?: string;
|
|
489
524
|
max_wait_seconds?: number;
|
|
490
525
|
dry_run?: boolean;
|
|
@@ -514,13 +549,31 @@ registerTool(
|
|
|
514
549
|
// make_video (or a dry_run then a real run) reuses this same draft.
|
|
515
550
|
let slug: string;
|
|
516
551
|
if (kind === "short") {
|
|
552
|
+
// Carry the creative fields inline so a one-shot short can be titled
|
|
553
|
+
// and branded (headline/subheadline/music_vibe are short-only; theme
|
|
554
|
+
// applies to both kinds).
|
|
517
555
|
const created = await client.post<{ data: { slug: string } }>(
|
|
518
556
|
"/shorts",
|
|
519
|
-
{
|
|
557
|
+
{
|
|
558
|
+
product_prompt: args.prompt,
|
|
559
|
+
language: args.language,
|
|
560
|
+
headline: args.headline,
|
|
561
|
+
subheadline: args.subheadline,
|
|
562
|
+
music_vibe: args.music_vibe,
|
|
563
|
+
theme: args.theme,
|
|
564
|
+
},
|
|
520
565
|
// Fold every create-affecting param into the key: two calls that
|
|
521
|
-
// differ only in
|
|
522
|
-
// transport retry of an identical call must reuse it.
|
|
523
|
-
idemKey(
|
|
566
|
+
// differ only in a creative field must NOT dedup to the same draft,
|
|
567
|
+
// and a transport retry of an identical call must reuse it.
|
|
568
|
+
idemKey(
|
|
569
|
+
"make-short",
|
|
570
|
+
args.prompt,
|
|
571
|
+
args.language ?? "",
|
|
572
|
+
args.headline ?? "",
|
|
573
|
+
args.subheadline ?? "",
|
|
574
|
+
args.music_vibe ?? "",
|
|
575
|
+
args.theme ?? "",
|
|
576
|
+
),
|
|
524
577
|
);
|
|
525
578
|
slug = created.data.slug;
|
|
526
579
|
} else {
|
|
@@ -531,6 +584,7 @@ registerTool(
|
|
|
531
584
|
product_prompt: args.prompt,
|
|
532
585
|
export_aspect_ratio: args.aspect,
|
|
533
586
|
voice_id: args.voice_id,
|
|
587
|
+
theme: args.theme,
|
|
534
588
|
},
|
|
535
589
|
idemKey(
|
|
536
590
|
"make-editor",
|
|
@@ -538,6 +592,7 @@ registerTool(
|
|
|
538
592
|
args.language ?? "en",
|
|
539
593
|
args.aspect ?? "",
|
|
540
594
|
args.voice_id ?? "",
|
|
595
|
+
args.theme ?? "",
|
|
541
596
|
),
|
|
542
597
|
);
|
|
543
598
|
slug = created.data.slug;
|
|
@@ -676,7 +731,23 @@ registerTool(
|
|
|
676
731
|
inputSchema: {},
|
|
677
732
|
annotations: { title: "Get credits", ...RO },
|
|
678
733
|
},
|
|
679
|
-
tool(async (_args, client) =>
|
|
734
|
+
tool(async (_args, client) => {
|
|
735
|
+
const res = await client.get<{ data: unknown }>("/studio/credits");
|
|
736
|
+
const d = asRecord(asRecord(res).data);
|
|
737
|
+
// Whitelist generation-relevant fields only. Deliberately DROP
|
|
738
|
+
// `first_editor_video_pack_available`: it's a purchase-funnel flag for the
|
|
739
|
+
// PAID one-time starter pack (33 credits — not free), and this server has
|
|
740
|
+
// no billing scope, so it can't act on it. Passing it through only invited
|
|
741
|
+
// the agent to misread `true` as "your first video may be free", which
|
|
742
|
+
// contradicts the product (the first video is never free).
|
|
743
|
+
return ok({
|
|
744
|
+
credits: d.credits,
|
|
745
|
+
spendable_credits: d.spendable_credits,
|
|
746
|
+
reserved_credits: d.reserved_credits,
|
|
747
|
+
remaining_reserved_credits: d.remaining_reserved_credits,
|
|
748
|
+
message: d.message,
|
|
749
|
+
});
|
|
750
|
+
}),
|
|
680
751
|
);
|
|
681
752
|
|
|
682
753
|
// ── Voices ────────────────────────────────────────────────────────────────
|
|
@@ -769,8 +840,13 @@ registerTool(
|
|
|
769
840
|
{
|
|
770
841
|
title: "Create a short (draft)",
|
|
771
842
|
description:
|
|
772
|
-
"Creates a short draft from a product prompt (min 10 chars).
|
|
773
|
-
"
|
|
843
|
+
"Creates a short draft from a product prompt (min 10 chars). A short is a 12s vertical (two 6s AI " +
|
|
844
|
+
"segments + an on-screen title overlay + music; 14s when an end-card poster is set). Returns the slug, " +
|
|
845
|
+
"costs 0 credits. Follow with generate_short to render. " +
|
|
846
|
+
"BE PROACTIVE: don't ship a bare clip — set a headline (the on-screen TITLE) and subheadline (secondary " +
|
|
847
|
+
"title), and pick a music_vibe and theme that fit the brand. To brand it further, attach a product image " +
|
|
848
|
+
"(set_short_product) or an end-card poster (set_short_poster) before generate_short. (Shorts have no logo " +
|
|
849
|
+
"overlay — that's an editor-only feature; use create_editor_ad for logo branding.)",
|
|
774
850
|
inputSchema: {
|
|
775
851
|
product_prompt: z
|
|
776
852
|
.string()
|
|
@@ -780,21 +856,109 @@ registerTool(
|
|
|
780
856
|
.string()
|
|
781
857
|
.optional()
|
|
782
858
|
.describe('Language code, e.g. "en" (default)'),
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
859
|
+
headline: z
|
|
860
|
+
.string()
|
|
861
|
+
.max(160)
|
|
862
|
+
.optional()
|
|
863
|
+
.describe(
|
|
864
|
+
"The on-screen TITLE composited over the short (poster text overlay). ≤160 chars. Set this.",
|
|
865
|
+
),
|
|
866
|
+
subheadline: z
|
|
867
|
+
.string()
|
|
868
|
+
.max(200)
|
|
869
|
+
.optional()
|
|
870
|
+
.describe(
|
|
871
|
+
"The SECONDARY title / supporting line under the headline. ≤200 chars.",
|
|
872
|
+
),
|
|
873
|
+
theme: z
|
|
874
|
+
.string()
|
|
875
|
+
.optional()
|
|
876
|
+
.describe(
|
|
877
|
+
"Visual theme (default realistic). One of: none (no imposed style — follows the prompt literally), " +
|
|
878
|
+
"realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, " +
|
|
879
|
+
"gaming, retro_80s, minimalist, cyberpunk.",
|
|
880
|
+
),
|
|
881
|
+
music_vibe: z
|
|
882
|
+
.string()
|
|
883
|
+
.optional()
|
|
884
|
+
.describe(
|
|
885
|
+
"Background-music mood. Recognized: Upbeat (default), Cinematic, Minimal, Luxury, Playful, Jazz.",
|
|
886
|
+
),
|
|
887
|
+
music_instruments: z
|
|
888
|
+
.array(z.string())
|
|
889
|
+
.optional()
|
|
890
|
+
.describe('Optional instrument hints, e.g. ["piano", "strings"]'),
|
|
891
|
+
text_position: z
|
|
892
|
+
.enum(["top", "center", "bottom"])
|
|
893
|
+
.optional()
|
|
894
|
+
.describe("Where the title overlay sits (default bottom)"),
|
|
895
|
+
text_animation: z
|
|
896
|
+
.enum(["reveal", "typewriter", "fade_in", "pop", "bounce"])
|
|
897
|
+
.optional()
|
|
898
|
+
.describe("Title overlay animation (default fade_in)"),
|
|
899
|
+
font_family: z
|
|
900
|
+
.string()
|
|
901
|
+
.optional()
|
|
902
|
+
.describe(
|
|
903
|
+
"Overlay font (default ShortFontSpaceGrotesk). One of: ShortFontSpaceGrotesk, ShortFontMontserrat, " +
|
|
904
|
+
"ShortFontTheBold, ShortFontImpact, ShortFontLato, ShortFontProximaNova, ShortFontAnton, " +
|
|
905
|
+
"ShortFontBebasNeue, ShortFontOswald, ShortFontArchivoBlack, ShortFontPoppins, ShortFontInter, " +
|
|
906
|
+
"ShortFontTikTokSans, ShortFontBangers.",
|
|
907
|
+
),
|
|
787
908
|
},
|
|
788
909
|
annotations: { title: "Create short", ...WRITE, idempotentHint: true },
|
|
789
910
|
},
|
|
790
|
-
tool(
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
911
|
+
tool(
|
|
912
|
+
async (
|
|
913
|
+
args: {
|
|
914
|
+
product_prompt: string;
|
|
915
|
+
language?: string;
|
|
916
|
+
headline?: string;
|
|
917
|
+
subheadline?: string;
|
|
918
|
+
theme?: string;
|
|
919
|
+
music_vibe?: string;
|
|
920
|
+
music_instruments?: string[];
|
|
921
|
+
text_position?: string;
|
|
922
|
+
text_animation?: string;
|
|
923
|
+
font_family?: string;
|
|
924
|
+
},
|
|
925
|
+
client,
|
|
926
|
+
) => {
|
|
927
|
+
// Map the agent-friendly field names onto the API's short_* params.
|
|
928
|
+
const body: Record<string, unknown> = {
|
|
929
|
+
product_prompt: args.product_prompt,
|
|
930
|
+
};
|
|
931
|
+
if (args.language !== undefined) body.language = args.language;
|
|
932
|
+
if (args.headline !== undefined) body.headline = args.headline;
|
|
933
|
+
if (args.subheadline !== undefined) body.subheadline = args.subheadline;
|
|
934
|
+
if (args.theme !== undefined) body.theme = args.theme;
|
|
935
|
+
if (args.music_vibe !== undefined) body.music_vibe = args.music_vibe;
|
|
936
|
+
if (args.music_instruments !== undefined)
|
|
937
|
+
body.music_instruments = args.music_instruments;
|
|
938
|
+
if (args.text_position !== undefined)
|
|
939
|
+
body.short_text_position = args.text_position;
|
|
940
|
+
if (args.text_animation !== undefined)
|
|
941
|
+
body.short_text_animation = args.text_animation;
|
|
942
|
+
if (args.font_family !== undefined)
|
|
943
|
+
body.short_font_family = args.font_family;
|
|
944
|
+
const res = await client.post<{ data: { slug: string } }>(
|
|
945
|
+
"/shorts",
|
|
946
|
+
body,
|
|
947
|
+
idemKey(
|
|
948
|
+
"create-short",
|
|
949
|
+
args.product_prompt,
|
|
950
|
+
args.language ?? "",
|
|
951
|
+
args.headline ?? "",
|
|
952
|
+
args.subheadline ?? "",
|
|
953
|
+
),
|
|
954
|
+
);
|
|
955
|
+
return ok({
|
|
956
|
+
slug: res.data.slug,
|
|
957
|
+
kind: "short",
|
|
958
|
+
next: "set_short_product / set_short_poster (optional branding), then generate_short",
|
|
959
|
+
});
|
|
960
|
+
},
|
|
961
|
+
),
|
|
798
962
|
);
|
|
799
963
|
|
|
800
964
|
registerTool(
|
|
@@ -828,7 +992,11 @@ registerTool(
|
|
|
828
992
|
"Creates an editor project from a product prompt and starts autopilot (server-orchestrated " +
|
|
829
993
|
"scenario → segments → narration → voice → music → render). Each AI-generated scene is a fixed 8 seconds. " +
|
|
830
994
|
"Returns the slug. Costs credits. Then poll with get_status / wait_for_completion (kind=editor). Prefer " +
|
|
831
|
-
"make_video for the one-shot path."
|
|
995
|
+
"make_video for the one-shot path. " +
|
|
996
|
+
"BRANDING: to attach the user's own product image, brand logo, or closing card, create the draft with " +
|
|
997
|
+
"create_editor_draft first, call set_product / set_logo / set_closing_image (all 0 credits), then " +
|
|
998
|
+
"start_autopilot — autopilot weaves them in. (Editor ads carry their copy in-scene/narration, not as a " +
|
|
999
|
+
"title overlay; for an on-screen title/subtitle use a short with headline/subheadline instead.)",
|
|
832
1000
|
inputSchema: {
|
|
833
1001
|
product_prompt: z
|
|
834
1002
|
.string()
|
|
@@ -841,7 +1009,9 @@ registerTool(
|
|
|
841
1009
|
theme: z
|
|
842
1010
|
.string()
|
|
843
1011
|
.optional()
|
|
844
|
-
.describe(
|
|
1012
|
+
.describe(
|
|
1013
|
+
'Visual theme (default "realistic"). One of: none (no imposed style — segments follow your prompts literally), realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, minimalist, cyberpunk.',
|
|
1014
|
+
),
|
|
845
1015
|
voice_id: z
|
|
846
1016
|
.string()
|
|
847
1017
|
.optional()
|
|
@@ -986,7 +1156,9 @@ registerTool(
|
|
|
986
1156
|
theme: z
|
|
987
1157
|
.string()
|
|
988
1158
|
.optional()
|
|
989
|
-
.describe(
|
|
1159
|
+
.describe(
|
|
1160
|
+
'Visual theme (default "realistic"). One of: none (no imposed style — segments follow your prompts literally), realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, minimalist, cyberpunk.',
|
|
1161
|
+
),
|
|
990
1162
|
voice_id: z
|
|
991
1163
|
.string()
|
|
992
1164
|
.regex(/^[A-Za-z0-9_-]+$/)
|
|
@@ -2158,6 +2330,89 @@ registerTool(
|
|
|
2158
2330
|
),
|
|
2159
2331
|
);
|
|
2160
2332
|
|
|
2333
|
+
// ── Shorts: branding images (product + end-card poster) ──────────────────────
|
|
2334
|
+
//
|
|
2335
|
+
// The editor branding tools above hit /editor/...; these hit /shorts/... A short
|
|
2336
|
+
// has no logo overlay (editor-only), but it CAN feature a product image woven
|
|
2337
|
+
// into the footage and a closing end-card poster. Both are 0 credits.
|
|
2338
|
+
|
|
2339
|
+
registerTool(
|
|
2340
|
+
"set_short_product",
|
|
2341
|
+
{
|
|
2342
|
+
title: "Attach a product image to a short (0 credits)",
|
|
2343
|
+
description:
|
|
2344
|
+
"Uploads a local product image and attaches it to a SHORT so the product is woven into the footage. " +
|
|
2345
|
+
"Accepted: " +
|
|
2346
|
+
IMAGE_EXTS.map((e) => `.${e}`).join(", ") +
|
|
2347
|
+
" (≤20 MB), local path confined to HUBFLUENCER_INPUT_DIR (or cwd). 0 credits. Optional description " +
|
|
2348
|
+
"(≤500 chars) guides how it's featured. Attach BEFORE generate_short so the product is part of the scenes. " +
|
|
2349
|
+
"(This is the shorts equivalent of set_product for editor projects.)",
|
|
2350
|
+
inputSchema: {
|
|
2351
|
+
slug: z.string().describe("Short slug (from create_short)"),
|
|
2352
|
+
file_path: z
|
|
2353
|
+
.string()
|
|
2354
|
+
.describe("Local product image path (.jpg/.jpeg/.png)"),
|
|
2355
|
+
description: z
|
|
2356
|
+
.string()
|
|
2357
|
+
.max(500)
|
|
2358
|
+
.optional()
|
|
2359
|
+
.describe("Product description (≤500 chars)"),
|
|
2360
|
+
},
|
|
2361
|
+
annotations: {
|
|
2362
|
+
title: "Set short product",
|
|
2363
|
+
...WRITE,
|
|
2364
|
+
idempotentHint: false,
|
|
2365
|
+
},
|
|
2366
|
+
},
|
|
2367
|
+
tool(
|
|
2368
|
+
async (
|
|
2369
|
+
args: { slug: string; file_path: string; description?: string },
|
|
2370
|
+
client,
|
|
2371
|
+
) => {
|
|
2372
|
+
const { s3_key } = await uploadImageFile(
|
|
2373
|
+
client,
|
|
2374
|
+
`/shorts/${args.slug}/product/presign`,
|
|
2375
|
+
args.file_path,
|
|
2376
|
+
);
|
|
2377
|
+
const res = await client.post<{ data: unknown }>(
|
|
2378
|
+
`/shorts/${args.slug}/product/confirm`,
|
|
2379
|
+
{ s3_key, product_description: args.description },
|
|
2380
|
+
);
|
|
2381
|
+
return ok(asRecord(res).data ?? res);
|
|
2382
|
+
},
|
|
2383
|
+
),
|
|
2384
|
+
);
|
|
2385
|
+
|
|
2386
|
+
registerTool(
|
|
2387
|
+
"set_short_poster",
|
|
2388
|
+
{
|
|
2389
|
+
title: "Set a short's end-card poster image (0 credits)",
|
|
2390
|
+
description:
|
|
2391
|
+
"Uploads a local image as the SHORT's end-card poster — a closing still shown at the end (extends the " +
|
|
2392
|
+
"render from 12s to 14s). Accepted: " +
|
|
2393
|
+
IMAGE_EXTS.map((e) => `.${e}`).join(", ") +
|
|
2394
|
+
" (≤20 MB), confined to HUBFLUENCER_INPUT_DIR/cwd. 0 credits. There is one poster per short; calling " +
|
|
2395
|
+
"again replaces it. (Shorts have no logo overlay — for a brand logo use an editor project + set_logo.)",
|
|
2396
|
+
inputSchema: {
|
|
2397
|
+
slug: z.string().describe("Short slug (from create_short)"),
|
|
2398
|
+
file_path: z.string().describe("Local poster image (.jpg/.jpeg/.png)"),
|
|
2399
|
+
},
|
|
2400
|
+
annotations: { title: "Set short poster", ...WRITE, idempotentHint: false },
|
|
2401
|
+
},
|
|
2402
|
+
tool(async (args: { slug: string; file_path: string }, client) => {
|
|
2403
|
+
const { s3_key } = await uploadShortPoster(
|
|
2404
|
+
client,
|
|
2405
|
+
args.slug,
|
|
2406
|
+
args.file_path,
|
|
2407
|
+
);
|
|
2408
|
+
const res = await client.post<{ data: unknown }>(
|
|
2409
|
+
`/shorts/${args.slug}/poster/confirm`,
|
|
2410
|
+
{ s3_key },
|
|
2411
|
+
);
|
|
2412
|
+
return ok(asRecord(res).data ?? res);
|
|
2413
|
+
}),
|
|
2414
|
+
);
|
|
2415
|
+
|
|
2161
2416
|
// ── Status / waiting ──────────────────────────────────────────────────────────
|
|
2162
2417
|
|
|
2163
2418
|
registerTool(
|
|
@@ -2298,6 +2553,13 @@ registerTool(
|
|
|
2298
2553
|
// ── Boot ──────────────────────────────────────────────────────────────────────
|
|
2299
2554
|
|
|
2300
2555
|
async function main() {
|
|
2556
|
+
// `hubfluencer-mcp login [name]` runs the device-link login instead of the
|
|
2557
|
+
// server. Single bin keeps the install idiomatic: `npx -y @hubfluencer/mcp`
|
|
2558
|
+
// starts the server; `npx -y @hubfluencer/mcp login` connects an account.
|
|
2559
|
+
if (process.argv[2] === "login") {
|
|
2560
|
+
await runLogin(process.argv[3]);
|
|
2561
|
+
return;
|
|
2562
|
+
}
|
|
2301
2563
|
const transport = new StdioServerTransport();
|
|
2302
2564
|
await server.connect(transport);
|
|
2303
2565
|
// stderr is safe; stdout is reserved for the MCP protocol.
|
package/src/login.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
1
|
/**
|
|
3
|
-
*
|
|
2
|
+
* Device-link login for the Hubfluencer MCP.
|
|
3
|
+
*
|
|
4
|
+
* Exposed as the `login` subcommand of the single `hubfluencer-mcp` bin:
|
|
5
|
+
*
|
|
6
|
+
* npx -y @hubfluencer/mcp login ["Client Name"]
|
|
4
7
|
*
|
|
5
8
|
* Connects this agent to a Hubfluencer account without copy-pasting a token:
|
|
6
9
|
* starts a link request, prints a URL + code for the user to approve in the
|
|
@@ -14,17 +17,21 @@ import { dirname } from "node:path";
|
|
|
14
17
|
import { assertSafeBaseUrl } from "./client.js";
|
|
15
18
|
import { CREDENTIALS_PATH } from "./credentials.js";
|
|
16
19
|
|
|
17
|
-
const BASE = (process.env.HUBFLUENCER_BASE_URL || "https://hubfluencer.com")
|
|
18
|
-
.replace(/\/+$/, "")
|
|
19
|
-
.replace(/\/api$/, "");
|
|
20
|
-
// Fail before printing a code / polling if the base would leak the token.
|
|
21
|
-
assertSafeBaseUrl(BASE);
|
|
22
20
|
const MAX_POLLS = 120;
|
|
23
21
|
|
|
24
22
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
25
23
|
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Runs the device-link login flow. Resolves once the token is stored; calls
|
|
26
|
+
* `process.exit(1)` on a terminal failure (it's a CLI entry path). Invoked from
|
|
27
|
+
* index.ts when the bin is run as `hubfluencer-mcp login [name]`.
|
|
28
|
+
*/
|
|
29
|
+
export async function runLogin(clientName = "Claude Code"): Promise<void> {
|
|
30
|
+
const BASE = (process.env.HUBFLUENCER_BASE_URL || "https://hubfluencer.com")
|
|
31
|
+
.replace(/\/+$/, "")
|
|
32
|
+
.replace(/\/api$/, "");
|
|
33
|
+
// Fail before printing a code / polling if the base would leak the token.
|
|
34
|
+
assertSafeBaseUrl(BASE);
|
|
28
35
|
|
|
29
36
|
const startRes = await fetch(`${BASE}/api/agent-link/start`, {
|
|
30
37
|
method: "POST",
|
|
@@ -77,7 +84,7 @@ async function main() {
|
|
|
77
84
|
};
|
|
78
85
|
|
|
79
86
|
if (pollRes.ok && body.status === "approved" && body.token) {
|
|
80
|
-
await storeToken(body.token);
|
|
87
|
+
await storeToken(body.token, BASE);
|
|
81
88
|
console.error(
|
|
82
89
|
`\n✓ Connected. Access token saved to ${CREDENTIALS_PATH}.`,
|
|
83
90
|
);
|
|
@@ -101,12 +108,12 @@ async function main() {
|
|
|
101
108
|
process.exit(1);
|
|
102
109
|
}
|
|
103
110
|
|
|
104
|
-
async function storeToken(token: string) {
|
|
111
|
+
async function storeToken(token: string, base: string) {
|
|
105
112
|
const dir = dirname(CREDENTIALS_PATH);
|
|
106
113
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
107
114
|
await writeFile(
|
|
108
115
|
CREDENTIALS_PATH,
|
|
109
|
-
JSON.stringify({ token, base_url:
|
|
116
|
+
JSON.stringify({ token, base_url: base }, null, 2),
|
|
110
117
|
{
|
|
111
118
|
mode: 0o600,
|
|
112
119
|
},
|
|
@@ -122,8 +129,3 @@ async function storeToken(token: string) {
|
|
|
122
129
|
// chmod is hardening — never fail the login over it
|
|
123
130
|
}
|
|
124
131
|
}
|
|
125
|
-
|
|
126
|
-
main().catch((e) => {
|
|
127
|
-
console.error("Fatal:", e instanceof Error ? e.message : String(e));
|
|
128
|
-
process.exit(1);
|
|
129
|
-
});
|
package/src/uploads.ts
CHANGED
|
@@ -365,5 +365,30 @@ export async function uploadImageFile(
|
|
|
365
365
|
return { s3_key };
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Uploads a local image as a short's end-card POSTER. The short poster presign
|
|
370
|
+
* is older than the editor asset presign and uses a different envelope: it takes
|
|
371
|
+
* `{ content_type }` and returns a bare `{ upload_url, s3_key }` (no `data`
|
|
372
|
+
* wrapper, `upload_url` not `presigned_url`, and a single fixed key per short).
|
|
373
|
+
* Kept separate from `uploadImageFile` (which speaks the editor `{data:{...}}`
|
|
374
|
+
* shape) so neither has to branch on the surface. Returns the `s3_key` to thread
|
|
375
|
+
* into `/shorts/:slug/poster/confirm`.
|
|
376
|
+
*/
|
|
377
|
+
export async function uploadShortPoster(
|
|
378
|
+
client: HubfluencerClient,
|
|
379
|
+
slug: string,
|
|
380
|
+
filePath: string,
|
|
381
|
+
): Promise<{ s3_key: string }> {
|
|
382
|
+
const file = await resolveReadPath(filePath, IMAGE_EXT_MIME, MAX_IMAGE_BYTES);
|
|
383
|
+
const presign = await client.post<{ upload_url: string; s3_key: string }>(
|
|
384
|
+
`/shorts/${slug}/poster/presign`,
|
|
385
|
+
{ content_type: file.mime },
|
|
386
|
+
);
|
|
387
|
+
const { upload_url, s3_key } = presign;
|
|
388
|
+
const buf = await readFile(file.path);
|
|
389
|
+
await putToPresignedUrl(upload_url, buf, file.mime);
|
|
390
|
+
return { s3_key };
|
|
391
|
+
}
|
|
392
|
+
|
|
368
393
|
export const LOGO_MAX_BYTES = MAX_LOGO_BYTES;
|
|
369
394
|
export const IMAGE_MAX_BYTES = MAX_IMAGE_BYTES;
|