egaki 0.3.0 → 0.4.1

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/src/cli.ts CHANGED
@@ -18,8 +18,15 @@ import {
18
18
  import fs from 'node:fs'
19
19
  import path from 'node:path'
20
20
  import pc from 'picocolors'
21
+ import { createParser, type EventSourceMessage } from 'eventsource-parser'
21
22
  import pkg from '../package.json' with { type: 'json' }
22
- import { injectCredentialsToEnv, PROVIDERS } from './credentials.js'
23
+ import {
24
+ injectCredentialsToEnv,
25
+ PROVIDERS,
26
+ getChatGptAuth,
27
+ saveChatGptAuth,
28
+ getKeyStatus,
29
+ } from './credentials.js'
23
30
  import {
24
31
  IMAGE_MODELS,
25
32
  VIDEO_MODELS,
@@ -29,6 +36,7 @@ import {
29
36
  createImageModel,
30
37
  createTextModel,
31
38
  createVideoModel,
39
+ shouldUseResponsesApi,
32
40
  } from './models.js'
33
41
  import { VIDEO_CATALOG } from './video-model-catalog.js'
34
42
  import {
@@ -44,6 +52,7 @@ import {
44
52
  unsubscribe,
45
53
  showUsage,
46
54
  } from './subscription.js'
55
+ import { getValidChatGptAuth } from './chatgpt-auth.js'
47
56
 
48
57
  const cli = goke('egaki')
49
58
 
@@ -118,8 +127,13 @@ cli
118
127
 
119
128
  // Non-interactive: --provider + --key or stdin
120
129
  if (options.provider) {
130
+ // ChatGPT uses browser OAuth — skip key reading
131
+ if (options.provider === 'chatgpt') {
132
+ await loginNonInteractive({ provider: options.provider, key: '' })
133
+ return
134
+ }
121
135
  const key = options.key || (await readKeyFromStdin())
122
- loginNonInteractive({ provider: options.provider, key })
136
+ await loginNonInteractive({ provider: options.provider, key })
123
137
  return
124
138
  }
125
139
 
@@ -291,23 +305,21 @@ cli
291
305
  .example('# Pipe to another tool')
292
306
  .example('egaki image "logo design" --stdout | convert - -resize 512x512 logo.png')
293
307
  .action(async (prompt, options) => {
294
- // Inject stored API keys as env vars before calling the AI SDK
308
+ // Inject stored API keys as env vars before calling the AI SDK.
295
309
  injectCredentialsToEnv()
296
310
 
297
- const model = options.model
298
- const outputPath = options.output
311
+ // goke infers schema .default() values via Zod's input type, which leaves
312
+ // options.model / options.output / options.count as `T | undefined` even
313
+ // though the runtime always resolves them. Apply the same defaults here.
314
+ const model = options.model ?? DEFAULT_MODEL
315
+ const outputPath = options.output ?? 'egaki-output.png'
316
+ const count = options.count ?? 1
299
317
  const config = getModelConfig(model)
318
+ const parsedAspectRatio = parseAspectRatioOption(options.aspectRatio)
300
319
 
301
320
  if (!options.stdout) {
302
321
  console.error(pc.dim(`Model: ${model}`))
303
322
  console.error(pc.dim(`Prompt: ${prompt}`))
304
- console.error(
305
- pc.dim(
306
- config.strategy === 'image'
307
- ? 'Mode: Image API (generateImage)'
308
- : 'Mode: Text+Image (generateText)',
309
- ),
310
- )
311
323
  }
312
324
 
313
325
  const inputImages = await readInputImages(options.input)
@@ -315,13 +327,34 @@ cli
315
327
  ? await readInputSource(options.mask)
316
328
  : undefined
317
329
 
318
- if (config.strategy === 'image') {
330
+ // When using ChatGPT OAuth with OpenAI image models, route through the
331
+ // Responses API + imageGeneration tool instead of the Image API.
332
+ // The Codex OAuth client lacks the `api.model.images.request` scope.
333
+ const useResponsesApi = config.strategy === 'image' && shouldUseResponsesApi(model)
334
+
335
+ if (useResponsesApi) {
336
+ if (!options.stdout) {
337
+ console.error(pc.dim('Mode: Responses API (ChatGPT OAuth)'))
338
+ }
339
+ await generateWithResponsesApi({
340
+ prompt,
341
+ model,
342
+ outputPath,
343
+ count,
344
+ aspectRatio: parsedAspectRatio,
345
+ seed: options.seed,
346
+ inputImages,
347
+ maskImage,
348
+ json: options.json || false,
349
+ stdout: options.stdout || false,
350
+ })
351
+ } else if (config.strategy === 'image') {
319
352
  await generateWithImageModel({
320
353
  prompt,
321
354
  model,
322
355
  outputPath,
323
- count: options.count,
324
- aspectRatio: options.aspectRatio as `${number}:${number}` | undefined,
356
+ count,
357
+ aspectRatio: parsedAspectRatio,
325
358
  seed: options.seed,
326
359
  inputImages,
327
360
  maskImage,
@@ -422,8 +455,10 @@ cli
422
455
  .action(async (prompt, options) => {
423
456
  injectCredentialsToEnv()
424
457
 
425
- const model = options.model
426
- const outputPath = options.output
458
+ // Apply the same .default(...) values goke's input-type inference drops.
459
+ const model = options.model ?? DEFAULT_VIDEO_MODEL
460
+ const outputPath = options.output ?? 'egaki-output.mp4'
461
+ const count = options.count ?? 1
427
462
  const config = getModelConfig(model)
428
463
 
429
464
  if (config.strategy !== 'video') {
@@ -445,7 +480,7 @@ cli
445
480
  prompt,
446
481
  model,
447
482
  outputPath,
448
- count: options.count,
483
+ count,
449
484
  aspectRatio: options.aspectRatio,
450
485
  resolution: options.resolution,
451
486
  duration: options.duration,
@@ -482,6 +517,9 @@ cli
482
517
  .action(async (options) => {
483
518
  const { CATALOG } = await import('./model-catalog.js')
484
519
  const yaml = await import('js-yaml')
520
+ const providerStatuses = Object.fromEntries(
521
+ Object.keys(PROVIDERS).map((provider) => [provider, getKeyStatus(provider)]),
522
+ )
485
523
 
486
524
  let models = options.type === 'video'
487
525
  ? VIDEO_CATALOG
@@ -502,6 +540,7 @@ cli
502
540
  name: m.name,
503
541
  ...(m.description ? { description: m.description } : {}),
504
542
  provider: m.provider,
543
+ auth: providerStatuses[m.provider] ?? { available: false, source: 'none' },
505
544
  strategy: m.strategy,
506
545
  released: m.released,
507
546
  cost: formatCatalogCost(m.cost),
@@ -782,6 +821,199 @@ async function generateWithTextModel({
782
821
  }
783
822
  }
784
823
 
824
+ // Generate using the OpenAI Responses API with imageGeneration tool.
825
+ // Used when auth comes from ChatGPT OAuth (no Image API scope).
826
+ async function generateWithResponsesApi({
827
+ prompt,
828
+ model,
829
+ outputPath,
830
+ count,
831
+ aspectRatio,
832
+ seed,
833
+ inputImages,
834
+ maskImage,
835
+ json,
836
+ stdout,
837
+ }: {
838
+ prompt: string
839
+ model: string
840
+ outputPath: string
841
+ count: number
842
+ aspectRatio?: `${number}:${number}`
843
+ seed?: number
844
+ inputImages: Uint8Array[]
845
+ maskImage?: Uint8Array
846
+ json: boolean
847
+ stdout: boolean
848
+ }) {
849
+ if (count !== 1) {
850
+ console.error(pc.red('ChatGPT image generation currently supports exactly one output image.'))
851
+ process.exit(1)
852
+ }
853
+
854
+ if (seed !== undefined) {
855
+ console.error(pc.red('ChatGPT image generation does not support --seed.'))
856
+ process.exit(1)
857
+ }
858
+
859
+ if (maskImage) {
860
+ console.error(pc.red('ChatGPT image generation does not support --mask yet.'))
861
+ process.exit(1)
862
+ }
863
+
864
+ const size = aspectRatio ? chatGptImageSizeFromAspectRatio(aspectRatio) : undefined
865
+ if (aspectRatio && !size) {
866
+ console.error(
867
+ pc.red(
868
+ 'ChatGPT image generation only supports --aspect-ratio 1:1, 3:2, or 2:3.',
869
+ ),
870
+ )
871
+ process.exit(1)
872
+ }
873
+
874
+ const storedAuth = getChatGptAuth()
875
+ if (!storedAuth?.accountId) {
876
+ console.error(pc.red('Missing ChatGPT account metadata. Please run `egaki login --provider chatgpt` again.'))
877
+ process.exit(1)
878
+ }
879
+
880
+ const auth = await getValidChatGptAuth(storedAuth, saveChatGptAuth)
881
+ if (auth instanceof Error) {
882
+ console.error(pc.red(auth.message))
883
+ process.exit(1)
884
+ }
885
+
886
+ if (!stdout) {
887
+ console.error(pc.cyan('Generating...'))
888
+ }
889
+
890
+ const content: Array<{ type: 'input_text'; text: string } | { type: 'input_image'; image_url: string }> = [
891
+ { type: 'input_text', text: prompt },
892
+ ...inputImages.map((image) => ({
893
+ type: 'input_image' as const,
894
+ image_url: imageBytesToDataUrl(image),
895
+ })),
896
+ ]
897
+
898
+ const response = await fetch('https://chatgpt.com/backend-api/codex/responses', {
899
+ method: 'POST',
900
+ headers: {
901
+ Authorization: `Bearer ${auth.access}`,
902
+ 'ChatGPT-Account-ID': auth.accountId,
903
+ 'Content-Type': 'application/json',
904
+ },
905
+ body: JSON.stringify({
906
+ model: 'gpt-5.4',
907
+ instructions: 'You are Codex.',
908
+ input: [
909
+ {
910
+ type: 'message',
911
+ role: 'user',
912
+ content,
913
+ },
914
+ ],
915
+ tools: [
916
+ {
917
+ type: 'image_generation',
918
+ model,
919
+ size: 'auto',
920
+ quality: 'auto',
921
+ output_format: 'png',
922
+ output_compression: 100,
923
+ moderation: 'auto',
924
+ ...(size ? { size } : {}),
925
+ },
926
+ ],
927
+ tool_choice: 'auto',
928
+ parallel_tool_calls: true,
929
+ stream: true,
930
+ store: false,
931
+ include: [],
932
+ }),
933
+ })
934
+
935
+ if (!response.ok || !response.body) {
936
+ const body = await response.text().catch(() => '')
937
+ console.error(pc.red(`ChatGPT image generation failed: ${response.status}`))
938
+ if (body) console.error(pc.dim(body))
939
+ process.exit(1)
940
+ }
941
+
942
+ let imageBase64: string | undefined
943
+ let revisedPrompt: string | null = null
944
+
945
+ const processSseMessage = (message: EventSourceMessage) => {
946
+ if (!message.data) return
947
+ try {
948
+ const event = JSON.parse(message.data) as {
949
+ type?: string
950
+ partial_image_b64?: string
951
+ item?: { type?: string; result?: string; revised_prompt?: string | null }
952
+ }
953
+ if (
954
+ event.type === 'response.image_generation_call.partial_image' &&
955
+ event.partial_image_b64 &&
956
+ !imageBase64
957
+ ) {
958
+ imageBase64 = event.partial_image_b64
959
+ }
960
+ if (
961
+ event.type === 'response.output_item.done' &&
962
+ event.item?.type === 'image_generation_call'
963
+ ) {
964
+ if (event.item.result) imageBase64 = event.item.result
965
+ revisedPrompt = event.item.revised_prompt ?? revisedPrompt
966
+ }
967
+ } catch {
968
+ // Ignore keepalive and partial parse noise.
969
+ }
970
+ }
971
+
972
+ const parser = createParser({
973
+ onEvent: processSseMessage,
974
+ })
975
+
976
+ const decoder = new TextDecoder()
977
+
978
+ for await (const chunk of response.body) {
979
+ parser.feed(decoder.decode(chunk, { stream: true }))
980
+ }
981
+ parser.feed(decoder.decode())
982
+
983
+ if (!imageBase64) {
984
+ console.error(pc.red('No images generated.'))
985
+ process.exit(1)
986
+ }
987
+
988
+ const imageBytes = Buffer.from(imageBase64, 'base64')
989
+ const mediaType = 'image/png'
990
+
991
+ if (stdout) {
992
+ process.stdout.write(imageBytes)
993
+ return
994
+ }
995
+
996
+ const savedFiles: string[] = []
997
+ const filePath = ensureExtension(outputPath, extensionFromMediaType(mediaType))
998
+ fs.writeFileSync(filePath, imageBytes)
999
+ console.error(pc.green(`Saved: ${filePath}`))
1000
+ savedFiles.push(filePath)
1001
+
1002
+ if (revisedPrompt && !json) {
1003
+ console.error(pc.dim(`Revised prompt: ${revisedPrompt}`))
1004
+ }
1005
+
1006
+ if (json) {
1007
+ const output = {
1008
+ model,
1009
+ files: savedFiles,
1010
+ count: 1,
1011
+ revisedPrompt,
1012
+ }
1013
+ console.log(JSON.stringify(output, null, 2))
1014
+ }
1015
+ }
1016
+
785
1017
  // Generate using experimental_generateVideo (video models)
786
1018
  async function generateWithVideoModel({
787
1019
  prompt,
@@ -983,6 +1215,49 @@ function extensionFromMediaType(mediaType: string): string {
983
1215
  return map[mediaType] || (mediaType.startsWith('video/') ? '.mp4' : '.png')
984
1216
  }
985
1217
 
1218
+ function inferImageMediaType(image: Uint8Array): string {
1219
+ if (image.length >= 8 && image[0] === 0x89 && image[1] === 0x50 && image[2] === 0x4e && image[3] === 0x47) {
1220
+ return 'image/png'
1221
+ }
1222
+ if (image.length >= 3 && image[0] === 0xff && image[1] === 0xd8 && image[2] === 0xff) {
1223
+ return 'image/jpeg'
1224
+ }
1225
+ if (
1226
+ image.length >= 12 &&
1227
+ image[0] === 0x52 && image[1] === 0x49 && image[2] === 0x46 && image[3] === 0x46 &&
1228
+ image[8] === 0x57 && image[9] === 0x45 && image[10] === 0x42 && image[11] === 0x50
1229
+ ) {
1230
+ return 'image/webp'
1231
+ }
1232
+ if (
1233
+ image.length >= 6 &&
1234
+ image[0] === 0x47 && image[1] === 0x49 && image[2] === 0x46 && image[3] === 0x38
1235
+ ) {
1236
+ return 'image/gif'
1237
+ }
1238
+ return 'image/png'
1239
+ }
1240
+
1241
+ function imageBytesToDataUrl(image: Uint8Array): string {
1242
+ const mediaType = inferImageMediaType(image)
1243
+ return `data:${mediaType};base64,${Buffer.from(image).toString('base64')}`
1244
+ }
1245
+
1246
+ function chatGptImageSizeFromAspectRatio(
1247
+ aspectRatio: `${number}:${number}`,
1248
+ ): '1024x1024' | '1536x1024' | '1024x1536' | undefined {
1249
+ switch (aspectRatio) {
1250
+ case '1:1':
1251
+ return '1024x1024'
1252
+ case '3:2':
1253
+ return '1536x1024'
1254
+ case '2:3':
1255
+ return '1024x1536'
1256
+ default:
1257
+ return undefined
1258
+ }
1259
+ }
1260
+
986
1261
  function normalizeResolutionKey(input?: string): string | undefined {
987
1262
  if (!input) return undefined
988
1263
  const normalized = input.trim().toLowerCase()
@@ -992,6 +1267,17 @@ function normalizeResolutionKey(input?: string): string | undefined {
992
1267
  return normalized
993
1268
  }
994
1269
 
1270
+ function isAspectRatio(input: string): input is `${number}:${number}` {
1271
+ return /^\d+:\d+$/.test(input)
1272
+ }
1273
+
1274
+ function parseAspectRatioOption(input?: string): `${number}:${number}` | undefined {
1275
+ if (!input || !isAspectRatio(input)) {
1276
+ return undefined
1277
+ }
1278
+ return input
1279
+ }
1280
+
995
1281
  function ensureExtension(filePath: string, ext: string): string {
996
1282
  const parsed = path.parse(filePath)
997
1283
  if (parsed.ext) {
@@ -1,22 +1,37 @@
1
1
  // Credential storage for egaki.
2
2
  // Reads and writes API keys from ~/.config/egaki/credentials.json.
3
3
  // Keys are stored per-provider and injected as env vars at runtime.
4
+ //
5
+ // Most providers store a plain API key string. The 'chatgpt' provider stores
6
+ // a structured ChatGptAuth object with OAuth tokens for the Codex backend flow.
4
7
  import fs from 'node:fs'
5
8
  import path from 'node:path'
6
9
  import os from 'node:os'
10
+ import type { ChatGptAuth } from './chatgpt-auth.js'
11
+
12
+ export type ProviderInfo = {
13
+ envVar: string
14
+ label: string
15
+ hint: string
16
+ /** If true, this provider uses OAuth instead of a plain API key */
17
+ oauth?: boolean
18
+ }
7
19
 
8
20
  // Maps provider names to their expected env var names.
9
21
  // When a key is stored, we set the corresponding env var before
10
22
  // calling the AI SDK so the provider picks it up automatically.
11
- export const PROVIDERS: Record<
12
- string,
13
- { envVar: string; label: string; hint: string }
14
- > = {
23
+ export const PROVIDERS: Record<string, ProviderInfo> = {
15
24
  egaki: {
16
25
  envVar: 'EGAKI_API_KEY',
17
26
  label: 'Egaki (all models, one subscription)',
18
27
  hint: 'Subscribe at https://egaki.org/buy or run: egaki subscribe',
19
28
  },
29
+ chatgpt: {
30
+ envVar: 'OPENAI_API_KEY',
31
+ label: 'ChatGPT (use your subscription)',
32
+ hint: 'Sign in with your ChatGPT account via browser',
33
+ oauth: true,
34
+ },
20
35
  google: {
21
36
  envVar: 'GOOGLE_GENERATIVE_AI_API_KEY',
22
37
  label: 'Google AI (Gemini, Imagen)',
@@ -29,7 +44,7 @@ export const PROVIDERS: Record<
29
44
  },
30
45
  openai: {
31
46
  envVar: 'OPENAI_API_KEY',
32
- label: 'OpenAI (DALL-E)',
47
+ label: 'OpenAI (API key)',
33
48
  hint: 'Get your key at https://platform.openai.com/api-keys',
34
49
  },
35
50
  replicate: {
@@ -57,7 +72,9 @@ function getCredentialsPath(): string {
57
72
  return path.join(getConfigDir(), 'credentials.json')
58
73
  }
59
74
 
60
- export type Credentials = Record<string, string>
75
+ /** A credential value is either a plain API key string or a ChatGPT OAuth object. */
76
+ export type CredentialValue = string | ChatGptAuth
77
+ export type Credentials = Record<string, CredentialValue>
61
78
 
62
79
  export function readCredentials(): Credentials {
63
80
  const filePath = getCredentialsPath()
@@ -83,6 +100,21 @@ export function saveProviderKey(provider: string, key: string): void {
83
100
  writeCredentials(creds)
84
101
  }
85
102
 
103
+ export function saveChatGptAuth(auth: ChatGptAuth): void {
104
+ const creds = readCredentials()
105
+ creds['chatgpt'] = auth
106
+ writeCredentials(creds)
107
+ }
108
+
109
+ export function getChatGptAuth(): ChatGptAuth | undefined {
110
+ const creds = readCredentials()
111
+ const val = creds['chatgpt']
112
+ if (val && typeof val === 'object') {
113
+ return val
114
+ }
115
+ return undefined
116
+ }
117
+
86
118
  export function removeProviderKey(provider: string): void {
87
119
  const creds = readCredentials()
88
120
  delete creds[provider]
@@ -91,35 +123,56 @@ export function removeProviderKey(provider: string): void {
91
123
 
92
124
  export function getProviderKey(provider: string): string | undefined {
93
125
  const creds = readCredentials()
94
- return creds[provider]
126
+ const val = creds[provider]
127
+ return typeof val === 'string' ? val : undefined
95
128
  }
96
129
 
97
- // Inject all stored credentials as env vars so the AI SDK
98
- // providers pick them up automatically. Env vars already set
99
- // by the user take precedence (we don't overwrite them).
130
+ // Inject all stored non-ChatGPT credentials as env vars so the AI SDK
131
+ // providers pick them up automatically. Env vars already set by the user take
132
+ // precedence (we don't overwrite them).
100
133
  export function injectCredentialsToEnv(): void {
101
134
  const creds = readCredentials()
102
- for (const [provider, key] of Object.entries(creds)) {
135
+ for (const [provider, value] of Object.entries(creds)) {
103
136
  const info = PROVIDERS[provider]
104
- if (!info) {
105
- continue
106
- }
107
- if (!process.env[info.envVar]) {
108
- process.env[info.envVar] = key
137
+ if (!info) continue
138
+
139
+ // Skip ChatGPT OAuth entries — handled separately
140
+ if (provider === 'chatgpt') continue
141
+
142
+ if (typeof value === 'string' && !process.env[info.envVar]) {
143
+ process.env[info.envVar] = value
109
144
  }
110
145
  }
111
146
  }
112
147
 
148
+ /**
149
+ * Returns true when the current run should use the ChatGPT/Codex backend flow
150
+ * for OpenAI image models instead of a direct OpenAI API key.
151
+ */
152
+ export function shouldUseChatGptBackend(): boolean {
153
+ return !process.env['OPENAI_API_KEY'] && Boolean(getChatGptAuth())
154
+ }
155
+
113
156
  // Check if a provider has a key available (from env or stored credentials).
114
157
  // Returns the source of the key for display purposes.
115
158
  export function getKeyStatus(provider: string): {
116
159
  available: boolean
117
- source: 'env' | 'stored' | 'none'
160
+ source: 'env' | 'stored' | 'oauth' | 'none'
118
161
  } {
119
162
  const info = PROVIDERS[provider]
120
163
  if (!info) {
121
164
  return { available: false, source: 'none' }
122
165
  }
166
+
167
+ // ChatGPT OAuth is a special case
168
+ if (provider === 'chatgpt') {
169
+ const auth = getChatGptAuth()
170
+ if (auth) {
171
+ return { available: true, source: 'oauth' }
172
+ }
173
+ return { available: false, source: 'none' }
174
+ }
175
+
123
176
  if (process.env[info.envVar]) {
124
177
  return { available: true, source: 'env' }
125
178
  }
package/src/login.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Login command — interactive and non-interactive API key management.
2
2
  // Interactive mode uses clack prompts for provider selection and key input.
3
3
  // Non-interactive mode supports --provider + --key flags and stdin piping.
4
+ // ChatGPT OAuth uses a browser flow instead of pasting a key.
4
5
  import {
5
6
  intro,
6
7
  outro,
@@ -17,8 +18,10 @@ import {
17
18
  saveProviderKey,
18
19
  removeProviderKey,
19
20
  getKeyStatus,
20
- readCredentials,
21
+ getChatGptAuth,
22
+ saveChatGptAuth,
21
23
  } from './credentials.js'
24
+ import { chatGptOAuthLogin, extractPlanType } from './chatgpt-auth.js'
22
25
 
23
26
  export async function loginInteractive(): Promise<void> {
24
27
  intro(pc.bold('egaki login'))
@@ -32,6 +35,11 @@ export async function loginInteractive(): Promise<void> {
32
35
  if (status.source === 'stored') {
33
36
  return pc.green('(saved)')
34
37
  }
38
+ if (status.source === 'oauth') {
39
+ const auth = getChatGptAuth()
40
+ const email = auth?.email
41
+ return pc.green(`(signed in${email ? ` as ${email}` : ''})`)
42
+ }
35
43
  return pc.dim('(not configured)')
36
44
  })()
37
45
  // Highlight the egaki option as recommended
@@ -61,6 +69,14 @@ export async function loginInteractive(): Promise<void> {
61
69
  process.exit(1)
62
70
  }
63
71
 
72
+ // ChatGPT uses browser OAuth instead of key paste
73
+ if (provider === 'chatgpt') {
74
+ const auth = await chatGptOAuthLogin()
75
+ saveChatGptAuth(auth)
76
+ outro('Done — ChatGPT OAuth saved')
77
+ return
78
+ }
79
+
64
80
  log.info(
65
81
  `${pc.dim('Env var:')} ${info.envVar} ${pc.dim('(also used by the CLI when set)')}`,
66
82
  )
@@ -91,13 +107,13 @@ export async function loginInteractive(): Promise<void> {
91
107
  outro('Done')
92
108
  }
93
109
 
94
- export function loginNonInteractive({
110
+ export async function loginNonInteractive({
95
111
  provider,
96
112
  key,
97
113
  }: {
98
114
  provider: string
99
115
  key: string
100
- }): void {
116
+ }): Promise<void> {
101
117
  const info = PROVIDERS[provider]
102
118
  if (!info) {
103
119
  const available = Object.keys(PROVIDERS).join(', ')
@@ -107,6 +123,14 @@ export function loginNonInteractive({
107
123
  process.exit(1)
108
124
  }
109
125
 
126
+ // ChatGPT uses browser OAuth — key flag is not needed
127
+ if (provider === 'chatgpt') {
128
+ const auth = await chatGptOAuthLogin()
129
+ saveChatGptAuth(auth)
130
+ console.log(pc.green('ChatGPT OAuth saved'))
131
+ return
132
+ }
133
+
110
134
  if (!key || key.trim().length === 0) {
111
135
  console.error(pc.red('API key cannot be empty'))
112
136
  process.exit(1)
@@ -121,6 +145,26 @@ export function showLoginStatus(): void {
121
145
 
122
146
  for (const [key, info] of Object.entries(PROVIDERS)) {
123
147
  const status = getKeyStatus(key)
148
+
149
+ if (key === 'chatgpt') {
150
+ const auth = getChatGptAuth()
151
+ if (auth) {
152
+ const plan = extractPlanType(auth)
153
+ const email = auth.email ?? auth.accountId ?? 'unknown'
154
+ const expired = auth.expires < Date.now()
155
+ const expiryLabel = expired
156
+ ? pc.yellow('(token expired, will auto-refresh)')
157
+ : pc.dim(`(token valid)`)
158
+ console.log(`${pc.green('*')} ${info.label} ${pc.green('(signed in)')}`)
159
+ console.log(pc.dim(` account: ${email}${plan ? `, plan: ${plan}` : ''}`))
160
+ console.log(` ${expiryLabel}`)
161
+ } else {
162
+ console.log(`${pc.dim('-')} ${info.label} ${pc.dim('(not signed in)')}`)
163
+ console.log(pc.dim(` run: egaki login → select ChatGPT`))
164
+ }
165
+ continue
166
+ }
167
+
124
168
  const icon = status.available ? pc.green('*') : pc.dim('-')
125
169
  const source = (() => {
126
170
  if (status.source === 'env') {
@@ -150,7 +194,8 @@ export function removeLogin(provider: string): void {
150
194
  }
151
195
 
152
196
  removeProviderKey(provider)
153
- console.log(pc.green(`${info.label} key removed`))
197
+ const label = provider === 'chatgpt' ? 'ChatGPT OAuth' : info.label
198
+ console.log(pc.green(`${label} key removed`))
154
199
  }
155
200
 
156
201
  // Read API key from stdin (for piping: echo "sk-xxx" | egaki login --provider google)