vibestreak 0.1.2 → 0.3.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 CHANGED
@@ -5,10 +5,10 @@ with AI agents and the commits you shipped with them, ranked on consistency,
5
5
  not spend. Profiles and the leaderboard: https://vibestreak.kingsbridge-consultancy.com
6
6
 
7
7
  ```bash
8
- npx vibestreak login --token PASTE_YOUR_TOKEN # token from Settings → Machines
9
- npx vibestreak emails scan # add every address you commit as
10
- npx vibestreak sync
11
- npx vibestreak autosync on # background syncs + evening streak check
8
+ npx vibestreak # sign in through the browser, sync, offer autosync
9
+ npx vibestreak setup --token PASTE_YOUR_TOKEN # or: everything with the defaults, no questions
10
+ npx vibestreak emails scan # addresses from your other accounts
11
+ npx vibestreak pause 3d # background syncs and the nudge off for a while
12
12
  ```
13
13
 
14
14
  All commands: https://vibestreak.kingsbridge-consultancy.com/cli
@@ -1,21 +1,35 @@
1
1
  #!/usr/bin/env node
2
+ import { createInterface } from 'node:readline/promises'
2
3
  import { fileURLToPath } from 'node:url'
3
4
  import { parseArgs } from 'node:util'
4
5
 
5
6
  import { autosyncStatus, clock, installAutosync, nextRun, removeAutosync, SYNC_TIMES } from '../src/autosync.mjs'
6
7
  import { NUDGE_DEFAULT, notify, nudgeAction, nudgeText, nudgeTimeOf, parseNudgeTime, shouldNudge } from '../src/nudge.mjs'
7
- import { collect, sinceDate, summarize } from '../src/collect.mjs'
8
+ import { collect, sinceDate, summarize, syncSince } from '../src/collect.mjs'
8
9
  import { CONFIG_PATH, loadConfig, saveConfig } from '../src/config.mjs'
9
- import { DEFAULT_ROOTS, globalEmail, scanEmails } from '../src/git.mjs'
10
+ import { configuredEmails, DEFAULT_ROOTS, globalEmail, scanEmails } from '../src/git.mjs'
11
+ import { deviceLogin } from '../src/login.mjs'
12
+ import { parsePause, pausedUntil, untilText } from '../src/pause.mjs'
13
+ import { boardView, dashboardView, profileView, todayView } from '../src/views.mjs'
10
14
 
11
15
  const BRAND = process.env.VIBESTREAK_BRAND ?? 'vibestreak'
12
16
  const DEFAULT_SERVER = process.env.VIBESTREAK_SERVER ?? 'https://vibestreak.kingsbridge-consultancy.com'
13
17
 
14
18
  const HELP = `${BRAND} — share your AI coding heatmap: what you shipped, and how steadily.
15
19
 
20
+ ${BRAND} your streak, heatmap and rank at a glance
21
+ (the first time: sets this machine up)
22
+ ${BRAND} me your profile: streak, heatmap, totals
23
+ ${BRAND} profile <handle> someone else's public profile
24
+ ${BRAND} board [today|7d|30d|90d|all] [consistency|shipped|streak|tokens] [tool]
25
+ the leaderboard (--from/--to for your own dates)
26
+ ${BRAND} today who is coding with AI today; streaks at risk
27
+
16
28
  ${BRAND} collect [--json] preview what would be sent (nothing leaves the machine)
17
- ${BRAND} login --token <t> [--server <url>]
18
- link this machine (token from your settings page)
29
+ ${BRAND} setup [--token <t>] link, sync and turn on autosync and the nudge
30
+ with the defaults, asking nothing
31
+ ${BRAND} login link this machine in the browser
32
+ (or --token <t> from your settings page)
19
33
  ${BRAND} sync [--quiet] collect and upload daily totals
20
34
  ${BRAND} emails [scan|add <e>|remove <e>]
21
35
  the addresses you commit as (all of your accounts)
@@ -26,6 +40,9 @@ const HELP = `${BRAND} — share your AI coding heatmap: what you shipped, and h
26
40
  ${BRAND} nudge [on|off|test] an evening notification when your streak is
27
41
  about to end (on with autosync); alone, its status
28
42
  ${BRAND} nudge at 21:45 when it checks (17:00 to 23:30; default 20:30)
43
+ ${BRAND} pause [1h|3d|tomorrow] pause background syncs and the nudge (up to 7
44
+ days; default until tomorrow 06:00)
45
+ ${BRAND} resume end a pause now
29
46
  ${BRAND} whoami the profile this machine syncs to
30
47
 
31
48
  Reads: Claude Code, Codex, Cursor, Copilot (CLI and VS Code), Gemini CLI,
@@ -39,7 +56,7 @@ All commands, with examples: ${DEFAULT_SERVER}/cli`
39
56
  const fmt = n => n.toLocaleString('en-US')
40
57
 
41
58
  async function api(config, path, init = {}) {
42
- if (!config.server || !config.token) throw new Error(`Not linked yet: run \`${BRAND} login --token <token>\`.`)
59
+ if (!config.server || !config.token) throw new Error(`Not linked yet: run \`${BRAND} login\`.`)
43
60
  const response = await fetch(new URL(path, config.server), {
44
61
  ...init,
45
62
  headers: { authorization: `Bearer ${config.token}`, 'content-type': 'application/json', ...init.headers },
@@ -58,14 +75,47 @@ async function main() {
58
75
  token: { type: 'string' },
59
76
  server: { type: 'string' },
60
77
  since: { type: 'string' },
78
+ full: { type: 'boolean' },
61
79
  help: { type: 'boolean', short: 'h' },
80
+ from: { type: 'string' },
81
+ to: { type: 'string' },
82
+ limit: { type: 'string' },
83
+ // Set by the background jobs, so a pause skips them and nothing else.
84
+ scheduled: { type: 'boolean' },
85
+ yes: { type: 'boolean', short: 'y' },
62
86
  },
63
87
  })
64
- const [command = 'help', ...rest] = positionals
88
+ const [command = 'dashboard', ...rest] = positionals
65
89
  const config = await loadConfig()
66
90
  const log = (...args) => values.quiet || console.log(...args)
67
91
 
68
92
  if (command === 'help' || values.help) return console.log(HELP)
