vibestreak 0.1.0 → 0.1.1

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.
@@ -2,8 +2,8 @@
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { parseArgs } from 'node:util'
4
4
 
5
- import { installAutosync, removeAutosync } from '../src/autosync.mjs'
6
- import { notify, nudgeText, shouldNudge } from '../src/nudge.mjs'
5
+ import { autosyncStatus, clock, installAutosync, NUDGE_TIME, nextRun, removeAutosync, SYNC_TIMES } from '../src/autosync.mjs'
6
+ import { notify, nudgeAction, nudgeText, shouldNudge } from '../src/nudge.mjs'
7
7
  import { collect, sinceDate, summarize } from '../src/collect.mjs'
8
8
  import { CONFIG_PATH, loadConfig, saveConfig } from '../src/config.mjs'
9
9
  import { DEFAULT_ROOTS, globalEmail, scanEmails } from '../src/git.mjs'
@@ -21,9 +21,10 @@ const HELP = `${BRAND} — share your AI coding heatmap: what you shipped, and h
21
21
  the addresses you commit as (all of your accounts)
22
22
  ${BRAND} roots [add <dir>|remove <dir>]
23
23
  where to look for git repos
24
+ ${BRAND} status profile, last sync, streak, autosync and nudge
24
25
  ${BRAND} autosync [on|off] sync in the background through the day
25
26
  ${BRAND} nudge [on|off|test] an evening notification when your streak is
26
- about to end (on with autosync)
27
+ about to end (on with autosync); alone, its status
27
28
  ${BRAND} whoami the profile this machine syncs to
28
29
 
29
30
  Reads: Claude Code, Codex, Cursor, Copilot (CLI and VS Code), Gemini CLI,
@@ -116,20 +117,24 @@ async function main() {
116
117
 
117
118
  if (command === 'collect' || command === 'sync') return sync(config, values, log, command === 'sync')
118
119
 
120
+ if (command === 'status') return status(config)
121
+
119
122
  if (command === 'nudge') {
120
123
  const bin = fileURLToPath(import.meta.url)
121
- if (rest[0] === 'on' || rest[0] === 'off') {
122
- config.nudge = rest[0] === 'on'
124
+ const action = nudgeAction(rest, values)
125
+ if (action === 'status') return nudgeStatus(config)
126
+ if (action === 'on' || action === 'off') {
127
+ config.nudge = action === 'on'
123
128
  await saveConfig(config)
124
129
  if (config.autosync) await installAutosync(bin, { nudge: config.nudge })
125
130
  return console.log(`Streak nudge ${config.nudge ? 'on: 20:30 each evening when your streak is at risk' : 'off'}.`)
126
131
  }
127
- if (rest[0] === 'test') {
132
+ if (action === 'test') {
128
133
  const { streak } = await api(config, '/api/v1/me')
129
134
  await notify(nudgeText({ ...streak, current: Math.max(1, streak.current) }))
130
135
  return console.log(`Sent a test notification (your streak: ${streak.current} days; ${streak.activeToday ? 'active today' : 'not active yet today'}).`)
131
136
  }
132
- // The evening run: sync, then nudge if the streak would end at midnight.
137
+ // `nudge run`, the evening job: sync, then nudge if the streak would end at midnight.
133
138
  const result = await sync(config, values, log, true)
134
139
  if (shouldNudge(result.streak, config)) {
135
140
  await notify(nudgeText(result.streak))
@@ -181,6 +186,7 @@ async function sync(config, values, log, isSync) {
181
186
  if (!isSync) return log(`\nNothing was sent. \`${BRAND} sync\` uploads these totals.`)
182
187
  const result = await api(config, '/api/v1/upload', { method: 'POST', body: JSON.stringify(payload) })
183
188
  config.lastSync = new Date().toISOString()
189
+ config.lastSyncSummary = { days: result.days, commits: result.commits, repos: s.repos }
184
190
  await saveConfig(config)
185
191
  // Always one line, quiet or not: the background sync's log is how a person sees it ran.
186
192
  const streak = result.streak ? ` · streak ${result.streak.current}d${result.streak.activeToday ? '' : ' (not yet today)'}` : ''
@@ -188,6 +194,60 @@ async function sync(config, values, log, isSync) {
188
194
  return result
189
195
  }
190
196
 
