diff --git a/README.md b/README.md index 3786af4..fbf3dd5 100644 --- a/README.md +++ b/README.md @@ -89,11 +89,12 @@ npx pixelvault-cli upload build-output.png | `register` | Create a new account | | `login` | Log in to existing account | | `upload ` | Upload images (prints URLs to stdout) | +| `generate ` | Generate an image from a prompt and host it (prints the URL; spends monthly AI credits) | | `list` | List uploaded images | | `get ` | Get an image's URL/metadata, or download it (optionally transformed) | | `delete ` | Delete an image | | `export` | Export all project images as a tar archive | -| `usage` | Show plan, period, and storage/upload/bandwidth usage and limits | +| `usage` | Show plan, period, storage/upload/bandwidth usage and limits, and AI generation credits | | `keys list\|create\|revoke` | List, create, and revoke the project's secret API keys | | `upgrade ` | Print a Stripe Checkout URL to subscribe (live key; you pay in the browser) | | `billing` | Print a Stripe billing-portal URL to change plan, update payment details or cancel (live key) | @@ -108,6 +109,21 @@ pixelvault upload *.png --folder icons # Bulk with folder pixelvault upload shot.png --json # Full JSON response ``` +### Generate Options + +```bash +pixelvault generate "flat vector hero, a robot at a laptop, no text" # prints the hosted URL +pixelvault generate a red bicycle --optimized # unquoted words work; print the WebP/AVIF URL +pixelvault generate "an og card" --model lucid-origin # Leonardo model (20 credits, paid plans) +pixelvault generate "a lake" --delete-after 1h # auto-delete the image +pixelvault generate "a lake" --private --expires 12h # signed link +``` + +Spends credits from your monthly grant: free 10 (`flux-1-schnell` only), Starter 300, Pro 1,500. `flux-1-schnell` +and the SDXL models cost 1 credit, the Leonardo models (`lucid-origin`, `phoenix-1.0`) cost 20. A failed +generation is refunded. stdout is just the URL (or `--json` for the full response); the credits used and left go to +stderr. Needs a secret key and a verified email. `pixelvault usage` shows your credits. + ### Get Options ```bash diff --git a/package-lock.json b/package-lock.json index 8a09ae8..e4ade4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pixelvault", - "version": "0.9.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pixelvault", - "version": "0.9.0", + "version": "0.10.0", "license": "MIT", "dependencies": { "citty": "^0.1.6" diff --git a/package.json b/package.json index 62e1914..d0f0034 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pixelvault-cli", - "version": "0.9.0", + "version": "0.10.0", "description": "CLI for PixelVault — agent-first image hosting", "type": "module", "bin": { diff --git a/src/commands/generate.ts b/src/commands/generate.ts new file mode 100644 index 0000000..0b7412a --- /dev/null +++ b/src/commands/generate.ts @@ -0,0 +1,144 @@ +import { defineCommand } from "citty"; +import { apiRequest } from "../lib/client.js"; +import { requireApiKey } from "../lib/config.js"; +import { CliError } from "../lib/errors.js"; +import { parseExpires } from "../lib/duration.js"; +import { stdout, stderr, jsonOut } from "../lib/output.js"; + +interface GenerateResponse { + data: { + id: string; + url: string; + optimized_url?: string; + visibility?: "public" | "private"; + mime_type: string; + size: number; + width?: number; + height?: number; + filename: string; + folder: string | null; + created_at: string; + expires_in?: number; + signed_expires?: number; + generation: { + model: string; + credits_charged: number; + credits_remaining: number; + }; + }; +} + +export default defineCommand({ + meta: { + name: "generate", + description: + "Generate an image from a prompt and host it (spends monthly AI credits; secret key required)", + }, + args: { + prompt: { + type: "positional", + description: "What to draw. Quote it, or pass several words", + required: true, + }, + model: { + type: "string", + alias: "m", + description: + "flux-1-schnell (default, 1 credit), sdxl-lightning or sdxl-base (1, paid plans), lucid-origin or phoenix-1.0 (20, paid plans)", + }, + folder: { + type: "string", + description: "Folder to organize the image in", + }, + private: { + type: "boolean", + description: + "Keep it private: returns a signed URL only holders of the link can open", + default: false, + }, + expires: { + type: "string", + description: + "Lifetime of a --private signed link: e.g. 30m, 12h, 7d (default 7d; min 60s, max 30d)", + }, + "delete-after": { + type: "string", + description: + "Delete the image automatically after this long: e.g. 1h, 7d (min 60s, max 30d)", + }, + optimized: { + type: "boolean", + description: + "Print the optimized URL (WebP/AVIF, usually much smaller) instead of the original; falls back to the original URL when none is returned", + default: false, + }, + json: { + type: "boolean", + description: "Output the full JSON response", + default: false, + }, + }, + async run({ args }) { + const apiKey = requireApiKey(); + + // Every positional (including the first) lands in `args._`, so an unquoted + // multi-word prompt still works. + const prompt = (args._.length ? args._.map(String) : [String(args.prompt ?? "")]) + .join(" ") + .trim(); + if (!prompt) { + throw new CliError('Give a prompt, e.g. pixelvault generate "a red bicycle".'); + } + + // Resolve durations up front so a bad value fails before any credit is spent. + if (args.expires !== undefined && !args.private) { + throw new CliError( + "--expires only applies to --private images (it sets the signed link's lifetime). Use --delete-after to expire the image itself." + ); + } + const signExpires = + args.expires !== undefined ? parseExpires(String(args.expires)) : undefined; + const deleteAfter = + args["delete-after"] !== undefined + ? parseExpires(String(args["delete-after"]), "--delete-after") + : undefined; + + if (args.private && args.folder) { + stderr("Note: --folder is ignored for --private images."); + } + + const res = await apiRequest({ + method: "POST", + path: "/v1/images/generate", + auth: apiKey, + body: { + prompt, + ...(args.model ? { model: String(args.model) } : {}), + ...(args.folder ? { folder: String(args.folder) } : {}), + ...(args.private ? { visibility: "private" } : {}), + ...(signExpires !== undefined ? { sign_expires_in: signExpires } : {}), + ...(deleteAfter !== undefined ? { expires_in: deleteAfter } : {}), + }, + }); + + // Fail closed: if we asked for private but the server didn't confirm it, + // don't hand back a URL the user would wrongly trust as private. + if (args.private && res.data.visibility !== "private") { + throw new CliError( + "Refusing to report the image: the server did not confirm it is private." + ); + } + + const g = res.data.generation; + // stderr only: stdout stays the bare URL so scripts and agents can capture it. + stderr( + `Generated with ${g.model}: ${g.credits_charged} credit${g.credits_charged === 1 ? "" : "s"} used, ${g.credits_remaining} left this month.` + ); + + if (args.json) { + jsonOut(res.data); + } else { + stdout(args.optimized ? (res.data.optimized_url ?? res.data.url) : res.data.url); + } + }, +}); diff --git a/src/commands/usage.ts b/src/commands/usage.ts index dbeaff1..480eb52 100644 --- a/src/commands/usage.ts +++ b/src/commands/usage.ts @@ -14,6 +14,12 @@ export interface UsageData { bandwidth_bytes_per_month: number | null; }; remaining: { storage_bytes: number | null; uploads: number | null }; + /** AI image-generation credits (`pixelvault generate`) for the month. */ + generation?: { + credits_used: number; + credits_limit: number; + credits_remaining: number; + }; } const bytes = (n: number | null | undefined) => @@ -28,6 +34,11 @@ export function renderUsage(d: UsageData): string[] { `Storage: ${formatBytes(d.usage.storage_bytes)} used / ${bytes(d.limits.storage_bytes)} limit / ${bytes(d.remaining.storage_bytes)} remaining`, `Uploads: ${d.usage.uploads} used / ${count(d.limits.uploads_per_month)} limit / ${count(d.remaining.uploads)} remaining`, `Bandwidth: ${bytes(d.limits.bandwidth_bytes_per_month)} limit per month (usage not reported)`, + ...(d.generation + ? [ + `AI images: ${d.generation.credits_used} credits used / ${d.generation.credits_limit} limit / ${d.generation.credits_remaining} remaining`, + ] + : []), ]; } diff --git a/src/index.ts b/src/index.ts index ff2b85d..6c23040 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ const main = defineCommand({ register: () => import("./commands/register.js").then((m) => m.default), login: () => import("./commands/login.js").then((m) => m.default), upload: () => import("./commands/upload.js").then((m) => m.default), + generate: () => import("./commands/generate.js").then((m) => m.default), list: () => import("./commands/list.js").then((m) => m.default), get: () => import("./commands/get.js").then((m) => m.default), delete: () => import("./commands/delete.js").then((m) => m.default), diff --git a/src/lib/client.ts b/src/lib/client.ts index 507cacc..a5f4c97 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -17,7 +17,7 @@ interface ApiError { // this channel (server reads X-PixelVault-Client at register/device-login — see // pixelvault docs/specs/lead-attribution.md). Keep the version in sync with // package.json on release (tests/lib/version.test.ts fails if they drift). -export const VERSION = "0.9.0"; +export const VERSION = "0.10.0"; const CLIENT_ID = `pixelvault-cli/${VERSION}`; /** CliError carrying the API's machine-readable error details. */ diff --git a/src/lib/duration.ts b/src/lib/duration.ts index 71ef1dd..741036f 100644 --- a/src/lib/duration.ts +++ b/src/lib/duration.ts @@ -9,11 +9,11 @@ export const MAX_EXPIRES_SECONDS = 30 * 24 * 60 * 60; * of seconds, into seconds. Rejects anything outside the API's accepted range so * a bad value fails locally with a clear message instead of a 400 from the server. */ -export function parseExpires(input: string): number { +export function parseExpires(input: string, flag = "--expires"): number { const match = /^(\d+)\s*([smhd]?)$/i.exec(input.trim()); if (!match) { throw new CliError( - `Invalid --expires "${input}". Use e.g. 30m, 12h, 7d, or a number of seconds.` + `Invalid ${flag} "${input}". Use e.g. 30m, 12h, 7d, or a number of seconds.` ); } @@ -23,7 +23,7 @@ export function parseExpires(input: string): number { if (seconds < MIN_EXPIRES_SECONDS || seconds > MAX_EXPIRES_SECONDS) { throw new CliError( - `--expires must be between 60s and 30d (got ${seconds}s).` + `${flag} must be between 60s and 30d (got ${seconds}s).` ); } diff --git a/tests/commands/generate.test.ts b/tests/commands/generate.test.ts new file mode 100644 index 0000000..b3eba95 --- /dev/null +++ b/tests/commands/generate.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("../../src/lib/config.js", () => ({ + requireApiKey: () => "pv_live_test", + getApiUrl: () => "https://api.test.pixelvault.dev", +})); + +const { default: generate } = await import("../../src/commands/generate.js"); + +const DATA = { + id: "img_abc", + url: "https://img.pixelvault.dev/proj/img_abc.jpg", + optimized_url: "https://img.pixelvault.dev/proj/img_abc.jpg?fmt=auto&q=85", + visibility: "public", + mime_type: "image/jpeg", + size: 160196, + width: 1024, + height: 1024, + filename: "a-red-bicycle.jpg", + folder: null, + created_at: "2026-10-30T00:00:00Z", + generation: { model: "flux-1-schnell", credits_charged: 1, credits_remaining: 9 }, +}; + +function mockFetch(status: number, body: unknown) { + const f = vi.fn().mockResolvedValue({ + ok: status < 400, + status, + statusText: "x", + headers: new Headers(), + json: () => Promise.resolve(body), + }); + globalThis.fetch = f as any; + return f; +} +const sent = (f: ReturnType) => JSON.parse(f.mock.calls[0][1].body as string); +const run = (args: Record) => + generate.run!({ args: { _: [], json: false, private: false, optimized: false, ...args } } as any); + +describe("generate command", () => { + const originalFetch = globalThis.fetch; + let out = ""; + let err = ""; + + beforeEach(() => { + out = ""; + err = ""; + vi.spyOn(process.stdout, "write").mockImplementation((s: any) => ((out += s), true)); + vi.spyOn(process.stderr, "write").mockImplementation((s: any) => ((err += s), true)); + }); + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("POSTs the prompt as JSON and prints ONLY the URL on stdout", async () => { + const f = mockFetch(201, { data: DATA }); + await run({ prompt: "a red bicycle", _: ["a red bicycle"] }); + expect(f.mock.calls[0][0]).toBe("https://api.test.pixelvault.dev/v1/images/generate"); + expect(f.mock.calls[0][1].method).toBe("POST"); + expect(f.mock.calls[0][1].headers.Authorization).toBe("Bearer pv_live_test"); + expect(sent(f)).toEqual({ prompt: "a red bicycle" }); + expect(out).toBe("https://img.pixelvault.dev/proj/img_abc.jpg\n"); + expect(err).toContain("Generated with flux-1-schnell: 1 credit used, 9 left this month."); + }); + + it("joins an unquoted multi-word prompt", async () => { + const f = mockFetch(201, { data: DATA }); + await run({ prompt: "a", _: ["a", "red", "bicycle"] }); + expect(sent(f).prompt).toBe("a red bicycle"); + }); + + it("sends model, folder and --delete-after as expires_in", async () => { + const f = mockFetch(201, { data: DATA }); + await run({ + prompt: "x", + _: ["x"], + model: "lucid-origin", + folder: "heroes", + "delete-after": "1h", + }); + expect(sent(f)).toEqual({ + prompt: "x", + model: "lucid-origin", + folder: "heroes", + expires_in: 3600, + }); + }); + + it("--optimized prints the optimized URL, falling back to the original", async () => { + mockFetch(201, { data: DATA }); + await run({ prompt: "x", _: ["x"], optimized: true }); + expect(out).toBe("https://img.pixelvault.dev/proj/img_abc.jpg?fmt=auto&q=85\n"); + out = ""; + mockFetch(201, { data: { ...DATA, optimized_url: undefined } }); + await run({ prompt: "x", _: ["x"], optimized: true }); + expect(out).toBe("https://img.pixelvault.dev/proj/img_abc.jpg\n"); + }); + + it("--json prints the full response data (including generation)", async () => { + mockFetch(201, { data: DATA }); + await run({ prompt: "x", _: ["x"], json: true }); + expect(JSON.parse(out).generation.credits_remaining).toBe(9); + }); + + it("--private sends visibility + sign_expires_in and prints the signed URL", async () => { + const f = mockFetch(201, { + data: { ...DATA, visibility: "private", url: "https://img.pixelvault.dev/p?sig=1" }, + }); + await run({ prompt: "x", _: ["x"], private: true, expires: "12h", folder: "ignored" }); + expect(sent(f)).toEqual({ + prompt: "x", + folder: "ignored", + visibility: "private", + sign_expires_in: 43200, + }); + expect(out).toBe("https://img.pixelvault.dev/p?sig=1\n"); + expect(err).toContain("--folder is ignored for --private"); + }); + + it("--private fails closed when the server does not confirm it", async () => { + mockFetch(201, { data: DATA }); // visibility: public + await expect(run({ prompt: "x", _: ["x"], private: true })).rejects.toThrow( + "did not confirm it is private", + ); + expect(out).toBe(""); + }); + + it("rejects --expires without --private, before any request", async () => { + const f = mockFetch(201, { data: DATA }); + await expect(run({ prompt: "x", _: ["x"], expires: "1h" })).rejects.toThrow( + "--expires only applies to --private", + ); + expect(f).not.toHaveBeenCalled(); + }); + + it("a bad --delete-after names that flag and sends nothing", async () => { + const f = mockFetch(201, { data: DATA }); + await expect( + run({ prompt: "x", _: ["x"], "delete-after": "soon" }), + ).rejects.toThrow('Invalid --delete-after "soon"'); + await expect( + run({ prompt: "x", _: ["x"], "delete-after": "10s" }), + ).rejects.toThrow("--delete-after must be between 60s and 30d"); + expect(f).not.toHaveBeenCalled(); + }); + + it("requires a prompt", async () => { + const f = mockFetch(201, { data: DATA }); + await expect(run({ prompt: " ", _: [] })).rejects.toThrow("Give a prompt"); + expect(f).not.toHaveBeenCalled(); + }); + + it("surfaces credit exhaustion with the API message and prints nothing", async () => { + mockFetch(402, { + error: { code: "generation_credits_exhausted", message: "You have 0 left this month." }, + }); + await expect(run({ prompt: "x", _: ["x"] })).rejects.toThrow("You have 0 left this month."); + expect(out).toBe(""); + }); + + it("pluralizes credits (20 credits for a Leonardo model)", async () => { + mockFetch(201, { + data: { + ...DATA, + generation: { model: "lucid-origin", credits_charged: 20, credits_remaining: 280 }, + }, + }); + await run({ prompt: "x", _: ["x"], model: "lucid-origin" }); + expect(err).toContain("20 credits used, 280 left"); + }); +}); diff --git a/tests/commands/usage-keys.test.ts b/tests/commands/usage-keys.test.ts index 6948808..8d503ee 100644 --- a/tests/commands/usage-keys.test.ts +++ b/tests/commands/usage-keys.test.ts @@ -79,6 +79,23 @@ describe("usage / keys commands", () => { expect(err).toBe(""); }); + it("shows AI generation credits when the API reports them", async () => { + mockFetch(200, { + data: { + ...USAGE, + generation: { credits_used: 1, credits_limit: 10, credits_remaining: 9 }, + }, + }); + await usage.run!({ args: { json: false } } as any); + expect(out).toContain("AI images: 1 credits used / 10 limit / 9 remaining"); + }); + + it("omits the AI line for an API that doesn't report credits", async () => { + mockFetch(200, { data: USAGE }); + await usage.run!({ args: { json: false } } as any); + expect(out).not.toContain("AI images"); + }); + it("--json prints the data object", async () => { mockFetch(200, { data: USAGE }); await usage.run!({ args: { json: true } } as any);