claude-second-brain 0.6.0 → 1.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/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,32 +50,9 @@ 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")
40
- try {
41
- for (const skill of readdirSync(skillsDir)) {
42
- filesToPatch.push(join(skillsDir, skill, "SKILL.md"))
43
- }
44
- } catch (err) {
45
- if (err.code !== "ENOENT") throw err
46
- }
47
-
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
55
- }
56
- }))
57
-
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.
58
56
  const readmePath = join(targetDir, "README.md")
59
57
  try {
60
58
  let readme = await readFile(readmePath, "utf8")
@@ -246,56 +244,798 @@ async function setupCloudflareRemote({ targetDir, repoName, namespace, spin }) {
246
244
  return { repoToken, remote }
247
245
  }
248
246
 
249
- async function installGlobalSkills(qmdPath) {
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
+ }
312
+
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
+ }
330
+
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 }) {
250
370
  const globalSkillsDir = join(homedir(), ".claude", "skills")
371
+ const decisions = await planGlobalSkillInstall({ isInteractive })
251
372
 
252
- await Promise.all(["brain-ingest", "brain-search", "brain-refresh"].map(async skillName => {
253
- const srcFile = join(TEMPLATE, ".claude/skills", skillName, "SKILL.md")
254
- const destDir = join(globalSkillsDir, skillName)
373
+ const installed = []
374
+ const skipped = []
375
+ const sameVersion = []
255
376
 
256
- let content = await readFile(srcFile, "utf8")
257
- content = content.replaceAll("__QMD_PATH__", qmdPath)
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")
258
391
 
259
392
  await mkdir(destDir, { recursive: true })
260
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)
261
400
  }))
401
+
402
+ return { installed, skipped, sameVersion }
262
403
  }
263
404
 
