Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,12 @@ npx pixelvault-cli upload build-output.png
| `register` | Create a new account |
| `login` | Log in to existing account |
| `upload <files...>` | Upload images (prints URLs to stdout) |
| `generate <prompt>` | Generate an image from a prompt and host it (prints the URL; spends monthly AI credits) |
| `list` | List uploaded images |
| `get <id>` | Get an image's URL/metadata, or download it (optionally transformed) |
| `delete <id>` | 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 <starter\|pro>` | 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) |
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
144 changes: 144 additions & 0 deletions src/commands/generate.ts
Original file line number Diff line number Diff line change
@@ -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<GenerateResponse>({
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);
}
},
});
11 changes: 11 additions & 0 deletions src/commands/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -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`,
]
: []),
];
}

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion src/lib/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
6 changes: 3 additions & 3 deletions src/lib/duration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
);
}

Expand All @@ -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).`
);
}

Expand Down
Loading
Loading