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/README.md +54 -32
- package/bin/create.js +922 -66
- package/package.json +3 -2
- package/template/.claude/skills/brain-ingest/SKILL.md +21 -4
- package/template/.claude/skills/brain-rebuild/SKILL.md +9 -9
- package/template/.claude/skills/brain-refresh/SKILL.md +18 -5
- package/template/.claude/skills/brain-search/SKILL.md +17 -5
- package/template/CLAUDE.md +7 -7
- package/template/README.md +24 -19
- package/template/scripts/qmd/reindex.ts +31 -19
- package/template/scripts/qmd/setup.ts +45 -36
- package/template/.claude/skills/qmd-cli/SKILL.md +0 -168
- package/template/.claude/skills/setup/SKILL.md +0 -66
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 {
|
|
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,
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
373
|
+
const installed = []
|
|
374
|
+
const skipped = []
|
|
375
|
+
const sameVersion = []
|
|
255
376
|
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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 =
|
|
1024
|
+
let targetName = initialName
|
|
270
1025
|
if (!targetName) {
|
|
271
1026
|
const answer = await p.text({
|
|
272
|
-
message: "
|
|
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
|
|
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
|
-
|
|
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(`${
|
|
1102
|
+
spin.stop(`${pc.dim(toDisplayPath(targetDir))} created`)
|
|
362
1103
|
|
|
363
|
-
// Patch vault
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
//
|
|
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(
|
|
392
|
-
|
|
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 ${
|
|
460
|
-
`${pc.cyan("
|
|
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)
|