claude-second-brain 0.5.1 → 0.6.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.
Files changed (2) hide show
  1. package/bin/create.js +249 -28
  2. package/package.json +1 -1
package/bin/create.js CHANGED
@@ -65,6 +65,187 @@ async function patchVault(targetDir, qmdPath, brainName) {
65
65
  }
66
66
  }
67
67
 
68
+ // Resolve the `wrangler` CLI as either an installed binary or `npx wrangler` fallback.
69
+ function resolveWranglerCli() {
70
+ if (commandExists("wrangler")) return { cmd: "wrangler", prefix: [] }
71
+ return { cmd: "npx", prefix: ["wrangler"] }
72
+ }
73
+
74
+ // Strip ANSI escape sequences that wrangler injects into stdout.
75
+ function stripAnsi(s) {
76
+ // eslint-disable-next-line no-control-regex
77
+ return s.replace(/\x1B\[[0-9;]*[A-Za-z]/g, "")
78
+ }
79
+
80
+ // Wrangler prints a decorative banner (⛅, ❅, etc.) alongside the token.
81
+ // Find the actual token line — a long run of Bearer-safe ASCII chars — and ignore the rest.
82
+ function extractBearerToken(stdout) {
83
+ const lines = stripAnsi(stdout).split(/\r?\n/)
84
+ const candidates = lines
85
+ .map(l => l.trim())
86
+ .filter(l => /^[A-Za-z0-9._~+/=-]{20,}$/.test(l))
87
+ // Prefer the longest candidate (real tokens are longer than any accidental match).
88
+ candidates.sort((a, b) => b.length - a.length)
89
+ return candidates[0] || null
90
+ }
91
+
92
+ function truncate(s, n = 80) {
93
+ if (!s) return ""
94
+ return s.length > n ? s.slice(0, n) + "…" : s
95
+ }
96
+
97
+ // End-to-end Cloudflare Artifacts setup: auth, create repo, set remote, push.
98
+ // Every failure logs the exact step, exit codes, stderr, and HTTP bodies so the
99
+ // user can see where in the flow the break happened.
100
+ async function setupCloudflareRemote({ targetDir, repoName, namespace, spin }) {
101
+ // Honor an existing API token as a shortcut — skip wrangler entirely.
102
+ let cfApiToken = process.env.CLOUDFLARE_API_TOKEN
103
+ let tokenSource = "CLOUDFLARE_API_TOKEN env"
104
+
105
+ if (!cfApiToken) {
106
+ // wrangler login requests artifacts:write by default — use it for auth.
107
+ const wrangler = resolveWranglerCli()
108
+ const wranglerLabel = [wrangler.cmd, ...wrangler.prefix].join(" ")
109
+
110
+ p.log.info(`Checking Cloudflare auth via \`${wranglerLabel} whoami\`...`)
111
+ const authCheck = spawnSync(wrangler.cmd, [...wrangler.prefix, "whoami"], { stdio: "pipe" })
112
+ const whoamiOut = stripAnsi(authCheck.stdout?.toString() || "") + "\n" + stripAnsi(authCheck.stderr?.toString() || "")
113
+ let loggedIn = authCheck.status === 0
114
+ // Wrangler's existing session may predate Artifacts — it warns "missing some expected Oauth scopes"
115
+ // and lists "artifacts:write". Detect that and force a re-login.
116
+ const hasArtifactsScope = /^\s*-\s*artifacts\s*\(write\)/im.test(whoamiOut)
117
+ const missingArtifactsScope = /missing some expected Oauth scopes/i.test(whoamiOut)
118
+ && /artifacts:write/i.test(whoamiOut)
119
+ const needsRelogin = loggedIn && (!hasArtifactsScope || missingArtifactsScope)
120
+
121
+ if (!loggedIn) {
122
+ const stderr = authCheck.stderr?.toString().trim()
123
+ p.log.info(`wrangler whoami exited ${authCheck.status}${stderr ? ` (${truncate(stderr, 160)})` : ""}`)
124
+ } else if (needsRelogin) {
125
+ p.log.info("Your wrangler session is missing the artifacts:write scope — re-login required.")
126
+ }
127
+
128
+ if (!loggedIn || needsRelogin) {
129
+ p.log.info("Starting wrangler login (grants artifacts:write scope)...")
130
+ const loginResult = spawnSync(wrangler.cmd, [...wrangler.prefix, "login"], { stdio: "inherit" })
131
+ loggedIn = loginResult.status === 0
132
+ if (!loggedIn) {
133
+ p.log.warn(`wrangler login exited ${loginResult.status} — set CLOUDFLARE_API_TOKEN and re-run.`)
134
+ return null
135
+ }
136
+
137
+ // Verify the new session actually has artifacts:write before proceeding.
138
+ const verify = spawnSync(wrangler.cmd, [...wrangler.prefix, "whoami"], { stdio: "pipe" })
139
+ const verifyOut = stripAnsi(verify.stdout?.toString() || "") + "\n" + stripAnsi(verify.stderr?.toString() || "")
140
+ if (!/^\s*-\s*artifacts\s*\(write\)/im.test(verifyOut)) {
141
+ p.log.warn("wrangler login completed but artifacts:write scope is still missing.")
142
+ p.log.warn("Upgrade wrangler (npm i -g wrangler@latest) or set CLOUDFLARE_API_TOKEN manually.")
143
+ return null
144
+ }
145
+ }
146
+
147
+ // Retrieve the OAuth token wrangler stored; it refreshes automatically if expired.
148
+ p.log.info(`Fetching OAuth token via \`${wranglerLabel} auth token\`...`)
149
+ const tokenResult = spawnSync(wrangler.cmd, [...wrangler.prefix, "auth", "token"], { stdio: "pipe" })
150
+ const rawStdout = tokenResult.stdout?.toString() || ""
151
+ const rawStderr = tokenResult.stderr?.toString() || ""
152
+
153
+ if (tokenResult.status !== 0) {
154
+ p.log.warn(`wrangler auth token exited ${tokenResult.status}`)
155
+ if (rawStderr.trim()) p.log.warn(`stderr: ${truncate(rawStderr.trim(), 400)}`)
156
+ if (rawStdout.trim()) p.log.warn(`stdout: ${truncate(rawStdout.trim(), 400)}`)
157
+ return null
158
+ }
159
+
160
+ cfApiToken = extractBearerToken(rawStdout)
161
+ if (!cfApiToken) {
162
+ p.log.warn("Could not parse a Bearer-safe token from wrangler output.")
163
+ p.log.warn(`raw stdout: ${truncate(rawStdout.trim(), 400)}`)
164
+ if (rawStderr.trim()) p.log.warn(`raw stderr: ${truncate(rawStderr.trim(), 400)}`)
165
+ return null
166
+ }
167
+ tokenSource = "wrangler auth token"
168
+ }
169
+
170
+ // Guard against tokens that contain non-ASCII (which would crash fetch's header encoder).
171
+ if (!/^[\x20-\x7E]+$/.test(cfApiToken)) {
172
+ p.log.warn(`Token from ${tokenSource} contains non-ASCII characters — refusing to use it.`)
173
+ p.log.warn(`token preview: ${truncate(cfApiToken, 60)}`)
174
+ return null
175
+ }
176
+
177
+ p.log.info(`Using token from ${tokenSource} (length ${cfApiToken.length}, prefix ${cfApiToken.slice(0, 8)}…)`)
178
+
179
+ const baseUrl = `https://artifacts.cloudflare.net/v1/api/namespaces/${namespace}`
180
+ spin.start(`Creating Cloudflare Artifact ${pc.dim(repoName)} at ${pc.dim(baseUrl)}`)
181
+
182
+ let res
183
+ let rawBody = ""
184
+ try {
185
+ res = await fetch(`${baseUrl}/repos`, {
186
+ method: "POST",
187
+ headers: {
188
+ "Authorization": `Bearer ${cfApiToken}`,
189
+ "Content-Type": "application/json",
190
+ },
191
+ body: JSON.stringify({ name: repoName, default_branch: "main" }),
192
+ })
193
+ rawBody = await res.text()
194
+ } catch (err) {
195
+ spin.stop(`Network error calling Artifacts API: ${err.message}`, 1)
196
+ return null
197
+ }
198
+
199
+ let createData
200
+ try {
201
+ createData = rawBody ? JSON.parse(rawBody) : {}
202
+ } catch {
203
+ spin.stop(`Artifacts API returned non-JSON (HTTP ${res.status})`, 1)
204
+ p.log.warn(`body: ${truncate(rawBody, 400)}`)
205
+ return null
206
+ }
207
+
208
+ if (!res.ok || !createData?.success) {
209
+ const errMsg = createData?.errors?.[0]?.message || createData?.message || "unknown error"
210
+ spin.stop(`Artifacts API failed (HTTP ${res.status}): ${errMsg}`, 1)
211
+ p.log.warn(`full response: ${truncate(rawBody, 400)}`)
212
+ return null
213
+ }
214
+
215
+ const { remote, token: repoToken } = createData.result
216
+ if (!remote || !repoToken) {
217
+ spin.stop("Artifacts API succeeded but response is missing remote/token", 1)
218
+ p.log.warn(`response: ${truncate(rawBody, 400)}`)
219
+ return null
220
+ }
221
+
222
+ const remoteAddResult = spawnSync("git", ["remote", "add", "origin", remote], { cwd: targetDir, stdio: "pipe" })
223
+ if (remoteAddResult.status !== 0) {
224
+ spin.stop(`git remote add failed (exit ${remoteAddResult.status})`, 1)
225
+ const stderr = remoteAddResult.stderr?.toString().trim()
226
+ if (stderr) p.log.warn(`git stderr: ${truncate(stderr, 400)}`)
227
+ return null
228
+ }
229
+
230
+ const pushResult = spawnSync(
231
+ "git",
232
+ ["-c", `http.extraHeader=Authorization: Bearer ${repoToken}`, "push", "-u", "origin", "main"],
233
+ { cwd: targetDir, stdio: "pipe" }
234
+ )
235
+ if (pushResult.status !== 0) {
236
+ spin.stop(`git push to Cloudflare Artifact failed (exit ${pushResult.status})`, 1)
237
+ const stderr = pushResult.stderr?.toString().trim()
238
+ const stdout = pushResult.stdout?.toString().trim()
239
+ if (stderr) p.log.warn(`git stderr: ${truncate(stderr, 400)}`)
240
+ if (stdout) p.log.warn(`git stdout: ${truncate(stdout, 400)}`)
241
+ return { repoToken, remote }
242
+ }
243
+
244
+ spin.stop(`Cloudflare Artifact created: ${pc.cyan(repoName)}`)
245
+ p.log.info(`Remote: ${pc.dim(remote)}`)
246
+ return { repoToken, remote }
247
+ }
248
+
68
249
  async function installGlobalSkills(qmdPath) {
69
250
  const globalSkillsDir = join(homedir(), ".claude", "skills")
70
251
 
@@ -100,37 +281,56 @@ async function main() {
100
281
  process.env.XDG_CACHE_HOME || join(homedir(), ".cache"),
101
282
  "qmd", "index.sqlite"
102
283
  )
284
+ const toDisplayPath = p => p.startsWith(homedir()) ? "~" + p.slice(homedir().length) : p
285
+ const expandHome = p => p.startsWith("~/") || p === "~" ? join(homedir(), p.slice(1)) : p
286
+ const displayQmdPath = toDisplayPath(defaultQmdPath)
103
287
  let qmdPath
104
288
  if (isInteractive) {
105
289
  const answer = await p.text({
106
290
  message: "Where to store the qmd index?",
107
- placeholder: defaultQmdPath,
108
- defaultValue: defaultQmdPath,
291
+ placeholder: displayQmdPath,
292
+ defaultValue: displayQmdPath,
109
293
  })
110
294
  if (p.isCancel(answer)) { p.cancel("Setup cancelled."); process.exit(0) }
111
- qmdPath = answer
295
+ qmdPath = expandHome(answer)
112
296
  } else {
113
297
  qmdPath = defaultQmdPath
114
298
  }
115
299
 
116
- let createGhRepo = false
117
- let ghRepoName = null
300
+ let remoteProvider = "none"
301
+ let repoName = null
302
+ let cfNamespace = "default"
118
303
  if (isInteractive) {
119
- const confirm = await p.confirm({
120
- message: "Create a private GitHub repo?",
121
- initialValue: false,
304
+ const provider = await p.select({
305
+ message: "Where to host the Git remote?",
306
+ options: [
307
+ { value: "github", label: "GitHub", hint: "default" },
308
+ { value: "cloudflare", label: "Cloudflare Artifacts" },
309
+ { value: "none", label: "Skip — I'll add a remote later" },
310
+ ],
311
+ initialValue: "github",
122
312
  })
123
- if (p.isCancel(confirm)) { p.cancel("Setup cancelled."); process.exit(0) }
124
- createGhRepo = confirm
313
+ if (p.isCancel(provider)) { p.cancel("Setup cancelled."); process.exit(0) }
314
+ remoteProvider = provider
125
315
 
126
- if (createGhRepo) {
316
+ if (remoteProvider !== "none") {
127
317
  const answer = await p.text({
128
- message: "GitHub repo name?",
318
+ message: "Repo name?",
129
319
  placeholder: targetName,
130
320
  defaultValue: targetName,
131
321
  })
132
322
  if (p.isCancel(answer)) { p.cancel("Setup cancelled."); process.exit(0) }
133
- ghRepoName = answer
323
+ repoName = answer
324
+ }
325
+
326
+ if (remoteProvider === "cloudflare") {
327
+ const ns = await p.text({
328
+ message: "Artifacts namespace?",
329
+ placeholder: "default",
330
+ defaultValue: "default",
331
+ })
332
+ if (p.isCancel(ns)) { p.cancel("Setup cancelled."); process.exit(0) }
333
+ cfNamespace = ns
134
334
  }
135
335
  }
136
336
 
@@ -163,7 +363,7 @@ async function main() {
163
363
  // Patch vault files with chosen qmd path
164
364
  spin.start("Configuring qmd index path")
165
365
  await patchVault(targetDir, qmdPath, targetName)
166
- spin.stop(`qmd index → ${pc.dim(qmdPath)}`)
366
+ spin.stop(`qmd index → ${pc.dim(toDisplayPath(qmdPath))}`)
167
367
 
168
368
  // Install mise if not present
169
369
  if (!commandExists("mise")) {
@@ -186,6 +386,11 @@ async function main() {
186
386
  if (pnpmOk) spin.stop("dependencies installed")
187
387
  else spin.stop("pnpm install failed — run it manually inside your vault", 1)
188
388
 
389
+ // Install global skills
390
+ spin.start("Installing global Claude skills")
391
+ await installGlobalSkills(qmdPath)
392
+ spin.stop(`Global skills installed → ${pc.dim(toDisplayPath(join(homedir(), ".claude", "skills")))}`)
393
+
189
394
  // Git init
190
395
  spin.start("Initializing git repo")
191
396
  const gitOk = run(["git", "init"], targetDir)
@@ -198,9 +403,9 @@ async function main() {
198
403
  }
199
404
 
200
405
  // GitHub repo (optional)
201
- if (createGhRepo) {
406
+ if (remoteProvider === "github") {
202
407
  if (!commandExists("gh")) {
203
- p.log.warn(`gh CLI not found — install from https://cli.github.com, then run:\n gh repo create ${ghRepoName} --private --source=. --remote=origin --push`)
408
+ 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
409
  } else {
205
410
  const authCheck = spawnSync("gh", ["auth", "status"], { stdio: "pipe" })
206
411
  let loggedIn = authCheck.status === 0
@@ -211,33 +416,49 @@ async function main() {
211
416
  }
212
417
 
213
418
  if (loggedIn) {
214
- spin.start(`Creating GitHub repo ${pc.dim(ghRepoName)}`)
419
+ spin.start(`Creating GitHub repo ${pc.dim(repoName)}`)
215
420
  const ghOk = run(
216
- ["gh", "repo", "create", ghRepoName, "--private", "--source=.", "--remote=origin", "--push"],
421
+ ["gh", "repo", "create", repoName, "--private", "--source=.", "--remote=origin", "--push"],
217
422
  targetDir
218
423
  )
219
424
  if (ghOk) {
220
- spin.stop(`GitHub repo created (private): ${pc.cyan(ghRepoName)}`)
425
+ spin.stop(`GitHub repo created (private): ${pc.cyan(repoName)}`)
221
426
  } else {
222
- spin.stop(`gh repo create failed — run: gh repo create ${ghRepoName} --private --source=. --remote=origin --push`, 1)
427
+ spin.stop(`gh repo create failed — run: gh repo create ${repoName} --private --source=. --remote=origin --push`, 1)
223
428
  }
224
429
  }
225
430
  }
226
431
  }
227
432
 
228
- // Install global skills
229
- spin.start("Installing global Claude skills")
230
- await installGlobalSkills(qmdPath)
231
- spin.stop("Global skills installed")
433
+ // Cloudflare Artifacts repo (optional)
434
+ if (remoteProvider === "cloudflare") {
435
+ const result = await setupCloudflareRemote({ targetDir, repoName, namespace: cfNamespace, spin })
436
+ if (result?.repoToken) {
437
+ p.log.warn(`Save your Artifacts repo token — it expires and you'll need to mint a new one:\n ${result.repoToken}`)
438
+ }
439
+ }
232
440
 
233
441
  // Next steps
442
+ const remoteSteps = remoteProvider === "cloudflare"
443
+ ? [
444
+ `${pc.dim("# Mint a fresh git push/pull token when the current one expires:")}`,
445
+ `${pc.cyan(`TOKEN=$(wrangler auth token)`)}`,
446
+ `${pc.cyan(`curl -X POST https://artifacts.cloudflare.net/v1/api/namespaces/${cfNamespace}/tokens \\`)}`,
447
+ `${pc.cyan(` -H "Authorization: Bearer $TOKEN" \\`)}`,
448
+ `${pc.cyan(` -H "Content-Type: application/json" \\`)}`,
449
+ `${pc.cyan(` -d '{"repo":"${repoName}","scope":"write","ttl":86400}'`)}`,
450
+ ]
451
+ : remoteProvider === "none"
452
+ ? [
453
+ `${pc.cyan("git remote add origin <url>")} connect to a remote for sync`,
454
+ `${pc.cyan("git push -u origin main")}`,
455
+ ]
456
+ : []
457
+
234
458
  const nextSteps = [
235
459
  `${pc.cyan(`cd ${targetName}`)}`,
236
460
  `${pc.cyan("claude")} open Claude Code, then run ${pc.bold("/setup")}`,
237
- ...(!createGhRepo ? [
238
- `${pc.cyan("git remote add origin <url>")} connect to GitHub for sync`,
239
- `${pc.cyan("git push -u origin main")}`,
240
- ] : []),
461
+ ...remoteSteps,
241
462
  ].join("\n")
242
463
 
243
464
  p.note(nextSteps, "Next steps")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-second-brain",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "The fastest way to start your personal knowledge base powered by Obsidian, Claude Code, qmd, and GitHub.",
5
5
  "type": "module",
6
6
  "bin": {