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/README.md +48 -32
- package/bin/create.js +588 -67
- 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 +17 -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,502 @@ async function setupCloudflareRemote({ targetDir, repoName, namespace, spin }) {
|
|
|
246
244
|
return { repoToken, remote }
|
|
247
245
|
}
|
|
248
246
|
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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 =
|
|
728
|
+
let targetName = initialName
|
|
270
729
|
if (!targetName) {
|
|
271
730
|
const answer = await p.text({
|
|
272
|
-
message: "
|
|
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
|
|
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
|
-
|
|
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(`${
|
|
806
|
+
spin.stop(`${pc.dim(toDisplayPath(targetDir))} created`)
|
|
362
807
|
|
|
363
|
-
// Patch vault
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
//
|
|
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(
|
|
392
|
-
|
|
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 ${
|
|
460
|
-
`${pc.cyan("
|
|
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)
|