saeeol 1.0.2 → 1.0.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "name": "saeeol",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -5,141 +5,43 @@ import * as prompts from "@clack/prompts"
5
5
  import { UI } from "../ui"
6
6
  import { Global } from "@saeeol/core/global"
7
7
  import { Instance } from "../../project/instance"
8
- import { Auth } from "../../auth"
9
8
  import { AppRuntime } from "../../effect/app-runtime"
10
9
  import { Config } from "@/config/config"
11
10
  import { Effect } from "effect"
12
11
  import { put } from "./providers-auth"
12
+ import { ModelsDev } from "@/provider/models"
13
+ import { map, pipe, sortBy, values } from "remeda"
13
14
 
14
- const { TEXT_HIGHLIGHT: H, TEXT_NORMAL: N, TEXT_DIM: D, TEXT_SUCCESS: S, TEXT_NORMAL_BOLD: B, TEXT_WARNING: W } = UI.Style
15
+ const { TEXT_HIGHLIGHT: H, TEXT_NORMAL: N, TEXT_DIM: D, TEXT_SUCCESS: S, TEXT_NORMAL_BOLD: B } = UI.Style
15
16
 
16
- interface Preset {
17
- id: string
18
- label: string
19
- provider: string
20
- envVar: string
21
- models: Array<{ id: string; label: string }>
22
- hint: string
23
- defaultModel?: string
24
- }
17
+ const getModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get()))
18
+ const refreshModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.refresh(true)))
25
19
 
