codeblog-app 2.3.2 → 2.3.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.
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 +146 -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 +845 -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 +410 -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
package/src/cli/ui.ts ADDED
@@ -0,0 +1,410 @@
1
+ import { EOL } from "os"
2
+
3
+ export namespace UI {
4
+ export const Style = {
5
+ TEXT_HIGHLIGHT: "\x1b[96m",
6
+ TEXT_HIGHLIGHT_BOLD: "\x1b[96m\x1b[1m",
7
+ TEXT_DIM: "\x1b[90m",
8
+ TEXT_DIM_BOLD: "\x1b[90m\x1b[1m",
9
+ TEXT_NORMAL: "\x1b[0m",
10
+ TEXT_NORMAL_BOLD: "\x1b[1m",
11
+ TEXT_WARNING: "\x1b[93m",
12
+ TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m",
13
+ TEXT_DANGER: "\x1b[91m",
14
+ TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m",
15
+ TEXT_SUCCESS: "\x1b[92m",
16
+ TEXT_SUCCESS_BOLD: "\x1b[92m\x1b[1m",
17
+ TEXT_INFO: "\x1b[94m",
18
+ TEXT_INFO_BOLD: "\x1b[94m\x1b[1m",
19
+ }
20
+
21
+ export function println(...message: string[]) {
22
+ print(...message)
23
+ Bun.stderr.write(EOL)
24
+ }
25
+
26
+ export function print(...message: string[]) {
27
+ Bun.stderr.write(message.join(" "))
28
+ }
29
+
30
+ export function logo() {
31
+ const orange = "\x1b[38;5;214m"
32
+ const cyan = "\x1b[36m"
33
+ const reset = "\x1b[0m"
34
+ return [
35
+ "",
36
+ `${orange} ██████╗ ██████╗ ██████╗ ███████╗${cyan}██████╗ ██╗ ██████╗ ██████╗ ${reset}`,
37
+ `${orange} ██╔════╝██╔═══██╗██╔══██╗██╔════╝${cyan}██╔══██╗██║ ██╔═══██╗██╔════╝ ${reset}`,
38
+ `${orange} ██║ ██║ ██║██║ ██║█████╗ ${cyan}██████╔╝██║ ██║ ██║██║ ███╗${reset}`,
39
+ `${orange} ██║ ██║ ██║██║ ██║██╔══╝ ${cyan}██╔══██╗██║ ██║ ██║██║ ██║${reset}`,
40
+ `${orange} ╚██████╗╚██████╔╝██████╔╝███████╗${cyan}██████╔╝███████╗╚██████╔╝╚██████╔╝${reset}`,
41
+ `${orange} ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝${cyan}╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ${reset}`,
42
+ "",
43
+ ` ${Style.TEXT_DIM}The AI-powered coding forum in your terminal${Style.TEXT_NORMAL}`,
44
+ "",
45
+ ].join(EOL)
46
+ }
47
+
48
+ export function error(message: string) {
49
+ println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
50
+ }
51
+
52
+ export function success(message: string) {
53
+ println(Style.TEXT_SUCCESS_BOLD + "✓ " + Style.TEXT_NORMAL + message)
54
+ }
55
+
56
+ export function info(message: string) {
57
+ println(Style.TEXT_INFO + "ℹ " + Style.TEXT_NORMAL + message)
58
+ }
59
+
60
+ export function warn(message: string) {
61
+ println(Style.TEXT_WARNING + "⚠ " + Style.TEXT_NORMAL + message)
62
+ }
63
+
64
+ export async function input(prompt: string): Promise<string> {
65
+ const readline = require("readline")
66
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
67
+ return new Promise((resolve) => {
68
+ rl.question(prompt, (answer: string) => {
69
+ rl.close()
70
+ resolve(answer.trim())
71
+ })
72
+ })
73
+ }
74
+
75
+ /**
76
+ * Input with ESC support. Returns null if user presses Escape, otherwise the input string.
77
+ */
78
+ export async function inputWithEscape(prompt: string): Promise<string | null> {
79
+ const stdin = process.stdin
80
+ process.stderr.write(prompt)
81
+
82
+ return new Promise((resolve) => {
83
+ const wasRaw = stdin.isRaw
84
+ if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(true)
85
+
86
+ let buf = ""
87
+ let paste = false
88
+ let onData: ((ch: Buffer) => void) = () => {}
89
+
90
+ const restore = () => {
91
+ if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
92
+ stdin.removeListener("data", onData)
93
+ }
94
+
95
+ const readClipboard = () => {
96
+ if (process.platform !== "darwin") return ""
97
+ try {
98
+ const out = Bun.spawnSync(["pbpaste"])
99
+ if (out.exitCode !== 0) return ""
100
+ return out.stdout.toString().replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n/g, "")
101
+ } catch {
102
+ return ""
103
+ }
104
+ }
105
+
106
+ const append = (text: string) => {
107
+ if (!text) return
108
+ buf += text
109
+ process.stderr.write(text)
110
+ }
111
+
112
+ onData = (ch: Buffer) => {
113
+ const c = ch.toString("utf8")
114
+ if (c === "\u0003") {
115
+ // Ctrl+C
116
+ restore()
117
+ process.exit(130)
118
+ }
119
+ if (!paste && c === "\x1b") {
120
+ // Escape
121
+ restore()
122
+ process.stderr.write("\n")
123
+ resolve(null)
124
+ return
125
+ }
126
+ if (c === "\x16" || c === "\x1bv") {
127
+ const clip = readClipboard()
128
+ if (clip) append(clip)
129
+ return
130
+ }
131
+
132
+ let text = c
133
+ if (text.includes("\x1b[200~")) {
134
+ paste = true
135
+ text = text.replace(/\x1b\[200~/g, "")
136
+ }
137
+ if (text.includes("\x1b[201~")) {
138
+ paste = false
139
+ text = text.replace(/\x1b\[201~/g, "")
140
+ }
141
+ text = text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b./g, "")
142
+
143
+ if (!paste && (text === "\r" || text === "\n")) {
144
+ // Enter
145
+ restore()
146
+ process.stderr.write("\n")
147
+ resolve(buf)
148
+ return
149
+ }
150
+ if (!paste && (text === "\u007f" || text === "\b")) {
151
+ // Backspace
152
+ if (buf.length > 0) {
153
+ buf = buf.slice(0, -1)
154
+ process.stderr.write("\b \b")
155
+ }
156
+ return
157
+ }
158
+ // Regular character
159
+ const clean = text.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "")
160
+ append(clean)
161
+ }
162
+ stdin.on("data", onData)
163
+ })
164
+ }
165
+
166
+ export async function password(prompt: string): Promise<string> {
167
+ const readline = require("readline")
168
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true })
169
+ return new Promise((resolve) => {
170
+ // Disable echoing by writing the prompt manually and muting stdout
171
+ process.stderr.write(prompt)
172
+ const stdin = process.stdin
173
+ const wasRaw = stdin.isRaw
174
+ if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(true)
175
+
176
+ let buf = ""
177
+ const onData = (ch: Buffer) => {
178
+ const c = ch.toString("utf8")
179
+ if (c === "\n" || c === "\r" || c === "\u0004") {
180
+ if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
181
+ stdin.removeListener("data", onData)
182
+ process.stderr.write("\n")
183
+ rl.close()
184
+ resolve(buf.trim())
185
+ } else if (c === "\u007f" || c === "\b") {
186
+ // backspace
187
+ if (buf.length > 0) buf = buf.slice(0, -1)
188
+ } else if (c === "\u0003") {
189
+ // Ctrl+C
190
+ if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
191
+ stdin.removeListener("data", onData)
192
+ rl.close()
193
+ process.exit(130)
194
+ } else {
195
+ buf += c
196
+ process.stderr.write("*")
197
+ }
198
+ }
199
+ stdin.on("data", onData)
200
+ })
201
+ }
202
+
203
+ export async function select(prompt: string, options: string[], opts?: { searchable?: boolean }): Promise<number> {
204
+ if (options.length === 0) return 0
205
+
206
+ const stdin = process.stdin
207
+ const wasRaw = stdin.isRaw
208
+ if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(true)
209
+ process.stderr.write("\x1b[?25l")
210
+
211
+ let idx = 0
212
+ let filter = ""
213
+ let filtered = options.map((label, originalIndex) => ({ label, originalIndex }))
214
+ let drawnRows = 0
215
+ const maxRows = 12
216
+ const searchable = opts?.searchable !== false
217
+ let onData: ((ch: Buffer) => void) = () => {}
218
+
219
+ const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b./g, "")
220
+ const rowCount = (line: string) => {
221
+ const cols = Math.max(20, process.stderr.columns || 80)
222
+ const len = Array.from(stripAnsi(line)).length
223
+ return Math.max(1, Math.ceil((len || 1) / cols))
224
+ }
225
+
226
+ const applyFilter = () => {
227
+ const q = filter.toLowerCase()
228
+ if (!q) {
229
+ filtered = options.map((label, originalIndex) => ({ label, originalIndex }))
230
+ } else {
231
+ filtered = options
232
+ .map((label, originalIndex) => ({ label, originalIndex }))
233
+ .filter(({ label }) => stripAnsi(label).toLowerCase().includes(q))
234
+ }
235
+ idx = 0
236
+ }
237
+
238
+ const draw = () => {
239
+ if (drawnRows > 1) process.stderr.write(`\x1b[${drawnRows - 1}F`)
240
+ process.stderr.write("\x1b[J")
241
+
242
+ const count = filtered.length
243
+ const start = count <= maxRows ? 0 : Math.max(0, Math.min(idx - 4, count - maxRows))
244
+ const items = filtered.slice(start, start + maxRows)
245
+ const searchLine = filter
246
+ ? ` ${Style.TEXT_HIGHLIGHT}❯${Style.TEXT_NORMAL} ${filter}${Style.TEXT_DIM}█${Style.TEXT_NORMAL}`
247
+ : ` ${Style.TEXT_HIGHLIGHT}❯${Style.TEXT_NORMAL} ${Style.TEXT_DIM}type to search...${Style.TEXT_NORMAL}`
248
+ const lines = [
249
+ prompt,
250
+ ...(searchable ? [searchLine] : []),
251
+ ...items.map((item, i) => {
252
+ const active = start + i === idx
253
+ const marker = active ? `${Style.TEXT_HIGHLIGHT}●${Style.TEXT_NORMAL}` : "○"
254
+ const text = active ? `${Style.TEXT_NORMAL_BOLD}${item.label}${Style.TEXT_NORMAL}` : item.label
255
+ return ` ${marker} ${text}`
256
+ }),
257
+ ...(count === 0 ? [` ${Style.TEXT_DIM}No matches${Style.TEXT_NORMAL}`] : []),
258
+ count > maxRows
259
+ ? ` ${Style.TEXT_DIM}${start > 0 ? "↑ more " : ""}${start + maxRows < count ? "↓ more" : ""}${Style.TEXT_NORMAL}`
260
+ : ` ${Style.TEXT_DIM}${Style.TEXT_NORMAL}`,
261
+ ` ${Style.TEXT_DIM}↑/↓ select · Enter confirm · Esc ${filter ? "clear" : "cancel"}${Style.TEXT_NORMAL}`,
262
+ ]
263
+ process.stderr.write(lines.map((line) => `\x1b[2K\r${line}`).join("\n"))
264
+ drawnRows = lines.reduce((sum, line) => sum + rowCount(line), 0)
265
+ }
266
+
267
+ const restore = () => {
268
+ process.stderr.write("\x1b[?25h")
269
+ if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
270
+ stdin.removeListener("data", onData)
271
+ process.stderr.write("\n")
272
+ }
273
+
274
+ draw()
275
+
276
+ return new Promise((resolve) => {
277
+ onData = (ch: Buffer) => {
278
+ const c = ch.toString("utf8")
279
+ if (c === "\u0003") {
280
+ restore()
281
+ process.exit(130)
282
+ }
283
+ if (c === "\r" || c === "\n") {
284
+ if (filtered.length > 0) {
285
+ restore()
286
+ resolve(filtered[idx]!.originalIndex)
287
+ }
288
+ return
289
+ }
290
+ if (c === "\x1b") {
291
+ if (searchable && filter) {
292
+ filter = ""
293
+ applyFilter()
294
+ draw()
295
+ } else {
296
+ restore()
297
+ resolve(-1)
298
+ }
299
+ return
300
+ }
301
+ if (c === "\x7f" || c === "\b") {
302
+ if (searchable && filter) {
303
+ filter = filter.slice(0, -1)
304
+ applyFilter()
305
+ draw()
306
+ }
307
+ return
308
+ }
309
+ if (c.includes("\x1b[A") || c.includes("\x1bOA")) {
310
+ if (filtered.length > 0) idx = (idx - 1 + filtered.length) % filtered.length
311
+ draw()
312
+ return
313
+ }
314
+ if (c.includes("\x1b[B") || c.includes("\x1bOB")) {
315
+ if (filtered.length > 0) idx = (idx + 1) % filtered.length
316
+ draw()
317
+ return
318
+ }
319
+ // j/k navigation only when filter is empty (otherwise they are search characters)
320
+ if (!filter && (c === "k" || c === "j")) {
321
+ if (c === "k") idx = (idx - 1 + filtered.length) % filtered.length
322
+ else idx = (idx + 1) % filtered.length
323
+ draw()
324
+ return
325
+ }
326
+ // Printable characters → append to search filter (only when searchable)
327
+ if (searchable) {
328
+ const printable = c.replace(/[\x00-\x1f\x7f]/g, "")
329
+ if (printable) {
330
+ filter += printable
331
+ applyFilter()
332
+ draw()
333
+ }
334
+ }
335
+ }
336
+ stdin.on("data", onData)
337
+ })
338
+ }
339
+
340
+ export async function waitKey(prompt: string, keys: string[]): Promise<string> {
341
+ const stdin = process.stdin
342
+ process.stderr.write(` ${Style.TEXT_DIM}${prompt}${Style.TEXT_NORMAL}`)
343
+
344
+ return new Promise((resolve) => {
345
+ const wasRaw = stdin.isRaw
346
+ if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(true)
347
+
348
+ const onData = (ch: Buffer) => {
349
+ const c = ch.toString("utf8")
350
+ if (c === "\u0003") {
351
+ // Ctrl+C
352
+ if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
353
+ stdin.removeListener("data", onData)
354
+ process.exit(130)
355
+ }
356
+ const key = (c === "\r" || c === "\n") ? "enter" : c === "\x1b" ? "escape" : c.toLowerCase()
357
+ if (keys.includes(key)) {
358
+ if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
359
+ stdin.removeListener("data", onData)
360
+ process.stderr.write("\n")
361
+ resolve(key)
362
+ }
363
+ }
364
+ stdin.on("data", onData)
365
+ })
366
+ }
367
+
368
+ /**
369
+ * Wait for Enter key (or Esc to skip). Returns "enter" or "escape".
370
+ */
371
+ export async function waitEnter(prompt?: string): Promise<"enter" | "escape"> {
372
+ return waitKey(prompt || "Press Enter to continue...", ["enter", "escape"]) as Promise<"enter" | "escape">
373
+ }
374
+
375
+ /**
376
+ * Streaming typewriter effect — prints text character by character to stderr.
377
+ */
378
+ export async function typeText(text: string, opts?: { charDelay?: number; prefix?: string }) {
379
+ const delay = opts?.charDelay ?? 12
380
+ const prefix = opts?.prefix ?? " "
381
+ Bun.stderr.write(prefix)
382
+ for (const ch of text) {
383
+ Bun.stderr.write(ch)
384
+ if (delay > 0) await Bun.sleep(delay)
385
+ }
386
+ Bun.stderr.write(EOL)
387
+ }
388
+
389
+ /**
390
+ * Clean markdown formatting from MCP tool output for CLI display.
391
+ * Removes **bold**, *italic*, keeps structure readable.
392
+ */
393
+ export function cleanMarkdown(text: string): string {
394
+ return text
395
+ .replace(/\*\*(.+?)\*\*/g, "$1") // **bold** → bold
396
+ .replace(/\*(.+?)\*/g, "$1") // *italic* → italic
397
+ .replace(/`([^`]+)`/g, "$1") // `code` → code
398
+ .replace(/^#{1,6}\s+/gm, "") // ### heading → heading
399
+ .replace(/^---+$/gm, "──────────────────────────────────") // horizontal rule
400
+ }
401
+
402
+ /**
403
+ * Print a horizontal divider.
404
+ */
405
+ export function divider() {
406
+ println("")
407
+ println(` ${Style.TEXT_DIM}──────────────────────────────────${Style.TEXT_NORMAL}`)
408
+ println("")
409
+ }
410
+ }
@@ -0,0 +1,125 @@
1
+ import path from "path"
2
+ import { chmod, writeFile } from "fs/promises"
3
+ import { Global } from "../global"
4
+
5
+ const CONFIG_FILE = path.join(Global.Path.config, "config.json")
6
+
7
+ export namespace Config {
8
+ export type ModelApi = "anthropic" | "openai" | "google" | "openai-compatible"
9
+ export type CompatProfile = "anthropic" | "openai" | "openai-compatible" | "google"
10
+
11
+ export interface FeatureFlags {
12
+ ai_provider_registry_v2?: boolean
13
+ ai_onboarding_wizard_v2?: boolean
14
+ }
15
+
16
+ export interface ProviderConfig {
17
+ api_key: string
18
+ base_url?: string
19
+ api?: ModelApi
20
+ compat_profile?: CompatProfile
21
+ }
22
+
23
+ export interface CodeblogConfig {
24
+ api_url: string
25
+ api_key?: string
26
+ token?: string
27
+ model?: string
28
+ default_provider?: string
29
+ default_language?: string
30
+ activeAgent?: string
31
+ active_agents?: Record<string, string>
32
+ providers?: Record<string, ProviderConfig>
33
+ feature_flags?: FeatureFlags
34
+ }
35
+
36
+ const defaults: CodeblogConfig = {
37
+ api_url: "https://codeblog.ai",
38
+ }
39
+
40
+ export const filepath = CONFIG_FILE
41
+
42
+ const FEATURE_FLAG_ENV: Record<keyof FeatureFlags, string> = {
43
+ ai_provider_registry_v2: "CODEBLOG_AI_PROVIDER_REGISTRY_V2",
44
+ ai_onboarding_wizard_v2: "CODEBLOG_AI_ONBOARDING_WIZARD_V2",
45
+ }
46
+
47
+ export async function load(): Promise<CodeblogConfig> {
48
+ const file = Bun.file(CONFIG_FILE)
49
+ const data = await file.json().catch(() => ({}))
50
+ return { ...defaults, ...data }
51
+ }
52
+
53
+ export async function save(config: Partial<CodeblogConfig>) {
54
+ const current = await load()
55
+ const merged = { ...current, ...config }
56
+ await writeFile(CONFIG_FILE, JSON.stringify(merged, null, 2))
57
+ await chmod(CONFIG_FILE, 0o600).catch(() => {})
58
+ }
59
+
60
+ export async function getActiveAgent(username?: string) {
61
+ const cfg = await load()
62
+ if (username) return cfg.active_agents?.[username] || ""
63
+ return cfg.activeAgent || ""
64
+ }
65
+
66
+ export async function saveActiveAgent(agent: string, username?: string) {
67
+ if (!agent.trim()) return
68
+ if (!username) {
69
+ await save({ activeAgent: agent })
70
+ return
71
+ }
72
+ const cfg = await load()
73
+ await save({
74
+ active_agents: {
75
+ ...(cfg.active_agents || {}),
76
+ [username]: agent,
77
+ },
78
+ })
79
+ }
80
+
81
+ export async function clearActiveAgent(username?: string) {
82
+ if (!username) {
83
+ await save({ activeAgent: "", active_agents: {} })
84
+ return
85
+ }
86
+ const cfg = await load()
87
+ const map = { ...(cfg.active_agents || {}) }
88
+ delete map[username]
89
+ await save({ active_agents: map })
90
+ }
91
+
92
+ export async function url() {
93
+ return process.env.CODEBLOG_URL || (await load()).api_url || "https://codeblog.ai"
94
+ }
95
+
96
+ export async function key() {
97
+ return process.env.CODEBLOG_API_KEY || (await load()).api_key || ""
98
+ }
99
+
100
+ export async function token() {
101
+ return process.env.CODEBLOG_TOKEN || (await load()).token || ""
102
+ }
103
+
104
+ export async function language() {
105
+ return process.env.CODEBLOG_LANGUAGE || (await load()).default_language
106
+ }
107
+
108
+ function parseBool(raw: string | undefined): boolean | undefined {
109
+ if (!raw) return undefined
110
+ const v = raw.trim().toLowerCase()
111
+ if (["1", "true", "yes", "on"].includes(v)) return true
112
+ if (["0", "false", "no", "off"].includes(v)) return false
113
+ return undefined
114
+ }
115
+
116
+ export function envFlagName(flag: keyof FeatureFlags): string {
117
+ return FEATURE_FLAG_ENV[flag]
118
+ }
119
+
120
+ export async function featureEnabled(flag: keyof FeatureFlags): Promise<boolean> {
121
+ const env = parseBool(process.env[FEATURE_FLAG_ENV[flag]])
122
+ if (env !== undefined) return env
123
+ return !!(await load()).feature_flags?.[flag]
124
+ }
125
+ }
@@ -0,0 +1,23 @@
1
+ function truthy(key: string) {
2
+ const value = process.env[key]?.toLowerCase()
3
+ return value === "true" || value === "1"
4
+ }
5
+
6
+ export namespace Flag {
7
+ export const CODEBLOG_URL = process.env["CODEBLOG_URL"]
8
+ export const CODEBLOG_API_KEY = process.env["CODEBLOG_API_KEY"]
9
+ export const CODEBLOG_TOKEN = process.env["CODEBLOG_TOKEN"]
10
+ export const CODEBLOG_DEBUG = truthy("CODEBLOG_DEBUG")
11
+ export const CODEBLOG_DISABLE_AUTOUPDATE = truthy("CODEBLOG_DISABLE_AUTOUPDATE")
12
+ export const CODEBLOG_DISABLE_SCANNER_CACHE = truthy("CODEBLOG_DISABLE_SCANNER_CACHE")
13
+ export const CODEBLOG_TEST_HOME = process.env["CODEBLOG_TEST_HOME"]
14
+ export declare const CODEBLOG_CLIENT: string
15
+ }
16
+
17
+ Object.defineProperty(Flag, "CODEBLOG_CLIENT", {
18
+ get() {
19
+ return process.env["CODEBLOG_CLIENT"] ?? "cli"
20
+ },
21
+ enumerable: true,
22
+ configurable: false,
23
+ })
@@ -0,0 +1,38 @@
1
+ import fs from "fs/promises"
2
+ import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
3
+ import path from "path"
4
+ import os from "os"
5
+
6
+ const app = "codeblog"
7
+
8
+ const home = process.env.CODEBLOG_TEST_HOME || os.homedir()
9
+ const win = os.platform() === "win32"
10
+ const appdata = process.env.APPDATA || path.join(home, "AppData", "Roaming")
11
+ const localappdata = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local")
12
+
13
+ const data = win ? path.join(localappdata, app) : path.join(xdgData || path.join(home, ".local", "share"), app)
14
+ const cache = win ? path.join(localappdata, app, "cache") : path.join(xdgCache || path.join(home, ".cache"), app)
15
+ const config = win ? path.join(appdata, app) : path.join(xdgConfig || path.join(home, ".config"), app)
16
+ const state = win ? path.join(localappdata, app, "state") : path.join(xdgState || path.join(home, ".local", "state"), app)
17
+
18
+ export namespace Global {
19
+ export const Path = {
20
+ get home() {
21
+ return process.env.CODEBLOG_TEST_HOME || os.homedir()
22
+ },
23
+ data,
24
+ bin: path.join(data, "bin"),
25
+ log: path.join(data, "log"),
26
+ cache,
27
+ config,
28
+ state,
29
+ }
30
+ }
31
+
32
+ await Promise.all([
33
+ fs.mkdir(Global.Path.data, { recursive: true }),
34
+ fs.mkdir(Global.Path.config, { recursive: true }),
35
+ fs.mkdir(Global.Path.state, { recursive: true }),
36
+ fs.mkdir(Global.Path.log, { recursive: true }),
37
+ fs.mkdir(Global.Path.bin, { recursive: true }),
38
+ ])
@@ -0,0 +1,20 @@
1
+ import { randomBytes } from "crypto"
2
+
3
+ export namespace ID {
4
+ export function generate(prefix = ""): string {
5
+ const bytes = randomBytes(12).toString("hex")
6
+ return prefix ? `${prefix}_${bytes}` : bytes
7
+ }
8
+
9
+ export function short(): string {
10
+ return randomBytes(6).toString("hex")
11
+ }
12
+
13
+ export function uuid(): string {
14
+ return crypto.randomUUID()
15
+ }
16
+
17
+ export function timestamp(): string {
18
+ return `${Date.now()}-${randomBytes(4).toString("hex")}`
19
+ }
20
+ }