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.
- package/bin/vibestreak.mjs +20 -8
- package/package.json +1 -1
- package/src/autosync.mjs +27 -21
- package/src/nudge.mjs +43 -1
package/bin/vibestreak.mjs
CHANGED
|
@@ -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,
|
|
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 ?
|
|
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
|
-
|
|
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 ${
|
|
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(
|
|
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.
|
|
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
|
|
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
|
-
|
|
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: [
|
|
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
|
-
|
|
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
|
|
222
|
-
|
|
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
|
|
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(
|
|
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}.` : ''
|