264
- 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 hasHelpFlag(args) {
429
+ return args.some(a => a === "--help" || a === "-h")
430
+ }
431
+
432
+ async function readConfigStatus() {
433
+ try {
434
+ const content = await readFile(CSB_CONFIG, "utf8")
435
+ const { defaultBrain, brains } = parseConfig(content)
436
+ return { defaultBrain, brains }
437
+ } catch {
438
+ return { defaultBrain: null, brains: [] }
439
+ }
440
+ }
441
+
442
+ function printHelp(topic = "top") {
443
+ const c = pc.cyan
444
+ const topics = {
445
+ top: async () => {
446
+ const { defaultBrain, brains } = await readConfigStatus()
447
+ const lines = [
448
+ `${pc.bold("claude-second-brain")} v${version} ${pc.dim("(alias: csb)")}`,
449
+ "",
450
+ "Usage:",
451
+ ` ${c("csb")} create a new brain (interactive)`,
452
+ ` ${c("csb <name>")} create a new brain named <name>`,
453
+ ` ${c("csb ls")} list all brains (default marked with *)`,
454
+ ` ${c("csb use <name>")} set the default brain`,
455
+ ` ${c("csb rm [<name>…]")} remove brains + their folders`,
456
+ ` ${c("csb path [qmd|config|root]")} print a path (default: root)`,
457
+ ` ${c("csb qmd [--brain N] <qmd args…>")} run qmd against the resolved brain`,
458
+ ` ${c("csb exec [--brain N] -- <cmd…>")} run a command inside the resolved brain`,
459
+ ` ${c("csb doctor")} verify your setup and suggest fixes`,
460
+ ` ${c("csb help [<command>]")} show help for a command`,
461
+ "",
462
+ "Examples:",
463
+ ` ${c('csb qmd query -c wiki "distributed systems"')}`,
464
+ ` ${c("csb path qmd")}`,
465
+ ` ${c("csb use work")}`,
466
+ ` ${c("csb exec -- pnpm qmd:reindex")}`,
467
+ "",
468
+ `Config: ${pc.dim(CSB_CONFIG)}`,
469
+ brains.length === 0
470
+ ? pc.dim("No brains registered yet.")
471
+ : `Default brain: ${defaultBrain ? pc.cyan(defaultBrain) : pc.yellow("(none set — run `csb use <name>`)")} ${pc.dim(`(${brains.length} registered)`)}`,
472
+ ]
473
+ console.log(lines.join("\n"))
474
+ },
475
+ path: () => console.log([
476
+ `${pc.bold("csb path")} — print a path for the default (or named) brain`,
477
+ "",
478
+ "Usage:",
479
+ ` ${c("csb path [qmd|config|root] [--brain <name>]")}`,
480
+ "",
481
+ "Positional (canonical):",
482
+ ` ${c("root")} the brain's directory (default)`,
483
+ ` ${c("qmd")} the brain's qmd SQLite index`,
484
+ ` ${c("config")} the central config.toml`,
485
+ "",
486
+ "Flags:",
487
+ ` ${c("--brain <name>")} target a specific brain instead of the default`,
488
+ ` ${c("--root | --qmd | --config")} ${pc.dim("(deprecated — use positional form)")}`,
489
+ ].join("\n")),
490
+ qmd: () => console.log([
491
+ `${pc.bold("csb qmd")} — run qmd against the default (or named) brain`,
492
+ "",
493
+ "Usage:",
494
+ ` ${c("csb qmd [--brain <name>] [--] <qmd args…>")}`,
495
+ "",
496
+ `${pc.dim("`--` is optional; use it only to pass a literal `--brain` through to qmd.")}`,
497
+ "",
498
+ "Examples:",
499
+ ` ${c('csb qmd query -c wiki "kafka"')}`,
500
+ ` ${c("csb qmd --brain work search -c wiki kafka")}`,
501
+ ].join("\n")),
502
+ exec: () => console.log([
503
+ `${pc.bold("csb exec")} — run a command inside the resolved brain's directory`,
504
+ "",
505
+ "Usage:",
506
+ ` ${c("csb exec [--brain <name>] -- <cmd…>")}`,
507
+ "",
508
+ `Sets ${c("INDEX_PATH")} to the brain's qmd index and ${c("cwd")} to the brain's root.`,
509
+ "",
510
+ "Examples:",
511
+ ` ${c("csb exec -- pnpm qmd:reindex")}`,
512
+ ` ${c("csb exec --brain work -- git status")}`,
513
+ ].join("\n")),
514
+ use: () => console.log([
515
+ `${pc.bold("csb use")} — set the default brain`,
516
+ "",
517
+ "Usage:",
518
+ ` ${c("csb use <name>")} ${pc.dim("(aliases: default, switch)")}`,
519
+ "",
520
+ "Rewrites the `default = …` line in config.toml. Affects every command that resolves the default brain (path, qmd, exec, global skills).",
521
+ ].join("\n")),
522
+ rm: () => console.log([
523
+ `${pc.bold("csb rm")} — remove one or more brains`,
524
+ "",
525
+ "Usage:",
526
+ ` ${c("csb rm [<name>…] [-y|--yes] [--delete-remote|--keep-remote]")} ${pc.dim("(alias: remove)")}`,
527
+ "",
528
+ "Flags:",
529
+ ` ${c("-y, --yes")} skip the confirmation prompt`,
530
+ ` ${c("--delete-remote")} also delete GitHub/Cloudflare remotes (no prompt)`,
531
+ ` ${c("--keep-remote")} keep remotes (no prompt)`,
532
+ "",
533
+ `${pc.dim("With -y and no remote flag, remotes are preserved (safe default).")}`,
534
+ `${pc.dim("Without -y and no remote flag, you'll be prompted per brain.")}`,
535
+ ].join("\n")),
536
+ ls: () => console.log([
537
+ `${pc.bold("csb ls")} — list all brains ${pc.dim("(alias: list)")}`,
538
+ "",
539
+ "Prints each registered brain with its path, creation date, and remote.",
540
+ "The default brain is shown in bold with a `*` marker.",
541
+ ].join("\n")),
542
+ doctor: () => console.log([
543
+ `${pc.bold("csb doctor")} — verify your setup and suggest fixes`,
544
+ "",
545
+ "Checks required and optional tools (gh, mise, pnpm, wrangler),",
546
+ "the central config, each registered brain's path, and its qmd index.",
547
+ "Prints a `Fix:` hint under each failing check.",
548
+ ].join("\n")),
549
+ }
550
+ const renderer = topics[topic] || topics.top
551
+ const out = renderer()
552
+ if (out && typeof out.then === "function") return out
553
+ }
554
+
555
+ // Resolve a brain entry from config. Name defaults to the `default` field.
556
+ async function resolveBrain(name) {
557
+ let content
558
+ try {
559
+ content = await readFile(CSB_CONFIG, "utf8")
560
+ } catch {
561
+ throw new Error(`No config at ${CSB_CONFIG}. Run \`claude-second-brain <name>\` to create your first brain.`)
562
+ }
563
+ const { defaultBrain, brains } = parseConfig(content)
564
+ const target = name || defaultBrain
565
+ if (!target) {
566
+ const available = brains.map(b => b.name).join(", ")
567
+ const hint = available
568
+ ? `Quick fix: \`csb use <name>\` (available: ${available}), or pass --brain <name>.`
569
+ : `Run \`csb <name>\` to create your first brain.`
570
+ throw new Error(`No default brain set in config.toml. ${hint}`)
571
+ }
572
+ const entry = brains.find(b => b.name === target)
573
+ if (!entry) {
574
+ const available = brains.map(b => b.name).join(", ") || "(none)"
575
+ throw new Error(`No brain named "${target}". Available: ${available}. Run \`claude-second-brain ls\` to list brains.`)
576
+ }
577
+ return entry
578
+ }
579
+
580
+ // Rewrites config.toml so `name` is the new default. Returns {previous, current}.
581
+ // Strips any existing `active = …` / `default = …` line from the header first.
582
+ async function setDefaultBrain(name) {
583
+ let content
584
+ try {
585
+ content = await readFile(CSB_CONFIG, "utf8")
586
+ } catch {
587
+ throw new Error(`No config at ${CSB_CONFIG}. Run \`csb <name>\` to create your first brain.`)
588
+ }
589
+ const { defaultBrain, brains, header } = parseConfig(content)
590
+ if (!brains.find(b => b.name === name)) {
591
+ const available = brains.map(b => b.name).join(", ") || "(none)"
592
+ throw new Error(`No brain named "${name}". Available: ${available}.`)
593
+ }
594
+ if (defaultBrain === name) {
595
+ return { previous: name, current: name, changed: false }
596
+ }
597
+ const blocks = content.split(/\n(?=\[\[brains\]\])/)
598
+ let newHeader = (blocks[0] || "")
599
+ .replace(/^active\s*=\s*"[^"]*"\s*\n?/m, "")
600
+ .replace(/^default\s*=\s*"[^"]*"\s*\n?/m, "")
601
+ .replace(/^\s+|\s+$/g, "")
602
+ newHeader = `default = "${name}"${newHeader ? "\n" + newHeader : ""}`
603
+ const entries = blocks.slice(1).map(b => b.replace(/^\s+|\s+$/g, ""))
604
+ const out = [newHeader, ...entries].filter(Boolean).join("\n\n").trimEnd() + "\n"
605
+ await writeFile(CSB_CONFIG, out, "utf8")
606
+ return { previous: defaultBrain, current: name, changed: true }
607
+ }
608
+
609
+ async function cmdUse(args) {
610
+ const name = args.find(a => !a.startsWith("-"))
611
+ if (!name) {
612
+ throw new Error("Usage: csb use <name>")
613
+ }
614
+ const { previous, current, changed } = await setDefaultBrain(name)
615
+ if (!changed) {
616
+ console.log(`default is already ${pc.cyan(current)}`)
617
+ return
618
+ }
619
+ if (previous) {
620
+ console.log(`default: ${pc.dim(previous)} → ${pc.cyan(current)}`)
621
+ } else {
622
+ console.log(`default set to ${pc.cyan(current)}`)
623
+ }
624
+ }
625
+
626
+ async function cmdPath(args) {
627
+ // Canonical: csb path [qmd|config|root] [--brain <name>]
628
+ // Deprecated: csb path --qmd | --config | --root
629
+ let name = null
630
+ let mode = null
631
+ let sawLegacyFlag = false
632
+ for (let i = 0; i < args.length; i++) {
633
+ const a = args[i]
634
+ if (a === "--brain") { name = args[++i]; continue }
635
+ if (a === "--root") { mode = "root"; sawLegacyFlag = true; continue }
636
+ if (a === "--qmd") { mode = "qmd"; sawLegacyFlag = true; continue }
637
+ if (a === "--config") { mode = "config"; sawLegacyFlag = true; continue }
638
+ if (a === "root" || a === "qmd" || a === "config") { mode = a; continue }
639
+ throw new Error(`Unknown path arg: ${a}. Run \`csb help path\` for usage.`)
640
+ }
641
+ if (!mode) mode = "root"
642
+ if (sawLegacyFlag) {
643
+ process.stderr.write(`${pc.yellow("warning:")} \`--${mode}\` is deprecated — use \`csb path ${mode}\` instead.\n`)
644
+ }
645
+ if (mode === "config") {
646
+ process.stdout.write(CSB_CONFIG + "\n")
647
+ return
648
+ }
649
+ const brain = await resolveBrain(name)
650
+ const out = mode === "qmd" ? brain.qmd_index : brain.path
651
+ process.stdout.write(out + "\n")
652
+ }
653
+
654
+ // Parse [--brain <name>] [--] <rest…>. `--` is optional; use it only to pass a
655
+ // literal `--brain` through to the inner command.
656
+ function parseBrainAndRest(args) {
657
+ let name = null
658
+ const rest = []
659
+ for (let i = 0; i < args.length; i++) {
660
+ const a = args[i]
661
+ if (a === "--brain") { name = args[++i]; continue }
662
+ if (a === "--") { rest.push(...args.slice(i + 1)); break }
663
+ rest.push(a)
664
+ }
665
+ return { name, rest }
666
+ }
667
+
668
+ async function runInBrain(brain, cmd) {
669
+ if (cmd.length === 0) {
670
+ throw new Error("No command to run. Usage: csb exec [--brain <name>] -- <cmd…>")
671
+ }
672
+ const result = spawnSync(cmd[0], cmd.slice(1), {
673
+ cwd: brain.path,
674
+ stdio: "inherit",
675
+ env: { ...process.env, INDEX_PATH: brain.qmd_index },
676
+ })
677
+ process.exit(result.status ?? 1)
678
+ }
679
+
680
+ async function cmdQmd(args) {
681
+ const { name, rest } = parseBrainAndRest(args)
682
+ const brain = await resolveBrain(name)
683
+ await runInBrain(brain, ["npx", "-y", "@tobilu/qmd", ...rest])
684
+ }
685
+
686
+ async function cmdExec(args) {
687
+ const { name, rest } = parseBrainAndRest(args)
688
+ const brain = await resolveBrain(name)
689
+ await runInBrain(brain, rest)
690
+ }
691
+
692
+ async function cmdDoctor() {
693
+ const toDisplayPath = p => p && p.startsWith(homedir()) ? "~" + p.slice(homedir().length) : p
694
+ const ok = s => ` ${pc.green("✓")} ${s}`
695
+ const warn = (s, fix) => ` ${pc.yellow("⚠")} ${s}${fix ? `\n ${pc.dim("Fix:")} ${fix}` : ""}`
696
+ const fail = (s, fix) => ` ${pc.red("✗")} ${s}${fix ? `\n ${pc.dim("Fix:")} ${fix}` : ""}`
697
+ const lines = []
698
+ let failures = 0
699
+ let warnings = 0
700
+
701
+ p.intro(`${pc.bgCyan(pc.black(" csb doctor "))} v${version}`)
702
+
703
+ // Tools
704
+ lines.push(pc.bold("Tools"))
705
+ lines.push(commandExists("gh")
706
+ ? ok("gh (GitHub CLI) installed")
707
+ : (warnings++, warn("gh (GitHub CLI) not installed — required for GitHub remotes", "brew install gh | https://cli.github.com")))
708
+ if (commandExists("gh")) {
709
+ const auth = spawnSync("gh", ["auth", "status"], { stdio: "pipe" })
710
+ lines.push(auth.status === 0
711
+ ? ok("gh authenticated")
712
+ : (warnings++, warn("gh is installed but not authenticated", "gh auth login")))
713
+ }
714
+ lines.push(commandExists("mise")
715
+ ? ok("mise installed")
716
+ : (failures++, fail("mise not installed — required for scaffolded vaults", "npm install -g @jdxcode/mise")))
717
+ lines.push(commandExists("wrangler") || commandExists("npx")
718
+ ? ok(commandExists("wrangler") ? "wrangler installed" : "wrangler available via npx (optional — for Cloudflare Artifacts)")
719
+ : (warnings++, warn("wrangler not installed — required only for Cloudflare Artifacts remotes", "npm install -g wrangler")))
720
+
721
+ // Config
722
+ lines.push("")
723
+ lines.push(pc.bold("Config"))
724
+ let parsed = null
725
+ try {
726
+ const content = await readFile(CSB_CONFIG, "utf8")
727
+ parsed = parseConfig(content)
728
+ lines.push(ok(`config readable at ${pc.dim(toDisplayPath(CSB_CONFIG))}`))
729
+ } catch {
730
+ failures++
731
+ lines.push(fail(`no config at ${toDisplayPath(CSB_CONFIG)}`, "csb <name> # create your first brain"))
732
+ }
733
+
734
+ if (parsed) {
735
+ if (parsed.brains.length === 0) {
736
+ warnings++
737
+ lines.push(warn("no brains registered", "csb <name> # create your first brain"))
738
+ } else {
739
+ lines.push(ok(`${parsed.brains.length} brain${parsed.brains.length === 1 ? "" : "s"} registered`))
740
+ if (parsed.defaultBrain) {
741
+ const entry = parsed.brains.find(b => b.name === parsed.defaultBrain)
742
+ if (entry) {
743
+ lines.push(ok(`default brain: ${pc.cyan(parsed.defaultBrain)}`))
744
+ } else {
745
+ failures++
746
+ lines.push(fail(`default brain "${parsed.defaultBrain}" is not registered`, `csb use <name> # pick one of: ${parsed.brains.map(b => b.name).join(", ")}`))
747
+ }
748
+ } else {
749
+ warnings++
750
+ lines.push(warn("no default brain set", `csb use <name> # pick one of: ${parsed.brains.map(b => b.name).join(", ")}`))
751
+ }
752
+ }
753
+
754
+ // Per-brain checks
755
+ if (parsed.brains.length > 0) {
756
+ lines.push("")
757
+ lines.push(pc.bold("Brains"))
758
+ for (const b of parsed.brains) {
759
+ lines.push(pc.cyan(b.name))
760
+ try {
761
+ await access(b.path)
762
+ lines.push(ok(`path exists: ${pc.dim(toDisplayPath(b.path))}`))
763
+ } catch {
764
+ failures++
765
+ lines.push(fail(`path missing: ${toDisplayPath(b.path)}`, `csb rm ${b.name} # drop the dead entry, then recreate`))
766
+ }
767
+ try {
768
+ await access(b.qmd_index)
769
+ lines.push(ok(`qmd index exists: ${pc.dim(toDisplayPath(b.qmd_index))}`))
770
+ } catch {
771
+ warnings++
772
+ lines.push(warn(`qmd index missing: ${toDisplayPath(b.qmd_index)}`, `csb exec --brain ${b.name} -- pnpm qmd:reindex`))
773
+ }
774
+ }
775
+ }
776
+ }
777
+
778
+ p.note(lines.join("\n"), "Checks")
779
+
780
+ if (failures > 0) {
781
+ p.outro(`${pc.red(`${failures} failure${failures === 1 ? "" : "s"}`)}${warnings > 0 ? `, ${pc.yellow(`${warnings} warning${warnings === 1 ? "" : "s"}`)}` : ""}`)
782
+ process.exit(1)
783
+ }
784
+ if (warnings > 0) {
785
+ p.outro(`${pc.yellow(`${warnings} warning${warnings === 1 ? "" : "s"}`)}`)
786
+ return
787
+ }
788
+ p.outro(pc.green("all checks passed"))
789
+ }
790
+
791
+ async function listBrains() {
792
+ const toDisplayPath = p => p && p.startsWith(homedir()) ? "~" + p.slice(homedir().length) : p
793
+ let content
794
+ try {
795
+ content = await readFile(CSB_CONFIG, "utf8")
796
+ } catch {
797
+ p.intro(`${pc.bgCyan(pc.black(" claude-second-brain "))} v${version}`)
798
+ p.log.info("No brains yet. Run `claude-second-brain <name>` to create one.")
799
+ p.outro("")
800
+ return
801
+ }
802
+
803
+ const { defaultBrain, brains } = parseConfig(content)
804
+ p.intro(`${pc.bgCyan(pc.black(" claude-second-brain "))} v${version}`)
805
+
806
+ if (brains.length === 0) {
807
+ p.log.info("No brains registered in config.toml.")
808
+ p.outro("")
809
+ return
810
+ }
811
+
812
+ const rows = brains.map(b => {
813
+ const marker = b.name === defaultBrain ? pc.green("*") : " "
814
+ const name = b.name === defaultBrain ? pc.bold(b.name) : b.name
815
+ const remote = b.git_remote || pc.dim("—")
816
+ return `${marker} ${name}\n path: ${pc.dim(toDisplayPath(b.path))}\n created: ${pc.dim(b.created || "—")}\n remote: ${pc.dim(remote)}`
817
+ })
818
+
819
+ p.note(rows.join("\n\n"), `${brains.length} brain${brains.length === 1 ? "" : "s"}`)
820
+ p.outro(defaultBrain ? `default: ${pc.cyan(defaultBrain)}` : "no default brain")
821
+ }
822
+
823
+ async function removeBrain(names, { yes = false, remoteMode = "ask" } = {}) {
824
+ p.intro(`${pc.bgCyan(pc.black(" claude-second-brain "))} v${version}`)
825
+
826
+ let content
827
+ try {
828
+ content = await readFile(CSB_CONFIG, "utf8")
829
+ } catch {
830
+ p.cancel(`No config at ${CSB_CONFIG} — nothing to remove.`)
831
+ process.exit(1)
832
+ }
833
+
834
+ const { defaultBrain, brains, header } = parseConfig(content)
835
+
836
+ if (brains.length === 0) {
837
+ p.cancel("No brains registered in config.toml.")
838
+ process.exit(1)
839
+ }
840
+
841
+ const toDisplayPath = p => p && p.startsWith(homedir()) ? "~" + p.slice(homedir().length) : p
842
+
843
+ if (!names || names.length === 0) {
844
+ const pick = await p.multiselect({
845
+ message: "Which brain(s) to remove? (space to toggle, enter to confirm)",
846
+ options: brains.map(b => ({
847
+ value: b.name,
848
+ label: b.name === defaultBrain ? `${b.name} ${pc.dim("(default)")}` : b.name,
849
+ hint: toDisplayPath(b.path),
850
+ })),
851
+ required: true,
852
+ })
853
+ if (p.isCancel(pick)) { p.cancel("Cancelled."); process.exit(0) }
854
+ names = pick
855
+ }
856
+
857
+ const targets = names.map(n => {
858
+ const t = brains.find(b => b.name === n)
859
+ if (!t) {
860
+ p.cancel(`No brain named "${n}". Run \`claude-second-brain ls\` to see registered brains.`)
861
+ process.exit(1)
862
+ }
863
+ return t
864
+ })
865
+
866
+ if (!yes) {
867
+ const list = targets.map(t => ` • ${t.name} ${pc.dim(toDisplayPath(t.path))}`).join("\n")
868
+ const ok = await p.confirm({
869
+ 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}`,
870
+ initialValue: false,
871
+ })
872
+ if (p.isCancel(ok) || !ok) {
873
+ p.cancel("Cancelled.")
874
+ process.exit(0)
875
+ }
876
+ }
877
+
878
+ // Decide per-target whether to also delete the remote.
879
+ // remoteMode === "keep" → never delete remotes
880
+ // remoteMode === "delete" → always delete remotes (no prompt)
881
+ // remoteMode === "ask" → default: prompt unless -y (then keep)
882
+ const remoteDecisions = new Map()
883
+ const resolvedRemoteMode =
884
+ remoteMode === "keep" ? "keep"
885
+ : remoteMode === "delete" ? "delete"
886
+ : yes ? "keep" : "ask"
887
+
888
+ if (resolvedRemoteMode !== "keep") {
889
+ for (const target of targets) {
890
+ const gh = parseGithubRepo(target.git_remote)
891
+ const cf = parseCloudflareArtifact(target.git_remote)
892
+ if (!gh && !cf) continue
893
+ if (resolvedRemoteMode === "delete") {
894
+ remoteDecisions.set(target.name, gh
895
+ ? { kind: "github", slug: gh }
896
+ : { kind: "cloudflare", namespace: cf.namespace, repo: cf.repo })
897
+ continue
898
+ }
899
+ const label = gh ? `GitHub repo ${pc.cyan(gh)}` : `Cloudflare Artifact ${pc.cyan(`${cf.namespace}/${cf.repo}`)}`
900
+ const ok = await p.confirm({
901
+ message: `Also delete ${label} for "${target.name}"?`,
902
+ initialValue: false,
903
+ })
904
+ if (p.isCancel(ok)) { p.cancel("Cancelled."); process.exit(0) }
905
+ if (ok) {
906
+ remoteDecisions.set(target.name, gh
907
+ ? { kind: "github", slug: gh }
908
+ : { kind: "cloudflare", namespace: cf.namespace, repo: cf.repo })
909
+ }
910
+ }
911
+ }
912
+
913
+ const spin = p.spinner()
914
+
915
+ for (const target of targets) {
916
+ spin.start(`Deleting ${toDisplayPath(target.path)}`)
917
+ if (target.path) {
918
+ try {
919
+ await rm(target.path, { recursive: true, force: true })
920
+ spin.stop(`Removed ${pc.dim(toDisplayPath(target.path))}`)
921
+ } catch (err) {
922
+ spin.stop(`Failed to remove ${target.name}: ${err.message}`, 1)
923
+ }
924
+ } else {
925
+ spin.stop(`${target.name}: no path on config entry — skipping directory removal`, 1)
926
+ }
927
+ }
928
+
929
+ for (const [name, decision] of remoteDecisions) {
930
+ if (decision.kind === "github") {
931
+ spin.start(`Deleting GitHub repo ${decision.slug}`)
932
+ const result = deleteGithubRepo(decision.slug)
933
+ if (result.ok) {
934
+ spin.stop(`Removed GitHub repo ${pc.dim(decision.slug)}`)
935
+ } else {
936
+ spin.stop(`Failed to delete GitHub repo ${decision.slug}: ${result.error}`, 1)
937
+ p.log.warn(`Delete it manually: ${pc.cyan(`gh repo delete ${decision.slug} --yes`)} (or via https://github.com/${decision.slug}/settings)`)
938
+ }
939
+ } else {
940
+ spin.start(`Deleting Cloudflare Artifact ${decision.namespace}/${decision.repo}`)
941
+ const result = await deleteCloudflareArtifact(decision)
942
+ if (result.ok) {
943
+ spin.stop(`Removed Cloudflare Artifact ${pc.dim(`${decision.namespace}/${decision.repo}`)}`)
944
+ } else {
945
+ spin.stop(`Failed to delete Cloudflare Artifact for ${name}: ${result.error}`, 1)
946
+ 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"`)}`)
947
+ }
948
+ }
949
+ }
950
+
951
+ // Rewrite config.toml without the removed brains.
952
+ const removedSet = new Set(names)
953
+ const remaining = brains.filter(b => !removedSet.has(b.name))
954
+ const removedDefault = removedSet.has(defaultBrain)
955
+ // Strip both legacy `active = …` and current `default = …` from the header;
956
+ // we rewrite it from scratch below.
957
+ let newHeader = header
958
+ .replace(/^active\s*=\s*"[^"]*"\s*\n?/m, "")
959
+ .replace(/^default\s*=\s*"[^"]*"\s*\n?/m, "")
960
+ .replace(/^\s+|\s+$/g, "")
961
+ if (removedDefault) {
962
+ if (remaining.length > 0) {
963
+ newHeader = `default = "${remaining[0].name}"${newHeader ? "\n" + newHeader : ""}`
964
+ }
965
+ } else if (defaultBrain) {
966
+ newHeader = `default = "${defaultBrain}"${newHeader ? "\n" + newHeader : ""}`
967
+ }
968
+
969
+ const entries = remaining.map(b => b.raw.replace(/^\s+|\s+$/g, ""))
970
+ const out = [newHeader, ...entries].filter(Boolean).join("\n\n").trimEnd() + "\n"
971
+ await writeFile(CSB_CONFIG, out, "utf8")
972
+
973
+ const defaultNow = parseConfig(out).defaultBrain
974
+ const n = targets.length
975
+ const removedMsg = `Removed ${n} brain${n === 1 ? "" : "s"}.`
976
+ p.outro(
977
+ removedDefault
978
+ ? (defaultNow ? `${removedMsg} Default brain is now ${pc.cyan(defaultNow)}.` : `${removedMsg} No brains left.`)
979
+ : removedMsg
980
+ )
981
+ }
982
+
983
+ async function writeConfig(brainName, brainPath, qmdPath, gitRemote) {
984
+ const entry = [
985
+ `[[brains]]`,
986
+ `name = "${brainName}"`,
987
+ `path = "${brainPath}"`,
988
+ `qmd_index = "${qmdPath}"`,
989
+ `created = "${new Date().toISOString().slice(0, 10)}"`,
990
+ `git_remote = "${gitRemote}"`,
991
+ ].join("\n")
992
+
993
+ let existing = null
994
+ try {
995
+ existing = await readFile(CSB_CONFIG, "utf8")
996
+ } catch {
997
+ // Config doesn't exist yet — create fresh
998
+ }
999
+
1000
+ if (!existing || !existing.trim()) {
1001
+ await writeFile(CSB_CONFIG, `default = "${brainName}"\n\n${entry}\n`, "utf8")
1002
+ return
1003
+ }
1004
+
1005
+ // Upsert: remove existing entry for this brain name, then append updated entry.
1006
+ // Also migrate legacy `active = …` to `default = …` on any write.
1007
+ // If the header lacks any default/active key (e.g. user hand-edited an empty
1008
+ // file, or earlier versions of this CLI wrote entries without one), inject
1009
+ // `default = "${brainName}"` so CLI subcommands can resolve a default.
1010
+ const blocks = existing.split(/\n(?=\[\[brains\]\])/)
1011
+ let header = blocks[0].replace(/^active\s*=/m, "default =")
1012
+ if (!/^default\s*=/m.test(header)) {
1013
+ header = `default = "${brainName}"\n\n${header.trimStart()}`
1014
+ }
1015
+ const otherBrains = blocks.slice(1).filter(b => !b.includes(`name = "${brainName}"`))
1016
+ await writeFile(CSB_CONFIG, [header, ...otherBrains, entry].join("\n").trimEnd() + "\n", "utf8")
1017
+ }
1018
+
1019
+ async function createBrain(initialName) {
265
1020
  const isInteractive = Boolean(process.stdin.isTTY)
266
1021
 
267
1022
  p.intro(`${pc.bgCyan(pc.black(" claude-second-brain "))} v${version}`)
268
1023
 
269
- let targetName = process.argv[2]
1024
+ let targetName = initialName
270
1025
  if (!targetName) {
271
1026
  const answer = await p.text({
272
- message: "Where to create your brain?",
1027
+ message: "Name of the brain?",
273
1028
  placeholder: "my-brain",
274
1029
  defaultValue: "my-brain",
275
1030
  })
276
1031
  if (p.isCancel(answer)) { p.cancel("Setup cancelled."); process.exit(0) }
277
1032
  targetName = answer
1033
+ } else {
1034
+ p.log.step(`Name of the brain?\n${pc.dim(targetName)}`)
278
1035
  }
279
1036
 
280
- const defaultQmdPath = join(
281
- process.env.XDG_CACHE_HOME || join(homedir(), ".cache"),
282
- "qmd", "index.sqlite"
283
- )
284
1037
  const toDisplayPath = p => p.startsWith(homedir()) ? "~" + p.slice(homedir().length) : p
285
- const expandHome = p => p.startsWith("~/") || p === "~" ? join(homedir(), p.slice(1)) : p
286
- const displayQmdPath = toDisplayPath(defaultQmdPath)
287
- let qmdPath
288
- if (isInteractive) {
289
- const answer = await p.text({
290
- message: "Where to store the qmd index?",
291
- placeholder: displayQmdPath,
292
- defaultValue: displayQmdPath,
293
- })
294
- if (p.isCancel(answer)) { p.cancel("Setup cancelled."); process.exit(0) }
295
- qmdPath = expandHome(answer)
296
- } else {
297
- qmdPath = defaultQmdPath
298
- }
1038
+ const qmdPath = join(CSB_ROOT, targetName, ".qmd", "index.sqlite")
299
1039
 
300
1040
  let remoteProvider = "none"
301
1041
  let repoName = null
@@ -334,7 +1074,8 @@ async function main() {
334
1074
  }
335
1075
  }
