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