codeblog-app 2.3.3 → 2.3.4

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.3.3",
4
+ "version": "2.3.4",
5
5
  "description": "CLI client for CodeBlog — the forum where AI writes the posts",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -58,11 +58,11 @@
58
58
  "typescript": "5.8.2"
59
59
  },
60
60
  "optionalDependencies": {
61
- "codeblog-app-darwin-arm64": "2.3.3",
62
- "codeblog-app-darwin-x64": "2.3.3",
63
- "codeblog-app-linux-arm64": "2.3.3",
64
- "codeblog-app-linux-x64": "2.3.3",
65
- "codeblog-app-windows-x64": "2.3.3"
61
+ "codeblog-app-darwin-arm64": "2.3.4",
62
+ "codeblog-app-darwin-x64": "2.3.4",
63
+ "codeblog-app-linux-arm64": "2.3.4",
64
+ "codeblog-app-linux-x64": "2.3.4",
65
+ "codeblog-app-windows-x64": "2.3.4"
66
66
  },
67
67
  "dependencies": {
68
68
  "@ai-sdk/anthropic": "^3.0.44",
@@ -73,7 +73,7 @@
73
73
  "@opentui/core": "^0.1.79",
74
74
  "@opentui/solid": "^0.1.79",
75
75
  "ai": "^6.0.86",
76
- "codeblog-mcp": "2.2.1",
76
+ "codeblog-mcp": "2.2.2",
77
77
  "drizzle-orm": "1.0.0-beta.12-a5629fb",
78
78
  "fuzzysort": "^3.1.0",
79
79
  "hono": "4.10.7",
package/src/auth/oauth.ts CHANGED
@@ -6,6 +6,9 @@ import { Log } from "../util/log"
6
6
 
7
7
  const log = Log.create({ service: "oauth" })
8
8
 
9
+ /** Set after a successful login — indicates whether the user already has agents. */
10
+ export let lastAuthHasAgents: boolean | undefined = undefined
11
+
9
12
  export namespace OAuth {
10
13
  export async function login(options?: { onUrl?: (url: string) => void }) {
11
14
  const open = (await import("open")).default
@@ -15,6 +18,8 @@ export namespace OAuth {
15
18
  const token = params.get("token")
16
19
  const key = params.get("api_key")
17
20
  const username = params.get("username") || undefined
21
+ const hasAgentsParam = params.get("has_agents")
22
+ lastAuthHasAgents = hasAgentsParam === "true" ? true : hasAgentsParam === "false" ? false : undefined
18
23
 
19
24
  if (key) {
20
25
  let ownerMismatch = ""
@@ -1,6 +1,6 @@
1
1
  import type { CommandModule } from "yargs"
2
2
  import { Auth } from "../../auth"
3
- import { OAuth } from "../../auth/oauth"
3
+ import { OAuth, lastAuthHasAgents } from "../../auth/oauth"
4
4
  import { McpBridge } from "../../mcp/client"
5
5
  import { UI } from "../ui"
6
6
  import { Config } from "../../config"
@@ -465,7 +465,7 @@ export async function runAISetupWizard(source: "setup" | "command" = "command"):
465
465
  }
466
466
 
467
467
  console.log("")
468
- const modeIdx = await UI.select(" Onboarding mode", ["QuickStart (recommended)", "Manual", "Skip for now"])
468
+ const modeIdx = await UI.select(" Onboarding mode", ["QuickStart (recommended)", "Manual", "Skip for now"], { searchable: false })
469
469
  if (modeIdx < 0 || modeIdx === 2) {
470
470
  UI.info("Skipped AI setup.")
471
471
  return
@@ -563,6 +563,212 @@ export async function runAISetupWizard(source: "setup" | "command" = "command"):
563
563
  console.log(` ${UI.Style.TEXT_DIM}You can rerun this wizard with: codeblog ai setup${UI.Style.TEXT_NORMAL}`)
564
564
  }
565
565
 
566
+ // ─── Agent Creation ─────────────────────────────────────────────────────────
567
+
568
+ const SOURCE_OPTIONS = ["Claude Code", "Cursor", "Windsurf", "Codex CLI", "Multiple / Other"]
569
+ const SOURCE_VALUES = ["claude-code", "cursor", "windsurf", "codex", "other"]
570
+
571
+ async function generateAgentNameAndEmoji(): Promise<{ name: string; emoji: string } | null> {
572
+ try {
573
+ const { AIProvider } = await import("../../ai/provider")
574
+ const { generateText } = await import("ai")
575
+ const model = await AIProvider.getModel()
576
+ const result = await generateText({
577
+ model,
578
+ prompt:
579
+ "You are naming an AI coding agent for a developer forum called CodeBlog. " +
580
+ "Generate a creative, short (1-3 words) agent name and pick a single emoji that fits as its avatar. " +
581
+ "The emoji should be fun, expressive, and have personality. " +
582
+ "Do NOT use 🤖 or plain colored circles (🟠🟣🟢🔵⚫) — those are reserved by the system. " +
583
+ "Respond ONLY with JSON: {\"name\": \"...\", \"emoji\": \"...\"}",
584
+ maxOutputTokens: 100,
585
+ })
586
+ const parsed = JSON.parse(result.text.trim())
587
+ if (parsed.name && parsed.emoji) {
588
+ return { name: String(parsed.name).slice(0, 50), emoji: String(parsed.emoji) }
589
+ }
590
+ } catch {}
591
+ return null
592
+ }
593
+
594
+ async function generateEmojiForName(agentName: string): Promise<string> {
595
+ try {
596
+ const { AIProvider } = await import("../../ai/provider")
597
+ const { generateText } = await import("ai")
598
+ const model = await AIProvider.getModel()
599
+ const result = await generateText({
600
+ model,
601
+ prompt:
602
+ `Pick a single emoji that best represents an AI coding agent named "${agentName}". ` +
603
+ "The emoji should be fun, expressive, and have personality. " +
604
+ "Do NOT use 🤖 or plain colored circles (🟠🟣🟢🔵⚫) — those are reserved by the system. " +
605
+ "Respond with ONLY the emoji, nothing else.",
606
+ maxOutputTokens: 10,
607
+ })
608
+ const emoji = result.text.trim()
609
+ if (emoji && emoji.length <= 16) return emoji
610
+ } catch {}
611
+ return "🦊"
612
+ }
613
+
614
+ async function detectSourceType(): Promise<string | null> {
615
+ try {
616
+ const result = await McpBridge.callTool("scan_sessions", { limit: 1 })
617
+ const sessions = JSON.parse(result)
618
+ if (sessions.length > 0 && sessions[0].source) {
619
+ const map: Record<string, string> = {
620
+ "claude-code": "claude-code",
621
+ cursor: "cursor",
622
+ windsurf: "windsurf",
623
+ codex: "codex",
624
+ }
625
+ return map[sessions[0].source] || null
626
+ }
627
+ } catch {}
628
+ return null
629
+ }
630
+
631
+ async function createAgentViaAPI(opts: {
632
+ name: string
633
+ avatar: string
634
+ sourceType: string
635
+ }): Promise<{ api_key: string; name: string; id: string } | null> {
636
+ const base = await Config.url()
637
+ const auth = await Auth.get()
638
+ if (!auth) return null
639
+
640
+ // This endpoint requires a JWT token, not an API key.
641
+ // During setup the user may only have a JWT (no agent/api_key yet).
642
+ const token = auth.type === "jwt" ? auth.value : null
643
+ if (!token) {
644
+ throw new Error("JWT token required to create an agent. Please re-login with: codeblog login")
645
+ }
646
+
647
+ const res = await fetch(`${base}/api/auth/create-agent`, {
648
+ method: "POST",
649
+ headers: {
650
+ Authorization: `Bearer ${token}`,
651
+ "Content-Type": "application/json",
652
+ },
653
+ body: JSON.stringify({
654
+ name: opts.name,
655
+ avatar: opts.avatar,
656
+ source_type: opts.sourceType,
657
+ }),
658
+ })
659
+
660
+ if (!res.ok) {
661
+ const err = await res.json().catch(() => ({ error: "Unknown error" })) as { error?: string }
662
+ throw new Error(err.error || `Server returned ${res.status}`)
663
+ }
664
+
665
+ const data = await res.json() as { agent: { api_key: string; name: string; id: string } }
666
+ return {
667
+ api_key: data.agent.api_key,
668
+ name: data.agent.name,
669
+ id: data.agent.id,
670
+ }
671
+ }
672
+
673
+ async function agentCreationWizard(): Promise<void> {
674
+ await UI.typeText("Now let's create your AI Agent!", { charDelay: 10 })
675
+ await UI.typeText("Your agent is your coding persona on CodeBlog — it represents you and your coding style.", { charDelay: 10 })
676
+ await UI.typeText("Give it a name, an emoji avatar, and it'll be ready to publish insights from your coding sessions.", { charDelay: 10 })
677
+ console.log("")
678
+
679
+ const { AIProvider } = await import("../../ai/provider")
680
+ const hasAI = await AIProvider.hasAnyKey()
681
+
682
+ let name = ""
683
+ let emoji = ""
684
+
685
+ // ── Name ──
686
+ if (hasAI) {
687
+ await UI.typeText("Want AI to come up with a creative name for your agent?", { charDelay: 10 })
688
+ const choice = await UI.waitEnter("Press Enter to generate, or Esc to type your own")
689
+
690
+ if (choice === "enter") {
691
+ await shimmerLine("Generating a creative name...", 1500)
692
+ const suggestion = await generateAgentNameAndEmoji()
693
+ if (suggestion) {
694
+ console.log("")
695
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}${suggestion.emoji} ${suggestion.name}${UI.Style.TEXT_NORMAL}`)
696
+ console.log("")
697
+ const accept = await UI.waitEnter("Like it? Press Enter to keep, or Esc to type your own")
698
+ if (accept === "enter") {
699
+ name = suggestion.name
700
+ emoji = suggestion.emoji
701
+ }
702
+ } else {
703
+ UI.warn("AI generation failed — let's type a name instead.")
704
+ }
705
+ }
706
+ }
707
+
708
+ if (!name) {
709
+ const entered = await UI.inputWithEscape(
710
+ ` ${UI.Style.TEXT_NORMAL_BOLD}Agent name${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}(e.g. "CodeNinja", "ByteWizard"):${UI.Style.TEXT_NORMAL} `,
711
+ )
712
+ if (entered === null || !entered.trim()) {
713
+ UI.info("No worries! You can create an agent later on the website or with: codeblog agent create")
714
+ return
715
+ }
716
+ name = entered.trim()
717
+ }
718
+
719
+ // ── Emoji avatar ──
720
+ if (!emoji) {
721
+ if (hasAI) {
722
+ await shimmerLine("Picking the perfect emoji avatar...", 800)
723
+ emoji = await generateEmojiForName(name)
724
+ console.log(` ${UI.Style.TEXT_DIM}Avatar:${UI.Style.TEXT_NORMAL} ${emoji}`)
725
+ } else {
726
+ const EMOJI_POOL = ["🦊", "🐙", "🧠", "⚡", "🔥", "🌟", "🎯", "🛠️", "💡", "🚀", "🎨", "🐱", "🦉", "🐺", "🐲", "🦋", "🧙", "🛡️", "🌊", "🦈"]
727
+ const idx = await UI.select(" Pick an emoji avatar for your agent", EMOJI_POOL)
728
+ emoji = idx >= 0 ? EMOJI_POOL[idx]! : "🦊"
729
+ }
730
+ }
731
+
732
+ // ── Source type ──
733
+ await shimmerLine("Detecting IDE...", 600)
734
+ let sourceType = await detectSourceType()
735
+ if (sourceType) {
736
+ const label = SOURCE_OPTIONS[SOURCE_VALUES.indexOf(sourceType)] || sourceType
737
+ console.log(` ${UI.Style.TEXT_DIM}Detected IDE:${UI.Style.TEXT_NORMAL} ${label}`)
738
+ } else {
739
+ const idx = await UI.select(" Which IDE do you primarily use?", SOURCE_OPTIONS)
740
+ sourceType = idx >= 0 ? SOURCE_VALUES[idx]! : "other"
741
+ }
742
+
743
+ // ── Preview & create ──
744
+ console.log("")
745
+ console.log(` ${UI.Style.TEXT_DIM}Your agent:${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_NORMAL_BOLD}${emoji} ${name}${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}(${SOURCE_OPTIONS[SOURCE_VALUES.indexOf(sourceType)] || sourceType})${UI.Style.TEXT_NORMAL}`)
746
+ console.log("")
747
+
748
+ await shimmerLine("Creating your agent...", 1200)
749
+
750
+ try {
751
+ const result = await createAgentViaAPI({ name, avatar: emoji, sourceType })
752
+ if (!result) throw new Error("No result returned")
753
+
754
+ // Save the new API key as primary auth
755
+ const auth = await Auth.get()
756
+ await Auth.set({ type: "apikey", value: result.api_key, username: auth?.username })
757
+ await Config.saveActiveAgent(result.name, auth?.username)
758
+
759
+ // Sync to MCP config
760
+ try {
761
+ await McpBridge.callTool("codeblog_setup", { api_key: result.api_key })
762
+ } catch {}
763
+
764
+ console.log("")
765
+ UI.success(`Your agent "${emoji} ${name}" is ready! It'll represent you on CodeBlog.`)
766
+ } catch (err) {
767
+ UI.error(`Failed to create agent: ${err instanceof Error ? err.message : String(err)}`)
768
+ await UI.typeText("No worries — you can create an agent later on the website or with: codeblog agent create")
769
+ }
770
+ }
771
+
566
772
  // ─── Setup Command ───────────────────────────────────────────────────────────
567
773
 
568
774
  export const SetupCommand: CommandModule = {
@@ -608,6 +814,13 @@ export const SetupCommand: CommandModule = {
608
814
  console.log("")
609
815
  await runAISetupWizard("setup")
610
816
 
817
+ // Phase 3.5: Agent creation (if the user has no agents yet)
818
+ const needsAgent = lastAuthHasAgents === false || (lastAuthHasAgents === undefined && !(await Auth.get())?.type?.startsWith("apikey"))
819
+ if (needsAgent) {
820
+ UI.divider()
821
+ await agentCreationWizard()
822
+ }
823
+
611
824
  // Phase 4: Interactive scan & publish
612
825
  UI.divider()
613
826
 
package/src/cli/ui.ts CHANGED
@@ -200,7 +200,7 @@ export namespace UI {
200
200
  })
201
201
  }
202
202
 
203
- export async function select(prompt: string, options: string[]): Promise<number> {
203
+ export async function select(prompt: string, options: string[], opts?: { searchable?: boolean }): Promise<number> {
204
204
  if (options.length === 0) return 0
205
205
 
206
206
  const stdin = process.stdin
@@ -209,8 +209,11 @@ export namespace UI {
209
209
  process.stderr.write("\x1b[?25l")
210
210
 
211
211
  let idx = 0
212
+ let filter = ""
213
+ let filtered = options.map((label, originalIndex) => ({ label, originalIndex }))
212
214
  let drawnRows = 0
213
215
  const maxRows = 12
216
+ const searchable = opts?.searchable !== false
214
217
  let onData: ((ch: Buffer) => void) = () => {}
215
218
 
216
219
  const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b./g, "")
@@ -220,24 +223,42 @@ export namespace UI {
220
223
  return Math.max(1, Math.ceil((len || 1) / cols))
221
224
  }
222
225
 
226
+ const applyFilter = () => {
227
+ const q = filter.toLowerCase()
228
+ if (!q) {
229
+ filtered = options.map((label, originalIndex) => ({ label, originalIndex }))
230
+ } else {
231
+ filtered = options
232
+ .map((label, originalIndex) => ({ label, originalIndex }))
233
+ .filter(({ label }) => stripAnsi(label).toLowerCase().includes(q))
234
+ }
235
+ idx = 0
236
+ }
237
+
223
238
  const draw = () => {
224
239
  if (drawnRows > 1) process.stderr.write(`\x1b[${drawnRows - 1}F`)
225
240
  process.stderr.write("\x1b[J")
226
241
 
227
- const start = options.length <= maxRows ? 0 : Math.max(0, Math.min(idx - 4, options.length - maxRows))
228
- const items = options.slice(start, start + maxRows)
242
+ const count = filtered.length
243
+ const start = count <= maxRows ? 0 : Math.max(0, Math.min(idx - 4, count - maxRows))
244
+ const items = filtered.slice(start, start + maxRows)
245
+ const searchLine = filter
246
+ ? ` ${Style.TEXT_HIGHLIGHT}❯${Style.TEXT_NORMAL} ${filter}${Style.TEXT_DIM}█${Style.TEXT_NORMAL}`
247
+ : ` ${Style.TEXT_HIGHLIGHT}❯${Style.TEXT_NORMAL} ${Style.TEXT_DIM}type to search...${Style.TEXT_NORMAL}`
229
248
  const lines = [
230
249
  prompt,
250
+ ...(searchable ? [searchLine] : []),
231
251
  ...items.map((item, i) => {
232
252
  const active = start + i === idx
233
253
  const marker = active ? `${Style.TEXT_HIGHLIGHT}●${Style.TEXT_NORMAL}` : "○"
234
- const text = active ? `${Style.TEXT_NORMAL_BOLD}${item}${Style.TEXT_NORMAL}` : item
254
+ const text = active ? `${Style.TEXT_NORMAL_BOLD}${item.label}${Style.TEXT_NORMAL}` : item.label
235
255
  return ` ${marker} ${text}`
236
256
  }),
237
- options.length > maxRows
238
- ? ` ${Style.TEXT_DIM}${start > 0 ? "↑ more " : ""}${start + maxRows < options.length ? "↓ more" : ""}${Style.TEXT_NORMAL}`
257
+ ...(count === 0 ? [` ${Style.TEXT_DIM}No matches${Style.TEXT_NORMAL}`] : []),
258
+ count > maxRows
259
+ ? ` ${Style.TEXT_DIM}${start > 0 ? "↑ more " : ""}${start + maxRows < count ? "↓ more" : ""}${Style.TEXT_NORMAL}`
239
260
  : ` ${Style.TEXT_DIM}${Style.TEXT_NORMAL}`,
240
- ` ${Style.TEXT_DIM}Use ↑/↓ then Enter (Esc to cancel)${Style.TEXT_NORMAL}`,
261
+ ` ${Style.TEXT_DIM}↑/↓ select · Enter confirm · Esc ${filter ? "clear" : "cancel"}${Style.TEXT_NORMAL}`,
241
262
  ]
242
263
  process.stderr.write(lines.map((line) => `\x1b[2K\r${line}`).join("\n"))
243
264
  drawnRows = lines.reduce((sum, line) => sum + rowCount(line), 0)
@@ -260,25 +281,57 @@ export namespace UI {
260
281
  process.exit(130)
261
282
  }
262
283
  if (c === "\r" || c === "\n") {
263
- restore()
264
- resolve(idx)
284
+ if (filtered.length > 0) {
285
+ restore()
286
+ resolve(filtered[idx]!.originalIndex)
287
+ }
265
288
  return
266
289
  }
267
290
  if (c === "\x1b") {
268
- restore()
269
- resolve(-1)
291
+ if (searchable && filter) {
292
+ filter = ""
293
+ applyFilter()
294
+ draw()
295
+ } else {
296
+ restore()
297
+ resolve(-1)
298
+ }
270
299
  return
271
300
  }
272
- if (c === "k" || c.includes("\x1b[A") || c.includes("\x1bOA")) {
273
- idx = (idx - 1 + options.length) % options.length
301
+ if (c === "\x7f" || c === "\b") {
302
+ if (searchable && filter) {
303
+ filter = filter.slice(0, -1)
304
+ applyFilter()
305
+ draw()
306
+ }
307
+ return
308
+ }
309
+ if (c.includes("\x1b[A") || c.includes("\x1bOA")) {
310
+ if (filtered.length > 0) idx = (idx - 1 + filtered.length) % filtered.length
274
311
  draw()
275
312
  return
276
313
  }
277
- if (c === "j" || c.includes("\x1b[B") || c.includes("\x1bOB")) {
278
- idx = (idx + 1) % options.length
314
+ if (c.includes("\x1b[B") || c.includes("\x1bOB")) {
315
+ if (filtered.length > 0) idx = (idx + 1) % filtered.length
279
316
  draw()
280
317
  return
281
318
  }
319
+ // j/k navigation only when filter is empty (otherwise they are search characters)
320
+ if (!filter && (c === "k" || c === "j")) {
321
+ if (c === "k") idx = (idx - 1 + filtered.length) % filtered.length
322
+ else idx = (idx + 1) % filtered.length
323
+ draw()
324
+ return
325
+ }
326
+ // Printable characters → append to search filter (only when searchable)
327
+ if (searchable) {
328
+ const printable = c.replace(/[\x00-\x1f\x7f]/g, "")
329
+ if (printable) {
330
+ filter += printable
331
+ applyFilter()
332
+ draw()
333
+ }
334
+ }
282
335
  }
283
336
  stdin.on("data", onData)
284
337
  })