93
+ const columns = process.stdout.columns || 100
94
+ const server = config.server ?? DEFAULT_SERVER
95
+
96
+ if (command === 'dashboard') {
97
+ // Plain `vibestreak`: the dashboard once linked; before that, setup (or,
98
+ // with no one at the keyboard, how to start).
99
+ if (!config.token) {
100
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return console.log(`${HELP}\n\nGet started: ${server.replace(/\/$/, '')}/cli`)
101
+ return setup(config, values, { ask: !values.yes })
102
+ }
103
+ const me = await api(config, '/api/v1/me')
104
+ return dashboardView({ server, token: config.token, handle: me.handle, columns })
105
+ }
106
+ if (command === 'me') {
107
+ const me = await api(config, '/api/v1/me')
108
+ return profileView({ server, token: config.token, handle: me.handle, columns })
109
+ }
110
+ if (command === 'profile') {
111
+ if (!rest[0]) throw new Error(`Whose? \`${BRAND} profile <handle>\` (yours: \`${BRAND} me\`).`)
112
+ return profileView({ server, token: config.token, handle: rest[0].replace(/^@/, ''), columns })
113
+ }
114
+ if (command === 'board') {
115
+ const you = config.token ? (await api(config, '/api/v1/me').catch(() => null))?.handle : undefined
116
+ return boardView({ server, words: rest, values, you, columns })
117
+ }
118
+ if (command === 'today') return todayView({ server })
69
119
 
