codeblog-app 2.1.2 → 2.1.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 (69) 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 +71 -8
  5. package/src/ai/__tests__/chat.test.ts +110 -0
  6. package/src/ai/__tests__/provider.test.ts +184 -0
  7. package/src/ai/__tests__/tools.test.ts +90 -0
  8. package/src/ai/chat.ts +169 -0
  9. package/src/ai/configure.ts +134 -0
  10. package/src/ai/provider.ts +238 -0
  11. package/src/ai/tools.ts +336 -0
  12. package/src/auth/index.ts +47 -0
  13. package/src/auth/oauth.ts +94 -0
  14. package/src/cli/__tests__/commands.test.ts +225 -0
  15. package/src/cli/cmd/agent.ts +97 -0
  16. package/src/cli/cmd/chat.ts +190 -0
  17. package/src/cli/cmd/comment.ts +67 -0
  18. package/src/cli/cmd/config.ts +153 -0
  19. package/src/cli/cmd/feed.ts +53 -0
  20. package/src/cli/cmd/forum.ts +106 -0
  21. package/src/cli/cmd/login.ts +45 -0
  22. package/src/cli/cmd/logout.ts +12 -0
  23. package/src/cli/cmd/me.ts +188 -0
  24. package/src/cli/cmd/post.ts +25 -0
  25. package/src/cli/cmd/publish.ts +64 -0
  26. package/src/cli/cmd/scan.ts +78 -0
  27. package/src/cli/cmd/search.ts +35 -0
  28. package/src/cli/cmd/setup.ts +273 -0
  29. package/src/cli/cmd/tui.ts +20 -0
  30. package/src/cli/cmd/uninstall.ts +156 -0
  31. package/src/cli/cmd/update.ts +78 -0
  32. package/src/cli/cmd/vote.ts +50 -0
  33. package/src/cli/cmd/whoami.ts +18 -0
  34. package/src/cli/mcp-print.ts +6 -0
  35. package/src/cli/ui.ts +195 -0
  36. package/src/config/index.ts +54 -0
  37. package/src/flag/index.ts +23 -0
  38. package/src/global/index.ts +38 -0
  39. package/src/id/index.ts +20 -0
  40. package/src/index.ts +200 -0
  41. package/src/mcp/__tests__/client.test.ts +149 -0
  42. package/src/mcp/__tests__/e2e.ts +327 -0
  43. package/src/mcp/__tests__/integration.ts +148 -0
  44. package/src/mcp/client.ts +148 -0
  45. package/src/server/index.ts +48 -0
  46. package/src/storage/chat.ts +71 -0
  47. package/src/storage/db.ts +85 -0
  48. package/src/storage/schema.sql.ts +39 -0
  49. package/src/storage/schema.ts +1 -0
  50. package/src/tui/app.tsx +179 -0
  51. package/src/tui/commands.ts +187 -0
  52. package/src/tui/context/exit.tsx +15 -0
  53. package/src/tui/context/helper.tsx +25 -0
  54. package/src/tui/context/route.tsx +24 -0
  55. package/src/tui/context/theme.tsx +470 -0
  56. package/src/tui/routes/home.tsx +508 -0
  57. package/src/tui/routes/model.tsx +207 -0
  58. package/src/tui/routes/notifications.tsx +87 -0
  59. package/src/tui/routes/post.tsx +102 -0
  60. package/src/tui/routes/search.tsx +105 -0
  61. package/src/tui/routes/setup.tsx +255 -0
  62. package/src/tui/routes/trending.tsx +107 -0
  63. package/src/util/__tests__/context.test.ts +31 -0
  64. package/src/util/__tests__/lazy.test.ts +37 -0
  65. package/src/util/context.ts +23 -0
  66. package/src/util/error.ts +46 -0
  67. package/src/util/lazy.ts +18 -0
  68. package/src/util/log.ts +142 -0
  69. package/tsconfig.json +11 -0
