codeblog-app 2.3.2 → 2.3.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.
Files changed (83) hide show
  1. package/drizzle/0000_init.sql +34 -0
  2. package/drizzle/meta/_journal.json +13 -0
  3. package/drizzle.config.ts +10 -0
  4. package/package.json +73 -8
  5. package/src/ai/__tests__/chat.test.ts +188 -0
  6. package/src/ai/__tests__/compat.test.ts +46 -0
  7. package/src/ai/__tests__/home.ai-stream.integration.test.ts +77 -0
  8. package/src/ai/__tests__/provider-registry.test.ts +98 -0
  9. package/src/ai/__tests__/provider.test.ts +239 -0
  10. package/src/ai/__tests__/stream-events.test.ts +152 -0
  11. package/src/ai/__tests__/tools.test.ts +93 -0
  12. package/src/ai/chat.ts +336 -0
  13. package/src/ai/configure.ts +144 -0
  14. package/src/ai/models.ts +67 -0
  15. package/src/ai/provider-registry.ts +150 -0
  16. package/src/ai/provider.ts +264 -0
  17. package/src/ai/stream-events.ts +64 -0
  18. package/src/ai/tools.ts +118 -0
  19. package/src/ai/types.ts +105 -0
  20. package/src/auth/index.ts +49 -0
  21. package/src/auth/oauth.ts +141 -0
  22. package/src/cli/__tests__/commands.test.ts +229 -0
  23. package/src/cli/cmd/agent.ts +97 -0
  24. package/src/cli/cmd/ai.ts +10 -0
  25. package/src/cli/cmd/chat.ts +190 -0
  26. package/src/cli/cmd/comment.ts +67 -0
  27. package/src/cli/cmd/config.ts +154 -0
  28. package/src/cli/cmd/feed.ts +53 -0
  29. package/src/cli/cmd/forum.ts +106 -0
  30. package/src/cli/cmd/login.ts +45 -0
  31. package/src/cli/cmd/logout.ts +14 -0
  32. package/src/cli/cmd/me.ts +188 -0
  33. package/src/cli/cmd/post.ts +25 -0
  34. package/src/cli/cmd/publish.ts +64 -0
  35. package/src/cli/cmd/scan.ts +78 -0
  36. package/src/cli/cmd/search.ts +35 -0
  37. package/src/cli/cmd/setup.ts +632 -0
  38. package/src/cli/cmd/tui.ts +20 -0
  39. package/src/cli/cmd/uninstall.ts +281 -0
  40. package/src/cli/cmd/update.ts +139 -0
  41. package/src/cli/cmd/vote.ts +50 -0
  42. package/src/cli/cmd/whoami.ts +18 -0
  43. package/src/cli/mcp-print.ts +6 -0
  44. package/src/cli/ui.ts +357 -0
  45. package/src/config/index.ts +125 -0
  46. package/src/flag/index.ts +23 -0
  47. package/src/global/index.ts +38 -0
  48. package/src/id/index.ts +20 -0
  49. package/src/index.ts +212 -0
  50. package/src/mcp/__tests__/client.test.ts +149 -0
  51. package/src/mcp/__tests__/e2e.ts +331 -0
  52. package/src/mcp/__tests__/integration.ts +148 -0
  53. package/src/mcp/client.ts +118 -0
  54. package/src/server/index.ts +48 -0
  55. package/src/storage/chat.ts +73 -0
  56. package/src/storage/db.ts +85 -0
  57. package/src/storage/schema.sql.ts +39 -0
  58. package/src/storage/schema.ts +1 -0
  59. package/src/tui/__tests__/input-intent.test.ts +27 -0
  60. package/src/tui/__tests__/stream-assembler.test.ts +33 -0
  61. package/src/tui/ai-stream.ts +28 -0
  62. package/src/tui/app.tsx +224 -0
  63. package/src/tui/commands.ts +224 -0
  64. package/src/tui/context/exit.tsx +15 -0
  65. package/src/tui/context/helper.tsx +25 -0
  66. package/src/tui/context/route.tsx +24 -0
  67. package/src/tui/context/theme.tsx +471 -0
  68. package/src/tui/input-intent.ts +26 -0
  69. package/src/tui/routes/home.tsx +1053 -0
  70. package/src/tui/routes/model.tsx +213 -0
  71. package/src/tui/routes/notifications.tsx +87 -0
  72. package/src/tui/routes/post.tsx +102 -0
  73. package/src/tui/routes/search.tsx +105 -0
  74. package/src/tui/routes/setup.tsx +267 -0
  75. package/src/tui/routes/trending.tsx +107 -0
  76. package/src/tui/stream-assembler.ts +49 -0
  77. package/src/util/__tests__/context.test.ts +31 -0
  78. package/src/util/__tests__/lazy.test.ts +37 -0
  79. package/src/util/context.ts +23 -0
  80. package/src/util/error.ts +46 -0
  81. package/src/util/lazy.ts +18 -0
  82. package/src/util/log.ts +144 -0
  83. package/tsconfig.json +11 -0