70
120
  if (command === 'emails') {
71
121
  const [action, email] = rest
@@ -96,19 +146,25 @@ async function main() {
96
146
  return console.log((config.roots ?? DEFAULT_ROOTS).join('\n'))
97
147
  }
98
148
 
99
- if (command === 'login') {
100
- if (!values.token) return console.log(`Get a token on ${values.server ?? config.server ?? DEFAULT_SERVER}/settings, then:\n ${BRAND} login --token <token>`)
101
- config.server = values.server ?? config.server ?? DEFAULT_SERVER
102
- config.token = values.token
103
- const me = await api(config, '/api/v1/me')
104
- if (!config.emails.length) {
105
- const first = await globalEmail()
106
- if (first) config.emails = [first]
107
- }
149
+ if (command === 'login') return login(config, values)
150
+ if (command === 'setup') return setup(config, values, { ask: false })
151
+
152
+ if (command === 'pause') {
153
+ const until = parsePause(rest[0])
154
+ config.pausedUntil = until.toISOString()
108
155
  await saveConfig(config)
109
- console.log(`Linked ${config.machineName} to ${me.handle} (${me.profileUrl}).`)
110
- console.log(`Commit addresses: ${config.emails.join(', ') || 'none'}; run \`${BRAND} emails scan\` to add your other accounts.`)
111
- return
156
+ await refreshJobs(config)
157
+ return console.log(
158
+ `Paused until ${untilText(until)}: no background syncs or streak nudge until then.` +
159
+ ` The first sync after it uploads everything you did meanwhile. \`${BRAND} resume\` ends it sooner.`,
160
+ )
161
+ }
162
+ if (command === 'resume') {
163
+ const was = pausedUntil(config)
164
+ delete config.pausedUntil
165
+ await saveConfig(config)
166
+ await refreshJobs(config)
167
+ return console.log(was ? `Resumed: background syncs${config.nudge === false ? '' : ' and the nudge'} run again from the next scheduled time.` : 'Not paused.')
112
168
  }
113
169
 
114
170
  if (command === 'whoami') {
@@ -116,7 +172,11 @@ async function main() {
116
172
  return console.log(`${me.handle} · ${me.profileUrl}\nGitHub: ${me.github.join(', ')}\nMachine: ${config.machineName}`)
117
173
  }
118
174
 
119
- if (command === 'collect' || command === 'sync') return sync(config, values, log, command === 'sync')
175
+ if (command === 'collect' || command === 'sync') {
176
+ const until = values.scheduled && pausedUntil(config)
177
+ if (until) return console.log(`${new Date().toISOString()} paused until ${until.toISOString()}; skipped`)
178
+ return sync(config, values, log, command === 'sync')
179
+ }
120
180
 
121
181
  if (command === 'status') return status(config)
122
182
 
@@ -146,6 +206,8 @@ async function main() {
146
206
  return console.log(`Sent a test notification (your streak: ${streak.current} days; ${streak.activeToday ? 'active today' : 'not active yet today'}).`)
147
207
  }
148
208
  // `nudge run`, the evening job: sync, then nudge if the streak would end at midnight.
209
+ const until = pausedUntil(config)
210
+ if (until) return console.log(`${new Date().toISOString()} paused until ${until.toISOString()}; skipped`)
149
211
  const result = await sync(config, values, log, true)
150
212
  if (shouldNudge(result.streak, config)) {
151
213
  await notify(nudgeText(result.streak))
@@ -176,6 +238,63 @@ async function main() {
176
238
  process.exitCode = 1
177
239
  }
178
240
 
241
+ // Links this machine: in the browser by default, or with a token from the
242
+ // settings page (for machines without one).
243
+ async function login(config, values, { addresses = true } = {}) {
244
+ config.server = values.server ?? config.server ?? DEFAULT_SERVER
245
+ if (values.token) config.token = values.token
246
+ else config.token = (await deviceLogin({ server: config.server, machine: config.machineName })).token
247
+ const me = await api(config, '/api/v1/me')
248
+ if (!config.emails.length) {
249
+ const first = await globalEmail()
250
+ if (first) config.emails = [first]
251
+ }
252
+ await saveConfig(config)
253
+ console.log(`Linked ${config.machineName} to @${me.handle} (${me.profileUrl}).`)
254
+ if (addresses) console.log(`Commit addresses: ${config.emails.join(', ') || 'none'}; \`${BRAND} emails scan\` finds your other accounts' addresses.`)
255
+ return me
256
+ }
257
+
258
+ // Everything a machine needs, with the defaults: linked (in the browser, or
259
+ // with --token), the commit addresses from git config, the usual repo
260
+ // folders, a first sync, and autosync with the evening nudge. The first plain
261
+ // `npx vibestreak` asks about autosync; `setup` asks nothing.
262
+ async function setup(config, values, { ask: interactive }) {
263
+ console.log(`${BRAND}: your AI coding heatmap and streak, from this machine's history.\n`)
264
+ console.log('Nothing but daily counts leaves this machine: never prompts, code, file paths or repo names.')
265
+ console.log(`(\`${BRAND} collect\` previews it all without sending anything.)\n`)
266
+ await login(config, values, { addresses: false })
267
+ const found = await configuredEmails(config.roots ?? DEFAULT_ROOTS)
268
+ config.emails = [...new Set([...config.emails, ...found])]
269
+ await saveConfig(config)
270
+ console.log(`Counting commits by ${config.emails.join(', ') || 'no address yet'} (from git config) and your GitHub accounts' noreply addresses.`)
271
+ console.log('\nFirst sync: reading your history (a minute for a big one)…')
272
+ await sync(config, values, console.log, true)
273
+ const yes = !interactive || (await ask(`\nKeep it up to date in the background (${SYNC_TIMES.map(clock).join(', ')}) and nudge you at ${clock(nudgeTimeOf(config))} when your streak is about to end? [Y/n] `))
274
+ if (yes) {
275
+ const result = await installAutosync(fileURLToPath(import.meta.url), { nudge: config.nudge !== false, nudgeTime: nudgeTimeOf(config) })
276
+ config.autosync = result.installed
277
+ await saveConfig(config)
278
+ console.log(result.installed ? `On: syncing via ${result.using}. \`${BRAND} pause\` or \`${BRAND} autosync off\` any time.` : result.hint)
279
+ } else console.log(`Off. \`${BRAND} sync\` updates it, or \`${BRAND} autosync on\` later.`)
280
+ console.log(`\nDone. \`${BRAND}\` shows your streak. Commit from other addresses too? \`${BRAND} emails scan\` lists them. \`${BRAND} help\` lists everything.`)
281
+ }
282
+
283
+ async function ask(question) {
284
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
285
+ try {
286
+ return !/^n/i.test((await rl.question(question)).trim())
287
+ } finally {
288
+ rl.close()
289
+ }
290
+ }
291
+
292
+ // The background jobs run a copy of the CLI kept in the config folder: a
293
+ // pause or resume refreshes it, so the jobs run code that knows about pauses.
294
+ async function refreshJobs(config) {
295
+ if (config.autosync) await installAutosync(fileURLToPath(import.meta.url), { nudge: config.nudge !== false, nudgeTime: nudgeTimeOf(config) })
296
+ }
297
+
179
298
  // Collects this machine's totals and, for a sync, uploads them; the server
180
299
  // answers with where the streak stands (the nudge's input).
181
300
  async function sync(config, values, log, isSync) {
@@ -186,7 +305,7 @@ async function sync(config, values, log, isSync) {
186
305
  if (first) emails.push(first)
187
306
  }
188
307
  const started = Date.now()
189
- const payload = await collect({ config: { ...config, emails }, salt: me?.salt ?? config.localSalt, since: values.since })
308
+ const payload = await collect({ config: { ...config, emails }, salt: me?.salt ?? config.localSalt, since: isSync ? syncSince(config, values) : values.since })
190
309
  if (values.json) return console.log(JSON.stringify(payload, null, 2))
191
310
  const s = summarize(payload)
192
311
  log(`${s.activeDays} active days since ${payload.since} (${((Date.now() - started) / 1000).toFixed(1)}s)`)
@@ -197,7 +316,9 @@ async function sync(config, values, log, isSync) {
197
316
  if (!isSync) return log(`\nNothing was sent. \`${BRAND} sync\` uploads these totals.`)
198
317
  const result = await api(config, '/api/v1/upload', { method: 'POST', body: JSON.stringify(payload) })
199
318
  config.lastSync = new Date().toISOString()
319
+ config.lastSyncServer = config.server
200
320
  config.lastSyncSummary = { days: result.days, commits: result.commits, repos: s.repos }
321
+ if (result.underReview) console.log(`Your profile is under review and off the boards for now: see ${new URL('/settings#trust', config.server)}`)
201
322
  await saveConfig(config)
202
323
  // Always one line, quiet or not: the background sync's log is how a person sees it ran.
203
324
  const streak = result.streak ? ` · streak ${result.streak.current}d${result.streak.activeToday ? '' : ' (not yet today)'}` : ''
@@ -214,7 +335,7 @@ const at = ({ at, day }) => `${day} ${clock([at.getHours(), at.getMinutes()])}`
214
335
 
215
336
  // The server's view of the streak, or why it can't be had (not linked, offline).
216
337
  async function serverStatus(config) {
217
- if (!config.server || !config.token) return { error: `not linked: run \`${BRAND} login --token PASTE_YOUR_TOKEN\`` }
338
+ if (!config.server || !config.token) return { error: `not linked: run \`${BRAND} login\`` }
218
339
  try {
219
340
  return await api(config, '/api/v1/me')
220
341
  } catch (error) {
@@ -238,7 +359,9 @@ async function nudgeStatus(config) {
238
359
  console.log(`Last nudge ${config.lastNudge ?? 'never'}`)
239
360
  if (me.error) return console.log(`Tonight unknown (${me.error})`)
240
361
  const s = me.streak
241
- const tonight = !on ? 'no check' : s.activeToday ? 'no nudge: you were active today' : s.current > 0 ? (config.lastNudge === s.today ? 'already nudged today' : `will nudge at ${time} unless you code with AI first`) : 'no nudge: no live streak'
362
+ const paused = pausedUntil(config)
363
+ if (paused) console.log(`Paused until ${untilText(paused)}`)
364
+ const tonight = !on ? 'no check' : paused && paused > nextRun([nudgeTimeOf(config)]).at ? 'no check: paused' : s.activeToday ? 'no nudge: you were active today' : s.current > 0 ? (config.lastNudge === s.today ? 'already nudged today' : `will nudge at ${time} unless you code with AI first`) : 'no nudge: no live streak'
242
365
  console.log(`Streak ${streakLine(s)}`)
243
366
  console.log(`Tonight ${tonight}`)
244
367
  }
@@ -253,6 +376,8 @@ async function status(config) {
253
376
  row('Last sync', config.lastSync ? `${ago(config.lastSync)}${last ? ` · ${last.days} days, ${last.commits} commits from ${last.repos} repos` : ''}` : `never: run \`${BRAND} sync\``)
254
377
  if (!me.error) row('Streak', streakLine(me.streak))
255
378
  row('Autosync', scheduled.on ? `on (${scheduled.using}) · next sync ${at(nextRun(SYNC_TIMES))}` : `off: \`${BRAND} autosync on\` syncs at ${SYNC_TIMES.map(clock).join(', ')}`)
379
+ const paused = pausedUntil(config)
380
+ if (paused) row('Paused', `until ${untilText(paused)}: background syncs and the nudge skip their runs (\`${BRAND} resume\` ends it)`)
256
381
  const nudgeOn = config.nudge !== false && scheduled.nudge
257
382
  row('Nudge', nudgeOn ? `on · checks at ${clock(nudgeTimeOf(config))} · last nudged ${config.lastNudge ?? 'never'}` : config.nudge === false ? 'off' : 'off (needs autosync)')
258
383
  const github = me.error ? 0 : (me.emails?.length ?? 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibestreak",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Your AI coding streak: a heatmap of the days you coded with Claude Code, Codex, Cursor, Copilot, Gemini and OpenCode, and the commits you shipped with them. Sends daily totals only.",
5
5
  "keywords": ["claude-code", "codex", "cursor", "copilot", "gemini", "opencode", "ai-coding", "heatmap", "streak", "leaderboard", "cli"],
6
6
  "homepage": "https://vibestreak.kingsbridge-consultancy.com/cli",
package/src/autosync.mjs CHANGED
@@ -26,8 +26,8 @@ const logPath = () => join(configDir(), 'sync.log')
26
26
  // The jobs to schedule: a sync at each sync time, and the evening nudge at
27
27
  // the person's chosen time.
28
28
  export const jobs = (nudge, nudgeTime = NUDGE_DEFAULT) => [
29
- { name: 'sync', args: ['sync', '--quiet'], times: SYNC_TIMES },
30
- ...(nudge ? [{ name: 'nudge', args: ['nudge', 'run', '--quiet'], times: [nudgeTime] }] : []),
29
+ { name: 'sync', args: ['sync', '--quiet', '--scheduled'], times: SYNC_TIMES },
30
+ ...(nudge ? [{ name: 'nudge', args: ['nudge', 'run', '--quiet', '--scheduled'], times: [nudgeTime] }] : []),
31
31
  ]
32
32
 
33
33
  // A CLI run through npx lives in npm's cache, which can be cleared; a
package/src/claude.mjs CHANGED
@@ -38,7 +38,7 @@ export async function collectClaude({ root = join(homedir(), '.claude', 'project
38
38
  return days.get(date)
39
39
  }
40
40
 
41
- for await (const path of walk(root, '.jsonl')) {
41
+ for await (const path of walk(root, '.jsonl', since)) {
42
42
  for await (const line of lines(path)) {
43
43
  const role = ROLE.exec(line)?.[1]
44
44
  if (!role) continue
package/src/codex.mjs CHANGED
@@ -24,7 +24,7 @@ export async function collectCodex({ root = join(homedir(), '.codex'), since } =
24
24
  const seenSessions = new Set()
25
25
 
26
26
  for (const folder of ['sessions', 'archived_sessions']) {
27
- for await (const path of walk(join(root, folder), '.jsonl')) {
27
+ for await (const path of walk(join(root, folder), '.jsonl', since)) {
28
28
  let session
29
29
  let model
30
30
  let last = { input: 0, cached: 0, output: 0 }
package/src/collect.mjs CHANGED
@@ -9,8 +9,19 @@ import { collectOpenCode } from './opencode.mjs'
9
9
 
10
10
  export const PAYLOAD_VERSION = 1
11
11
 
12
- // How far back a sync reaches: a year and a bit, the most a heatmap shows.
13
- export const sinceDate = (days = 400) => dayKey(new Date(Date.now() - days * 864e5))
12
+ // How far back a sync reaches: a machine's first sync sends a year and a
13
+ // bit (the most a heatmap shows); after that the server takes only the last
14
+ // week from it (its history lock), so later syncs send just that.
15
+ export const FULL_DAYS = 400
16
+ export const RECENT_DAYS = 7
17
+ export const sinceDate = (days = FULL_DAYS) => dayKey(new Date(Date.now() - days * 864e5))
18
+
19
+ // Where this machine's next sync starts: a full history the first time (or
20
+ // on --full, or after linking to another server), the last week after that.
21
+ export function syncSince(config, { full = false, since } = {}) {
22
+ if (since) return since
23
+ return full || !config.lastSync || config.lastSyncServer !== config.server ? sinceDate(FULL_DAYS) : sinceDate(RECENT_DAYS)
24
+ }
14
25
 
15
26
  // Everything one machine sends: per-day totals per tool, and its commits as
16
27
  // keyed hashes. No prompt, code, path, repo name or commit message is read
package/src/files.mjs CHANGED
@@ -1,8 +1,10 @@
1
- import { open, readdir } from 'node:fs/promises'
1
+ import { open, readdir, stat } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
 
4
- // Every file under `root` whose name ends in `suffix`, symbolic links not followed.
5
- export async function* walk(root, suffix) {
4
+ // Every file under `root` whose name ends in `suffix`, symbolic links not
5
+ // followed; with `since` (YYYY-MM-DD), only files changed on or after that
6
+ // day (a file untouched since can hold nothing newer).
7
+ export async function* walk(root, suffix, since) {
6
8
  let entries
7
9
  try {
8
10
  entries = await readdir(root, { withFileTypes: true })
@@ -11,11 +13,19 @@ export async function* walk(root, suffix) {
11
13
  }
12
14
  for (const entry of entries) {
13
15
  const path = join(root, entry.name)
14
- if (entry.isDirectory()) yield* walk(path, suffix)
15
- else if (entry.isFile() && entry.name.endsWith(suffix)) yield path
16
+ if (entry.isDirectory()) yield* walk(path, suffix, since)
17
+ else if (entry.isFile() && entry.name.endsWith(suffix)) {
18
+ if (since && (await stat(path).catch(() => undefined))?.mtime < localMidnight(since)) continue
19
+ yield path
20
+ }
16
21
  }
17
22
  }
18
23
 
24
+ const localMidnight = day => {
25
+ const [y, m, d] = day.split('-').map(Number)
26
+ return new Date(y, m - 1, d)
27
+ }
28
+
19
29
  const CHUNK = 4 * 1024 * 1024
20
30
  const NEWLINE = 10
21
31
 
package/src/git.mjs CHANGED
@@ -136,6 +136,21 @@ export async function scanEmails({ roots, since }) {
136
136
  return [...counts].sort((a, b) => b[1] - a[1])
137
137
  }
138
138
 
139
+ // The addresses this person commits as, by their own git config: the global
140
+ // one, and any a repo sets for itself (a work address in work repos). Other
141
+ // people's addresses in the same repos are never picked up this way.
142
+ export async function configuredEmails(roots = DEFAULT_ROOTS) {
143
+ const local = await Promise.all(
144
+ (await findRepos(roots)).map(repo =>
145
+ run('git', ['-C', repo, 'config', '--local', '--get', 'user.email']).then(
146
+ r => r.stdout.trim(),
147
+ () => '',
148
+ ),
149
+ ),
150
+ )
151
+ return [...new Set([await globalEmail(), ...local].filter(e => e && e.includes('@')))]
152
+ }
153
+
139
154
  export async function globalEmail() {
140
155
  try {
141
156
  return (await run('git', ['config', '--global', 'user.email'])).stdout.trim() || undefined
package/src/google.mjs CHANGED
@@ -27,7 +27,7 @@ const dayMap = tool => {
27
27
  // tool calls and tokens (`input` includes the cached part, split out here).
28
28
  export async function collectGeminiCli({ root = join(GEMINI(), 'tmp'), since } = {}) {
29
29
  const { day, list } = dayMap('gemini')
30
- for await (const path of walk(root, '.json')) {
30
+ for await (const path of walk(root, '.json', since)) {
31
31
  if (!/[/\\]chats[/\\]/.test(path)) continue
32
32
  let chat
33
33
  try {
@@ -74,7 +74,7 @@ const seconds = buffer => {
74
74
 
75
75
  export async function collectAntigravity({ root = join(GEMINI(), 'antigravity', 'conversations'), since } = {}) {
76
76
  const { day, list } = dayMap('antigravity')
77
- for await (const path of walk(root, '.db')) {
77
+ for await (const path of walk(root, '.db', since)) {
78
78
  const id = path
79
79
  const steps = await query(path, 'SELECT step_type AS type, hex(metadata) AS meta FROM steps')
80
80
  let lastDate
package/src/login.mjs ADDED
@@ -0,0 +1,45 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ // Opens a page in the default browser; false when there's no way to (a
4
+ // server, a container), and the person opens the printed link themselves.
5
+ export function openBrowser(url, platform = process.platform) {
6
+ const [command, args] =
7
+ platform === 'darwin' ? ['open', [url]] : platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]] : ['xdg-open', [url]]
8
+ return new Promise(resolve => {
9
+ try {
10
+ const child = spawn(command, args, { stdio: 'ignore', detached: true, windowsHide: true })
11
+ child.on('error', () => resolve(false))
12
+ child.on('spawn', () => {
13
+ child.unref()
14
+ resolve(true)
15
+ })
16
+ } catch {
17
+ resolve(false)
18
+ }
19
+ })
20
+ }
21
+
22
+ const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
23
+
24
+ // Browser sign-in: the server hands out a code, the person approves it on
25
+ // the site (signing in with GitHub first if need be), and the token comes back
26
+ // to the secret only this process holds. Nothing to copy or paste.
27
+ export async function deviceLogin({ server, machine, log = console.log, open = openBrowser, fetchImpl = fetch, sleep = pause }) {
28
+ const post = (path, body) => fetchImpl(new URL(path, server), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
29
+ const started = await post('/api/v1/device', { machine })
30
+ if (!started.ok) throw new Error(`Couldn't start sign-in (${started.status}); try again, or use a token from ${new URL('/settings', server)}.`)
31
+ const { code, secret, verifyUrl, interval = 2, expiresIn = 600 } = await started.json()
32
+ const opened = await open(verifyUrl)
33
+ log(`${opened ? 'Opening your browser to link this machine' : 'Open this page to link this machine'}: ${verifyUrl}`)
34
+ log(`Check it shows ${code}, then approve. Waiting…`)
35
+ const deadline = Date.now() + expiresIn * 1000
36
+ while (Date.now() < deadline) {
37
+ await sleep(interval * 1000)
38
+ const response = await post('/api/v1/device/token', { secret }).catch(() => null)
39
+ if (!response || response.status === 202 || response.status === 429 || response.status >= 500) continue
40
+ const body = await response.json().catch(() => ({}))
41
+ if (response.ok && body.token) return body
42
+ throw new Error(body.error ?? `Sign-in failed (${response.status}).`)
43
+ }
44
+ throw new Error('Sign-in timed out; run login again.')
45
+ }
package/src/opencode.mjs CHANGED
@@ -73,7 +73,7 @@ export async function collectOpenCode({ dir = OPENCODE_DIR(), since } = {}) {
73
73
  }
74
74
  return n
75
75
  }
76
- for await (const path of walk(join(storage, 'message'), '.json')) {
76
+ for await (const path of walk(join(storage, 'message'), '.json', since)) {
77
77
  let m
78
78
  try {
79
79
  m = JSON.parse(await readFile(path, 'utf8'))
package/src/pause.mjs ADDED
@@ -0,0 +1,40 @@
1
+ // Pausing the background jobs: the scheduled syncs and the evening nudge skip
2
+ // their runs until a set time, then carry on by themselves. A pause is held
3
+ // to 7 days, the window a sync catches up afterwards (older days can't be
4
+ // changed), so nothing done during it is lost: it is uploaded on resume.
5
+ export const MAX_PAUSE_DAYS = 7
6
+ export const MORNING = 6
7
+
8
+ // `1h`…`168h`, `1d`…`7d`, or `tomorrow` (the default: until 06:00 tomorrow,
9
+ // before the first sync of the day).
10
+ export function parsePause(word = 'tomorrow', from = new Date()) {
11
+ const w = String(word).trim().toLowerCase()
12
+ if (w === 'tomorrow' || w === 'today') {
13
+ const until = new Date(from)
14
+ until.setDate(until.getDate() + 1)
15
+ until.setHours(MORNING, 0, 0, 0)
16
+ return until
17
+ }
18
+ const match = /^(\d+)\s*(h|hours?|d|days?)$/.exec(w)
19
+ if (!match) throw new Error(`Pause for how long? \`1h\`, \`3d\` or \`tomorrow\` (up to ${MAX_PAUSE_DAYS} days).`)
20
+ const hours = Number(match[1]) * (match[2].startsWith('d') ? 24 : 1)
21
+ if (hours < 1 || hours > MAX_PAUSE_DAYS * 24) {
22
+ throw new Error(`A pause lasts 1 hour to ${MAX_PAUSE_DAYS} days: a sync afterwards catches up only the last ${MAX_PAUSE_DAYS}. For longer, \`autosync off\`.`)
23
+ }
24
+ return new Date(from.getTime() + hours * 3600e3)
25
+ }
26
+
27
+ // The time background runs resume, while a pause lasts.
28
+ export const pausedUntil = (config, at = new Date()) => {
29
+ const until = config.pausedUntil ? new Date(config.pausedUntil) : undefined
30
+ return until && until > at ? until : undefined
31
+ }
32
+
33
+ export const untilText = (until, at = new Date()) => {
34
+ const time = until.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })
35
+ const sameDay = until.toDateString() === at.toDateString()
36
+ const tomorrow = new Date(at)
37
+ tomorrow.setDate(tomorrow.getDate() + 1)
38
+ const day = sameDay ? 'today' : until.toDateString() === tomorrow.toDateString() ? 'tomorrow' : until.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
39
+ return `${day} ${time}`
40
+ }
package/src/view.mjs ADDED
@@ -0,0 +1,138 @@
1
+ import { dayKey } from './days.mjs'
2
+
3
+ // Drawing in the terminal: the site's blue heatmap in coloured squares, its
4
+ // orange for work shipped with AI, plain characters where colour is off.
5
+
6
+ const BLUES = ['#0d3a63', '#1a5fa3', '#2f81f7', '#79c0ff']
7
+ const EMPTY = '#2a323d'
8
+ const SHIP = '#f2994a'
9
+ const MUTED = '#8b949e'
10
+
11
+ // How much colour the terminal takes: none (NO_COLOR, piped output, a dumb
12
+ // terminal), 256 colours, or full colour (COLORTERM says so; Windows Terminal).
13
+ export function colorLevel(stream = process.stdout, env = process.env) {
14
+ if ('NO_COLOR' in env) return 0
15
+ if (env.FORCE_COLOR === '0') return 0
16
+ if (env.FORCE_COLOR) return env.FORCE_COLOR === '1' ? 2 : 3
17
+ if (!stream.isTTY || env.TERM === 'dumb') return 0
18
+ if (/truecolor|24bit/i.test(env.COLORTERM ?? '') || env.WT_SESSION) return 3
19
+ return 2
20
+ }
21
+
22
+ const rgb = hex => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16))
23
+ const to256 = ([r, g, b]) => 16 + 36 * Math.round((r / 255) * 5) + 6 * Math.round((g / 255) * 5) + Math.round((b / 255) * 5)
24
+
25
+ export function painter(level = colorLevel()) {
26
+ const fg = hex => (level === 3 ? `\x1b[38;2;${rgb(hex).join(';')}m` : level === 2 ? `\x1b[38;5;${to256(rgb(hex))}m` : '')
27
+ const wrap = (open, text) => (level ? `${open}${text}\x1b[0m` : text)
28
+ return {
29
+ level,
30
+ color: (hex, text) => wrap(fg(hex), text),
31
+ bold: text => wrap('\x1b[1m', text),
32
+ dim: text => wrap(fg(MUTED), text),
33
+ ship: text => wrap(fg(SHIP), text),
34
+ }
35
+ }
36
+
37
+ // One day's cell: a coloured square by shade (with an orange dot after it
38
+ // when something shipped with AI), or plain characters without colour.
39
+ const PLAIN = ['░', '▒', '▓', '█']
40
+ export function cell(p, shade, shipped) {
41
+ if (shade === null) return ' '
42
+ const mark = shipped ? (p.level ? p.ship('•') : '*') : ' '
43
+ if (p.level === 0) return (shade < 0 ? '·' : PLAIN[shade]) + mark
44
+ return p.color(shade < 0 ? EMPTY : BLUES[shade], '■') + mark
45
+ }
46
+
47
+ const value = d => (d ? (d.messages ?? 0) + (d.aiCommits ?? 0) * 20 : 0)
48
+ const active = d => !!d && ((d.messages ?? 0) > 0 || (d.aiCommits ?? 0) > 0)
49
+
50
+ // Shades by quartile of the person's own active days, as on the site.
51
+ export function shader(days, from, to) {
52
+ const sorted = Object.entries(days)
53
+ .filter(([date, d]) => date >= from && date <= to && active(d))
54
+ .map(([, d]) => value(d))
55
+ .sort((a, b) => a - b)
56
+ const at = q => sorted[Math.floor(q * (sorted.length - 1))] ?? 0
57
+ const cuts = [at(0.25), at(0.5), at(0.75)]
58
+ return d => (!active(d) ? -1 : value(d) <= cuts[0] ? 0 : value(d) <= cuts[1] ? 1 : value(d) <= cuts[2] ? 2 : 3)
59
+ }
60
+
61
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
62
+ const addDays = (key, n) => {
63
+ const [y, m, d] = key.split('-').map(Number)
64
+ return dayKey(new Date(y, m - 1, d + n))
65
+ }
66
+ const weekday = key => {
67
+ const [y, m, d] = key.split('-').map(Number)
68
+ return new Date(y, m - 1, d).getDay()
69
+ }
70
+
71
+ // The heatmap: Sunday-first columns from the first active day (or as many
72
+ // weeks as fit), month names above, Mon/Wed/Fri down the side.
73
+ export function heatmap(p, days, today, { columns = 80, first } = {}) {
74
+ const maxWeeks = Math.max(4, Math.floor((columns - 5) / 2))
75
+ const earliest = addDays(today, -(maxWeeks * 7 - 1) - weekday(today))
76
+ const from = first && first > earliest ? first : earliest
77
+ const start = addDays(from, -weekday(from))
78
+ const weeks = []
79
+ for (let day = start; day <= today; day = addDays(day, 7)) weeks.push(Array.from({ length: 7 }, (_, i) => addDays(day, i)))
80
+ const shade = shader(days, from, today)
81
+ // A month's name sits over its first week. When it would collide with the
82
+ // previous name, the previous one (a month shown for a week at most) gives way.
83
+ const labels = []
84
+ let lastMonth = -1
85
+ weeks.forEach((week, w) => {
86
+ const shown = week.find(d => d >= from) ?? week[0]
87
+ const month = Number(shown.slice(5, 7)) - 1
88
+ if (month !== lastMonth && w <= weeks.length - 2) {
89
+ const previous = labels.at(-1)
90
+ if (previous && w - previous.w < 2) labels.pop()
91
+ labels.push({ w, name: MONTHS[month] })
92
+ }
93
+ lastMonth = month
94
+ })
95
+ let header = ' '
96
+ for (const { w, name } of labels) if (header.length <= 5 + w * 2) header = header.padEnd(5 + w * 2) + name
97
+ const rows = ['', 'Mon', '', 'Wed', '', 'Fri', ''].map((label, d) => {
98
+ const cells = weeks.map(week => {
99
+ const date = week[d]
100
+ if (date < from || date > today) return ' '
101
+ const day = days[date]
102
+ return cell(p, shade(day), (day?.aiCommits ?? 0) > 0)
103
+ })
104
+ return `${p.dim(label.padEnd(4))} ${cells.join('')}`
105
+ })
106
+ const legend = ` ${p.dim('less')} ${[-1, 0, 1, 2, 3].map(s => cell(p, s, false)).join('')}${p.dim('more')} ${p.level ? p.ship('•') : '*'} ${p.dim('shipped with AI')}`
107
+ return [p.dim(header), ...rows, legend].join('\n')
108
+ }
109
+
110
+ // The last four weeks in one line, from a board row's 12-week strip.
111
+ export function recent(p, strip, length = 28) {
112
+ return strip
113
+ .flat()
114
+ .filter(s => s !== -2)
115
+ .slice(-length)
116
+ .map(s => cell(p, s, false).trimEnd() || ' ')
117
+ .join('')
118
+ }
119
+
120
+ export const compact = n =>
121
+ n >= 1e12 ? `${(n / 1e12).toFixed(1)}T` : n >= 1e9 ? `${(n / 1e9).toFixed(1)}B` : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e4 ? `${(n / 1e3).toFixed(1)}K` : n.toLocaleString('en-US')
122
+
123
+ export const TOOL_NAMES = {
124
+ claude: 'Claude Code',
125
+ codex: 'Codex',
126
+ cursor: 'Cursor',
127
+ copilot: 'Copilot',
128
+ gemini: 'Gemini CLI',
129
+ antigravity: 'Antigravity',
130
+ opencode: 'OpenCode',
131
+ }
132
+
133
+ // Pads text that may hold colour codes to a visible width.
134
+ export const visible = text => text.replace(/\x1b\[[0-9;]*m/g, '')
135
+ export const pad = (text, width, right = false) => {
136
+ const gap = ' '.repeat(Math.max(0, width - [...visible(text)].length))
137
+ return right ? gap + text : text + gap
138
+ }
package/src/views.mjs ADDED
@@ -0,0 +1,130 @@
1
+ import { compact, heatmap, pad, painter, recent, TOOL_NAMES } from './view.mjs'
2
+
3
+ // The read-only commands: your profile and others', the board, today, and
4
+ // the dashboard plain `vibestreak` shows. Each fetches the public API (with
5
+ // the machine's token where it has one, so a hidden profile's owner still
6
+ // sees it) and draws for the terminal's width.
7
+
8
+ const fmt = n => Number(n ?? 0).toLocaleString('en-US')
9
+ const plural = (n, one, many = `${one}s`) => `${fmt(n)} ${n === 1 ? one : many}`
10
+ const toolList = tools => (tools ?? []).map(t => TOOL_NAMES[t] ?? t).join(', ')
11
+
12
+ const PERIODS = ['today', '7d', '30d', '90d', 'all']
13
+ const METRICS = ['consistency', 'shipped', 'streak', 'tokens']
14
+ const TOOLS = Object.keys(TOOL_NAMES)
15
+
16
+ async function getJson(server, path, token) {
17
+ const response = await fetch(new URL(path, server), { headers: token ? { authorization: `Bearer ${token}` } : {} })
18
+ const body = await response.json().catch(() => ({}))
19
+ if (response.status === 404) throw new Error('No such public profile.')
20
+ if (!response.ok) throw new Error(body.error ?? `${response.status} ${response.statusText}`)
21
+ return body
22
+ }
23
+
24
+ function streakLine(p, profile) {
25
+ const s = profile.summary.streak
26
+ const today = profile.activeToday ? p.color('#3fb950', '✓ active today') : s.current > 0 ? p.ship('streak ends at midnight') : p.dim('no live streak')
27
+ const level = profile.level ? `${p.color('#2f81f7', '◆')} ${profile.level}` : ''
28
+ const next = profile.next ? p.dim(` · ${plural(profile.next.remaining, 'day')} to ${profile.next.name}`) : ''
29
+ return `${p.bold(`${fmt(s.current)}-day streak`)} ${p.dim(`(best ${fmt(s.longest)})`)} ${today} ${level}${next}`
30
+ }
31
+
32
+ // `vibestreak me` and `vibestreak profile <handle>`.
33
+ export async function profileView({ server, token, handle, columns }) {
34
+ const p = painter()
35
+ const profile = await getJson(server, `/api/v1/users/${encodeURIComponent(handle)}.json`, token)
36
+ const s = profile.summary
37
+ const out = [
38
+ `${p.bold(`@${profile.handle}`)} ${p.dim(toolList(profile.tools))}`,
39
+ streakLine(p, profile),
40
+ '',
41
+ heatmap(p, profile.days, profile.today, { columns, first: s.first }),
42
+ '',
43
+ `${p.bold(fmt(s.activeDays))} active days${s.first ? p.dim(` since ${s.first}`) : ''} ${p.ship(fmt(s.aiCommits))} commits shipped with AI ${p.dim(`on ${fmt(s.shippedDays)} days`)}`,
44
+ `${fmt(s.messages)} messages · ${fmt(s.toolCalls)} tool calls · ${compact(s.input + s.output + s.cacheRead + s.cacheWrite)} tokens${s.busiest ? p.dim(` busiest day ${s.busiest.date}, ${fmt(s.busiest.messages)} messages`) : ''}`,
45
+ p.dim(profile.profileUrl),
46
+ ]
47
+ console.log(out.join('\n'))
48
+ }
49
+
50
+ // Board options from words or flags: `board 7d shipped claude`, `--from … --to …`.
51
+ export function boardQuery(words, values) {
52
+ const params = new URLSearchParams()
53
+ for (const word of words.map(w => w.toLowerCase())) {
54
+ if (PERIODS.includes(word)) params.set('period', word)
55
+ else if (METRICS.includes(word)) params.set('metric', word)
56
+ else if (TOOLS.includes(word)) params.set('tool', word)
57
+ else throw new Error(`"${word}" is not a period (${PERIODS.join(', ')}), a ranking (${METRICS.join(', ')}) or a tool (${TOOLS.join(', ')}).`)
58
+ }
59
+ if (values.from || values.to) {
60
+ if (!values.from || !values.to) throw new Error('A custom range needs both --from and --to (YYYY-MM-DD).')
61
+ params.set('from', values.from)
62
+ params.set('to', values.to)
63
+ params.delete('period')
64
+ }
65
+ return params
66
+ }
67
+
68
+ const METRIC_LABEL = { consistency: 'active days', shipped: 'commits shipped with AI', streak: 'current streak', tokens: 'tokens' }
69
+
70
+ // `vibestreak board`: the top ten, a four-week strip per person, and your
71
+ // own row pinned below when you rank lower.
72
+ export async function boardView({ server, words, values, you, columns }) {
73
+ const p = painter()
74
+ const params = boardQuery(words, values)
75
+ params.set('limit', String(values.limit ?? 10))
76
+ if (you) params.set('you', you)
77
+ const b = await getJson(server, `/api/v1/board?${params}`)
78
+ const period = b.range ? `${b.range.from} → ${b.range.to}` : { today: 'today', all: 'all time' }[b.period] ?? `last ${b.period}`
79
+ console.log(`${p.bold('Leaderboard')} ${p.dim(`· ${METRIC_LABEL[b.metric]} · ${period}${b.tool ? ` · ${TOOL_NAMES[b.tool]}` : ''} · ${plural(b.total, 'developer')}`)}`)
80
+ if (!b.rows.length) return console.log(p.dim('Nobody here yet for these days.'))
81
+ const wide = columns >= 96
82
+ const handleWidth = Math.min(22, Math.max(8, ...b.rows.map(r => r.handle.length + 1)))
83
+ const header = [pad('#', 4), pad('developer', handleWidth + 1), wide ? pad('last 4 weeks', 29) : '', pad('active', 7, true), pad('streak', 8, true), pad('shipped', 9, true), pad('tokens', 8, true)].join(' ')
84
+ console.log(p.dim(header))
85
+ const line = r => {
86
+ const me = you && r.handle.toLowerCase() === you.toLowerCase()
87
+ const name = pad(`@${r.handle}`, handleWidth + 1)
88
+ return [
89
+ pad(String(r.rank), 4),
90
+ me ? p.bold(name) : name,
91
+ wide ? pad(recent(p, r.strip), 29) : '',
92
+ pad(fmt(r.activeDays), 7, true),
93
+ pad(`${fmt(r.streak)}d`, 8, true),
94
+ pad(p.ship(fmt(r.aiCommits)), 9, true),
95
+ pad(compact(r.tokens), 8, true),
96
+ ].join(' ') + (me ? p.dim(' ← you') : '')
97
+ }
98
+ for (const r of b.rows) console.log(line(r))
99
+ if (b.you && b.you.rank > b.rows.length) console.log(`${p.dim(' …')}\n${line(b.you)}`)
100
+ console.log(p.dim(`\nMore: vibestreak board [today|7d|30d|90d|all] [consistency|shipped|streak|tokens] [tool] · ${new URL('/', server)}`))
101
+ }
102
+
103
+ // `vibestreak today`: who is coding with AI today, and whose streak ends at midnight.
104
+ export async function todayView({ server }) {
105
+ const p = painter()
106
+ const t = await getJson(server, '/api/v1/today')
107
+ console.log(`${p.color('#3fb950', '●')} ${p.bold(plural(t.active.length, 'developer'))} coding with AI today ${p.dim(`(of ${fmt(t.developers)})`)}`)
108
+ for (const r of t.active.slice(0, 15)) console.log(` @${pad(r.handle, 22)} ${pad(`${fmt(r.streak)}d`, 6, true)} ${p.dim(toolList(r.tools))}`)
109
+ if (t.atRisk.length) {
110
+ console.log(`\n${p.ship(`${plural(t.atRisk.length, 'streak')} at risk`)} ${p.dim('· ends at their midnight')}`)
111
+ for (const r of t.atRisk.slice(0, 10)) console.log(` @${pad(r.handle, 22)} ${pad(p.ship(`${fmt(r.streak)}d`), 6, true)}`)
112
+ }
113
+ }
114
+
115
+ // Plain `vibestreak`: your streak, the last twelve weeks, your rank, and
116
+ // what to run next.
117
+ export async function dashboardView({ server, token, handle, columns }) {
118
+ const p = painter()
119
+ const [profile, b] = await Promise.all([
120
+ getJson(server, `/api/v1/users/${encodeURIComponent(handle)}.json`, token),
121
+ getJson(server, `/api/v1/board?limit=0&you=${encodeURIComponent(handle)}`).catch(() => null),
122
+ ])
123
+ const rank = b?.you ? `#${b.you.rank} of ${fmt(b.total)} ${p.dim('· 30 days, consistency')}` : p.dim('not on the public board')
124
+ console.log(`${p.bold(`@${profile.handle}`)} ${rank}`)
125
+ console.log(streakLine(p, profile))
126
+ console.log('')
127
+ console.log(heatmap(p, profile.days, profile.today, { columns: Math.min(columns, 31), first: profile.summary.first }))
128
+ console.log('')
129
+ console.log(p.dim('vibestreak me · board · today · status · help'))
130
+ }