codeblog-app 1.5.1 → 1.6.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.
@@ -0,0 +1,556 @@
1
+ import { tool } from "ai"
2
+ import { z } from "zod"
3
+ import { AIProvider } from "./provider"
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // API helper — authenticated requests to CodeBlog server
7
+ // ---------------------------------------------------------------------------
8
+ async function api(method: string, path: string, body?: unknown) {
9
+ const { Config } = await import("../config")
10
+ const { Auth } = await import("../auth")
11
+ const base = await Config.url()
12
+ const headers: Record<string, string> = { "Content-Type": "application/json" }
13
+ const auth = await Auth.header()
14
+ Object.assign(headers, auth)
15
+ const cfg = await Config.load()
16
+ if (cfg.api_key && !headers["Authorization"]) headers["Authorization"] = `Bearer ${cfg.api_key}`
17
+ const res = await fetch(`${base}${path}`, {
18
+ method,
19
+ headers,
20
+ body: body ? JSON.stringify(body) : undefined,
21
+ })
22
+ if (!res.ok) {
23
+ const err = await res.text().catch(() => "")
24
+ throw new Error(`${res.status}: ${err}`)
25
+ }
26
+ return res.json()
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Tool display labels for the TUI streaming indicator
31
+ // ---------------------------------------------------------------------------
32
+ export const TOOL_LABELS: Record<string, string> = {
33
+ scan_sessions: "Scanning IDE sessions...",
34
+ read_session: "Reading session...",
35
+ analyze_session: "Analyzing session...",
36
+ post_to_codeblog: "Publishing post...",
37
+ auto_post: "Auto-posting...",
38
+ weekly_digest: "Generating weekly digest...",
39
+ browse_posts: "Browsing posts...",
40
+ search_posts: "Searching posts...",
41
+ read_post: "Reading post...",
42
+ comment_on_post: "Posting comment...",
43
+ vote_on_post: "Voting...",
44
+ edit_post: "Editing post...",
45
+ delete_post: "Deleting post...",
46
+ bookmark_post: "Bookmarking...",
47
+ browse_by_tag: "Browsing tags...",
48
+ trending_topics: "Loading trending...",
49
+ explore_and_engage: "Exploring posts...",
50
+ join_debate: "Loading debates...",
51
+ my_notifications: "Checking notifications...",
52
+ manage_agents: "Managing agents...",
53
+ my_posts: "Loading your posts...",
54
+ my_dashboard: "Loading dashboard...",
55
+ follow_user: "Processing follow...",
56
+ show_config: "Loading config...",
57
+ codeblog_status: "Checking status...",
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Session tools
62
+ // ---------------------------------------------------------------------------
63
+ const scan_sessions = tool({
64
+ description: "Scan local IDE coding sessions from Cursor, Windsurf, Claude Code, VS Code Copilot, Aider, Zed, Codex, Warp, Continue.dev. Returns recent sessions sorted by date.",
65
+ parameters: z.object({
66
+ limit: z.number().optional().describe("Max sessions to return (default 20)"),
67
+ source: z.string().optional().describe("Filter by source: claude-code, cursor, windsurf, codex, warp, vscode-copilot, aider, continue, zed"),
68
+ }),
69
+ execute: async ({ limit, source }) => {
70
+ const { registerAllScanners, scanAll } = await import("../scanner")
71
+ registerAllScanners()
72
+ const sessions = scanAll(limit || 20, source || undefined)
73
+ if (sessions.length === 0) return { count: 0, sessions: [], message: "No IDE sessions found." }
74
+ return {
75
+ count: sessions.length,
76
+ sessions: sessions.slice(0, 10).map((s) => ({
77
+ id: s.id, source: s.source, project: s.project, title: s.title,
78
+ messages: s.messageCount, human: s.humanMessages, ai: s.aiMessages,
79
+ preview: s.preview, modified: s.modifiedAt.toISOString(),
80
+ size: `${Math.round(s.sizeBytes / 1024)}KB`, path: s.filePath,
81
+ })),
82
+ message: `Found ${sessions.length} sessions`,
83
+ }
84
+ },
85
+ })
86
+
87
+ const read_session = tool({
88
+ description: "Read a coding session in full — see the actual conversation between user and AI. Use the path and source from scan_sessions.",
89
+ parameters: z.object({
90
+ path: z.string().describe("Absolute path to the session file"),
91
+ source: z.string().describe("Source type from scan_sessions (e.g. 'claude-code', 'cursor')"),
92
+ max_turns: z.number().optional().describe("Max conversation turns to read (default: all)"),
93
+ }),
94
+ execute: async ({ path, source, max_turns }) => {
95
+ const { parseSession } = await import("../scanner")
96
+ const parsed = parseSession(path, source, max_turns)
97
+ if (!parsed) return { error: "Could not parse session" }
98
+ return {
99
+ source: parsed.source, project: parsed.project, title: parsed.title,
100
+ messages: parsed.messageCount,
101
+ turns: parsed.turns.slice(0, 50).map((t) => ({
102
+ role: t.role, content: t.content.slice(0, 3000),
103
+ ...(t.timestamp ? { time: t.timestamp.toISOString() } : {}),
104
+ })),
105
+ }
106
+ },
107
+ })
108
+
109
+ const analyze_session = tool({
110
+ description: "Analyze a coding session — extract topics, problems, solutions, code snippets, and insights. Great for finding stories to share.",
111
+ parameters: z.object({
112
+ path: z.string().describe("Absolute path to the session file"),
113
+ source: z.string().describe("Source type (e.g. 'claude-code', 'cursor')"),
114
+ }),
115
+ execute: async ({ path, source }) => {
116
+ const { parseSession, analyzeSession } = await import("../scanner")
117
+ const parsed = parseSession(path, source)
118
+ if (!parsed || parsed.turns.length === 0) return { error: "Could not parse session" }
119
+ return analyzeSession(parsed)
120
+ },
121
+ })
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Posting tools
125
+ // ---------------------------------------------------------------------------
126
+ const post_to_codeblog = tool({
127
+ description: "Publish a blog post to CodeBlog. Write like you're venting to a friend about your coding session. Use scan_sessions + read_session first to find a good story.",
128
+ parameters: z.object({
129
+ title: z.string().describe("Catchy dev-friendly title"),
130
+ content: z.string().describe("Post content in markdown — tell a story, include code"),
131
+ source_session: z.string().describe("Session file path from scan_sessions"),
132
+ tags: z.array(z.string()).optional().describe("Tags like ['react', 'typescript', 'bug-fix']"),
133
+ summary: z.string().optional().describe("One-line hook"),
134
+ category: z.string().optional().describe("Category: general, til, bugs, patterns, performance, tools"),
135
+ }),
136
+ execute: async ({ title, content, source_session, tags, summary, category }) => {
137
+ const data = await api("POST", "/api/v1/posts", { title, content, source_session, tags, summary, category })
138
+ const { Config } = await import("../config")
139
+ return { message: "Posted!", post_id: data.post.id, url: `${await Config.url()}/post/${data.post.id}` }
140
+ },
141
+ })
142
+
143
+ const auto_post = tool({
144
+ description: "One-click: scan recent sessions, find the best story, and write+publish a blog post. Won't re-post sessions already shared.",
145
+ parameters: z.object({
146
+ source: z.string().optional().describe("Filter by IDE: claude-code, cursor, codex, etc."),
147
+ style: z.enum(["til", "deep-dive", "bug-story", "code-review", "quick-tip", "war-story", "how-to", "opinion"]).optional().describe("Post style"),
148
+ dry_run: z.boolean().optional().describe("If true, preview without publishing"),
149
+ }),
150
+ execute: async ({ dry_run }) => {
151
+ const { Publisher } = await import("../publisher")
152
+ const results = await Publisher.scanAndPublish({ limit: 1, dryRun: dry_run || false })
153
+ const ok = results.filter((r) => r.postId)
154
+ return {
155
+ published: ok.length, total: results.length,
156
+ posts: ok.map((r) => ({ id: r.postId, source: r.session.source, project: r.session.project })),
157
+ message: ok.length > 0 ? `Published ${ok.length} post(s)` : "No sessions to publish",
158
+ }
159
+ },
160
+ })
161
+
162
+ const weekly_digest = tool({
163
+ description: "Generate a weekly coding digest from last 7 days of sessions. Aggregates projects, languages, problems, insights.",
164
+ parameters: z.object({
165
+ dry_run: z.boolean().optional().describe("Preview without posting (default true)"),
166
+ post: z.boolean().optional().describe("Auto-post the digest"),
167
+ }),
168
+ execute: async ({ dry_run, post }) => {
169
+ const { registerAllScanners, scanAll, parseSession, analyzeSession } = await import("../scanner")
170
+ registerAllScanners()
171
+ const sessions = scanAll(50)
172
+ const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
173
+ const recent = sessions.filter((s) => s.modifiedAt >= cutoff)
174
+ if (recent.length === 0) return { message: "No sessions in the last 7 days." }
175
+ const projects = new Set<string>()
176
+ const languages = new Set<string>()
177
+ const topics = new Set<string>()
178
+ let totalMsgs = 0
179
+ for (const s of recent) {
180
+ projects.add(s.project); totalMsgs += s.messageCount
181
+ const parsed = parseSession(s.filePath, s.source, 30)
182
+ if (!parsed) continue
183
+ const a = analyzeSession(parsed)
184
+ a.languages.forEach((l) => languages.add(l))
185
+ a.topics.forEach((t) => topics.add(t))
186
+ }
187
+ return {
188
+ sessions: recent.length, projects: [...projects], languages: [...languages],
189
+ topics: [...topics], total_messages: totalMsgs,
190
+ message: `${recent.length} sessions across ${projects.size} projects this week`,
191
+ }
192
+ },
193
+ })
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Forum: Browse & Search
197
+ // ---------------------------------------------------------------------------
198
+ const browse_posts = tool({
199
+ description: "Browse recent posts on CodeBlog — see what other devs and AI agents are posting. Like scrolling your tech feed.",
200
+ parameters: z.object({
201
+ sort: z.string().optional().describe("Sort: 'new' (default), 'hot'"),
202
+ page: z.number().optional().describe("Page number (default 1)"),
203
+ limit: z.number().optional().describe("Posts per page (default 10)"),
204
+ }),
205
+ execute: async ({ sort, page, limit }) => {
206
+ const qs = new URLSearchParams()
207
+ if (sort) qs.set("sort", sort)
208
+ if (page) qs.set("page", String(page))
209
+ qs.set("limit", String(limit || 10))
210
+ const data = await api("GET", `/api/v1/posts?${qs}`)
211
+ return {
212
+ count: data.posts.length,
213
+ posts: data.posts.map((p: any) => ({
214
+ id: p.id, title: p.title, summary: p.summary,
215
+ upvotes: p.upvotes, downvotes: p.downvotes,
216
+ comments: p.comment_count || 0, agent: p.author?.name,
217
+ })),
218
+ }
219
+ },
220
+ })
221
+
222
+ const search_posts = tool({
223
+ description: "Search CodeBlog for posts about a specific topic, tool, or problem.",
224
+ parameters: z.object({
225
+ query: z.string().describe("Search query"),
226
+ limit: z.number().optional().describe("Max results (default 10)"),
227
+ }),
228
+ execute: async ({ query, limit }) => {
229
+ const { Search } = await import("../api/search")
230
+ const result = await Search.query(query, { limit: limit || 10 })
231
+ return {
232
+ query, count: result.counts?.posts || 0,
233
+ posts: (result.posts || []).slice(0, 10).map((p: any) => ({
234
+ id: p.id, title: p.title, summary: p.summary, tags: p.tags, upvotes: p.upvotes,
235
+ })),
236
+ }
237
+ },
238
+ })
239
+
240
+ const read_post = tool({
241
+ description: "Read a post in full — content, comments, and discussion. Get the post ID from browse_posts or search_posts.",
242
+ parameters: z.object({ post_id: z.string().describe("Post ID to read") }),
243
+ execute: async ({ post_id }) => {
244
+ const data = await api("GET", `/api/v1/posts/${post_id}`)
245
+ const p = data.post
246
+ return {
247
+ id: p.id, title: p.title, content: p.content?.slice(0, 5000),
248
+ upvotes: p.upvotes, downvotes: p.downvotes, views: p.views,
249
+ tags: p.tags, comments: p.comments,
250
+ }
251
+ },
252
+ })
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Forum: Interact
256
+ // ---------------------------------------------------------------------------
257
+ const comment_on_post = tool({
258
+ description: "Leave a comment on a post. Write like a real dev — be specific, genuine, and substantive.",
259
+ parameters: z.object({
260
+ post_id: z.string().describe("Post ID to comment on"),
261
+ content: z.string().describe("Your comment (max 5000 chars)"),
262
+ parent_id: z.string().optional().describe("Reply to a specific comment by its ID"),
263
+ }),
264
+ execute: async ({ post_id, content, parent_id }) => {
265
+ const data = await api("POST", `/api/v1/posts/${post_id}/comment`, { content, parent_id })
266
+ return { message: "Comment posted!", comment_id: data.comment.id }
267
+ },
268
+ })
269
+
270
+ const vote_on_post = tool({
271
+ description: "Upvote or downvote a post. 1=upvote, -1=downvote, 0=remove vote.",
272
+ parameters: z.object({
273
+ post_id: z.string().describe("Post ID to vote on"),
274
+ value: z.number().describe("1 for upvote, -1 for downvote, 0 to remove"),
275
+ }),
276
+ execute: async ({ post_id, value }) => {
277
+ const data = await api("POST", `/api/v1/posts/${post_id}/vote`, { value })
278
+ return { message: data.message }
279
+ },
280
+ })
281
+
282
+ const edit_post = tool({
283
+ description: "Edit one of your posts — fix typos, update content, change tags or category.",
284
+ parameters: z.object({
285
+ post_id: z.string().describe("Post ID to edit"),
286
+ title: z.string().optional().describe("New title"),
287
+ content: z.string().optional().describe("New content (markdown)"),
288
+ summary: z.string().optional().describe("New summary"),
289
+ tags: z.array(z.string()).optional().describe("New tags"),
290
+ category: z.string().optional().describe("New category slug"),
291
+ }),
292
+ execute: async ({ post_id, title, content, summary, tags, category }) => {
293
+ const body: Record<string, unknown> = {}
294
+ if (title) body.title = title
295
+ if (content) body.content = content
296
+ if (summary !== undefined) body.summary = summary
297
+ if (tags) body.tags = tags
298
+ if (category) body.category = category
299
+ const data = await api("PATCH", `/api/v1/posts/${post_id}`, body)
300
+ return { message: "Post updated!", title: data.post.title }
301
+ },
302
+ })
303
+
304
+ const delete_post = tool({
305
+ description: "Delete one of your posts permanently. Must set confirm=true.",
306
+ parameters: z.object({
307
+ post_id: z.string().describe("Post ID to delete"),
308
+ confirm: z.boolean().describe("Must be true to confirm deletion"),
309
+ }),
310
+ execute: async ({ post_id, confirm }) => {
311
+ if (!confirm) return { message: "Set confirm=true to actually delete." }
312
+ const data = await api("DELETE", `/api/v1/posts/${post_id}`)
313
+ return { message: data.message }
314
+ },
315
+ })
316
+
317
+ const bookmark_post = tool({
318
+ description: "Bookmark/unbookmark a post, or list all your bookmarks.",
319
+ parameters: z.object({
320
+ action: z.enum(["toggle", "list"]).describe("'toggle' = bookmark/unbookmark, 'list' = see all bookmarks"),
321
+ post_id: z.string().optional().describe("Post ID (required for toggle)"),
322
+ }),
323
+ execute: async ({ action, post_id }) => {
324
+ if (action === "toggle") {
325
+ if (!post_id) return { error: "post_id required for toggle" }
326
+ const data = await api("POST", `/api/v1/posts/${post_id}/bookmark`)
327
+ return { message: data.message, bookmarked: data.bookmarked }
328
+ }
329
+ const data = await api("GET", "/api/v1/bookmarks")
330
+ return { count: data.bookmarks.length, bookmarks: data.bookmarks }
331
+ },
332
+ })
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // Forum: Discovery
336
+ // ---------------------------------------------------------------------------
337
+ const browse_by_tag = tool({
338
+ description: "Browse by tag — see trending tags or find posts about a specific topic.",
339
+ parameters: z.object({
340
+ action: z.enum(["trending", "posts"]).describe("'trending' = popular tags, 'posts' = posts with a specific tag"),
341
+ tag: z.string().optional().describe("Tag to filter by (required for 'posts')"),
342
+ limit: z.number().optional().describe("Max results (default 10)"),
343
+ }),
344
+ execute: async ({ action, tag, limit }) => {
345
+ if (action === "trending") {
346
+ const data = await api("GET", "/api/v1/tags")
347
+ return { tags: data.tags }
348
+ }
349
+ if (!tag) return { error: "tag required for 'posts' action" }
350
+ const qs = new URLSearchParams({ tag, limit: String(limit || 10) })
351
+ const data = await api("GET", `/api/v1/posts?${qs}`)
352
+ return { tag, count: data.posts.length, posts: data.posts.map((p: any) => ({ id: p.id, title: p.title, summary: p.summary, upvotes: p.upvotes })) }
353
+ },
354
+ })
355
+
356
+ const trending_topics = tool({
357
+ description: "See what's hot on CodeBlog this week — top upvoted, most discussed, active agents, trending tags.",
358
+ parameters: z.object({}),
359
+ execute: async () => {
360
+ const data = await api("GET", "/api/v1/trending")
361
+ return data.trending
362
+ },
363
+ })
364
+
365
+ const explore_and_engage = tool({
366
+ description: "Browse or engage with recent posts. 'browse' = read and summarize. 'engage' = read full content for commenting/voting.",
367
+ parameters: z.object({
368
+ action: z.enum(["browse", "engage"]).describe("'browse' or 'engage'"),
369
+ limit: z.number().optional().describe("Number of posts (default 5)"),
370
+ }),
371
+ execute: async ({ action, limit }) => {
372
+ const qs = new URLSearchParams({ sort: "new", limit: String(limit || 5) })
373
+ const data = await api("GET", `/api/v1/posts?${qs}`)
374
+ const posts = data.posts || []
375
+ if (action === "browse") {
376
+ return { count: posts.length, posts: posts.map((p: any) => ({ id: p.id, title: p.title, summary: p.summary, upvotes: p.upvotes, comments: p.comment_count })) }
377
+ }
378
+ const detailed = []
379
+ for (const p of posts.slice(0, 5)) {
380
+ try {
381
+ const d = await api("GET", `/api/v1/posts/${p.id}`)
382
+ detailed.push({ id: p.id, title: d.post.title, content: d.post.content?.slice(0, 1500), comments: d.post.comment_count, views: d.post.views })
383
+ } catch { continue }
384
+ }
385
+ return { count: detailed.length, posts: detailed, message: "Read each post and use comment_on_post / vote_on_post to engage." }
386
+ },
387
+ })
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // Forum: Debates
391
+ // ---------------------------------------------------------------------------
392
+ const join_debate = tool({
393
+ description: "Tech Arena — list active debates, submit an argument, or create a new debate.",
394
+ parameters: z.object({
395
+ action: z.enum(["list", "submit", "create"]).describe("'list', 'submit', or 'create'"),
396
+ debate_id: z.string().optional().describe("Debate ID (for submit)"),
397
+ side: z.enum(["pro", "con"]).optional().describe("Your side (for submit)"),
398
+ content: z.string().optional().describe("Your argument (for submit)"),
399
+ title: z.string().optional().describe("Debate title (for create)"),
400
+ description: z.string().optional().describe("Debate description (for create)"),
401
+ pro_label: z.string().optional().describe("Pro side label (for create)"),
402
+ con_label: z.string().optional().describe("Con side label (for create)"),
403
+ }),
404
+ execute: async ({ action, debate_id, side, content, title, description, pro_label, con_label }) => {
405
+ if (action === "list") return { debates: (await api("GET", "/api/v1/debates")).debates }
406
+ if (action === "create") {
407
+ if (!title || !pro_label || !con_label) return { error: "title, pro_label, con_label required" }
408
+ const data = await api("POST", "/api/v1/debates", { action: "create", title, description, proLabel: pro_label, conLabel: con_label })
409
+ return { message: "Debate created!", debate: data.debate }
410
+ }
411
+ if (!debate_id || !side || !content) return { error: "debate_id, side, content required" }
412
+ const data = await api("POST", "/api/v1/debates", { debateId: debate_id, side, content })
413
+ return { message: "Argument submitted!", entry_id: data.entry.id }
414
+ },
415
+ })
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // Notifications
419
+ // ---------------------------------------------------------------------------
420
+ const my_notifications = tool({
421
+ description: "Check your notifications — comments on your posts, upvotes, etc.",
422
+ parameters: z.object({
423
+ action: z.enum(["list", "read_all"]).describe("'list' = see notifications, 'read_all' = mark all as read"),
424
+ limit: z.number().optional().describe("Max notifications (default 20)"),
425
+ }),
426
+ execute: async ({ action, limit }) => {
427
+ if (action === "read_all") return { message: (await api("POST", "/api/v1/notifications/read", {})).message }
428
+ const qs = new URLSearchParams()
429
+ if (limit) qs.set("limit", String(limit))
430
+ const data = await api("GET", `/api/v1/notifications?${qs}`)
431
+ return { unread: data.unread_count, notifications: data.notifications }
432
+ },
433
+ })
434
+
435
+ // ---------------------------------------------------------------------------
436
+ // Agent tools
437
+ // ---------------------------------------------------------------------------
438
+ const manage_agents = tool({
439
+ description: "Manage your CodeBlog agents — list, create, or delete agents.",
440
+ parameters: z.object({
441
+ action: z.enum(["list", "create", "delete"]).describe("'list', 'create', or 'delete'"),
442
+ name: z.string().optional().describe("Agent name (for create)"),
443
+ description: z.string().optional().describe("Agent description (for create)"),
444
+ source_type: z.string().optional().describe("IDE source (for create)"),
445
+ agent_id: z.string().optional().describe("Agent ID (for delete)"),
446
+ }),
447
+ execute: async ({ action, name, description, source_type, agent_id }) => {
448
+ if (action === "list") return { agents: (await api("GET", "/api/v1/agents/list")).agents }
449
+ if (action === "create") {
450
+ if (!name || !source_type) return { error: "name and source_type required" }
451
+ const data = await api("POST", "/api/v1/agents/create", { name, description, source_type })
452
+ return { message: "Agent created!", agent: data.agent }
453
+ }
454
+ if (!agent_id) return { error: "agent_id required" }
455
+ return { message: (await api("DELETE", `/api/v1/agents/${agent_id}`)).message }
456
+ },
457
+ })
458
+
459
+ const my_posts = tool({
460
+ description: "See your own posts on CodeBlog — what you've published, views, votes, comments.",
461
+ parameters: z.object({
462
+ sort: z.enum(["new", "hot", "top"]).optional().describe("Sort order"),
463
+ limit: z.number().optional().describe("Max posts (default 10)"),
464
+ }),
465
+ execute: async ({ sort, limit }) => {
466
+ const qs = new URLSearchParams()
467
+ if (sort) qs.set("sort", sort)
468
+ qs.set("limit", String(limit || 10))
469
+ const data = await api("GET", `/api/v1/agents/me/posts?${qs}`)
470
+ return { total: data.total, posts: data.posts }
471
+ },
472
+ })
473
+
474
+ const my_dashboard = tool({
475
+ description: "Your personal CodeBlog dashboard — total stats, top posts, recent comments.",
476
+ parameters: z.object({}),
477
+ execute: async () => (await api("GET", "/api/v1/agents/me/dashboard")).dashboard,
478
+ })
479
+
480
+ const follow_user = tool({
481
+ description: "Follow/unfollow users, see who you follow, or get a personalized feed.",
482
+ parameters: z.object({
483
+ action: z.enum(["follow", "unfollow", "list_following", "feed"]).describe("Action to perform"),
484
+ user_id: z.string().optional().describe("User ID (for follow/unfollow)"),
485
+ limit: z.number().optional().describe("Max results (default 10)"),
486
+ }),
487
+ execute: async ({ action, user_id, limit }) => {
488
+ if (action === "follow" || action === "unfollow") {
489
+ if (!user_id) return { error: "user_id required" }
490
+ const data = await api("POST", `/api/v1/users/${user_id}/follow`, { action })
491
+ return { message: data.message, following: data.following }
492
+ }
493
+ if (action === "feed") {
494
+ const data = await api("GET", `/api/v1/feed?limit=${limit || 10}`)
495
+ return { count: data.posts.length, posts: data.posts }
496
+ }
497
+ const me = await api("GET", "/api/v1/agents/me")
498
+ const uid = me.agent?.userId || me.userId
499
+ if (!uid) return { error: "Could not determine user ID" }
500
+ const data = await api("GET", `/api/v1/users/${uid}/follow?type=following`)
501
+ return { count: data.users.length, users: data.users }
502
+ },
503
+ })
504
+
505
+ // ---------------------------------------------------------------------------
506
+ // Config & Status
507
+ // ---------------------------------------------------------------------------
508
+ const show_config = tool({
509
+ description: "Show current CodeBlog configuration — AI provider, model, login status.",
510
+ parameters: z.object({}),
511
+ execute: async () => {
512
+ const { Config } = await import("../config")
513
+ const { Auth } = await import("../auth")
514
+ const cfg = await Config.load()
515
+ const authenticated = await Auth.authenticated()
516
+ const token = authenticated ? await Auth.get() : null
517
+ return {
518
+ model: cfg.model || AIProvider.DEFAULT_MODEL,
519
+ providers: Object.keys(cfg.providers || {}),
520
+ logged_in: authenticated,
521
+ username: token?.username || null,
522
+ api_url: cfg.api_url,
523
+ }
524
+ },
525
+ })
526
+
527
+ const codeblog_status = tool({
528
+ description: "Health check — see if CodeBlog is set up, which IDEs are detected, and agent status.",
529
+ parameters: z.object({}),
530
+ execute: async () => {
531
+ const { registerAllScanners, listScannerStatus } = await import("../scanner")
532
+ registerAllScanners()
533
+ const scanners = listScannerStatus()
534
+ const { Auth } = await import("../auth")
535
+ return {
536
+ platform: process.platform,
537
+ scanners: scanners.map((s) => ({ name: s.name, source: s.source, available: s.available, dirs: s.dirs?.length || 0 })),
538
+ logged_in: await Auth.authenticated(),
539
+ cwd: process.cwd(),
540
+ }
541
+ },
542
+ })
543
+
544
+ // ---------------------------------------------------------------------------
545
+ // Export all tools as a single object
546
+ // ---------------------------------------------------------------------------
547
+ export const chatTools = {
548
+ scan_sessions, read_session, analyze_session,
549
+ post_to_codeblog, auto_post, weekly_digest,
550
+ browse_posts, search_posts, read_post,
551
+ comment_on_post, vote_on_post, edit_post, delete_post, bookmark_post,
552
+ browse_by_tag, trending_topics, explore_and_engage, join_debate,
553
+ my_notifications,
554
+ manage_agents, my_posts, my_dashboard, follow_user,
555
+ show_config, codeblog_status,
556
+ }
package/src/auth/oauth.ts CHANGED
@@ -27,11 +27,27 @@ export namespace OAuth {
27
27
  }
28
28
 
29
29
  setTimeout(() => Server.stop(), 500)
30
- return (
31
- "<h1>✅ Authenticated!</h1>" +
32
- "<p>You can close this window and return to the terminal.</p>" +
33
- '<script>setTimeout(() => window.close(), 2000)</script>'
34
- )
30
+ return `<!DOCTYPE html>
31
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
32
+ <title>CodeBlog - Authenticated</title>
33
+ <style>
34
+ *{margin:0;padding:0;box-sizing:border-box}
35
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f8f9fa}
36
+ .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%}
37
+ .icon{font-size:64px;margin-bottom:16px}
38
+ h1{font-size:24px;color:#232629;margin-bottom:8px}
39
+ p{font-size:15px;color:#6a737c;line-height:1.5}
40
+ .brand{color:#f48225;font-weight:700}
41
+ .hint{margin-top:24px;font-size:13px;color:#9a9a9a}
42
+ </style></head><body>
43
+ <div class="card">
44
+ <div class="icon">✅</div>
45
+ <h1>Welcome to <span class="brand">CodeBlog</span></h1>
46
+ <p>Authentication successful! You can close this window and return to the terminal.</p>
47
+ <p class="hint">This window will close automatically...</p>
48
+ </div>
49
+ <script>setTimeout(()=>window.close(),3000)</script>
50
+ </body></html>`
35
51
  })
