egaki 0.0.1 → 0.0.2

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/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## 0.0.2
6
+
7
+ - Make subscribe messaging explicit about both auth modes.
8
+ - Clarify BYOK provider keys vs single Egaki subscription key in CLI + docs.
9
+ - Expand README with advanced usage and model-specific command examples.
10
+
11
+ ## 0.0.1
12
+
13
+ - Initial public release of the `egaki` CLI.
14
+ - Image generation command with model selection and file/stdout output.
15
+ - Login, subscription, unsubscribe, usage, and models commands.
package/README.md ADDED
@@ -0,0 +1,166 @@
1
+ # egaki
2
+
3
+ Generate AI images from the terminal with one command.
4
+
5
+ `egaki` is a TypeScript CLI built on the Vercel AI SDK and goke. It supports
6
+ Google Imagen, Gemini image-capable models, and model discovery with pricing.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ pnpm add -g egaki
12
+ ```
13
+
14
+ ## Quick start
15
+
16
+ ```bash
17
+ egaki login
18
+ egaki image "a watercolor fox reading a map" -o fox.png
19
+ ```
20
+
21
+ ## CLI examples
22
+
23
+ ### Generate from text
24
+
25
+ ```bash
26
+ egaki image "cinematic mountain village at sunrise" -o village.png
27
+ egaki image "isometric floating city, detailed, soft colors" -m imagen-4.0-generate-001
28
+ ```
29
+
30
+ ### Edit with input image
31
+
32
+ ```bash
33
+ egaki image "add a red scarf and make it winter" --input portrait.jpg -o portrait-winter.png
34
+ egaki image "turn this into a manga panel" --input https://example.com/photo.jpg -o manga.png
35
+ ```
36
+
37
+ ### Inpainting with a mask
38
+
39
+ ```bash
40
+ egaki image "replace the sky with dramatic storm clouds" --input landscape.png --mask mask.png -o storm.png
41
+ ```
42
+
43
+ ### Generate multiple images
44
+
45
+ ```bash
46
+ egaki image "minimal logo concepts for a cat cafe" -n 4 -o logo.png
47
+ ```
48
+
49
+ ### Control composition
50
+
51
+ ```bash
52
+ egaki image "cyberpunk alley at night" --aspect-ratio 16:9
53
+ egaki image "polaroid-style travel photo" --aspect-ratio 4:5
54
+ ```
55
+
56
+ ### Pipe output to other tools
57
+
58
+ ```bash
59
+ egaki image "flat icon of a fox" --stdout | magick - -resize 512x512 fox-icon.png
60
+ ```
61
+
62
+ ### Discover models and pricing
63
+
64
+ ```bash
65
+ egaki models
66
+ egaki models --provider google
67
+ egaki models --json
68
+ ```
69
+
70
+ ### Advanced examples
71
+
72
+ ```bash
73
+ # Deterministic result (models that support seed)
74
+ egaki image "studio product shot of a ceramic mug" \
75
+ -m imagen-4.0-generate-001 \
76
+ --seed 42 \
77
+ -o mug-seed-42.png
78
+
79
+ # High quality Gemini image generation with 4K output
80
+ egaki image "architectural concept art, brutalist library interior" \
81
+ -m gemini-2.5-flash-image \
82
+ --image-size 4K \
83
+ --aspect-ratio 16:9 \
84
+ -o library-4k.png
85
+
86
+ # Multi-reference edit (pass --input multiple times)
87
+ egaki image "blend style from first image and color palette from second" \
88
+ --input style-reference.jpg \
89
+ --input palette-reference.jpg \
90
+ -m nano-banana-pro-preview \
91
+ -o hybrid-style.png
92
+
93
+ # JSON mode for automation/pipelines
94
+ egaki image "futuristic sneaker concept" \
95
+ -m gpt-image-1.5 \
96
+ --json \
97
+ -o sneaker.png
98
+
99
+ # Batch generation with indexed output names
100
+ egaki image "mascot variations, flat vector look" \
101
+ -m gpt-image-1-mini \
102
+ -n 6 \
103
+ -o mascot.png
104
+ # writes mascot-0.png ... mascot-5.png
105
+ ```
106
+
107
+ ## Model input examples
108
+
109
+ Use `egaki models --json` to see the full list. These are practical examples of
110
+ which flags pair well with common models:
111
+
112
+ | Model ID | Best for | Example command |
113
+ | --- | --- | --- |
114
+ | `imagen-4.0-ultra-generate-001` | High-quality prompt-to-image with seed + ratio | `egaki image "luxury perfume ad on marble" -m imagen-4.0-ultra-generate-001 --aspect-ratio 3:4 --seed 7 -o perfume.png` |
115
+ | `gemini-3.1-flash-image-preview` | Fast, cheap text+image edits with wide aspect-ratio support | `egaki image "turn into manga splash page" -m gemini-3.1-flash-image-preview --input portrait.jpg --aspect-ratio 4:1 -o manga-wide.png` |
116
+ | `nano-banana-pro-preview` | Highest-fidelity Google text+image output | `egaki image "fashion editorial, dramatic rim light" -m nano-banana-pro-preview --input model.jpg --image-size 2K -o editorial.png` |
117
+ | `gpt-image-1.5` | OpenAI image generation with strong editing/inpainting support | `egaki image "replace background with neon city" -m gpt-image-1.5 --input product.png --mask bg-mask.png -o product-neon.png` |
118
+ | `black-forest-labs/flux-fill-pro` | Inpainting-focused Flux workflow on Replicate | `egaki image "restore damaged poster corners" -m black-forest-labs/flux-fill-pro --input poster.png --mask corners-mask.png -o restored.png` |
119
+ | `fal-ai/flux/schnell` | Very fast low-cost ideation batches | `egaki image "logo sketch, geometric fox" -m fal-ai/flux/schnell -n 8 -o fox-logo.png` |
120
+
121
+ ## Feature support by model family
122
+
123
+ Quick rule of thumb for flags:
124
+
125
+ - **Google Imagen (`imagen-*`)**: supports `--seed`, `--aspect-ratio`, `--input`, `--mask`, `-n`
126
+ - **Google Gemini image models**: supports `--input`, `--aspect-ratio`, `--image-size`; usually no `--seed`
127
+ - **OpenAI image models**: strong editing and inpainting; size controls are model-specific
128
+ - **Flux/Fal/Replicate models**: broad aspect-ratio + seed support; editing/inpainting depends on exact model
129
+
130
+ ### Subscription and usage
131
+
132
+ egaki supports **both** authentication modes:
133
+
134
+ - **BYOK (bring your own keys):** add provider keys with `egaki login` per provider.
135
+ - **Egaki subscription:** use one `egaki_...` key to access all supported models without managing keys for each provider.
136
+
137
+ ```bash
138
+ # Subscribe and get a checkout URL
139
+ egaki subscribe --email user@example.com --plan pro
140
+
141
+ # Save your Egaki key after checkout
142
+ egaki login --provider egaki --key egaki_...
143
+
144
+ # BYOK example (direct provider key)
145
+ egaki login --provider google --key AIza...
146
+
147
+ # Check subscription usage / cancel
148
+ egaki usage
149
+ egaki unsubscribe
150
+ ```
151
+
152
+ ## Help
153
+
154
+ ```bash
155
+ egaki --help
156
+ egaki image --help
157
+ ```
158
+
159
+ ## Auth and billing
160
+
161
+ - `egaki login` stores provider keys in `~/.config/egaki/credentials.json`.
162
+ - `egaki subscribe`, `egaki usage`, and `egaki unsubscribe` manage Egaki plans.
163
+
164
+ ## License
165
+
166
+ MIT
@@ -0,0 +1,294 @@
1
+ // ChatGPT OAuth authentication for egaki.
2
+ // Implements the same OAuth 2.0 PKCE browser flow used by OpenAI Codex CLI.
3
+ // Tokens are stored inside credentials.json under the 'chatgpt' key as a
4
+ // structured object (not a plain string like other providers).
5
+ //
6
+ // The access token is a standard OpenAI bearer token that works with the
7
+ // Image API, so we can inject it as OPENAI_API_KEY for the SDK to pick up.
8
+ import { createServer } from 'node:http';
9
+ import { spawn } from 'node:child_process';
10
+ import { spinner, log, note } from '@clack/prompts';
11
+ import pc from 'picocolors';
12
+ const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
13
+ const ISSUER = 'https://auth.openai.com';
14
+ const OAUTH_PORT = 1455;
15
+ const REDIRECT_URI = `http://localhost:${OAUTH_PORT}/auth/callback`;
16
+ // ─── PKCE helpers ────────────────────────────────────────────────────────────
17
+ function randomString(length) {
18
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
19
+ const bytes = crypto.getRandomValues(new Uint8Array(length));
20
+ return Array.from(bytes)
21
+ .map((b) => chars[b % chars.length])
22
+ .join('');
23
+ }
24
+ function base64UrlEncode(buffer) {
25
+ const bytes = new Uint8Array(buffer);
26
+ const binary = String.fromCharCode(...bytes);
27
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
28
+ }
29
+ async function generatePKCE() {
30
+ const verifier = randomString(43);
31
+ const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
32
+ return { verifier, challenge: base64UrlEncode(hash) };
33
+ }
34
+ function generateState() {
35
+ return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer);
36
+ }
37
+ // ─── JWT parsing ─────────────────────────────────────────────────────────────
38
+ function parseJwtClaims(token) {
39
+ const parts = token.split('.');
40
+ if (parts.length !== 3)
41
+ return undefined;
42
+ const payload = parts[1];
43
+ if (!payload)
44
+ return undefined;
45
+ try {
46
+ return JSON.parse(Buffer.from(payload, 'base64url').toString());
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
52
+ function extractEmail(tokens) {
53
+ const idClaims = tokens.id_token ? parseJwtClaims(tokens.id_token) : undefined;
54
+ const accessClaims = parseJwtClaims(tokens.access_token);
55
+ return ((typeof idClaims?.email === 'string' ? idClaims.email : undefined) ??
56
+ (typeof accessClaims?.email === 'string' ? accessClaims.email : undefined));
57
+ }
58
+ function extractAccountId(tokens) {
59
+ const idClaims = tokens.id_token ? parseJwtClaims(tokens.id_token) : undefined;
60
+ const accessClaims = parseJwtClaims(tokens.access_token);
61
+ const idAuth = idClaims?.['https://api.openai.com/auth'];
62
+ const accessAuth = accessClaims?.['https://api.openai.com/auth'];
63
+ return ((typeof idClaims?.chatgpt_account_id === 'string' ? idClaims.chatgpt_account_id : undefined) ??
64
+ (typeof idAuth?.chatgpt_account_id === 'string' ? idAuth.chatgpt_account_id : undefined) ??
65
+ (typeof accessClaims?.chatgpt_account_id === 'string'
66
+ ? accessClaims.chatgpt_account_id
67
+ : undefined) ??
68
+ (typeof accessAuth?.chatgpt_account_id === 'string'
69
+ ? accessAuth.chatgpt_account_id
70
+ : undefined));
71
+ }
72
+ /** Extract the ChatGPT plan type (e.g. "plus", "pro") from the access token. */
73
+ export function extractPlanType(auth) {
74
+ const claims = parseJwtClaims(auth.access);
75
+ const authClaim = claims?.['https://api.openai.com/auth'];
76
+ return authClaim?.chatgpt_plan_type;
77
+ }
78
+ // ─── OAuth URL + token exchange ──────────────────────────────────────────────
79
+ function buildAuthorizeUrl(pkce, state) {
80
+ const params = new URLSearchParams({
81
+ response_type: 'code',
82
+ client_id: CLIENT_ID,
83
+ redirect_uri: REDIRECT_URI,
84
+ scope: 'openid profile email offline_access',
85
+ code_challenge: pkce.challenge,
86
+ code_challenge_method: 'S256',
87
+ id_token_add_organizations: 'true',
88
+ codex_cli_simplified_flow: 'true',
89
+ state,
90
+ originator: 'egaki',
91
+ });
92
+ return `${ISSUER}/oauth/authorize?${params.toString()}`;
93
+ }
94
+ async function exchangeCodeForTokens(code, verifier) {
95
+ const response = await fetch(`${ISSUER}/oauth/token`, {
96
+ method: 'POST',
97
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
98
+ body: new URLSearchParams({
99
+ grant_type: 'authorization_code',
100
+ code,
101
+ redirect_uri: REDIRECT_URI,
102
+ client_id: CLIENT_ID,
103
+ code_verifier: verifier,
104
+ }).toString(),
105
+ });
106
+ if (!response.ok) {
107
+ throw new Error(`Token exchange failed: ${response.status}`);
108
+ }
109
+ return (await response.json());
110
+ }
111
+ // ─── local callback server ───────────────────────────────────────────────────
112
+ const HTML_SUCCESS = `<!doctype html>
113
+ <html><body style="font-family:system-ui;text-align:center;padding:4em">
114
+ <h1>Authorization Successful</h1>
115
+ <p>You can close this window and return to the terminal.</p>
116
+ </body></html>`;
117
+ const htmlError = (error) => `<!doctype html><html><body style="font-family:system-ui;text-align:center;padding:4em">
118
+ <h1>Authorization Failed</h1><pre>${error}</pre></body></html>`;
119
+ let oauthServer;
120
+ let pendingOAuth;
121
+ function handleOAuthRequest(req, res) {
122
+ const url = new URL(req.url ?? '/', `http://localhost:${OAUTH_PORT}`);
123
+ if (url.pathname !== '/auth/callback') {
124
+ res.statusCode = 404;
125
+ res.end('Not found');
126
+ return;
127
+ }
128
+ const code = url.searchParams.get('code');
129
+ const state = url.searchParams.get('state');
130
+ const error = url.searchParams.get('error');
131
+ const errorDescription = url.searchParams.get('error_description');
132
+ if (error) {
133
+ const message = errorDescription || error;
134
+ pendingOAuth?.reject(new Error(message));
135
+ pendingOAuth = undefined;
136
+ res.statusCode = 400;
137
+ res.setHeader('Content-Type', 'text/html');
138
+ res.end(htmlError(message));
139
+ return;
140
+ }
141
+ if (!code) {
142
+ const message = 'Missing authorization code';
143
+ pendingOAuth?.reject(new Error(message));
144
+ pendingOAuth = undefined;
145
+ res.statusCode = 400;
146
+ res.setHeader('Content-Type', 'text/html');
147
+ res.end(htmlError(message));
148
+ return;
149
+ }
150
+ if (!pendingOAuth || state !== pendingOAuth.state) {
151
+ const message = 'Invalid state — potential CSRF attack';
152
+ pendingOAuth?.reject(new Error(message));
153
+ pendingOAuth = undefined;
154
+ res.statusCode = 400;
155
+ res.setHeader('Content-Type', 'text/html');
156
+ res.end(htmlError(message));
157
+ return;
158
+ }
159
+ const current = pendingOAuth;
160
+ pendingOAuth = undefined;
161
+ exchangeCodeForTokens(code, current.verifier)
162
+ .then((tokens) => current.resolve(tokens))
163
+ .catch((err) => current.reject(err instanceof Error ? err : new Error(String(err))));
164
+ res.statusCode = 200;
165
+ res.setHeader('Content-Type', 'text/html');
166
+ res.end(HTML_SUCCESS);
167
+ }
168
+ async function startOAuthServer() {
169
+ if (oauthServer)
170
+ return;
171
+ oauthServer = createServer(handleOAuthRequest);
172
+ await new Promise((resolve, reject) => {
173
+ oauthServer?.once('error', reject);
174
+ oauthServer?.listen(OAUTH_PORT, '127.0.0.1', () => resolve());
175
+ });
176
+ }
177
+ async function stopOAuthServer() {
178
+ if (!oauthServer)
179
+ return;
180
+ await new Promise((resolve) => oauthServer?.close(() => resolve()));
181
+ oauthServer = undefined;
182
+ }
183
+ function waitForOAuthCallback(verifier, state) {
184
+ return new Promise((resolve, reject) => {
185
+ const timeout = setTimeout(() => {
186
+ if (pendingOAuth) {
187
+ pendingOAuth = undefined;
188
+ reject(new Error('OAuth callback timeout — authorization took too long'));
189
+ }
190
+ }, 5 * 60 * 1000);
191
+ pendingOAuth = {
192
+ verifier,
193
+ state,
194
+ resolve: (tokens) => {
195
+ clearTimeout(timeout);
196
+ resolve(tokens);
197
+ },
198
+ reject: (error) => {
199
+ clearTimeout(timeout);
200
+ reject(error);
201
+ },
202
+ };
203
+ });
204
+ }
205
+ function openBrowser(url) {
206
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
207
+ const child = spawn(cmd, [url], { detached: true, stdio: 'ignore' });
208
+ child.unref();
209
+ }
210
+ // ─── public API ──────────────────────────────────────────────────────────────
211
+ /**
212
+ * Run the full ChatGPT OAuth browser flow with clack UI.
213
+ * Returns the ChatGptAuth object to be stored in credentials.json.
214
+ */
215
+ export async function chatGptOAuthLogin() {
216
+ await startOAuthServer();
217
+ const pkce = await generatePKCE();
218
+ const state = generateState();
219
+ const authUrl = buildAuthorizeUrl(pkce, state);
220
+ const callbackPromise = waitForOAuthCallback(pkce.verifier, state);
221
+ note(`Open this URL in your browser to sign in with your ChatGPT account:\n\n` +
222
+ ` ${pc.cyan(pc.underline(authUrl))}\n\n` +
223
+ `${pc.dim(`Listening for callback on ${REDIRECT_URI}`)}`, 'ChatGPT OAuth');
224
+ openBrowser(authUrl);
225
+ const s = spinner();
226
+ s.start('Waiting for authorization in browser...');
227
+ let tokens;
228
+ try {
229
+ tokens = await callbackPromise;
230
+ }
231
+ finally {
232
+ await stopOAuthServer();
233
+ }
234
+ s.stop('Authorization received');
235
+ const now = Date.now();
236
+ const auth = {
237
+ email: extractEmail(tokens),
238
+ accountId: extractAccountId(tokens),
239
+ refresh: tokens.refresh_token,
240
+ access: tokens.access_token,
241
+ expires: now + (tokens.expires_in ?? 3600) * 1000,
242
+ };
243
+ const plan = extractPlanType(auth);
244
+ const label = auth.email ?? auth.accountId ?? 'unknown';
245
+ log.success(`Signed in as ${pc.bold(label)}` + (plan ? ` (${pc.cyan(plan)} plan)` : ''));
246
+ return auth;
247
+ }
248
+ /**
249
+ * Refresh an expired ChatGPT access token using the refresh token.
250
+ * Returns the updated auth object, or an Error if refresh fails.
251
+ */
252
+ export async function refreshChatGptToken(auth) {
253
+ try {
254
+ const response = await fetch(`${ISSUER}/oauth/token`, {
255
+ method: 'POST',
256
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
257
+ body: new URLSearchParams({
258
+ grant_type: 'refresh_token',
259
+ refresh_token: auth.refresh,
260
+ client_id: CLIENT_ID,
261
+ }).toString(),
262
+ });
263
+ if (!response.ok) {
264
+ return new Error(`Token refresh failed: ${response.status}`);
265
+ }
266
+ const json = (await response.json());
267
+ return {
268
+ ...auth,
269
+ access: json.access_token,
270
+ refresh: json.refresh_token ?? auth.refresh,
271
+ expires: Date.now() + (json.expires_in ?? 3600) * 1000,
272
+ };
273
+ }
274
+ catch (err) {
275
+ return err instanceof Error ? err : new Error(String(err));
276
+ }
277
+ }
278
+ /**
279
+ * Get a valid access token from a ChatGptAuth object.
280
+ * Auto-refreshes if expired. Returns the token string or an Error.
281
+ * If the auth was refreshed, the `onRefresh` callback is called with
282
+ * the updated auth so the caller can persist it.
283
+ */
284
+ export async function getValidAccessToken(auth, onRefresh) {
285
+ // Token still valid (with 60s buffer)
286
+ if (auth.expires > Date.now() + 60_000) {
287
+ return auth.access;
288
+ }
289
+ const refreshed = await refreshChatGptToken(auth);
290
+ if (refreshed instanceof Error)
291
+ return refreshed;
292
+ onRefresh?.(refreshed);
293
+ return refreshed.access;
294
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}