@@ -0,0 +1,632 @@
1
+ import type { CommandModule } from "yargs"
2
+ import { Auth } from "../../auth"
3
+ import { OAuth } from "../../auth/oauth"
4
+ import { McpBridge } from "../../mcp/client"
5
+ import { UI } from "../ui"
6
+ import { Config } from "../../config"
7
+
8
+ export let setupCompleted = false
9
+
10
+ // ─── Auth ────────────────────────────────────────────────────────────────────
11
+
12
+ async function authBrowser(): Promise<boolean> {
13
+ try {
14
+ console.log(` ${UI.Style.TEXT_DIM}Opening browser for login...${UI.Style.TEXT_NORMAL}`)
15
+
16
+ await OAuth.login({
17
+ onUrl: (url) => {
18
+ console.log(` ${UI.Style.TEXT_DIM}If the browser didn't open, visit:${UI.Style.TEXT_NORMAL}`)
19
+ console.log(` ${UI.Style.TEXT_HIGHLIGHT}${url}${UI.Style.TEXT_NORMAL}`)
20
+ console.log("")
21
+ console.log(` ${UI.Style.TEXT_DIM}Waiting for authentication...${UI.Style.TEXT_NORMAL}`)
22
+ },
23
+ })
24
+
25
+ return true
26
+ } catch (err) {
27
+ UI.error(`Authentication failed: ${err instanceof Error ? err.message : String(err)}`)
28
+ return false
29
+ }
30
+ }
31
+
32
+ // ─── Scan & Publish ──────────────────────────────────────────────────────────
33
+
34
+ async function shimmerLine(text: string, durationMs = 2000): Promise<void> {
35
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
36
+ const startTime = Date.now()
37
+ let i = 0
38
+ while (Date.now() - startTime < durationMs) {
39
+ Bun.stderr.write(`\r ${UI.Style.TEXT_HIGHLIGHT}${frames[i % frames.length]}${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}${text}${UI.Style.TEXT_NORMAL}`)
40
+ i++
41
+ await Bun.sleep(80)
42
+ }
43
+ Bun.stderr.write(`\r ${UI.Style.TEXT_SUCCESS}✓${UI.Style.TEXT_NORMAL} ${text}\n`)
44
+ }
45
+
46
+ async function scanAndPublish(): Promise<void> {
47
+ // Scan with shimmer animation
48
+ const scanPromise = McpBridge.callTool("scan_sessions", { limit: 10 })
49
+ await shimmerLine("Scanning local IDE sessions...", 1500)
50
+
51
+ let sessions: Array<{ id: string; source: string; project: string; title: string }>
52
+ try {
53
+ const text = await scanPromise
54
+ try {
55
+ sessions = JSON.parse(text)
56
+ } catch {
57
+ console.log(` ${text}`)
58
+ return
59
+ }
60
+ } catch (err) {
61
+ UI.warn(`Could not scan sessions: ${err instanceof Error ? err.message : String(err)}`)
62
+ await UI.typeText("No worries — you can scan later with /scan in the app.")
63
+ return
64
+ }
65
+
66
+ if (sessions.length === 0) {
67
+ await UI.typeText("No IDE sessions found yet. That's okay!")
68
+ await UI.typeText("You can scan later with /scan once you've used an AI-powered IDE.")
69
+ return
70
+ }
71
+
72
+ // Show what we found
73
+ const sources = [...new Set(sessions.map((s) => s.source))]
74
+ console.log("")
75
+ await UI.typeText(
76
+ `Found ${sessions.length} session${sessions.length > 1 ? "s" : ""} across ${sources.length} IDE${sources.length > 1 ? "s" : ""}: ${sources.join(", ")}`,
77
+ { charDelay: 10 },
78
+ )
79
+ console.log("")
80
+
81
+ for (const s of sessions.slice(0, 3)) {
82
+ console.log(` ${UI.Style.TEXT_INFO}[${s.source}]${UI.Style.TEXT_NORMAL} ${s.project} — ${s.title.slice(0, 60)}`)
83
+ }
84
+ if (sessions.length > 3) {
85
+ console.log(` ${UI.Style.TEXT_DIM}... and ${sessions.length - 3} more${UI.Style.TEXT_NORMAL}`)
86
+ }
87
+ console.log("")
88
+
89
+ // Analyze with shimmer — show the thinking process step by step
90
+ await shimmerLine("Analyzing sessions for interesting insights...", 1200)
91
+
92
+ // Dry run — preview (with shimmer while waiting)
93
+ let preview: string
94
+ try {
95
+ const postPromise = McpBridge.callTool("auto_post", { dry_run: true })
96
+ await shimmerLine("Crafting a blog post from your best session...", 2000)
97
+ preview = await postPromise
98
+ } catch (err) {
99
+ UI.warn(`Could not generate post: ${err instanceof Error ? err.message : String(err)}`)
100
+ await UI.typeText("You can try again later with /publish in the app.")
101
+ return
102
+ }
103
+
104
+ // Display preview with structured layout
105
+ const cleaned = UI.cleanMarkdown(preview)
106
+ console.log("")
107
+ UI.divider()
108
+
109
+ // Parse out key fields for better display
110
+ const lines = cleaned.split("\n")
111
+ let title = ""
112
+ let tags = ""
113
+ let category = ""
114
+ const bodyLines: string[] = []
115
+
116
+ for (const line of lines) {
117
+ const trimmed = line.trim()
118
+ if (!trimmed || trimmed.startsWith("DRY RUN") || trimmed === "---" || trimmed.match(/^─+$/)) continue
119
+ if (trimmed.startsWith("Title:")) { title = trimmed.replace("Title:", "").trim(); continue }
120
+ if (trimmed.startsWith("Tags:")) { tags = trimmed.replace("Tags:", "").trim(); continue }
121
+ if (trimmed.startsWith("Category:")) { category = trimmed.replace("Category:", "").trim(); continue }
122
+ if (trimmed.startsWith("Session:")) continue
123
+ bodyLines.push(trimmed)
124
+ }
125
+
126
+ // Structured display
127
+ if (title) {
128
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}📝 ${title}${UI.Style.TEXT_NORMAL}`)
129
+ console.log("")
130
+ }
131
+ if (category || tags) {
132
+ const meta: string[] = []
133
+ if (category) meta.push(`Category: ${category}`)
134
+ if (tags) meta.push(`Tags: ${tags}`)
135
+ console.log(` ${UI.Style.TEXT_DIM}${meta.join(" · ")}${UI.Style.TEXT_NORMAL}`)
136
+ console.log("")
137
+ }
138
+ if (bodyLines.length > 0) {
139
+ // Show a preview snippet (first few meaningful lines)
140
+ const snippet = bodyLines.slice(0, 6)
141
+ for (const line of snippet) {
142
+ console.log(` ${line}`)
143
+ }
144
+ if (bodyLines.length > 6) {
145
+ console.log(` ${UI.Style.TEXT_DIM}... (${bodyLines.length - 6} more lines)${UI.Style.TEXT_NORMAL}`)
146
+ }
147
+ }
148
+
149
+ UI.divider()
150
+
151
+ // Confirm publish
152
+ const choice = await UI.waitEnter("Press Enter to publish, or Esc to skip")
153
+
154
+ if (choice === "escape") {
155
+ await UI.typeText("Skipped. You can publish later with /publish in the app.")
156
+ return
157
+ }
158
+
159
+ // Publish with shimmer
160
+ const publishPromise = McpBridge.callTool("auto_post", { dry_run: false })
161
+ await shimmerLine("Publishing your post...", 1500)
162
+
163
+ try {
164
+ const result = await publishPromise
165
+ console.log("")
166
+
167
+ // Extract URL and details from result
168
+ const urlMatch = result.match(/(?:URL|View at|view at)[:\s]*(https?:\/\/\S+)/i)
169
+ if (urlMatch) {
170
+ UI.success("Post published successfully!")
171
+ console.log("")
172
+ if (title) {
173
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}${title}${UI.Style.TEXT_NORMAL}`)
174
+ }
175
+ console.log(` ${UI.Style.TEXT_HIGHLIGHT}${urlMatch[1]}${UI.Style.TEXT_NORMAL}`)
176
+ console.log("")
177
+ await UI.typeText("Your first post is live! Others can now read, comment, and vote on it.", { charDelay: 10 })
178
+ } else {
179
+ UI.success("Post published!")
180
+ // Fallback: show cleaned result
181
+ const cleanResult = UI.cleanMarkdown(result)
182
+ for (const line of cleanResult.split("\n").slice(0, 5)) {
183
+ if (line.trim()) console.log(` ${line.trim()}`)
184
+ }
185
+ }
186
+ } catch (err) {
187
+ UI.error(`Publish failed: ${err instanceof Error ? err.message : String(err)}`)
188
+ await UI.typeText("You can try again later with /publish.")
189
+ }
190
+ }
191
+
192
+ // ─── AI Configuration ────────────────────────────────────────────────────────
193
+
194
+ async function aiQuickConfigPrompt(): Promise<void> {
195
+ const { AIProvider } = await import("../../ai/provider")
196
+ const hasKey = await AIProvider.hasAnyKey()
197
+
198
+ if (hasKey) {
199
+ UI.success("AI provider already configured!")
200
+ return
201
+ }
202
+
203
+ UI.divider()
204
+
205
+ await UI.typeText("One more thing — would you like to configure an AI chat provider?")
206
+ console.log("")
207
+ await UI.typeText("With AI configured, you can interact with the forum using natural language:", { charDelay: 8 })
208
+ console.log(` ${UI.Style.TEXT_DIM}"Show me trending posts about TypeScript"${UI.Style.TEXT_NORMAL}`)
209
+ console.log(` ${UI.Style.TEXT_DIM}"Analyze my latest coding session"${UI.Style.TEXT_NORMAL}`)
210
+ console.log(` ${UI.Style.TEXT_DIM}"Write a post about my React refactoring"${UI.Style.TEXT_NORMAL}`)
211
+ console.log("")
212
+
213
+ const choice = await UI.waitEnter("Press Enter to configure AI, or Esc to skip")
214
+
215
+ if (choice === "escape") {
216
+ console.log("")
217
+ await UI.typeText("No problem! You can configure AI later with /ai in the app.")
218
+ console.log("")
219
+ await UI.typeText("Even without AI, you can use slash commands to interact:", { charDelay: 8 })
220
+ console.log(` ${UI.Style.TEXT_HIGHLIGHT}/scan${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}Scan IDE sessions${UI.Style.TEXT_NORMAL}`)
221
+ console.log(` ${UI.Style.TEXT_HIGHLIGHT}/publish${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}Publish a post${UI.Style.TEXT_NORMAL}`)
222
+ console.log(` ${UI.Style.TEXT_HIGHLIGHT}/feed${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}Browse the forum${UI.Style.TEXT_NORMAL}`)
223
+ console.log(` ${UI.Style.TEXT_HIGHLIGHT}/theme${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}Change color theme${UI.Style.TEXT_NORMAL}`)
224
+ return
225
+ }
226
+
227
+ // AI config flow: URL → Key with ESC support
228
+ console.log("")
229
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}API URL${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}(or press Enter to skip URL, Esc to cancel):${UI.Style.TEXT_NORMAL}`)
230
+ const urlResult = await UI.inputWithEscape(` ${UI.Style.TEXT_HIGHLIGHT}❯ ${UI.Style.TEXT_NORMAL}`)
231
+
232
+ if (urlResult === null) {
233
+ // User pressed Esc
234
+ console.log("")
235
+ await UI.typeText("Skipped AI configuration. You can configure later with /ai in the app.")
236
+ return
237
+ }
238
+
239
+ const url = urlResult.trim()
240
+
241
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}API Key${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}(press Esc to cancel):${UI.Style.TEXT_NORMAL}`)
242
+ const keyResult = await UI.inputWithEscape(` ${UI.Style.TEXT_HIGHLIGHT}❯ ${UI.Style.TEXT_NORMAL}`)
243
+
244
+ if (keyResult === null) {
245
+ // User pressed Esc
246
+ console.log("")
247
+ await UI.typeText("Skipped AI configuration. You can configure later with /ai in the app.")
248
+ return
249
+ }
250
+
251
+ const key = keyResult.trim()
252
+
253
+ // Both empty → friendly skip
254
+ if (!url && !key) {
255
+ console.log("")
256
+ UI.info("No AI configuration provided — skipping for now.")
257
+ await UI.typeText("You can configure AI later with /ai in the app.")
258
+ return
259
+ }
260
+
261
+ // Key empty but URL provided → friendly skip
262
+ if (!key) {
263
+ console.log("")
264
+ UI.info("No API key provided — skipping AI configuration.")
265
+ await UI.typeText("You can configure AI later with /ai in the app.")
266
+ return
267
+ }
268
+
269
+ if (key.length < 5) {
270
+ UI.warn("API key seems too short, skipping AI configuration.")
271
+ await UI.typeText("You can configure AI later with /ai in the app.")
272
+ return
273
+ }
274
+
275
+ try {
276
+ const { saveProvider } = await import("../../ai/configure")
277
+ await shimmerLine("Detecting API format...", 1500)
278
+ const result = await saveProvider(url, key)
279
+ if (result.error) {
280
+ UI.warn(result.error)
281
+ await UI.typeText("You can try again later with /ai in the app.")
282
+ } else {
283
+ UI.success(`AI configured! (${result.provider})`)
284
+ }
285
+ } catch (err) {
286
+ UI.warn(`Configuration failed: ${err instanceof Error ? err.message : String(err)}`)
287
+ await UI.typeText("You can try again later with /ai in the app.")
288
+ }
289
+ }
290
+
291
+ type WizardMode = "quick" | "manual"
292
+
293
+ interface ProviderChoice {
294
+ name: string
295
+ providerID: string
296
+ api: "anthropic" | "openai" | "google" | "openai-compatible"
297
+ baseURL?: string
298
+ hint?: string
299
+ }
300
+
301
+ const PROVIDER_CHOICES: ProviderChoice[] = [
302
+ { name: "OpenAI", providerID: "openai", api: "openai", baseURL: "https://api.openai.com", hint: "Codex OAuth + API key style" },
303
+ { name: "Anthropic", providerID: "anthropic", api: "anthropic", baseURL: "https://api.anthropic.com", hint: "Claude API key" },
304
+ { name: "Google", providerID: "google", api: "google", baseURL: "https://generativelanguage.googleapis.com", hint: "Gemini API key" },
305
+ { name: "OpenRouter", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://openrouter.ai/api", hint: "OpenAI-compatible" },
306
+ { name: "vLLM", providerID: "openai-compatible", api: "openai-compatible", baseURL: "http://127.0.0.1:8000", hint: "Local/self-hosted OpenAI-compatible" },
307
+ { name: "MiniMax", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://api.minimax.io", hint: "OpenAI-compatible endpoint" },
308
+ { name: "Moonshot AI (Kimi K2.5)", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://api.moonshot.ai", hint: "OpenAI-compatible endpoint" },
309
+ { name: "xAI (Grok)", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://api.x.ai", hint: "OpenAI-compatible endpoint" },
310
+ { name: "Qianfan", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://qianfan.baidubce.com", hint: "OpenAI-compatible endpoint" },
311
+ { name: "Vercel AI Gateway", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://ai-gateway.vercel.sh", hint: "OpenAI-compatible endpoint" },
312
+ { name: "OpenCode Zen", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://opencode.ai/zen", hint: "OpenAI-compatible endpoint" },
313
+ { name: "Xiaomi", providerID: "anthropic", api: "anthropic", baseURL: "https://api.xiaomimimo.com/anthropic", hint: "Anthropic-compatible endpoint" },
314
+ { name: "Synthetic", providerID: "anthropic", api: "anthropic", baseURL: "https://api.synthetic.new", hint: "Anthropic-compatible endpoint" },
315
+ { name: "Together AI", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://api.together.xyz", hint: "OpenAI-compatible endpoint" },
316
+ { name: "Hugging Face", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://router.huggingface.co", hint: "OpenAI-compatible endpoint" },
317
+ { name: "Venice AI", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://api.venice.ai/api", hint: "OpenAI-compatible endpoint" },
318
+ { name: "LiteLLM", providerID: "openai-compatible", api: "openai-compatible", baseURL: "http://localhost:4000", hint: "Unified OpenAI-compatible gateway" },
319
+ { name: "Cloudflare AI Gateway", providerID: "anthropic", api: "anthropic", hint: "Enter full Anthropic gateway URL manually" },
320
+ { name: "Custom Provider", providerID: "openai-compatible", api: "openai-compatible", hint: "Any OpenAI-compatible URL" },
321
+ ]
322
+
323
+ async function fetchOpenAIModels(baseURL: string, key: string): Promise<string[]> {
324
+ try {
325
+ const clean = baseURL.replace(/\/+$/, "")
326
+ const url = clean.endsWith("/v1") ? `${clean}/models` : `${clean}/v1/models`
327
+ const r = await fetch(url, {
328
+ headers: { Authorization: `Bearer ${key}` },
329
+ signal: AbortSignal.timeout(8000),
330
+ })
331
+ if (!r.ok) return []
332
+ const data = await r.json() as { data?: Array<{ id: string }> }
333
+ return data.data?.map((m) => m.id) || []
334
+ } catch {
335
+ return []
336
+ }
337
+ }
338
+
339
+ function pickPreferredRemoteModel(models: string[]): string | undefined {
340
+ if (models.length === 0) return undefined
341
+ const preferred = [/^gpt-5\.2$/, /^claude-sonnet-4(?:-5)?/, /^gpt-5(?:\.|$|-)/, /^gpt-4o$/, /^gpt-4o-mini$/, /^gemini-2\.5-flash$/]
342
+ for (const pattern of preferred) {
343
+ const found = models.find((id) => pattern.test(id))
344
+ if (found) return found
345
+ }
346
+ return models[0]
347
+ }
348
+
349
+ function isOfficialOpenAIBase(baseURL: string): boolean {
350
+ try {
351
+ const u = new URL(baseURL)
352
+ return u.hostname === "api.openai.com"
353
+ } catch {
354
+ return false
355
+ }
356
+ }
357
+
358
+ async function verifyEndpoint(choice: ProviderChoice, baseURL: string, key: string): Promise<{ ok: boolean; detail: string; detectedApi?: Config.ModelApi }> {
359
+ try {
360
+ if (choice.api === "anthropic") {
361
+ const clean = baseURL.replace(/\/+$/, "")
362
+ const r = await fetch(`${clean}/v1/messages`, {
363
+ method: "POST",
364
+ headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json" },
365
+ body: JSON.stringify({ model: "claude-3-5-haiku-latest", max_tokens: 1, messages: [{ role: "user", content: "ping" }] }),
366
+ signal: AbortSignal.timeout(8000),
367
+ })
368
+ if (r.status !== 404) return { ok: true, detail: `Anthropic endpoint reachable (${r.status})`, detectedApi: "anthropic" }
369
+ return { ok: false, detail: "Anthropic endpoint returned 404" }
370
+ }
371
+
372
+ if (choice.api === "google") {
373
+ const clean = baseURL.replace(/\/+$/, "")
374
+ const r = await fetch(`${clean}/v1beta/models?key=${encodeURIComponent(key)}`, {
375
+ signal: AbortSignal.timeout(8000),
376
+ })
377
+ if (r.ok || r.status === 401 || r.status === 403) return { ok: true, detail: `Google endpoint reachable (${r.status})` }
378
+ return { ok: false, detail: `Google endpoint responded ${r.status}` }
379
+ }
380
+
381
+ const { probe } = await import("../../ai/configure")
382
+ const detected = await probe(baseURL, key)
383
+ if (detected === "anthropic") return { ok: true, detail: "Detected Anthropic API format", detectedApi: "anthropic" }
384
+ if (detected === "openai") {
385
+ const detectedApi: Config.ModelApi =
386
+ choice.providerID === "openai" && isOfficialOpenAIBase(baseURL)
387
+ ? "openai"
388
+ : "openai-compatible"
389
+ return { ok: true, detail: "Detected OpenAI API format", detectedApi }
390
+ }
391
+
392
+ const models = await fetchOpenAIModels(baseURL, key)
393
+ if (models.length > 0) {
394
+ const detectedApi: Config.ModelApi =
395
+ choice.providerID === "openai" && isOfficialOpenAIBase(baseURL)
396
+ ? "openai"
397
+ : "openai-compatible"
398
+ return { ok: true, detail: `Model endpoint reachable (${models.length} models)`, detectedApi }
399
+ }
400
+
401
+ return { ok: false, detail: "Could not detect endpoint format or list models" }
402
+ } catch (err) {
403
+ return { ok: false, detail: err instanceof Error ? err.message : String(err) }
404
+ }
405
+ }
406
+
407
+ async function chooseProvider(): Promise<ProviderChoice | undefined> {
408
+ console.log("")
409
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}Model/auth provider${UI.Style.TEXT_NORMAL}`)
410
+ const idx = await UI.select(
411
+ " Choose a provider",
412
+ [...PROVIDER_CHOICES.map((p) => p.hint ? `${p.name} (${p.hint})` : p.name), "Skip for now"],
413
+ )
414
+ if (idx < 0 || idx >= PROVIDER_CHOICES.length) return undefined
415
+ return PROVIDER_CHOICES[idx]
416
+ }
417
+
418
+ async function chooseModel(choice: ProviderChoice, mode: WizardMode, baseURL: string, key: string): Promise<string | undefined> {
419
+ const { AIProvider } = await import("../../ai/provider")
420
+ const builtin = Object.values(AIProvider.BUILTIN_MODELS).filter((m) => m.providerID === choice.providerID).map((m) => m.id)
421
+ const openaiCustom = choice.providerID === "openai" && !isOfficialOpenAIBase(baseURL)
422
+ const useRemote = choice.providerID === "openai-compatible" || openaiCustom
423
+
424
+ if (mode === "quick") {
425
+ if (choice.providerID === "anthropic") return "claude-sonnet-4-20250514"
426
+ if (choice.providerID === "openai" && !openaiCustom) return "gpt-5.2"
427
+ if (choice.providerID === "google") return "gemini-2.5-flash"
428
+ const remote = await fetchOpenAIModels(baseURL, key)
429
+ return pickPreferredRemoteModel(remote) || "gpt-5.2"
430
+ }
431
+
432
+ let options = builtin
433
+ if (useRemote) {
434
+ const remote = await fetchOpenAIModels(baseURL, key)
435
+ options = remote
436
+ }
437
+ if (options.length === 0) {
438
+ const typed = await UI.input(` Model ID: `)
439
+ return typed.trim() || "gpt-5.2"
440
+ }
441
+
442
+ const idx = await UI.select(" Choose a model", [...options, "Custom model id"])
443
+ if (idx < 0) return undefined
444
+ if (idx >= options.length) {
445
+ const typed = await UI.input(` Model ID: `)
446
+ return typed.trim() || options[0]!
447
+ }
448
+ return options[idx]!
449
+ }
450
+
451
+ export async function runAISetupWizard(source: "setup" | "command" = "command"): Promise<void> {
452
+ const { AIProvider } = await import("../../ai/provider")
453
+ const hasKey = await AIProvider.hasAnyKey()
454
+
455
+ UI.divider()
456
+ if (source === "setup") {
457
+ await UI.typeText("AI onboarding")
458
+ } else {
459
+ await UI.typeText("CodeBlog AI setup wizard")
460
+ }
461
+
462
+ if (hasKey) {
463
+ const keep = await UI.waitEnter("AI is already configured. Press Enter to reconfigure, or Esc to keep current config")
464
+ if (keep === "escape") return
465
+ }
466
+
467
+ console.log("")
468
+ const modeIdx = await UI.select(" Onboarding mode", ["QuickStart (recommended)", "Manual", "Skip for now"])
469
+ if (modeIdx < 0 || modeIdx === 2) {
470
+ UI.info("Skipped AI setup.")
471
+ return
472
+ }
473
+ const mode = modeIdx === 0 ? "quick" : "manual"
474
+
475
+ const provider = await chooseProvider()
476
+ if (!provider) {
477
+ UI.info("Skipped AI setup.")
478
+ return
479
+ }
480
+ if (provider.hint) UI.info(`${provider.name}: ${provider.hint}`)
481
+
482
+ const defaultBaseURL = provider.baseURL || ""
483
+ const needsBasePrompt =
484
+ mode === "manual" ||
485
+ provider.providerID === "openai-compatible" ||
486
+ provider.providerID === "openai" ||
487
+ !defaultBaseURL
488
+ let baseURL = defaultBaseURL
489
+
490
+ if (needsBasePrompt) {
491
+ const endpointHint = defaultBaseURL ? ` [${defaultBaseURL}]` : ""
492
+ const entered = await UI.inputWithEscape(` Endpoint base URL${endpointHint}: `)
493
+ if (entered === null) {
494
+ UI.info("Skipped AI setup.")
495
+ return
496
+ }
497
+ baseURL = entered.trim() || defaultBaseURL
498
+ }
499
+
500
+ const keyRaw = await UI.inputWithEscape(` API key / Bearer token: `)
501
+ if (keyRaw === null) {
502
+ UI.info("Skipped AI setup.")
503
+ return
504
+ }
505
+ const key = keyRaw.trim()
506
+ if (!key || key.length < 5) {
507
+ UI.warn("Credential seems invalid, setup skipped.")
508
+ return
509
+ }
510
+
511
+ if (!baseURL) {
512
+ UI.warn("Endpoint URL is required for this provider.")
513
+ return
514
+ }
515
+
516
+ let verified = false
517
+ let detectedApi: Config.ModelApi | undefined
518
+
519
+ while (!verified) {
520
+ await shimmerLine("Verifying endpoint...", 900)
521
+ const verify = await verifyEndpoint(provider, baseURL, key)
522
+ detectedApi = verify.detectedApi
523
+ if (verify.ok) {
524
+ UI.success(verify.detail)
525
+ verified = true
526
+ break
527
+ }
528
+ UI.warn(`Endpoint verification failed: ${verify.detail}`)
529
+ const retry = await UI.waitEnter("Press Enter to retry verification, or Esc to continue anyway")
530
+ if (retry === "escape") break
531
+ }
532
+
533
+ const selectedModel = await chooseModel(provider, mode, baseURL, key)
534
+ if (!selectedModel) {
535
+ UI.info("Skipped AI setup.")
536
+ return
537
+ }
538
+ const cfg = await Config.load()
539
+ const providers = cfg.providers || {}
540
+ const resolvedApi = detectedApi || provider.api
541
+ const resolvedCompat = provider.providerID === "openai-compatible" && resolvedApi === "openai"
542
+ ? "openai-compatible"
543
+ : resolvedApi
544
+ const providerConfig: Config.ProviderConfig = {
545
+ api_key: key,
546
+ api: resolvedApi,
547
+ compat_profile: resolvedCompat,
548
+ }
549
+ if (baseURL) providerConfig.base_url = baseURL
550
+ providers[provider.providerID] = providerConfig
551
+
552
+ const model = provider.providerID === "openai-compatible" && !selectedModel.includes("/")
553
+ ? `openai-compatible/${selectedModel}`
554
+ : selectedModel
555
+
556
+ await Config.save({
557
+ providers,
558
+ default_provider: provider.providerID,
559
+ model,
560
+ })
561
+
562
+ UI.success(`AI configured: ${provider.name} (${model})`)
563
+ console.log(` ${UI.Style.TEXT_DIM}You can rerun this wizard with: codeblog ai setup${UI.Style.TEXT_NORMAL}`)
564
+ }
565
+
566
+ // ─── Setup Command ───────────────────────────────────────────────────────────
567
+
568
+ export const SetupCommand: CommandModule = {
569
+ command: "setup",
570
+ describe: "First-time setup wizard: authenticate, scan, publish, configure AI",
571
+ handler: async () => {
572
+ // Phase 1: Welcome
573
+ console.log(UI.logo())
574
+ await UI.typeText("Welcome to CodeBlog!", { charDelay: 20 })
575
+ await UI.typeText("The AI-powered coding forum in your terminal.", { charDelay: 15 })
576
+ console.log("")
577
+
578
+ // Phase 2: Authentication
579
+ const alreadyAuthed = await Auth.authenticated()
580
+ let authenticated = alreadyAuthed
581
+
582
+ if (alreadyAuthed) {
583
+ const token = await Auth.get()
584
+ UI.success(`Already authenticated as ${token?.username || "user"}!`)
585
+ } else {
586
+ await UI.typeText("Let's get you set up. First, we need to authenticate your account.")
587
+ await UI.typeText("You may need to sign up or log in on the website first.", { charDelay: 10 })
588
+ console.log("")
589
+
590
+ await UI.waitEnter("Press Enter to open browser...")
591
+
592
+ authenticated = await authBrowser()
593
+ }
594
+
595
+ if (!authenticated) {
596
+ console.log("")
597
+ UI.info("You can try again with: codeblog setup")
598
+ return
599
+ }
600
+
601
+ const token = await Auth.get()
602
+ UI.success(`Authenticated as ${token?.username || "user"}!`)
603
+
604
+ // Phase 3: AI configuration (OpenClaw-like provider chooser)
605
+ UI.divider()
606
+ await UI.typeText("Let's connect your AI provider first.", { charDelay: 10 })
607
+ await UI.typeText("Choose a provider, enter key/endpoint, and we'll verify it.", { charDelay: 10 })
608
+ console.log("")
609
+ await runAISetupWizard("setup")
610
+
611
+ // Phase 4: Interactive scan & publish
612
+ UI.divider()
613
+
614
+ await UI.typeText("Great! Let's see what you've been working on.")
615
+ await UI.typeText("I'll scan your local IDE sessions to find interesting coding experiences.", { charDelay: 10 })
616
+ console.log("")
617
+
618
+ const scanChoice = await UI.waitEnter("Press Enter to continue, or Esc to skip")
619
+
620
+ if (scanChoice === "enter") {
621
+ await scanAndPublish()
622
+ } else {
623
+ await UI.typeText("Skipped. You can scan and publish later in the app.")
624
+ }
625
+
626
+ // Phase 5: Transition to TUI
627
+ UI.divider()
628
+ setupCompleted = true
629
+ await UI.typeText("All set! Launching CodeBlog...", { charDelay: 20 })
630
+ await Bun.sleep(800)
631
+ },
632
+ }
@@ -0,0 +1,20 @@
1
+ import type { CommandModule } from "yargs"
2
+
3
+ export const TuiCommand: CommandModule = {
4
+ command: "tui",
5
+ aliases: ["ui"],
6
+ describe: "Launch interactive TUI — browse feed, chat with AI, manage posts",
7
+ builder: (yargs) =>
8
+ yargs
9
+ .option("model", {
10
+ alias: "m",
11
+ describe: "Default AI model",
12
+ type: "string",
13
+ }),
14
+ handler: async (args) => {
15
+ const { tui } = await import("../../tui/app")
16
+ await tui({
17
+ onExit: async () => {},
18
+ })
19
+ },
20
+ }