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 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.1",
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.0.0",
60
- "codeblog-app-darwin-x64": "2.0.0",
61
- "codeblog-app-linux-arm64": "2.0.0",
62
- "codeblog-app-linux-x64": "2.0.0",
63
- "codeblog-app-windows-x64": "2.0.0"
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.hasKey).toBe(true)
182
- expect(providers.openai.models.length).toBeGreaterThan(0)
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 24 tools", () => {
20
+ test("exports all 25 tools", () => {
21
21
  const toolNames = Object.keys(chatTools)
22
- expect(toolNames).toHaveLength(24)
22
+ expect(toolNames).toHaveLength(25)
23
23
  })
24
24
 
25
25
  test("each expected tool is present in chatTools", () => {
@@ -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
- await Config.save({ providers })
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
- await Config.save({ providers })
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() overloads don't match our MCP proxy pattern.
6
- // This wrapper casts to `any` to avoid TS2769 while keeping runtime behavior intact.
7
- const tool: any = _rawTool
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 delete agents.",
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 'delete'"),
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(provider: "github" | "google" = "github") {
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
@@ -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(args.provider as "github" | "google")
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)}`)
@@ -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
- function extractApiKey(text: string): string | null {
8
- const match = text.match(/^API-KEY:\s*(\S+)/m)
9
- return match ? match[1]! : null
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
- function extractUsername(text: string): string | null {
13
- // Try "Account: username (email)" first (registration path)
14
- const acct = text.match(/^Account:\s*(\S+)/m)
15
- if (acct) return acct[1]!
16
- // Try "Owner: username" (api_key verification path)
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
- export { extractApiKey, extractUsername }
31
+ // ─── Scan & Publish ──────────────────────────────────────────────────────────
23
32
 
24
- async function authQuickSignup(): Promise<boolean> {
25
- const email = await UI.input(" Email: ")
26
- if (!email) { UI.error("Email is required."); return false }
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
- const username = await UI.input(" Username: ")
29
- if (!username) { UI.error("Username is required."); return false }
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
- const password = await UI.password(" Password: ")
32
- if (!password || password.length < 6) { UI.error("Password must be at least 6 characters."); return false }
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
- const lang = await UI.input(" Language (e.g. English/中文) [English]: ")
35
- const default_language = lang || "English"
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
- const result = await McpBridge.callTool("codeblog_setup", {
42
- email, username, password, default_language,
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
- const apiKey = extractApiKey(result)
46
- const user = extractUsername(result)
88
+ // Display preview
89
+ const cleaned = UI.cleanMarkdown(preview)
90
+ UI.divider()
47
91
 
48
- if (apiKey) {
49
- await Auth.set({ type: "apikey", value: apiKey, username: user || username })
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
- UI.warn("Account created but could not extract API key from response.")
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
- return true
57
- } catch (err) {
58
- UI.error(`Signup failed: ${err instanceof Error ? err.message : String(err)}`)
59
- return false
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 OAuth.login(chosen)
68
- return true
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(`Authentication failed: ${err instanceof Error ? err.message : String(err)}`)
71
- return false
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
- async function authApiKey(): Promise<boolean> {
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
- UI.info("Verifying API key...")
83
- try {
84
- const result = await McpBridge.callTool("codeblog_setup", { api_key: key })
85
- const username = extractUsername(result)
86
- await Auth.set({ type: "apikey", value: key, username: username || undefined })
87
- return true
88
- } catch (err) {
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
- async function postAuthFlow(): Promise<void> {
95
- console.log("")
157
+ UI.divider()
96
158
 
97
- // Scan
98
- UI.info("Scanning your IDE sessions...")
99
- try {
100
- const text = await McpBridge.callTool("scan_sessions", { limit: 10 })
101
- let sessions: Array<{ id: string; source: string; project: string; title: string }>
102
- try {
103
- sessions = JSON.parse(text)
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
- if (sessions.length === 0) {
110
- UI.warn("No IDE sessions found. You can scan later with: codeblog scan")
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
- console.log(` Found ${UI.Style.TEXT_HIGHLIGHT}${sessions.length}${UI.Style.TEXT_NORMAL} sessions`)
170
+ if (choice === "escape") {
115
171
  console.log("")
116
- for (const s of sessions.slice(0, 5)) {
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
- // Publish
125
- const answer = await UI.input(" Publish your latest session to CodeBlog? (y/n) [y]: ")
126
- if (answer.toLowerCase() === "n") {
127
- UI.info("Skipped. You can publish later with: codeblog publish")
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.info("Publishing...")
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.error(`Scan failed: ${err instanceof Error ? err.message : String(err)}`)
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 scan publish",
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
- console.log(` ${UI.Style.TEXT_NORMAL_BOLD}Welcome to CodeBlog!${UI.Style.TEXT_NORMAL}`)
150
- console.log(` ${UI.Style.TEXT_DIM}The AI-powered coding forum${UI.Style.TEXT_NORMAL}`)
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
- UI.success("Already authenticated!")
226
+ const token = await Auth.get()
227
+ UI.success(`Already authenticated as ${token?.username || "user"}!`)
158
228
  } else {
159
- const choice = await UI.select(" How would you like to get started?", [
160
- "Quick signup (email + password)",
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
- switch (choice) {
168
- case 0:
169
- authenticated = await authQuickSignup()
170
- break
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
- await postAuthFlow()
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
- UI.success("Setup complete!")
193
- console.log("")
194
- console.log(` ${UI.Style.TEXT_DIM}Useful commands:${UI.Style.TEXT_NORMAL}`)
195
- console.log(` codeblog feed ${UI.Style.TEXT_DIM}— Browse the forum${UI.Style.TEXT_NORMAL}`)
196
- console.log(` codeblog scan ${UI.Style.TEXT_DIM}— Scan IDE sessions${UI.Style.TEXT_NORMAL}`)
197
- console.log(` codeblog publish ${UI.Style.TEXT_DIM}— Publish sessions${UI.Style.TEXT_NORMAL}`)
198
- console.log(` codeblog chat ${UI.Style.TEXT_DIM} AI chat${UI.Style.TEXT_NORMAL}`)
199
- console.log("")
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.exit(0)
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("github")
108
+ await OAuth.login()
109
109
  const { Auth } = await import("../auth")
110
110
  setLoggedIn(true)
111
111
  const token = await Auth.get()
@@ -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 ",
@@ -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
- <box flexDirection="row" backgroundColor={i() === selectedIdx() ? theme.colors.primary : undefined}>
457
- <text fg={i() === selectedIdx() ? "#ffffff" : theme.colors.primary}>
458
- {" " + cmd.name.padEnd(18)}
459
- </text>
460
- <text fg={i() === selectedIdx() ? "#ffffff" : theme.colors.textMuted}>
461
- {cmd.description}
462
- </text>
463
- </box>
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 && props.hasAI}>
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}>{TIPS[tipIdx]}</text>
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
- Context.provide(ctx, "hello", () => {
9
- expect(Context.use(ctx)).toBe("hello")
8
+ ctx.provide("hello", () => {
9
+ expect(ctx.use()).toBe("hello")
10
10
  })
11
11
  })
12
12
 
13
- test("use returns undefined outside provider", () => {
13
+ test("throws outside provider", () => {
14
14
  const ctx = Context.create<number>("num")
15
- expect(Context.use(ctx)).toBeUndefined()
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
- Context.provide(ctx, "outer", () => {
22
- expect(Context.use(ctx)).toBe("outer")
21
+ ctx.provide("outer", () => {
22
+ expect(ctx.use()).toBe("outer")
23
23
 
24
- Context.provide(ctx, "inner", () => {
25
- expect(Context.use(ctx)).toBe("inner")
24
+ ctx.provide("inner", () => {
25
+ expect(ctx.use()).toBe("inner")
26
26
  })
27
27
 
28
- expect(Context.use(ctx)).toBe("outer")
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 { Lazy } from "../lazy"
2
+ import { lazy } from "../lazy"
3
3
 
4
- describe("Lazy", () => {
4
+ describe("lazy", () => {
5
5
  test("initializes value on first call", () => {
6
6
  let count = 0
7
- const lazy = Lazy.create(() => {
7
+ const val = lazy(() => {
8
8
  count++
9
9
  return 42
10
10
  })
11
- expect(lazy()).toBe(42)
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 lazy = Lazy.create(() => {
17
+ const val = lazy(() => {
18
18
  count++
19
19
  return "hello"
20
20
  })
21
- lazy()
22
- lazy()
23
- lazy()
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 lazy = Lazy.create(() => {
29
+ const val = lazy(() => {
30
30
  count++
31
31
  return count
32
32
  })
33
- expect(lazy()).toBe(1)
34
- lazy.reset()
35
- expect(lazy()).toBe(2)
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
- })