@@ -0,0 +1,225 @@
1
+ import { describe, test, expect, mock, beforeEach } 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 = undefined as any
57
+ })
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // ScanCommand
61
+ // ---------------------------------------------------------------------------
62
+ describe("ScanCommand", () => {
63
+ test("has correct command name and describe", () => {
64
+ expect(ScanCommand.command).toBe("scan")
65
+ expect(ScanCommand.describe).toBeTruthy()
66
+ })
67
+
68
+ test("handler calls scan_sessions MCP tool", async () => {
69
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("[]"))
70
+ await (ScanCommand.handler as any)({ limit: 10 })
71
+ expect(mockCallTool).toHaveBeenCalledWith("scan_sessions", { limit: 10 })
72
+ })
73
+
74
+ test("handler calls codeblog_status when --status flag", async () => {
75
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("Status: OK"))
76
+ await (ScanCommand.handler as any)({ status: true, limit: 20 })
77
+ expect(mockCallTool).toHaveBeenCalledWith("codeblog_status")
78
+ })
79
+
80
+ test("handler passes source when provided", async () => {
81
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("[]"))
82
+ await (ScanCommand.handler as any)({ limit: 5, source: "cursor" })
83
+ expect(mockCallTool).toHaveBeenCalledWith("scan_sessions", { limit: 5, source: "cursor" })
84
+ })
85
+
86
+ test("handler sets exitCode on error", async () => {
87
+ mockCallTool.mockImplementationOnce(() => Promise.reject(new Error("fail")))
88
+ await (ScanCommand.handler as any)({ limit: 10 })
89
+ expect(process.exitCode).toBe(1)
90
+ expect(mockError).toHaveBeenCalled()
91
+ })
92
+ })
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // FeedCommand
96
+ // ---------------------------------------------------------------------------
97
+ describe("FeedCommand", () => {
98
+ test("has correct command name", () => {
99
+ expect(FeedCommand.command).toBe("feed")
100
+ })
101
+
102
+ test("handler calls browse_posts MCP tool", async () => {
103
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("post1\npost2"))
104
+ await (FeedCommand.handler as any)({ limit: 15, page: 1, sort: "new" })
105
+ expect(mockCallTool).toHaveBeenCalledWith("browse_posts", {
106
+ limit: 15,
107
+ page: 1,
108
+ sort: "new",
109
+ })
110
+ })
111
+
112
+ test("handler includes tag filter when provided", async () => {
113
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("post1"))
114
+ await (FeedCommand.handler as any)({ limit: 10, page: 1, sort: "new", tag: "react" })
115
+ expect(mockCallTool).toHaveBeenCalledWith("browse_posts", {
116
+ limit: 10,
117
+ page: 1,
118
+ sort: "new",
119
+ tag: "react",
120
+ })
121
+ })
122
+
123
+ test("handler sets exitCode on error", async () => {
124
+ mockCallTool.mockImplementationOnce(() => Promise.reject(new Error("network")))
125
+ await (FeedCommand.handler as any)({ limit: 10, page: 1, sort: "new" })
126
+ expect(process.exitCode).toBe(1)
127
+ })
128
+ })
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // SearchCommand
132
+ // ---------------------------------------------------------------------------
133
+ describe("SearchCommand", () => {
134
+ test("has correct command format", () => {
135
+ expect(SearchCommand.command).toBe("search <query>")
136
+ })
137
+
138
+ test("handler calls search_posts MCP tool", async () => {
139
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("result1"))
140
+ await (SearchCommand.handler as any)({ query: "typescript", limit: 20 })
141
+ expect(mockCallTool).toHaveBeenCalledWith("search_posts", {
142
+ query: "typescript",
143
+ limit: 20,
144
+ })
145
+ })
146
+
147
+ test("handler sets exitCode on error", async () => {
148
+ mockCallTool.mockImplementationOnce(() => Promise.reject(new Error("search failed")))
149
+ await (SearchCommand.handler as any)({ query: "test", limit: 10 })
150
+ expect(process.exitCode).toBe(1)
151
+ })
152
+ })
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // PublishCommand
156
+ // ---------------------------------------------------------------------------
157
+ describe("PublishCommand", () => {
158
+ test("has correct command name", () => {
159
+ expect(PublishCommand.command).toBe("publish")
160
+ })
161
+
162
+ test("handler calls auto_post for normal publish", async () => {
163
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("Published!"))
164
+ await (PublishCommand.handler as any)({ dryRun: false, weekly: false })
165
+ expect(mockCallTool).toHaveBeenCalledWith("auto_post", {
166
+ dry_run: false,
167
+ })
168
+ })
169
+
170
+ test("handler passes dry_run correctly when true", async () => {
171
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("Preview"))
172
+ await (PublishCommand.handler as any)({ dryRun: true, weekly: false })
173
+ expect(mockCallTool).toHaveBeenCalledWith("auto_post", {
174
+ dry_run: true,
175
+ })
176
+ })
177
+
178
+ test("handler calls weekly_digest for --weekly", async () => {
179
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("Digest"))
180
+ await (PublishCommand.handler as any)({ dryRun: false, weekly: true })
181
+ expect(mockCallTool).toHaveBeenCalledWith("weekly_digest", {
182
+ dry_run: false,
183
+ post: true,
184
+ })
185
+ })
186
+
187
+ test("weekly with dry-run sets dry_run true", async () => {
188
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("Digest preview"))
189
+ await (PublishCommand.handler as any)({ dryRun: true, weekly: true })
190
+ expect(mockCallTool).toHaveBeenCalledWith("weekly_digest", {
191
+ dry_run: true,
192
+ })
193
+ })
194
+
195
+ test("handler passes source and style options", async () => {
196
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("OK"))
197
+ await (PublishCommand.handler as any)({
198
+ dryRun: false,
199
+ weekly: false,
200
+ source: "cursor",
201
+ style: "bug-story",
202
+ })
203
+ expect(mockCallTool).toHaveBeenCalledWith("auto_post", {
204
+ dry_run: false,
205
+ source: "cursor",
206
+ style: "bug-story",
207
+ })
208
+ })
209
+
210
+ test("handler sets exitCode on error", async () => {
211
+ mockCallTool.mockImplementationOnce(() => Promise.reject(new Error("publish failed")))
212
+ await (PublishCommand.handler as any)({ dryRun: false, weekly: false })
213
+ expect(process.exitCode).toBe(1)
214
+ })
215
+
216
+ // Regression test: dry_run should NOT always be true
217
+ test("REGRESSION: publish --weekly does NOT always set dry_run=true", async () => {
218
+ mockCallTool.mockImplementationOnce(() => Promise.resolve("Posted"))
219
+ await (PublishCommand.handler as any)({ dryRun: false, weekly: true })
220
+ const callArgs = mockCallTool.mock.calls[0]
221
+ expect(callArgs![1]).toHaveProperty("dry_run", false)
222
+ expect(callArgs![1]).toHaveProperty("post", true)
223
+ })
224
+ })
225
+ })
@@ -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,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-4o)",
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
+ }
@@ -0,0 +1,67 @@
1
+ import type { CommandModule } from "yargs"
2
+ import { mcpPrint } from "../mcp-print"
3
+ import { UI } from "../ui"
4
+
5
+ export const CommentCommand: CommandModule = {
6
+ command: "comment <post_id>",
7
+ describe: "Comment on a post",
8
+ builder: (yargs) =>
9
+ yargs
10
+ .positional("post_id", {
11
+ describe: "Post ID to comment on",
12
+ type: "string",
13
+ demandOption: true,
14
+ })
15
+ .option("reply", {
16
+ alias: "r",
17
+ describe: "Reply to a specific comment by its ID",
18
+ type: "string",
19
+ }),
20
+ handler: async (args) => {
21
+ const postId = args.post_id as string
22
+
23
+ console.log("")
24
+ console.log(` ${UI.Style.TEXT_DIM}Write your comment (end with an empty line):${UI.Style.TEXT_NORMAL}`)
25
+ console.log("")
26
+
27
+ // Read multiline input
28
+ const lines: string[] = []
29
+ const readline = require("readline")
30
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
31
+
32
+ const content = await new Promise<string>((resolve) => {
33
+ rl.on("line", (line: string) => {
34
+ if (line === "" && lines.length > 0) {
35
+ rl.close()
36
+ resolve(lines.join("\n"))
37
+ } else {
38
+ lines.push(line)
39
+ }
40
+ })
41
+ rl.on("close", () => {
42
+ resolve(lines.join("\n"))
43
+ })
44
+ rl.prompt()
45
+ })
46
+
47
+ if (!content.trim()) {
48
+ UI.warn("Empty comment, skipped.")
49
+ return
50
+ }
51
+
52
+ try {
53
+ const mcpArgs: Record<string, unknown> = {
54
+ post_id: postId,
55
+ content: content.trim(),
56
+ }
57
+ if (args.reply) mcpArgs.parent_id = args.reply
58
+
59
+ console.log("")
60
+ await mcpPrint("comment_on_post", mcpArgs)
61
+ console.log("")
62
+ } catch (err) {
63
+ UI.error(`Comment failed: ${err instanceof Error ? err.message : String(err)}`)
64
+ process.exitCode = 1
65
+ }
66
+ },
67
+ }