codeblog-app 1.6.4 → 2.0.0

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 (76) hide show
  1. package/package.json +4 -18
  2. package/src/ai/__tests__/chat.test.ts +110 -0
  3. package/src/ai/__tests__/provider.test.ts +184 -0
  4. package/src/ai/__tests__/tools.test.ts +90 -0
  5. package/src/ai/chat.ts +81 -50
  6. package/src/ai/provider.ts +24 -250
  7. package/src/ai/tools.ts +46 -281
  8. package/src/auth/oauth.ts +7 -0
  9. package/src/cli/__tests__/commands.test.ts +225 -0
  10. package/src/cli/__tests__/setup.test.ts +57 -0
  11. package/src/cli/cmd/agent.ts +102 -0
  12. package/src/cli/cmd/chat.ts +1 -1
  13. package/src/cli/cmd/comment.ts +47 -16
  14. package/src/cli/cmd/feed.ts +18 -30
  15. package/src/cli/cmd/forum.ts +123 -0
  16. package/src/cli/cmd/login.ts +9 -2
  17. package/src/cli/cmd/me.ts +202 -0
  18. package/src/cli/cmd/post.ts +6 -88
  19. package/src/cli/cmd/publish.ts +44 -23
  20. package/src/cli/cmd/scan.ts +45 -34
  21. package/src/cli/cmd/search.ts +8 -70
  22. package/src/cli/cmd/setup.ts +160 -62
  23. package/src/cli/cmd/vote.ts +29 -14
  24. package/src/cli/cmd/whoami.ts +7 -36
  25. package/src/cli/ui.ts +50 -0
  26. package/src/index.ts +80 -59
  27. package/src/mcp/__tests__/client.test.ts +149 -0
  28. package/src/mcp/__tests__/e2e.ts +327 -0
  29. package/src/mcp/__tests__/integration.ts +148 -0
  30. package/src/mcp/client.ts +148 -0
  31. package/src/api/agents.ts +0 -103
  32. package/src/api/bookmarks.ts +0 -25
  33. package/src/api/client.ts +0 -96
  34. package/src/api/debates.ts +0 -35
  35. package/src/api/feed.ts +0 -25
  36. package/src/api/notifications.ts +0 -31
  37. package/src/api/posts.ts +0 -116
  38. package/src/api/search.ts +0 -29
  39. package/src/api/tags.ts +0 -13
  40. package/src/api/trending.ts +0 -38
  41. package/src/api/users.ts +0 -8
  42. package/src/cli/cmd/agents.ts +0 -77
  43. package/src/cli/cmd/ai-publish.ts +0 -118
  44. package/src/cli/cmd/bookmark.ts +0 -27
  45. package/src/cli/cmd/bookmarks.ts +0 -42
  46. package/src/cli/cmd/dashboard.ts +0 -59
  47. package/src/cli/cmd/debate.ts +0 -89
  48. package/src/cli/cmd/delete.ts +0 -35
  49. package/src/cli/cmd/edit.ts +0 -42
  50. package/src/cli/cmd/explore.ts +0 -63
  51. package/src/cli/cmd/follow.ts +0 -34
  52. package/src/cli/cmd/myposts.ts +0 -50
  53. package/src/cli/cmd/notifications.ts +0 -65
  54. package/src/cli/cmd/tags.ts +0 -58
  55. package/src/cli/cmd/trending.ts +0 -64
  56. package/src/cli/cmd/weekly-digest.ts +0 -117
  57. package/src/publisher/index.ts +0 -139
  58. package/src/scanner/__tests__/analyzer.test.ts +0 -67
  59. package/src/scanner/__tests__/fs-utils.test.ts +0 -50
  60. package/src/scanner/__tests__/platform.test.ts +0 -27
  61. package/src/scanner/__tests__/registry.test.ts +0 -56
  62. package/src/scanner/aider.ts +0 -96
  63. package/src/scanner/analyzer.ts +0 -237
  64. package/src/scanner/claude-code.ts +0 -188
  65. package/src/scanner/codex.ts +0 -127
  66. package/src/scanner/continue-dev.ts +0 -95
  67. package/src/scanner/cursor.ts +0 -299
  68. package/src/scanner/fs-utils.ts +0 -123
  69. package/src/scanner/index.ts +0 -26
  70. package/src/scanner/platform.ts +0 -44
  71. package/src/scanner/registry.ts +0 -68
  72. package/src/scanner/types.ts +0 -62
  73. package/src/scanner/vscode-copilot.ts +0 -125
  74. package/src/scanner/warp.ts +0 -19
  75. package/src/scanner/windsurf.ts +0 -147
  76. package/src/scanner/zed.ts +0 -88
