claude-second-brain 0.6.0 → 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,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,502 @@ async function setupCloudflareRemote({ targetDir, repoName, namespace, spin }) {
246
244
  return { repoToken, remote }
247
245
  }
248
246
 
249
- async function installGlobalSkills(qmdPath) {
250
- 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
+ }
251
261
 
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)
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
+ }
255
330
 
256
- let content = await readFile(srcFile, "utf8")
257
- 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")
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 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) {
265
724
  const isInteractive = Boolean(process.stdin.isTTY)
266
725
 
267
726
  p.intro(`${pc.bgCyan(pc.black(" claude-second-brain "))} v${version}`)
268
727
 
269
- let targetName = process.argv[2]
728
+ let targetName = initialName
270
729
  if (!targetName) {
271
730
  const answer = await p.text({
272
- message: "Where to create your brain?",
731
+ message: "Name of the brain?",
273
732
  placeholder: "my-brain",
274
733
  defaultValue: "my-brain",
275
734
  })
276
735
  if (p.isCancel(answer)) { p.cancel("Setup cancelled."); process.exit(0) }
277
736
  targetName = answer
737
+ } else {
738
+ p.log.step(`Name of the brain?\n${pc.dim(targetName)}`)
278
739
  }
279
740
 
280
- const defaultQmdPath = join(
281
- process.env.XDG_CACHE_HOME || join(homedir(), ".cache"),
282
- "qmd", "index.sqlite"
283
- )
284
741
  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
- }
742
+ const qmdPath = join(CSB_ROOT, targetName, ".qmd", "index.sqlite")
299
743
 
300
744
  let remoteProvider = "none"
301
745
  let repoName = null
@@ -334,7 +778,8 @@ async function main() {
334
778
  }
335
779
  }
336
780
 
337
- const targetDir = join(process.cwd(), targetName)
781
+ await mkdir(CSB_ROOT, { recursive: true })
782
+ const targetDir = join(CSB_ROOT, targetName)
338
783
 
339
784
  // Fail fast if target already exists
340
785
  try {
@@ -358,12 +803,13 @@ async function main() {
358
803
  } catch {
359
804
  // .gitignore.template not present (e.g. running locally where npm didn't strip it)
360
805
  }
361
- spin.stop(`${targetName}/ created`)
806
+ spin.stop(`${pc.dim(toDisplayPath(targetDir))} created`)
362
807
 
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))}`)
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")
367
813
 
368
814
  // Install mise if not present
369
815
  if (!commandExists("mise")) {
@@ -386,10 +832,43 @@ async function main() {
386
832
  if (pnpmOk) spin.stop("dependencies installed")
387
833
  else spin.stop("pnpm install failed — run it manually inside your vault", 1)
388
834
 
389
- // Install global skills
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)
390
863
  spin.start("Installing global Claude skills")
391
- await installGlobalSkills(qmdPath)
392
- spin.stop(`Global skills installed → ${pc.dim(toDisplayPath(join(homedir(), ".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")))}`)
393
872
 
394
873
  // Git init
395
874
  spin.start("Initializing git repo")
@@ -431,14 +910,27 @@ async function main() {
431
910
  }
432
911
 
433
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
+ : ""
434
919
  if (remoteProvider === "cloudflare") {
435
920
  const result = await setupCloudflareRemote({ targetDir, repoName, namespace: cfNamespace, spin })
436
921
  if (result?.repoToken) {
437
922
  p.log.warn(`Save your Artifacts repo token — it expires and you'll need to mint a new one:\n ${result.repoToken}`)
438
923
  }
924
+ gitRemote = result?.remote || ""
439
925
  }
440
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))}`)
931
+
441
932
  // Next steps
933
+ const brainDisplayPath = toDisplayPath(targetDir)
442
934
  const remoteSteps = remoteProvider === "cloudflare"
443
935
  ? [
444
936
  `${pc.dim("# Mint a fresh git push/pull token when the current one expires:")}`,
@@ -456,8 +948,9 @@ async function main() {
456
948
  : []
457
949
 
458
950
  const nextSteps = [
459
- `${pc.cyan(`cd ${targetName}`)}`,
460
- `${pc.cyan("claude")} open Claude Code, then run ${pc.bold("/setup")}`,
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`,
461
954
  ...remoteSteps,
462
955
  ].join("\n")
463
956
 
@@ -465,6 +958,34 @@ async function main() {
465
958
  p.outro("Happy knowledge building!")
466
959
  }
467
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
+
468
989
  main().catch(err => {
469
990
  p.cancel(err.message)
470
991
  process.exit(1)