claude-second-brain 0.5.1 → 1.0.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/bin/create.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { cp, rename, access, readFile, writeFile, mkdir } from "fs/promises"
3
- import { readdirSync, readFileSync } from "fs"
2
+ import { cp, rename, access, readFile, writeFile, mkdir, rm } from "fs/promises"
3
+ import { readFileSync } from "fs"
4
4
  import { join, dirname } from "path"
5
5
  import { fileURLToPath } from "url"
6
6
  import { spawnSync } from "child_process"
@@ -11,6 +11,8 @@ import pc from "picocolors"
11
11
  const __dirname = dirname(fileURLToPath(import.meta.url))
12
12
  const TEMPLATE = join(__dirname, "../template")
13
13
  const { version } = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8"))
14
+ const CSB_ROOT = join(homedir(), ".claude-second-brain")
15
+ const CSB_CONFIG = join(CSB_ROOT, "config.toml")
14
16
 
15
17
  // Non-interactive commands — output piped (won't corrupt spinner)
16
18
  function run(cmd, cwd) {
@@ -18,6 +20,25 @@ function run(cmd, cwd) {
18
20
  return result.status === 0
19
21
  }
20
22
 
23
+ // Like `run`, but returns captured stdout/stderr so callers can surface the
24
+ // real failure to the user instead of a generic "failed" message.
25
+ function runCapture(cmd, cwd) {
26
+ const result = spawnSync(cmd[0], cmd.slice(1), { cwd, stdio: "pipe", encoding: "utf8" })
27
+ return {
28
+ ok: result.status === 0,
29
+ stdout: result.stdout || "",
30
+ stderr: result.stderr || "",
31
+ }
32
+ }
33
+
34
+ // Pick the most informative tail of a child process's output for an error message.
35
+ function tailForError({ stdout, stderr }, lines = 10) {
36
+ const source = (stderr && stderr.trim()) ? stderr : stdout
37
+ const trimmed = (source || "").trim()
38
+ if (!trimmed) return ""
39
+ return trimmed.split("\n").slice(-lines).join("\n")
40
+ }
41
+
21
42
  // Interactive commands (e.g. gh auth login) — must inherit stdio; call outside spinner
22
43
  function runInteractive(cmd, cwd) {
23
44
  const result = spawnSync(cmd[0], cmd.slice(1), { cwd, stdio: "inherit" })
@@ -29,112 +50,736 @@ function commandExists(cmd) {
29
50
  return result.status === 0
30
51
  }
31
52
 
32
- async function patchVault(targetDir, qmdPath, brainName) {
33
- const filesToPatch = [
34
- join(targetDir, "scripts/qmd/setup.ts"),
35
- join(targetDir, "scripts/qmd/reindex.ts"),
36
- join(targetDir, "CLAUDE.md"),
37
- ]
38
-
39
- const skillsDir = join(targetDir, ".claude/skills")
53
+ async function patchVault(targetDir, brainName) {
54
+ // Only __BRAIN_NAME__ is substituted — all path resolution happens at
55
+ // invocation time via the `claude-second-brain` CLI subcommands.
56
+ const readmePath = join(targetDir, "README.md")
40
57
  try {
41
- for (const skill of readdirSync(skillsDir)) {
42
- filesToPatch.push(join(skillsDir, skill, "SKILL.md"))
43
- }
58
+ let readme = await readFile(readmePath, "utf8")
59
+ readme = readme.replaceAll("__BRAIN_NAME__", brainName)
60
+ await writeFile(readmePath, readme, "utf8")
44
61
  } catch (err) {
45
62
  if (err.code !== "ENOENT") throw err
46
63
  }
64
+ }
65
+
66
+ // Resolve the `wrangler` CLI as either an installed binary or `npx wrangler` fallback.
67
+ function resolveWranglerCli() {
68
+ if (commandExists("wrangler")) return { cmd: "wrangler", prefix: [] }
69
+ return { cmd: "npx", prefix: ["wrangler"] }
70
+ }
71
+
72
+ // Strip ANSI escape sequences that wrangler injects into stdout.
73
+ function stripAnsi(s) {
74
+ // eslint-disable-next-line no-control-regex
75
+ return s.replace(/\x1B\[[0-9;]*[A-Za-z]/g, "")
76
+ }
77
+
78
+ // Wrangler prints a decorative banner (⛅, ❅, etc.) alongside the token.
79
+ // Find the actual token line — a long run of Bearer-safe ASCII chars — and ignore the rest.
80
+ function extractBearerToken(stdout) {
81
+ const lines = stripAnsi(stdout).split(/\r?\n/)
82
+ const candidates = lines
83
+ .map(l => l.trim())
84
+ .filter(l => /^[A-Za-z0-9._~+/=-]{20,}$/.test(l))
85
+ // Prefer the longest candidate (real tokens are longer than any accidental match).
86
+ candidates.sort((a, b) => b.length - a.length)
87
+ return candidates[0] || null
88
+ }
89
+
90
+ function truncate(s, n = 80) {
91
+ if (!s) return ""
92
+ return s.length > n ? s.slice(0, n) + "…" : s
93
+ }
47
94
 
48
- await Promise.all(filesToPatch.map(async file => {
49
- try {
50
- let content = await readFile(file, "utf8")
51
- content = content.replaceAll("__QMD_PATH__", qmdPath)
52
- await writeFile(file, content, "utf8")
53
- } catch (err) {
54
- if (err.code !== "ENOENT") throw err
95
+ // End-to-end Cloudflare Artifacts setup: auth, create repo, set remote, push.
96
+ // Every failure logs the exact step, exit codes, stderr, and HTTP bodies so the
97
+ // user can see where in the flow the break happened.
98
+ async function setupCloudflareRemote({ targetDir, repoName, namespace, spin }) {
99
+ // Honor an existing API token as a shortcut — skip wrangler entirely.
100
+ let cfApiToken = process.env.CLOUDFLARE_API_TOKEN
101
+ let tokenSource = "CLOUDFLARE_API_TOKEN env"
102
+
103
+ if (!cfApiToken) {
104
+ // wrangler login requests artifacts:write by default — use it for auth.
105
+ const wrangler = resolveWranglerCli()
106
+ const wranglerLabel = [wrangler.cmd, ...wrangler.prefix].join(" ")
107
+
108
+ p.log.info(`Checking Cloudflare auth via \`${wranglerLabel} whoami\`...`)
109
+ const authCheck = spawnSync(wrangler.cmd, [...wrangler.prefix, "whoami"], { stdio: "pipe" })
110
+ const whoamiOut = stripAnsi(authCheck.stdout?.toString() || "") + "\n" + stripAnsi(authCheck.stderr?.toString() || "")
111
+ let loggedIn = authCheck.status === 0
112
+ // Wrangler's existing session may predate Artifacts — it warns "missing some expected Oauth scopes"
113
+ // and lists "artifacts:write". Detect that and force a re-login.
114
+ const hasArtifactsScope = /^\s*-\s*artifacts\s*\(write\)/im.test(whoamiOut)
115
+ const missingArtifactsScope = /missing some expected Oauth scopes/i.test(whoamiOut)
116
+ && /artifacts:write/i.test(whoamiOut)
117
+ const needsRelogin = loggedIn && (!hasArtifactsScope || missingArtifactsScope)
118
+
119
+ if (!loggedIn) {
120
+ const stderr = authCheck.stderr?.toString().trim()
121
+ p.log.info(`wrangler whoami exited ${authCheck.status}${stderr ? ` (${truncate(stderr, 160)})` : ""}`)
122
+ } else if (needsRelogin) {
123
+ p.log.info("Your wrangler session is missing the artifacts:write scope — re-login required.")
55
124
  }
56
- }))
57
125
 
58
- const readmePath = join(targetDir, "README.md")
126
+ if (!loggedIn || needsRelogin) {
127
+ p.log.info("Starting wrangler login (grants artifacts:write scope)...")
128
+ const loginResult = spawnSync(wrangler.cmd, [...wrangler.prefix, "login"], { stdio: "inherit" })
129
+ loggedIn = loginResult.status === 0
130
+ if (!loggedIn) {
131
+ p.log.warn(`wrangler login exited ${loginResult.status} — set CLOUDFLARE_API_TOKEN and re-run.`)
132
+ return null
133
+ }
134
+
135
+ // Verify the new session actually has artifacts:write before proceeding.
136
+ const verify = spawnSync(wrangler.cmd, [...wrangler.prefix, "whoami"], { stdio: "pipe" })
137
+ const verifyOut = stripAnsi(verify.stdout?.toString() || "") + "\n" + stripAnsi(verify.stderr?.toString() || "")
138
+ if (!/^\s*-\s*artifacts\s*\(write\)/im.test(verifyOut)) {
139
+ p.log.warn("wrangler login completed but artifacts:write scope is still missing.")
140
+ p.log.warn("Upgrade wrangler (npm i -g wrangler@latest) or set CLOUDFLARE_API_TOKEN manually.")
141
+ return null
142
+ }
143
+ }
144
+
145
+ // Retrieve the OAuth token wrangler stored; it refreshes automatically if expired.
146
+ p.log.info(`Fetching OAuth token via \`${wranglerLabel} auth token\`...`)
147
+ const tokenResult = spawnSync(wrangler.cmd, [...wrangler.prefix, "auth", "token"], { stdio: "pipe" })
148
+ const rawStdout = tokenResult.stdout?.toString() || ""
149
+ const rawStderr = tokenResult.stderr?.toString() || ""
150
+
151
+ if (tokenResult.status !== 0) {
152
+ p.log.warn(`wrangler auth token exited ${tokenResult.status}`)
153
+ if (rawStderr.trim()) p.log.warn(`stderr: ${truncate(rawStderr.trim(), 400)}`)
154
+ if (rawStdout.trim()) p.log.warn(`stdout: ${truncate(rawStdout.trim(), 400)}`)
155
+ return null
156
+ }
157
+
158
+ cfApiToken = extractBearerToken(rawStdout)
159
+ if (!cfApiToken) {
160
+ p.log.warn("Could not parse a Bearer-safe token from wrangler output.")
161
+ p.log.warn(`raw stdout: ${truncate(rawStdout.trim(), 400)}`)
162
+ if (rawStderr.trim()) p.log.warn(`raw stderr: ${truncate(rawStderr.trim(), 400)}`)
163
+ return null
164
+ }
165
+ tokenSource = "wrangler auth token"
166
+ }
167
+
168
+ // Guard against tokens that contain non-ASCII (which would crash fetch's header encoder).
169
+ if (!/^[\x20-\x7E]+$/.test(cfApiToken)) {
170
+ p.log.warn(`Token from ${tokenSource} contains non-ASCII characters — refusing to use it.`)
171
+ p.log.warn(`token preview: ${truncate(cfApiToken, 60)}`)
172
+ return null
173
+ }
174
+
175
+ p.log.info(`Using token from ${tokenSource} (length ${cfApiToken.length}, prefix ${cfApiToken.slice(0, 8)}…)`)
176
+
177
+ const baseUrl = `https://artifacts.cloudflare.net/v1/api/namespaces/${namespace}`
178
+ spin.start(`Creating Cloudflare Artifact ${pc.dim(repoName)} at ${pc.dim(baseUrl)}`)
179
+
180
+ let res
181
+ let rawBody = ""
59
182
  try {
60
- let readme = await readFile(readmePath, "utf8")
61
- readme = readme.replaceAll("__BRAIN_NAME__", brainName)
62
- await writeFile(readmePath, readme, "utf8")
183
+ res = await fetch(`${baseUrl}/repos`, {
184
+ method: "POST",
185
+ headers: {
186
+ "Authorization": `Bearer ${cfApiToken}`,
187
+ "Content-Type": "application/json",
188
+ },
189
+ body: JSON.stringify({ name: repoName, default_branch: "main" }),
190
+ })
191
+ rawBody = await res.text()
63
192
  } catch (err) {
64
- if (err.code !== "ENOENT") throw err
193
+ spin.stop(`Network error calling Artifacts API: ${err.message}`, 1)
194
+ return null
195
+ }
196
+
197
+ let createData
198
+ try {
199
+ createData = rawBody ? JSON.parse(rawBody) : {}
200
+ } catch {
201
+ spin.stop(`Artifacts API returned non-JSON (HTTP ${res.status})`, 1)
202
+ p.log.warn(`body: ${truncate(rawBody, 400)}`)
203
+ return null
204
+ }
205
+
206
+ if (!res.ok || !createData?.success) {
207
+ const errMsg = createData?.errors?.[0]?.message || createData?.message || "unknown error"
208
+ spin.stop(`Artifacts API failed (HTTP ${res.status}): ${errMsg}`, 1)
209
+ p.log.warn(`full response: ${truncate(rawBody, 400)}`)
210
+ return null
211
+ }
212
+
213
+ const { remote, token: repoToken } = createData.result
214
+ if (!remote || !repoToken) {
215
+ spin.stop("Artifacts API succeeded but response is missing remote/token", 1)
216
+ p.log.warn(`response: ${truncate(rawBody, 400)}`)
217
+ return null
218
+ }
219
+
220
+ const remoteAddResult = spawnSync("git", ["remote", "add", "origin", remote], { cwd: targetDir, stdio: "pipe" })
221
+ if (remoteAddResult.status !== 0) {
222
+ spin.stop(`git remote add failed (exit ${remoteAddResult.status})`, 1)
223
+ const stderr = remoteAddResult.stderr?.toString().trim()
224
+ if (stderr) p.log.warn(`git stderr: ${truncate(stderr, 400)}`)
225
+ return null
226
+ }
227
+
228
+ const pushResult = spawnSync(
229
+ "git",
230
+ ["-c", `http.extraHeader=Authorization: Bearer ${repoToken}`, "push", "-u", "origin", "main"],
231
+ { cwd: targetDir, stdio: "pipe" }
232
+ )
233
+ if (pushResult.status !== 0) {
234
+ spin.stop(`git push to Cloudflare Artifact failed (exit ${pushResult.status})`, 1)
235
+ const stderr = pushResult.stderr?.toString().trim()
236
+ const stdout = pushResult.stdout?.toString().trim()
237
+ if (stderr) p.log.warn(`git stderr: ${truncate(stderr, 400)}`)
238
+ if (stdout) p.log.warn(`git stdout: ${truncate(stdout, 400)}`)
239
+ return { repoToken, remote }
65
240
  }
241
+
242
+ spin.stop(`Cloudflare Artifact created: ${pc.cyan(repoName)}`)
243
+ p.log.info(`Remote: ${pc.dim(remote)}`)
244
+ return { repoToken, remote }
66
245
  }
67
246
 
68
- async function installGlobalSkills(qmdPath) {
69
- const globalSkillsDir = join(homedir(), ".claude", "skills")
247
+ function parseGithubRepo(url) {
248
+ if (!url) return null
249
+ const m = url.match(/github\.com[:/]([^/\s]+)\/([^/\s.]+)/)
250
+ return m ? `${m[1]}/${m[2]}` : null
251
+ }
252
+
253
+ function parseCloudflareArtifact(url) {
254
+ if (!url || !/artifacts\.cloudflare/.test(url)) return null
255
+ const explicit = url.match(/namespaces\/([^/]+)\/repos\/([^/.]+)/)
256
+ if (explicit) return { namespace: explicit[1], repo: explicit[2] }
257
+ const parts = url.replace(/\.git$/, "").split("/").filter(Boolean)
258
+ if (parts.length >= 2) return { namespace: parts[parts.length - 2], repo: parts[parts.length - 1] }
259
+ return null
260
+ }
261
+
262
+ async function resolveCloudflareToken() {
263
+ if (process.env.CLOUDFLARE_API_TOKEN) {
264
+ return { token: process.env.CLOUDFLARE_API_TOKEN, source: "CLOUDFLARE_API_TOKEN env" }
265
+ }
266
+ const wrangler = resolveWranglerCli()
267
+ const tokenResult = spawnSync(wrangler.cmd, [...wrangler.prefix, "auth", "token"], { stdio: "pipe" })
268
+ if (tokenResult.status !== 0) return null
269
+ const token = extractBearerToken(tokenResult.stdout?.toString() || "")
270
+ if (!token || !/^[\x20-\x7E]+$/.test(token)) return null
271
+ return { token, source: "wrangler auth token" }
272
+ }
273
+
274
+ function deleteGithubRepo(slug) {
275
+ if (!commandExists("gh")) return { ok: false, error: "gh CLI not installed" }
276
+ const r = spawnSync("gh", ["repo", "delete", slug, "--yes"], { stdio: "pipe" })
277
+ if (r.status !== 0) {
278
+ const stderr = r.stderr?.toString().trim() || `exit ${r.status}`
279
+ return { ok: false, error: truncate(stderr, 200) }
280
+ }
281
+ return { ok: true }
282
+ }
283
+
284
+ async function deleteCloudflareArtifact({ namespace, repo }) {
285
+ const auth = await resolveCloudflareToken()
286
+ if (!auth) return { ok: false, error: "no Cloudflare API token (set CLOUDFLARE_API_TOKEN or run `wrangler login`)" }
287
+ try {
288
+ const res = await fetch(`https://artifacts.cloudflare.net/v1/api/namespaces/${namespace}/repos/${repo}`, {
289
+ method: "DELETE",
290
+ headers: { Authorization: `Bearer ${auth.token}` },
291
+ })
292
+ if (!res.ok) {
293
+ const body = await res.text()
294
+ return { ok: false, error: `HTTP ${res.status}: ${truncate(body, 200)}` }
295
+ }
296
+ return { ok: true }
297
+ } catch (err) {
298
+ return { ok: false, error: err.message }
299
+ }
300
+ }
301
+
302
+ const GLOBAL_SKILLS = ["brain-ingest", "brain-search", "brain-refresh"]
303
+
304
+ async function readSkillVersion(skillDir) {
305
+ try {
306
+ const raw = await readFile(join(skillDir, ".csb-version"), "utf8")
307
+ return JSON.parse(raw).version || null
308
+ } catch {
309
+ return null
310
+ }
311
+ }
70
312
 
71
- await Promise.all(["brain-ingest", "brain-search", "brain-refresh"].map(async skillName => {
72
- const srcFile = join(TEMPLATE, ".claude/skills", skillName, "SKILL.md")
73
- const destDir = join(globalSkillsDir, skillName)
313
+ // Decide whether to (re)install each global skill. Returns a map name→"install"|"skip".
314
+ // Interactive mismatch prompts once per run with update-all / skip-all / ask-per-skill.
315
+ async function planGlobalSkillInstall({ isInteractive }) {
316
+ const decisions = {}
317
+ const mismatches = []
318
+
319
+ for (const name of GLOBAL_SKILLS) {
320
+ const destDir = join(homedir(), ".claude", "skills", name)
321
+ const installed = await readSkillVersion(destDir)
322
+ if (!installed) {
323
+ decisions[name] = "install"
324
+ } else if (installed === version) {
325
+ decisions[name] = "skip"
326
+ } else {
327
+ mismatches.push({ name, installed })
328
+ }
329
+ }
74
330
 
75
- let content = await readFile(srcFile, "utf8")
76
- content = content.replaceAll("__QMD_PATH__", qmdPath)
331
+ if (mismatches.length === 0) return decisions
332
+
333
+ // CI / non-interactive / opt-out: silently update.
334
+ if (!isInteractive || process.env.CSB_SKIP_SKILL_UPDATES === "1") {
335
+ for (const m of mismatches) decisions[m.name] = "install"
336
+ return decisions
337
+ }
338
+
339
+ const mode = await p.select({
340
+ message: `${mismatches.length} global skill${mismatches.length === 1 ? "" : "s"} at a different version than this release (v${version}). Update?`,
341
+ options: [
342
+ { value: "update-all", label: "Update all" },
343
+ { value: "skip-all", label: "Keep existing (skip all)" },
344
+ { value: "ask", label: "Ask per skill" },
345
+ ],
346
+ initialValue: "update-all",
347
+ })
348
+ if (p.isCancel(mode)) {
349
+ for (const m of mismatches) decisions[m.name] = "skip"
350
+ return decisions
351
+ }
352
+
353
+ if (mode === "update-all") {
354
+ for (const m of mismatches) decisions[m.name] = "install"
355
+ } else if (mode === "skip-all") {
356
+ for (const m of mismatches) decisions[m.name] = "skip"
357
+ } else {
358
+ for (const m of mismatches) {
359
+ const ok = await p.confirm({
360
+ message: `Update "${m.name}" from v${m.installed} → v${version}?`,
361
+ initialValue: true,
362
+ })
363
+ decisions[m.name] = p.isCancel(ok) || !ok ? "skip" : "install"
364
+ }
365
+ }
366
+ return decisions
367
+ }
368
+
369
+ async function installGlobalSkills({ isInteractive }) {
370
+ const globalSkillsDir = join(homedir(), ".claude", "skills")
371
+ const decisions = await planGlobalSkillInstall({ isInteractive })
372
+
373
+ const installed = []
374
+ const skipped = []
375
+ const sameVersion = []
376
+
377
+ await Promise.all(GLOBAL_SKILLS.map(async name => {
378
+ const destDir = join(globalSkillsDir, name)
379
+ const decision = decisions[name]
380
+
381
+ if (decision === "skip") {
382
+ // Differentiate "already at this version" from "user declined update" for logging.
383
+ const current = await readSkillVersion(destDir)
384
+ if (current === version) sameVersion.push(name)
385
+ else skipped.push(name)
386
+ return
387
+ }
388
+
389
+ const srcFile = join(TEMPLATE, ".claude/skills", name, "SKILL.md")
390
+ const content = await readFile(srcFile, "utf8")
77
391
 
78
392
  await mkdir(destDir, { recursive: true })
79
393
  await writeFile(join(destDir, "SKILL.md"), content, "utf8")
394
+ await writeFile(
395
+ join(destDir, ".csb-version"),
396
+ JSON.stringify({ version, installed: new Date().toISOString() }, null, 2) + "\n",
397
+ "utf8",
398
+ )
399
+ installed.push(name)
80
400
  }))
401
+
402
+ return { installed, skipped, sameVersion }
81
403
  }
82
404
 
83
- async function main() {
405
+ function parseConfig(content) {
406
+ if (!content) return { defaultBrain: null, brains: [], header: "" }
407
+ const blocks = content.split(/\n(?=\[\[brains\]\])/)
408
+ const header = blocks[0] || ""
409
+ // Prefer `default = …` over legacy `active = …` so existing configs upgrade
410
+ // transparently on the next write.
411
+ const defaultMatch =
412
+ header.match(/^default\s*=\s*"([^"]+)"/m) ||
413
+ header.match(/^active\s*=\s*"([^"]+)"/m)
414
+ const brains = blocks.slice(1).map(block => {
415
+ const get = re => (block.match(re) || [])[1] || ""
416
+ return {
417
+ raw: block,
418
+ name: get(/^name\s*=\s*"([^"]+)"/m),
419
+ path: get(/^path\s*=\s*"([^"]+)"/m),
420
+ qmd_index: get(/^qmd_index\s*=\s*"([^"]*)"/m),
421
+ created: get(/^created\s*=\s*"([^"]*)"/m),
422
+ git_remote: get(/^git_remote\s*=\s*"([^"]*)"/m),
423
+ }
424
+ }).filter(b => b.name)
425
+ return { defaultBrain: defaultMatch ? defaultMatch[1] : null, brains, header }
426
+ }
427
+
428
+ function printHelp() {
429
+ const lines = [
430
+ `${pc.bold("claude-second-brain")} v${version}`,
431
+ "",
432
+ "Usage:",
433
+ ` ${pc.cyan("claude-second-brain")} create a new brain (interactive)`,
434
+ ` ${pc.cyan("claude-second-brain <name>")} create a new brain named <name>`,
435
+ ` ${pc.cyan("claude-second-brain ls")} list all brains`,
436
+ ` ${pc.cyan("claude-second-brain rm [<name>…]")} remove one or more brains (space to multi-select)`,
437
+ ` ${pc.cyan("claude-second-brain path [--brain N] [flag]")} print a path (flags: --root, --qmd, --config)`,
438
+ ` ${pc.cyan("claude-second-brain qmd [--brain N] -- …")} run qmd against the resolved brain`,
439
+ ` ${pc.cyan("claude-second-brain help")} show this message`,
440
+ ]
441
+ console.log(lines.join("\n"))
442
+ }
443
+
444
+ // Resolve a brain entry from config. Name defaults to the `default` field.
445
+ async function resolveBrain(name) {
446
+ let content
447
+ try {
448
+ content = await readFile(CSB_CONFIG, "utf8")
449
+ } catch {
450
+ throw new Error(`No config at ${CSB_CONFIG}. Run \`claude-second-brain <name>\` to create your first brain.`)
451
+ }
452
+ const { defaultBrain, brains } = parseConfig(content)
453
+ const target = name || defaultBrain
454
+ if (!target) {
455
+ const available = brains.map(b => b.name).join(", ")
456
+ const hint = available
457
+ ? `Pass --brain <name> (available: ${available}), or edit ${CSB_CONFIG} and set \`default = "<name>"\`.`
458
+ : `Run \`claude-second-brain <name>\` to create your first brain.`
459
+ throw new Error(`No default brain set in config.toml. ${hint}`)
460
+ }
461
+ const entry = brains.find(b => b.name === target)
462
+ if (!entry) {
463
+ const available = brains.map(b => b.name).join(", ") || "(none)"
464
+ throw new Error(`No brain named "${target}". Available: ${available}. Run \`claude-second-brain ls\` to list brains.`)
465
+ }
466
+ return entry
467
+ }
468
+
469
+ async function cmdPath(args) {
470
+ // Parse flags: --brain <name>, --root | --qmd | --config (default: --root)
471
+ let name = null
472
+ let mode = "root"
473
+ for (let i = 0; i < args.length; i++) {
474
+ const a = args[i]
475
+ if (a === "--brain") { name = args[++i]; continue }
476
+ if (a === "--root") { mode = "root"; continue }
477
+ if (a === "--qmd") { mode = "qmd"; continue }
478
+ if (a === "--config") { mode = "config"; continue }
479
+ throw new Error(`Unknown path arg: ${a}`)
480
+ }
481
+ if (mode === "config") {
482
+ process.stdout.write(CSB_CONFIG + "\n")
483
+ return
484
+ }
485
+ const brain = await resolveBrain(name)
486
+ const out = mode === "qmd" ? brain.qmd_index : brain.path
487
+ process.stdout.write(out + "\n")
488
+ }
489
+
490
+ async function cmdQmd(args) {
491
+ // `claude-second-brain qmd [--brain N] [--] <qmd args…>`
492
+ let name = null
493
+ const rest = []
494
+ for (let i = 0; i < args.length; i++) {
495
+ const a = args[i]
496
+ if (a === "--brain") { name = args[++i]; continue }
497
+ if (a === "--") { rest.push(...args.slice(i + 1)); break }
498
+ rest.push(a)
499
+ }
500
+ const brain = await resolveBrain(name)
501
+ const result = spawnSync("npx", ["-y", "@tobilu/qmd", ...rest], {
502
+ stdio: "inherit",
503
+ env: { ...process.env, INDEX_PATH: brain.qmd_index },
504
+ })
505
+ process.exit(result.status ?? 1)
506
+ }
507
+
508
+ async function listBrains() {
509
+ const toDisplayPath = p => p && p.startsWith(homedir()) ? "~" + p.slice(homedir().length) : p
510
+ let content
511
+ try {
512
+ content = await readFile(CSB_CONFIG, "utf8")
513
+ } catch {
514
+ p.intro(`${pc.bgCyan(pc.black(" claude-second-brain "))} v${version}`)
515
+ p.log.info("No brains yet. Run `claude-second-brain <name>` to create one.")
516
+ p.outro("")
517
+ return
518
+ }
519
+
520
+ const { defaultBrain, brains } = parseConfig(content)
521
+ p.intro(`${pc.bgCyan(pc.black(" claude-second-brain "))} v${version}`)
522
+
523
+ if (brains.length === 0) {
524
+ p.log.info("No brains registered in config.toml.")
525
+ p.outro("")
526
+ return
527
+ }
528
+
529
+ const rows = brains.map(b => {
530
+ const marker = b.name === defaultBrain ? pc.green("*") : " "
531
+ const name = b.name === defaultBrain ? pc.bold(b.name) : b.name
532
+ const remote = b.git_remote || pc.dim("—")
533
+ return `${marker} ${name}\n path: ${pc.dim(toDisplayPath(b.path))}\n created: ${pc.dim(b.created || "—")}\n remote: ${pc.dim(remote)}`
534
+ })
535
+
536
+ p.note(rows.join("\n\n"), `${brains.length} brain${brains.length === 1 ? "" : "s"}`)
537
+ p.outro(defaultBrain ? `default: ${pc.cyan(defaultBrain)}` : "no default brain")
538
+ }
539
+
540
+ async function removeBrain(names, { yes = false } = {}) {
541
+ p.intro(`${pc.bgCyan(pc.black(" claude-second-brain "))} v${version}`)
542
+
543
+ let content
544
+ try {
545
+ content = await readFile(CSB_CONFIG, "utf8")
546
+ } catch {
547
+ p.cancel(`No config at ${CSB_CONFIG} — nothing to remove.`)
548
+ process.exit(1)
549
+ }
550
+
551
+ const { defaultBrain, brains, header } = parseConfig(content)
552
+
553
+ if (brains.length === 0) {
554
+ p.cancel("No brains registered in config.toml.")
555
+ process.exit(1)
556
+ }
557
+
558
+ const toDisplayPath = p => p && p.startsWith(homedir()) ? "~" + p.slice(homedir().length) : p
559
+
560
+ if (!names || names.length === 0) {
561
+ const pick = await p.multiselect({
562
+ message: "Which brain(s) to remove? (space to toggle, enter to confirm)",
563
+ options: brains.map(b => ({
564
+ value: b.name,
565
+ label: b.name === defaultBrain ? `${b.name} ${pc.dim("(default)")}` : b.name,
566
+ hint: toDisplayPath(b.path),
567
+ })),
568
+ required: true,
569
+ })
570
+ if (p.isCancel(pick)) { p.cancel("Cancelled."); process.exit(0) }
571
+ names = pick
572
+ }
573
+
574
+ const targets = names.map(n => {
575
+ const t = brains.find(b => b.name === n)
576
+ if (!t) {
577
+ p.cancel(`No brain named "${n}". Run \`claude-second-brain ls\` to see registered brains.`)
578
+ process.exit(1)
579
+ }
580
+ return t
581
+ })
582
+
583
+ if (!yes) {
584
+ const list = targets.map(t => ` • ${t.name} ${pc.dim(toDisplayPath(t.path))}`).join("\n")
585
+ const ok = await p.confirm({
586
+ message: `Remove ${targets.length} brain${targets.length === 1 ? "" : "s"}? This deletes the ${targets.length === 1 ? "directory" : "directories"} and config ${targets.length === 1 ? "entry" : "entries"}.\n${list}`,
587
+ initialValue: false,
588
+ })
589
+ if (p.isCancel(ok) || !ok) {
590
+ p.cancel("Cancelled.")
591
+ process.exit(0)
592
+ }
593
+ }
594
+
595
+ // Ask per-target whether to also delete the remote. Skip in -y mode — remote
596
+ // deletion is destructive beyond the local vault and requires explicit opt-in.
597
+ const remoteDecisions = new Map()
598
+ if (!yes) {
599
+ for (const target of targets) {
600
+ const gh = parseGithubRepo(target.git_remote)
601
+ const cf = parseCloudflareArtifact(target.git_remote)
602
+ if (!gh && !cf) continue
603
+ const label = gh ? `GitHub repo ${pc.cyan(gh)}` : `Cloudflare Artifact ${pc.cyan(`${cf.namespace}/${cf.repo}`)}`
604
+ const ok = await p.confirm({
605
+ message: `Also delete ${label} for "${target.name}"?`,
606
+ initialValue: false,
607
+ })
608
+ if (p.isCancel(ok)) { p.cancel("Cancelled."); process.exit(0) }
609
+ if (ok) {
610
+ remoteDecisions.set(target.name, gh
611
+ ? { kind: "github", slug: gh }
612
+ : { kind: "cloudflare", namespace: cf.namespace, repo: cf.repo })
613
+ }
614
+ }
615
+ }
616
+
617
+ const spin = p.spinner()
618
+
619
+ for (const target of targets) {
620
+ spin.start(`Deleting ${toDisplayPath(target.path)}`)
621
+ if (target.path) {
622
+ try {
623
+ await rm(target.path, { recursive: true, force: true })
624
+ spin.stop(`Removed ${pc.dim(toDisplayPath(target.path))}`)
625
+ } catch (err) {
626
+ spin.stop(`Failed to remove ${target.name}: ${err.message}`, 1)
627
+ }
628
+ } else {
629
+ spin.stop(`${target.name}: no path on config entry — skipping directory removal`, 1)
630
+ }
631
+ }
632
+
633
+ for (const [name, decision] of remoteDecisions) {
634
+ if (decision.kind === "github") {
635
+ spin.start(`Deleting GitHub repo ${decision.slug}`)
636
+ const result = deleteGithubRepo(decision.slug)
637
+ if (result.ok) {
638
+ spin.stop(`Removed GitHub repo ${pc.dim(decision.slug)}`)
639
+ } else {
640
+ spin.stop(`Failed to delete GitHub repo ${decision.slug}: ${result.error}`, 1)
641
+ p.log.warn(`Delete it manually: ${pc.cyan(`gh repo delete ${decision.slug} --yes`)} (or via https://github.com/${decision.slug}/settings)`)
642
+ }
643
+ } else {
644
+ spin.start(`Deleting Cloudflare Artifact ${decision.namespace}/${decision.repo}`)
645
+ const result = await deleteCloudflareArtifact(decision)
646
+ if (result.ok) {
647
+ spin.stop(`Removed Cloudflare Artifact ${pc.dim(`${decision.namespace}/${decision.repo}`)}`)
648
+ } else {
649
+ spin.stop(`Failed to delete Cloudflare Artifact for ${name}: ${result.error}`, 1)
650
+ p.log.warn(`Delete it manually:\n ${pc.cyan(`TOKEN=$(wrangler auth token)`)}\n ${pc.cyan(`curl -X DELETE https://artifacts.cloudflare.net/v1/api/namespaces/${decision.namespace}/repos/${decision.repo} \\`)}\n ${pc.cyan(` -H "Authorization: Bearer $TOKEN"`)}`)
651
+ }
652
+ }
653
+ }
654
+
655
+ // Rewrite config.toml without the removed brains.
656
+ const removedSet = new Set(names)
657
+ const remaining = brains.filter(b => !removedSet.has(b.name))
658
+ const removedDefault = removedSet.has(defaultBrain)
659
+ // Strip both legacy `active = …` and current `default = …` from the header;
660
+ // we rewrite it from scratch below.
661
+ let newHeader = header
662
+ .replace(/^active\s*=\s*"[^"]*"\s*\n?/m, "")
663
+ .replace(/^default\s*=\s*"[^"]*"\s*\n?/m, "")
664
+ .replace(/^\s+|\s+$/g, "")
665
+ if (removedDefault) {
666
+ if (remaining.length > 0) {
667
+ newHeader = `default = "${remaining[0].name}"${newHeader ? "\n" + newHeader : ""}`
668
+ }
669
+ } else if (defaultBrain) {
670
+ newHeader = `default = "${defaultBrain}"${newHeader ? "\n" + newHeader : ""}`
671
+ }
672
+
673
+ const entries = remaining.map(b => b.raw.replace(/^\s+|\s+$/g, ""))
674
+ const out = [newHeader, ...entries].filter(Boolean).join("\n\n").trimEnd() + "\n"
675
+ await writeFile(CSB_CONFIG, out, "utf8")
676
+
677
+ const defaultNow = parseConfig(out).defaultBrain
678
+ const n = targets.length
679
+ const removedMsg = `Removed ${n} brain${n === 1 ? "" : "s"}.`
680
+ p.outro(
681
+ removedDefault
682
+ ? (defaultNow ? `${removedMsg} Default brain is now ${pc.cyan(defaultNow)}.` : `${removedMsg} No brains left.`)
683
+ : removedMsg
684
+ )
685
+ }
686
+
687
+ async function writeConfig(brainName, brainPath, qmdPath, gitRemote) {
688
+ const entry = [
689
+ `[[brains]]`,
690
+ `name = "${brainName}"`,
691
+ `path = "${brainPath}"`,
692
+ `qmd_index = "${qmdPath}"`,
693
+ `created = "${new Date().toISOString().slice(0, 10)}"`,
694
+ `git_remote = "${gitRemote}"`,
695
+ ].join("\n")
696
+
697
+ let existing = null
698
+ try {
699
+ existing = await readFile(CSB_CONFIG, "utf8")
700
+ } catch {
701
+ // Config doesn't exist yet — create fresh
702
+ }
703
+
704
+ if (!existing || !existing.trim()) {
705
+ await writeFile(CSB_CONFIG, `default = "${brainName}"\n\n${entry}\n`, "utf8")
706
+ return
707
+ }
708
+
709
+ // Upsert: remove existing entry for this brain name, then append updated entry.
710
+ // Also migrate legacy `active = …` to `default = …` on any write.
711
+ // If the header lacks any default/active key (e.g. user hand-edited an empty
712
+ // file, or earlier versions of this CLI wrote entries without one), inject
713
+ // `default = "${brainName}"` so CLI subcommands can resolve a default.
714
+ const blocks = existing.split(/\n(?=\[\[brains\]\])/)
715
+ let header = blocks[0].replace(/^active\s*=/m, "default =")
716
+ if (!/^default\s*=/m.test(header)) {
717
+ header = `default = "${brainName}"\n\n${header.trimStart()}`
718
+ }
719
+ const otherBrains = blocks.slice(1).filter(b => !b.includes(`name = "${brainName}"`))
720
+ await writeFile(CSB_CONFIG, [header, ...otherBrains, entry].join("\n").trimEnd() + "\n", "utf8")
721
+ }
722
+
723
+ async function createBrain(initialName) {
84
724
  const isInteractive = Boolean(process.stdin.isTTY)
85
725
 
86
726
  p.intro(`${pc.bgCyan(pc.black(" claude-second-brain "))} v${version}`)
87
727
 
88
- let targetName = process.argv[2]
728
+ let targetName = initialName
89
729
  if (!targetName) {
90
730
  const answer = await p.text({
91
- message: "Where to create your brain?",
731
+ message: "Name of the brain?",
92
732
  placeholder: "my-brain",
93
733
  defaultValue: "my-brain",
94
734
  })
95
735
  if (p.isCancel(answer)) { p.cancel("Setup cancelled."); process.exit(0) }
96
736
  targetName = answer
97
- }
98
-
99
- const defaultQmdPath = join(
100
- process.env.XDG_CACHE_HOME || join(homedir(), ".cache"),
101
- "qmd", "index.sqlite"
102
- )
103
- let qmdPath
104
- if (isInteractive) {
105
- const answer = await p.text({
106
- message: "Where to store the qmd index?",
107
- placeholder: defaultQmdPath,
108
- defaultValue: defaultQmdPath,
109
- })
110
- if (p.isCancel(answer)) { p.cancel("Setup cancelled."); process.exit(0) }
111
- qmdPath = answer
112
737
  } else {
113
- qmdPath = defaultQmdPath
738
+ p.log.step(`Name of the brain?\n${pc.dim(targetName)}`)
114
739
  }
115
740
 
116
- let createGhRepo = false
117
- let ghRepoName = null
741
+ const toDisplayPath = p => p.startsWith(homedir()) ? "~" + p.slice(homedir().length) : p
742
+ const qmdPath = join(CSB_ROOT, targetName, ".qmd", "index.sqlite")
743
+
744
+ let remoteProvider = "none"
745
+ let repoName = null
746
+ let cfNamespace = "default"
118
747
  if (isInteractive) {
119
- const confirm = await p.confirm({
120
- message: "Create a private GitHub repo?",
121
- initialValue: false,
748
+ const provider = await p.select({
749
+ message: "Where to host the Git remote?",
750
+ options: [
751
+ { value: "github", label: "GitHub", hint: "default" },
752
+ { value: "cloudflare", label: "Cloudflare Artifacts" },
753
+ { value: "none", label: "Skip — I'll add a remote later" },
754
+ ],
755
+ initialValue: "github",
122
756
  })
123
- if (p.isCancel(confirm)) { p.cancel("Setup cancelled."); process.exit(0) }
124
- createGhRepo = confirm
757
+ if (p.isCancel(provider)) { p.cancel("Setup cancelled."); process.exit(0) }
758
+ remoteProvider = provider
125
759
 
126
- if (createGhRepo) {
760
+ if (remoteProvider !== "none") {
127
761
  const answer = await p.text({
128
- message: "GitHub repo name?",
762
+ message: "Repo name?",
129
763
  placeholder: targetName,
130
764
  defaultValue: targetName,
131
765
  })
132
766
  if (p.isCancel(answer)) { p.cancel("Setup cancelled."); process.exit(0) }
133
- ghRepoName = answer
767
+ repoName = answer
768
+ }
769
+
770
+ if (remoteProvider === "cloudflare") {
771
+ const ns = await p.text({
772
+ message: "Artifacts namespace?",
773
+ placeholder: "default",
774
+ defaultValue: "default",
775
+ })
776
+ if (p.isCancel(ns)) { p.cancel("Setup cancelled."); process.exit(0) }
777
+ cfNamespace = ns
134
778
  }
135
779
  }
136
780
 
137
- const targetDir = join(process.cwd(), targetName)
781
+ await mkdir(CSB_ROOT, { recursive: true })
782
+ const targetDir = join(CSB_ROOT, targetName)
138
783
 
139
784
  // Fail fast if target already exists
140
785
  try {
@@ -158,12 +803,13 @@ async function main() {
158
803
  } catch {
159
804
  // .gitignore.template not present (e.g. running locally where npm didn't strip it)
160
805
  }
161
- spin.stop(`${targetName}/ created`)
806
+ spin.stop(`${pc.dim(toDisplayPath(targetDir))} created`)
162
807
 
163
- // Patch vault files with chosen qmd path
164
- spin.start("Configuring qmd index path")
165
- await patchVault(targetDir, qmdPath, targetName)
166
- spin.stop(`qmd index → ${pc.dim(qmdPath)}`)
808
+ // Patch vault README (__BRAIN_NAME__). All other paths are resolved at
809
+ // runtime via `claude-second-brain path` / `claude-second-brain qmd`.
810
+ spin.start("Patching README")
811
+ await patchVault(targetDir, targetName)
812
+ spin.stop("README patched")
167
813
 
168
814
  // Install mise if not present
169
815
  if (!commandExists("mise")) {
@@ -186,6 +832,44 @@ async function main() {
186
832
  if (pnpmOk) spin.stop("dependencies installed")
187
833
  else spin.stop("pnpm install failed — run it manually inside your vault", 1)
188
834
 
835
+ // Register qmd collections + contexts. Skip reindex — first reindex downloads
836
+ // ~2GB of GGUF models; user runs it explicitly when ready.
837
+ if (pnpmOk) {
838
+ let doQmdSetup = true
839
+ if (isInteractive) {
840
+ const ok = await p.confirm({
841
+ message: "Register qmd collections now? (wiki + raw-sources)",
842
+ initialValue: true,
843
+ })
844
+ if (p.isCancel(ok)) { p.cancel("Setup cancelled."); process.exit(0) }
845
+ doQmdSetup = ok
846
+ }
847
+ if (doQmdSetup) {
848
+ spin.start("Registering qmd collections (wiki, raw-sources)")
849
+ const qmdRes = runCapture(["mise", "exec", "--", "pnpm", "qmd:setup"], targetDir)
850
+ if (qmdRes.ok) {
851
+ spin.stop("qmd collections registered")
852
+ } else {
853
+ spin.stop("pnpm qmd:setup failed — run it manually inside your vault", 1)
854
+ const tail = tailForError(qmdRes)
855
+ if (tail) p.log.warn(`qmd:setup output:\n${tail}`)
856
+ }
857
+ } else {
858
+ p.log.info("Skipped qmd:setup — run `pnpm qmd:setup` from the vault root when ready.")
859
+ }
860
+ }
861
+
862
+ // Install global skills (version-aware — prompts to update when versions differ)
863
+ spin.start("Installing global Claude skills")
864
+ const skillResult = await installGlobalSkills({ isInteractive })
865
+ const installedCount = skillResult.installed.length
866
+ const skippedCount = skillResult.skipped.length
867
+ const summary = [
868
+ installedCount > 0 ? `installed ${installedCount}` : null,
869
+ skippedCount > 0 ? `kept ${skippedCount}` : null,
870
+ ].filter(Boolean).join(", ") || "already up to date"
871
+ spin.stop(`Global skills ${summary} → ${pc.dim(toDisplayPath(join(homedir(), ".claude", "skills")))}`)
872
+
189
873
  // Git init
190
874
  spin.start("Initializing git repo")
191
875
  const gitOk = run(["git", "init"], targetDir)
@@ -198,9 +882,9 @@ async function main() {
198
882
  }
199
883
 
200
884
  // GitHub repo (optional)
201
- if (createGhRepo) {
885
+ if (remoteProvider === "github") {
202
886
  if (!commandExists("gh")) {
203
- p.log.warn(`gh CLI not found — install from https://cli.github.com, then run:\n gh repo create ${ghRepoName} --private --source=. --remote=origin --push`)
887
+ p.log.warn(`gh CLI not found — install from https://cli.github.com, then run:\n gh repo create ${repoName} --private --source=. --remote=origin --push`)
204
888
  } else {
205
889
  const authCheck = spawnSync("gh", ["auth", "status"], { stdio: "pipe" })
206
890
  let loggedIn = authCheck.status === 0
@@ -211,39 +895,97 @@ async function main() {
211
895
  }
212
896
 
213
897
  if (loggedIn) {
214
- spin.start(`Creating GitHub repo ${pc.dim(ghRepoName)}`)
898
+ spin.start(`Creating GitHub repo ${pc.dim(repoName)}`)
215
899
  const ghOk = run(
216
- ["gh", "repo", "create", ghRepoName, "--private", "--source=.", "--remote=origin", "--push"],
900
+ ["gh", "repo", "create", repoName, "--private", "--source=.", "--remote=origin", "--push"],
217
901
  targetDir
218
902
  )
219
903
  if (ghOk) {
220
- spin.stop(`GitHub repo created (private): ${pc.cyan(ghRepoName)}`)
904
+ spin.stop(`GitHub repo created (private): ${pc.cyan(repoName)}`)
221
905
  } else {
222
- spin.stop(`gh repo create failed — run: gh repo create ${ghRepoName} --private --source=. --remote=origin --push`, 1)
906
+ spin.stop(`gh repo create failed — run: gh repo create ${repoName} --private --source=. --remote=origin --push`, 1)
223
907
  }
224
908
  }
225
909
  }
226
910
  }
227
911
 
228
- // Install global skills
229
- spin.start("Installing global Claude skills")
230
- await installGlobalSkills(qmdPath)
231
- spin.stop("Global skills installed")
912
+ // Cloudflare Artifacts repo (optional)
913
+ let gitRemote = remoteProvider === "github"
914
+ ? (() => {
915
+ const r = spawnSync("git", ["remote", "get-url", "origin"], { cwd: targetDir, stdio: "pipe" })
916
+ return r.status === 0 ? r.stdout.toString().trim() : ""
917
+ })()
918
+ : ""
919
+ if (remoteProvider === "cloudflare") {
920
+ const result = await setupCloudflareRemote({ targetDir, repoName, namespace: cfNamespace, spin })
921
+ if (result?.repoToken) {
922
+ p.log.warn(`Save your Artifacts repo token — it expires and you'll need to mint a new one:\n ${result.repoToken}`)
923
+ }
924
+ gitRemote = result?.remote || ""
925
+ }
926
+
927
+ // Write central config
928
+ spin.start("Registering brain in config")
929
+ await writeConfig(targetName, targetDir, qmdPath, gitRemote)
930
+ spin.stop(`Config updated → ${pc.dim(toDisplayPath(CSB_CONFIG))}`)
232
931
 
233
932
  // Next steps
933
+ const brainDisplayPath = toDisplayPath(targetDir)
934
+ const remoteSteps = remoteProvider === "cloudflare"
935
+ ? [
936
+ `${pc.dim("# Mint a fresh git push/pull token when the current one expires:")}`,
937
+ `${pc.cyan(`TOKEN=$(wrangler auth token)`)}`,
938
+ `${pc.cyan(`curl -X POST https://artifacts.cloudflare.net/v1/api/namespaces/${cfNamespace}/tokens \\`)}`,
939
+ `${pc.cyan(` -H "Authorization: Bearer $TOKEN" \\`)}`,
940
+ `${pc.cyan(` -H "Content-Type: application/json" \\`)}`,
941
+ `${pc.cyan(` -d '{"repo":"${repoName}","scope":"write","ttl":86400}'`)}`,
942
+ ]
943
+ : remoteProvider === "none"
944
+ ? [
945
+ `${pc.cyan("git remote add origin <url>")} connect to a remote for sync`,
946
+ `${pc.cyan("git push -u origin main")}`,
947
+ ]
948
+ : []
949
+
234
950
  const nextSteps = [
235
- `${pc.cyan(`cd ${targetName}`)}`,
236
- `${pc.cyan("claude")} open Claude Code, then run ${pc.bold("/setup")}`,
237
- ...(!createGhRepo ? [
238
- `${pc.cyan("git remote add origin <url>")} connect to GitHub for sync`,
239
- `${pc.cyan("git push -u origin main")}`,
240
- ] : []),
951
+ `${pc.cyan(`cd ${brainDisplayPath}`)}`,
952
+ `${pc.cyan("pnpm qmd:reindex")} ${pc.dim("# first run downloads ~2GB of embedding models")}`,
953
+ `${pc.cyan("claude")} open Claude Code and start ingesting sources`,
954
+ ...remoteSteps,
241
955
  ].join("\n")
242
956
 
243
957
  p.note(nextSteps, "Next steps")
244
958
  p.outro("Happy knowledge building!")
245
959
  }
246
960
 
961
+ async function main() {
962
+ const [, , cmd, ...rest] = process.argv
963
+
964
+ if (cmd === "help" || cmd === "--help" || cmd === "-h") {
965
+ printHelp()
966
+ return
967
+ }
968
+ if (cmd === "ls" || cmd === "list") {
969
+ await listBrains()
970
+ return
971
+ }
972
+ if (cmd === "rm" || cmd === "remove") {
973
+ const names = rest.filter(a => !a.startsWith("-"))
974
+ const yes = rest.some(a => a === "-y" || a === "--yes")
975
+ await removeBrain(names, { yes })
976
+ return
977
+ }
978
+ if (cmd === "path") {
979
+ await cmdPath(rest)
980
+ return
981
+ }
982
+ if (cmd === "qmd") {
983
+ await cmdQmd(rest)
984
+ return
985
+ }
986
+ await createBrain(cmd)
987
+ }
988
+
247
989
  main().catch(err => {
248
990
  p.cancel(err.message)
249
991
  process.exit(1)