26
- const presets: Preset[] = [
27
- {
28
- id: "anthropic",
29
- label: "Anthropic (Claude)",
30
- provider: "anthropic",
31
- envVar: "ANTHROPIC_API_KEY",
32
- models: [
33
- { id: "anthropic/claude-sonnet-4", label: "Claude Sonnet 4 (추천)" },
34
- { id: "anthropic/claude-haiku-4-5", label: "Claude Haiku 4.5 (빠름/저렴)" },
35
- { id: "anthropic/claude-opus-4", label: "Claude Opus 4 (강력)" },
36
- ],
37
- hint: "https://console.anthropic.com/settings/keys",
38
- defaultModel: "anthropic/claude-sonnet-4",
39
- },
40
- {
41
- id: "openai",
42
- label: "OpenAI (GPT)",
43
- provider: "openai",
44
- envVar: "OPENAI_API_KEY",
45
- models: [
46
- { id: "openai/gpt-5", label: "GPT-5" },
47
- { id: "openai/gpt-5-mini", label: "GPT-5 Mini (빠름)" },
48
- { id: "openai/o3", label: "o3 (추론)" },
49
- ],
50
- hint: "https://platform.openai.com/api-keys",
51
- },
52
- {
53
- id: "google",
54
- label: "Google (Gemini)",
55
- provider: "google",
56
- envVar: "GOOGLE_GENERATIVE_AI_API_KEY",
57
- models: [
58
- { id: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro" },
59
- { id: "google/gemini-2.5-flash", label: "Gemini 2.5 Flash (빠름)" },
60
- ],
61
- hint: "https://aistudio.google.com/apikey",
62
- },
63
- {
64
- id: "openrouter",
65
- label: "OpenRouter (멀티 provider)",
66
- provider: "openrouter",
67
- envVar: "OPENROUTER_API_KEY",
68
- models: [
69
- { id: "openrouter/anthropic/claude-sonnet-4", label: "Claude Sonnet 4 via OpenRouter" },
70
- { id: "openrouter/openai/gpt-5", label: "GPT-5 via OpenRouter" },
71
- { id: "openrouter/google/gemini-2.5-pro", label: "Gemini 2.5 Pro via OpenRouter" },
72
- ],
73
- hint: "https://openrouter.ai/keys",
74
- },
75
- {
76
- id: "groq",
77
- label: "Groq (초고속 추론)",
78
- provider: "groq",
79
- envVar: "GROQ_API_KEY",
80
- models: [
81
- { id: "groq/llama-4-maverick-17b-128e-instruct", label: "Llama 4 Maverick" },
82
- ],
83
- hint: "https://console.groq.com/keys",
84
- },
85
- {
86
- id: "github-copilot",
87
- label: "GitHub Copilot (무료)",
88
- provider: "github-copilot",
89
- envVar: "",
90
- models: [
91
- { id: "github-copilot/gpt-5", label: "GPT-5 via Copilot" },
92
- { id: "github-copilot/claude-sonnet-4", label: "Claude Sonnet 4 via Copilot" },
93
- ],
94
- hint: "GitHub 계정으로 OAuth 로그인",
95
- },
96
- {
97
- id: "bedrock",
98
- label: "Amazon Bedrock",
99
- provider: "amazon-bedrock",
100
- envVar: "AWS_ACCESS_KEY_ID",
101
- models: [
102
- { id: "amazon-bedrock/anthropic.claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
103
- ],
104
- hint: "AWS 자격 증명 필요",
105
- },
106
- {
107
- id: "custom",
108
- label: "직접 입력 (OpenAI 호환)",
109
- provider: "",
110
- envVar: "",
111
- models: [],
112
- hint: "",
113
- },
114
- ]
20
+ type ProviderInfo = { id: string; name: string; env: string[]; models: Record<string, unknown> }
115
21
 
116
22
  export const InitCommand = cmd({
117
23
  command: "init",
118
24
  describe: "최초 설정 — provider, API key, 모델 선택",
119
25
  builder: (y) =>
120
26
  y
121
- .option("preset", {
27
+ .option("provider", {
122
28
  type: "string",
123
- describe: "프리셋 ID (anthropic, openai, google, openrouter, groq, github-copilot, bedrock, custom)",
124
- choices: presets.map((p) => p.id),
29
+ alias: "p",
30
+ describe: "provider id (예: anthropic, openai, google)",
125
31
  })
126
32
  .option("api-key", {
127
33
  type: "string",
128
- describe: "API key (인터랙티브 입력 스킵)",
34
+ alias: "k",
35
+ describe: "API key",
129
36
  })
130
37
  .option("model", {
131
38
  type: "string",
39
+ alias: "m",
132
40
  describe: "기본 모델 (예: anthropic/claude-sonnet-4)",
133
41
  })
134
42
  .option("base-url", {
135
43
  type: "string",
136
- describe: "커스텀 API base URL (custom 프리셋용)",
137
- })
138
- .option("yes", {
139
- type: "boolean",
140
- alias: "y",
141
- describe: "기본값으로 자동 진행",
142
- default: false,
44
+ describe: "커스텀 API base URL (OpenAI 호환 엔드포인트용)",
143
45
  }),
144
46
  handler: async (args) => {
145
47
  await Instance.provide({
@@ -151,36 +53,90 @@ export const InitCommand = cmd({
151
53
  prompts.intro(`${B}SAEEOL 초기 설정${N}`)
152
54
  UI.empty()
153
55
 
154
- // 1. Preset 선택
155
- const preset = resolvePreset(args.preset)
156
- const selected = preset ?? (await promptPreset())
157
- if (!selected) return
56
+ const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
158
57
 
159
- // 2. API Key
160
- if (selected.id === "github-copilot") {
161
- prompts.log.info("GitHub Copilot은 OAuth 로그인이 필요합니다.")
162
- prompts.log.info("나중에 ${H}saeeol auth login -p github-copilot${N} 으로 로그인하세요.")
163
- } else if (selected.id === "custom") {
164
- await handleCustom(args)
165
- return
166
- } else if (selected.envVar) {
167
- const existingKey = process.env[selected.envVar]
168
- if (existingKey) {
169
- prompts.log.info(`${D}${selected.envVar} 환경변수가 이미 설정되어 있습니다.${N}`)
170
- } else {
171
- const key = args["api-key"] ?? (await promptApiKey(selected))
172
- if (!key) return
173
- await put(selected.provider, { type: "api", key })
174
- prompts.log.success("API key 저장 완료")
58
+ // 1. Provider 목록 동적 로드
59
+ const s = prompts.spinner()
60
+ s.start("provider 목록 로드 중...")
61
+ await refreshModels().catch(() => {})
62
+ const database = await getModels()
63
+ s.stop(`${Object.keys(database).length}개 provider 로드 완료`)
64
+
65
+ // 2. Provider 선택
66
+ const disabled = new Set(config.disabled_providers ?? [])
67
+ const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
68
+ const providers = Object.entries(database)
69
+ .filter(([key]) => (enabled ? enabled.has(key) : true) && !disabled.has(key))
70
+ .map(([id, info]) => ({ id, name: info.name || id, env: info.env, models: info.models || {} } as ProviderInfo))
71
+
72
+ const priority: Record<string, number> = {
73
+ anthropic: 0, openai: 1, google: 2, "github-copilot": 3, openrouter: 4, groq: 5,
74
+ "amazon-bedrock": 6, azure: 7, mistral: 8, deepinfra: 9, xai: 10, cerebras: 11,
75
+ }
76
+
77
+ let providerId: string
78
+ if (args.provider) {
79
+ providerId = args.provider
80
+ const found = providers.find((p) => p.id === providerId)
81
+ if (!found) {
82
+ // 커스텀 provider — base-url 필요
83
+ if (!args["base-url"]) {
84
+ prompts.log.warn(`"${providerId}"은(는) 알려진 provider가 아닙니다. --base-url이 필요합니다.`)
85
+ return
86
+ }
175
87
  }
88
+ } else {
89
+ const sorted = pipe(
90
+ providers,
91
+ sortBy((x) => priority[x.id] ?? 99, (x) => x.name),
92
+ )
93
+ const selected = await prompts.select({
94
+ message: "사용할 AI Provider를 선택하세요",
95
+ maxItems: 12,
96
+ options: [
97
+ ...sorted.map((p) => ({ label: p.name, value: p.id })),
98
+ { label: "직접 입력 (OpenAI 호환)", value: "__custom__" },
99
+ ],
100
+ })
101
+ if (prompts.isCancel(selected)) throw new UI.CancelledError()
102
+ providerId = selected as string
103
+ }
104
+
105
+ // 3. 커스텀 엔드포인트
106
+ if (providerId === "__custom__") {
107
+ await handleCustom(args, database)
108
+ return
176
109
  }
177
110
 
178
- // 3. 모델 선택
179
- const model = args.model ?? (await promptModel(selected))
111
+ // 4. API Key 입력
112
+ const providerInfo = providers.find((p) => p.id === providerId)
113
+ const envVar = providerInfo?.env?.[0]
114
+ const existingKey = envVar ? process.env[envVar] : undefined
115
+
116
+ if (existingKey) {
117
+ prompts.log.info(`${D}${envVar} 환경변수가 이미 설정되어 있습니다.${N}`)
118
+ } else if (providerId === "github-copilot") {
119
+ prompts.log.info("GitHub Copilot은 OAuth 로그인이 필요합니다.")
120
+ prompts.log.info(` ${H}saeeol auth login -p github-copilot${N} 으로 로그인하세요.`)
121
+ } else {
122
+ const hint = providerHint(providerId)
123
+ if (hint) prompts.log.info(`${D}API key 발급: ${hint}${N}`)
124
+
125
+ const key = args["api-key"] ?? (await prompts.password({
126
+ message: `${providerInfo?.name ?? providerId} API Key`,
127
+ validate: (x) => (x && x.length > 0 ? undefined : "필수 입력입니다"),
128
+ }))
129
+ if (prompts.isCancel(key)) throw new UI.CancelledError()
130
+ await put(providerId, { type: "api", key })
131
+ prompts.log.success("API key 저장 완료")
132
+ }
133
+
134
+ // 5. 모델 목록 동적 조회
135
+ const model = args.model ?? (await pickModel(providerId, database))
180
136
  if (!model) return
181
137
 
182
- // 4. 설정 파일 저장
183
- await saveConfig(selected, model, args["base-url"])
138
+ // 6. 설정 저장
139
+ await saveConfigFile({ $schema: "https://app.saeeol.ai/config.json", model })
184
140
 
185
141
  UI.empty()
186
142
  prompts.outro(`${S}설정 완료!${N}`)
@@ -194,111 +150,137 @@ export const InitCommand = cmd({
194
150
  },
195
151
  })
196
152
 
197
- function logo(): string {
198
- const lines = [
199
- " ██████╗ █████╗ ███████╗███████╗██╗ ██╗",
200
- " ██╔══██╗██╔══██╗██╔════╝██╔════╝██║ ██╔╝",
201
- " ██████╔╝███████║███████╗███████╗█████╔╝ ",
202
- " ██╔═══╝ ██╔══██║╚════██║╚════██║██╔═██╗ ",
203
- " ██║ ██║ ██║███████║███████║██║ ██╗",
204
- " ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝",
205
- ]
206
- return lines.map((l) => `${H}${l}${N}`).join("\n")
207
- }
208
-
209
- function resolvePreset(id?: string): Preset | undefined {
210
- if (!id) return
211
- return presets.find((p) => p.id === id)
212
- }
153
+ async function pickModel(
154
+ providerId: string,
155
+ database: Record<string, any>,
156
+ ): Promise<string | undefined> {
157
+ const provider = database[providerId]
158
+ if (!provider?.models) {
159
+ prompts.log.warn("해당 provider의 모델 목록을 가져올 수 없습니다.")
160
+ return `${providerId}/default`
161
+ }
213
162
 
214
- async function promptPreset(): Promise<Preset | undefined> {
215
- const selected = await prompts.select({
216
- message: "사용할 AI Provider를 선택하세요",
217
- options: presets.map((p) => ({
218
- label: p.label,
219
- value: p.id,
220
- hint: p.id === "custom" ? "OpenAI 호환 API" : p.provider,
221
- })),
222
- })
223
- if (prompts.isCancel(selected)) throw new UI.CancelledError()
224
- return presets.find((p) => p.id === selected)
225
- }
163
+ const models = Object.entries(provider.models as Record<string, any>)
164
+ .filter(([, m]) => m.status !== "deprecated" && m.status !== "alpha")
165
+ .map(([id, m]) => ({
166
+ id: `${providerId}/${id}`,
167
+ name: m.name || id,
168
+ context: m.limit?.context,
169
+ }))
226
170
 
227
- async function promptApiKey(preset: Preset): Promise<string | undefined> {
228
- prompts.log.info(`${D}API key 발급: ${preset.hint}${N}`)
229
- const key = await prompts.password({
230
- message: `${preset.label} API Key를 입력하세요`,
231
- validate: (x) => (x && x.length > 0 ? undefined : "필수 입력입니다"),
232
- })
233
- if (prompts.isCancel(key)) throw new UI.CancelledError()
234
- return key
235
- }
171
+ if (models.length === 0) {
172
+ prompts.log.warn("사용 가능한 모델이 없습니다.")
173
+ return `${providerId}/default`
174
+ }
236
175
 
237
- async function promptModel(preset: Preset): Promise<string | undefined> {
238
- if (preset.models.length === 0) return
239
- if (preset.models.length === 1 && preset.defaultModel) return preset.defaultModel
176
+ // 컨텍스트 길이 기준 내림차순 정렬 ( 보통 강력)
177
+ models.sort((a, b) => (b.context ?? 0) - (a.context ?? 0))
240
178
 
241
179
  const selected = await prompts.select({
242
180
  message: "기본 모델을 선택하세요",
243
- options: preset.models.map((m) => ({
244
- label: m.label,
181
+ maxItems: 10,
182
+ options: models.map((m) => ({
183
+ label: m.name,
245
184
  value: m.id,
185
+ hint: m.context ? `${(m.context / 1000).toFixed(0)}K ctx` : undefined,
246
186
  })),
247
187
  })
248
188
  if (prompts.isCancel(selected)) throw new UI.CancelledError()
249
189
  return selected as string
250
190
  }
251
191
 
252
- async function handleCustom(args: Record<string, unknown>) {
192
+ async function handleCustom(args: Record<string, unknown>, database: Record<string, any>) {
253
193
  const baseUrl = (args["base-url"] as string) ?? (await prompts.text({
254
- message: "API Base URL을 입력하세요",
194
+ message: "API Base URL",
255
195
  placeholder: "https://api.example.com/v1",
256
- validate: (x) => (x && x.startsWith("http") ? undefined : "http:// 또는 https:// 로 시작해야 합니다"),
196
+ validate: (x) => (x && x.startsWith("http") ? undefined : "http(s)://로 시작해야 합니다"),
257
197
  }))
258
198
  if (prompts.isCancel(baseUrl)) throw new UI.CancelledError()
259
199
 
260
200
  const key = (args["api-key"] as string) ?? (await prompts.password({
261
- message: "API Key를 입력하세요",
201
+ message: "API Key",
262
202
  validate: (x) => (x && x.length > 0 ? undefined : "필수 입력입니다"),
263
203
  }))
264
204
  if (prompts.isCancel(key)) throw new UI.CancelledError()
265
205
 
266
- const modelId = (args.model as string) ?? (await prompts.text({
267
- message: "기본 모델 ID를 입력하세요",
268
- placeholder: "custom/my-model",
269
- validate: (x) => (x && x.length > 0 ? undefined : "필수 입력입니다"),
270
- }))
271
- if (prompts.isCancel(modelId)) throw new UI.CancelledError()
206
+ // /v1/models 호출로 동적 모델 조회
207
+ let modelId: string | undefined
208
+ try {
209
+ const resp = await fetch(`${baseUrl.replace(/\/+$/, "")}/models`, {
210
+ headers: { Authorization: `Bearer ${key}` },
211
+ signal: AbortSignal.timeout(10000),
212
+ })
213
+ if (resp.ok) {
214
+ const body = (await resp.json()) as { data?: Array<{ id: string }> }
215
+ const remoteModels = body.data ?? []
216
+ if (remoteModels.length > 0) {
217
+ const selected = await prompts.select({
218
+ message: `${remoteModels.length}개 모델 발견. 기본 모델 선택`,
219
+ maxItems: 10,
220
+ options: remoteModels.slice(0, 50).map((m) => ({ label: m.id, value: m.id })),
221
+ })
222
+ if (!prompts.isCancel(selected)) modelId = selected as string
223
+ }
224
+ }
225
+ } catch {
226
+ // /models 엔드포인트 없으면 수동 입력
227
+ }
272
228
 
273
- const providerId = modelId.split("/")[0] || "custom"
274
- await put(providerId, { type: "api", key })
229
+ if (!modelId) {
230
+ modelId = (args.model as string) ?? (await prompts.text({
231
+ message: "모델 ID",
232
+ placeholder: "gpt-4o",
233
+ validate: (x) => (x && x.length > 0 ? undefined : "필수 입력입니다"),
234
+ })) as string
235
+ if (prompts.isCancel(modelId)) throw new UI.CancelledError()
236
+ }
237
+
238
+ const pid = `custom-${Date.now().toString(36)}`
239
+ await put(pid, { type: "api", key })
275
240
 
276
- const config = buildConfigObj(modelId, { [providerId]: { api: baseUrl } })
277
- await saveConfigFile(config)
241
+ await saveConfigFile({
242
+ $schema: "https://app.saeeol.ai/config.json",
243
+ model: `${pid}/${modelId}`,
244
+ provider: {
245
+ [pid]: {
246
+ name: "Custom",
247
+ npm: "@ai-sdk/openai-compatible",
248
+ api: baseUrl,
249
+ },
250
+ },
251
+ })
278
252
 
279
253
  UI.empty()
280
254
  prompts.outro(`${S}설정 완료!${N}`)
281
255
  }
282
256
 
283
- function buildConfigObj(model: string, provider?: Record<string, unknown>) {
284
- const obj: Record<string, unknown> = {
285
- $schema: "https://app.saeeol.ai/config.json",
286
- model,
287
- }
288
- if (provider && Object.keys(provider).length > 0) {
289
- obj.provider = provider
257
+ function providerHint(id: string): string | undefined {
258
+ const hints: Record<string, string> = {
259
+ anthropic: "https://console.anthropic.com/settings/keys",
260
+ openai: "https://platform.openai.com/api-keys",
261
+ google: "https://aistudio.google.com/apikey",
262
+ openrouter: "https://openrouter.ai/keys",
263
+ groq: "https://console.groq.com/keys",
264
+ "amazon-bedrock": "AWS 자격 증명 필요",
265
+ azure: "Azure OpenAI 리소스 필요",
266
+ mistral: "https://console.mistral.ai/api-keys",
267
+ deepinfra: "https://deepinfra.com/dash/api_keys",
268
+ xai: "https://console.x.ai",
269
+ cerebras: "https://cloud.cerebras.ai",
290
270
  }
291
- return obj
271
+ return hints[id]
292
272
  }
293
273
 
294
- async function saveConfig(preset: Preset, model: string, baseUrl?: string) {
295
- const provider: Record<string, unknown> = {}
296
- if (baseUrl) {
297
- const pid = model.split("/")[0] || preset.provider
298
- provider[pid] = { api: baseUrl }
299
- }
300
- const config = buildConfigObj(model, provider)
301
- await saveConfigFile(config)
274
+ function logo(): string {
275
+ const lines = [
276
+ " ██████╗ █████╗ ███████╗███████╗██╗ ██╗",
277
+ " ██╔══██╗██╔══██╗██╔════╝██╔════╝██║ ██╔╝",
278
+ " ██████╔╝███████║███████╗███████╗█████╔╝ ",
279
+ " ██╔═══╝ ██╔══██║╚════██║╚════██║██╔═██╗ ",
280
+ " ██║ ██║ ██║███████║███████║██║ ██╗",
281
+ " ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝",
282
+ ]
283
+ return lines.map((l) => `${H}${l}${N}`).join("\n")
302
284
  }
303
285
 
304
286
  async function saveConfigFile(config: Record<string, unknown>) {
package/src/index.ts CHANGED
@@ -58,6 +58,7 @@ import { JsonMigration } from "@/storage/json-migration"
58
58
  import { Database } from "@/storage/db"
59
59
  import { errorMessage } from "./util/error"
60
60
  import { PluginCommand } from "./cli/cmd/plug"
61
+ import { InitCommand } from "./cli/cmd/init"
61
62
  import { Heap } from "./cli/heap"
62
63
  import { drizzle } from "drizzle-orm/bun-sqlite"
63
64
  import { ensureProcessMetadata } from "@saeeol/core/util/saeeol-process"
@@ -232,6 +233,7 @@ let cli = yargs(args)
232
233
  .command(RemoteCommand)
233
234
  .command(ConfigCLICommand)
234
235
  .command(PluginCommand)
236
+ .command(InitCommand)
235
237
  .command(DbCommand)
236
238
  if (InstallationBuildKind !== "release") {
237
239
  cli = cli.command(DevSetupCommand).command(DevAliasCommand)