codeblog-app 2.0.1 → 2.1.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.
- package/package.json +6 -6
- package/src/ai/__tests__/provider.test.ts +2 -2
- package/src/ai/__tests__/tools.test.ts +3 -3
- package/src/ai/configure.ts +47 -2
- package/src/ai/tools.ts +22 -7
- package/src/auth/oauth.ts +2 -1
- package/src/cli/cmd/login.ts +1 -1
- package/src/cli/cmd/setup.ts +203 -131
- package/src/cli/ui.ts +71 -0
- package/src/index.ts +13 -2
- package/src/tui/app.tsx +1 -1
- package/src/tui/commands.ts +35 -25
- package/src/tui/routes/home.tsx +32 -14
- package/src/util/__tests__/context.test.ts +9 -9
- package/src/util/__tests__/lazy.test.ts +12 -12
- package/src/cli/__tests__/setup.test.ts +0 -57
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.0
|
|
4
|
+
"version": "2.1.0",
|
|
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.
|
|
60
|
-
"codeblog-app-darwin-x64": "2.
|
|
61
|
-
"codeblog-app-linux-arm64": "2.
|
|
62
|
-
"codeblog-app-linux-x64": "2.
|
|
63
|
-
"codeblog-app-windows-x64": "2.
|
|
59
|
+
"codeblog-app-darwin-arm64": "2.1.0",
|
|
60
|
+
"codeblog-app-darwin-x64": "2.1.0",
|
|
61
|
+
"codeblog-app-linux-arm64": "2.1.0",
|
|
62
|
+
"codeblog-app-linux-x64": "2.1.0",
|
|
63
|
+
"codeblog-app-windows-x64": "2.1.0"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@ai-sdk/anthropic": "^3.0.44",
|
|
@@ -178,7 +178,7 @@ describe("AIProvider", () => {
|
|
|
178
178
|
process.env.OPENAI_API_KEY = "sk-test"
|
|
179
179
|
const providers = await AIProvider.listProviders()
|
|
180
180
|
expect(providers.openai).toBeDefined()
|
|
181
|
-
expect(providers.openai
|
|
182
|
-
expect(providers.openai
|
|
181
|
+
expect(providers.openai!.hasKey).toBe(true)
|
|
182
|
+
expect(providers.openai!.models.length).toBeGreaterThan(0)
|
|
183
183
|
})
|
|
184
184
|
})
|
|
@@ -14,12 +14,12 @@ describe("AI Tools", () => {
|
|
|
14
14
|
"browse_by_tag", "trending_topics", "explore_and_engage", "join_debate",
|
|
15
15
|
"my_notifications",
|
|
16
16
|
"manage_agents", "my_posts", "my_dashboard", "follow_user",
|
|
17
|
-
"codeblog_status",
|
|
17
|
+
"codeblog_setup", "codeblog_status",
|
|
18
18
|
]
|
|
19
19
|
|
|
20
|
-
test("exports all
|
|
20
|
+
test("exports all 25 tools", () => {
|
|
21
21
|
const toolNames = Object.keys(chatTools)
|
|
22
|
-
expect(toolNames).toHaveLength(
|
|
22
|
+
expect(toolNames).toHaveLength(25)
|
|
23
23
|
})
|
|
24
24
|
|
|
25
25
|
test("each expected tool is present in chatTools", () => {
|
package/src/ai/configure.ts
CHANGED
|
@@ -46,6 +46,29 @@ const ENV_MAP: Record<string, string> = {
|
|
|
46
46
|
"openai-compatible": "OPENAI_COMPATIBLE_API_KEY",
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
async function fetchFirstModel(base: string, key: string): Promise<string | null> {
|
|
50
|
+
try {
|
|
51
|
+
const clean = base.replace(/\/+$/, "")
|
|
52
|
+
const r = await fetch(`${clean}/v1/models`, {
|
|
53
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
54
|
+
signal: AbortSignal.timeout(8000),
|
|
55
|
+
})
|
|
56
|
+
if (!r.ok) return null
|
|
57
|
+
const data = await r.json() as { data?: Array<{ id: string }> }
|
|
58
|
+
if (!data.data || data.data.length === 0) return null
|
|
59
|
+
|
|
60
|
+
// Prefer capable models: claude-sonnet > gpt-4o > claude-opus > first available
|
|
61
|
+
const ids = data.data.map((m) => m.id)
|
|
62
|
+
const preferred = [/^claude-sonnet-4/, /^gpt-4o$/, /^claude-opus-4/, /^gpt-4o-mini$/, /^gemini-2\.5-flash$/]
|
|
63
|
+
for (const pattern of preferred) {
|
|
64
|
+
const match = ids.find((id) => pattern.test(id))
|
|
65
|
+
if (match) return match
|
|
66
|
+
}
|
|
67
|
+
return ids[0] ?? null
|
|
68
|
+
} catch {}
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
49
72
|
export function detectProvider(key: string) {
|
|
50
73
|
for (const [prefix, provider] of Object.entries(KEY_PREFIX_MAP)) {
|
|
51
74
|
if (key.startsWith(prefix)) return provider
|
|
@@ -69,7 +92,20 @@ export async function saveProvider(url: string, key: string): Promise<{ provider
|
|
|
69
92
|
const cfg = await Config.load()
|
|
70
93
|
const providers = cfg.providers || {}
|
|
71
94
|
providers[provider] = { api_key: key, base_url: url }
|
|
72
|
-
|
|
95
|
+
|
|
96
|
+
// Auto-set model if not already configured
|
|
97
|
+
const update: Record<string, unknown> = { providers }
|
|
98
|
+
if (!cfg.model) {
|
|
99
|
+
if (detected === "anthropic") {
|
|
100
|
+
update.model = "claude-sonnet-4-20250514"
|
|
101
|
+
} else {
|
|
102
|
+
// For openai-compatible with custom URL, try to fetch available models
|
|
103
|
+
const model = await fetchFirstModel(url, key)
|
|
104
|
+
if (model) update.model = `openai-compatible/${model}`
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
await Config.save(update)
|
|
73
109
|
return { provider: `${detected} format` }
|
|
74
110
|
}
|
|
75
111
|
|
|
@@ -79,7 +115,16 @@ export async function saveProvider(url: string, key: string): Promise<{ provider
|
|
|
79
115
|
const cfg = await Config.load()
|
|
80
116
|
const providers = cfg.providers || {}
|
|
81
117
|
providers[provider] = { api_key: key }
|
|
82
|
-
|
|
118
|
+
|
|
119
|
+
// Auto-set model for known providers
|
|
120
|
+
const update: Record<string, unknown> = { providers }
|
|
121
|
+
if (!cfg.model) {
|
|
122
|
+
const { AIProvider } = await import("./provider")
|
|
123
|
+
const models = Object.values(AIProvider.BUILTIN_MODELS).filter((m) => m.providerID === provider)
|
|
124
|
+
if (models.length > 0) update.model = models[0]!.id
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await Config.save(update)
|
|
83
128
|
return { provider }
|
|
84
129
|
}
|
|
85
130
|
|
package/src/ai/tools.ts
CHANGED
|
@@ -2,9 +2,15 @@ import { tool as _rawTool } from "ai"
|
|
|
2
2
|
import { z } from "zod"
|
|
3
3
|
import { McpBridge } from "../mcp/client"
|
|
4
4
|
|
|
5
|
-
// Workaround: zod v4 + AI SDK v6 tool()
|
|
6
|
-
//
|
|
7
|
-
|
|
5
|
+
// Workaround: zod v4 + AI SDK v6 + Bun — tool() sets `parameters` but streamText reads
|
|
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
|
+
}
|
|
8
14
|
|
|
9
15
|
// ---------------------------------------------------------------------------
|
|
10
16
|
// Tool display labels for the TUI streaming indicator
|
|
@@ -33,6 +39,7 @@ export const TOOL_LABELS: Record<string, string> = {
|
|
|
33
39
|
my_posts: "Loading your posts...",
|
|
34
40
|
my_dashboard: "Loading dashboard...",
|
|
35
41
|
follow_user: "Processing follow...",
|
|
42
|
+
codeblog_setup: "Configuring CodeBlog...",
|
|
36
43
|
codeblog_status: "Checking status...",
|
|
37
44
|
}
|
|
38
45
|
|
|
@@ -261,13 +268,13 @@ const my_notifications = tool({
|
|
|
261
268
|
// Agent tools
|
|
262
269
|
// ---------------------------------------------------------------------------
|
|
263
270
|
const manage_agents = tool({
|
|
264
|
-
description: "Manage your CodeBlog agents — list, create, or
|
|
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.",
|
|
265
272
|
parameters: z.object({
|
|
266
|
-
action: z.enum(["list", "create", "delete"]).describe("'list', 'create', or '
|
|
273
|
+
action: z.enum(["list", "create", "delete", "switch"]).describe("'list', 'create', 'delete', or 'switch'"),
|
|
267
274
|
name: z.string().optional().describe("Agent name (for create)"),
|
|
268
275
|
description: z.string().optional().describe("Agent description (for create)"),
|
|
269
276
|
source_type: z.string().optional().describe("IDE source (for create)"),
|
|
270
|
-
agent_id: z.string().optional().describe("Agent ID (for delete)"),
|
|
277
|
+
agent_id: z.string().optional().describe("Agent ID (for delete or switch)"),
|
|
271
278
|
}),
|
|
272
279
|
execute: async (args: any) => mcp("manage_agents", clean(args)),
|
|
273
280
|
})
|
|
@@ -300,6 +307,14 @@ const follow_user = tool({
|
|
|
300
307
|
// ---------------------------------------------------------------------------
|
|
301
308
|
// Config & Status
|
|
302
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
|
+
})
|
|
317
|
+
|
|
303
318
|
const codeblog_status = tool({
|
|
304
319
|
description: "Health check — see if CodeBlog is set up, which IDEs are detected, and agent status.",
|
|
305
320
|
parameters: z.object({}),
|
|
@@ -317,5 +332,5 @@ export const chatTools = {
|
|
|
317
332
|
browse_by_tag, trending_topics, explore_and_engage, join_debate,
|
|
318
333
|
my_notifications,
|
|
319
334
|
manage_agents, my_posts, my_dashboard, follow_user,
|
|
320
|
-
codeblog_status,
|
|
335
|
+
codeblog_setup, codeblog_status,
|
|
321
336
|
}
|
package/src/auth/oauth.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { Log } from "../util/log"
|
|
|
7
7
|
const log = Log.create({ service: "oauth" })
|
|
8
8
|
|
|
9
9
|
export namespace OAuth {
|
|
10
|
-
export async function login(
|
|
10
|
+
export async function login(options?: { onUrl?: (url: string) => void }) {
|
|
11
11
|
const open = (await import("open")).default
|
|
12
12
|
const base = await Config.url()
|
|
13
13
|
|
|
@@ -81,6 +81,7 @@ p{font-size:15px;color:#6a737c;line-height:1.5}
|
|
|
81
81
|
|
|
82
82
|
const authUrl = `${base}/auth/cli?port=${port}`
|
|
83
83
|
log.info("opening browser", { url: authUrl })
|
|
84
|
+
if (options?.onUrl) options.onUrl(authUrl)
|
|
84
85
|
open(authUrl)
|
|
85
86
|
|
|
86
87
|
// Timeout after 5 minutes
|
package/src/cli/cmd/login.ts
CHANGED
|
@@ -35,7 +35,7 @@ export const LoginCommand: CommandModule = {
|
|
|
35
35
|
|
|
36
36
|
UI.info(`Opening browser for ${args.provider} authentication...`)
|
|
37
37
|
try {
|
|
38
|
-
await OAuth.login(
|
|
38
|
+
await OAuth.login()
|
|
39
39
|
UI.success("Successfully authenticated!")
|
|
40
40
|
} catch (err) {
|
|
41
41
|
UI.error(`Authentication failed: ${err instanceof Error ? err.message : String(err)}`)
|
package/src/cli/cmd/setup.ts
CHANGED
|
@@ -4,177 +4,236 @@ import { OAuth } from "../../auth/oauth"
|
|
|
4
4
|
import { McpBridge } from "../../mcp/client"
|
|
5
5
|
import { UI } from "../ui"
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
export let setupCompleted = false
|
|
8
|
+
|
|
9
|
+
// ─── Auth ────────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
async function authBrowser(): Promise<boolean> {
|
|
12
|
+
try {
|
|
13
|
+
console.log(` ${UI.Style.TEXT_DIM}Opening browser for login...${UI.Style.TEXT_NORMAL}`)
|
|
14
|
+
|
|
15
|
+
await OAuth.login({
|
|
16
|
+
onUrl: (url) => {
|
|
17
|
+
console.log(` ${UI.Style.TEXT_DIM}If the browser didn't open, visit:${UI.Style.TEXT_NORMAL}`)
|
|
18
|
+
console.log(` ${UI.Style.TEXT_HIGHLIGHT}${url}${UI.Style.TEXT_NORMAL}`)
|
|
19
|
+
console.log("")
|
|
20
|
+
console.log(` ${UI.Style.TEXT_DIM}Waiting for authentication...${UI.Style.TEXT_NORMAL}`)
|
|
21
|
+
},
|
|
22
|
+
})
|
|
11
23
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const owner = text.match(/^Owner:\s*(\S+)/m)
|
|
18
|
-
if (owner) return owner[1]!
|
|
19
|
-
return null
|
|
24
|
+
return true
|
|
25
|
+
} catch (err) {
|
|
26
|
+
UI.error(`Authentication failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
20
29
|
}
|
|
21
30
|
|
|
22
|
-
|
|
31
|
+
// ─── Scan & Publish ──────────────────────────────────────────────────────────
|
|
23
32
|
|
|
24
|
-
async function
|
|
25
|
-
|
|
26
|
-
|
|
33
|
+
async function scanAndPublish(): Promise<void> {
|
|
34
|
+
// Scan
|
|
35
|
+
await UI.typeText("Scanning your local IDE sessions...", { charDelay: 15 })
|
|
36
|
+
console.log("")
|
|
27
37
|
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
let sessions: Array<{ id: string; source: string; project: string; title: string }>
|
|
39
|
+
try {
|
|
40
|
+
const text = await McpBridge.callTool("scan_sessions", { limit: 10 })
|
|
41
|
+
try {
|
|
42
|
+
sessions = JSON.parse(text)
|
|
43
|
+
} catch {
|
|
44
|
+
console.log(` ${text}`)
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
UI.warn(`Could not scan sessions: ${err instanceof Error ? err.message : String(err)}`)
|
|
49
|
+
await UI.typeText("No worries — you can scan later with /scan in the app.")
|
|
50
|
+
return
|
|
51
|
+
}
|
|
30
52
|
|
|
31
|
-
|
|
32
|
-
|
|
53
|
+
if (sessions.length === 0) {
|
|
54
|
+
await UI.typeText("No IDE sessions found yet. That's okay!")
|
|
55
|
+
await UI.typeText("You can scan later with /scan once you've used an AI-powered IDE.")
|
|
56
|
+
return
|
|
57
|
+
}
|
|
33
58
|
|
|
34
|
-
|
|
35
|
-
const
|
|
59
|
+
// Show what we found
|
|
60
|
+
const sources = [...new Set(sessions.map((s) => s.source))]
|
|
61
|
+
await UI.typeText(
|
|
62
|
+
`Found ${sessions.length} session${sessions.length > 1 ? "s" : ""} across ${sources.length} IDE${sources.length > 1 ? "s" : ""}: ${sources.join(", ")}`,
|
|
63
|
+
{ charDelay: 10 },
|
|
64
|
+
)
|
|
65
|
+
console.log("")
|
|
66
|
+
|
|
67
|
+
for (const s of sessions.slice(0, 3)) {
|
|
68
|
+
console.log(` ${UI.Style.TEXT_INFO}[${s.source}]${UI.Style.TEXT_NORMAL} ${s.project} — ${s.title.slice(0, 60)}`)
|
|
69
|
+
}
|
|
70
|
+
if (sessions.length > 3) {
|
|
71
|
+
console.log(` ${UI.Style.TEXT_DIM}... and ${sessions.length - 3} more${UI.Style.TEXT_NORMAL}`)
|
|
72
|
+
}
|
|
73
|
+
console.log("")
|
|
36
74
|
|
|
75
|
+
await UI.typeText("Let me analyze your most interesting session and create a blog post...")
|
|
37
76
|
console.log("")
|
|
38
|
-
UI.info("Creating your account...")
|
|
39
77
|
|
|
78
|
+
// Dry run — preview
|
|
79
|
+
let preview: string
|
|
40
80
|
try {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
})
|
|
81
|
+
preview = await McpBridge.callTool("auto_post", { dry_run: true })
|
|
82
|
+
} catch (err) {
|
|
83
|
+
UI.warn(`Could not generate post: ${err instanceof Error ? err.message : String(err)}`)
|
|
84
|
+
await UI.typeText("You can try again later with /publish in the app.")
|
|
85
|
+
return
|
|
86
|
+
}
|
|
44
87
|
|
|
45
|
-
|
|
46
|
-
|
|
88
|
+
// Display preview
|
|
89
|
+
const cleaned = UI.cleanMarkdown(preview)
|
|
90
|
+
UI.divider()
|
|
47
91
|
|
|
48
|
-
|
|
49
|
-
|
|
92
|
+
// Extract and display title/tags nicely
|
|
93
|
+
const lines = cleaned.split("\n")
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
const trimmed = line.trim()
|
|
96
|
+
if (!trimmed) {
|
|
97
|
+
console.log("")
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
if (trimmed.startsWith("DRY RUN")) continue
|
|
101
|
+
if (trimmed.startsWith("Title:")) {
|
|
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
|
|
50
107
|
} else {
|
|
51
|
-
|
|
52
|
-
UI.info("Try: codeblog setup → option 3 to paste your API key manually.")
|
|
53
|
-
return false
|
|
108
|
+
console.log(` ${trimmed}`)
|
|
54
109
|
}
|
|
110
|
+
}
|
|
55
111
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
112
|
+
UI.divider()
|
|
113
|
+
|
|
114
|
+
// Confirm publish
|
|
115
|
+
console.log(` ${UI.Style.TEXT_DIM}Press Enter to publish this post, or Esc to skip${UI.Style.TEXT_NORMAL}`)
|
|
116
|
+
const choice = await UI.waitEnter()
|
|
117
|
+
|
|
118
|
+
if (choice === "escape") {
|
|
119
|
+
await UI.typeText("Skipped. You can publish later with /publish in the app.")
|
|
120
|
+
return
|
|
60
121
|
}
|
|
61
|
-
}
|
|
62
|
-
async function authOAuth(): Promise<boolean> {
|
|
63
|
-
const provider = await UI.input(" Choose provider (github/google) [github]: ")
|
|
64
|
-
const chosen = (provider === "google" ? "google" : "github") as "github" | "google"
|
|
65
122
|
|
|
123
|
+
// Publish
|
|
124
|
+
await UI.typeText("Publishing...", { charDelay: 20 })
|
|
66
125
|
try {
|
|
67
|
-
await
|
|
68
|
-
|
|
126
|
+
const result = await McpBridge.callTool("auto_post", { dry_run: false })
|
|
127
|
+
console.log("")
|
|
128
|
+
|
|
129
|
+
// Extract URL from result
|
|
130
|
+
const urlMatch = result.match(/(?:URL|View at|view at)[:\s]*(https?:\/\/\S+)/i)
|
|
131
|
+
if (urlMatch) {
|
|
132
|
+
UI.success(`Published! View at: ${urlMatch[1]}`)
|
|
133
|
+
} else {
|
|
134
|
+
// Fallback: show cleaned result
|
|
135
|
+
const cleanResult = UI.cleanMarkdown(result)
|
|
136
|
+
for (const line of cleanResult.split("\n").slice(0, 5)) {
|
|
137
|
+
if (line.trim()) console.log(` ${line.trim()}`)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
69
140
|
} catch (err) {
|
|
70
|
-
UI.error(`
|
|
71
|
-
|
|
141
|
+
UI.error(`Publish failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
142
|
+
await UI.typeText("You can try again later with /publish.")
|
|
72
143
|
}
|
|
73
144
|
}
|
|
74
145
|
|
|
75
|
-
|
|
76
|
-
const key = await UI.input(" Paste your API key (starts with cbk_): ")
|
|
77
|
-
if (!key || !key.startsWith("cbk_")) {
|
|
78
|
-
UI.error("Invalid API key. It should start with 'cbk_'.")
|
|
79
|
-
return false
|
|
80
|
-
}
|
|
146
|
+
// ─── AI Configuration ────────────────────────────────────────────────────────
|
|
81
147
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
UI.error(`Verification failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
90
|
-
return false
|
|
148
|
+
async function aiConfigPrompt(): Promise<void> {
|
|
149
|
+
const { AIProvider } = await import("../../ai/provider")
|
|
150
|
+
const hasKey = await AIProvider.hasAnyKey()
|
|
151
|
+
|
|
152
|
+
if (hasKey) {
|
|
153
|
+
UI.success("AI provider already configured!")
|
|
154
|
+
return
|
|
91
155
|
}
|
|
92
|
-
}
|
|
93
156
|
|
|
94
|
-
|
|
95
|
-
console.log("")
|
|
157
|
+
UI.divider()
|
|
96
158
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
} catch {
|
|
105
|
-
console.log(text)
|
|
106
|
-
return
|
|
107
|
-
}
|
|
159
|
+
await UI.typeText("One more thing — would you like to configure an AI chat provider?")
|
|
160
|
+
console.log("")
|
|
161
|
+
await UI.typeText("With AI configured, you can interact with the forum using natural language:", { charDelay: 8 })
|
|
162
|
+
console.log(` ${UI.Style.TEXT_DIM}"Show me trending posts about TypeScript"${UI.Style.TEXT_NORMAL}`)
|
|
163
|
+
console.log(` ${UI.Style.TEXT_DIM}"Analyze my latest coding session"${UI.Style.TEXT_NORMAL}`)
|
|
164
|
+
console.log(` ${UI.Style.TEXT_DIM}"Write a post about my React refactoring"${UI.Style.TEXT_NORMAL}`)
|
|
165
|
+
console.log("")
|
|
108
166
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
return
|
|
112
|
-
}
|
|
167
|
+
console.log(` ${UI.Style.TEXT_DIM}Press Enter to configure AI, or Esc to skip${UI.Style.TEXT_NORMAL}`)
|
|
168
|
+
const choice = await UI.waitEnter()
|
|
113
169
|
|
|
114
|
-
|
|
170
|
+
if (choice === "escape") {
|
|
115
171
|
console.log("")
|
|
116
|
-
|
|
117
|
-
console.log(` ${UI.Style.TEXT_INFO}[${s.source}]${UI.Style.TEXT_NORMAL} ${s.project} — ${s.title.slice(0, 60)}`)
|
|
118
|
-
}
|
|
119
|
-
if (sessions.length > 5) {
|
|
120
|
-
console.log(` ${UI.Style.TEXT_DIM}... and ${sessions.length - 5} more${UI.Style.TEXT_NORMAL}`)
|
|
121
|
-
}
|
|
172
|
+
await UI.typeText("No problem! You can configure AI later with /ai in the app.")
|
|
122
173
|
console.log("")
|
|
174
|
+
await UI.typeText("Even without AI, you can use slash commands to interact:", { charDelay: 8 })
|
|
175
|
+
console.log(` ${UI.Style.TEXT_HIGHLIGHT}/scan${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}Scan IDE sessions${UI.Style.TEXT_NORMAL}`)
|
|
176
|
+
console.log(` ${UI.Style.TEXT_HIGHLIGHT}/publish${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}Publish a post${UI.Style.TEXT_NORMAL}`)
|
|
177
|
+
console.log(` ${UI.Style.TEXT_HIGHLIGHT}/feed${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}Browse the forum${UI.Style.TEXT_NORMAL}`)
|
|
178
|
+
console.log(` ${UI.Style.TEXT_HIGHLIGHT}/theme${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}Change color theme${UI.Style.TEXT_NORMAL}`)
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// AI config flow: URL → Key (reuses saveProvider from ai/configure.ts)
|
|
183
|
+
console.log("")
|
|
184
|
+
const url = await UI.input(` ${UI.Style.TEXT_NORMAL_BOLD}API URL${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}(or press Enter to skip):${UI.Style.TEXT_NORMAL} `)
|
|
185
|
+
const key = await UI.input(` ${UI.Style.TEXT_NORMAL_BOLD}API Key:${UI.Style.TEXT_NORMAL} `)
|
|
186
|
+
|
|
187
|
+
if (!key || key.length < 5) {
|
|
188
|
+
UI.warn("API key too short, skipping AI configuration.")
|
|
189
|
+
await UI.typeText("You can configure AI later with /ai in the app.")
|
|
190
|
+
return
|
|
191
|
+
}
|
|
123
192
|
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
193
|
+
try {
|
|
194
|
+
const { saveProvider } = await import("../../ai/configure")
|
|
195
|
+
console.log(` ${UI.Style.TEXT_DIM}Detecting API format...${UI.Style.TEXT_NORMAL}`)
|
|
196
|
+
const result = await saveProvider(url.trim(), key.trim())
|
|
197
|
+
if (result.error) {
|
|
198
|
+
UI.warn(result.error)
|
|
199
|
+
await UI.typeText("You can try again later with /ai in the app.")
|
|
128
200
|
} else {
|
|
129
|
-
UI.
|
|
130
|
-
try {
|
|
131
|
-
const result = await McpBridge.callTool("auto_post", { dry_run: false })
|
|
132
|
-
console.log("")
|
|
133
|
-
for (const line of result.split("\n")) {
|
|
134
|
-
console.log(` ${line}`)
|
|
135
|
-
}
|
|
136
|
-
} catch (pubErr) {
|
|
137
|
-
UI.error(`Publish failed: ${pubErr instanceof Error ? pubErr.message : String(pubErr)}`)
|
|
138
|
-
}
|
|
201
|
+
UI.success(`AI configured! (${result.provider})`)
|
|
139
202
|
}
|
|
140
203
|
} catch (err) {
|
|
141
|
-
UI.
|
|
204
|
+
UI.warn(`Configuration failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
205
|
+
await UI.typeText("You can try again later with /ai in the app.")
|
|
142
206
|
}
|
|
143
207
|
}
|
|
208
|
+
|
|
209
|
+
// ─── Setup Command ───────────────────────────────────────────────────────────
|
|
210
|
+
|
|
144
211
|
export const SetupCommand: CommandModule = {
|
|
145
212
|
command: "setup",
|
|
146
|
-
describe: "First-time setup wizard: authenticate
|
|
213
|
+
describe: "First-time setup wizard: authenticate, scan, publish, configure AI",
|
|
147
214
|
handler: async () => {
|
|
215
|
+
// Phase 1: Welcome
|
|
148
216
|
console.log(UI.logo())
|
|
149
|
-
|
|
150
|
-
|
|
217
|
+
await UI.typeText("Welcome to CodeBlog!", { charDelay: 20 })
|
|
218
|
+
await UI.typeText("The AI-powered coding forum in your terminal.", { charDelay: 15 })
|
|
151
219
|
console.log("")
|
|
152
220
|
|
|
221
|
+
// Phase 2: Authentication
|
|
153
222
|
const alreadyAuthed = await Auth.authenticated()
|
|
154
223
|
let authenticated = alreadyAuthed
|
|
155
224
|
|
|
156
225
|
if (alreadyAuthed) {
|
|
157
|
-
|
|
226
|
+
const token = await Auth.get()
|
|
227
|
+
UI.success(`Already authenticated as ${token?.username || "user"}!`)
|
|
158
228
|
} else {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
"Login with GitHub / Google",
|
|
162
|
-
"Paste existing API key",
|
|
163
|
-
])
|
|
164
|
-
|
|
229
|
+
await UI.typeText("Let's get you set up. First, we need to authenticate your account.")
|
|
230
|
+
await UI.typeText("You may need to sign up or log in on the website first.", { charDelay: 10 })
|
|
165
231
|
console.log("")
|
|
166
232
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
case 1:
|
|
172
|
-
authenticated = await authOAuth()
|
|
173
|
-
break
|
|
174
|
-
case 2:
|
|
175
|
-
authenticated = await authApiKey()
|
|
176
|
-
break
|
|
177
|
-
}
|
|
233
|
+
console.log(` ${UI.Style.TEXT_DIM}Press Enter to open browser...${UI.Style.TEXT_NORMAL}`)
|
|
234
|
+
await UI.waitEnter()
|
|
235
|
+
|
|
236
|
+
authenticated = await authBrowser()
|
|
178
237
|
}
|
|
179
238
|
|
|
180
239
|
if (!authenticated) {
|
|
@@ -186,16 +245,29 @@ export const SetupCommand: CommandModule = {
|
|
|
186
245
|
const token = await Auth.get()
|
|
187
246
|
UI.success(`Authenticated as ${token?.username || "user"}!`)
|
|
188
247
|
|
|
189
|
-
|
|
248
|
+
// Phase 3: Interactive scan & publish
|
|
249
|
+
UI.divider()
|
|
190
250
|
|
|
251
|
+
await UI.typeText("Great! Let's see what you've been working on.")
|
|
252
|
+
await UI.typeText("I'll scan your local IDE sessions to find interesting coding experiences.", { charDelay: 10 })
|
|
191
253
|
console.log("")
|
|
192
|
-
|
|
193
|
-
console.log(
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
254
|
+
|
|
255
|
+
console.log(` ${UI.Style.TEXT_DIM}Press Enter to continue...${UI.Style.TEXT_NORMAL}`)
|
|
256
|
+
const scanChoice = await UI.waitEnter()
|
|
257
|
+
|
|
258
|
+
if (scanChoice === "enter") {
|
|
259
|
+
await scanAndPublish()
|
|
260
|
+
} else {
|
|
261
|
+
await UI.typeText("Skipped. You can scan and publish later in the app.")
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Phase 4: AI configuration
|
|
265
|
+
await aiConfigPrompt()
|
|
266
|
+
|
|
267
|
+
// Phase 5: Transition to TUI
|
|
268
|
+
UI.divider()
|
|
269
|
+
setupCompleted = true
|
|
270
|
+
await UI.typeText("All set! Launching CodeBlog...", { charDelay: 20 })
|
|
271
|
+
await Bun.sleep(800)
|
|
200
272
|
},
|
|
201
|
-
}
|
|
273
|
+
}
|
package/src/cli/ui.ts
CHANGED
|
@@ -121,4 +121,75 @@ export namespace UI {
|
|
|
121
121
|
if (isNaN(num) || num < 1 || num > options.length) return 0
|
|
122
122
|
return num - 1
|
|
123
123
|
}
|
|
124
|
+
|
|
125
|
+
export async function waitKey(prompt: string, keys: string[]): Promise<string> {
|
|
126
|
+
const stdin = process.stdin
|
|
127
|
+
process.stderr.write(` ${Style.TEXT_DIM}${prompt}${Style.TEXT_NORMAL}`)
|
|
128
|
+
|
|
129
|
+
return new Promise((resolve) => {
|
|
130
|
+
const wasRaw = stdin.isRaw
|
|
131
|
+
if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(true)
|
|
132
|
+
|
|
133
|
+
const onData = (ch: Buffer) => {
|
|
134
|
+
const c = ch.toString("utf8")
|
|
135
|
+
if (c === "\u0003") {
|
|
136
|
+
// Ctrl+C
|
|
137
|
+
if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
|
|
138
|
+
stdin.removeListener("data", onData)
|
|
139
|
+
process.exit(130)
|
|
140
|
+
}
|
|
141
|
+
const key = (c === "\r" || c === "\n") ? "enter" : c === "\x1b" ? "escape" : c.toLowerCase()
|
|
142
|
+
if (keys.includes(key)) {
|
|
143
|
+
if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
|
|
144
|
+
stdin.removeListener("data", onData)
|
|
145
|
+
process.stderr.write("\n")
|
|
146
|
+
resolve(key)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
stdin.on("data", onData)
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Wait for Enter key (or Esc to skip). Returns "enter" or "escape".
|
|
155
|
+
*/
|
|
156
|
+
export async function waitEnter(prompt?: string): Promise<"enter" | "escape"> {
|
|
157
|
+
return waitKey(prompt || "Press Enter to continue...", ["enter", "escape"]) as Promise<"enter" | "escape">
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Streaming typewriter effect — prints text character by character to stderr.
|
|
162
|
+
*/
|
|
163
|
+
export async function typeText(text: string, opts?: { charDelay?: number; prefix?: string }) {
|
|
164
|
+
const delay = opts?.charDelay ?? 12
|
|
165
|
+
const prefix = opts?.prefix ?? " "
|
|
166
|
+
Bun.stderr.write(prefix)
|
|
167
|
+
for (const ch of text) {
|
|
168
|
+
Bun.stderr.write(ch)
|
|
169
|
+
if (delay > 0) await Bun.sleep(delay)
|
|
170
|
+
}
|
|
171
|
+
Bun.stderr.write(EOL)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Clean markdown formatting from MCP tool output for CLI display.
|
|
176
|
+
* Removes **bold**, *italic*, keeps structure readable.
|
|
177
|
+
*/
|
|
178
|
+
export function cleanMarkdown(text: string): string {
|
|
179
|
+
return text
|
|
180
|
+
.replace(/\*\*(.+?)\*\*/g, "$1") // **bold** → bold
|
|
181
|
+
.replace(/\*(.+?)\*/g, "$1") // *italic* → italic
|
|
182
|
+
.replace(/`([^`]+)`/g, "$1") // `code` → code
|
|
183
|
+
.replace(/^#{1,6}\s+/gm, "") // ### heading → heading
|
|
184
|
+
.replace(/^---+$/gm, "──────────────────────────────────") // horizontal rule
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Print a horizontal divider.
|
|
189
|
+
*/
|
|
190
|
+
export function divider() {
|
|
191
|
+
println("")
|
|
192
|
+
println(` ${Style.TEXT_DIM}──────────────────────────────────${Style.TEXT_NORMAL}`)
|
|
193
|
+
println("")
|
|
194
|
+
}
|
|
124
195
|
}
|
package/src/index.ts
CHANGED
|
@@ -153,12 +153,23 @@ if (!hasSubcommand && !isHelp && !isVersion) {
|
|
|
153
153
|
|
|
154
154
|
const authed = await Auth.authenticated()
|
|
155
155
|
if (!authed) {
|
|
156
|
-
UI.warn("Not logged in. Running setup wizard...")
|
|
157
156
|
console.log("")
|
|
158
157
|
// Use the statically imported SetupCommand
|
|
159
158
|
await (SetupCommand.handler as Function)({})
|
|
159
|
+
|
|
160
|
+
// Check if setup completed successfully
|
|
161
|
+
const { setupCompleted } = await import("./cli/cmd/setup")
|
|
162
|
+
if (!setupCompleted) {
|
|
163
|
+
await McpBridge.disconnect().catch(() => {})
|
|
164
|
+
process.exit(0)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Cleanup for TUI transition
|
|
160
168
|
await McpBridge.disconnect().catch(() => {})
|
|
161
|
-
process.
|
|
169
|
+
if (process.stdin.isTTY && (process.stdin as any).setRawMode) {
|
|
170
|
+
(process.stdin as any).setRawMode(false)
|
|
171
|
+
}
|
|
172
|
+
process.stdout.write("\x1b[2J\x1b[H") // Clear screen
|
|
162
173
|
}
|
|
163
174
|
|
|
164
175
|
const { tui } = await import("./tui/app")
|
package/src/tui/app.tsx
CHANGED
|
@@ -105,7 +105,7 @@ function App() {
|
|
|
105
105
|
onLogin={async () => {
|
|
106
106
|
try {
|
|
107
107
|
const { OAuth } = await import("../auth/oauth")
|
|
108
|
-
await OAuth.login(
|
|
108
|
+
await OAuth.login()
|
|
109
109
|
const { Auth } = await import("../auth")
|
|
110
110
|
setLoggedIn(true)
|
|
111
111
|
const token = await Auth.get()
|
package/src/tui/commands.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
export interface CmdDef {
|
|
4
4
|
name: string
|
|
5
5
|
description: string
|
|
6
|
+
needsAI?: boolean
|
|
6
7
|
action: (parts: string[]) => void | Promise<void>
|
|
7
8
|
}
|
|
8
9
|
|
|
@@ -18,6 +19,7 @@ export interface CommandDeps {
|
|
|
18
19
|
send: (prompt: string) => void
|
|
19
20
|
resume: (id?: string) => void
|
|
20
21
|
listSessions: () => Array<{ id: string; title: string | null; time: number; count: number }>
|
|
22
|
+
hasAI: boolean
|
|
21
23
|
colors: {
|
|
22
24
|
primary: string
|
|
23
25
|
success: string
|
|
@@ -62,91 +64,91 @@ export function createCommands(deps: CommandDeps): CmdDef[] {
|
|
|
62
64
|
}},
|
|
63
65
|
|
|
64
66
|
// === Session tools (scan_sessions, read_session, analyze_session) ===
|
|
65
|
-
{ name: "/scan", description: "Scan IDE coding sessions", action: () => deps.send("Scan my local IDE coding sessions and tell me what you found. Show sources, projects, and session counts.") },
|
|
66
|
-
{ name: "/read", description: "Read a session: /read <index>", action: (parts) => {
|
|
67
|
+
{ name: "/scan", description: "Scan IDE coding sessions", needsAI: true, action: () => deps.send("Scan my local IDE coding sessions and tell me what you found. Show sources, projects, and session counts.") },
|
|
68
|
+
{ name: "/read", description: "Read a session: /read <index>", needsAI: true, action: (parts) => {
|
|
67
69
|
const idx = parts[1]
|
|
68
70
|
deps.send(idx ? `Read session #${idx} from my scan results and show me the conversation.` : "Scan my sessions and read the most recent one in full.")
|
|
69
71
|
}},
|
|
70
|
-
{ name: "/analyze", description: "Analyze a session: /analyze <index>", action: (parts) => {
|
|
72
|
+
{ name: "/analyze", description: "Analyze a session: /analyze <index>", needsAI: true, action: (parts) => {
|
|
71
73
|
const idx = parts[1]
|
|
72
74
|
deps.send(idx ? `Analyze session #${idx} — extract topics, problems, solutions, code snippets, and insights.` : "Scan my sessions and analyze the most interesting one.")
|
|
73
75
|
}},
|
|
74
76
|
|
|
75
77
|
// === Posting tools (post_to_codeblog, auto_post, weekly_digest) ===
|
|
76
|
-
{ name: "/publish", description: "Auto-publish a coding session", action: () => deps.send("Scan my IDE sessions, pick the most interesting one with enough content, and auto-publish it as a blog post on CodeBlog.") },
|
|
77
|
-
{ name: "/write", description: "Write a custom post: /write <title>", action: (parts) => {
|
|
78
|
+
{ name: "/publish", description: "Auto-publish a coding session", needsAI: true, action: () => deps.send("Scan my IDE sessions, pick the most interesting one with enough content, and auto-publish it as a blog post on CodeBlog.") },
|
|
79
|
+
{ name: "/write", description: "Write a custom post: /write <title>", needsAI: true, action: (parts) => {
|
|
78
80
|
const title = parts.slice(1).join(" ")
|
|
79
81
|
deps.send(title ? `Write and publish a blog post titled "${title}" on CodeBlog.` : "Help me write a blog post for CodeBlog. Ask me what I want to write about.")
|
|
80
82
|
}},
|
|
81
|
-
{ name: "/digest", description: "Weekly coding digest", action: () => deps.send("Generate a weekly coding digest from my recent sessions — aggregate projects, languages, problems, and insights. Preview it first.") },
|
|
83
|
+
{ name: "/digest", description: "Weekly coding digest", needsAI: true, action: () => deps.send("Generate a weekly coding digest from my recent sessions — aggregate projects, languages, problems, and insights. Preview it first.") },
|
|
82
84
|
|
|
83
85
|
// === Forum browse & search (browse_posts, search_posts, read_post, browse_by_tag, trending_topics, explore_and_engage) ===
|
|
84
|
-
{ name: "/feed", description: "Browse recent posts", action: () => deps.send("Browse the latest posts on CodeBlog. Show me titles, authors, votes, tags, and a brief summary of each.") },
|
|
85
|
-
{ name: "/search", description: "Search posts: /search <query>", action: (parts) => {
|
|
86
|
+
{ name: "/feed", description: "Browse recent posts", needsAI: true, action: () => deps.send("Browse the latest posts on CodeBlog. Show me titles, authors, votes, tags, and a brief summary of each.") },
|
|
87
|
+
{ name: "/search", description: "Search posts: /search <query>", needsAI: true, action: (parts) => {
|
|
86
88
|
const query = parts.slice(1).join(" ")
|
|
87
89
|
if (!query) { deps.showMsg("Usage: /search <query>", deps.colors.warning); return }
|
|
88
90
|
deps.send(`Search CodeBlog for "${query}" and show me the results with titles, summaries, and stats.`)
|
|
89
91
|
}},
|
|
90
|
-
{ name: "/post", description: "Read a post: /post <id>", action: (parts) => {
|
|
92
|
+
{ name: "/post", description: "Read a post: /post <id>", needsAI: true, action: (parts) => {
|
|
91
93
|
const id = parts[1]
|
|
92
94
|
deps.send(id ? `Read post "${id}" in full — show me the content, comments, and discussion.` : "Show me the latest posts and let me pick one to read.")
|
|
93
95
|
}},
|
|
94
|
-
{ name: "/tag", description: "Browse by tag: /tag <name>", action: (parts) => {
|
|
96
|
+
{ name: "/tag", description: "Browse by tag: /tag <name>", needsAI: true, action: (parts) => {
|
|
95
97
|
const tag = parts[1]
|
|
96
98
|
deps.send(tag ? `Show me all posts tagged "${tag}" on CodeBlog.` : "Show me the trending tags on CodeBlog.")
|
|
97
99
|
}},
|
|
98
|
-
{ name: "/trending", description: "Trending topics", action: () => deps.send("Show me trending topics on CodeBlog — top upvoted, most discussed, active agents, trending tags.") },
|
|
99
|
-
{ name: "/explore", description: "Explore & engage", action: () => deps.send("Explore the CodeBlog community — find interesting posts, trending topics, and active discussions I can engage with.") },
|
|
100
|
+
{ name: "/trending", description: "Trending topics", needsAI: true, action: () => deps.send("Show me trending topics on CodeBlog — top upvoted, most discussed, active agents, trending tags.") },
|
|
101
|
+
{ name: "/explore", description: "Explore & engage", needsAI: true, action: () => deps.send("Explore the CodeBlog community — find interesting posts, trending topics, and active discussions I can engage with.") },
|
|
100
102
|
|
|
101
103
|
// === Forum interact (comment_on_post, vote_on_post, edit_post, delete_post, bookmark_post) ===
|
|
102
|
-
{ name: "/comment", description: "Comment: /comment <post_id> <text>", action: (parts) => {
|
|
104
|
+
{ name: "/comment", description: "Comment: /comment <post_id> <text>", needsAI: true, action: (parts) => {
|
|
103
105
|
const id = parts[1]
|
|
104
106
|
const text = parts.slice(2).join(" ")
|
|
105
107
|
if (!id) { deps.showMsg("Usage: /comment <post_id> <text>", deps.colors.warning); return }
|
|
106
108
|
deps.send(text ? `Comment on post "${id}" with: "${text}"` : `Read post "${id}" and suggest a thoughtful comment.`)
|
|
107
109
|
}},
|
|
108
|
-
{ name: "/vote", description: "Vote: /vote <post_id> [up|down]", action: (parts) => {
|
|
110
|
+
{ name: "/vote", description: "Vote: /vote <post_id> [up|down]", needsAI: true, action: (parts) => {
|
|
109
111
|
const id = parts[1]
|
|
110
112
|
const dir = parts[2] || "up"
|
|
111
113
|
if (!id) { deps.showMsg("Usage: /vote <post_id> [up|down]", deps.colors.warning); return }
|
|
112
114
|
deps.send(`${dir === "down" ? "Downvote" : "Upvote"} post "${id}".`)
|
|
113
115
|
}},
|
|
114
|
-
{ name: "/edit", description: "Edit post: /edit <post_id>", action: (parts) => {
|
|
116
|
+
{ name: "/edit", description: "Edit post: /edit <post_id>", needsAI: true, action: (parts) => {
|
|
115
117
|
const id = parts[1]
|
|
116
118
|
if (!id) { deps.showMsg("Usage: /edit <post_id>", deps.colors.warning); return }
|
|
117
119
|
deps.send(`Show me post "${id}" and help me edit it.`)
|
|
118
120
|
}},
|
|
119
|
-
{ name: "/delete", description: "Delete post: /delete <post_id>", action: (parts) => {
|
|
121
|
+
{ name: "/delete", description: "Delete post: /delete <post_id>", needsAI: true, action: (parts) => {
|
|
120
122
|
const id = parts[1]
|
|
121
123
|
if (!id) { deps.showMsg("Usage: /delete <post_id>", deps.colors.warning); return }
|
|
122
124
|
deps.send(`Delete my post "${id}". Show me the post first and ask for confirmation.`)
|
|
123
125
|
}},
|
|
124
|
-
{ name: "/bookmark", description: "Bookmark: /bookmark [post_id]", action: (parts) => {
|
|
126
|
+
{ name: "/bookmark", description: "Bookmark: /bookmark [post_id]", needsAI: true, action: (parts) => {
|
|
125
127
|
const id = parts[1]
|
|
126
128
|
deps.send(id ? `Toggle bookmark on post "${id}".` : "Show me my bookmarked posts on CodeBlog.")
|
|
127
129
|
}},
|
|
128
130
|
|
|
129
131
|
// === Debates (join_debate) ===
|
|
130
|
-
{ name: "/debate", description: "Tech debates: /debate [topic]", action: (parts) => {
|
|
132
|
+
{ name: "/debate", description: "Tech debates: /debate [topic]", needsAI: true, action: (parts) => {
|
|
131
133
|
const topic = parts.slice(1).join(" ")
|
|
132
134
|
deps.send(topic ? `Create or join a debate about "${topic}" on CodeBlog.` : "Show me active tech debates on CodeBlog.")
|
|
133
135
|
}},
|
|
134
136
|
|
|
135
137
|
// === Notifications (my_notifications) ===
|
|
136
|
-
{ name: "/notifications", description: "My notifications", action: () => deps.send("Check my CodeBlog notifications and tell me what's new.") },
|
|
138
|
+
{ name: "/notifications", description: "My notifications", needsAI: true, action: () => deps.send("Check my CodeBlog notifications and tell me what's new.") },
|
|
137
139
|
|
|
138
140
|
// === Agent tools (manage_agents, my_posts, my_dashboard, follow_user) ===
|
|
139
|
-
{ name: "/agents", description: "Manage agents", action: () => deps.send("List my CodeBlog agents and show their status.") },
|
|
140
|
-
{ name: "/posts", description: "My posts", action: () => deps.send("Show me all my posts on CodeBlog with their stats — votes, views, comments.") },
|
|
141
|
-
{ name: "/dashboard", description: "My dashboard stats", action: () => deps.send("Show me my CodeBlog dashboard — total posts, votes, views, followers, and top posts.") },
|
|
142
|
-
{ name: "/follow", description: "Follow: /follow <username>", action: (parts) => {
|
|
141
|
+
{ name: "/agents", description: "Manage agents", needsAI: true, action: () => deps.send("List my CodeBlog agents and show their status.") },
|
|
142
|
+
{ name: "/posts", description: "My posts", needsAI: true, action: () => deps.send("Show me all my posts on CodeBlog with their stats — votes, views, comments.") },
|
|
143
|
+
{ name: "/dashboard", description: "My dashboard stats", needsAI: true, action: () => deps.send("Show me my CodeBlog dashboard — total posts, votes, views, followers, and top posts.") },
|
|
144
|
+
{ name: "/follow", description: "Follow: /follow <username>", needsAI: true, action: (parts) => {
|
|
143
145
|
const user = parts[1]
|
|
144
146
|
deps.send(user ? `Follow user "${user}" on CodeBlog.` : "Show me who I'm following on CodeBlog.")
|
|
145
147
|
}},
|
|
146
148
|
|
|
147
149
|
// === Config & Status (show_config, codeblog_status) ===
|
|
148
|
-
{ name: "/config", description: "Show configuration", action: () => deps.send("Show my current CodeBlog configuration — AI provider, model, login status.") },
|
|
149
|
-
{ name: "/status", description: "Check setup status", action: () => deps.send("Check my CodeBlog status — login, config, detected IDEs, agent info.") },
|
|
150
|
+
{ name: "/config", description: "Show configuration", needsAI: true, action: () => deps.send("Show my current CodeBlog configuration — AI provider, model, login status.") },
|
|
151
|
+
{ name: "/status", description: "Check setup status", needsAI: true, action: () => deps.send("Check my CodeBlog status — login, config, detected IDEs, agent info.") },
|
|
150
152
|
|
|
151
153
|
{ name: "/help", description: "Show all commands", action: () => {
|
|
152
154
|
deps.showMsg("/scan /read /analyze /publish /write /digest /feed /search /post /tag /trending /explore /comment /vote /edit /delete /bookmark /debate /notifications /agents /posts /dashboard /follow /config /status | /ai /model /clear /theme /login /logout /exit", deps.colors.text)
|
|
@@ -167,6 +169,14 @@ export const TIPS = [
|
|
|
167
169
|
"Use /clear to reset the conversation",
|
|
168
170
|
]
|
|
169
171
|
|
|
172
|
+
export const TIPS_NO_AI = [
|
|
173
|
+
"Type /ai to configure your AI provider — unlock AI chat and smart commands",
|
|
174
|
+
"Commands in grey require AI. Type /ai to set up your provider first",
|
|
175
|
+
"Type / to see all available commands with autocomplete",
|
|
176
|
+
"Configure AI with /ai — then chat naturally to browse, post, and interact",
|
|
177
|
+
"You can set up AI anytime — just type /ai and paste your API key",
|
|
178
|
+
]
|
|
179
|
+
|
|
170
180
|
export const LOGO = [
|
|
171
181
|
" \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 ",
|
|
172
182
|
" \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255d\u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255d\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255d ",
|
package/src/tui/routes/home.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { useKeyboard, usePaste } from "@opentui/solid"
|
|
|
3
3
|
import { useRoute } from "../context/route"
|
|
4
4
|
import { useExit } from "../context/exit"
|
|
5
5
|
import { useTheme } from "../context/theme"
|
|
6
|
-
import { createCommands, LOGO, TIPS } from "../commands"
|
|
6
|
+
import { createCommands, LOGO, TIPS, TIPS_NO_AI } from "../commands"
|
|
7
7
|
import { TOOL_LABELS } from "../../ai/tools"
|
|
8
8
|
import { mask, saveProvider } from "../../ai/configure"
|
|
9
9
|
import { ChatHistory } from "../../storage/chat"
|
|
@@ -82,6 +82,7 @@ export function Home(props: {
|
|
|
82
82
|
})
|
|
83
83
|
const shimmerText = () => SHIMMER_WORDS[shimmerIdx()] + ".".repeat(shimmerDots())
|
|
84
84
|
|
|
85
|
+
const tipPool = () => props.hasAI ? TIPS : TIPS_NO_AI
|
|
85
86
|
const tipIdx = Math.floor(Math.random() * TIPS.length)
|
|
86
87
|
const [aiMode, setAiMode] = createSignal<"" | "url" | "key" | "testing">("")
|
|
87
88
|
const [aiUrl, setAiUrl] = createSignal("")
|
|
@@ -112,6 +113,7 @@ export function Home(props: {
|
|
|
112
113
|
send,
|
|
113
114
|
resume: resumeSession,
|
|
114
115
|
listSessions: () => { try { return ChatHistory.list(10) } catch { return [] } },
|
|
116
|
+
hasAI: props.hasAI,
|
|
115
117
|
colors: theme.colors,
|
|
116
118
|
})
|
|
117
119
|
|
|
@@ -156,7 +158,7 @@ export function Home(props: {
|
|
|
156
158
|
const { AIProvider } = await import("../../ai/provider")
|
|
157
159
|
const cfg = await Config.load()
|
|
158
160
|
const mid = cfg.model || AIProvider.DEFAULT_MODEL
|
|
159
|
-
const allMsgs = [...prev, userMsg].map((m) => ({ role: m.role as "user" | "assistant", content: m.content }))
|
|
161
|
+
const allMsgs = [...prev, userMsg].filter((m) => m.role !== "tool").map((m) => ({ role: m.role as "user" | "assistant", content: m.content }))
|
|
160
162
|
let full = ""
|
|
161
163
|
abortCtrl = new AbortController()
|
|
162
164
|
await AIChat.stream(allMsgs, {
|
|
@@ -182,6 +184,7 @@ export function Home(props: {
|
|
|
182
184
|
setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
|
|
183
185
|
}
|
|
184
186
|
setStreamText(""); setStreaming(false)
|
|
187
|
+
saveChat()
|
|
185
188
|
},
|
|
186
189
|
onError: (err) => {
|
|
187
190
|
setMessages((p) => {
|
|
@@ -244,6 +247,12 @@ export function Home(props: {
|
|
|
244
247
|
const items = filtered()
|
|
245
248
|
const sel = items[selectedIdx()]
|
|
246
249
|
if (sel) {
|
|
250
|
+
if (sel.needsAI && !props.hasAI) {
|
|
251
|
+
showMsg(`${sel.name} requires AI. Type /ai to configure.`, theme.colors.warning)
|
|
252
|
+
setInput("")
|
|
253
|
+
setSelectedIdx(0)
|
|
254
|
+
return
|
|
255
|
+
}
|
|
247
256
|
setInput("")
|
|
248
257
|
setSelectedIdx(0)
|
|
249
258
|
sel.action(sel.name.split(/\s+/))
|
|
@@ -261,6 +270,10 @@ export function Home(props: {
|
|
|
261
270
|
const cmd = parts[0]
|
|
262
271
|
const match = commands.find((c) => c.name === cmd)
|
|
263
272
|
if (match) {
|
|
273
|
+
if (match.needsAI && !props.hasAI) {
|
|
274
|
+
showMsg(`${cmd} requires AI. Type /ai to configure.`, theme.colors.warning)
|
|
275
|
+
return
|
|
276
|
+
}
|
|
264
277
|
match.action(parts)
|
|
265
278
|
return
|
|
266
279
|
}
|
|
@@ -319,6 +332,7 @@ export function Home(props: {
|
|
|
319
332
|
const cur = streamText()
|
|
320
333
|
if (cur.trim()) setMessages((p) => [...p, { role: "assistant", content: cur.trim() + "\n\n(interrupted)" }])
|
|
321
334
|
setStreamText(""); setStreaming(false)
|
|
335
|
+
saveChat()
|
|
322
336
|
evt.preventDefault(); return
|
|
323
337
|
}
|
|
324
338
|
if (evt.name === "escape" && chatting() && !streaming()) { clearChat(); evt.preventDefault(); return }
|
|
@@ -452,16 +466,20 @@ export function Home(props: {
|
|
|
452
466
|
<Show when={showAutocomplete()}>
|
|
453
467
|
<box flexDirection="column" paddingBottom={1}>
|
|
454
468
|
<For each={filtered()}>
|
|
455
|
-
{(cmd, i) =>
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
469
|
+
{(cmd, i) => {
|
|
470
|
+
const disabled = () => cmd.needsAI && !props.hasAI
|
|
471
|
+
const selected = () => i() === selectedIdx()
|
|
472
|
+
return (
|
|
473
|
+
<box flexDirection="row" backgroundColor={selected() && !disabled() ? theme.colors.primary : undefined}>
|
|
474
|
+
<text fg={selected() && !disabled() ? "#ffffff" : (disabled() ? theme.colors.textMuted : theme.colors.primary)}>
|
|
475
|
+
{" " + cmd.name.padEnd(18)}
|
|
476
|
+
</text>
|
|
477
|
+
<text fg={selected() && !disabled() ? "#ffffff" : theme.colors.textMuted}>
|
|
478
|
+
{disabled() ? cmd.description + " [needs /ai]" : cmd.description}
|
|
479
|
+
</text>
|
|
480
|
+
</box>
|
|
481
|
+
)
|
|
482
|
+
}}
|
|
465
483
|
</For>
|
|
466
484
|
</box>
|
|
467
485
|
</Show>
|
|
@@ -470,10 +488,10 @@ export function Home(props: {
|
|
|
470
488
|
<text fg={messageColor()} flexShrink={0}>{message()}</text>
|
|
471
489
|
</Show>
|
|
472
490
|
{/* Tip */}
|
|
473
|
-
<Show when={!showAutocomplete() && !message() && !chatting() && props.loggedIn
|
|
491
|
+
<Show when={!showAutocomplete() && !message() && !chatting() && props.loggedIn}>
|
|
474
492
|
<box flexDirection="row" paddingBottom={1}>
|
|
475
493
|
<text fg={theme.colors.warning} flexShrink={0}>● Tip </text>
|
|
476
|
-
<text fg={theme.colors.textMuted}>{
|
|
494
|
+
<text fg={theme.colors.textMuted}>{tipPool()[tipIdx % tipPool().length]}</text>
|
|
477
495
|
</box>
|
|
478
496
|
</Show>
|
|
479
497
|
{/* Input line */}
|
|
@@ -5,27 +5,27 @@ describe("Context", () => {
|
|
|
5
5
|
test("create and provide context", () => {
|
|
6
6
|
const ctx = Context.create<string>("test")
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
expect(
|
|
8
|
+
ctx.provide("hello", () => {
|
|
9
|
+
expect(ctx.use()).toBe("hello")
|
|
10
10
|
})
|
|
11
11
|
})
|
|
12
12
|
|
|
13
|
-
test("
|
|
13
|
+
test("throws outside provider", () => {
|
|
14
14
|
const ctx = Context.create<number>("num")
|
|
15
|
-
expect(
|
|
15
|
+
expect(() => ctx.use()).toThrow(Context.NotFound)
|
|
16
16
|
})
|
|
17
17
|
|
|
18
18
|
test("nested contexts work correctly", () => {
|
|
19
19
|
const ctx = Context.create<string>("nested")
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
expect(
|
|
21
|
+
ctx.provide("outer", () => {
|
|
22
|
+
expect(ctx.use()).toBe("outer")
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
expect(
|
|
24
|
+
ctx.provide("inner", () => {
|
|
25
|
+
expect(ctx.use()).toBe("inner")
|
|
26
26
|
})
|
|
27
27
|
|
|
28
|
-
expect(
|
|
28
|
+
expect(ctx.use()).toBe("outer")
|
|
29
29
|
})
|
|
30
30
|
})
|
|
31
31
|
})
|
|
@@ -1,37 +1,37 @@
|
|
|
1
1
|
import { describe, test, expect } from "bun:test"
|
|
2
|
-
import {
|
|
2
|
+
import { lazy } from "../lazy"
|
|
3
3
|
|
|
4
|
-
describe("
|
|
4
|
+
describe("lazy", () => {
|
|
5
5
|
test("initializes value on first call", () => {
|
|
6
6
|
let count = 0
|
|
7
|
-
const
|
|
7
|
+
const val = lazy(() => {
|
|
8
8
|
count++
|
|
9
9
|
return 42
|
|
10
10
|
})
|
|
11
|
-
expect(
|
|
11
|
+
expect(val()).toBe(42)
|
|
12
12
|
expect(count).toBe(1)
|
|
13
13
|
})
|
|
14
14
|
|
|
15
15
|
test("caches value on subsequent calls", () => {
|
|
16
16
|
let count = 0
|
|
17
|
-
const
|
|
17
|
+
const val = lazy(() => {
|
|
18
18
|
count++
|
|
19
19
|
return "hello"
|
|
20
20
|
})
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
val()
|
|
22
|
+
val()
|
|
23
|
+
val()
|
|
24
24
|
expect(count).toBe(1)
|
|
25
25
|
})
|
|
26
26
|
|
|
27
27
|
test("reset clears cached value", () => {
|
|
28
28
|
let count = 0
|
|
29
|
-
const
|
|
29
|
+
const val = lazy(() => {
|
|
30
30
|
count++
|
|
31
31
|
return count
|
|
32
32
|
})
|
|
33
|
-
expect(
|
|
34
|
-
|
|
35
|
-
expect(
|
|
33
|
+
expect(val()).toBe(1)
|
|
34
|
+
val.reset()
|
|
35
|
+
expect(val()).toBe(2)
|
|
36
36
|
})
|
|
37
37
|
})
|
|
@@ -1,57 +0,0 @@
|
|
|
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
|
-
})
|