vibestreak 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kingsbridge Consultancy LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,42 @@
1
- # Temporary Holding Version
1
+ # vibestreak
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Your AI coding streak, from your own machine: a heatmap of the days you coded
4
+ with AI agents and the commits you shipped with them, ranked on consistency,
5
+ not spend. Profiles and the leaderboard: https://vibestreak.kingsbridge-consultancy.com
6
+
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
12
+ ```
13
+
14
+ All commands: https://vibestreak.kingsbridge-consultancy.com/cli
15
+
16
+ ## What it reads
17
+
18
+ | Tool | Where |
19
+ | --- | --- |
20
+ | Claude Code | `~/.claude/projects` |
21
+ | Codex | `~/.codex/sessions`, `~/.codex/archived_sessions` |
22
+ | Cursor | its editor database and AI code-tracking database |
23
+ | GitHub Copilot | Copilot CLI's `~/.copilot/session-state`; Copilot Chat sessions in VS Code |
24
+ | Gemini CLI | `~/.gemini/tmp/*/chats` |
25
+ | Antigravity | `~/.gemini/antigravity/conversations` |
26
+ | OpenCode | `~/.local/share/opencode` |
27
+ | Commits | `git log` in the repos under your usual folders, for the addresses you list |
28
+
29
+ ## What it sends
30
+
31
+ Per day and tool: message, tool-call, session and token counts, and model names.
32
+ Per commit you authored: a keyed hash of its id, its day, and the AI agent named
33
+ in its co-author trailer. Never prompts, responses, code, file paths, repo names,
34
+ commit messages or email addresses. `npx vibestreak collect --json` prints the
35
+ exact upload without sending it.
36
+
37
+ ## Requirements
38
+
39
+ Node.js 22.13 or newer and git, on macOS, Linux or Windows. Background syncs use
40
+ the system's own scheduler: launchd (macOS), systemd user timers or crontab
41
+ (Linux), Task Scheduler (Windows). The evening streak check notifies through
42
+ Notification Center, `notify-send`, or a Windows toast.
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath } from 'node:url'
3
+ import { parseArgs } from 'node:util'
4
+
5
+ import { installAutosync, removeAutosync } from '../src/autosync.mjs'
6
+ import { notify, nudgeText, shouldNudge } from '../src/nudge.mjs'
7
+ import { collect, sinceDate, summarize } from '../src/collect.mjs'
8
+ import { CONFIG_PATH, loadConfig, saveConfig } from '../src/config.mjs'
9
+ import { DEFAULT_ROOTS, globalEmail, scanEmails } from '../src/git.mjs'
10
+
11
+ const BRAND = process.env.VIBESTREAK_BRAND ?? 'vibestreak'
12
+ const DEFAULT_SERVER = process.env.VIBESTREAK_SERVER ?? 'https://vibestreak.kingsbridge-consultancy.com'
13
+
14
+ const HELP = `${BRAND} — share your AI coding heatmap: what you shipped, and how steadily.
15
+
16
+ ${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)
19
+ ${BRAND} sync [--quiet] collect and upload daily totals
20
+ ${BRAND} emails [scan|add <e>|remove <e>]
21
+ the addresses you commit as (all of your accounts)
22
+ ${BRAND} roots [add <dir>|remove <dir>]
23
+ where to look for git repos
24
+ ${BRAND} autosync [on|off] sync in the background through the day
25
+ ${BRAND} nudge [on|off|test] an evening notification when your streak is
26
+ about to end (on with autosync)
27
+ ${BRAND} whoami the profile this machine syncs to
28
+
29
+ Reads: Claude Code, Codex, Cursor, Copilot (CLI and VS Code), Gemini CLI,
30
+ Antigravity and OpenCode history, and git.
31
+ Sent: per day and tool, message, tool-call, session and token counts and model
32
+ names; per commit you authored, a keyed hash, its day and the AI agent named in
33
+ its co-author trailer. Never prompts, code, file paths, repo names or messages.
34
+ Config: ${CONFIG_PATH}
35
+ All commands, with examples: ${DEFAULT_SERVER}/cli`
36
+
37
+ const fmt = n => n.toLocaleString('en-US')
38
+
39
+ async function api(config, path, init = {}) {
40
+ if (!config.server || !config.token) throw new Error(`Not linked yet: run \`${BRAND} login --token <token>\`.`)
41
+ const response = await fetch(new URL(path, config.server), {
42
+ ...init,
43
+ headers: { authorization: `Bearer ${config.token}`, 'content-type': 'application/json', ...init.headers },
44
+ })
45
+ const body = await response.json().catch(() => ({}))
46
+ if (!response.ok) throw new Error(body.error ?? `${response.status} ${response.statusText}`)
47
+ return body
48
+ }
49
+
50
+ async function main() {
51
+ const { positionals, values } = parseArgs({
52
+ allowPositionals: true,
53
+ options: {
54
+ json: { type: 'boolean' },
55
+ quiet: { type: 'boolean' },
56
+ token: { type: 'string' },
57
+ server: { type: 'string' },
58
+ since: { type: 'string' },
59
+ help: { type: 'boolean', short: 'h' },
60
+ },
61
+ })
62
+ const [command = 'help', ...rest] = positionals
63
+ const config = await loadConfig()
64
+ const log = (...args) => values.quiet || console.log(...args)
65
+
66
+ if (command === 'help' || values.help) return console.log(HELP)
67
+
68
+ if (command === 'emails') {
69
+ const [action, email] = rest
70
+ if (!config.emails.length) {
71
+ const first = await globalEmail()
72
+ if (first) config.emails = [first]
73
+ }
74
+ if (action === 'add' && email) config.emails = [...new Set([...config.emails, email])]
75
+ if (action === 'remove' && email) config.emails = config.emails.filter(e => e !== email)
76
+ if (action === 'scan') {
77
+ console.log('Author addresses in your repos (last 400 days); add each one that is you:\n')
78
+ for (const [address, count] of await scanEmails({ roots: config.roots ?? DEFAULT_ROOTS, since: sinceDate() })) {
79
+ console.log(` ${config.emails.includes(address) ? '✓' : ' '} ${String(count).padStart(6)} ${address}`)
80
+ }
81
+ console.log(`\n ${BRAND} emails add <address>`)
82
+ }
83
+ await saveConfig(config)
84
+ if (action !== 'scan') console.log(config.emails.length ? config.emails.join('\n') : 'No addresses yet.')
85
+ return
86
+ }
87
+
88
+ if (command === 'roots') {
89
+ const [action, dir] = rest
90
+ const roots = config.roots ?? DEFAULT_ROOTS
91
+ if (action === 'add' && dir) config.roots = [...new Set([...roots, dir])]
92
+ if (action === 'remove' && dir) config.roots = roots.filter(r => r !== dir)
93
+ await saveConfig(config)
94
+ return console.log((config.roots ?? DEFAULT_ROOTS).join('\n'))
95
+ }
96
+
97
+ if (command === 'login') {
98
+ if (!values.token) return console.log(`Get a token on ${values.server ?? config.server ?? DEFAULT_SERVER}/settings, then:\n ${BRAND} login --token <token>`)
99
+ config.server = values.server ?? config.server ?? DEFAULT_SERVER
100
+ config.token = values.token
101
+ const me = await api(config, '/api/v1/me')
102
+ if (!config.emails.length) {
103
+ const first = await globalEmail()
104
+ if (first) config.emails = [first]
105
+ }
106
+ await saveConfig(config)
107
+ console.log(`Linked ${config.machineName} to ${me.handle} (${me.profileUrl}).`)
108
+ console.log(`Commit addresses: ${config.emails.join(', ') || 'none'}; run \`${BRAND} emails scan\` to add your other accounts.`)
109
+ return
110
+ }
111
+
112
+ if (command === 'whoami') {
113
+ const me = await api(config, '/api/v1/me')
114
+ return console.log(`${me.handle} · ${me.profileUrl}\nGitHub: ${me.github.join(', ')}\nMachine: ${config.machineName}`)
115
+ }
116
+
117
+ if (command === 'collect' || command === 'sync') return sync(config, values, log, command === 'sync')
118
+
119
+ if (command === 'nudge') {
120
+ const bin = fileURLToPath(import.meta.url)
121
+ if (rest[0] === 'on' || rest[0] === 'off') {
122
+ config.nudge = rest[0] === 'on'
123
+ await saveConfig(config)
124
+ if (config.autosync) await installAutosync(bin, { nudge: config.nudge })
125
+ return console.log(`Streak nudge ${config.nudge ? 'on: 20:30 each evening when your streak is at risk' : 'off'}.`)
126
+ }
127
+ if (rest[0] === 'test') {
128
+ const { streak } = await api(config, '/api/v1/me')
129
+ await notify(nudgeText({ ...streak, current: Math.max(1, streak.current) }))
130
+ return console.log(`Sent a test notification (your streak: ${streak.current} days; ${streak.activeToday ? 'active today' : 'not active yet today'}).`)
131
+ }
132
+ // The evening run: sync, then nudge if the streak would end at midnight.
133
+ const result = await sync(config, values, log, true)
134
+ if (shouldNudge(result.streak, config)) {
135
+ await notify(nudgeText(result.streak))
136
+ config.lastNudge = result.streak.today
137
+ await saveConfig(config)
138
+ console.log(`${new Date().toISOString()} nudged: ${result.streak.current}-day streak at risk`)
139
+ }
140
+ return
141
+ }
142
+
143
+ if (command === 'autosync') {
144
+ if (rest[0] === 'off') {
145
+ config.autosync = false
146
+ await saveConfig(config)
147
+ return console.log(await removeAutosync())
148
+ }
149
+ const result = await installAutosync(fileURLToPath(import.meta.url), { nudge: config.nudge !== false })
150
+ config.autosync = result.installed
151
+ await saveConfig(config)
152
+ return console.log(
153
+ result.installed
154
+ ? `Syncing at ${result.times.join(', ')} via ${result.using}${result.nudgeAt ? `; streak check at ${result.nudgeAt}` : ''}. Log: ${result.log}`
155
+ : result.hint,
156
+ )
157
+ }
158
+
159
+ console.log(HELP)
160
+ process.exitCode = 1
161
+ }
162
+
163
+ // Collects this machine's totals and, for a sync, uploads them; the server
164
+ // answers with where the streak stands (the nudge's input).
165
+ async function sync(config, values, log, isSync) {
166
+ const me = isSync ? await api(config, '/api/v1/me') : undefined
167
+ const emails = [...new Set([...config.emails, ...(me?.emails ?? [])])]
168
+ if (!emails.length) {
169
+ const first = await globalEmail()
170
+ if (first) emails.push(first)
171
+ }
172
+ const started = Date.now()
173
+ const payload = await collect({ config: { ...config, emails }, salt: me?.salt ?? config.localSalt, since: values.since })
174
+ if (values.json) return console.log(JSON.stringify(payload, null, 2))
175
+ const s = summarize(payload)
176
+ log(`${s.activeDays} active days since ${payload.since} (${((Date.now() - started) / 1000).toFixed(1)}s)`)
177
+ for (const [tool, t] of Object.entries(s.tools)) {
178
+ log(` ${tool.padEnd(11)} ${String(t.days).padStart(4)} days ${fmt(t.messages).padStart(10)} messages ${fmt(t.toolCalls).padStart(9)} tool calls ${fmt(t.tokens).padStart(16)} tokens`)
179
+ }
180
+ log(` ${'commits'.padEnd(11)} ${fmt(s.commits)} by you in ${s.repos} repos, ${fmt(s.aiCommits)} with an AI co-author (${emails.join(', ') || 'no addresses'})`)
181
+ if (!isSync) return log(`\nNothing was sent. \`${BRAND} sync\` uploads these totals.`)
182
+ const result = await api(config, '/api/v1/upload', { method: 'POST', body: JSON.stringify(payload) })
183
+ config.lastSync = new Date().toISOString()
184
+ await saveConfig(config)
185
+ // Always one line, quiet or not: the background sync's log is how a person sees it ran.
186
+ const streak = result.streak ? ` · streak ${result.streak.current}d${result.streak.activeToday ? '' : ' (not yet today)'}` : ''
187
+ console.log(`${config.lastSync} uploaded ${result.days} days, ${result.commits} commits from ${s.repos} repos${streak} → ${result.profileUrl}`)
188
+ return result
189
+ }
190
+
191
+ main().catch(error => {
192
+ console.error(`${BRAND}: ${error.message}`)
193
+ process.exitCode = 1
194
+ })
package/package.json CHANGED
@@ -1,6 +1,15 @@
1
1
  {
2
2
  "name": "vibestreak",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "keywords": ["claude-code", "codex", "cursor", "copilot", "gemini", "opencode", "ai-coding", "heatmap", "streak", "leaderboard", "cli"],
6
+ "homepage": "https://vibestreak.kingsbridge-consultancy.com/cli",
7
+ "license": "MIT",
8
+ "author": "Kingsbridge Consultancy LLC <support@kingsbridge-consultancy.com>",
9
+ "type": "module",
10
+ "bin": { "vibestreak": "bin/vibestreak.mjs" },
11
+ "files": ["bin", "src", "README.md", "LICENSE"],
12
+ "engines": { "node": ">=22.13" },
13
+ "os": ["darwin", "linux", "win32"],
14
+ "scripts": { "test": "node --test test/*.test.mjs" }
15
+ }
@@ -0,0 +1,231 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { cp, mkdir, rm, writeFile } from 'node:fs/promises'
3
+ import { homedir, platform } from 'node:os'
4
+ import { dirname, join } from 'node:path'
5
+ import { promisify } from 'node:util'
6
+
7
+ import { CONFIG_PATH } from './config.mjs'
8
+
9
+ const run = promisify(execFile)
10
+
11
+ // Syncs through the day keep the Today board current; the evening run also
12
+ // checks whether the streak is about to end (`vibestreak nudge`).
13
+ export const SYNC_TIMES = [
14
+ [9, 17],
15
+ [13, 17],
16
+ [17, 17],
17
+ ]
18
+ export const NUDGE_TIME = [20, 30]
19
+
20
+ export const clock = ([h, m]) => `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
21
+ const configDir = () => dirname(CONFIG_PATH)
22
+ const logPath = () => join(configDir(), 'sync.log')
23
+
24
+ // The jobs to schedule: a sync at each sync time, and the evening nudge.
25
+ export const jobs = nudge => [
26
+ { name: 'sync', args: ['sync', '--quiet'], times: SYNC_TIMES },
27
+ ...(nudge ? [{ name: 'nudge', args: ['nudge', '--quiet'], times: [NUDGE_TIME] }] : []),
28
+ ]
29
+
30
+ // A CLI run through npx lives in npm's cache, which can be cleared; a
31
+ // scheduled job needs a copy that stays put, so autosync keeps one in the
32
+ // config folder (refreshed by every `autosync on`).
33
+ export async function stableBin(bin) {
34
+ if (!/[/\\](_npx|npm-cache)[/\\]/.test(bin)) return bin
35
+ const pkg = dirname(dirname(bin))
36
+ const home = join(configDir(), 'cli')
37
+ await rm(home, { recursive: true, force: true })
38
+ await cp(pkg, home, { recursive: true, filter: src => !/[/\\](node_modules|test)([/\\]|$)/.test(src.slice(pkg.length)) })
39
+ return join(home, 'bin', 'vibestreak.mjs')
40
+ }
41
+
42
+ // --- macOS: launchd agents ------------------------------------------------
43
+
44
+ const LAUNCHD = { sync: 'dev.vibestreak.sync', nudge: 'dev.vibestreak.nudge' }
45
+ const agents = () => join(homedir(), 'Library', 'LaunchAgents')
46
+
47
+ export function launchdPlist(label, node, bin, args, times, log) {
48
+ const intervals = times.map(([h, m]) => `<dict><key>Hour</key><integer>${h}</integer><key>Minute</key><integer>${m}</integer></dict>`).join('')
49
+ return `<?xml version="1.0" encoding="UTF-8"?>
50
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
51
+ <plist version="1.0">
52
+ <dict>
53
+ <key>Label</key><string>${label}</string>
54
+ <key>ProgramArguments</key>
55
+ <array><string>${node}</string><string>${bin}</string>${args.map(a => `<string>${a}</string>`).join('')}</array>
56
+ <key>StartCalendarInterval</key>
57
+ <array>${intervals}</array>
58
+ <key>RunAtLoad</key><false/>
59
+ <key>StandardOutPath</key><string>${log}</string>
60
+ <key>StandardErrorPath</key><string>${log}</string>
61
+ </dict>
62
+ </plist>
63
+ `
64
+ }
65
+
66
+ const launchd = {
67
+ async install(node, bin, nudge) {
68
+ await mkdir(agents(), { recursive: true })
69
+ for (const job of jobs(true)) {
70
+ const plist = join(agents(), `${LAUNCHD[job.name]}.plist`)
71
+ await run('launchctl', ['unload', plist]).catch(() => {})
72
+ if (job.name === 'nudge' && !nudge) {
73
+ await rm(plist, { force: true })
74
+ continue
75
+ }
76
+ await writeFile(plist, launchdPlist(LAUNCHD[job.name], node, bin, job.args, job.times, logPath()))
77
+ await run('launchctl', ['load', plist])
78
+ }
79
+ return 'launchd'
80
+ },
81
+ async remove() {
82
+ for (const label of Object.values(LAUNCHD)) {
83
+ const plist = join(agents(), `${label}.plist`)
84
+ await run('launchctl', ['unload', plist]).catch(() => {})
85
+ await rm(plist, { force: true })
86
+ }
87
+ },
88
+ }
89
+
90
+ // --- Linux: systemd user timers, else crontab -------------------------------
91
+
92
+ const units = () => join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'systemd', 'user')
93
+ // systemd's own quoting: backslash-escaped quotes and backslashes, and $ and %
94
+ // (its variables and specifiers) doubled.
95
+ const quoteArg = a => `"${a.replace(/(["\\])/g, '\\$1').replace(/\$/g, '$$$$').replace(/%/g, '%%')}"`
96
+
97
+ export function systemdUnits(node, bin, job, log) {
98
+ const calendar = job.times.map(t => `OnCalendar=*-*-* ${clock(t)}:00`).join('\n')
99
+ return {
100
+ service: `[Unit]
101
+ Description=vibestreak ${job.name}
102
+
103
+ [Service]
104
+ Type=oneshot
105
+ ExecStart=${[node, bin, ...job.args].map(quoteArg).join(' ')}
106
+ StandardOutput=append:${log}
107
+ StandardError=append:${log}
108
+ `,
109
+ // Persistent: a run missed while the machine slept happens when it wakes.
110
+ timer: `[Unit]
111
+ Description=vibestreak ${job.name} schedule
112
+
113
+ [Timer]
114
+ ${calendar}
115
+ Persistent=true
116
+
117
+ [Install]
118
+ WantedBy=timers.target
119
+ `,
120
+ }
121
+ }
122
+
123
+ const CRON_BEGIN = '# vibestreak:begin'
124
+ const CRON_END = '# vibestreak:end'
125
+ const shellQuote = a => `'${a.replace(/'/g, `'\\''`)}'`
126
+
127
+ export function cronBlock(node, bin, nudge, log) {
128
+ const lines = jobs(nudge).flatMap(job =>
129
+ job.times.map(([h, m]) => `${m} ${h} * * * ${[node, bin, ...job.args].map(shellQuote).join(' ')} >> ${shellQuote(log)} 2>&1`),
130
+ )
131
+ return [CRON_BEGIN, ...lines, CRON_END].join('\n')
132
+ }
133
+
134
+ // The crontab with our block replaced (or removed, given no block).
135
+ export const withCronBlock = (current, block) => {
136
+ const kept = current.replace(new RegExp(`\\n?${CRON_BEGIN}[\\s\\S]*?${CRON_END}\\n?`, 'g'), '\n').trim()
137
+ return `${[kept, block].filter(Boolean).join('\n')}\n`
138
+ }
139
+
140
+ async function writeCrontab(block) {
141
+ const current = await run('crontab', ['-l']).then(r => r.stdout, () => '')
142
+ await new Promise((resolve, reject) => {
143
+ const child = execFile('crontab', ['-'], error => (error ? reject(error) : resolve()))
144
+ child.stdin.end(withCronBlock(current, block))
145
+ })
146
+ }
147
+
148
+ const hasUserSystemd = () => run('systemctl', ['--user', 'show-environment']).then(() => true, () => false)
149
+
150
+ const linux = {
151
+ async install(node, bin, nudge) {
152
+ if (await hasUserSystemd()) {
153
+ await mkdir(units(), { recursive: true })
154
+ for (const job of jobs(true)) {
155
+ const name = `vibestreak-${job.name}`
156
+ if (job.name === 'nudge' && !nudge) {
157
+ await run('systemctl', ['--user', 'disable', '--now', `${name}.timer`]).catch(() => {})
158
+ await rm(join(units(), `${name}.timer`), { force: true })
159
+ await rm(join(units(), `${name}.service`), { force: true })
160
+ continue
161
+ }
162
+ const { service, timer } = systemdUnits(node, bin, job, logPath())
163
+ await writeFile(join(units(), `${name}.service`), service)
164
+ await writeFile(join(units(), `${name}.timer`), timer)
165
+ }
166
+ await run('systemctl', ['--user', 'daemon-reload'])
167
+ for (const job of jobs(nudge)) await run('systemctl', ['--user', 'enable', '--now', `vibestreak-${job.name}.timer`])
168
+ return 'systemd'
169
+ }
170
+ await writeCrontab(cronBlock(node, bin, nudge, logPath()))
171
+ return 'crontab'
172
+ },
173
+ async remove() {
174
+ for (const name of ['vibestreak-sync', 'vibestreak-nudge']) {
175
+ await run('systemctl', ['--user', 'disable', '--now', `${name}.timer`]).catch(() => {})
176
+ await rm(join(units(), `${name}.timer`), { force: true })
177
+ await rm(join(units(), `${name}.service`), { force: true })
178
+ }
179
+ await run('systemctl', ['--user', 'daemon-reload']).catch(() => {})
180
+ await writeCrontab('').catch(() => {})
181
+ },
182
+ }
183
+
184
+ // --- Windows: Task Scheduler ------------------------------------------------
185
+
186
+ // Each task runs a small .cmd in the config folder, so the command (and its
187
+ // log redirect) has no length limit and no quoting inside schtasks.
188
+ export const windowsTaskName = (job, [h, m]) => `vibestreak\\${job.name}-${String(h).padStart(2, '0')}${String(m).padStart(2, '0')}`
189
+ const cmdPath = p => p.replace(/%/g, '%%')
190
+ export const windowsScript = (node, bin, job, log) => `@echo off\r\n"${cmdPath(node)}" "${cmdPath(bin)}" ${job.args.join(' ')} >> "${cmdPath(log)}" 2>&1\r\n`
191
+
192
+ const ALL_WINDOWS_TASKS = jobs(true).flatMap(job => job.times.map(t => windowsTaskName(job, t)))
193
+
194
+ const windows = {
195
+ async install(node, bin, nudge) {
196
+ await windows.remove()
197
+ await mkdir(configDir(), { recursive: true })
198
+ for (const job of jobs(nudge)) {
199
+ const script = join(configDir(), `${job.name}.cmd`)
200
+ await writeFile(script, windowsScript(node, bin, job, logPath()))
201
+ for (const time of job.times) {
202
+ await run('schtasks', ['/Create', '/F', '/SC', 'DAILY', '/ST', clock(time), '/TN', windowsTaskName(job, time), '/TR', `"${script}"`])
203
+ }
204
+ }
205
+ return 'Task Scheduler'
206
+ },
207
+ async remove() {
208
+ for (const name of ALL_WINDOWS_TASKS) await run('schtasks', ['/Delete', '/F', '/TN', name]).catch(() => {})
209
+ },
210
+ }
211
+
212
+ const SCHEDULERS = { darwin: launchd, linux, win32: windows }
213
+
214
+ // Schedules the background syncs (and the evening nudge) with the system's
215
+ // own scheduler.
216
+ export async function installAutosync(bin, { nudge = true } = {}) {
217
+ const scheduler = SCHEDULERS[platform()]
218
+ const times = SYNC_TIMES.map(clock)
219
+ const nudgeAt = nudge ? clock(NUDGE_TIME) : undefined
220
+ if (!scheduler) {
221
+ return { installed: false, hint: `No scheduler support on ${platform()} yet. Run \`${bin} sync --quiet\` at ${times.join(', ')}${nudge ? ` and \`nudge --quiet\` at ${nudgeAt}` : ''}.` }
222
+ }
223
+ await mkdir(configDir(), { recursive: true })
224
+ const using = await scheduler.install(process.execPath, await stableBin(bin), nudge)
225
+ return { installed: true, using, times, nudgeAt, log: logPath() }
226
+ }
227
+
228
+ export async function removeAutosync() {
229
+ await SCHEDULERS[platform()]?.remove()
230
+ return { removed: true }
231
+ }
package/src/claude.mjs ADDED
@@ -0,0 +1,83 @@
1
+ import { homedir } from 'node:os'
2
+ import { join } from 'node:path'
3
+
4
+ import { dayOfIso, emptyDay } from './days.mjs'
5
+ import { lines, walk } from './files.mjs'
6
+
7
+ // Claude Code keeps every session as JSONL under ~/.claude/projects (subagents
8
+ // included). Lines are matched with regular expressions rather than parsed:
9
+ // a heavy user has gigabytes of transcripts, and the fields needed sit at the
10
+ // top level of each row.
11
+ //
12
+ // - A message is a user or assistant row; a row copied into another file (a
13
+ // fork, a resume) keeps its uuid and is counted once.
14
+ // - One API request is written as several assistant rows repeating its usage,
15
+ // so tokens count once per requestId, on the day of its first row, each
16
+ // field at its largest.
17
+ const ROLE = /"type":"(user|assistant)"/
18
+ const TIMESTAMP = /"timestamp":"([^"]+)"/
19
+ const UUID = /"uuid":"([0-9a-f-]{36})"/
20
+ const SESSION = /"sessionId":"([^"]+)"/
21
+ const REQUEST = /"requestId":"([^"]+)"/
22
+ const MODEL = /"model":"((?:claude|gpt|gemini)[^"]*)"/
23
+ const TOOL_USE = /"type":"tool_use"/g
24
+ const USAGE = {
25
+ input: /"input_tokens":(\d+)/,
26
+ output: /"output_tokens":(\d+)/,
27
+ cacheRead: /"cache_read_input_tokens":(\d+)/,
28
+ cacheWrite: /"cache_creation_input_tokens":(\d+)/,
29
+ }
30
+
31
+ export async function collectClaude({ root = join(homedir(), '.claude', 'projects'), since } = {}) {
32
+ const days = new Map()
33
+ const seen = new Set()
34
+ const sessions = new Map()
35
+ const requests = new Map()
36
+ const day = date => {
37
+ if (!days.has(date)) days.set(date, { ...emptyDay(date, 'claude'), models: new Set() })
38
+ return days.get(date)
39
+ }
40
+
41
+ for await (const path of walk(root, '.jsonl')) {
42
+ for await (const line of lines(path)) {
43
+ const role = ROLE.exec(line)?.[1]
44
+ if (!role) continue
45
+ const date = dayOfIso(TIMESTAMP.exec(line)?.[1] ?? '')
46
+ if (!date || (since && date < since)) continue
47
+ const uuid = UUID.exec(line)?.[1]
48
+ if (uuid) {
49
+ if (seen.has(uuid)) continue
50
+ seen.add(uuid)
51
+ }
52
+ const counts = day(date)
53
+ counts.messages++
54
+ counts.toolCalls += line.match(TOOL_USE)?.length ?? 0
55
+ const session = SESSION.exec(line)?.[1]
56
+ if (session) {
57
+ if (!sessions.has(date)) sessions.set(date, new Set())
58
+ sessions.get(date).add(session)
59
+ }
60
+ if (role !== 'assistant') continue
61
+ const model = MODEL.exec(line)?.[1]
62
+ if (model && model !== '<synthetic>') counts.models.add(model)
63
+ const request = REQUEST.exec(line)?.[1]
64
+ if (!request) continue
65
+ const usage = requests.get(request) ?? { date, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
66
+ for (const [field, pattern] of Object.entries(USAGE)) {
67
+ const value = Number(pattern.exec(line)?.[1] ?? 0)
68
+ if (value > usage[field]) usage[field] = value
69
+ }
70
+ requests.set(request, usage)
71
+ }
72
+ }
73
+
74
+ for (const { date, ...usage } of requests.values()) {
75
+ const counts = day(date)
76
+ for (const field of Object.keys(USAGE)) counts[field] += usage[field]
77
+ }
78
+ for (const [date, ids] of sessions) day(date).sessions = ids.size
79
+
80
+ return [...days.values()]
81
+ .map(d => ({ ...d, models: [...d.models].sort() }))
82
+ .sort((a, b) => a.date.localeCompare(b.date))
83
+ }