ima2-gen 1.0.1 → 1.0.3
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 +211 -40
- package/assets/screenshot.png +0 -0
- package/bin/commands/edit.js +70 -0
- package/bin/commands/gen.js +136 -0
- package/bin/commands/ls.js +49 -0
- package/bin/commands/ping.js +28 -0
- package/bin/commands/ps.js +46 -0
- package/bin/commands/show.js +57 -0
- package/bin/ima2.js +206 -12
- package/bin/lib/args.js +73 -0
- package/bin/lib/client.js +97 -0
- package/bin/lib/files.js +39 -0
- package/bin/lib/output.js +48 -0
- package/package.json +11 -4
- package/server.js +639 -83
- package/ui/dist/assets/index-CGvmo0q2.js +16 -0
- package/ui/dist/assets/index-CGvmo0q2.js.map +1 -0
- package/ui/dist/assets/index-Dr1O_KZg.css +1 -0
- package/ui/dist/index.html +24 -0
- package/public/index.html +0 -1008
package/README.md
CHANGED
|
@@ -1,82 +1,253 @@
|
|
|
1
1
|
# ima2-gen
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/ima2-gen)
|
|
4
|
+
[](https://nodejs.org/)
|
|
5
|
+
[](LICENSE)
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
> **Read in other languages**: [한국어](docs/README.ko.md) · [日本語](docs/README.ja.md) · [简体中文](docs/README.zh-CN.md)
|
|
8
|
+
|
|
9
|
+
Minimal CLI + web UI for OpenAI **GPT Image 2** (`gpt-image-2`) image generation. OAuth (free via ChatGPT Plus/Pro) or API key. Parallel generation, multi-image references, CLI automation, persistent history.
|
|
6
10
|
|
|
7
11
|

|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
10
16
|
|
|
11
17
|
```bash
|
|
18
|
+
# Run instantly with npx (no install)
|
|
12
19
|
npx ima2-gen serve
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
Or install globally:
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
# Or install globally
|
|
18
22
|
npm install -g ima2-gen
|
|
19
23
|
ima2 serve
|
|
20
24
|
```
|
|
21
25
|
|
|
22
|
-
First run prompts you to
|
|
26
|
+
First run prompts you to pick an auth method:
|
|
23
27
|
|
|
24
28
|
```
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
Choose authentication method:
|
|
30
|
+
1) API Key — paste your OpenAI API key (paid)
|
|
31
|
+
2) OAuth — login with ChatGPT account (free)
|
|
27
32
|
```
|
|
28
33
|
|
|
29
|
-
|
|
34
|
+
Web UI opens at `http://localhost:3333`.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
Everything in the screenshot above, shipping today:
|
|
41
|
+
|
|
42
|
+
### Authentication
|
|
43
|
+
- **OAuth** — log in with your ChatGPT Plus/Pro account, $0 per image
|
|
44
|
+
- **API Key** — paste your `sk-...` key, pay per call
|
|
45
|
+
|
|
46
|
+
Both indicators shown live in the left panel (green dot = ready, red dot = disabled). API key path is hard-disabled by default; OAuth is the primary route.
|
|
47
|
+
|
|
48
|
+
### Generation controls
|
|
49
|
+
| Control | Options |
|
|
50
|
+
|---------|---------|
|
|
51
|
+
| **Quality** | Low (fast) · Medium (balanced) · High (best) |
|
|
52
|
+
| **Size** | `1024²` `1536×1024` `1024×1536` `1360×1024` `1024×1360` `1824×1024` `1024×1824` `2048²` `2048×1152` `1152×2048` `3824×2160` `2160×3824` · `auto` · custom |
|
|
53
|
+
| **Format** | PNG · JPEG · WebP |
|
|
54
|
+
| **Moderation** | Low (less restrictive) · Auto (standard) |
|
|
55
|
+
| **Count** | 1 · 2 · 4 parallel |
|
|
56
|
+
|
|
57
|
+
All sizes respect gpt-image-2 constraints: every side is a multiple of 16, long:short ratio ≤ 3:1, 655,360–8,294,400 total pixels.
|
|
30
58
|
|
|
31
|
-
|
|
59
|
+
### Workflow
|
|
60
|
+
- **Multi-reference** — attach up to 5 reference images, drag & drop anywhere on the left panel
|
|
61
|
+
- **Prompt-with-context** — mixes text + reference images in one request
|
|
62
|
+
- **Use current** — one-click re-use of the selected image as a new reference
|
|
63
|
+
- **Download** · **Copy to clipboard** · **Copy prompt** directly from the canvas
|
|
64
|
+
- **Sticky gallery strip** at the bottom, fixed-position so it never scrolls away
|
|
65
|
+
- **Gallery modal (+)** — grid view of everything in history
|
|
66
|
+
- **Session persistence** — refresh mid-generation and your pending jobs reconcile automatically
|
|
32
67
|
|
|
68
|
+
### CLI (headless automation)
|
|
33
69
|
```bash
|
|
34
|
-
ima2
|
|
35
|
-
ima2
|
|
36
|
-
ima2
|
|
70
|
+
ima2 gen "a shiba in space" -q high -o shiba.png
|
|
71
|
+
ima2 gen "merge these" --ref a.png --ref b.png -n 4 -d out/
|
|
72
|
+
ima2 ls -n 10
|
|
73
|
+
ima2 ps
|
|
74
|
+
ima2 ping
|
|
37
75
|
```
|
|
38
76
|
|
|
39
|
-
|
|
77
|
+
Full command matrix below ↓
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## CLI Commands
|
|
82
|
+
|
|
83
|
+
### Server commands
|
|
84
|
+
| Command | Alias | Description |
|
|
85
|
+
|---------|-------|-------------|
|
|
86
|
+
| `ima2 serve` | — | Start the web server (auto-setup on first run) |
|
|
87
|
+
| `ima2 setup` | `login` | Reconfigure authentication method |
|
|
88
|
+
| `ima2 status` | — | Show current config & auth status |
|
|
89
|
+
| `ima2 doctor` | — | Diagnose environment & dependencies |
|
|
90
|
+
| `ima2 open` | — | Open web UI in browser |
|
|
91
|
+
| `ima2 reset` | — | Clear saved configuration |
|
|
92
|
+
| `ima2 --version` | `-v` | Show version |
|
|
93
|
+
| `ima2 --help` | `-h` | Show help |
|
|
94
|
+
|
|
95
|
+
### Client commands (require a running `ima2 serve`)
|
|
96
|
+
| Command | Description |
|
|
97
|
+
|---------|-------------|
|
|
98
|
+
| `ima2 gen <prompt>` | Generate image(s) from the CLI |
|
|
99
|
+
| `ima2 edit <file>` | Edit an existing image (requires `--prompt`) |
|
|
100
|
+
| `ima2 ls` | List recent history (table or `--json`) |
|
|
101
|
+
| `ima2 show <name>` | Reveal one history item (`--reveal`) |
|
|
102
|
+
| `ima2 ps` | List active jobs (`--kind`, `--session`) |
|
|
103
|
+
| `ima2 ping` | Health-check the running server |
|
|
104
|
+
|
|
105
|
+
The running server advertises itself at `~/.ima2/server.json`. Client commands auto-discover it; override with `--server <url>` or `IMA2_SERVER=...`.
|
|
106
|
+
|
|
107
|
+
### Exit codes
|
|
108
|
+
`0` ok · `2` bad args · `3` server unreachable · `4` APIKEY_DISABLED · `5` 4xx · `6` 5xx · `7` safety refusal · `8` timeout.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Roadmap
|
|
113
|
+
|
|
114
|
+
Public roadmap — subject to change. Version numbers reflect the actual ship cycle, not time estimates.
|
|
115
|
+
|
|
116
|
+
### ✅ Shipped
|
|
117
|
+
- **0.06** Session DB — SQLite-backed history with sidecar JSON
|
|
118
|
+
- **0.07** Multi-reference — up to 5 attachments, i2i merged into unified flow
|
|
119
|
+
- **0.08** Inflight tracking — refresh-safe pending state, phase tracking
|
|
120
|
+
- **0.09** Node mode (dev-only) — graph-based canvas for branching generations
|
|
121
|
+
- **0.09.1** CLI integration — `gen / edit / ls / show / ps / ping` + `/api/health` + port advertisement
|
|
122
|
+
|
|
123
|
+
### 🚧 0.10 — Compare & Reuse (current cycle)
|
|
124
|
+
- **F3 Prompt presets** — save/apply `{prompt, refs, quality, size}` bundles
|
|
125
|
+
- **F3 Gallery groupBy** — `preset / date / compareRun` grouping
|
|
126
|
+
- **F2 Batch A/B compare** — spawn 2–6 parallel variants from one prompt, keyboard-driven judging (`1-6`, `Space`=winner, `V`=variation, `P`=save preset)
|
|
127
|
+
- **F4 Export bundle** — zip selected images with `manifest.json` + per-image prompt `.txt`
|
|
128
|
+
- Every server verb ships with its CLI mirror (`ima2 preset / compare / export`)
|
|
129
|
+
|
|
130
|
+
### 🔭 0.11 — Card-news mode
|
|
131
|
+
- Instagram carousel generation (4 / 6 / 10 cards)
|
|
132
|
+
- Style consistency via `file_id` fan-out (not `previous_response_id`, not seed)
|
|
133
|
+
- Parallel card regeneration without breaking the style chain
|
|
40
134
|
|
|
41
|
-
|
|
42
|
-
-
|
|
43
|
-
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
|
|
135
|
+
### 🔭 0.12 — Style kit
|
|
136
|
+
- Codified house-style presets with style-reference uploads
|
|
137
|
+
- Optional `input_fidelity: "high"` for identity-critical edits
|
|
138
|
+
|
|
139
|
+
### 🗂 Backlog
|
|
140
|
+
- Web UI dark/light toggle
|
|
141
|
+
- Keyboard shortcuts cheat-sheet overlay
|
|
142
|
+
- Collaborative sessions (shared SQLite over WebSocket)
|
|
143
|
+
- Plugin system for custom post-processing
|
|
144
|
+
|
|
145
|
+
---
|
|
51
146
|
|
|
52
147
|
## Architecture
|
|
53
148
|
|
|
54
149
|
```
|
|
55
150
|
ima2 serve
|
|
56
|
-
├── Express (:3333)
|
|
57
|
-
|
|
151
|
+
├── Express server (:3333)
|
|
152
|
+
│ ├── GET /api/health — version, uptime, activeJobs, pid
|
|
153
|
+
│ ├── GET /api/providers — available auth methods
|
|
154
|
+
│ ├── GET /api/oauth/status — OAuth proxy health check
|
|
155
|
+
│ ├── POST /api/generate — text+ref → image (parallel via n)
|
|
156
|
+
│ ├── POST /api/edit — ref-heavy edit path
|
|
157
|
+
│ ├── GET /api/history — paginated sidecar listing
|
|
158
|
+
│ ├── GET /api/inflight — in-progress jobs (kind/session filters)
|
|
159
|
+
│ ├── GET /api/sessions/* — node-graph sessions (dev-only)
|
|
160
|
+
│ ├── GET /api/billing — API credit / cost info
|
|
161
|
+
│ └── Static files (public/) — web UI
|
|
162
|
+
│
|
|
163
|
+
├── openai-oauth proxy (:10531) — embedded OAuth relay
|
|
164
|
+
└── ~/.ima2/server.json — port advertisement for CLI auto-discovery
|
|
58
165
|
```
|
|
59
166
|
|
|
60
|
-
|
|
167
|
+
**Node mode** is dev-only (`npm run dev`) and gated from npm publishes until the session DB + multi-user story lands.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## Configuration
|
|
61
172
|
|
|
62
|
-
|
|
173
|
+
Config lives in `.ima2/config.json` (auto-created, gitignored).
|
|
63
174
|
|
|
64
|
-
|
|
175
|
+
### Environment Variables
|
|
176
|
+
| Variable | Default | Description |
|
|
177
|
+
|----------|---------|-------------|
|
|
178
|
+
| `OPENAI_API_KEY` | — | OpenAI API key (skips OAuth) |
|
|
179
|
+
| `PORT` | `3333` | Web server port |
|
|
180
|
+
| `OAUTH_PORT` | `10531` | OAuth proxy port |
|
|
181
|
+
| `IMA2_SERVER` | — | Client: override target server URL |
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
cp .env.example .env
|
|
65
185
|
```
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## API Pricing (API Key mode only)
|
|
190
|
+
|
|
191
|
+
| Quality | 1024×1024 | 1024×1536 | 1536×1024 | 2048×2048 | 3840×2160 |
|
|
192
|
+
|---------|-----------|-----------|-----------|-----------|-----------|
|
|
193
|
+
| Low | $0.006 | $0.005 | $0.005 | $0.012 | $0.023 |
|
|
194
|
+
| Medium | $0.053 | $0.041 | $0.041 | $0.106 | $0.200 |
|
|
195
|
+
| High | $0.211 | $0.165 | $0.165 | $0.422 | $0.800 |
|
|
196
|
+
|
|
197
|
+
**OAuth mode is free** — billed against your existing ChatGPT Plus/Pro plan.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Development
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
git clone https://github.com/lidge-jun/ima2-gen.git
|
|
205
|
+
cd ima2-gen
|
|
206
|
+
npm install
|
|
207
|
+
npm run dev # server with --watch + Node mode enabled
|
|
208
|
+
npm test # 51+ tests (health, CLI lib, commands, server)
|
|
69
209
|
```
|
|
70
210
|
|
|
71
|
-
|
|
211
|
+
Frontend stack:
|
|
212
|
+
- Vanilla HTML/CSS/JS (no framework in the published build)
|
|
213
|
+
- Vite + React for the Node-mode canvas (dev-only, gated)
|
|
214
|
+
- Fonts: Outfit + Geist Mono
|
|
215
|
+
|
|
216
|
+
## Tech Stack
|
|
217
|
+
- **Runtime**: Node.js ≥18
|
|
218
|
+
- **Server**: Express 5, SQLite (better-sqlite3)
|
|
219
|
+
- **API**: OpenAI SDK v5
|
|
220
|
+
- **OAuth**: `openai-oauth` proxy
|
|
221
|
+
- **Tests**: Node built-in test runner
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Troubleshooting
|
|
226
|
+
|
|
227
|
+
**Port already in use / "why is it on 3457?"**
|
|
228
|
+
→ The default is `3333`. If `PORT` is set in your shell (e.g. inherited from another server like `cli-jaw`), ima2 uses that instead. Unset it or run `PORT=3333 ima2 serve`.
|
|
229
|
+
|
|
230
|
+
**`ima2 ping` says server unreachable**
|
|
231
|
+
→ Is `ima2 serve` running? Check `~/.ima2/server.json`. Override with `ima2 ping --server http://localhost:3333`.
|
|
72
232
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
| Low | $0.006 | $0.005 | $0.005 |
|
|
76
|
-
| Medium | $0.053 | $0.041 | $0.041 |
|
|
77
|
-
| High | $0.211 | $0.165 | $0.165 |
|
|
233
|
+
**OAuth login not working**
|
|
234
|
+
→ Run `npx @openai/codex login` manually, then `ima2 serve`.
|
|
78
235
|
|
|
79
|
-
|
|
236
|
+
**`ima2 doctor` fails on node_modules**
|
|
237
|
+
→ `npm install`.
|
|
238
|
+
|
|
239
|
+
**Images not generating**
|
|
240
|
+
→ `ima2 status` to verify config. API key must start with `sk-`.
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## Release
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
npm run release:patch # 1.0.2 → 1.0.3
|
|
248
|
+
npm run release:minor # 1.0.x → 1.1.0
|
|
249
|
+
npm run release:major # 1.x.x → 2.0.0
|
|
250
|
+
```
|
|
80
251
|
|
|
81
252
|
## License
|
|
82
253
|
|
package/assets/screenshot.png
CHANGED
|
Binary file
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { parseArgs } from "../lib/args.js";
|
|
2
|
+
import { resolveServer, request } from "../lib/client.js";
|
|
3
|
+
import { fileToDataUri, dataUriToFile, defaultOutName } from "../lib/files.js";
|
|
4
|
+
import { out, die, color, json, exitCodeForError } from "../lib/output.js";
|
|
5
|
+
|
|
6
|
+
const SPEC = {
|
|
7
|
+
flags: {
|
|
8
|
+
prompt: { short: "p", type: "string" },
|
|
9
|
+
quality: { short: "q", type: "string", default: "low" },
|
|
10
|
+
size: { short: "s", type: "string", default: "1024x1024" },
|
|
11
|
+
out: { short: "o", type: "string" },
|
|
12
|
+
json: { type: "boolean" },
|
|
13
|
+
timeout: { type: "string", default: "180" },
|
|
14
|
+
server: { type: "string" },
|
|
15
|
+
help: { short: "h", type: "boolean" },
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const HELP = `
|
|
20
|
+
ima2 edit <file> --prompt "<text>" [options]
|
|
21
|
+
|
|
22
|
+
Edit an existing image (inpainting-style).
|
|
23
|
+
|
|
24
|
+
Options:
|
|
25
|
+
-p, --prompt <text> Edit instruction (required)
|
|
26
|
+
-q, --quality <low|medium|high|auto>
|
|
27
|
+
-s, --size <WxH>
|
|
28
|
+
-o, --out <file>
|
|
29
|
+
--json
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
export default async function editCmd(argv) {
|
|
33
|
+
const args = parseArgs(argv, SPEC);
|
|
34
|
+
if (args.help) { out(HELP); return; }
|
|
35
|
+
const input = args.positional[0];
|
|
36
|
+
if (!input) die(2, "input image path required");
|
|
37
|
+
if (!args.prompt) die(2, "--prompt is required");
|
|
38
|
+
|
|
39
|
+
let server;
|
|
40
|
+
try { server = await resolveServer({ serverFlag: args.server }); }
|
|
41
|
+
catch (e) { die(exitCodeForError(e), e.message); }
|
|
42
|
+
|
|
43
|
+
const imageDataUri = await fileToDataUri(input);
|
|
44
|
+
const imageB64 = imageDataUri.split(",")[1];
|
|
45
|
+
|
|
46
|
+
const timeoutMs = (parseInt(args.timeout) || 180) * 1000;
|
|
47
|
+
let resp;
|
|
48
|
+
try {
|
|
49
|
+
resp = await request(server.base, "/api/edit", {
|
|
50
|
+
method: "POST",
|
|
51
|
+
body: { prompt: args.prompt, image: imageB64, quality: args.quality, size: args.size },
|
|
52
|
+
timeoutMs,
|
|
53
|
+
});
|
|
54
|
+
} catch (e) {
|
|
55
|
+
if (args.json) json({ ok: false, error: e.message, code: e.code });
|
|
56
|
+
die(exitCodeForError(e), e.message);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const image = resp.image;
|
|
60
|
+
if (!image) die(1, "server returned no image");
|
|
61
|
+
const target = args.out || defaultOutName(0, 1);
|
|
62
|
+
await dataUriToFile(image, target);
|
|
63
|
+
|
|
64
|
+
if (args.json) {
|
|
65
|
+
json({ ok: true, path: target, requestId: resp.requestId, elapsed: resp.elapsed });
|
|
66
|
+
} else {
|
|
67
|
+
out(color.green("✓ ") + target);
|
|
68
|
+
if (resp.elapsed) out(color.dim(`elapsed ${resp.elapsed}s`));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { parseArgs } from "../lib/args.js";
|
|
2
|
+
import { resolveServer, request, normalizeGenerate } from "../lib/client.js";
|
|
3
|
+
import { fileToDataUri, dataUriToFile, defaultOutName, readStdin } from "../lib/files.js";
|
|
4
|
+
import { out, err, die, color, json, exitCodeForError } from "../lib/output.js";
|
|
5
|
+
|
|
6
|
+
const SPEC = {
|
|
7
|
+
flags: {
|
|
8
|
+
quality: { short: "q", type: "string", default: "low" },
|
|
9
|
+
size: { short: "s", type: "string", default: "1024x1024" },
|
|
10
|
+
count: { short: "n", type: "string", default: "1" },
|
|
11
|
+
ref: { type: "string", repeatable: true },
|
|
12
|
+
out: { short: "o", type: "string" },
|
|
13
|
+
"out-dir": { short: "d", type: "string" },
|
|
14
|
+
json: { type: "boolean" },
|
|
15
|
+
"no-save": { type: "boolean" },
|
|
16
|
+
force: { type: "boolean" },
|
|
17
|
+
stdin: { type: "boolean" },
|
|
18
|
+
timeout: { type: "string", default: "180" },
|
|
19
|
+
server: { type: "string" },
|
|
20
|
+
help: { short: "h", type: "boolean" },
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const HELP = `
|
|
25
|
+
ima2 gen <prompt...> [options]
|
|
26
|
+
|
|
27
|
+
Generate image(s) via the running ima2 server.
|
|
28
|
+
|
|
29
|
+
Options:
|
|
30
|
+
-q, --quality <low|medium|high|auto> Default: low
|
|
31
|
+
-s, --size <WxH | auto> Default: 1024x1024
|
|
32
|
+
-n, --count <1..8> Default: 1
|
|
33
|
+
--ref <file> Attach reference image (repeatable, max 5)
|
|
34
|
+
-o, --out <file> Single-image output path (implies -n 1)
|
|
35
|
+
-d, --out-dir <dir> Output dir for multiple images
|
|
36
|
+
--json Print JSON result to stdout
|
|
37
|
+
--no-save Skip save; print b64 to stdout (use --force for TTY)
|
|
38
|
+
--stdin Read prompt from stdin
|
|
39
|
+
--timeout <sec> Default: 180
|
|
40
|
+
--server <url> Override server URL
|
|
41
|
+
|
|
42
|
+
Examples:
|
|
43
|
+
ima2 gen "a shiba in space"
|
|
44
|
+
ima2 gen "merge" --ref a.png --ref b.png -q high -o out.png
|
|
45
|
+
cat prompt.txt | ima2 gen --stdin -n 2 -d ./out
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
export default async function genCmd(argv) {
|
|
49
|
+
const args = parseArgs(argv, SPEC);
|
|
50
|
+
if (args.help) { out(HELP); return; }
|
|
51
|
+
|
|
52
|
+
let prompt = args.positional.join(" ");
|
|
53
|
+
if (args.stdin) {
|
|
54
|
+
const piped = await readStdin();
|
|
55
|
+
if (piped) prompt = prompt ? `${prompt} ${piped}` : piped;
|
|
56
|
+
}
|
|
57
|
+
if (!prompt) die(2, "prompt is required (positional or via --stdin)");
|
|
58
|
+
|
|
59
|
+
const refs = args.ref || [];
|
|
60
|
+
if (refs.length > 5) die(2, "max 5 --ref attachments");
|
|
61
|
+
|
|
62
|
+
const n = Math.max(1, Math.min(8, parseInt(args.count) || 1));
|
|
63
|
+
const timeoutMs = (parseInt(args.timeout) || 180) * 1000;
|
|
64
|
+
|
|
65
|
+
let server;
|
|
66
|
+
try {
|
|
67
|
+
server = await resolveServer({ serverFlag: args.server });
|
|
68
|
+
} catch (e) {
|
|
69
|
+
die(exitCodeForError(e), e.message);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const references = await Promise.all(refs.map((p) => fileToDataUri(p)));
|
|
73
|
+
|
|
74
|
+
const body = {
|
|
75
|
+
prompt,
|
|
76
|
+
quality: args.quality,
|
|
77
|
+
size: args.size,
|
|
78
|
+
n,
|
|
79
|
+
references,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
let resp;
|
|
83
|
+
try {
|
|
84
|
+
resp = await request(server.base, "/api/generate", { method: "POST", body, timeoutMs });
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (args.json) json({ ok: false, error: e.message, code: e.code, status: e.status });
|
|
87
|
+
die(exitCodeForError(e), e.message);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const norm = normalizeGenerate(resp);
|
|
91
|
+
if (norm.images.length === 0) die(1, "server returned no images");
|
|
92
|
+
|
|
93
|
+
// --no-save path
|
|
94
|
+
if (args["no-save"]) {
|
|
95
|
+
const totalBytes = norm.images.reduce((s, im) => s + im.image.length, 0);
|
|
96
|
+
if (process.stdout.isTTY && totalBytes > 2 * 1024 * 1024 && !args.force) {
|
|
97
|
+
die(2, "refusing to print >2MB of b64 to TTY; use --force or drop --no-save");
|
|
98
|
+
}
|
|
99
|
+
for (const im of norm.images) out(im.image);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Save path
|
|
104
|
+
const outDir = args["out-dir"] || null;
|
|
105
|
+
const explicitOut = args.out || null;
|
|
106
|
+
if (explicitOut && norm.images.length > 1) {
|
|
107
|
+
die(2, "--out only supports a single image; use --out-dir for n>1");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const savedPaths = [];
|
|
111
|
+
for (let i = 0; i < norm.images.length; i++) {
|
|
112
|
+
const im = norm.images[i];
|
|
113
|
+
let target;
|
|
114
|
+
if (explicitOut) {
|
|
115
|
+
target = explicitOut;
|
|
116
|
+
} else if (outDir) {
|
|
117
|
+
target = `${outDir}/${defaultOutName(i, norm.images.length)}`;
|
|
118
|
+
} else {
|
|
119
|
+
target = defaultOutName(i, norm.images.length);
|
|
120
|
+
}
|
|
121
|
+
await dataUriToFile(im.image, target);
|
|
122
|
+
savedPaths.push(target);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (args.json) {
|
|
126
|
+
json({
|
|
127
|
+
ok: true,
|
|
128
|
+
requestId: norm.requestId,
|
|
129
|
+
elapsed: norm.elapsed,
|
|
130
|
+
images: savedPaths.map((p, i) => ({ path: p, filename: norm.images[i].filename })),
|
|
131
|
+
});
|
|
132
|
+
} else {
|
|
133
|
+
for (const p of savedPaths) out(color.green("✓ ") + p);
|
|
134
|
+
if (norm.elapsed) out(color.dim(`elapsed ${norm.elapsed}s`));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { parseArgs } from "../lib/args.js";
|
|
2
|
+
import { resolveServer, request } from "../lib/client.js";
|
|
3
|
+
import { out, die, color, json, table, exitCodeForError } from "../lib/output.js";
|
|
4
|
+
|
|
5
|
+
const SPEC = {
|
|
6
|
+
flags: {
|
|
7
|
+
count: { short: "n", type: "string", default: "20" },
|
|
8
|
+
json: { type: "boolean" },
|
|
9
|
+
server: { type: "string" },
|
|
10
|
+
help: { short: "h", type: "boolean" },
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default async function lsCmd(argv) {
|
|
15
|
+
const args = parseArgs(argv, SPEC);
|
|
16
|
+
if (args.help) { out("ima2 ls [-n count] [--json]"); return; }
|
|
17
|
+
|
|
18
|
+
let server;
|
|
19
|
+
try { server = await resolveServer({ serverFlag: args.server }); }
|
|
20
|
+
catch (e) { die(exitCodeForError(e), e.message); }
|
|
21
|
+
|
|
22
|
+
const limit = parseInt(args.count) || 20;
|
|
23
|
+
let resp;
|
|
24
|
+
try { resp = await request(server.base, "/api/history"); }
|
|
25
|
+
catch (e) { die(exitCodeForError(e), e.message); }
|
|
26
|
+
|
|
27
|
+
const items = (resp.items || resp.history || []).slice(0, limit);
|
|
28
|
+
|
|
29
|
+
if (args.json) { json({ items }); return; }
|
|
30
|
+
|
|
31
|
+
if (items.length === 0) {
|
|
32
|
+
out(color.dim("(no history)"));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
table(items, [
|
|
36
|
+
{ key: "filename", label: "FILENAME" },
|
|
37
|
+
{ key: "quality", label: "Q" },
|
|
38
|
+
{ key: "size", label: "SIZE" },
|
|
39
|
+
{ key: "createdAt", label: "WHEN", format: (v) => {
|
|
40
|
+
if (!v) return "";
|
|
41
|
+
const d = new Date(v);
|
|
42
|
+
return d.toISOString().replace("T", " ").slice(0, 19);
|
|
43
|
+
} },
|
|
44
|
+
{ key: "prompt", label: "PROMPT", format: (v) => {
|
|
45
|
+
const s = String(v || "").replace(/\s+/g, " ");
|
|
46
|
+
return s.length > 48 ? s.slice(0, 45) + "…" : s;
|
|
47
|
+
} },
|
|
48
|
+
]);
|
|
49
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { parseArgs } from "../lib/args.js";
|
|
2
|
+
import { resolveServer } from "../lib/client.js";
|
|
3
|
+
import { out, die, color, json, exitCodeForError } from "../lib/output.js";
|
|
4
|
+
|
|
5
|
+
const SPEC = {
|
|
6
|
+
flags: {
|
|
7
|
+
json: { type: "boolean" },
|
|
8
|
+
server: { type: "string" },
|
|
9
|
+
help: { short: "h", type: "boolean" },
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default async function pingCmd(argv) {
|
|
14
|
+
const args = parseArgs(argv, SPEC);
|
|
15
|
+
if (args.help) { out("ima2 ping [--json]"); return; }
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const { base, health } = await resolveServer({ serverFlag: args.server });
|
|
19
|
+
if (args.json) {
|
|
20
|
+
json({ ok: true, base, ...health });
|
|
21
|
+
} else {
|
|
22
|
+
out(color.green("✓ ") + `${base} v${health.version} uptime ${health.uptimeSec}s activeJobs ${health.activeJobs}`);
|
|
23
|
+
}
|
|
24
|
+
} catch (e) {
|
|
25
|
+
if (args.json) { json({ ok: false, error: e.message }); process.exit(exitCodeForError(e)); }
|
|
26
|
+
die(exitCodeForError(e), e.message);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { parseArgs } from "../lib/args.js";
|
|
2
|
+
import { resolveServer, request } from "../lib/client.js";
|
|
3
|
+
import { out, die, color, json, table, exitCodeForError } from "../lib/output.js";
|
|
4
|
+
|
|
5
|
+
const SPEC = {
|
|
6
|
+
flags: {
|
|
7
|
+
kind: { type: "string" },
|
|
8
|
+
session: { type: "string" },
|
|
9
|
+
json: { type: "boolean" },
|
|
10
|
+
server: { type: "string" },
|
|
11
|
+
help: { short: "h", type: "boolean" },
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export default async function psCmd(argv) {
|
|
16
|
+
const args = parseArgs(argv, SPEC);
|
|
17
|
+
if (args.help) { out("ima2 ps [--kind classic|node] [--session id] [--json]"); return; }
|
|
18
|
+
|
|
19
|
+
let server;
|
|
20
|
+
try { server = await resolveServer({ serverFlag: args.server }); }
|
|
21
|
+
catch (e) { die(exitCodeForError(e), e.message); }
|
|
22
|
+
|
|
23
|
+
const qs = new URLSearchParams();
|
|
24
|
+
if (args.kind) qs.set("kind", args.kind);
|
|
25
|
+
if (args.session) qs.set("sessionId", args.session);
|
|
26
|
+
const path = `/api/inflight${qs.toString() ? `?${qs}` : ""}`;
|
|
27
|
+
let resp;
|
|
28
|
+
try { resp = await request(server.base, path); }
|
|
29
|
+
catch (e) { die(exitCodeForError(e), e.message); }
|
|
30
|
+
|
|
31
|
+
const jobs = resp.jobs || resp.items || [];
|
|
32
|
+
if (args.json) { json({ jobs }); return; }
|
|
33
|
+
if (jobs.length === 0) { out(color.dim("(no active jobs)")); return; }
|
|
34
|
+
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
table(jobs, [
|
|
37
|
+
{ key: "requestId", label: "ID", format: (v) => String(v || "").slice(0, 10) },
|
|
38
|
+
{ key: "kind", label: "KIND" },
|
|
39
|
+
{ key: "phase", label: "PHASE" },
|
|
40
|
+
{ key: "startedAt", label: "AGE", format: (v) => v ? `${Math.round((now - v) / 1000)}s` : "" },
|
|
41
|
+
{ key: "prompt", label: "PROMPT", format: (v) => {
|
|
42
|
+
const s = String(v || "").replace(/\s+/g, " ");
|
|
43
|
+
return s.length > 40 ? s.slice(0, 37) + "…" : s;
|
|
44
|
+
} },
|
|
45
|
+
]);
|
|
46
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { parseArgs } from "../lib/args.js";
|
|
2
|
+
import { resolveServer, request } from "../lib/client.js";
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { out, die, color, json, exitCodeForError } from "../lib/output.js";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const SPEC = {
|
|
8
|
+
flags: {
|
|
9
|
+
json: { type: "boolean" },
|
|
10
|
+
reveal: { type: "boolean" },
|
|
11
|
+
server: { type: "string" },
|
|
12
|
+
help: { short: "h", type: "boolean" },
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default async function showCmd(argv) {
|
|
17
|
+
const args = parseArgs(argv, SPEC);
|
|
18
|
+
if (args.help) { out("ima2 show <filename> [--json] [--reveal]"); return; }
|
|
19
|
+
const name = args.positional[0];
|
|
20
|
+
if (!name) die(2, "filename required");
|
|
21
|
+
|
|
22
|
+
let server;
|
|
23
|
+
try { server = await resolveServer({ serverFlag: args.server }); }
|
|
24
|
+
catch (e) { die(exitCodeForError(e), e.message); }
|
|
25
|
+
|
|
26
|
+
let resp;
|
|
27
|
+
try { resp = await request(server.base, "/api/history"); }
|
|
28
|
+
catch (e) { die(exitCodeForError(e), e.message); }
|
|
29
|
+
|
|
30
|
+
const items = resp.items || resp.history || [];
|
|
31
|
+
const item = items.find((it) => it.filename === name || (it.filename && it.filename.endsWith(name)));
|
|
32
|
+
if (!item) die(1, `not found: ${name}`);
|
|
33
|
+
|
|
34
|
+
if (args.json) { json(item); }
|
|
35
|
+
else {
|
|
36
|
+
out(color.bold(item.filename));
|
|
37
|
+
out(color.dim(` prompt:`) + ` ${item.prompt || ""}`);
|
|
38
|
+
out(color.dim(` size:`) + ` ${item.size || ""} quality: ${item.quality || ""}`);
|
|
39
|
+
if (item.createdAt) out(color.dim(` when:`) + ` ${new Date(item.createdAt).toISOString()}`);
|
|
40
|
+
if (item.url) out(color.dim(` url:`) + ` ${server.base}${item.url}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (args.reveal) {
|
|
44
|
+
const url = item.url ? `${server.base}${item.url}` : null;
|
|
45
|
+
try {
|
|
46
|
+
if (process.platform === "darwin") {
|
|
47
|
+
execSync(`open "${server.base}${item.url || ""}"`);
|
|
48
|
+
} else if (process.platform === "win32") {
|
|
49
|
+
execSync(`start "" "${url}"`);
|
|
50
|
+
} else {
|
|
51
|
+
execSync(`xdg-open "${url}"`);
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
out(color.yellow("(could not reveal)"));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|