336
1076
 
337
- const targetDir = join(process.cwd(), targetName)
1077
+ await mkdir(CSB_ROOT, { recursive: true })
1078
+ const targetDir = join(CSB_ROOT, targetName)
338
1079
 
339
1080
  // Fail fast if target already exists
340
1081
  try {
@@ -358,12 +1099,13 @@ async function main() {
358
1099
  } catch {
359
1100
  // .gitignore.template not present (e.g. running locally where npm didn't strip it)
360
1101
  }
361
- spin.stop(`${targetName}/ created`)
1102
+ spin.stop(`${pc.dim(toDisplayPath(targetDir))} created`)
362
1103
 
363
- // Patch vault files with chosen qmd path
364
- spin.start("Configuring qmd index path")
365
- await patchVault(targetDir, qmdPath, targetName)
366
- spin.stop(`qmd index → ${pc.dim(toDisplayPath(qmdPath))}`)
1104
+ // Patch vault README (__BRAIN_NAME__). All other paths are resolved at
1105
+ // runtime via `claude-second-brain path` / `claude-second-brain qmd`.
1106
+ spin.start("Patching README")
1107
+ await patchVault(targetDir, targetName)
1108
+ spin.stop("README patched")
367
1109
 
368
1110
  // Install mise if not present
369
1111
  if (!commandExists("mise")) {
@@ -386,10 +1128,43 @@ async function main() {
386
1128
  if (pnpmOk) spin.stop("dependencies installed")
387
1129
  else spin.stop("pnpm install failed — run it manually inside your vault", 1)
388
1130
 
389
- // Install global skills
1131
+ // Register qmd collections + contexts. Skip reindex — first reindex downloads
1132
+ // ~2GB of GGUF models; user runs it explicitly when ready.
1133
+ if (pnpmOk) {
1134
+ let doQmdSetup = true
1135
+ if (isInteractive) {
1136
+ const ok = await p.confirm({
1137
+ message: "Register qmd collections now? (wiki + raw-sources)",
1138
+ initialValue: true,
1139
+ })
1140
+ if (p.isCancel(ok)) { p.cancel("Setup cancelled."); process.exit(0) }
1141
+ doQmdSetup = ok
1142
+ }
1143
+ if (doQmdSetup) {
1144
+ spin.start("Registering qmd collections (wiki, raw-sources)")
1145
+ const qmdRes = runCapture(["mise", "exec", "--", "pnpm", "qmd:setup"], targetDir)
1146
+ if (qmdRes.ok) {
1147
+ spin.stop("qmd collections registered")
1148
+ } else {
1149
+ spin.stop("pnpm qmd:setup failed — run it manually inside your vault", 1)
1150
+ const tail = tailForError(qmdRes)
1151
+ if (tail) p.log.warn(`qmd:setup output:\n${tail}`)
1152
+ }
1153
+ } else {
1154
+ p.log.info("Skipped qmd:setup — run `pnpm qmd:setup` from the vault root when ready.")
1155
+ }
1156
+ }
1157
+
1158
+ // Install global skills (version-aware — prompts to update when versions differ)
390
1159
  spin.start("Installing global Claude skills")
391
- await installGlobalSkills(qmdPath)
392
- spin.stop(`Global skills installed → ${pc.dim(toDisplayPath(join(homedir(), ".claude", "skills")))}`)
1160
+ const skillResult = await installGlobalSkills({ isInteractive })
1161
+ const installedCount = skillResult.installed.length
1162
+ const skippedCount = skillResult.skipped.length
1163
+ const summary = [
1164
+ installedCount > 0 ? `installed ${installedCount}` : null,
1165
+ skippedCount > 0 ? `kept ${skippedCount}` : null,
1166
+ ].filter(Boolean).join(", ") || "already up to date"
1167
+ spin.stop(`Global skills ${summary} → ${pc.dim(toDisplayPath(join(homedir(), ".claude", "skills")))}`)
393
1168
 
394
1169
  // Git init
395
1170
  spin.start("Initializing git repo")
@@ -431,14 +1206,27 @@ async function main() {
431
1206
  }
432
1207
 
433
1208
  // Cloudflare Artifacts repo (optional)
1209
+ let gitRemote = remoteProvider === "github"
1210
+ ? (() => {
1211
+ const r = spawnSync("git", ["remote", "get-url", "origin"], { cwd: targetDir, stdio: "pipe" })
1212
+ return r.status === 0 ? r.stdout.toString().trim() : ""
1213
+ })()
1214
+ : ""
434
1215
  if (remoteProvider === "cloudflare") {
435
1216
  const result = await setupCloudflareRemote({ targetDir, repoName, namespace: cfNamespace, spin })
436
1217
  if (result?.repoToken) {
437
1218
  p.log.warn(`Save your Artifacts repo token — it expires and you'll need to mint a new one:\n ${result.repoToken}`)
438
1219
  }
1220
+ gitRemote = result?.remote || ""
439
1221
  }
440
1222
 
1223
+ // Write central config
1224
+ spin.start("Registering brain in config")
1225
+ await writeConfig(targetName, targetDir, qmdPath, gitRemote)
1226
+ spin.stop(`Config updated → ${pc.dim(toDisplayPath(CSB_CONFIG))}`)
1227
+
441
1228
  // Next steps
1229
+ const brainDisplayPath = toDisplayPath(targetDir)
442
1230
  const remoteSteps = remoteProvider === "cloudflare"
443
1231
  ? [
444
1232
  `${pc.dim("# Mint a fresh git push/pull token when the current one expires:")}`,
@@ -456,8 +1244,9 @@ async function main() {
456
1244
  : []
457
1245
 
458
1246
  const nextSteps = [
459
- `${pc.cyan(`cd ${targetName}`)}`,
460
- `${pc.cyan("claude")} open Claude Code, then run ${pc.bold("/setup")}`,
1247
+ `${pc.cyan(`cd ${brainDisplayPath}`)}`,
1248
+ `${pc.cyan("pnpm qmd:reindex")} ${pc.dim("# first run downloads ~2GB of embedding models")}`,
1249
+ `${pc.cyan("claude")} open Claude Code and start ingesting sources`,
461
1250
  ...remoteSteps,
462
1251
  ].join("\n")
463
1252
 
@@ -465,6 +1254,73 @@ async function main() {
465
1254
  p.outro("Happy knowledge building!")
466
1255
  }
467
1256
 
1257
+ const SUBCOMMANDS = new Set([
1258
+ "help", "--help", "-h",
1259
+ "ls", "list",
1260
+ "rm", "remove",
1261
+ "path",
1262
+ "qmd",
1263
+ "exec",
1264
+ "use", "default", "switch",
1265
+ "doctor",
1266
+ ])
1267
+
1268
+ async function main() {
1269
+ const [, , cmd, ...rest] = process.argv
1270
+
1271
+ if (cmd === "help" || cmd === "--help" || cmd === "-h") {
1272
+ // `csb help <topic>` or `csb help`
1273
+ const topic = rest[0]
1274
+ await printHelp(topic || "top")
1275
+ return
1276
+ }
1277
+
1278
+ // Per-subcommand --help / -h — intercept before any prompts run.
1279
+ if (SUBCOMMANDS.has(cmd) && hasHelpFlag(rest)) {
1280
+ const topicMap = { list: "ls", remove: "rm", default: "use", switch: "use" }
1281
+ await printHelp(topicMap[cmd] || cmd)
1282
+ return
1283
+ }
1284
+
1285
+ if (cmd === "ls" || cmd === "list") {
1286
+ await listBrains()
1287
+ return
1288
+ }
1289
+ if (cmd === "rm" || cmd === "remove") {
1290
+ const names = rest.filter(a => !a.startsWith("-"))
1291
+ const yes = rest.some(a => a === "-y" || a === "--yes")
1292
+ const deleteRemote = rest.some(a => a === "--delete-remote")
1293
+ const keepRemote = rest.some(a => a === "--keep-remote")
1294
+ if (deleteRemote && keepRemote) {
1295
+ throw new Error("Cannot use --delete-remote and --keep-remote together.")
1296
+ }
1297
+ const remoteMode = deleteRemote ? "delete" : keepRemote ? "keep" : "ask"
1298
+ await removeBrain(names, { yes, remoteMode })
1299
+ return
1300
+ }
1301
+ if (cmd === "path") {
1302
+ await cmdPath(rest)
1303
+ return
1304
+ }
1305
+ if (cmd === "qmd") {
1306
+ await cmdQmd(rest)
1307
+ return
1308
+ }
1309
+ if (cmd === "exec") {
1310
+ await cmdExec(rest)
1311
+ return
1312
+ }
1313
+ if (cmd === "use" || cmd === "default" || cmd === "switch") {
1314
+ await cmdUse(rest)
1315
+ return
1316
+ }
1317
+ if (cmd === "doctor") {
1318
+ await cmdDoctor()
1319
+ return
1320
+ }
1321
+ await createBrain(cmd)
1322
+ }
1323
+
468
1324
  main().catch(err => {
469
1325
  p.cancel(err.message)
470
1326
  process.exit(1)