197
+ const ago = iso => {
198
+ if (!iso) return 'never'
199
+ const minutes = Math.round((Date.now() - Date.parse(iso)) / 60000)
200
+ return minutes < 1 ? 'just now' : minutes < 60 ? `${minutes} min ago` : minutes < 48 * 60 ? `${Math.round(minutes / 60)} h ago` : `${Math.round(minutes / 1440)} days ago`
201
+ }
202
+ const at = ({ at, day }) => `${day} ${clock([at.getHours(), at.getMinutes()])}`
203
+
204
+ // The server's view of the streak, or why it can't be had (not linked, offline).
205
+ async function serverStatus(config) {
206
+ if (!config.server || !config.token) return { error: `not linked: run \`${BRAND} login --token PASTE_YOUR_TOKEN\`` }
207
+ try {
208
+ return await api(config, '/api/v1/me')
209
+ } catch (error) {
210
+ return { error: error.message }
211
+ }
212
+ }
213
+
214
+ function streakLine(streak) {
215
+ if (!streak) return 'unknown'
216
+ const today = streak.activeToday ? 'active today' : streak.current > 0 ? 'not yet today: ends at midnight' : 'no live streak'
217
+ const next = streak.next ? ` · ${streak.next.remaining} ${streak.next.remaining === 1 ? 'day' : 'days'} to ${streak.next.name}` : ''
218
+ return `${streak.current} ${streak.current === 1 ? 'day' : 'days'} · ${today}${next}`
219
+ }
220
+
221
+ async function nudgeStatus(config) {
222
+ const [scheduled, me] = await Promise.all([autosyncStatus(), serverStatus(config)])
223
+ const on = config.nudge !== false && scheduled.nudge
224
+ const why = config.nudge === false ? ` (\`${BRAND} nudge on\` turns it on)` : !scheduled.on ? ` (needs autosync: \`${BRAND} autosync on\`)` : ''
225
+ console.log(`Nudge ${on ? `on · checks at ${clock(NUDGE_TIME)}` : `off${why}`}`)
226
+ console.log(`Last nudge ${config.lastNudge ?? 'never'}`)
227
+ if (me.error) return console.log(`Tonight unknown (${me.error})`)
228
+ const s = me.streak
229
+ 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 ${clock(NUDGE_TIME)} unless you code with AI first`) : 'no nudge: no live streak'
230
+ console.log(`Streak ${streakLine(s)}`)
231
+ console.log(`Tonight ${tonight}`)
232
+ }
233
+
234
+ async function status(config) {
235
+ const [scheduled, me] = await Promise.all([autosyncStatus(), serverStatus(config)])
236
+ const row = (label, value) => console.log(`${label.padEnd(11)} ${value}`)
237
+ row('Profile', me.error ? me.error : `@${me.handle} · ${me.profileUrl}`)
238
+ if (!me.error && me.github?.length) row('GitHub', me.github.map(g => `github.com/${g}`).join(', '))
239
+ row('Machine', config.machineName)
240
+ const last = config.lastSyncSummary
241
+ row('Last sync', config.lastSync ? `${ago(config.lastSync)}${last ? ` · ${last.days} days, ${last.commits} commits from ${last.repos} repos` : ''}` : `never: run \`${BRAND} sync\``)
242
+ if (!me.error) row('Streak', streakLine(me.streak))
243
+ row('Autosync', scheduled.on ? `on (${scheduled.using}) · next sync ${at(nextRun(SYNC_TIMES))}` : `off: \`${BRAND} autosync on\` syncs at ${SYNC_TIMES.map(clock).join(', ')}`)
244
+ const nudgeOn = config.nudge !== false && scheduled.nudge
245
+ row('Nudge', nudgeOn ? `on · checks at ${clock(NUDGE_TIME)} · last nudged ${config.lastNudge ?? 'never'}` : config.nudge === false ? 'off' : 'off (needs autosync)')
246
+ const github = me.error ? 0 : (me.emails?.length ?? 0)
247
+ row('Addresses', `${config.emails.join(', ') || 'none'}${github ? ` · +${github} from GitHub` : ''}`)
248
+ row('Config', CONFIG_PATH)
249
+ }
250
+
191
251
  main().catch(error => {
192
252
  console.error(`${BRAND}: ${error.message}`)
193
253
  process.exitCode = 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibestreak",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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
@@ -1,4 +1,5 @@
1
1
  import { execFile } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
2
3
  import { cp, mkdir, rm, writeFile } from 'node:fs/promises'
3
4
  import { homedir, platform } from 'node:os'
4
5
  import { dirname, join } from 'node:path'
@@ -24,7 +25,7 @@ const logPath = () => join(configDir(), 'sync.log')
24
25
  // The jobs to schedule: a sync at each sync time, and the evening nudge.
25
26
  export const jobs = nudge => [
26
27
  { name: 'sync', args: ['sync', '--quiet'], times: SYNC_TIMES },
27
- ...(nudge ? [{ name: 'nudge', args: ['nudge', '--quiet'], times: [NUDGE_TIME] }] : []),
28
+ ...(nudge ? [{ name: 'nudge', args: ['nudge', 'run', '--quiet'], times: [NUDGE_TIME] }] : []),
28
29
  ]
29
30
 
30
31
  // A CLI run through npx lives in npm's cache, which can be cleared; a
@@ -78,6 +79,10 @@ const launchd = {
78
79
  }
79
80
  return 'launchd'
80
81
  },
82
+ async status() {
83
+ const has = name => existsSync(join(agents(), `${LAUNCHD[name]}.plist`))
84
+ return { on: has('sync'), using: 'launchd', nudge: has('nudge') }
85
+ },
81
86
  async remove() {
82
87
  for (const label of Object.values(LAUNCHD)) {
83
88
  const plist = join(agents(), `${label}.plist`)
@@ -170,6 +175,14 @@ const linux = {
170
175
  await writeCrontab(cronBlock(node, bin, nudge, logPath()))
171
176
  return 'crontab'
172
177
  },
178
+ async status() {
179
+ if (existsSync(join(units(), 'vibestreak-sync.timer'))) {
180
+ return { on: true, using: 'systemd', nudge: existsSync(join(units(), 'vibestreak-nudge.timer')) }
181
+ }
182
+ const cron = await run('crontab', ['-l']).then(r => r.stdout, () => '')
183
+ const block = cron.match(new RegExp(`${CRON_BEGIN}[\\s\\S]*?${CRON_END}`))?.[0] ?? ''
184
+ return { on: /\bsync\b/.test(block), using: 'crontab', nudge: /\bnudge\b/.test(block) }
185
+ },
173
186
  async remove() {
174
187
  for (const name of ['vibestreak-sync', 'vibestreak-nudge']) {
175
188
  await run('systemctl', ['--user', 'disable', '--now', `${name}.timer`]).catch(() => {})
@@ -204,6 +217,11 @@ const windows = {
204
217
  }
205
218
  return 'Task Scheduler'
206
219
  },
220
+ async status() {
221
+ const has = name => run('schtasks', ['/Query', '/TN', name]).then(() => true, () => false)
222
+ const [syncJob, nudgeJob] = jobs(true)
223
+ return { on: await has(windowsTaskName(syncJob, syncJob.times[0])), using: 'Task Scheduler', nudge: await has(windowsTaskName(nudgeJob, NUDGE_TIME)) }
224
+ },
207
225
  async remove() {
208
226
  for (const name of ALL_WINDOWS_TASKS) await run('schtasks', ['/Delete', '/F', '/TN', name]).catch(() => {})
209
227
  },
@@ -225,6 +243,21 @@ export async function installAutosync(bin, { nudge = true } = {}) {
225
243
  return { installed: true, using, times, nudgeAt, log: logPath() }
226
244
  }
227
245
 
246
+ // What the system's scheduler holds now: whether the syncs and the nudge are scheduled.
247
+ export async function autosyncStatus() {
248
+ const scheduler = SCHEDULERS[platform()]
249
+ return scheduler ? scheduler.status() : { on: false, using: undefined, nudge: false }
250
+ }
251
+
252
+ // The next of the given daily times after `now`: today if one is still ahead, else tomorrow's first.
253
+ export function nextRun(times, now = new Date()) {
254
+ const today = times.map(([h, m]) => new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, m)).sort((a, b) => a - b)
255
+ const later = today.find(t => t > now)
256
+ if (later) return { at: later, day: 'today' }
257
+ const [first] = today
258
+ return { at: new Date(first.getFullYear(), first.getMonth(), first.getDate() + 1, first.getHours(), first.getMinutes()), day: 'tomorrow' }
259
+ }
260
+
228
261
  export async function removeAutosync() {
229
262
  await SCHEDULERS[platform()]?.remove()
230
263
  return { removed: true }
package/src/nudge.mjs CHANGED
@@ -11,6 +11,14 @@ export function shouldNudge(streak, config) {
11
11
  return streak.atRisk === true && streak.current > 0 && config.lastNudge !== streak.today
12
12
  }
13
13
 
14
+ // What `nudge` was asked to do. Alone it shows the nudge's status; with
15
+ // --quiet and nothing else it is the scheduled check as 0.1.0 installed it
16
+ // (`nudge --quiet`), so those jobs keep working until autosync is rerun.
17
+ export function nudgeAction(rest, values) {
18
+ if (['on', 'off', 'test', 'run'].includes(rest[0])) return rest[0]
19
+ return values.quiet ? 'run' : 'status'
20
+ }
21
+
14
22
  export function nudgeText(streak) {
15
23
  const days = streak.current === 1 ? '1-day streak' : `${streak.current}-day streak`
16
24
  const next = streak.next && streak.current + 1 >= streak.next.days ? ` Today makes it ${streak.next.name}.` : ''