SLOPSHOPPER

higgsfield-create

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

newpanecommandprocess
★ 5v0.1.0no licenseupdated 2026-10-06mverab/recursos-ia/recursos/claude-mods/higgsfield-create
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · higgsfield-create
│ ┃ Higgsfield ✕ › fix the failing auth test and add an audit log call │ ┃ Higgsfield · CLI oficial │ ┃ Saldo no leído ⏺ Read(src/auth.ts) │ ┃ Reconstrucción original; adaptación del MCP ⎿ Read 6 lines │ ┃ a CLI. Ningún gasto al abrir. ⏺ Update(src/auth.ts) │ ┃ Respuesta CLI no es JSON válido ⎿ Added 2 lines, removed 1 line │ ┃ Medio: Photo ▾ ⏺ Bash(bun test) │ ┃ Consultar job existente (UUID, no genera): ⎿ 3 pass, 1 fail │ ┃ [ Cancelar cotización (no cancela un job env │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /create │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Higgsfield
Higgsfield · CLI oficial Saldo no leído Reconstrucción original; adaptación del MCP a CLI. Ningún gasto al abrir. Respuesta CLI no es JSON válido Medio: Photo ▾ Consultar job existente (UUID, no genera): [ Cancelar cotización (no cancela un job enviado) ]
README

Higgsfield Create — mod de Claude Code

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.

Requisitos

  • Claude Code 2.1.287 o superior (verificado en 2.1.289).
  • CLI de Higgsfield instalado y autenticado (higgsfield auth login).
  • Créditos Higgsfield. La cotización aparece antes de cada generación y cambiar cualquier parámetro la invalida: confirmas siempre el precio vigente.

Instalación

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.

Uso

  1. /create — abre el panel.
  2. Elige Photo o Video, modelo (los que tu CLI tenga disponibles), proporción y resolución.
  3. Escribe el prompt; es editable hasta el último momento.
  4. Revisa la cotización en créditos y confirma explícitamente, o cancela sin gasto.
  5. El resultado se descarga a outputs/ del plugin sin sobrescribir archivos anteriores. También puedes consultar jobs anteriores y guardar sus resultados.

Límites honestos

  • Funciona en la terminal de Claude Code; no incluye superficie Desktop/móvil.
  • No sube imágenes de referencia ni mejora prompts con otro modelo en esta versión.
  • La cotización es una estimación del CLI, no un precio reservado en servidor.
  • Sin tu confirmación en el botón de precio, no hay gasto: está probado que un botón viejo no puede autorizar una cotización nueva.

Verificación

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.

Licencia

MIT. Reconstrucción original para la comunidad; el backend es el CLI oficial de Higgsfield.

Source 2 files
hooks/register.tsx 375 lines
1import 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};
375
hooks/core.ts 201 lines
1const 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