vibestreak 0.1.1 → 0.1.2

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 { autosyncStatus, clock, installAutosync, NUDGE_TIME, nextRun, removeAutosync, SYNC_TIMES } from '../src/autosync.mjs'
6
- import { notify, nudgeAction, nudgeText, shouldNudge } from '../src/nudge.mjs'
5
+ import { autosyncStatus, clock, installAutosync, nextRun, removeAutosync, SYNC_TIMES } from '../src/autosync.mjs'
6
+ import { NUDGE_DEFAULT, notify, nudgeAction, nudgeText, nudgeTimeOf, parseNudgeTime, 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'
@@ -25,6 +25,7 @@ const HELP = `${BRAND} — share your AI coding heatmap: what you shipped, and h
25
25
  ${BRAND} autosync [on|off] sync in the background through the day
26
26
  ${BRAND} nudge [on|off|test] an evening notification when your streak is
27
27
  about to end (on with autosync); alone, its status
28
+ ${BRAND} nudge at 21:45 when it checks (17:00 to 23:30; default 20:30)
28
29
  ${BRAND} whoami the profile this machine syncs to
29
30
 
30
31
  Reads: Claude Code, Codex, Cursor, Copilot (CLI and VS Code), Gemini CLI,
@@ -123,11 +124,21 @@ async function main() {
123
124
  const bin = fileURLToPath(import.meta.url)
124
125
  const action = nudgeAction(rest, values)
125
126
  if (action === 'status') return nudgeStatus(config)
127
+ if (action === 'at') {
128
+ const time = parseNudgeTime(rest[1])
129
+ config.nudgeTime = clock(time)
130
+ if (rest[1].trim().toLowerCase() === 'default') delete config.nudgeTime
131
+ await saveConfig(config)
132
+ const scheduled = await autosyncStatus()
133
+ if (scheduled.on && config.nudge !== false) await installAutosync(bin, { nudge: true, nudgeTime: time })
134
+ const note = config.nudge === false ? ` It's off: \`${BRAND} nudge on\` turns it on.` : !scheduled.on ? ` It runs once autosync is on: \`${BRAND} autosync on\`.` : ''
135
+ return console.log(`Streak check at ${clock(time)}${clock(time) === clock(NUDGE_DEFAULT) ? ' (the default)' : ''}.${note}`)
136
+ }
126
137
  if (action === 'on' || action === 'off') {
127
138
  config.nudge = action === 'on'
128
139
  await saveConfig(config)
129
- if (config.autosync) await installAutosync(bin, { nudge: config.nudge })
130
- return console.log(`Streak nudge ${config.nudge ? 'on: 20:30 each evening when your streak is at risk' : 'off'}.`)
140
+ if (config.autosync) await installAutosync(bin, { nudge: config.nudge, nudgeTime: nudgeTimeOf(config) })
141
+ return console.log(`Streak nudge ${config.nudge ? `on: ${clock(nudgeTimeOf(config))} each evening when your streak is at risk` : 'off'}.`)
131
142
  }
132
143
  if (action === 'test') {
133
144
  const { streak } = await api(config, '/api/v1/me')
@@ -151,7 +162,7 @@ async function main() {
151
162
  await saveConfig(config)
152
163
  return console.log(await removeAutosync())
153
164
  }
154
- const result = await installAutosync(fileURLToPath(import.meta.url), { nudge: config.nudge !== false })
165
+ const result = await installAutosync(fileURLToPath(import.meta.url), { nudge: config.nudge !== false, nudgeTime: nudgeTimeOf(config) })
155
166
  config.autosync = result.installed
156
167
  await saveConfig(config)
157
168
  return console.log(
@@ -222,11 +233,12 @@ async function nudgeStatus(config) {
222
233
  const [scheduled, me] = await Promise.all([autosyncStatus(), serverStatus(config)])
223
234
  const on = config.nudge !== false && scheduled.nudge
224
235
  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}`}`)
236
+ const time = clock(nudgeTimeOf(config))
237
+ console.log(`Nudge ${on ? `on · checks at ${time}${config.nudgeTime ? '' : ' (default; `nudge at 21:45` changes it)'}` : `off${why}`}`)
226
238
  console.log(`Last nudge ${config.lastNudge ?? 'never'}`)
227
239
  if (me.error) return console.log(`Tonight unknown (${me.error})`)
228
240
  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'
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'
230
242
  console.log(`Streak ${streakLine(s)}`)
231
243
  console.log(`Tonight ${tonight}`)
232
244
  }