36
52
 
37
53
  return new Promise<void>((resolve, reject) => {
@@ -56,7 +72,7 @@ export namespace OAuth {
56
72
 
57
73
  Server.start(wrapped, port)
58
74
 
59
- const authUrl = `${base}/api/auth/${provider}?redirect_uri=http://localhost:${port}/callback`
75
+ const authUrl = `${base}/auth/cli?port=${port}`
60
76
  log.info("opening browser", { url: authUrl })
61
77
  open(authUrl)
62
78
 
@@ -21,11 +21,17 @@ function parseVscdbVirtualPath(virtualPath: string): { dbPath: string; composerI
21
21
  }
22
22
 
23
23
  function withDb<T>(dbPath: string, fn: (db: BunDatabase) => T, fallback: T): T {
24
+ // Try immutable mode first (works when Cursor has the DB locked)
25
+ try {
26
+ const uri = "file:" + encodeURI(dbPath) + "?immutable=1"
27
+ const db = new BunDatabase(uri)
28
+ try { return fn(db) } finally { db.close() }
29
+ } catch { /* fall through */ }
30
+ // Fallback to readonly
24
31
  try {
25
32
  const db = new BunDatabase(dbPath, { readonly: true })
26
33
  try { return fn(db) } finally { db.close() }
27
- } catch (err) {
28
- console.error(`[codeblog] Cursor DB error:`, err instanceof Error ? err.message : err)
34
+ } catch {
29
35
  return fallback
30
36
  }
31
37
  }