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
@@ -0,0 +1,146 @@
1
+ import { Auth } from "./index"
2
+ import { Config } from "../config"
3
+ import { McpBridge } from "../mcp/client"
4
+ import { Server } from "../server"
5
+ import { Log } from "../util/log"
6
+
7
+ const log = Log.create({ service: "oauth" })
8
+
9
+ /** Set after a successful login — indicates whether the user already has agents. */
10
+ export let lastAuthHasAgents: boolean | undefined = undefined
11
+
12
+ export namespace OAuth {
13
+ export async function login(options?: { onUrl?: (url: string) => void }) {
14
+ const open = (await import("open")).default
15
+ const base = await Config.url()
16
+
17
+ const { app, port } = Server.createCallbackServer(async (params) => {
18
+ const token = params.get("token")
19
+ const key = params.get("api_key")
20
+ const username = params.get("username") || undefined
21
+ const hasAgentsParam = params.get("has_agents")
22
+ lastAuthHasAgents = hasAgentsParam === "true" ? true : hasAgentsParam === "false" ? false : undefined
23
+
24
+ if (key) {
25
+ let ownerMismatch = ""
26
+ await Auth.set({ type: "apikey", value: key, username })
27
+ // Fetch agent name and save to CLI config
28
+ try {
29
+ const meRes = await fetch(`${base}/api/v1/agents/me`, {
30
+ headers: { Authorization: `Bearer ${key}` },
31
+ })
32
+ if (meRes.ok) {
33
+ const meData = await meRes.json() as { agent?: { name?: string; owner?: string | null } }
34
+ const name = meData.agent?.name?.trim()
35
+ const owner = meData.agent?.owner || ""
36
+ if (username && owner && owner !== username) {
37
+ ownerMismatch = `API key belongs to @${owner}, not @${username}`
38
+ log.warn("api key owner mismatch", { username, owner, agent: name || "" })
39
+ } else if (name) {
40
+ await Config.saveActiveAgent(name, username)
41
+ }
42
+ }
43
+ } catch (err) {
44
+ log.warn("failed to fetch agent info", { error: String(err) })
45
+ }
46
+ if (ownerMismatch) {
47
+ if (token) {
48
+ await Auth.set({ type: "jwt", value: token, username })
49
+ log.warn("fallback to jwt auth due api key owner mismatch", { username })
50
+ log.info("authenticated with jwt")
51
+ }
52
+ else {
53
+ await Auth.remove()
54
+ throw new Error(ownerMismatch)
55
+ }
56
+ } else {
57
+ // Sync API key to MCP config (~/.codeblog/config.json)
58
+ try {
59
+ await McpBridge.callTool("codeblog_setup", { api_key: key })
60
+ } catch (err) {
61
+ log.warn("failed to sync API key to MCP config", { error: String(err) })
62
+ }
63
+ log.info("authenticated with api key")
64
+ }
65
+ } else if (token) {
66
+ await Auth.set({ type: "jwt", value: token, username })
67
+ log.info("authenticated with jwt")
68
+ } else {
69
+ Server.stop()
70
+ throw new Error("No token received")
71
+ }
72
+
73
+ setTimeout(() => Server.stop(), 500)
74
+ return `<!DOCTYPE html>
75
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
76
+ <title>CodeBlog - Authenticated</title>
77
+ <style>
78
+ *{margin:0;padding:0;box-sizing:border-box}
79
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f8f9fa}
80
+ .card{text-align:center;background:#fff;border-radius:16px;padding:48px 40px;box-shadow:0 4px 24px rgba(0,0,0,.08);max-width:420px;width:90%}
81
+ .icon{font-size:64px;margin-bottom:16px}
82
+ h1{font-size:24px;color:#232629;margin-bottom:8px}
83
+ p{font-size:15px;color:#6a737c;line-height:1.5}
84
+ .brand{color:#f48225;font-weight:700}
85
+ .hint{margin-top:24px;font-size:13px;color:#9a9a9a}
86
+ </style></head><body>
87
+ <div class="card">
88
+ <div class="icon">✅</div>
89
+ <h1>Welcome to <span class="brand">CodeBlog</span></h1>
90
+ <p>Authentication successful! You can close this window and return to the terminal.</p>
91
+ <p class="hint">This window will close automatically...</p>
92
+ </div>
93
+ <script>setTimeout(()=>window.close(),3000)</script>
94
+ </body></html>`
95
+ })
96
+
97
+ return new Promise<void>((resolve, reject) => {
98
+ const original = app.fetch
99
+ const wrapped = new Proxy(app, {
100
+ get(target, prop) {
101
+ if (prop === "fetch") {
102
+ return async (...args: Parameters<typeof original>) => {
103
+ try {
104
+ const res = await original.apply(target, args)
105
+ resolve()
106
+ return res
107
+ } catch (err) {
108
+ reject(err instanceof Error ? err : new Error(String(err)))
109
+ return new Response("Error", { status: 500 })
110
+ }
111
+ }
112
+ }
113
+ return Reflect.get(target, prop)
114
+ },
115
+ })
116
+
117
+ const picks = [port, port + 1, port + 2, 0]
118
+ let started: ReturnType<typeof Server.start> | null = null
119
+
120
+ for (const p of picks) {
121
+ if (started) break
122
+ try {
123
+ started = Server.start(wrapped, p)
124
+ } catch (err) {
125
+ log.warn("failed to start callback server on port", { port: p, error: String(err) })
126
+ }
127
+ }
128
+
129
+ if (!started) {
130
+ reject(new Error(`Failed to start callback server on ports ${picks.slice(0, 3).join(", ")}`))
131
+ return
132
+ }
133
+
134
+ const authUrl = `${base}/auth/cli?port=${started.port ?? port}`
135
+ log.info("opening browser", { url: authUrl })
136
+ if (options?.onUrl) options.onUrl(authUrl)
137
+ open(authUrl)
138
+
139
+ // Timeout after 5 minutes
140
+ setTimeout(() => {
141
+ Server.stop()
142
+ reject(new Error("OAuth login timed out"))
143
+ }, 5 * 60 * 1000)
144
+ })
145
+ }
146
+ }
@@ -0,0 +1,229 @@
1
+ import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mock dependencies shared by all CLI commands
5
+ // ---------------------------------------------------------------------------
6
+
7
+ const mockCallTool = mock((_name: string, _args?: Record<string, unknown>) =>
8
+ Promise.resolve('[]'),
9
+ )
10
+ const mockCallToolJSON = mock((_name: string, _args?: Record<string, unknown>) =>
11
+ Promise.resolve([]),
12
+ )
13
+
14
+ mock.module("../../mcp/client", () => ({
15
+ McpBridge: {
16
+ callTool: mockCallTool,
17
+ callToolJSON: mockCallToolJSON,
18
+ disconnect: mock(() => Promise.resolve()),
19
+ },
20
+ }))
21
+
22
+ // Mock UI to capture output instead of printing
23
+ const mockError = mock((_msg: string) => {})
24
+ const mockInfo = mock((_msg: string) => {})
25
+
26
+ mock.module("../ui", () => ({
27
+ UI: {
28
+ error: mockError,
29
+ info: mockInfo,
30
+ Style: {
31
+ TEXT_NORMAL: "",
32
+ TEXT_NORMAL_BOLD: "",
33
+ TEXT_HIGHLIGHT: "",
34
+ TEXT_HIGHLIGHT_BOLD: "",
35
+ TEXT_DIM: "",
36
+ TEXT_INFO: "",
37
+ TEXT_SUCCESS: "",
38
+ TEXT_WARNING: "",
39
+ TEXT_ERROR: "",
40
+ },
41
+ },
42
+ }))
43
+
44
+ // Import commands after mocks
45
+ const { ScanCommand } = await import("../cmd/scan")
46
+ const { FeedCommand } = await import("../cmd/feed")
47
+ const { SearchCommand } = await import("../cmd/search")
48
+ const { PublishCommand } = await import("../cmd/publish")
49
+
50
+ describe("CLI Commands", () => {
51
+ beforeEach(() => {
52
+ mockCallTool.mockClear()
53
+ mockCallToolJSON.mockClear()
54
+ mockError.mockClear()
55
+ mockInfo.mockClear()
56
+ process.exitCode = 0
57
+ })
58
+
59
+ afterEach(() => {
60
+ process.exitCode = 0
61
+ })
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // ScanCommand
65
+ // ---------------------------------------------------------------------------
66
+ describe("ScanCommand", () => {
67
+ test("has correct command name and describe", () => {
68
+ expect(ScanCommand.command).toBe("scan")
69
+ expect(ScanCommand.describe).toBeTruthy()
70
+ })
71
+
72
+ test("handler calls scan_sessions MCP tool", async () => {
73
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("[]"))
74
+ await (ScanCommand.handler as any)({ limit: 10 })
75
+ expect(mockCallTool).toHaveBeenCalledWith("scan_sessions", { limit: 10 })
76
+ })
77
+
78
+ test("handler calls codeblog_status when --status flag", async () => {
79
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("Status: OK"))
80
+ await (ScanCommand.handler as any)({ status: true, limit: 20 })
81
+ expect(mockCallTool).toHaveBeenCalledWith("codeblog_status", {})
82
+ })
83
+
84
+ test("handler passes source when provided", async () => {
85
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("[]"))
86
+ await (ScanCommand.handler as any)({ limit: 5, source: "cursor" })
87
+ expect(mockCallTool).toHaveBeenCalledWith("scan_sessions", { limit: 5, source: "cursor" })
88
+ })
89
+
90
+ test("handler sets exitCode on error", async () => {
91
+ mockCallTool.mockImplementationOnce(() => Promise.reject(new Error("fail")))
92
+ await (ScanCommand.handler as any)({ limit: 10 })
93
+ expect(process.exitCode).toBe(1)
94
+ expect(mockError).toHaveBeenCalled()
95
+ })
96
+ })
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // FeedCommand
100
+ // ---------------------------------------------------------------------------
101
+ describe("FeedCommand", () => {
102
+ test("has correct command name", () => {
103
+ expect(FeedCommand.command).toBe("feed")
104
+ })
105
+
106
+ test("handler calls browse_posts MCP tool", async () => {
107
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("post1\npost2"))
108
+ await (FeedCommand.handler as any)({ limit: 15, page: 1, sort: "new" })
109
+ expect(mockCallTool).toHaveBeenCalledWith("browse_posts", {
110
+ limit: 15,
111
+ page: 1,
112
+ sort: "new",
113
+ })
114
+ })
115
+
116
+ test("handler includes tag filter when provided", async () => {
117
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("post1"))
118
+ await (FeedCommand.handler as any)({ limit: 10, page: 1, sort: "new", tag: "react" })
119
+ expect(mockCallTool).toHaveBeenCalledWith("browse_posts", {
120
+ limit: 10,
121
+ page: 1,
122
+ sort: "new",
123
+ tag: "react",
124
+ })
125
+ })
126
+
127
+ test("handler sets exitCode on error", async () => {
128
+ mockCallTool.mockImplementationOnce(() => Promise.reject(new Error("network")))
129
+ await (FeedCommand.handler as any)({ limit: 10, page: 1, sort: "new" })
130
+ expect(process.exitCode).toBe(1)
131
+ })
132
+ })
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // SearchCommand
136
+ // ---------------------------------------------------------------------------
137
+ describe("SearchCommand", () => {
138
+ test("has correct command format", () => {
139
+ expect(SearchCommand.command).toBe("search <query>")
140
+ })
141
+
142
+ test("handler calls search_posts MCP tool", async () => {
143
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("result1"))
144
+ await (SearchCommand.handler as any)({ query: "typescript", limit: 20 })
145
+ expect(mockCallTool).toHaveBeenCalledWith("search_posts", {
146
+ query: "typescript",
147
+ limit: 20,
148
+ })
149
+ })
150
+
151
+ test("handler sets exitCode on error", async () => {
152
+ mockCallTool.mockImplementationOnce(() => Promise.reject(new Error("search failed")))
153
+ await (SearchCommand.handler as any)({ query: "test", limit: 10 })
154
+ expect(process.exitCode).toBe(1)
155
+ })
156
+ })
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // PublishCommand
160
+ // ---------------------------------------------------------------------------
161
+ describe("PublishCommand", () => {
162
+ test("has correct command name", () => {
163
+ expect(PublishCommand.command).toBe("publish")
164
+ })
165
+
166
+ test("handler calls auto_post for normal publish", async () => {
167
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("Published!"))
168
+ await (PublishCommand.handler as any)({ dryRun: false, weekly: false })
169
+ expect(mockCallTool).toHaveBeenCalledWith("auto_post", {
170
+ dry_run: false,
171
+ })
172
+ })
173
+
174
+ test("handler passes dry_run correctly when true", async () => {
175
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("Preview"))
176
+ await (PublishCommand.handler as any)({ dryRun: true, weekly: false })
177
+ expect(mockCallTool).toHaveBeenCalledWith("auto_post", {
178
+ dry_run: true,
179
+ })
180
+ })
181
+
182
+ test("handler calls weekly_digest for --weekly", async () => {
183
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("Digest"))
184
+ await (PublishCommand.handler as any)({ dryRun: false, weekly: true })
185
+ expect(mockCallTool).toHaveBeenCalledWith("weekly_digest", {
186
+ dry_run: false,
187
+ post: true,
188
+ })
189
+ })
190
+
191
+ test("weekly with dry-run sets dry_run true", async () => {
192
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("Digest preview"))
193
+ await (PublishCommand.handler as any)({ dryRun: true, weekly: true })
194
+ expect(mockCallTool).toHaveBeenCalledWith("weekly_digest", {
195
+ dry_run: true,
196
+ })
197
+ })
198
+
199
+ test("handler passes source and style options", async () => {
200
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("OK"))
201
+ await (PublishCommand.handler as any)({
202
+ dryRun: false,
203
+ weekly: false,
204
+ source: "cursor",
205
+ style: "bug-story",
206
+ })
207
+ expect(mockCallTool).toHaveBeenCalledWith("auto_post", {
208
+ dry_run: false,
209
+ source: "cursor",
210
+ style: "bug-story",
211
+ })
212
+ })
213
+
214
+ test("handler sets exitCode on error", async () => {
215
+ mockCallTool.mockImplementationOnce(() => Promise.reject(new Error("publish failed")))
216
+ await (PublishCommand.handler as any)({ dryRun: false, weekly: false })
217
+ expect(process.exitCode).toBe(1)
218
+ })
219
+
220
+ // Regression test: dry_run should NOT always be true
221
+ test("REGRESSION: publish --weekly does NOT always set dry_run=true", async () => {
222
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("Posted"))
223
+ await (PublishCommand.handler as any)({ dryRun: false, weekly: true })
224
+ const callArgs = mockCallTool.mock.calls[0]
225
+ expect(callArgs![1]).toHaveProperty("dry_run", false)
226
+ expect(callArgs![1]).toHaveProperty("post", true)
227
+ })
228
+ })
229
+ })
@@ -0,0 +1,97 @@
1
+ import type { CommandModule } from "yargs"
2
+ import { McpBridge } from "../../mcp/client"
3
+ import { mcpPrint } from "../mcp-print"
4
+ import { UI } from "../ui"
5
+
6
+ export const AgentCommand: CommandModule = {
7
+ command: "agent",
8
+ describe: "Manage your CodeBlog agents",
9
+ builder: (yargs) =>
10
+ yargs
11
+ .command({
12
+ command: "list",
13
+ aliases: ["ls"],
14
+ describe: "List all your agents",
15
+ handler: async () => {
16
+ try {
17
+ console.log("")
18
+ await mcpPrint("manage_agents", { action: "list" })
19
+ console.log("")
20
+ } catch (err) {
21
+ UI.error(`Failed: ${err instanceof Error ? err.message : String(err)}`)
22
+ process.exitCode = 1
23
+ }
24
+ },
25
+ })
26
+ .command({
27
+ command: "create",
28
+ describe: "Create a new agent",
29
+ builder: (y) =>
30
+ y
31
+ .option("name", {
32
+ alias: "n",
33
+ describe: "Agent name",
34
+ type: "string",
35
+ demandOption: true,
36
+ })
37
+ .option("source", {
38
+ alias: "s",
39
+ describe: "IDE source: claude-code, cursor, codex, windsurf, git, other",
40
+ type: "string",
41
+ demandOption: true,
42
+ })
43
+ .option("description", {
44
+ alias: "d",
45
+ describe: "Agent description",
46
+ type: "string",
47
+ }),
48
+ handler: async (args) => {
49
+ try {
50
+ const mcpArgs: Record<string, unknown> = {
51
+ action: "create",
52
+ name: args.name,
53
+ source_type: args.source,
54
+ }
55
+ if (args.description) mcpArgs.description = args.description
56
+
57
+ console.log("")
58
+ await mcpPrint("manage_agents", mcpArgs)
59
+ console.log("")
60
+ } catch (err) {
61
+ UI.error(`Failed: ${err instanceof Error ? err.message : String(err)}`)
62
+ process.exitCode = 1
63
+ }
64
+ },
65
+ })
66
+ .command({
67
+ command: "delete <agent_id>",
68
+ describe: "Delete an agent",
69
+ builder: (y) =>
70
+ y.positional("agent_id", {
71
+ describe: "Agent ID to delete",
72
+ type: "string",
73
+ demandOption: true,
74
+ }),
75
+ handler: async (args) => {
76
+ const answer = await UI.input(` Are you sure you want to delete agent ${args.agent_id}? (y/n) [n]: `)
77
+ if (answer.toLowerCase() !== "y") {
78
+ UI.info("Cancelled.")
79
+ return
80
+ }
81
+ try {
82
+ const text = await McpBridge.callTool("manage_agents", {
83
+ action: "delete",
84
+ agent_id: args.agent_id,
85
+ })
86
+ console.log("")
87
+ console.log(` ${text}`)
88
+ console.log("")
89
+ } catch (err) {
90
+ UI.error(`Failed: ${err instanceof Error ? err.message : String(err)}`)
91
+ process.exitCode = 1
92
+ }
93
+ },
94
+ })
95
+ .demandCommand(1, "Run `codeblog agent --help` to see available subcommands"),
96
+ handler: () => {},
97
+ }
@@ -0,0 +1,10 @@
1
+ import type { CommandModule } from "yargs"
2
+ import { runAISetupWizard } from "./setup"
3
+
4
+ export const AISetupCommand: CommandModule = {
5
+ command: "ai setup",
6
+ describe: "Run full AI onboarding wizard",
7
+ handler: async () => {
8
+ await runAISetupWizard("command")
9
+ },
10
+ }
@@ -0,0 +1,190 @@
1
+ import type { CommandModule } from "yargs"
2
+ import { AIChat } from "../../ai/chat"
3
+ import { AIProvider } from "../../ai/provider"
4
+ import { UI } from "../ui"
5
+ import readline from "readline"
6
+
7
+ export const ChatCommand: CommandModule = {
8
+ command: "chat",
9
+ aliases: ["c"],
10
+ describe: "Interactive AI chat — write posts, analyze code, browse the forum",
11
+ builder: (yargs) =>
12
+ yargs
13
+ .option("model", {
14
+ alias: "m",
15
+ describe: "Model to use (e.g. claude-sonnet-4-20250514, gpt-5.2)",
16
+ type: "string",
17
+ })
18
+ .option("prompt", {
19
+ alias: "p",
20
+ describe: "Single prompt (non-interactive mode)",
21
+ type: "string",
22
+ }),
23
+ handler: async (args) => {
24
+ const modelID = args.model as string | undefined
25
+
26
+ // Check AI key before doing anything
27
+ const hasKey = await AIProvider.hasAnyKey()
28
+ if (!hasKey) {
29
+ console.log("")
30
+ UI.warn("No AI provider configured. AI features require an API key.")
31
+ console.log("")
32
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}Configure an AI provider:${UI.Style.TEXT_NORMAL}`)
33
+ console.log("")
34
+ console.log(` ${UI.Style.TEXT_HIGHLIGHT}codeblog config --provider anthropic --api-key sk-ant-...${UI.Style.TEXT_NORMAL}`)
35
+ console.log(` ${UI.Style.TEXT_HIGHLIGHT}codeblog config --provider openai --api-key sk-...${UI.Style.TEXT_NORMAL}`)
36
+ console.log(` ${UI.Style.TEXT_HIGHLIGHT}codeblog config --provider google --api-key AIza...${UI.Style.TEXT_NORMAL}`)
37
+ console.log("")
38
+ console.log(` ${UI.Style.TEXT_DIM}Or set an environment variable: ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.${UI.Style.TEXT_NORMAL}`)
39
+ console.log(` ${UI.Style.TEXT_DIM}Run: codeblog config --list to see all 15+ supported providers${UI.Style.TEXT_NORMAL}`)
40
+ console.log("")
41
+ process.exitCode = 1
42
+ return
43
+ }
44
+
45
+ // Non-interactive: single prompt
46
+ if (args.prompt) {
47
+ try {
48
+ await AIChat.stream(
49
+ [{ role: "user", content: args.prompt as string }],
50
+ {
51
+ onToken: (token) => process.stdout.write(token),
52
+ onFinish: () => process.stdout.write("\n"),
53
+ onError: (err) => UI.error(err.message),
54
+ },
55
+ modelID,
56
+ )
57
+ } catch (err) {
58
+ UI.error(err instanceof Error ? err.message : String(err))
59
+ process.exitCode = 1
60
+ }
61
+ return
62
+ }
63
+
64
+ // Interactive REPL
65
+ const modelInfo = AIProvider.BUILTIN_MODELS[modelID || AIProvider.DEFAULT_MODEL]
66
+ const modelName = modelInfo?.name || modelID || AIProvider.DEFAULT_MODEL
67
+
68
+ console.log("")
69
+ console.log(` ${UI.Style.TEXT_HIGHLIGHT_BOLD}CodeBlog AI Chat${UI.Style.TEXT_NORMAL}`)
70
+ console.log(` ${UI.Style.TEXT_DIM}Model: ${modelName}${UI.Style.TEXT_NORMAL}`)
71
+ console.log(` ${UI.Style.TEXT_DIM}Type your message. Commands: /help /model /clear /exit${UI.Style.TEXT_NORMAL}`)
72
+ console.log("")
73
+
74
+ const messages: AIChat.Message[] = []
75
+ const rl = readline.createInterface({
76
+ input: process.stdin,
77
+ output: process.stdout,
78
+ prompt: `${UI.Style.TEXT_HIGHLIGHT}❯ ${UI.Style.TEXT_NORMAL}`,
79
+ })
80
+
81
+ let currentModel = modelID
82
+
83
+ rl.prompt()
84
+
85
+ rl.on("line", async (line) => {
86
+ const input = line.trim()
87
+ if (!input) {
88
+ rl.prompt()
89
+ return
90
+ }
91
+
92
+ // Handle commands
93
+ if (input.startsWith("/")) {
94
+ const cmd = input.split(" ")[0]!
95
+ const rest = input.slice(cmd.length).trim()
96
+
97
+ if (cmd === "/exit" || cmd === "/quit" || cmd === "/q") {
98
+ console.log("")
99
+ UI.info("Bye!")
100
+ rl.close()
101
+ return
102
+ }
103
+
104
+ if (cmd === "/clear") {
105
+ messages.length = 0
106
+ console.log(` ${UI.Style.TEXT_DIM}Chat history cleared${UI.Style.TEXT_NORMAL}`)
107
+ rl.prompt()
108
+ return
109
+ }
110
+
111
+ if (cmd === "/model") {
112
+ if (rest) {
113
+ currentModel = rest
114
+ console.log(` ${UI.Style.TEXT_SUCCESS}Model: ${rest}${UI.Style.TEXT_NORMAL}`)
115
+ } else {
116
+ const current = AIProvider.BUILTIN_MODELS[currentModel || AIProvider.DEFAULT_MODEL]
117
+ console.log(` ${UI.Style.TEXT_DIM}Current: ${current?.name || currentModel || AIProvider.DEFAULT_MODEL}${UI.Style.TEXT_NORMAL}`)
118
+ console.log(` ${UI.Style.TEXT_DIM}Built-in: ${Object.keys(AIProvider.BUILTIN_MODELS).join(", ")}${UI.Style.TEXT_NORMAL}`)
119
+ console.log(` ${UI.Style.TEXT_DIM}Any model from models.dev works too (e.g. anthropic/claude-sonnet-4-20250514)${UI.Style.TEXT_NORMAL}`)
120
+ }
121
+ rl.prompt()
122
+ return
123
+ }
124
+
125
+ if (cmd === "/help") {
126
+ console.log("")
127
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}Commands${UI.Style.TEXT_NORMAL}`)
128
+ console.log(` ${UI.Style.TEXT_DIM}/model [id]${UI.Style.TEXT_NORMAL} Switch or show model`)
129
+ console.log(` ${UI.Style.TEXT_DIM}/clear${UI.Style.TEXT_NORMAL} Clear chat history`)
130
+ console.log(` ${UI.Style.TEXT_DIM}/exit${UI.Style.TEXT_NORMAL} Exit chat`)
131
+ console.log("")
132
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}Tips${UI.Style.TEXT_NORMAL}`)
133
+ console.log(` ${UI.Style.TEXT_DIM}Ask me to write a blog post, analyze code, draft comments,${UI.Style.TEXT_NORMAL}`)
134
+ console.log(` ${UI.Style.TEXT_DIM}summarize discussions, or generate tags and titles.${UI.Style.TEXT_NORMAL}`)
135
+ console.log("")
136
+ rl.prompt()
137
+ return
138
+ }
139
+
140
+ console.log(` ${UI.Style.TEXT_DIM}Unknown command: ${cmd}. Type /help${UI.Style.TEXT_NORMAL}`)
141
+ rl.prompt()
142
+ return
143
+ }
144
+
145
+ // Send message to AI
146
+ messages.push({ role: "user", content: input })
147
+
148
+ console.log("")
149
+ process.stdout.write(` ${UI.Style.TEXT_INFO}`)
150
+
151
+ try {
152
+ let response = ""
153
+ await AIChat.stream(
154
+ messages,
155
+ {
156
+ onToken: (token) => {
157
+ process.stdout.write(token)
158
+ response += token
159
+ },
160
+ onFinish: () => {
161
+ process.stdout.write(UI.Style.TEXT_NORMAL)
162
+ console.log("")
163
+ console.log("")
164
+ },
165
+ onError: (err) => {
166
+ process.stdout.write(UI.Style.TEXT_NORMAL)
167
+ console.log("")
168
+ UI.error(err.message)
169
+ },
170
+ },
171
+ currentModel,
172
+ )
173
+ messages.push({ role: "assistant", content: response })
174
+ } catch (err) {
175
+ process.stdout.write(UI.Style.TEXT_NORMAL)
176
+ console.log("")
177
+ UI.error(err instanceof Error ? err.message : String(err))
178
+ }
179
+
180
+ rl.prompt()
181
+ })
182
+
183
+ rl.on("close", () => {
184
+ process.exit(0)
185
+ })
186
+
187
+ // Keep process alive
188
+ await new Promise(() => {})
189
+ },
190
+ }