@@ -242,7 +254,7 @@ async function status(config) {
242
254
  if (!me.error) row('Streak', streakLine(me.streak))
243
255
  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
256
  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)')
257
+ row('Nudge', nudgeOn ? `on · checks at ${clock(nudgeTimeOf(config))} · last nudged ${config.lastNudge ?? 'never'}` : config.nudge === false ? 'off' : 'off (needs autosync)')
246
258
  const github = me.error ? 0 : (me.emails?.length ?? 0)
247
259
  row('Addresses', `${config.emails.join(', ') || 'none'}${github ? ` · +${github} from GitHub` : ''}`)
248
260
  row('Config', CONFIG_PATH)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibestreak",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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
@@ -6,6 +6,7 @@ import { dirname, join } from 'node:path'
6
6
  import { promisify } from 'node:util'
7
7
 
8
8
  import { CONFIG_PATH } from './config.mjs'
9
+ import { NUDGE_DEFAULT } from './nudge.mjs'
9
10
 
10
11
  const run = promisify(execFile)
11
12
 
@@ -16,16 +17,17 @@ export const SYNC_TIMES = [
16
17
  [13, 17],
17
18
  [17, 17],
18
19
  ]
19
- export const NUDGE_TIME = [20, 30]
20
+ export { NUDGE_DEFAULT as NUDGE_TIME }
20
21
 
21
22
  export const clock = ([h, m]) => `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
22
23
  const configDir = () => dirname(CONFIG_PATH)
23
24
  const logPath = () => join(configDir(), 'sync.log')
24
25
 
25
- // The jobs to schedule: a sync at each sync time, and the evening nudge.
26
- export const jobs = nudge => [
26
+ // The jobs to schedule: a sync at each sync time, and the evening nudge at
27
+ // the person's chosen time.
28
+ export const jobs = (nudge, nudgeTime = NUDGE_DEFAULT) => [
27
29
  { name: 'sync', args: ['sync', '--quiet'], times: SYNC_TIMES },
28
- ...(nudge ? [{ name: 'nudge', args: ['nudge', 'run', '--quiet'], times: [NUDGE_TIME] }] : []),
30
+ ...(nudge ? [{ name: 'nudge', args: ['nudge', 'run', '--quiet'], times: [nudgeTime] }] : []),
29
31
  ]
30
32
 
31
33
  // A CLI run through npx lives in npm's cache, which can be cleared; a
@@ -65,9 +67,9 @@ export function launchdPlist(label, node, bin, args, times, log) {
65
67
  }
66
68
 
67
69
  const launchd = {
68
- async install(node, bin, nudge) {
70
+ async install(node, bin, nudge, nudgeTime) {
69
71
  await mkdir(agents(), { recursive: true })
70
- for (const job of jobs(true)) {
72
+ for (const job of jobs(true, nudgeTime)) {
71
73
  const plist = join(agents(), `${LAUNCHD[job.name]}.plist`)
72
74
  await run('launchctl', ['unload', plist]).catch(() => {})
73
75
  if (job.name === 'nudge' && !nudge) {
@@ -129,8 +131,8 @@ const CRON_BEGIN = '# vibestreak:begin'
129
131
  const CRON_END = '# vibestreak:end'
130
132
  const shellQuote = a => `'${a.replace(/'/g, `'\\''`)}'`
131
133
 
