Reconstrucción original /create nativa. Backend CLI oficial, no MCP original.

Crea imágenes y video con Higgsfield sin salir de Claude Code. Abre un panel nativo con /create, eliges Photo o Video, modelo, proporción y resolución, escribes o editas el prompt, ves la cotización real en créditos y nada se gasta hasta que confirmas el precio en pantalla.
higgsfield auth login).Desde tu proyecto:
claude --plugin-dir /ruta/a/higgsfield-create
Para dejarlo permanente: /plugin dentro de Claude Code y agrégalo como plugin local. Si el typecheck lo pide, genera los tipos de tu versión con /plugin-types dentro de una sesión.
/create — abre el panel.outputs/ del plugin sin sobrescribir archivos anteriores. También puedes consultar jobs anteriores y guardar sus resultados.19 pruebas automatizadas (comportamiento, seguridad de descarga y engine nativo), validación estricta del plugin y sesión TUI real con cuenta autenticada: panel, saldo, cotización, cancelación y descarga sin sobrescritura. La generación pagada de extremo a extremo se ejercita con tu primera creación confirmada.
MIT. Reconstrucción original para la comunidad; el backend es el CLI oficial de Higgsfield.
hooks/register.tsx 375 lines1import type { Register, EngineInterface } from "claude-code";
2import {
3 Studio,
4 cli,
5 jobIds,
6 jobView,
7 type Schema,
8 type Runner,
9} from "./core.ts";
10type Model = { job_type: string; display_name: string; type: string };
11const runner =
12 ($: EngineInterface): Runner =>
13 (argv) =>
14 $.process.run(argv, { timeoutMs: 60000 });
15const redraw = ($: EngineInterface) => $.ui.invalidate("ui.render");
16async function act(
17 $: EngineInterface,
18 state: { busy: boolean; error: string },
19 fn: () => Promise<unknown>,
20) {
21 if (state.busy) return;
22 state.busy = true;
23 state.error = "";
24 redraw($);
25 try {
26 await fn();
27 } catch (e) {
28 state.error = e instanceof Error ? e.message : "Operación rechazada";
29 } finally {
30 state.busy = false;
31 redraw($);
32 }
33}
34async function loadModel(
35 $: EngineInterface,
36 models: Model[],
37 kind: string,
38 studio: Studio,
39 id: string,
40) {
41 studio.unselect();
42 redraw($);
43 if (!models.some((m) => m.job_type === id && m.type === kind))
44 throw new Error("Modelo no disponible");
45 const schema: Schema = await cli(runner($), [
46 "higgsfield",
47 "model",
48 "get",
49 id,
50 "--json",
51 ]);
52 if (schema.job_type !== id || schema.type !== kind)
53 throw new Error("Schema no coincide");
54 studio.setSchema(schema);
55}
56export const register: Register = (on) => {
57 const studio = new Studio();
58 let models: Model[] = [];
59 let kind = "image";
60 let balance = "Saldo no leído";
61 const state = { error: "", busy: false };
62 let jobs: ReturnType<typeof jobView>[] = [];
63 let saved: string[] = [];
64 on("session.start", async ($, e, next) => {
65 await $.command.register({
66 name: "create",
67 description: "Studio Higgsfield · Photo/Video · quote antes de gastar",
68 });
69 return next(e);
70 });
71 on("command.run", { command: "create" }, async ($) => {
72 studio.cancel();
73 await $.ui.open({
74 id: "higgsfield-create",
75 title: "Higgsfield",
76 focus: true,
77 closeOnEscape: true,
78 rows: 28,
79 });
80 await act($, state, async () => {
81 const account = await cli(runner($), [
82 "higgsfield",
83 "account",
84 "status",
85 "--json",
86 ]);
87 if (typeof account.credits !== "number")
88 throw new Error("Saldo no válido");
89 balance = `${account.credits} créditos · ${account.subscription_plan_type ?? "plan desconocido"}`;
90 const list = await cli(runner($), [
91 "higgsfield",
92 "model",
93 "list",
94 "--json",
95 ]);
96 if (!Array.isArray(list)) throw new Error("Catálogo no válido");
97 models = list.filter(
98 (m: Model) =>
99 ["image", "video"].includes(m.type) &&
100 /^[a-z][a-z0-9_]*$/.test(m.job_type),
101 );
102 const id =
103 models.find(
104 (m) =>
105 m.job_type ===
106 (kind === "image" ? "nano_banana_pro" : "seedance_2_5"),
107 )?.job_type ?? models.find((m) => m.type === kind)?.job_type;
108 if (!id) throw new Error("Sin modelos disponibles");
109 await loadModel($, models, kind, studio, id);
110 });
111 return {};
112 });
113 on("ui.close", ($, e, next) => {
114 if (e.id === "higgsfield-create") studio.cancel();
115 return next(e);
116 });
117 on("ui.render", { component: "Pane" }, ($, e, next) => {
118 if (e.requestId !== "higgsfield-create" || e.surface !== "terminal")
119 return next(e);
120 const { Box, Text, Button, Input, Select } = $.ui.resolve(e);
121 const quoted = studio.quoted;
122 const edit = (prompt: string) => {
123 studio.edit({ prompt });
124 redraw($);
125 };
126 return (
127 <Box flexDirection="column">
128 <Text key="studio-title">Higgsfield · CLI oficial</Text>
129 <Text>{balance}</Text>
130 <Text dimColor>
131 Reconstrucción original; adaptación del MCP a CLI. Ningún gasto al
132 abrir.
133 </Text>
134 {state.error && <Text color="red">{state.error}</Text>}
135 {state.busy && <Text>Consultando…</Text>}
136 <Select
137 key="kind"
138 label="Medio"
139 value={kind}
140 options={[
141 { value: "image", label: "Photo" },
142 { value: "video", label: "Video" },
143 ]}
144 onSelect={(value) => {
145 if (state.busy) {
146 redraw($);
147 return;
148 }
149 studio.cancel();
150 kind = value;
151 redraw($);
152 return act($, state, async () => {
153 const id =
154 models.find(
155 (m) =>
156 m.job_type ===
157 (kind === "image" ? "nano_banana_pro" : "seedance_2_5"),
158 )?.job_type ?? models.find((m) => m.type === kind)?.job_type;
159 if (!id) throw new Error("Sin modelos");
160 await loadModel($, models, kind, studio, id);
161 });
162 }}
163 />
164 {models.some((m) => m.type === kind) && (
165 <Select
166 key="model"
167 label="Modelo"
168 value={studio.request.model}
169 options={models
170 .filter((m) => m.type === kind)
171 .map((m) => ({ value: m.job_type, label: m.display_name }))}
172 onSelect={(id) => {
173 if (state.busy) {
174 redraw($);
175 return;
176 }
177 studio.cancel();
178 return act($, state, () =>
179 loadModel($, models, kind, studio, id),
180 );
181 }}
182 />
183 )}
184 {studio.schema && (
185 <Input
186 key="prompt"
187 label="Prompt editable"
188 value={String(studio.request.params.prompt ?? "")}
189 onInput={edit}
190 onSubmit={edit}
191 />
192 )}
193 {studio.schema?.params
194 .filter(
195 (p) =>
196 p.enum &&
197 p.enum.length &&
198 ["string", "integer", "number", "boolean"].includes(p.type),
199 )
200 .map((p) => (
201 <Select
202 key={p.name}
203 label={p.name}
204 value={String(
205 studio.request.params[p.name] ?? p.default ?? p.enum?.[0],
206 )}
207 options={p.enum!.map((v) => ({
208 value: String(v),
209 label: String(v),
210 }))}
211 onSelect={(value) => {
212 const val =
213 p.type === "integer" || p.type === "number"
214 ? Number(value)
215 : p.type === "boolean"
216 ? value === "true"
217 : value;
218 studio.edit({ params: { [p.name]: val } });
219 redraw($);
220 }}
221 />
222 ))}
223 {studio.schema?.params
224 .filter(
225 (p) =>
226 !p.enum &&
227 p.name !== "prompt" &&
228 ["integer", "number", "boolean"].includes(p.type),
229 )
230 .map((p) => (
231 <Input
232 key={p.name}
233 label={p.name}
234 value={String(studio.request.params[p.name] ?? "")}
235 onInput={() => {
236 studio.cancel();
237 redraw($);
238 }}
239 onSubmit={(value) => {
240 studio.edit({
241 params: {
242 [p.name]:
243 p.type === "boolean" ? value === "true" : Number(value),
244 },
245 });
246 redraw($);
247 }}
248 />
249 ))}
250 {studio.schema && (
251 <Button
252 key="quote"
253 label="Cotizar sin generar"
254 onPress={() =>
255 act($, state, async () => {
256 await studio.quote(runner($), $.clock.now());
257 })
258 }
259 />
260 )}
261 {studio.quoted && (
262 <Text>{`Cotización: ${studio.quoted.credits} créditos · caduca en 60 s`}</Text>
263 )}
264 {quoted && (
265 <Button
266 key="confirm"
267 label={`Sí, GENERAR y gastar ${quoted.credits} créditos`}
268 onPress={() =>
269 act($, state, async () => {
270 const model = studio.request.model;
271 const ids = jobIds(
272 await studio.confirm(
273 runner($),
274 quoted.id,
275 "GENERAR",
276 await $.clock.now(),
277 ),
278 );
279 jobs = ids.map((id) => ({ id, status: "submitted", model }));
280 })
281 }
282 />
283 )}
284 <Input
285 key="job-id"
286 label="Consultar job existente (UUID, no genera)"
287 onSubmit={(id) =>
288 act($, state, async () => {
289 jobIds([id]);
290 const j = jobView(
291 await cli(runner($), [
292 "higgsfield",
293 "generate",
294 "get",
295 id,
296 "--json",
297 ]),
298 );
299 if (j.id !== id) throw new Error("Job no coincide");
300 jobs = [j];
301 })
302 }
303 />
304 {jobs.map((job) => (
305 <Box key={job.id} flexDirection="column">
306 <Text>{`${job.id} · ${job.status} · ${job.model ?? "modelo desconocido"}`}</Text>
307 {job.model &&
308 job.model !== studio.request.model &&
309 studio.request.model && (
310 <Text>Modelo del job distinto al seleccionado.</Text>
311 )}
312 <Button
313 key={"refresh:" + job.id}
314 label="Actualizar estado"
315 onPress={() =>
316 act($, state, async () => {
317 const j = jobView(
318 await cli(runner($), [
319 "higgsfield",
320 "generate",
321 "get",
322 job.id,
323 "--json",
324 ]),
325 );
326 if (j.id !== job.id) throw new Error("Job no coincide");
327 jobs = jobs.map((v) => (v.id === j.id ? j : v));
328 })
329 }
330 />
331 {job.url && <Text>{job.url}</Text>}
332 {job.url && (
333 <Button
334 key={"save:" + job.id}
335 label="Guardar en outputs/ (sin sobrescribir)"
336 onPress={() =>
337 act($, state, async () => {
338 const result = await cli(runner($), [
339 "python3",
340 `${$.plugin.root}/hooks/save.py`,
341 job.url!,
342 "higgsfield",
343 ]);
344 const expected = `${$.plugin.root}/outputs/`;
345 if (
346 typeof result.file !== "string" ||
347 !result.file.startsWith(expected) ||
348 !/^higgsfield_[0-9]+\.(png|jpg|webp|mp4)$/.test(
349 result.file.slice(expected.length),
350 )
351 )
352 throw new Error("Ruta guardada no permitida");
353 saved.push(new URL("file://" + result.file).href);
354 })
355 }
356 />
357 )}
358 </Box>
359 ))}
360 {saved.map((url) => (
361 <Text>{url}</Text>
362 ))}
363 <Button
364 key="cancel"
365 label="Cancelar cotización (no cancela un job enviado)"
366 onPress={() => {
367 studio.cancel();
368 redraw($);
369 }}
370 />
371 </Box>
372 );
373 });
374};
375hooks/core.ts 201 lines1const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
2export function jobIds(data: unknown): string[] {
3 if (
4 !Array.isArray(data) ||
5 data.length === 0 ||
6 data.some((id) => typeof id !== "string" || !UUID.test(id))
7 )
8 throw new Error(
9 "Respuesta de jobs no reconocida; no reintentar automáticamente",
10 );
11 return data;
12}
13export function jobView(data: unknown): {
14 id: string;
15 status: string;
16 model?: string;
17 url?: string;
18} {
19 if (!data || typeof data !== "object") throw new Error("Job inválido");
20 const d = data as Record<string, unknown>;
21 if (
22 typeof d.id !== "string" ||
23 !UUID.test(d.id) ||
24 typeof d.status !== "string"
25 )
26 throw new Error("Job inválido");
27 const view: { id: string; status: string; model?: string; url?: string } = {
28 id: d.id,
29 status: d.status,
30 };
31 if (typeof d.job_type === "string") view.model = d.job_type;
32 if (d.result_url) {
33 if (typeof d.result_url !== "string" || /[\x00-\x20]/.test(d.result_url))
34 throw new Error("URL no segura");
35 const u = new URL(d.result_url);
36 if (
37 u.protocol !== "https:" ||
38 ![
39 "d8j0ntlcm91z4.cloudfront.net",
40 "d2ol7oe51mr4n9.cloudfront.net",
41 ].includes(u.host) ||
42 u.username ||
43 u.password ||
44 u.hash
45 )
46 throw new Error("URL fuera del CDN allowlisted");
47 view.url = d.result_url;
48 }
49 return view;
50}
51export type Param = {
52 name: string;
53 type: string;
54 default: unknown;
55 required: boolean;
56 enum?: (string | number | boolean)[];
57};
58export type Schema = {
59 job_type: string;
60 display_name: string;
61 type: string;
62 params: Param[];
63 rules?: { cel: string; message: string }[];
64};
65export type Request = {
66 model: string;
67 params: Record<string, string | number | boolean>;
68};
69export type Runner = (
70 argv: readonly string[],
71) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
72export type Quote = {
73 id: number;
74 credits: number;
75 expires: number;
76 argv: string[];
77};
78export async function cli(run: Runner, argv: readonly string[]): Promise<any> {
79 const result = await run(argv);
80 if (result.exitCode !== 0)
81 throw new Error(
82 "Higgsfield rechazó la operación; revisa autenticación/permisos/schema en CLI.",
83 );
84 try {
85 return JSON.parse(result.stdout);
86 } catch {
87 throw new Error("Respuesta CLI no es JSON válido");
88 }
89}
90export class Studio {
91 quoted?: Quote;
92 private serial = 0;
93 private revision = 0;
94 cancel() {
95 this.revision++;
96 this.quoted = undefined;
97 }
98 schema?: Schema;
99 unselect() {
100 this.cancel();
101 this.schema = undefined;
102 this.request = {
103 model: "",
104 params: { prompt: this.request.params.prompt ?? "" },
105 };
106 }
107 async quote(run: Runner, now: number | Promise<number>): Promise<Quote> {
108 this.cancel();
109 const revision = this.revision;
110 if (
111 !this.schema ||
112 !this.request.model ||
113 typeof this.request.params.prompt !== "string" ||
114 !this.request.params.prompt.trim()
115 )
116 throw new Error("Escribe un prompt antes de cotizar");
117 const argv = ["higgsfield", "generate", "cost", this.request.model];
118 for (const [k, v] of Object.entries(this.request.params))
119 argv.push("--" + k, String(v));
120 argv.push("--json");
121 const timestamp = await now;
122 const data = await cli(run, argv);
123 if (this.revision !== revision)
124 throw new Error("Quote descartado: parámetros cambiados o cancelación");
125 if (
126 typeof data.credits !== "number" ||
127 !Number.isFinite(data.credits) ||
128 data.credits < 0
129 )
130 throw new Error("Quote inválido");
131 return (this.quoted = {
132 id: ++this.serial,
133 credits: data.credits,
134 expires: timestamp + 60000,
135 argv,
136 });
137 }
138 async confirm(
139 run: Runner,
140 id: number,
141 answer: string,
142 now: number,
143 ): Promise<any> {
144 const q = this.quoted;
145 if (answer !== "GENERAR" || !q || q.id !== id || now > q.expires)
146 throw new Error("Confirmación explícita y quote vigente requeridos");
147 this.quoted = undefined;
148 const argv = [...q.argv];
149 argv[2] = "create";
150 return cli(run, argv);
151 }
152 request: Request = { model: "", params: {} };
153 setSchema(schema: Schema) {
154 this.cancel();
155 if (
156 !/^[a-z][a-z0-9_]*$/.test(schema.job_type) ||
157 !["image", "video"].includes(schema.type)
158 )
159 throw new Error("Modelo inválido");
160 const params: Request["params"] = {};
161 for (const p of schema.params) {
162 if (!/^[a-z][a-z0-9_]*$/.test(p.name))
163 throw new Error("Nombre de parámetro inválido");
164 if (
165 ["string", "integer", "number", "boolean"].includes(p.type) &&
166 p.default !== null &&
167 p.default !== undefined
168 )
169 params[p.name] = p.default as string | number | boolean;
170 }
171 params.prompt = this.request.params.prompt ?? "";
172 this.schema = schema;
173 this.request = { model: schema.job_type, params };
174 }
175 edit(change: { prompt?: string; params?: Request["params"] }) {
176 this.cancel();
177 const params = {
178 ...this.request.params,
179 ...change.params,
180 ...(change.prompt === undefined ? {} : { prompt: change.prompt }),
181 };
182 for (const [key, value] of Object.entries(params)) {
183 const p = this.schema?.params.find((p) => p.name === key);
184 if (!p || !/^[a-z][a-z0-9_]*$/.test(key))
185 throw new Error("Parámetro desconocido");
186 if (p.enum && !p.enum.includes(value))
187 throw new Error("Valor fuera de schema");
188 if (
189 (p.type === "string" && typeof value !== "string") ||
190 (p.type === "boolean" && typeof value !== "boolean") ||
191 (["integer", "number"].includes(p.type) &&
192 (typeof value !== "number" || !Number.isFinite(value)))
193 )
194 throw new Error("Tipo inválido");
195 if (p.type === "integer" && !Number.isInteger(value))
196 throw new Error("Entero requerido");
197 }
198 this.request = { model: this.request.model, params };
199 }
200}
201