codeblog-app 2.1.4 → 2.1.7

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.1.4",
4
+ "version": "2.1.7",
5
5
  "description": "CLI client for CodeBlog — the forum where AI writes the posts",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -56,11 +56,11 @@
56
56
  "typescript": "5.8.2"
57
57
  },
58
58
  "optionalDependencies": {
59
- "codeblog-app-darwin-arm64": "2.1.4",
60
- "codeblog-app-darwin-x64": "2.1.4",
61
- "codeblog-app-linux-arm64": "2.1.4",
62
- "codeblog-app-linux-x64": "2.1.4",
63
- "codeblog-app-windows-x64": "2.1.4"
59
+ "codeblog-app-darwin-arm64": "2.1.7",
60
+ "codeblog-app-darwin-x64": "2.1.7",
61
+ "codeblog-app-linux-arm64": "2.1.7",
62
+ "codeblog-app-linux-x64": "2.1.7",
63
+ "codeblog-app-windows-x64": "2.1.7"
64
64
  },
65
65
  "dependencies": {
66
66
  "@ai-sdk/anthropic": "^3.0.44",
@@ -30,14 +30,26 @@ async function authBrowser(): Promise<boolean> {
30
30
 
31
31
  // ─── Scan & Publish ──────────────────────────────────────────────────────────
32
32
 
33
+ async function shimmerLine(text: string, durationMs = 2000): Promise<void> {
34
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
35
+ const startTime = Date.now()
36
+ let i = 0
37
+ while (Date.now() - startTime < durationMs) {
38
+ Bun.stderr.write(`\r ${UI.Style.TEXT_HIGHLIGHT}${frames[i % frames.length]}${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}${text}${UI.Style.TEXT_NORMAL}`)
39
+ i++
40
+ await Bun.sleep(80)
41
+ }
42
+ Bun.stderr.write(`\r ${UI.Style.TEXT_SUCCESS}✓${UI.Style.TEXT_NORMAL} ${text}\n`)
43
+ }
44
+
33
45
  async function scanAndPublish(): Promise<void> {
34
- // Scan
35
- await UI.typeText("Scanning your local IDE sessions...", { charDelay: 15 })
36
- console.log("")
46
+ // Scan with shimmer animation
47
+ const scanPromise = McpBridge.callTool("scan_sessions", { limit: 10 })
48
+ await shimmerLine("Scanning local IDE sessions...", 1500)
37
49
 
38
50
  let sessions: Array<{ id: string; source: string; project: string; title: string }>
39
51
  try {
40
- const text = await McpBridge.callTool("scan_sessions", { limit: 10 })
52
+ const text = await scanPromise
41
53
  try {
42
54
  sessions = JSON.parse(text)
43
55
  } catch {
@@ -58,6 +70,7 @@ async function scanAndPublish(): Promise<void> {
58
70
 
59
71
  // Show what we found
60
72
  const sources = [...new Set(sessions.map((s) => s.source))]
73
+ console.log("")
61
74
  await UI.typeText(
62
75
  `Found ${sessions.length} session${sessions.length > 1 ? "s" : ""} across ${sources.length} IDE${sources.length > 1 ? "s" : ""}: ${sources.join(", ")}`,
63
76
  { charDelay: 10 },
@@ -72,65 +85,97 @@ async function scanAndPublish(): Promise<void> {
72
85
  }
73
86
  console.log("")
74
87
 
75
- await UI.typeText("Let me analyze your most interesting session and create a blog post...")
76
- console.log("")
88
+ // Analyze with shimmer show the thinking process step by step
89
+ await shimmerLine("Analyzing sessions for interesting insights...", 1200)
77
90
 
78
- // Dry run — preview
91
+ // Dry run — preview (with shimmer while waiting)
79
92
  let preview: string
80
93
  try {
81
- preview = await McpBridge.callTool("auto_post", { dry_run: true })
94
+ const postPromise = McpBridge.callTool("auto_post", { dry_run: true })
95
+ await shimmerLine("Crafting a blog post from your best session...", 2000)
96
+ preview = await postPromise
82
97
  } catch (err) {
83
98
  UI.warn(`Could not generate post: ${err instanceof Error ? err.message : String(err)}`)
84
99
  await UI.typeText("You can try again later with /publish in the app.")
85
100
  return
86
101
  }
87
102
 
88
- // Display preview
103
+ // Display preview with structured layout
89
104
  const cleaned = UI.cleanMarkdown(preview)
105
+ console.log("")
90
106
  UI.divider()
91
107
 
92
- // Extract and display title/tags nicely
108
+ // Parse out key fields for better display
93
109
  const lines = cleaned.split("\n")
110
+ let title = ""
111
+ let tags = ""
112
+ let category = ""
113
+ const bodyLines: string[] = []
114
+
94
115
  for (const line of lines) {
95
116
  const trimmed = line.trim()
96
- if (!trimmed) {
97
- console.log("")
98
- continue
117
+ if (!trimmed || trimmed.startsWith("DRY RUN") || trimmed === "---" || trimmed.match(/^─+$/)) continue
118
+ if (trimmed.startsWith("Title:")) { title = trimmed.replace("Title:", "").trim(); continue }
119
+ if (trimmed.startsWith("Tags:")) { tags = trimmed.replace("Tags:", "").trim(); continue }
120
+ if (trimmed.startsWith("Category:")) { category = trimmed.replace("Category:", "").trim(); continue }
121
+ if (trimmed.startsWith("Session:")) continue
122
+ bodyLines.push(trimmed)
123
+ }
124
+
125
+ // Structured display
126
+ if (title) {
127
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}📝 ${title}${UI.Style.TEXT_NORMAL}`)
128
+ console.log("")
129
+ }
130
+ if (category || tags) {
131
+ const meta: string[] = []
132
+ if (category) meta.push(`Category: ${category}`)
133
+ if (tags) meta.push(`Tags: ${tags}`)
134
+ console.log(` ${UI.Style.TEXT_DIM}${meta.join(" · ")}${UI.Style.TEXT_NORMAL}`)
135
+ console.log("")
136
+ }
137
+ if (bodyLines.length > 0) {
138
+ // Show a preview snippet (first few meaningful lines)
139
+ const snippet = bodyLines.slice(0, 6)
140
+ for (const line of snippet) {
141
+ console.log(` ${line}`)
99
142
  }
100
- if (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
107
- } else {
108
- console.log(` ${trimmed}`)
143
+ if (bodyLines.length > 6) {
144
+ console.log(` ${UI.Style.TEXT_DIM}... (${bodyLines.length - 6} more lines)${UI.Style.TEXT_NORMAL}`)
109
145
  }
110
146
  }
111
147
 
112
148
  UI.divider()
113
149
 
114
150
  // Confirm publish
115
- 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()
151
+ const choice = await UI.waitEnter("Press Enter to publish, or Esc to skip")
117
152
 
118
153
  if (choice === "escape") {
119
154
  await UI.typeText("Skipped. You can publish later with /publish in the app.")
120
155
  return
121
156
  }
122
157
 
123
- // Publish
124
- await UI.typeText("Publishing...", { charDelay: 20 })
158
+ // Publish with shimmer
159
+ const publishPromise = McpBridge.callTool("auto_post", { dry_run: false })
160
+ await shimmerLine("Publishing your post...", 1500)
161
+
125
162
  try {
126
- const result = await McpBridge.callTool("auto_post", { dry_run: false })
163
+ const result = await publishPromise
127
164
  console.log("")
128
165
 
129
- // Extract URL from result
166
+ // Extract URL and details from result
130
167
  const urlMatch = result.match(/(?:URL|View at|view at)[:\s]*(https?:\/\/\S+)/i)
131
168
  if (urlMatch) {
132
- UI.success(`Published! View at: ${urlMatch[1]}`)
169
+ UI.success("Post published successfully!")
170
+ console.log("")
171
+ if (title) {
172
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}${title}${UI.Style.TEXT_NORMAL}`)
173
+ }
174
+ console.log(` ${UI.Style.TEXT_HIGHLIGHT}${urlMatch[1]}${UI.Style.TEXT_NORMAL}`)
175
+ console.log("")
176
+ await UI.typeText("Your first post is live! Others can now read, comment, and vote on it.", { charDelay: 10 })
133
177
  } else {
178
+ UI.success("Post published!")
134
179
  // Fallback: show cleaned result
135
180
  const cleanResult = UI.cleanMarkdown(result)
136
181
  for (const line of cleanResult.split("\n").slice(0, 5)) {
@@ -164,8 +209,7 @@ async function aiConfigPrompt(): Promise<void> {
164
209
  console.log(` ${UI.Style.TEXT_DIM}"Write a post about my React refactoring"${UI.Style.TEXT_NORMAL}`)
165
210
  console.log("")
166
211
 
167
- 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()
212
+ const choice = await UI.waitEnter("Press Enter to configure AI, or Esc to skip")
169
213
 
170
214
  if (choice === "escape") {
171
215
  console.log("")
@@ -179,21 +223,58 @@ async function aiConfigPrompt(): Promise<void> {
179
223
  return
180
224
  }
181
225
 
182
- // AI config flow: URL → Key (reuses saveProvider from ai/configure.ts)
226
+ // AI config flow: URL → Key with ESC support
183
227
  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} `)
228
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}API URL${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}(or press Enter to skip URL, Esc to cancel):${UI.Style.TEXT_NORMAL}`)
229
+ const urlResult = await UI.inputWithEscape(` ${UI.Style.TEXT_HIGHLIGHT} ${UI.Style.TEXT_NORMAL}`)
230
+
231
+ if (urlResult === null) {
232
+ // User pressed Esc
233
+ console.log("")
234
+ await UI.typeText("Skipped AI configuration. You can configure later with /ai in the app.")
235
+ return
236
+ }
237
+
238
+ const url = urlResult.trim()
239
+
240
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}API Key${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}(press Esc to cancel):${UI.Style.TEXT_NORMAL}`)
241
+ const keyResult = await UI.inputWithEscape(` ${UI.Style.TEXT_HIGHLIGHT}❯ ${UI.Style.TEXT_NORMAL}`)
242
+
243
+ if (keyResult === null) {
244
+ // User pressed Esc
245
+ console.log("")
246
+ await UI.typeText("Skipped AI configuration. You can configure later with /ai in the app.")
247
+ return
248
+ }
249
+
250
+ const key = keyResult.trim()
251
+
252
+ // Both empty → friendly skip
253
+ if (!url && !key) {
254
+ console.log("")
255
+ UI.info("No AI configuration provided — skipping for now.")
256
+ await UI.typeText("You can configure AI later with /ai in the app.")
257
+ return
258
+ }
259
+
260
+ // Key empty but URL provided → friendly skip
261
+ if (!key) {
262
+ console.log("")
263
+ UI.info("No API key provided — skipping AI configuration.")
264
+ await UI.typeText("You can configure AI later with /ai in the app.")
265
+ return
266
+ }
186
267
 
187
- if (!key || key.length < 5) {
188
- UI.warn("API key too short, skipping AI configuration.")
268
+ if (key.length < 5) {
269
+ UI.warn("API key seems too short, skipping AI configuration.")
189
270
  await UI.typeText("You can configure AI later with /ai in the app.")
190
271
  return
191
272
  }
192
273
 
193
274
  try {
194
275
  const { saveProvider } = await import("../../ai/configure")
195
- console.log(` ${UI.Style.TEXT_DIM}Detecting API format...${UI.Style.TEXT_NORMAL}`)
196
- const result = await saveProvider(url.trim(), key.trim())
276
+ await shimmerLine("Detecting API format...", 1500)
277
+ const result = await saveProvider(url, key)
197
278
  if (result.error) {
198
279
  UI.warn(result.error)
199
280
  await UI.typeText("You can try again later with /ai in the app.")
@@ -230,8 +311,7 @@ export const SetupCommand: CommandModule = {
230
311
  await UI.typeText("You may need to sign up or log in on the website first.", { charDelay: 10 })
231
312
  console.log("")
232
313
 
233
- console.log(` ${UI.Style.TEXT_DIM}Press Enter to open browser...${UI.Style.TEXT_NORMAL}`)
234
- await UI.waitEnter()
314
+ await UI.waitEnter("Press Enter to open browser...")
235
315
 
236
316
  authenticated = await authBrowser()
237
317
  }
@@ -252,8 +332,7 @@ export const SetupCommand: CommandModule = {
252
332
  await UI.typeText("I'll scan your local IDE sessions to find interesting coding experiences.", { charDelay: 10 })
253
333
  console.log("")
254
334
 
255
- console.log(` ${UI.Style.TEXT_DIM}Press Enter to continue...${UI.Style.TEXT_NORMAL}`)
256
- const scanChoice = await UI.waitEnter()
335
+ const scanChoice = await UI.waitEnter("Press Enter to continue, or Esc to skip")
257
336
 
258
337
  if (scanChoice === "enter") {
259
338
  await scanAndPublish()
@@ -5,6 +5,39 @@ import fs from "fs/promises"
5
5
  import path from "path"
6
6
  import os from "os"
7
7
 
8
+ const DIM = "\x1b[90m"
9
+ const RESET = "\x1b[0m"
10
+ const BOLD = "\x1b[1m"
11
+ const RED = "\x1b[91m"
12
+ const GREEN = "\x1b[92m"
13
+ const YELLOW = "\x1b[93m"
14
+ const CYAN = "\x1b[36m"
15
+
16
+ const W = 60 // inner width of the box
17
+ const BAR = `${DIM}│${RESET}`
18
+
19
+ /** Strip ANSI escape sequences to get visible character length */
20
+ function visLen(s: string): number {
21
+ return s.replace(/\x1b\[[0-9;]*m/g, "").length
22
+ }
23
+
24
+ function line(text = "") {
25
+ const pad = Math.max(0, W - visLen(text) - 1)
26
+ console.log(` ${BAR} ${text}${" ".repeat(pad)}${BAR}`)
27
+ }
28
+
29
+ function lineSuccess(text: string) {
30
+ line(`${GREEN}✓${RESET} ${text}`)
31
+ }
32
+
33
+ function lineWarn(text: string) {
34
+ line(`${YELLOW}⚠${RESET} ${text}`)
35
+ }
36
+
37
+ function lineInfo(text: string) {
38
+ line(`${DIM}${text}${RESET}`)
39
+ }
40
+
8
41
  export const UninstallCommand: CommandModule = {
9
42
  command: "uninstall",
10
43
  describe: "Uninstall codeblog CLI and remove all local data",
@@ -15,35 +48,86 @@ export const UninstallCommand: CommandModule = {
15
48
  default: false,
16
49
  }),
17
50
  handler: async (args) => {
18
- UI.println("")
19
- UI.warn("This will uninstall codeblog from your system.")
20
-
21
- if (!(args["keep-data"] as boolean)) {
22
- UI.println(` The following directories will be removed:`)
23
- UI.println(` ${UI.Style.TEXT_DIM}${Global.Path.config}${UI.Style.TEXT_NORMAL}`)
24
- UI.println(` ${UI.Style.TEXT_DIM}${Global.Path.data}${UI.Style.TEXT_NORMAL}`)
25
- UI.println(` ${UI.Style.TEXT_DIM}${Global.Path.cache}${UI.Style.TEXT_NORMAL}`)
26
- UI.println(` ${UI.Style.TEXT_DIM}${Global.Path.state}${UI.Style.TEXT_NORMAL}`)
51
+ const keepData = args["keep-data"] as boolean
52
+ const binPath = process.execPath
53
+ const pkg = await import("../../../package.json")
54
+
55
+ console.log(UI.logo())
56
+
57
+ // Top border
58
+ console.log(` ${DIM}┌${"─".repeat(W)}┐${RESET}`)
59
+ line()
60
+ line(`${RED}${BOLD}Uninstall CodeBlog${RESET} ${DIM}v${pkg.version}${RESET}`)
61
+ line()
62
+
63
+ // Show what will be removed
64
+ line(`${BOLD}The following will be removed:${RESET}`)
65
+ line()
66
+ line(` ${DIM}Binary${RESET} ${binPath}`)
67
+
68
+ if (!keepData) {
69
+ const dirs = [Global.Path.config, Global.Path.data, Global.Path.cache, Global.Path.state]
70
+ for (const dir of dirs) {
71
+ const label = dir.includes("config") ? "Config" : dir.includes("data") || dir.includes("share") ? "Data" : dir.includes("cache") ? "Cache" : "State"
72
+ try {
73
+ await fs.access(dir)
74
+ line(` ${DIM}${label.padEnd(10)}${RESET}${dir}`)
75
+ } catch {
76
+ // dir doesn't exist, skip
77
+ }
78
+ }
27
79
  }
28
- UI.println("")
29
80
 
30
- const answer = await UI.input(` Type "yes" to confirm: `)
81
+ if (os.platform() !== "win32") {
82
+ const rcFiles = getShellRcFiles()
83
+ for (const rc of rcFiles) {
84
+ try {
85
+ const content = await fs.readFile(rc, "utf-8")
86
+ if (content.includes("# codeblog")) {
87
+ line(` ${DIM}Shell RC${RESET} ${rc} ${DIM}(PATH entry)${RESET}`)
88
+ }
89
+ } catch {}
90
+ }
91
+ }
92
+
93
+ line()
94
+
95
+ // Separator
96
+ console.log(` ${DIM}├${"─".repeat(W)}┤${RESET}`)
97
+ line()
98
+
99
+ // Confirm
100
+ line(`${BOLD}Type "yes" to confirm uninstall:${RESET}`)
101
+ process.stderr.write(` ${BAR} ${DIM}> ${RESET}`)
102
+ const answer = await readLine()
103
+ // Print the line with right border after input
104
+ const inputDisplay = answer || ""
105
+ const inputLine = `${DIM}> ${RESET}${inputDisplay}`
106
+ const inputPad = Math.max(0, W - visLen(inputLine) - 1)
107
+ process.stderr.write(`\x1b[A\r ${BAR} ${inputLine}${" ".repeat(inputPad)}${BAR}\n`)
108
+
31
109
  if (answer.toLowerCase() !== "yes") {
32
- UI.info("Uninstall cancelled.")
110
+ line()
111
+ line(`Uninstall cancelled.`)
112
+ line()
113
+ console.log(` ${DIM}└${"─".repeat(W)}┘${RESET}`)
114
+ console.log("")
33
115
  return
34
116
  }
35
117
 
36
- UI.println("")
118
+ line()
37
119
 
120
+ // Execute uninstall steps
38
121
  // 1. Remove data directories
39
- if (!(args["keep-data"] as boolean)) {
122
+ if (!keepData) {
40
123
  const dirs = [Global.Path.config, Global.Path.data, Global.Path.cache, Global.Path.state]
41
124
  for (const dir of dirs) {
42
125
  try {
126
+ await fs.access(dir)
43
127
  await fs.rm(dir, { recursive: true, force: true })
44
- UI.success(`Removed ${dir}`)
128
+ lineSuccess(`Removed ${dir}`)
45
129
  } catch {
46
- // ignore if already gone
130
+ // dir doesn't exist
47
131
  }
48
132
  }
49
133
  }
@@ -53,67 +137,109 @@ export const UninstallCommand: CommandModule = {
53
137
  await cleanShellRc()
54
138
  }
55
139
 
56
- // 3. Remove the binary itself
57
- const binPath = process.execPath
140
+ // 3. Remove the binary
58
141
  const binDir = path.dirname(binPath)
59
142
 
60
143
  if (os.platform() === "win32") {
61
- // Windows: can't delete running exe, schedule removal
62
- UI.info(`Binary at ${binPath}`)
63
- UI.info("On Windows, please delete the binary manually after this process exits:")
64
- UI.println(` ${UI.Style.TEXT_HIGHLIGHT}del "${binPath}"${UI.Style.TEXT_NORMAL}`)
65
-
66
- // Try to remove from PATH
144
+ lineInfo(`Binary at ${binPath}`)
145
+ lineWarn(`On Windows, delete manually after exit:`)
146
+ line(` ${CYAN}del "${binPath}"${RESET}`)
67
147
  await cleanWindowsPath(binDir)
68
148
  } else {
69
149
  try {
70
150
  await fs.unlink(binPath)
71
- UI.success(`Removed binary: ${binPath}`)
151
+ lineSuccess(`Removed ${binPath}`)
72
152
  } catch (e: any) {
73
153
  if (e.code === "EBUSY" || e.code === "ETXTBSY") {
74
- // Binary is running, schedule delete via shell
75
154
  const { spawn } = await import("child_process")
76
155
  spawn("sh", ["-c", `sleep 1 && rm -f "${binPath}"`], {
77
156
  detached: true,
78
157
  stdio: "ignore",
79
158
  }).unref()
80
- UI.success(`Binary will be removed: ${binPath}`)
159
+ lineSuccess(`Binary will be removed: ${binPath}`)
81
160
  } else {
82
- UI.warn(`Could not remove binary: ${e.message}`)
83
- UI.println(` Remove it manually: ${UI.Style.TEXT_HIGHLIGHT}rm "${binPath}"${UI.Style.TEXT_NORMAL}`)
161
+ lineWarn(`Could not remove binary: ${e.message}`)
162
+ line(` Run manually: ${CYAN}rm "${binPath}"${RESET}`)
84
163
  }
85
164
  }
86
165
  }
87
166
 
88
- UI.println("")
89
- UI.success("codeblog has been uninstalled. Goodbye!")
90
- UI.println("")
167
+ line()
168
+
169
+ // Separator
170
+ console.log(` ${DIM}├${"─".repeat(W)}┤${RESET}`)
171
+ line()
172
+ line(`${GREEN}${BOLD}CodeBlog has been uninstalled.${RESET} Goodbye!`)
173
+ line()
174
+
175
+ // Bottom border
176
+ console.log(` ${DIM}└${"─".repeat(W)}┘${RESET}`)
177
+ console.log("")
91
178
  },
92
179
  }
93
180
 
94
- async function cleanShellRc() {
181
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
182
+
183
+ function readLine(): Promise<string> {
184
+ const stdin = process.stdin
185
+ return new Promise((resolve) => {
186
+ const wasRaw = stdin.isRaw
187
+ if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(true)
188
+
189
+ let buf = ""
190
+ const onData = (ch: Buffer) => {
191
+ const c = ch.toString("utf8")
192
+ if (c === "\u0003") {
193
+ if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
194
+ stdin.removeListener("data", onData)
195
+ process.exit(130)
196
+ }
197
+ if (c === "\r" || c === "\n") {
198
+ if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
199
+ stdin.removeListener("data", onData)
200
+ process.stderr.write("\n")
201
+ resolve(buf)
202
+ return
203
+ }
204
+ if (c === "\u007f" || c === "\b") {
205
+ if (buf.length > 0) {
206
+ buf = buf.slice(0, -1)
207
+ process.stderr.write("\b \b")
208
+ }
209
+ return
210
+ }
211
+ const clean = c.replace(/[\x00-\x1f\x7f]/g, "")
212
+ if (clean) {
213
+ buf += clean
214
+ process.stderr.write(clean)
215
+ }
216
+ }
217
+ stdin.on("data", onData)
218
+ })
219
+ }
220
+
221
+ function getShellRcFiles(): string[] {
95
222
  const home = os.homedir()
96
- const rcFiles = [
223
+ return [
97
224
  path.join(home, ".zshrc"),
98
225
  path.join(home, ".bashrc"),
99
226
  path.join(home, ".profile"),
100
227
  ]
228
+ }
101
229
 
102
- for (const rc of rcFiles) {
230
+ async function cleanShellRc() {
231
+ for (const rc of getShellRcFiles()) {
103
232
  try {
104
233
  const content = await fs.readFile(rc, "utf-8")
105
234
  if (!content.includes("# codeblog")) continue
106
235
 
107
- // Remove the "# codeblog" line and the export PATH line that follows
108
236
  const lines = content.split("\n")
109
237
  const filtered: string[] = []
110
238
  for (let i = 0; i < lines.length; i++) {
111
239
  if (lines[i]!.trim() === "# codeblog") {
112
- // Skip this line and the next export PATH line
113
240
  if (i + 1 < lines.length && lines[i + 1]!.includes("export PATH=")) {
114
- i++ // skip next line too
241
+ i++
115
242
  }
116
- // Also skip a preceding blank line if present
117
243
  if (filtered.length > 0 && filtered[filtered.length - 1]!.trim() === "") {
118
244
  filtered.pop()
119
245
  }
@@ -123,7 +249,7 @@ async function cleanShellRc() {
123
249
  }
124
250
 
125
251
  await fs.writeFile(rc, filtered.join("\n"), "utf-8")
126
- UI.success(`Cleaned PATH entry from ${rc}`)
252
+ lineSuccess(`Cleaned PATH from ${rc}`)
127
253
  } catch {
128
254
  // file doesn't exist or not readable
129
255
  }
@@ -136,7 +262,6 @@ async function cleanWindowsPath(binDir: string) {
136
262
  const { promisify } = await import("util")
137
263
  const execAsync = promisify(exec)
138
264
 
139
- // Read current user PATH
140
265
  const { stdout } = await execAsync(
141
266
  `powershell -Command "[Environment]::GetEnvironmentVariable('Path','User')"`,
142
267
  )
@@ -148,9 +273,9 @@ async function cleanWindowsPath(binDir: string) {
148
273
  await execAsync(
149
274
  `powershell -Command "[Environment]::SetEnvironmentVariable('Path','${newPath}','User')"`,
150
275
  )
151
- UI.success(`Removed ${binDir} from user PATH`)
276
+ lineSuccess(`Removed ${binDir} from user PATH`)
152
277
  }
153
278
  } catch {
154
- UI.warn("Could not clean PATH. You may need to remove it manually from System Settings.")
279
+ lineWarn("Could not clean PATH. Remove manually from System Settings.")
155
280
  }
156
281
  }
package/src/cli/ui.ts CHANGED
@@ -72,6 +72,61 @@ export namespace UI {
72
72
  })
73
73
  }
74
74
 
75
+ /**
76
+ * Input with ESC support. Returns null if user presses Escape, otherwise the input string.
77
+ */
78
+ export async function inputWithEscape(prompt: string): Promise<string | null> {
79
+ const stdin = process.stdin
80
+ process.stderr.write(prompt)
81
+
82
+ return new Promise((resolve) => {
83
+ const wasRaw = stdin.isRaw
84
+ if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(true)
85
+
86
+ let buf = ""
87
+ const onData = (ch: Buffer) => {
88
+ const c = ch.toString("utf8")
89
+ if (c === "\u0003") {
90
+ // Ctrl+C
91
+ if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
92
+ stdin.removeListener("data", onData)
93
+ process.exit(130)
94
+ }
95
+ if (c === "\x1b") {
96
+ // Escape
97
+ if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
98
+ stdin.removeListener("data", onData)
99
+ process.stderr.write("\n")
100
+ resolve(null)
101
+ return
102
+ }
103
+ if (c === "\r" || c === "\n") {
104
+ // Enter
105
+ if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
106
+ stdin.removeListener("data", onData)
107
+ process.stderr.write("\n")
108
+ resolve(buf)
109
+ return
110
+ }
111
+ if (c === "\u007f" || c === "\b") {
112
+ // Backspace
113
+ if (buf.length > 0) {
114
+ buf = buf.slice(0, -1)
115
+ process.stderr.write("\b \b")
116
+ }
117
+ return
118
+ }
119
+ // Regular character
120
+ const clean = c.replace(/[\x00-\x1f\x7f]/g, "")
121
+ if (clean) {
122
+ buf += clean
123
+ process.stderr.write(clean)
124
+ }
125
+ }
126
+ stdin.on("data", onData)
127
+ })
128
+ }
129
+
75
130
  export async function password(prompt: string): Promise<string> {
76
131
  const readline = require("readline")
77
132
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true })
@@ -234,11 +234,25 @@ export function Home(props: {
234
234
  const v = aiUrl().trim()
235
235
  if (v && !v.startsWith("http")) { showMsg("URL must start with http:// or https://", theme.colors.error); return }
236
236
  setAiMode("key")
237
- showMsg("Now paste your API key:", theme.colors.primary)
237
+ showMsg("Now paste your API key (or press Esc to cancel):", theme.colors.primary)
238
238
  return
239
239
  }
240
240
  if (aiMode() === "key") {
241
- if (aiKey().trim().length < 5) { showMsg("API key too short", theme.colors.error); return }
241
+ const url = aiUrl().trim()
242
+ const key = aiKey().trim()
243
+ // Both empty → friendly skip
244
+ if (!url && !key) {
245
+ showMsg("No AI configuration provided — skipped. Use /ai anytime to configure.", theme.colors.warning)
246
+ setAiMode("")
247
+ return
248
+ }
249
+ // Key empty but URL provided → friendly skip
250
+ if (!key) {
251
+ showMsg("No API key provided — skipped. Use /ai anytime to configure.", theme.colors.warning)
252
+ setAiMode("")
253
+ return
254
+ }
255
+ if (key.length < 5) { showMsg("API key too short", theme.colors.error); return }
242
256
  saveAI()
243
257
  return
244
258
  }