132
- export function cronBlock(node, bin, nudge, log) {
133
- const lines = jobs(nudge).flatMap(job =>
134
+ export function cronBlock(node, bin, nudge, log, nudgeTime) {
135
+ const lines = jobs(nudge, nudgeTime).flatMap(job =>
134
136
  job.times.map(([h, m]) => `${m} ${h} * * * ${[node, bin, ...job.args].map(shellQuote).join(' ')} >> ${shellQuote(log)} 2>&1`),
135
137
  )
136
138
  return [CRON_BEGIN, ...lines, CRON_END].join('\n')
@@ -153,10 +155,10 @@ async function writeCrontab(block) {
153
155
  const hasUserSystemd = () => run('systemctl', ['--user', 'show-environment']).then(() => true, () => false)
154
156
 
155
157
  const linux = {
156
- async install(node, bin, nudge) {
158
+ async install(node, bin, nudge, nudgeTime) {
157
159
  if (await hasUserSystemd()) {
158
160
  await mkdir(units(), { recursive: true })
159
- for (const job of jobs(true)) {
161
+ for (const job of jobs(true, nudgeTime)) {
160
162
  const name = `vibestreak-${job.name}`
161
163
  if (job.name === 'nudge' && !nudge) {
162
164
  await run('systemctl', ['--user', 'disable', '--now', `${name}.timer`]).catch(() => {})
@@ -172,7 +174,7 @@ const linux = {
172
174
  for (const job of jobs(nudge)) await run('systemctl', ['--user', 'enable', '--now', `vibestreak-${job.name}.timer`])
173
175
  return 'systemd'
174
176
  }
175
- await writeCrontab(cronBlock(node, bin, nudge, logPath()))
177
+ await writeCrontab(cronBlock(node, bin, nudge, logPath(), nudgeTime))
176
178
  return 'crontab'
177
179
  },
178
180
  async status() {
@@ -202,13 +204,18 @@ export const windowsTaskName = (job, [h, m]) => `vibestreak\\${job.name}-${Strin
202
204
  const cmdPath = p => p.replace(/%/g, '%%')
203
205
  export const windowsScript = (node, bin, job, log) => `@echo off\r\n"${cmdPath(node)}" "${cmdPath(bin)}" ${job.args.join(' ')} >> "${cmdPath(log)}" 2>&1\r\n`
204
206
 
205
- const ALL_WINDOWS_TASKS = jobs(true).flatMap(job => job.times.map(t => windowsTaskName(job, t)))
207
+ // Our tasks as Task Scheduler holds them, whatever times they were made for.
208
+ async function windowsTasks() {
209
+ const { stdout } = await run('schtasks', ['/Query', '/FO', 'CSV', '/NH']).catch(() => ({ stdout: '' }))
210
+ const names = stdout.split(/\r?\n/).map(line => /^"([^"]+)"/.exec(line)?.[1]).filter(Boolean)
211
+ return [...new Set(names.filter(n => /^\\vibestreak\\/i.test(n)).map(n => n.slice(1)))]
212
+ }
206
213
 
207
214
  const windows = {
208
- async install(node, bin, nudge) {
215
+ async install(node, bin, nudge, nudgeTime) {
209
216
  await windows.remove()
210
217
  await mkdir(configDir(), { recursive: true })
211
- for (const job of jobs(nudge)) {
218
+ for (const job of jobs(nudge, nudgeTime)) {
212
219
  const script = join(configDir(), `${job.name}.cmd`)
213
220
  await writeFile(script, windowsScript(node, bin, job, logPath()))
214
221
  for (const time of job.times) {
@@ -218,12 +225,11 @@ const windows = {
218
225
  return 'Task Scheduler'
219
226
  },
220
227
  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)) }
228
+ const names = await windowsTasks()
229
+ return { on: names.some(n => /\\sync-/i.test(n)), using: 'Task Scheduler', nudge: names.some(n => /\\nudge-/i.test(n)) }
224
230
  },
225
231
  async remove() {
226
- for (const name of ALL_WINDOWS_TASKS) await run('schtasks', ['/Delete', '/F', '/TN', name]).catch(() => {})
232
+ for (const name of await windowsTasks()) await run('schtasks', ['/Delete', '/F', '/TN', name]).catch(() => {})
227
233
  },
228
234
  }
229
235
 
@@ -231,15 +237,15 @@ const SCHEDULERS = { darwin: launchd, linux, win32: windows }
231
237
 
232
238
  // Schedules the background syncs (and the evening nudge) with the system's
233
239
  // own scheduler.
234
- export async function installAutosync(bin, { nudge = true } = {}) {
240
+ export async function installAutosync(bin, { nudge = true, nudgeTime = NUDGE_DEFAULT } = {}) {
235
241
  const scheduler = SCHEDULERS[platform()]
236
242
  const times = SYNC_TIMES.map(clock)
237
- const nudgeAt = nudge ? clock(NUDGE_TIME) : undefined
243
+ const nudgeAt = nudge ? clock(nudgeTime) : undefined
238
244
  if (!scheduler) {
239
245
  return { installed: false, hint: `No scheduler support on ${platform()} yet. Run \`${bin} sync --quiet\` at ${times.join(', ')}${nudge ? ` and \`nudge --quiet\` at ${nudgeAt}` : ''}.` }
240
246
  }
241
247
  await mkdir(configDir(), { recursive: true })
242
- const using = await scheduler.install(process.execPath, await stableBin(bin), nudge)
248
+ const using = await scheduler.install(process.execPath, await stableBin(bin), nudge, nudgeTime)
243
249
  return { installed: true, using, times, nudgeAt, log: logPath() }
244
250
  }
245
251
 
package/src/nudge.mjs CHANGED
@@ -15,10 +15,52 @@ export function shouldNudge(streak, config) {
15
15
  // --quiet and nothing else it is the scheduled check as 0.1.0 installed it
16
16
  // (`nudge --quiet`), so those jobs keep working until autosync is rerun.
17
17
  export function nudgeAction(rest, values) {
18
- if (['on', 'off', 'test', 'run'].includes(rest[0])) return rest[0]
18
+ if (['on', 'off', 'test', 'run', 'at'].includes(rest[0])) return rest[0]
19
19
  return values.quiet ? 'run' : 'status'
20
20
  }
21
21
 
22
+ // When the evening check may run: late enough that the day is mostly done,
23
+ // early enough to still code before midnight (streaks count calendar days).
24
+ export const NUDGE_DEFAULT = [20, 30]
25
+ export const NUDGE_EARLIEST = [17, 0]
26
+ export const NUDGE_LATEST = [23, 30]
27
+ const minutes = ([h, m]) => h * 60 + m
28
+ const hhmm = ([h, m]) => `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
29
+
30
+ // A time as people type it: 21:45, 2145, 9:45pm, 9pm, 21, or `default`.
31
+ // Returns [hour, minute], or throws a sentence saying what to type instead.
32
+ export function parseNudgeTime(input) {
33
+ const text = String(input ?? '').trim().toLowerCase().replace(/\s+/g, '')
34
+ if (text === 'default') return NUDGE_DEFAULT
35
+ const match = /^(\d{1,2})(?::?(\d{2}))?(am|pm)?$/.exec(text)
36
+ if (!match) throw new Error(`"${input}" is not a time: try 21:45 or 9:45pm.`)
37
+ let hour = Number(match[1])
38
+ const minute = Number(match[2] ?? 0)
39
+ if (match[3]) {
40
+ if (hour < 1 || hour > 12) throw new Error(`"${input}" is not a time: with am/pm the hour is 1 to 12.`)
41
+ hour = (hour % 12) + (match[3] === 'pm' ? 12 : 0)
42
+ }
43
+ if (hour > 23 || minute > 59) throw new Error(`"${input}" is not a time of day.`)
44
+ const time = [hour, minute]
45
+ if (minutes(time) < minutes(NUDGE_EARLIEST) || minutes(time) > minutes(NUDGE_LATEST)) {
46
+ // Small hours (before 5:00) mean 'late at night': the nearest is the latest allowed.
47
+ const nearest = minutes(time) < minutes(NUDGE_EARLIEST) && hour >= 5 ? NUDGE_EARLIEST : NUDGE_LATEST
48
+ throw new Error(
49
+ `The nudge runs between ${hhmm(NUDGE_EARLIEST)} and ${hhmm(NUDGE_LATEST)}: late enough that your day is mostly done, early enough to still code before midnight. The nearest is ${hhmm(nearest)}.`,
50
+ )
51
+ }
52
+ return time
53
+ }
54
+
55
+ // The person's chosen nudge time, or the default.
56
+ export function nudgeTimeOf(config) {
57
+ try {
58
+ return config.nudgeTime ? parseNudgeTime(config.nudgeTime) : NUDGE_DEFAULT
59
+ } catch {
60
+ return NUDGE_DEFAULT
61
+ }
62
+ }
63
+
22
64
  export function nudgeText(streak) {
23
65
  const days = streak.current === 1 ? '1-day streak' : `${streak.current}-day streak`
24
66
  const next = streak.next && streak.current + 1 >= streak.next.days ? ` Today makes it ${streak.next.name}.` : ''