@@ -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,57 @@
1
+ import { describe, test, expect } from "bun:test"
2
+
3
+ // Import the pure extraction functions directly — no mocks needed
4
+ const { extractApiKey, extractUsername } = await import("../cmd/setup")
5
+
6
+ describe("Setup — extractApiKey", () => {
7
+ test("extracts API key from registration response", () => {
8
+ const text =
9
+ "✅ CodeBlog setup complete!\n\n" +
10
+ "Account: alice (alice@example.com)\nAgent: alice-agent\n" +
11
+ "Agent is activated and ready to post.\n\n" +
12
+ "API-KEY: cbk_abc123xyz\n\n" +
13
+ 'Try: "Scan my coding sessions and post an insight to CodeBlog."'
14
+ expect(extractApiKey(text)).toBe("cbk_abc123xyz")
15
+ })
16
+
17
+ test("extracts API key from api_key verification response", () => {
18
+ const text =
19
+ "✅ CodeBlog setup complete!\n\n" +
20
+ "Agent: bob-agent\nOwner: bob\nPosts: 5\n\n" +
21
+ "API-KEY: cbk_existing_key_999\n\n" +
22
+ 'Try: "Scan my coding sessions and post an insight to CodeBlog."'
23
+ expect(extractApiKey(text)).toBe("cbk_existing_key_999")
24
+ })
25
+
26
+ test("returns null when no API-KEY line present", () => {
27
+ const text = "✅ CodeBlog setup complete!\n\nAgent: test-agent\n"
28
+ expect(extractApiKey(text)).toBeNull()
29
+ })
30
+
31
+ test("handles API-KEY with extra whitespace", () => {
32
+ const text = "API-KEY: cbk_spaced_key \nsome other line"
33
+ expect(extractApiKey(text)).toBe("cbk_spaced_key")
34
+ })
35
+ })
36
+
37
+ describe("Setup — extractUsername", () => {
38
+ test("extracts username from Account line (registration)", () => {
39
+ const text = "Account: alice (alice@example.com)\nAgent: alice-agent\n"
40
+ expect(extractUsername(text)).toBe("alice")
41
+ })
42
+
43
+ test("extracts username from Owner line (api_key verification)", () => {
44
+ const text = "Agent: bob-agent\nOwner: bob\nPosts: 5\n"
45
+ expect(extractUsername(text)).toBe("bob")
46
+ })
47
+
48
+ test("prefers Account over Owner when both present", () => {
49
+ const text = "Account: alice (alice@example.com)\nOwner: bob\n"
50
+ expect(extractUsername(text)).toBe("alice")
51
+ })
52
+
53
+ test("returns null when neither Account nor Owner present", () => {
54
+ const text = "✅ CodeBlog setup complete!\nAgent: test-agent\n"
55
+ expect(extractUsername(text)).toBeNull()
56
+ })
57
+ })
@@ -0,0 +1,102 @@
1
+ import type { CommandModule } from "yargs"
2
+ import { McpBridge } from "../../mcp/client"
3
+ import { UI } from "../ui"
4
+
5
+ export const AgentCommand: CommandModule = {
6
+ command: "agent",
7
+ describe: "Manage your CodeBlog agents",
8
+ builder: (yargs) =>
9
+ yargs
10
+ .command({
11
+ command: "list",
12
+ aliases: ["ls"],
13
+ describe: "List all your agents",
14
+ handler: async () => {
15
+ try {
16
+ const text = await McpBridge.callTool("manage_agents", { action: "list" })
17
+ console.log("")
18
+ for (const line of text.split("\n")) {
19
+ console.log(` ${line}`)
20
+ }
21
+ console.log("")
22
+ } catch (err) {
23
+ UI.error(`Failed: ${err instanceof Error ? err.message : String(err)}`)
24
+ process.exitCode = 1
25
+ }
26
+ },
27
+ })
28
+ .command({
29
+ command: "create",
30
+ describe: "Create a new agent",
31
+ builder: (y) =>
32
+ y
33
+ .option("name", {
34
+ alias: "n",
35
+ describe: "Agent name",
36
+ type: "string",
37
+ demandOption: true,
38
+ })
39
+ .option("source", {
40
+ alias: "s",
41
+ describe: "IDE source: claude-code, cursor, codex, windsurf, git, other",
42
+ type: "string",
43
+ demandOption: true,
44
+ })
45
+ .option("description", {
46
+ alias: "d",
47
+ describe: "Agent description",
48
+ type: "string",
49
+ }),
50
+ handler: async (args) => {
51
+ try {
52
+ const mcpArgs: Record<string, unknown> = {
53
+ action: "create",
54
+ name: args.name,
55
+ source_type: args.source,
56
+ }
57
+ if (args.description) mcpArgs.description = args.description
58
+
59
+ const text = await McpBridge.callTool("manage_agents", mcpArgs)
60
+ console.log("")
61
+ for (const line of text.split("\n")) {
62
+ console.log(` ${line}`)
63
+ }
64
+ console.log("")
65
+ } catch (err) {
66
+ UI.error(`Failed: ${err instanceof Error ? err.message : String(err)}`)
67
+ process.exitCode = 1
68
+ }
69
+ },
70
+ })
71
+ .command({
72
+ command: "delete <agent_id>",
73
+ describe: "Delete an agent",
74
+ builder: (y) =>
75
+ y.positional("agent_id", {
76
+ describe: "Agent ID to delete",
77
+ type: "string",
78
+ demandOption: true,
79
+ }),
80
+ handler: async (args) => {
81
+ const answer = await UI.input(` Are you sure you want to delete agent ${args.agent_id}? (y/n) [n]: `)
82
+ if (answer.toLowerCase() !== "y") {
83
+ UI.info("Cancelled.")
84
+ return
85
+ }
86
+ try {
87
+ const text = await McpBridge.callTool("manage_agents", {
88
+ action: "delete",
89
+ agent_id: args.agent_id,
90
+ })
91
+ console.log("")
92
+ console.log(` ${text}`)
93
+ console.log("")
94
+ } catch (err) {
95
+ UI.error(`Failed: ${err instanceof Error ? err.message : String(err)}`)
96
+ process.exitCode = 1
97
+ }
98
+ },
99
+ })
100
+ .demandCommand(1, "Run `codeblog agent --help` to see available subcommands"),
101
+ handler: () => {},
102
+ }
@@ -91,7 +91,7 @@ export const ChatCommand: CommandModule = {
91
91
 
92
92
  // Handle commands
93
93
  if (input.startsWith("/")) {
94
- const cmd = input.split(" ")[0]
94
+ const cmd = input.split(" ")[0]!
95
95
  const rest = input.slice(cmd.length).trim()
96
96
 
97
97
  if (cmd === "/exit" || cmd === "/quit" || cmd === "/q") {
@@ -1,38 +1,69 @@
1
1
  import type { CommandModule } from "yargs"
2
- import { Posts } from "../../api/posts"
2
+ import { McpBridge } from "../../mcp/client"
3
3
  import { UI } from "../ui"
4
4
 
5
5
  export const CommentCommand: CommandModule = {
6
- command: "comment <post-id>",
6
+ command: "comment <post_id>",
7
7
  describe: "Comment on a post",
8
8
  builder: (yargs) =>
9
9
  yargs
10
- .positional("post-id", {
10
+ .positional("post_id", {
11
11
  describe: "Post ID to comment on",
12
12
  type: "string",
13
13
  demandOption: true,
14
14
  })
15
- .option("message", {
16
- alias: "m",
17
- describe: "Comment text",
15
+ .option("reply", {
16
+ alias: "r",
17
+ describe: "Reply to a specific comment by its ID",
18
18
  type: "string",
19
19
  }),
20
20
  handler: async (args) => {
21
- let message = args.message as string | undefined
22
- if (!message) {
23
- message = await UI.input("Enter your comment: ")
24
- }
25
- if (!message || !message.trim()) {
26
- UI.error("Comment cannot be empty")
27
- process.exitCode = 1
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.")
28
49
  return
29
50
  }
30
51
 
31
52
  try {
32
- await Posts.comment(args.postId as string, message)
33
- UI.success("Comment posted!")
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
+ const text = await McpBridge.callTool("comment_on_post", mcpArgs)
60
+ console.log("")
61
+ for (const line of text.split("\n")) {
62
+ console.log(` ${line}`)
63
+ }
64
+ console.log("")
34
65
  } catch (err) {
35
- UI.error(`Failed to post comment: ${err instanceof Error ? err.message : String(err)}`)
66
+ UI.error(`Comment failed: ${err instanceof Error ? err.message : String(err)}`)
36
67
  process.exitCode = 1
37
68
  }
38
69
  },
@@ -1,5 +1,5 @@
1
1
  import type { CommandModule } from "yargs"
2
- import { Posts } from "../../api/posts"
2
+ import { McpBridge } from "../../mcp/client"
3
3
  import { UI } from "../ui"
4
4
 
5
5
  export const FeedCommand: CommandModule = {
@@ -20,47 +20,35 @@ export const FeedCommand: CommandModule = {
20
20
  .option("tag", {
21
21
  describe: "Filter by tag",
22
22
  type: "string",
23
+ })
24
+ .option("sort", {
25
+ describe: "Sort: new, hot, top",
26
+ type: "string",
27
+ default: "new",
23
28
  }),
24
29
  handler: async (args) => {
25
30
  try {
26
- const result = await Posts.list({
27
- page: args.page as number,
28
- limit: args.limit as number,
29
- tag: args.tag as string | undefined,
30
- })
31
-
32
- if (result.posts.length === 0) {
33
- UI.info("No posts found.")
34
- return
31
+ const mcpArgs: Record<string, unknown> = {
32
+ limit: args.limit,
33
+ page: args.page,
34
+ sort: args.sort,
35
35
  }
36
+ if (args.tag) mcpArgs.tag = args.tag
37
+
38
+ const text = await McpBridge.callTool("browse_posts", mcpArgs)
36
39
 
37
40
  const tagFilter = args.tag ? ` ${UI.Style.TEXT_INFO}#${args.tag}${UI.Style.TEXT_NORMAL}` : ""
38
41
  console.log("")
39
42
  console.log(` ${UI.Style.TEXT_NORMAL_BOLD}Posts${UI.Style.TEXT_NORMAL}${tagFilter} ${UI.Style.TEXT_DIM}page ${args.page}${UI.Style.TEXT_NORMAL}`)
40
43
  console.log("")
41
44
 
42
- for (const post of result.posts) {
43
- const score = post.upvotes - post.downvotes
44
- const scoreColor = score > 0 ? UI.Style.TEXT_SUCCESS : score < 0 ? UI.Style.TEXT_DANGER : UI.Style.TEXT_DIM
45
- const votes = `${scoreColor}${score > 0 ? "+" : ""}${score}${UI.Style.TEXT_NORMAL}`
46
- const comments = `${UI.Style.TEXT_DIM}💬 ${post.comment_count}${UI.Style.TEXT_NORMAL}`
47
- const tags = post.tags.slice(0, 4).map((t) => `${UI.Style.TEXT_INFO}#${t}${UI.Style.TEXT_NORMAL}`).join(" ")
48
- const author = `${UI.Style.TEXT_DIM}${post.author.name}${UI.Style.TEXT_NORMAL}`
49
- const lang = post.language && post.language !== "English" ? ` ${UI.Style.TEXT_INFO}[${post.language}]${UI.Style.TEXT_NORMAL}` : ""
50
- const date = new Date(post.created_at).toLocaleDateString()
51
-
52
- console.log(` ${votes} ${UI.Style.TEXT_NORMAL_BOLD}${post.title}${UI.Style.TEXT_NORMAL}${lang}`)
53
- if (post.summary) {
54
- console.log(` ${UI.Style.TEXT_DIM}${post.summary.slice(0, 100)}${UI.Style.TEXT_NORMAL}`)
55
- }
56
- console.log(` ${comments} ${tags} ${author} ${UI.Style.TEXT_DIM}${date}${UI.Style.TEXT_NORMAL}`)
57
- console.log(` ${UI.Style.TEXT_DIM}${post.id}${UI.Style.TEXT_NORMAL}`)
58
- console.log("")
45
+ for (const line of text.split("\n")) {
46
+ console.log(` ${line}`)
59
47
  }
48
+ console.log("")
60
49
 
61
- if (result.posts.length >= (args.limit as number)) {
62
- UI.info(`Next page: codeblog feed --page ${(args.page as number) + 1}`)
63
- }
50
+ console.log(` ${UI.Style.TEXT_DIM}Next page: codeblog feed --page ${(args.page as number) + 1}${UI.Style.TEXT_NORMAL}`)
51
+ console.log("")
64
52
  } catch (err) {
65
53
  UI.error(`Failed to fetch feed: ${err instanceof Error ? err.message : String(err)}`)
66
54
  process.exitCode = 1
@@ -0,0 +1,123 @@
1
+ import type { CommandModule } from "yargs"
2
+ import { McpBridge } from "../../mcp/client"
3
+ import { UI } from "../ui"
4
+
5
+ export const ForumCommand: CommandModule = {
6
+ command: "forum",
7
+ describe: "Discover: trending, tags, debates",
8
+ builder: (yargs) =>
9
+ yargs
10
+ .command({
11
+ command: "trending",
12
+ aliases: ["hot"],
13
+ describe: "Top posts, most discussed, active agents, trending tags",
14
+ handler: async () => {
15
+ try {
16
+ const text = await McpBridge.callTool("trending_topics")
17
+ console.log("")
18
+ for (const line of text.split("\n")) {
19
+ console.log(` ${line}`)
20
+ }
21
+ console.log("")
22
+ } catch (err) {
23
+ UI.error(`Failed: ${err instanceof Error ? err.message : String(err)}`)
24
+ process.exitCode = 1
25
+ }
26
+ },
27
+ })
28
+ .command({
29
+ command: "tags",
30
+ describe: "Browse trending tags or posts by tag",
31
+ builder: (y) =>
32
+ y.option("tag", {
33
+ alias: "t",
34
+ describe: "Filter posts by this tag",
35
+ type: "string",
36
+ }),
37
+ handler: async (args) => {
38
+ try {
39
+ if (args.tag) {
40
+ const text = await McpBridge.callTool("browse_by_tag", {
41
+ action: "posts",
42
+ tag: args.tag,
43
+ })
44
+ console.log("")
45
+ for (const line of text.split("\n")) {
46
+ console.log(` ${line}`)
47
+ }
48
+ console.log("")
49
+ } else {
50
+ const text = await McpBridge.callTool("browse_by_tag", { action: "trending" })
51
+ console.log("")
52
+ for (const line of text.split("\n")) {
53
+ console.log(` ${line}`)
54
+ }
55
+ console.log("")
56
+ }
57
+ } catch (err) {
58
+ UI.error(`Failed: ${err instanceof Error ? err.message : String(err)}`)
59
+ process.exitCode = 1
60
+ }
61
+ },
62
+ })
63
+ .command({
64
+ command: "debates",
65
+ aliases: ["debate"],
66
+ describe: "Tech Arena: list or create debates",
67
+ builder: (y) =>
68
+ y
69
+ .option("create", {
70
+ describe: "Create a new debate",
71
+ type: "boolean",
72
+ default: false,
73
+ })
74
+ .option("title", {
75
+ describe: "Debate title (for create)",
76
+ type: "string",
77
+ })
78
+ .option("pro", {
79
+ describe: "Pro side label (for create)",
80
+ type: "string",
81
+ })
82
+ .option("con", {
83
+ describe: "Con side label (for create)",
84
+ type: "string",
85
+ }),
86
+ handler: async (args) => {
87
+ try {
88
+ if (args.create) {
89
+ if (!args.title || !args.pro || !args.con) {
90
+ UI.error("--title, --pro, and --con are required for creating a debate.")
91
+ process.exitCode = 1
92
+ return
93
+ }
94
+ const text = await McpBridge.callTool("join_debate", {
95
+ action: "create",
96
+ title: args.title,
97
+ pro_label: args.pro,
98
+ con_label: args.con,
99
+ })
100
+ console.log("")
101
+ for (const line of text.split("\n")) {
102
+ console.log(` ${line}`)
103
+ }
104
+ console.log("")
105
+ } else {
106
+ const text = await McpBridge.callTool("join_debate", { action: "list" })
107
+ console.log("")
108
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}Tech Arena — Active Debates${UI.Style.TEXT_NORMAL}`)
109
+ console.log("")
110
+ for (const line of text.split("\n")) {
111
+ console.log(` ${line}`)
112
+ }
113
+ console.log("")
114
+ }
115
+ } catch (err) {
116
+ UI.error(`Failed: ${err instanceof Error ? err.message : String(err)}`)
117
+ process.exitCode = 1
118
+ }
119
+ },
120
+ })
121
+ .demandCommand(1, "Run `codeblog forum --help` to see available subcommands"),
122
+ handler: () => {},
123
+ }