codeblog-app 2.1.3 → 2.1.6
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.
- package/package.json +6 -6
- package/src/ai/__tests__/tools.test.ts +26 -62
- package/src/ai/chat.ts +3 -2
- package/src/ai/tools.ts +33 -278
- package/src/cli/cmd/setup.ts +121 -42
- package/src/cli/cmd/update.ts +50 -5
- package/src/cli/ui.ts +55 -0
- package/src/tui/routes/home.tsx +16 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "codeblog-app",
|
|
4
|
-
"version": "2.1.
|
|
4
|
+
"version": "2.1.6",
|
|
5
5
|
"description": "CLI client for CodeBlog — the forum where AI writes the posts",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
@@ -56,11 +56,11 @@
|
|
|
56
56
|
"typescript": "5.8.2"
|
|
57
57
|
},
|
|
58
58
|
"optionalDependencies": {
|
|
59
|
-
"codeblog-app-darwin-arm64": "2.1.
|
|
60
|
-
"codeblog-app-darwin-x64": "2.1.
|
|
61
|
-
"codeblog-app-linux-arm64": "2.1.
|
|
62
|
-
"codeblog-app-linux-x64": "2.1.
|
|
63
|
-
"codeblog-app-windows-x64": "2.1.
|
|
59
|
+
"codeblog-app-darwin-arm64": "2.1.6",
|
|
60
|
+
"codeblog-app-darwin-x64": "2.1.6",
|
|
61
|
+
"codeblog-app-linux-arm64": "2.1.6",
|
|
62
|
+
"codeblog-app-linux-x64": "2.1.6",
|
|
63
|
+
"codeblog-app-windows-x64": "2.1.6"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@ai-sdk/anthropic": "^3.0.44",
|
|
@@ -1,62 +1,41 @@
|
|
|
1
1
|
import { describe, test, expect } from "bun:test"
|
|
2
|
-
import {
|
|
2
|
+
import { getChatTools, TOOL_LABELS, clearChatToolsCache } from "../tools"
|
|
3
3
|
|
|
4
|
-
describe("AI Tools", () => {
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
4
|
+
describe("AI Tools (dynamic MCP discovery)", () => {
|
|
5
|
+
// These tests require a running MCP server subprocess.
|
|
6
|
+
// getChatTools() connects to codeblog-mcp via stdio and calls listTools().
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
"scan_sessions", "read_session", "analyze_session",
|
|
11
|
-
"post_to_codeblog", "auto_post", "weekly_digest",
|
|
12
|
-
"browse_posts", "search_posts", "read_post",
|
|
13
|
-
"comment_on_post", "vote_on_post", "edit_post", "delete_post", "bookmark_post",
|
|
14
|
-
"browse_by_tag", "trending_topics", "explore_and_engage", "join_debate",
|
|
15
|
-
"my_notifications",
|
|
16
|
-
"manage_agents", "my_posts", "my_dashboard", "follow_user",
|
|
17
|
-
"codeblog_setup", "codeblog_status",
|
|
18
|
-
]
|
|
8
|
+
let chatTools: Record<string, any>
|
|
19
9
|
|
|
20
|
-
test("
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
test("each expected tool is present in chatTools", () => {
|
|
26
|
-
for (const name of expectedTools) {
|
|
27
|
-
expect(chatTools).toHaveProperty(name)
|
|
28
|
-
}
|
|
10
|
+
test("getChatTools() discovers tools from MCP server", async () => {
|
|
11
|
+
clearChatToolsCache()
|
|
12
|
+
chatTools = await getChatTools()
|
|
13
|
+
const names = Object.keys(chatTools)
|
|
14
|
+
expect(names.length).toBeGreaterThanOrEqual(20)
|
|
29
15
|
})
|
|
30
16
|
|
|
31
17
|
test("each tool has parameters and execute", () => {
|
|
32
|
-
for (const [name,
|
|
33
|
-
const
|
|
34
|
-
expect(
|
|
35
|
-
expect(
|
|
36
|
-
expect(typeof
|
|
18
|
+
for (const [name, t] of Object.entries(chatTools)) {
|
|
19
|
+
const tool = t as any
|
|
20
|
+
expect(tool.parameters).toBeDefined()
|
|
21
|
+
expect(tool.execute).toBeDefined()
|
|
22
|
+
expect(typeof tool.execute).toBe("function")
|
|
37
23
|
}
|
|
38
24
|
})
|
|
39
25
|
|
|
40
26
|
test("each tool has a description", () => {
|
|
41
|
-
for (const [name,
|
|
42
|
-
const
|
|
43
|
-
expect(
|
|
44
|
-
expect(typeof
|
|
45
|
-
expect(
|
|
27
|
+
for (const [name, t] of Object.entries(chatTools)) {
|
|
28
|
+
const tool = t as any
|
|
29
|
+
expect(tool.description).toBeDefined()
|
|
30
|
+
expect(typeof tool.description).toBe("string")
|
|
31
|
+
expect(tool.description.length).toBeGreaterThan(10)
|
|
46
32
|
}
|
|
47
33
|
})
|
|
48
34
|
|
|
49
35
|
// ---------------------------------------------------------------------------
|
|
50
|
-
// TOOL_LABELS tests
|
|
36
|
+
// TOOL_LABELS tests (static fallback map)
|
|
51
37
|
// ---------------------------------------------------------------------------
|
|
52
38
|
|
|
53
|
-
test("TOOL_LABELS has an entry for every chatTool", () => {
|
|
54
|
-
for (const name of Object.keys(chatTools)) {
|
|
55
|
-
expect(TOOL_LABELS).toHaveProperty(name)
|
|
56
|
-
expect(typeof TOOL_LABELS[name]).toBe("string")
|
|
57
|
-
}
|
|
58
|
-
})
|
|
59
|
-
|
|
60
39
|
test("TOOL_LABELS values are non-empty strings", () => {
|
|
61
40
|
for (const [key, label] of Object.entries(TOOL_LABELS)) {
|
|
62
41
|
expect(label.length).toBeGreaterThan(0)
|
|
@@ -64,27 +43,12 @@ describe("AI Tools", () => {
|
|
|
64
43
|
})
|
|
65
44
|
|
|
66
45
|
// ---------------------------------------------------------------------------
|
|
67
|
-
//
|
|
46
|
+
// Caching
|
|
68
47
|
// ---------------------------------------------------------------------------
|
|
69
48
|
|
|
70
|
-
test("
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
expect(
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
test("post_to_codeblog requires title, content, source_session", () => {
|
|
77
|
-
const params = (chatTools.post_to_codeblog as any).parameters
|
|
78
|
-
expect(params).toBeDefined()
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
test("vote_on_post requires post_id and value", () => {
|
|
82
|
-
const params = (chatTools.vote_on_post as any).parameters
|
|
83
|
-
expect(params).toBeDefined()
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
test("delete_post requires post_id and confirm", () => {
|
|
87
|
-
const params = (chatTools.delete_post as any).parameters
|
|
88
|
-
expect(params).toBeDefined()
|
|
49
|
+
test("getChatTools() returns cached result on second call", async () => {
|
|
50
|
+
const tools1 = await getChatTools()
|
|
51
|
+
const tools2 = await getChatTools()
|
|
52
|
+
expect(tools1).toBe(tools2) // same reference
|
|
89
53
|
})
|
|
90
54
|
})
|
package/src/ai/chat.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { streamText, type ModelMessage } from "ai"
|
|
2
2
|
import { AIProvider } from "./provider"
|
|
3
|
-
import {
|
|
3
|
+
import { getChatTools } from "./tools"
|
|
4
4
|
import { Log } from "../util/log"
|
|
5
5
|
|
|
6
6
|
const log = Log.create({ service: "ai-chat" })
|
|
@@ -37,6 +37,7 @@ export namespace AIChat {
|
|
|
37
37
|
|
|
38
38
|
export async function stream(messages: Message[], callbacks: StreamCallbacks, modelID?: string, signal?: AbortSignal) {
|
|
39
39
|
const model = await AIProvider.getModel(modelID)
|
|
40
|
+
const tools = await getChatTools()
|
|
40
41
|
log.info("streaming", { model: modelID || AIProvider.DEFAULT_MODEL, messages: messages.length })
|
|
41
42
|
|
|
42
43
|
// Build history: only user/assistant text (tool context is added per-step below)
|
|
@@ -52,7 +53,7 @@ export namespace AIChat {
|
|
|
52
53
|
model,
|
|
53
54
|
system: SYSTEM_PROMPT,
|
|
54
55
|
messages: history,
|
|
55
|
-
tools
|
|
56
|
+
tools,
|
|
56
57
|
maxSteps: 1,
|
|
57
58
|
abortSignal: signal,
|
|
58
59
|
} as any)
|
package/src/ai/tools.ts
CHANGED
|
@@ -1,19 +1,13 @@
|
|
|
1
|
-
import { tool
|
|
2
|
-
import { z } from "zod"
|
|
1
|
+
import { tool, jsonSchema } from "ai"
|
|
3
2
|
import { McpBridge } from "../mcp/client"
|
|
3
|
+
import { Log } from "../util/log"
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
// `inputSchema`. Under certain Bun module resolution, `inputSchema` stays undefined so
|
|
7
|
-
// the provider receives an empty schema. This wrapper patches each tool to copy
|
|
8
|
-
// `parameters` → `inputSchema` so `asSchema()` in streamText always finds the Zod schema.
|
|
9
|
-
function tool(opts: any): any {
|
|
10
|
-
const t = (_rawTool as any)(opts)
|
|
11
|
-
if (t.parameters && !t.inputSchema) t.inputSchema = t.parameters
|
|
12
|
-
return t
|
|
13
|
-
}
|
|
5
|
+
const log = Log.create({ service: "ai-tools" })
|
|
14
6
|
|
|
15
7
|
// ---------------------------------------------------------------------------
|
|
16
|
-
// Tool display labels for the TUI streaming indicator
|
|
8
|
+
// Tool display labels for the TUI streaming indicator.
|
|
9
|
+
// Kept as a static fallback — new tools added to MCP will show their name
|
|
10
|
+
// as-is if not listed here, which is acceptable.
|
|
17
11
|
// ---------------------------------------------------------------------------
|
|
18
12
|
export const TOOL_LABELS: Record<string, string> = {
|
|
19
13
|
scan_sessions: "Scanning IDE sessions...",
|
|
@@ -50,7 +44,7 @@ async function mcp(name: string, args: Record<string, unknown> = {}): Promise<an
|
|
|
50
44
|
return McpBridge.callToolJSON(name, args)
|
|
51
45
|
}
|
|
52
46
|
|
|
53
|
-
// Strip undefined values from args before sending to MCP
|
|
47
|
+
// Strip undefined/null values from args before sending to MCP
|
|
54
48
|
function clean(obj: Record<string, unknown>): Record<string, unknown> {
|
|
55
49
|
const result: Record<string, unknown> = {}
|
|
56
50
|
for (const [k, v] of Object.entries(obj)) {
|
|
@@ -60,277 +54,38 @@ function clean(obj: Record<string, unknown>): Record<string, unknown> {
|
|
|
60
54
|
}
|
|
61
55
|
|
|
62
56
|
// ---------------------------------------------------------------------------
|
|
63
|
-
//
|
|
64
|
-
// ---------------------------------------------------------------------------
|
|
65
|
-
const scan_sessions = tool({
|
|
66
|
-
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.",
|
|
67
|
-
parameters: z.object({
|
|
68
|
-
limit: z.number().optional().describe("Max sessions to return (default 20)"),
|
|
69
|
-
source: z.string().optional().describe("Filter by source: claude-code, cursor, windsurf, codex, warp, vscode-copilot, aider, continue, zed"),
|
|
70
|
-
}),
|
|
71
|
-
execute: async (args: any) => mcp("scan_sessions", clean(args)),
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
const read_session = tool({
|
|
75
|
-
description: "Read a coding session in full — see the actual conversation between user and AI. Use the path and source from scan_sessions.",
|
|
76
|
-
parameters: z.object({
|
|
77
|
-
path: z.string().describe("Absolute path to the session file"),
|
|
78
|
-
source: z.string().describe("Source type from scan_sessions (e.g. 'claude-code', 'cursor')"),
|
|
79
|
-
max_turns: z.number().optional().describe("Max conversation turns to read (default: all)"),
|
|
80
|
-
}),
|
|
81
|
-
execute: async (args: any) => mcp("read_session", clean(args)),
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
const analyze_session = tool({
|
|
85
|
-
description: "Analyze a coding session — extract topics, problems, solutions, code snippets, and insights. Great for finding stories to share.",
|
|
86
|
-
parameters: z.object({
|
|
87
|
-
path: z.string().describe("Absolute path to the session file"),
|
|
88
|
-
source: z.string().describe("Source type (e.g. 'claude-code', 'cursor')"),
|
|
89
|
-
}),
|
|
90
|
-
execute: async (args: any) => mcp("analyze_session", clean(args)),
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
|
-
// Posting tools
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
96
|
-
const post_to_codeblog = tool({
|
|
97
|
-
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.",
|
|
98
|
-
parameters: z.object({
|
|
99
|
-
title: z.string().describe("Catchy dev-friendly title"),
|
|
100
|
-
content: z.string().describe("Post content in markdown — tell a story, include code"),
|
|
101
|
-
source_session: z.string().describe("Session file path from scan_sessions"),
|
|
102
|
-
tags: z.array(z.string()).optional().describe("Tags like ['react', 'typescript', 'bug-fix']"),
|
|
103
|
-
summary: z.string().optional().describe("One-line hook"),
|
|
104
|
-
category: z.string().optional().describe("Category: general, til, bugs, patterns, performance, tools"),
|
|
105
|
-
}),
|
|
106
|
-
execute: async (args: any) => mcp("post_to_codeblog", clean(args)),
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
const auto_post = tool({
|
|
110
|
-
description: "One-click: scan recent sessions, find the best story, and write+publish a blog post. Won't re-post sessions already shared.",
|
|
111
|
-
parameters: z.object({
|
|
112
|
-
source: z.string().optional().describe("Filter by IDE: claude-code, cursor, codex, etc."),
|
|
113
|
-
style: z.enum(["til", "deep-dive", "bug-story", "code-review", "quick-tip", "war-story", "how-to", "opinion"]).optional().describe("Post style"),
|
|
114
|
-
dry_run: z.boolean().optional().describe("If true, preview without publishing"),
|
|
115
|
-
}),
|
|
116
|
-
execute: async (args: any) => mcp("auto_post", clean(args)),
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
const weekly_digest = tool({
|
|
120
|
-
description: "Generate a weekly coding digest from last 7 days of sessions. Aggregates projects, languages, problems, insights.",
|
|
121
|
-
parameters: z.object({
|
|
122
|
-
dry_run: z.boolean().optional().describe("Preview without posting (default true)"),
|
|
123
|
-
post: z.boolean().optional().describe("Auto-post the digest"),
|
|
124
|
-
}),
|
|
125
|
-
execute: async (args: any) => mcp("weekly_digest", clean(args)),
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
// ---------------------------------------------------------------------------
|
|
129
|
-
// Forum: Browse & Search
|
|
130
|
-
// ---------------------------------------------------------------------------
|
|
131
|
-
const browse_posts = tool({
|
|
132
|
-
description: "Browse recent posts on CodeBlog — see what other devs and AI agents are posting. Like scrolling your tech feed.",
|
|
133
|
-
parameters: z.object({
|
|
134
|
-
sort: z.string().optional().describe("Sort: 'new' (default), 'hot'"),
|
|
135
|
-
page: z.number().optional().describe("Page number (default 1)"),
|
|
136
|
-
limit: z.number().optional().describe("Posts per page (default 10)"),
|
|
137
|
-
}),
|
|
138
|
-
execute: async (args: any) => mcp("browse_posts", clean(args)),
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
const search_posts = tool({
|
|
142
|
-
description: "Search CodeBlog for posts about a specific topic, tool, or problem.",
|
|
143
|
-
parameters: z.object({
|
|
144
|
-
query: z.string().describe("Search query"),
|
|
145
|
-
limit: z.number().optional().describe("Max results (default 10)"),
|
|
146
|
-
}),
|
|
147
|
-
execute: async (args: any) => mcp("search_posts", clean(args)),
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
const read_post = tool({
|
|
151
|
-
description: "Read a post in full — content, comments, and discussion. Get the post ID from browse_posts or search_posts.",
|
|
152
|
-
parameters: z.object({ post_id: z.string().describe("Post ID to read") }),
|
|
153
|
-
execute: async (args: any) => mcp("read_post", clean(args)),
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
// ---------------------------------------------------------------------------
|
|
157
|
-
// Forum: Interact
|
|
57
|
+
// Dynamic tool discovery from MCP server
|
|
158
58
|
// ---------------------------------------------------------------------------
|
|
159
|
-
|
|
160
|
-
description: "Leave a comment on a post. Write like a real dev — be specific, genuine, and substantive.",
|
|
161
|
-
parameters: z.object({
|
|
162
|
-
post_id: z.string().describe("Post ID to comment on"),
|
|
163
|
-
content: z.string().describe("Your comment (max 5000 chars)"),
|
|
164
|
-
parent_id: z.string().optional().describe("Reply to a specific comment by its ID"),
|
|
165
|
-
}),
|
|
166
|
-
execute: async (args: any) => mcp("comment_on_post", clean(args)),
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
const vote_on_post = tool({
|
|
170
|
-
description: "Upvote or downvote a post. 1=upvote, -1=downvote, 0=remove vote.",
|
|
171
|
-
parameters: z.object({
|
|
172
|
-
post_id: z.string().describe("Post ID to vote on"),
|
|
173
|
-
value: z.number().describe("1 for upvote, -1 for downvote, 0 to remove"),
|
|
174
|
-
}),
|
|
175
|
-
execute: async (args: any) => mcp("vote_on_post", clean(args)),
|
|
176
|
-
})
|
|
177
|
-
|
|
178
|
-
const edit_post = tool({
|
|
179
|
-
description: "Edit one of your posts — fix typos, update content, change tags or category.",
|
|
180
|
-
parameters: z.object({
|
|
181
|
-
post_id: z.string().describe("Post ID to edit"),
|
|
182
|
-
title: z.string().optional().describe("New title"),
|
|
183
|
-
content: z.string().optional().describe("New content (markdown)"),
|
|
184
|
-
summary: z.string().optional().describe("New summary"),
|
|
185
|
-
tags: z.array(z.string()).optional().describe("New tags"),
|
|
186
|
-
category: z.string().optional().describe("New category slug"),
|
|
187
|
-
}),
|
|
188
|
-
execute: async (args: any) => mcp("edit_post", clean(args)),
|
|
189
|
-
})
|
|
59
|
+
let _cached: Record<string, any> | null = null
|
|
190
60
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
execute: async (args: any) => mcp("delete_post", clean(args)),
|
|
198
|
-
})
|
|
61
|
+
/**
|
|
62
|
+
* Build AI SDK tools dynamically from the MCP server's listTools() response.
|
|
63
|
+
* Results are cached after the first successful call.
|
|
64
|
+
*/
|
|
65
|
+
export async function getChatTools(): Promise<Record<string, any>> {
|
|
66
|
+
if (_cached) return _cached
|
|
199
67
|
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
parameters: z.object({
|
|
203
|
-
action: z.enum(["toggle", "list"]).describe("'toggle' = bookmark/unbookmark, 'list' = see all bookmarks"),
|
|
204
|
-
post_id: z.string().optional().describe("Post ID (required for toggle)"),
|
|
205
|
-
}),
|
|
206
|
-
execute: async (args: any) => mcp("bookmark_post", clean(args)),
|
|
207
|
-
})
|
|
68
|
+
const { tools: mcpTools } = await McpBridge.listTools()
|
|
69
|
+
log.info("discovered MCP tools", { count: mcpTools.length, names: mcpTools.map((t) => t.name) })
|
|
208
70
|
|
|
209
|
-
|
|
210
|
-
// Forum: Discovery
|
|
211
|
-
// ---------------------------------------------------------------------------
|
|
212
|
-
const browse_by_tag = tool({
|
|
213
|
-
description: "Browse by tag — see trending tags or find posts about a specific topic.",
|
|
214
|
-
parameters: z.object({
|
|
215
|
-
action: z.enum(["trending", "posts"]).describe("'trending' = popular tags, 'posts' = posts with a specific tag"),
|
|
216
|
-
tag: z.string().optional().describe("Tag to filter by (required for 'posts')"),
|
|
217
|
-
limit: z.number().optional().describe("Max results (default 10)"),
|
|
218
|
-
}),
|
|
219
|
-
execute: async (args: any) => mcp("browse_by_tag", clean(args)),
|
|
220
|
-
})
|
|
71
|
+
const tools: Record<string, any> = {}
|
|
221
72
|
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
execute: async () => mcp("trending_topics"),
|
|
226
|
-
})
|
|
73
|
+
for (const t of mcpTools) {
|
|
74
|
+
const name = t.name
|
|
75
|
+
const schema = t.inputSchema as Record<string, unknown>
|
|
227
76
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
execute: async (args: any) => mcp("explore_and_engage", clean(args)),
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
// ---------------------------------------------------------------------------
|
|
238
|
-
// Forum: Debates
|
|
239
|
-
// ---------------------------------------------------------------------------
|
|
240
|
-
const join_debate = tool({
|
|
241
|
-
description: "Tech Arena — list active debates, submit an argument, or create a new debate.",
|
|
242
|
-
parameters: z.object({
|
|
243
|
-
action: z.enum(["list", "submit", "create"]).describe("'list', 'submit', or 'create'"),
|
|
244
|
-
debate_id: z.string().optional().describe("Debate ID (for submit)"),
|
|
245
|
-
side: z.enum(["pro", "con"]).optional().describe("Your side (for submit)"),
|
|
246
|
-
content: z.string().optional().describe("Your argument (for submit)"),
|
|
247
|
-
title: z.string().optional().describe("Debate title (for create)"),
|
|
248
|
-
description: z.string().optional().describe("Debate description (for create)"),
|
|
249
|
-
pro_label: z.string().optional().describe("Pro side label (for create)"),
|
|
250
|
-
con_label: z.string().optional().describe("Con side label (for create)"),
|
|
251
|
-
}),
|
|
252
|
-
execute: async (args: any) => mcp("join_debate", clean(args)),
|
|
253
|
-
})
|
|
254
|
-
|
|
255
|
-
// ---------------------------------------------------------------------------
|
|
256
|
-
// Notifications
|
|
257
|
-
// ---------------------------------------------------------------------------
|
|
258
|
-
const my_notifications = tool({
|
|
259
|
-
description: "Check your notifications — comments on your posts, upvotes, etc.",
|
|
260
|
-
parameters: z.object({
|
|
261
|
-
action: z.enum(["list", "read_all"]).describe("'list' = see notifications, 'read_all' = mark all as read"),
|
|
262
|
-
limit: z.number().optional().describe("Max notifications (default 20)"),
|
|
263
|
-
}),
|
|
264
|
-
execute: async (args: any) => mcp("my_notifications", clean(args)),
|
|
265
|
-
})
|
|
266
|
-
|
|
267
|
-
// ---------------------------------------------------------------------------
|
|
268
|
-
// Agent tools
|
|
269
|
-
// ---------------------------------------------------------------------------
|
|
270
|
-
const manage_agents = tool({
|
|
271
|
-
description: "Manage your CodeBlog agents — list, create, delete, or switch agents. Use 'switch' with an agent_id to change which agent posts on your behalf.",
|
|
272
|
-
parameters: z.object({
|
|
273
|
-
action: z.enum(["list", "create", "delete", "switch"]).describe("'list', 'create', 'delete', or 'switch'"),
|
|
274
|
-
name: z.string().optional().describe("Agent name (for create)"),
|
|
275
|
-
description: z.string().optional().describe("Agent description (for create)"),
|
|
276
|
-
source_type: z.string().optional().describe("IDE source (for create)"),
|
|
277
|
-
agent_id: z.string().optional().describe("Agent ID (for delete or switch)"),
|
|
278
|
-
}),
|
|
279
|
-
execute: async (args: any) => mcp("manage_agents", clean(args)),
|
|
280
|
-
})
|
|
281
|
-
|
|
282
|
-
const my_posts = tool({
|
|
283
|
-
description: "See your own posts on CodeBlog — what you've published, views, votes, comments.",
|
|
284
|
-
parameters: z.object({
|
|
285
|
-
sort: z.enum(["new", "hot", "top"]).optional().describe("Sort order"),
|
|
286
|
-
limit: z.number().optional().describe("Max posts (default 10)"),
|
|
287
|
-
}),
|
|
288
|
-
execute: async (args: any) => mcp("my_posts", clean(args)),
|
|
289
|
-
})
|
|
290
|
-
|
|
291
|
-
const my_dashboard = tool({
|
|
292
|
-
description: "Your personal CodeBlog dashboard — total stats, top posts, recent comments.",
|
|
293
|
-
parameters: z.object({}),
|
|
294
|
-
execute: async () => mcp("my_dashboard"),
|
|
295
|
-
})
|
|
296
|
-
|
|
297
|
-
const follow_user = tool({
|
|
298
|
-
description: "Follow/unfollow users, see who you follow, or get a personalized feed.",
|
|
299
|
-
parameters: z.object({
|
|
300
|
-
action: z.enum(["follow", "unfollow", "list_following", "feed"]).describe("Action to perform"),
|
|
301
|
-
user_id: z.string().optional().describe("User ID (for follow/unfollow)"),
|
|
302
|
-
limit: z.number().optional().describe("Max results (default 10)"),
|
|
303
|
-
}),
|
|
304
|
-
execute: async (args: any) => mcp("follow_agent", clean(args)),
|
|
305
|
-
})
|
|
306
|
-
|
|
307
|
-
// ---------------------------------------------------------------------------
|
|
308
|
-
// Config & Status
|
|
309
|
-
// ---------------------------------------------------------------------------
|
|
310
|
-
const codeblog_setup = tool({
|
|
311
|
-
description: "Configure CodeBlog with an API key — use this to switch agents or set up a new agent. Pass the agent's API key to authenticate as that agent.",
|
|
312
|
-
parameters: z.object({
|
|
313
|
-
api_key: z.string().describe("Agent API key (cbk_xxx format)"),
|
|
314
|
-
}),
|
|
315
|
-
execute: async (args: any) => mcp("codeblog_setup", clean(args)),
|
|
316
|
-
})
|
|
77
|
+
tools[name] = (tool as any)({
|
|
78
|
+
description: t.description || name,
|
|
79
|
+
parameters: jsonSchema(schema),
|
|
80
|
+
execute: async (args: any) => mcp(name, clean(args)),
|
|
81
|
+
})
|
|
82
|
+
}
|
|
317
83
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
execute: async () => mcp("codeblog_status"),
|
|
322
|
-
})
|
|
84
|
+
_cached = tools
|
|
85
|
+
return tools
|
|
86
|
+
}
|
|
323
87
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
export const chatTools = {
|
|
328
|
-
scan_sessions, read_session, analyze_session,
|
|
329
|
-
post_to_codeblog, auto_post, weekly_digest,
|
|
330
|
-
browse_posts, search_posts, read_post,
|
|
331
|
-
comment_on_post, vote_on_post, edit_post, delete_post, bookmark_post,
|
|
332
|
-
browse_by_tag, trending_topics, explore_and_engage, join_debate,
|
|
333
|
-
my_notifications,
|
|
334
|
-
manage_agents, my_posts, my_dashboard, follow_user,
|
|
335
|
-
codeblog_setup, codeblog_status,
|
|
88
|
+
/** Clear the cached tools (useful for testing or reconnection). */
|
|
89
|
+
export function clearChatToolsCache(): void {
|
|
90
|
+
_cached = null
|
|
336
91
|
}
|
package/src/cli/cmd/setup.ts
CHANGED
|
@@ -30,14 +30,26 @@ async function authBrowser(): Promise<boolean> {
|
|
|
30
30
|
|
|
31
31
|
// ─── Scan & Publish ──────────────────────────────────────────────────────────
|
|
32
32
|
|
|
33
|
+
async function shimmerLine(text: string, durationMs = 2000): Promise<void> {
|
|
34
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
35
|
+
const startTime = Date.now()
|
|
36
|
+
let i = 0
|
|
37
|
+
while (Date.now() - startTime < durationMs) {
|
|
38
|
+
Bun.stderr.write(`\r ${UI.Style.TEXT_HIGHLIGHT}${frames[i % frames.length]}${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}${text}${UI.Style.TEXT_NORMAL}`)
|
|
39
|
+
i++
|
|
40
|
+
await Bun.sleep(80)
|
|
41
|
+
}
|
|
42
|
+
Bun.stderr.write(`\r ${UI.Style.TEXT_SUCCESS}✓${UI.Style.TEXT_NORMAL} ${text}\n`)
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
async function scanAndPublish(): Promise<void> {
|
|
34
|
-
// Scan
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
// Scan with shimmer animation
|
|
47
|
+
const scanPromise = McpBridge.callTool("scan_sessions", { limit: 10 })
|
|
48
|
+
await shimmerLine("Scanning local IDE sessions...", 1500)
|
|
37
49
|
|
|
38
50
|
let sessions: Array<{ id: string; source: string; project: string; title: string }>
|
|
39
51
|
try {
|
|
40
|
-
const text = await
|
|
52
|
+
const text = await scanPromise
|
|
41
53
|
try {
|
|
42
54
|
sessions = JSON.parse(text)
|
|
43
55
|
} catch {
|
|
@@ -58,6 +70,7 @@ async function scanAndPublish(): Promise<void> {
|
|
|
58
70
|
|
|
59
71
|
// Show what we found
|
|
60
72
|
const sources = [...new Set(sessions.map((s) => s.source))]
|
|
73
|
+
console.log("")
|
|
61
74
|
await UI.typeText(
|
|
62
75
|
`Found ${sessions.length} session${sessions.length > 1 ? "s" : ""} across ${sources.length} IDE${sources.length > 1 ? "s" : ""}: ${sources.join(", ")}`,
|
|
63
76
|
{ charDelay: 10 },
|
|
@@ -72,65 +85,97 @@ async function scanAndPublish(): Promise<void> {
|
|
|
72
85
|
}
|
|
73
86
|
console.log("")
|
|
74
87
|
|
|
75
|
-
|
|
76
|
-
|
|
88
|
+
// Analyze with shimmer — show the thinking process step by step
|
|
89
|
+
await shimmerLine("Analyzing sessions for interesting insights...", 1200)
|
|
77
90
|
|
|
78
|
-
// Dry run — preview
|
|
91
|
+
// Dry run — preview (with shimmer while waiting)
|
|
79
92
|
let preview: string
|
|
80
93
|
try {
|
|
81
|
-
|
|
94
|
+
const postPromise = McpBridge.callTool("auto_post", { dry_run: true })
|
|
95
|
+
await shimmerLine("Crafting a blog post from your best session...", 2000)
|
|
96
|
+
preview = await postPromise
|
|
82
97
|
} catch (err) {
|
|
83
98
|
UI.warn(`Could not generate post: ${err instanceof Error ? err.message : String(err)}`)
|
|
84
99
|
await UI.typeText("You can try again later with /publish in the app.")
|
|
85
100
|
return
|
|
86
101
|
}
|
|
87
102
|
|
|
88
|
-
// Display preview
|
|
103
|
+
// Display preview with structured layout
|
|
89
104
|
const cleaned = UI.cleanMarkdown(preview)
|
|
105
|
+
console.log("")
|
|
90
106
|
UI.divider()
|
|
91
107
|
|
|
92
|
-
//
|
|
108
|
+
// Parse out key fields for better display
|
|
93
109
|
const lines = cleaned.split("\n")
|
|
110
|
+
let title = ""
|
|
111
|
+
let tags = ""
|
|
112
|
+
let category = ""
|
|
113
|
+
const bodyLines: string[] = []
|
|
114
|
+
|
|
94
115
|
for (const line of lines) {
|
|
95
116
|
const trimmed = line.trim()
|
|
96
|
-
if (!trimmed)
|
|
97
|
-
|
|
98
|
-
|
|
117
|
+
if (!trimmed || trimmed.startsWith("DRY RUN") || trimmed === "---" || trimmed.match(/^─+$/)) continue
|
|
118
|
+
if (trimmed.startsWith("Title:")) { title = trimmed.replace("Title:", "").trim(); continue }
|
|
119
|
+
if (trimmed.startsWith("Tags:")) { tags = trimmed.replace("Tags:", "").trim(); continue }
|
|
120
|
+
if (trimmed.startsWith("Category:")) { category = trimmed.replace("Category:", "").trim(); continue }
|
|
121
|
+
if (trimmed.startsWith("Session:")) continue
|
|
122
|
+
bodyLines.push(trimmed)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Structured display
|
|
126
|
+
if (title) {
|
|
127
|
+
console.log(` ${UI.Style.TEXT_NORMAL_BOLD}📝 ${title}${UI.Style.TEXT_NORMAL}`)
|
|
128
|
+
console.log("")
|
|
129
|
+
}
|
|
130
|
+
if (category || tags) {
|
|
131
|
+
const meta: string[] = []
|
|
132
|
+
if (category) meta.push(`Category: ${category}`)
|
|
133
|
+
if (tags) meta.push(`Tags: ${tags}`)
|
|
134
|
+
console.log(` ${UI.Style.TEXT_DIM}${meta.join(" · ")}${UI.Style.TEXT_NORMAL}`)
|
|
135
|
+
console.log("")
|
|
136
|
+
}
|
|
137
|
+
if (bodyLines.length > 0) {
|
|
138
|
+
// Show a preview snippet (first few meaningful lines)
|
|
139
|
+
const snippet = bodyLines.slice(0, 6)
|
|
140
|
+
for (const line of snippet) {
|
|
141
|
+
console.log(` ${line}`)
|
|
99
142
|
}
|
|
100
|
-
if (
|
|
101
|
-
|
|
102
|
-
console.log(` ${UI.Style.TEXT_NORMAL_BOLD}${trimmed}${UI.Style.TEXT_NORMAL}`)
|
|
103
|
-
} else if (trimmed.startsWith("Tags:") || trimmed.startsWith("Category:") || trimmed.startsWith("Session:")) {
|
|
104
|
-
console.log(` ${UI.Style.TEXT_DIM}${trimmed}${UI.Style.TEXT_NORMAL}`)
|
|
105
|
-
} else if (trimmed === "---" || trimmed.match(/^─+$/)) {
|
|
106
|
-
// skip dividers in content
|
|
107
|
-
} else {
|
|
108
|
-
console.log(` ${trimmed}`)
|
|
143
|
+
if (bodyLines.length > 6) {
|
|
144
|
+
console.log(` ${UI.Style.TEXT_DIM}... (${bodyLines.length - 6} more lines)${UI.Style.TEXT_NORMAL}`)
|
|
109
145
|
}
|
|
110
146
|
}
|
|
111
147
|
|
|
112
148
|
UI.divider()
|
|
113
149
|
|
|
114
150
|
// Confirm publish
|
|
115
|
-
|
|
116
|
-
const choice = await UI.waitEnter()
|
|
151
|
+
const choice = await UI.waitEnter("Press Enter to publish, or Esc to skip")
|
|
117
152
|
|
|
118
153
|
if (choice === "escape") {
|
|
119
154
|
await UI.typeText("Skipped. You can publish later with /publish in the app.")
|
|
120
155
|
return
|
|
121
156
|
}
|
|
122
157
|
|
|
123
|
-
// Publish
|
|
124
|
-
|
|
158
|
+
// Publish with shimmer
|
|
159
|
+
const publishPromise = McpBridge.callTool("auto_post", { dry_run: false })
|
|
160
|
+
await shimmerLine("Publishing your post...", 1500)
|
|
161
|
+
|
|
125
162
|
try {
|
|
126
|
-
const result = await
|
|
163
|
+
const result = await publishPromise
|
|
127
164
|
console.log("")
|
|
128
165
|
|
|
129
|
-
// Extract URL from result
|
|
166
|
+
// Extract URL and details from result
|
|
130
167
|
const urlMatch = result.match(/(?:URL|View at|view at)[:\s]*(https?:\/\/\S+)/i)
|
|
131
168
|
if (urlMatch) {
|
|
132
|
-
UI.success(
|
|
169
|
+
UI.success("Post published successfully!")
|
|
170
|
+
console.log("")
|
|
171
|
+
if (title) {
|
|
172
|
+
console.log(` ${UI.Style.TEXT_NORMAL_BOLD}${title}${UI.Style.TEXT_NORMAL}`)
|
|
173
|
+
}
|
|
174
|
+
console.log(` ${UI.Style.TEXT_HIGHLIGHT}${urlMatch[1]}${UI.Style.TEXT_NORMAL}`)
|
|
175
|
+
console.log("")
|
|
176
|
+
await UI.typeText("Your first post is live! Others can now read, comment, and vote on it.", { charDelay: 10 })
|
|
133
177
|
} else {
|
|
178
|
+
UI.success("Post published!")
|
|
134
179
|
// Fallback: show cleaned result
|
|
135
180
|
const cleanResult = UI.cleanMarkdown(result)
|
|
136
181
|
for (const line of cleanResult.split("\n").slice(0, 5)) {
|
|
@@ -164,8 +209,7 @@ async function aiConfigPrompt(): Promise<void> {
|
|
|
164
209
|
console.log(` ${UI.Style.TEXT_DIM}"Write a post about my React refactoring"${UI.Style.TEXT_NORMAL}`)
|
|
165
210
|
console.log("")
|
|
166
211
|
|
|
167
|
-
|
|
168
|
-
const choice = await UI.waitEnter()
|
|
212
|
+
const choice = await UI.waitEnter("Press Enter to configure AI, or Esc to skip")
|
|
169
213
|
|
|
170
214
|
if (choice === "escape") {
|
|
171
215
|
console.log("")
|
|
@@ -179,21 +223,58 @@ async function aiConfigPrompt(): Promise<void> {
|
|
|
179
223
|
return
|
|
180
224
|
}
|
|
181
225
|
|
|
182
|
-
// AI config flow: URL → Key
|
|
226
|
+
// AI config flow: URL → Key with ESC support
|
|
183
227
|
console.log("")
|
|
184
|
-
|
|
185
|
-
const
|
|
228
|
+
console.log(` ${UI.Style.TEXT_NORMAL_BOLD}API URL${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}(or press Enter to skip URL, Esc to cancel):${UI.Style.TEXT_NORMAL}`)
|
|
229
|
+
const urlResult = await UI.inputWithEscape(` ${UI.Style.TEXT_HIGHLIGHT}❯ ${UI.Style.TEXT_NORMAL}`)
|
|
230
|
+
|
|
231
|
+
if (urlResult === null) {
|
|
232
|
+
// User pressed Esc
|
|
233
|
+
console.log("")
|
|
234
|
+
await UI.typeText("Skipped AI configuration. You can configure later with /ai in the app.")
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const url = urlResult.trim()
|
|
239
|
+
|
|
240
|
+
console.log(` ${UI.Style.TEXT_NORMAL_BOLD}API Key${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}(press Esc to cancel):${UI.Style.TEXT_NORMAL}`)
|
|
241
|
+
const keyResult = await UI.inputWithEscape(` ${UI.Style.TEXT_HIGHLIGHT}❯ ${UI.Style.TEXT_NORMAL}`)
|
|
242
|
+
|
|
243
|
+
if (keyResult === null) {
|
|
244
|
+
// User pressed Esc
|
|
245
|
+
console.log("")
|
|
246
|
+
await UI.typeText("Skipped AI configuration. You can configure later with /ai in the app.")
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const key = keyResult.trim()
|
|
251
|
+
|
|
252
|
+
// Both empty → friendly skip
|
|
253
|
+
if (!url && !key) {
|
|
254
|
+
console.log("")
|
|
255
|
+
UI.info("No AI configuration provided — skipping for now.")
|
|
256
|
+
await UI.typeText("You can configure AI later with /ai in the app.")
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Key empty but URL provided → friendly skip
|
|
261
|
+
if (!key) {
|
|
262
|
+
console.log("")
|
|
263
|
+
UI.info("No API key provided — skipping AI configuration.")
|
|
264
|
+
await UI.typeText("You can configure AI later with /ai in the app.")
|
|
265
|
+
return
|
|
266
|
+
}
|
|
186
267
|
|
|
187
|
-
if (
|
|
188
|
-
UI.warn("API key too short, skipping AI configuration.")
|
|
268
|
+
if (key.length < 5) {
|
|
269
|
+
UI.warn("API key seems too short, skipping AI configuration.")
|
|
189
270
|
await UI.typeText("You can configure AI later with /ai in the app.")
|
|
190
271
|
return
|
|
191
272
|
}
|
|
192
273
|
|
|
193
274
|
try {
|
|
194
275
|
const { saveProvider } = await import("../../ai/configure")
|
|
195
|
-
|
|
196
|
-
const result = await saveProvider(url
|
|
276
|
+
await shimmerLine("Detecting API format...", 1500)
|
|
277
|
+
const result = await saveProvider(url, key)
|
|
197
278
|
if (result.error) {
|
|
198
279
|
UI.warn(result.error)
|
|
199
280
|
await UI.typeText("You can try again later with /ai in the app.")
|
|
@@ -230,8 +311,7 @@ export const SetupCommand: CommandModule = {
|
|
|
230
311
|
await UI.typeText("You may need to sign up or log in on the website first.", { charDelay: 10 })
|
|
231
312
|
console.log("")
|
|
232
313
|
|
|
233
|
-
|
|
234
|
-
await UI.waitEnter()
|
|
314
|
+
await UI.waitEnter("Press Enter to open browser...")
|
|
235
315
|
|
|
236
316
|
authenticated = await authBrowser()
|
|
237
317
|
}
|
|
@@ -252,8 +332,7 @@ export const SetupCommand: CommandModule = {
|
|
|
252
332
|
await UI.typeText("I'll scan your local IDE sessions to find interesting coding experiences.", { charDelay: 10 })
|
|
253
333
|
console.log("")
|
|
254
334
|
|
|
255
|
-
|
|
256
|
-
const scanChoice = await UI.waitEnter()
|
|
335
|
+
const scanChoice = await UI.waitEnter("Press Enter to continue, or Esc to skip")
|
|
257
336
|
|
|
258
337
|
if (scanChoice === "enter") {
|
|
259
338
|
await scanAndPublish()
|
package/src/cli/cmd/update.ts
CHANGED
|
@@ -17,7 +17,22 @@ export const UpdateCommand: CommandModule = {
|
|
|
17
17
|
UI.info(`Current version: v${current}`)
|
|
18
18
|
UI.info("Checking for updates...")
|
|
19
19
|
|
|
20
|
-
const
|
|
20
|
+
const checkController = new AbortController()
|
|
21
|
+
const checkTimeout = setTimeout(() => checkController.abort(), 10_000)
|
|
22
|
+
let res: Response
|
|
23
|
+
try {
|
|
24
|
+
res = await fetch("https://registry.npmjs.org/codeblog-app/latest", { signal: checkController.signal })
|
|
25
|
+
} catch (e: any) {
|
|
26
|
+
clearTimeout(checkTimeout)
|
|
27
|
+
if (e.name === "AbortError") {
|
|
28
|
+
UI.error("Version check timed out (10s). Please check your network and try again.")
|
|
29
|
+
} else {
|
|
30
|
+
UI.error(`Failed to check for updates: ${e.message}`)
|
|
31
|
+
}
|
|
32
|
+
process.exitCode = 1
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
clearTimeout(checkTimeout)
|
|
21
36
|
if (!res.ok) {
|
|
22
37
|
UI.error("Failed to check for updates")
|
|
23
38
|
process.exitCode = 1
|
|
@@ -46,16 +61,36 @@ export const UpdateCommand: CommandModule = {
|
|
|
46
61
|
const tmp = path.join(tmpdir, `codeblog-update-${Date.now()}`)
|
|
47
62
|
await fs.mkdir(tmp, { recursive: true })
|
|
48
63
|
|
|
64
|
+
UI.info("Downloading...")
|
|
49
65
|
const tgz = path.join(tmp, "pkg.tgz")
|
|
50
|
-
const
|
|
66
|
+
const dlController = new AbortController()
|
|
67
|
+
const dlTimeout = setTimeout(() => dlController.abort(), 60_000)
|
|
68
|
+
let dlRes: Response
|
|
69
|
+
try {
|
|
70
|
+
dlRes = await fetch(url, { signal: dlController.signal })
|
|
71
|
+
} catch (e: any) {
|
|
72
|
+
clearTimeout(dlTimeout)
|
|
73
|
+
await fs.rm(tmp, { recursive: true, force: true }).catch(() => {})
|
|
74
|
+
if (e.name === "AbortError") {
|
|
75
|
+
UI.error("Download timed out (60s). Please check your network and try again.")
|
|
76
|
+
} else {
|
|
77
|
+
UI.error(`Download failed: ${e.message}`)
|
|
78
|
+
}
|
|
79
|
+
process.exitCode = 1
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
clearTimeout(dlTimeout)
|
|
51
83
|
if (!dlRes.ok) {
|
|
52
|
-
UI.error(`Failed to download update for ${platform}`)
|
|
84
|
+
UI.error(`Failed to download update for ${platform} (HTTP ${dlRes.status})`)
|
|
85
|
+
await fs.rm(tmp, { recursive: true, force: true }).catch(() => {})
|
|
53
86
|
process.exitCode = 1
|
|
54
87
|
return
|
|
55
88
|
}
|
|
56
89
|
|
|
57
|
-
await
|
|
90
|
+
const arrayBuf = await dlRes.arrayBuffer()
|
|
91
|
+
await fs.writeFile(tgz, Buffer.from(arrayBuf))
|
|
58
92
|
|
|
93
|
+
UI.info("Extracting...")
|
|
59
94
|
const proc = Bun.spawn(["tar", "-xzf", tgz, "-C", tmp], { stdout: "ignore", stderr: "ignore" })
|
|
60
95
|
await proc.exited
|
|
61
96
|
|
|
@@ -63,12 +98,22 @@ export const UpdateCommand: CommandModule = {
|
|
|
63
98
|
const ext = os === "windows" ? ".exe" : ""
|
|
64
99
|
const src = path.join(tmp, "package", "bin", `codeblog${ext}`)
|
|
65
100
|
|
|
101
|
+
UI.info("Installing...")
|
|
102
|
+
// On macOS/Linux, remove the running binary first to avoid ETXTBSY
|
|
103
|
+
if (os !== "windows") {
|
|
104
|
+
try {
|
|
105
|
+
await fs.unlink(bin)
|
|
106
|
+
} catch {
|
|
107
|
+
// ignore if removal fails
|
|
108
|
+
}
|
|
109
|
+
}
|
|
66
110
|
await fs.copyFile(src, bin)
|
|
67
111
|
if (os !== "windows") {
|
|
68
112
|
await fs.chmod(bin, 0o755)
|
|
69
113
|
}
|
|
70
114
|
if (os === "darwin") {
|
|
71
|
-
Bun.spawn(["codesign", "--sign", "-", "--force", bin], { stdout: "ignore", stderr: "ignore" })
|
|
115
|
+
const cs = Bun.spawn(["codesign", "--sign", "-", "--force", bin], { stdout: "ignore", stderr: "ignore" })
|
|
116
|
+
await cs.exited
|
|
72
117
|
}
|
|
73
118
|
|
|
74
119
|
await fs.rm(tmp, { recursive: true, force: true })
|
package/src/cli/ui.ts
CHANGED
|
@@ -72,6 +72,61 @@ export namespace UI {
|
|
|
72
72
|
})
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Input with ESC support. Returns null if user presses Escape, otherwise the input string.
|
|
77
|
+
*/
|
|
78
|
+
export async function inputWithEscape(prompt: string): Promise<string | null> {
|
|
79
|
+
const stdin = process.stdin
|
|
80
|
+
process.stderr.write(prompt)
|
|
81
|
+
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
const wasRaw = stdin.isRaw
|
|
84
|
+
if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(true)
|
|
85
|
+
|
|
86
|
+
let buf = ""
|
|
87
|
+
const onData = (ch: Buffer) => {
|
|
88
|
+
const c = ch.toString("utf8")
|
|
89
|
+
if (c === "\u0003") {
|
|
90
|
+
// Ctrl+C
|
|
91
|
+
if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
|
|
92
|
+
stdin.removeListener("data", onData)
|
|
93
|
+
process.exit(130)
|
|
94
|
+
}
|
|
95
|
+
if (c === "\x1b") {
|
|
96
|
+
// Escape
|
|
97
|
+
if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
|
|
98
|
+
stdin.removeListener("data", onData)
|
|
99
|
+
process.stderr.write("\n")
|
|
100
|
+
resolve(null)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
if (c === "\r" || c === "\n") {
|
|
104
|
+
// Enter
|
|
105
|
+
if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
|
|
106
|
+
stdin.removeListener("data", onData)
|
|
107
|
+
process.stderr.write("\n")
|
|
108
|
+
resolve(buf)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
if (c === "\u007f" || c === "\b") {
|
|
112
|
+
// Backspace
|
|
113
|
+
if (buf.length > 0) {
|
|
114
|
+
buf = buf.slice(0, -1)
|
|
115
|
+
process.stderr.write("\b \b")
|
|
116
|
+
}
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
// Regular character
|
|
120
|
+
const clean = c.replace(/[\x00-\x1f\x7f]/g, "")
|
|
121
|
+
if (clean) {
|
|
122
|
+
buf += clean
|
|
123
|
+
process.stderr.write(clean)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
stdin.on("data", onData)
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
75
130
|
export async function password(prompt: string): Promise<string> {
|
|
76
131
|
const readline = require("readline")
|
|
77
132
|
const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true })
|
package/src/tui/routes/home.tsx
CHANGED
|
@@ -234,11 +234,25 @@ export function Home(props: {
|
|
|
234
234
|
const v = aiUrl().trim()
|
|
235
235
|
if (v && !v.startsWith("http")) { showMsg("URL must start with http:// or https://", theme.colors.error); return }
|
|
236
236
|
setAiMode("key")
|
|
237
|
-
showMsg("Now paste your API key:", theme.colors.primary)
|
|
237
|
+
showMsg("Now paste your API key (or press Esc to cancel):", theme.colors.primary)
|
|
238
238
|
return
|
|
239
239
|
}
|
|
240
240
|
if (aiMode() === "key") {
|
|
241
|
-
|
|
241
|
+
const url = aiUrl().trim()
|
|
242
|
+
const key = aiKey().trim()
|
|
243
|
+
// Both empty → friendly skip
|
|
244
|
+
if (!url && !key) {
|
|
245
|
+
showMsg("No AI configuration provided — skipped. Use /ai anytime to configure.", theme.colors.warning)
|
|
246
|
+
setAiMode("")
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
// Key empty but URL provided → friendly skip
|
|
250
|
+
if (!key) {
|
|
251
|
+
showMsg("No API key provided — skipped. Use /ai anytime to configure.", theme.colors.warning)
|
|
252
|
+
setAiMode("")
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
if (key.length < 5) { showMsg("API key too short", theme.colors.error); return }
|
|
242
256
|
saveAI()
|
|
243
257
|
return
|
|
244
258
|
}
|