bestviber 0.1.0 → 0.2.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
@@ -2,12 +2,28 @@
2
2
 
3
3
  Focus timer and focus analytics for you and your AI agents.
4
4
 
5
+ ## Claude Code, in two commands
6
+
5
7
  ```sh
6
8
  npm install -g bestviber
7
- bestviber login # approve a code in your browser
9
+ bestviber login
10
+ ```
11
+
12
+ `login` opens your browser to approve a short code, installs the agent skill,
13
+ and asks whether to track your Claude Code sessions automatically. Say yes and
14
+ there is nothing else to do: every Claude Code session is timed and filed
15
+ under the repository you work in. Quiet stretches are recorded as pauses, and
16
+ ten quiet minutes end the session. Then ask your agent "how was my week?", or
17
+ run `bestviber week`.
18
+
19
+ `bestviber logout` signs out and removes the tracking.
20
+
21
+ ## By hand
22
+
23
+ ```sh
8
24
  bestviber start # start a focus session
9
25
  bestviber stop --note "wrote the launch post"
10
- bestviber week # this week: total, by day, by hour, by tag, vs last week
26
+ bestviber week # this week: total, by day, by hour, by tag and project
11
27
  bestviber insights # peak hours, best weekday, streaks, trends
12
28
  ```
13
29
 
@@ -15,11 +31,13 @@ Every command takes `--json` for scripts and agents. `bestviber tools` lists
15
31
  every action the server offers and `bestviber run <action>` runs any of them,
16
32
  so new features work without updating the CLI.
17
33
 
18
- ## Agents
34
+ ## Other agents
19
35
 
20
- - `bestviber skill install` adds the bestviber skill for Claude Code and
21
- other agents that read `~/.agents/skills`.
36
+ - The skill goes to `~/.claude/skills/bestviber`, and also to
37
+ `~/.agents/skills/bestviber` if you have an `~/.agents` folder. Reinstall or
38
+ update it with `bestviber skill install`.
22
39
  - `bestviber mcp` prints how to connect any MCP client to
23
- `https://www.bestviber.com/api/mcp`.
40
+ `https://www.bestviber.com/api/mcp`. Sign in first: the setup line uses
41
+ your token.
24
42
 
25
43
  Free. Same account as [bestviber.com](https://www.bestviber.com).
package/bin/bestviber.mjs CHANGED
@@ -10,9 +10,9 @@ const pkg = JSON.parse(
10
10
 
11
11
  function help() {
12
12
  const width = Math.max(...Object.keys(commands).map((c) => c.length))
13
- const lines = Object.entries(commands).map(
14
- ([name, c]) => ` ${name.padEnd(width)} ${c.summary}`,
15
- )
13
+ const lines = Object.entries(commands)
14
+ .filter(([, c]) => !c.hidden)
15
+ .map(([name, c]) => ` ${name.padEnd(width)} ${c.summary}`)
16
16
  return `bestviber ${pkg.version}: focus timer and focus analytics for you and your agents
17
17
 
18
18
  Usage: bestviber <command> [options]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bestviber",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Focus timer and focus analytics for you and your AI agents. Start, pause and finish sessions, and get daily, weekly and monthly stats from the terminal.",
5
5
  "keywords": [
6
6
  "focus",
package/src/commands.mjs CHANGED
@@ -18,6 +18,15 @@ import {
18
18
  writeConfig,
19
19
  } from './client.mjs'
20
20
  import { clock, duration, elapsed, stats } from './format.mjs'
21
+ import {
22
+ clearTrackingState,
23
+ hasClaudeCode,
24
+ hooksInstalled,
25
+ installHooks,
26
+ launchHook,
27
+ runHookWorker,
28
+ uninstallHooks,
29
+ } from './hooks.mjs'
21
30
 
22
31
  /**
23
32
  * @typedef {{ flags: Record<string, any>, args: string[], json: boolean }} Ctx
@@ -25,10 +34,80 @@ import { clock, duration, elapsed, stats } from './format.mjs'
25
34
  * summary: string,
26
35
  * usage?: string,
27
36
  * actions: string[],
37
+ * hidden?: boolean,
28
38
  * run: (ctx: Ctx) => Promise<unknown>,
29
39
  * }} Command
30
40
  */
31
41
 
42
+ /** Download the agent skill into the folders agents read. */
43
+ async function installSkill(/** @type {string | undefined} */ dir) {
44
+ // ~/.claude/skills for Claude Code; ~/.agents/skills is the shared folder
45
+ // other agents read, when it exists.
46
+ const dirs = dir
47
+ ? [dir]
48
+ : [
49
+ join(homedir(), '.claude', 'skills'),
50
+ ...(existsSync(join(homedir(), '.agents'))
51
+ ? [join(homedir(), '.agents', 'skills')]
52
+ : []),
53
+ ]
54
+ const files = await Promise.all(
55
+ ['SKILL.md', 'reference.md'].map(async (name) => {
56
+ const res = await fetch(`${await baseUrl()}/skill/${name}`)
57
+ if (!res.ok)
58
+ throw new CliError(`Could not download ${name} (HTTP ${res.status}).`)
59
+ return { name, body: await res.text() }
60
+ }),
61
+ )
62
+ const written = []
63
+ for (const d of dirs) {
64
+ const target = join(d, 'bestviber')
65
+ await mkdir(target, { recursive: true })
66
+ for (const f of files) await writeFile(join(target, f.name), f.body)
67
+ written.push(target)
68
+ }
69
+ return written
70
+ }
71
+
72
+ /** Yes/no on a terminal; with no terminal (an agent ran us), the default. */
73
+ async function confirm(/** @type {string} */ question, fallback = true) {
74
+ if (!process.stdin.isTTY || !process.stderr.isTTY) return fallback
75
+ const { createInterface } = await import('node:readline/promises')
76
+ const rl = createInterface({ input: process.stdin, output: process.stderr })
77
+ const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase()
78
+ rl.close()
79
+ return answer === '' ? fallback : answer.startsWith('y')
80
+ }
81
+
82
+ /** After sign-in: the skill, and automatic tracking for Claude Code. */
83
+ async function setUpAgents() {
84
+ const lines = []
85
+ try {
86
+ await installSkill(undefined)
87
+ lines.push('Installed the agent skill.')
88
+ } catch (err) {
89
+ lines.push(`Skipped the agent skill: ${/** @type {Error} */ (err).message}`)
90
+ }
91
+ if (!hasClaudeCode()) return lines
92
+ const ask = process.stdin.isTTY && process.stderr.isTTY
93
+ if (
94
+ await confirm(
95
+ '\nTrack your Claude Code sessions automatically? Each session is filed under the repository you work in.',
96
+ )
97
+ ) {
98
+ await installHooks()
99
+ lines.push(
100
+ 'Claude Code sessions are now tracked automatically, by project.' +
101
+ (ask ? '' : ' (Turn it off with `bestviber tracking off`.)'),
102
+ )
103
+ } else {
104
+ lines.push(
105
+ 'Automatic tracking is off. Turn it on with `bestviber tracking on`.',
106
+ )
107
+ }
108
+ return lines
109
+ }
110
+
32
111
  const sleep = (/** @type {number} */ ms) =>
33
112
  new Promise((r) => setTimeout(r, ms))
34
113
 
@@ -121,10 +200,11 @@ export const commands = {
121
200
  email: me?.user?.email,
122
201
  })
123
202
  process.stderr.write(' approved.\n')
203
+ const setup = await setUpAgents()
124
204
  return out(
125
205
  ctx,
126
- { ok: true, email: me?.user?.email },
127
- `Signed in as ${me?.user?.email ?? 'you'}.`,
206
+ { ok: true, email: me?.user?.email, setup },
207
+ [`Signed in as ${me?.user?.email ?? 'you'}.`, ...setup].join('\n'),
128
208
  )
129
209
  }
130
210
  const err = res.data?.error
@@ -152,7 +232,15 @@ export const commands = {
152
232
  if (await token())
153
233
  await request('/api/auth/sign-out', { body: {} }).catch(() => {})
154
234
  await clearConfig()
155
- return out(ctx, { ok: true }, 'Signed out.')
235
+ const removed = await uninstallHooks().catch(() => false)
236
+ await clearTrackingState()
237
+ return out(
238
+ ctx,
239
+ { ok: true, trackingRemoved: removed },
240
+ removed
241
+ ? 'Signed out, and stopped tracking Claude Code sessions.'
242
+ : 'Signed out.',
243
+ )
156
244
  },
157
245
  },
158
246
 
@@ -196,6 +284,12 @@ export const commands = {
196
284
  }
197
285
  if (ctx.json) return config
198
286
  return [
287
+ ...((await token())
288
+ ? []
289
+ : [
290
+ 'Not signed in yet: run `bestviber login` first, or the token below is empty.',
291
+ '',
292
+ ]),
199
293
  'Claude Code:',
200
294
  ` claude mcp add --transport http bestviber ${url} \\`,
201
295
  ' --header "Authorization: Bearer $(bestviber token)" \\',
@@ -217,33 +311,9 @@ export const commands = {
217
311
  async run(ctx) {
218
312
  if (ctx.args[0] !== 'install')
219
313
  throw new CliError('Usage: bestviber skill install [--dir <folder>]')
220
- // ~/.claude/skills for Claude Code; ~/.agents/skills is the shared
221
- // folder other agents read, when it exists.
222
- const dirs = ctx.flags.dir
223
- ? [String(ctx.flags.dir)]
224
- : [
225
- join(homedir(), '.claude', 'skills'),
226
- ...(existsSync(join(homedir(), '.agents'))
227
- ? [join(homedir(), '.agents', 'skills')]
228
- : []),
229
- ]
230
- const files = await Promise.all(
231
- ['SKILL.md', 'reference.md'].map(async (name) => {
232
- const res = await fetch(`${await baseUrl()}/skill/${name}`)
233
- if (!res.ok)
234
- throw new CliError(
235
- `Could not download ${name} (HTTP ${res.status}).`,
236
- )
237
- return { name, body: await res.text() }
238
- }),
314
+ const written = await installSkill(
315
+ ctx.flags.dir ? String(ctx.flags.dir) : undefined,
239
316
  )
240
- const written = []
241
- for (const dir of dirs) {
242
- const target = join(dir, 'bestviber')
243
- await mkdir(target, { recursive: true })
244
- for (const f of files) await writeFile(join(target, f.name), f.body)
245
- written.push(target)
246
- }
247
317
  return out(
248
318
  ctx,
249
319
  { installed: written },
@@ -252,6 +322,56 @@ export const commands = {
252
322
  },
253
323
  },
254
324
 
325
+ // Called by Claude Code, not by people: see hooks.mjs.
326
+ hook: {
327
+ summary: 'Claude Code hook entry point',
328
+ actions: [
329
+ 'get_active_session',
330
+ 'start_focus_session',
331
+ 'pause_focus_session',
332
+ 'resume_focus_session',
333
+ 'finish_focus_session',
334
+ ],
335
+ hidden: true,
336
+ async run(ctx) {
337
+ if (ctx.flags.run) await runHookWorker()
338
+ else await launchHook()
339
+ return undefined
340
+ },
341
+ },
342
+
343
+ tracking: {
344
+ summary: 'Automatic tracking of Claude Code sessions: on, off or status',
345
+ usage: 'tracking [on | off]',
346
+ actions: [],
347
+ hidden: true,
348
+ async run(ctx) {
349
+ const sub = ctx.args[0]
350
+ if (sub === 'on') {
351
+ if (!(await token()))
352
+ throw new CliError('Not signed in. Run `bestviber login` first.', 2)
353
+ const r = await installHooks()
354
+ return out(
355
+ ctx,
356
+ { on: true, ...r },
357
+ 'Claude Code sessions are tracked automatically.',
358
+ )
359
+ }
360
+ if (sub === 'off') {
361
+ await uninstallHooks()
362
+ await clearTrackingState()
363
+ return out(ctx, { on: false }, 'Automatic tracking is off.')
364
+ }
365
+ if (sub) throw new CliError('Usage: bestviber tracking [on | off]')
366
+ const on = await hooksInstalled()
367
+ return out(
368
+ ctx,
369
+ { on },
370
+ on ? 'Automatic tracking is on.' : 'Automatic tracking is off.',
371
+ )
372
+ },
373
+ },
374
+
255
375
  // ───── timer ─────
256
376
  start: {
257
377
  summary: 'Start a focus session',
package/src/format.mjs CHANGED
@@ -83,5 +83,15 @@ export function stats(stats) {
83
83
  for (const t of stats.byTag)
84
84
  lines.push(` ${t.tag.padEnd(16)} ${duration(t.ms)}`)
85
85
  }
86
+
87
+ if (stats.byProject?.length) {
88
+ lines.push('', ' by project')
89
+ const width = Math.max(
90
+ 16,
91
+ ...stats.byProject.map((/** @type {any} */ p) => p.project.length),
92
+ )
93
+ for (const p of stats.byProject)
94
+ lines.push(` ${p.project.padEnd(width)} ${duration(p.ms)}`)
95
+ }
86
96
  return lines.join('\n')
87
97
  }
package/src/hooks.mjs ADDED
@@ -0,0 +1,306 @@
1
+ // Claude Code hooks: install them, and run them.
2
+ //
3
+ // Claude Code runs `bestviber hook` on SessionStart, UserPromptSubmit, Stop
4
+ // and SessionEnd. That command must be instant and silent (stdout on
5
+ // SessionStart and UserPromptSubmit is added to Claude's context, and
6
+ // SessionEnd gets about 1.5 s before exit), so it only hands the event to a
7
+ // detached worker and exits. The worker talks to the server.
8
+
9
+ import { execFileSync, spawn } from 'node:child_process'
10
+ import { existsSync } from 'node:fs'
11
+ import {
12
+ appendFile,
13
+ copyFile,
14
+ mkdir,
15
+ readFile,
16
+ rename,
17
+ rm,
18
+ stat,
19
+ writeFile,
20
+ } from 'node:fs/promises'
21
+ import { homedir } from 'node:os'
22
+ import { dirname, join } from 'node:path'
23
+ import { fileURLToPath } from 'node:url'
24
+ import { call, configPath, token } from './client.mjs'
25
+ import { EMPTY_STATE, decide, projectName } from './tracker.mjs'
26
+
27
+ const BIN = fileURLToPath(new URL('../bin/bestviber.mjs', import.meta.url))
28
+ const stateDir = () => dirname(configPath())
29
+ const statePath = () => join(stateDir(), 'tracking.json')
30
+ const logPath = () => join(stateDir(), 'hook.log')
31
+ const lockPath = () => join(stateDir(), 'tracking.lock')
32
+
33
+ // ───── the hook command ─────
34
+
35
+ /** Read the event from stdin and hand it to a detached worker. Never prints. */
36
+ export async function launchHook() {
37
+ const payload = await readStdin(2000)
38
+ try {
39
+ spawn(process.execPath, [BIN, 'hook', '--run'], {
40
+ detached: true,
41
+ stdio: 'ignore',
42
+ env: { ...process.env, BESTVIBER_HOOK_EVENT: payload },
43
+ }).unref()
44
+ } catch {
45
+ // Tracking is best-effort; never get in the way of Claude Code.
46
+ }
47
+ }
48
+
49
+ function readStdin(/** @type {number} */ ms) {
50
+ return new Promise((resolve) => {
51
+ let data = ''
52
+ const done = () => resolve(data)
53
+ const timer = setTimeout(done, ms)
54
+ process.stdin.setEncoding('utf8')
55
+ process.stdin.on('data', (c) => (data += c))
56
+ process.stdin.on('end', () => {
57
+ clearTimeout(timer)
58
+ done()
59
+ })
60
+ process.stdin.on('error', done)
61
+ })
62
+ }
63
+
64
+ /** Map a Claude Code hook event to a tracker event, or null to ignore it. */
65
+ function trackerEvent(/** @type {any} */ e) {
66
+ switch (e?.hook_event_name) {
67
+ case 'SessionStart':
68
+ return 'start'
69
+ case 'UserPromptSubmit':
70
+ return 'prompt'
71
+ case 'Stop':
72
+ return 'reply'
73
+ case 'SessionEnd':
74
+ // /clear and /resume also end a session id, but the work goes on.
75
+ return ['prompt_input_exit', 'logout', 'other'].includes(e.reason)
76
+ ? 'end'
77
+ : null
78
+ default:
79
+ return null
80
+ }
81
+ }
82
+
83
+ // ───── the worker ─────
84
+
85
+ export async function runHookWorker() {
86
+ try {
87
+ const e = JSON.parse(process.env.BESTVIBER_HOOK_EVENT || '{}')
88
+ const event = trackerEvent(e)
89
+ if (!event || !(await token())) return
90
+ await withLock(async () => {
91
+ const state = await readState()
92
+ const project = detectProject(e.cwd || process.cwd())
93
+ const open =
94
+ event === 'reply'
95
+ ? state.sessionId
96
+ ? { id: state.sessionId, status: 'active', project: state.project }
97
+ : null
98
+ : await call('get_active_session')
99
+ const result = decide({ state, open, event, now: new Date(), project })
100
+ const next = { ...result.state }
101
+ for (const c of result.calls) {
102
+ const res = await call(c.name, c.input)
103
+ if (c.name === 'start_focus_session') next.sessionId = res?.id ?? null
104
+ }
105
+ await writeState(next)
106
+ })
107
+ } catch (err) {
108
+ await log(String(/** @type {Error} */ (err)?.stack ?? err))
109
+ }
110
+ }
111
+
112
+ function detectProject(/** @type {string} */ cwd) {
113
+ const git = (/** @type {string[]} */ args) => {
114
+ try {
115
+ return (
116
+ execFileSync('git', ['-C', cwd, ...args], {
117
+ encoding: 'utf8',
118
+ stdio: ['ignore', 'pipe', 'ignore'],
119
+ timeout: 1500,
120
+ }).trim() || null
121
+ )
122
+ } catch {
123
+ return null
124
+ }
125
+ }
126
+ return projectName({
127
+ remote: git(['remote', 'get-url', 'origin']),
128
+ toplevel: git(['rev-parse', '--show-toplevel']),
129
+ cwd,
130
+ })
131
+ }
132
+
133
+ async function readState() {
134
+ try {
135
+ return {
136
+ ...EMPTY_STATE,
137
+ ...JSON.parse(await readFile(statePath(), 'utf8')),
138
+ }
139
+ } catch {
140
+ return EMPTY_STATE
141
+ }
142
+ }
143
+
144
+ async function writeState(/** @type {object} */ state) {
145
+ await mkdir(stateDir(), { recursive: true, mode: 0o700 })
146
+ await writeFile(statePath(), JSON.stringify(state) + '\n', { mode: 0o600 })
147
+ }
148
+
149
+ export async function clearTrackingState() {
150
+ await rm(statePath(), { force: true })
151
+ }
152
+
153
+ /** One worker at a time: a prompt and a reply can land together. */
154
+ async function withLock(/** @type {() => Promise<void>} */ fn) {
155
+ await mkdir(stateDir(), { recursive: true, mode: 0o700 })
156
+ const deadline = Date.now() + 8000
157
+ for (;;) {
158
+ try {
159
+ await mkdir(lockPath())
160
+ break
161
+ } catch {
162
+ // A lock older than 30 s belongs to a worker that died.
163
+ const age = await stat(lockPath())
164
+ .then((s) => Date.now() - s.mtimeMs)
165
+ .catch(() => 0)
166
+ if (age > 30_000) await rm(lockPath(), { recursive: true, force: true })
167
+ else if (Date.now() > deadline) return
168
+ else await new Promise((r) => setTimeout(r, 150))
169
+ }
170
+ }
171
+ try {
172
+ await fn()
173
+ } finally {
174
+ await rm(lockPath(), { recursive: true, force: true })
175
+ }
176
+ }
177
+
178
+ async function log(/** @type {string} */ line) {
179
+ try {
180
+ const size = await stat(logPath())
181
+ .then((s) => s.size)
182
+ .catch(() => 0)
183
+ if (size > 100_000) await rm(logPath(), { force: true })
184
+ await appendFile(logPath(), `${new Date().toISOString()} ${line}\n`)
185
+ } catch {
186
+ // Nowhere left to report to.
187
+ }
188
+ }
189
+
190
+ // ───── installing into Claude Code ─────
191
+
192
+ const claudeDir = () =>
193
+ process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude')
194
+ const settingsPath = () => join(claudeDir(), 'settings.json')
195
+
196
+ /** Whether Claude Code is set up on this machine. */
197
+ export const hasClaudeCode = () => existsSync(claudeDir())
198
+
199
+ /** The command Claude Code runs, with an absolute path (hooks may not share the shell's PATH). */
200
+ function hookCommand() {
201
+ const q = (/** @type {string} */ s) => `"${s.replace(/(["\\$`])/g, '\\$1')}"`
202
+ let onPath = null
203
+ try {
204
+ onPath = execFileSync('sh', ['-c', 'command -v bestviber'], {
205
+ encoding: 'utf8',
206
+ stdio: ['ignore', 'pipe', 'ignore'],
207
+ }).trim()
208
+ } catch {
209
+ // Not on PATH (for example run through pnpm dlx).
210
+ }
211
+ return onPath ? `${q(onPath)} hook` : `${q(process.execPath)} ${q(BIN)} hook`
212
+ }
213
+
214
+ const isOurs = (/** @type {any} */ h) =>
215
+ typeof h?.command === 'string' &&
216
+ /bestviber(\.mjs)?"?\s+hook$/.test(h.command)
217
+
218
+ /** Remove our hook entries from every event, keeping everything else. */
219
+ function withoutOurs(/** @type {Record<string, any>} */ hooks) {
220
+ /** @type {Record<string, any>} */
221
+ const cleaned = {}
222
+ for (const [event, groups] of Object.entries(hooks ?? {})) {
223
+ if (!Array.isArray(groups)) {
224
+ cleaned[event] = groups
225
+ continue
226
+ }
227
+ const kept = groups
228
+ .map((g) => ({
229
+ ...g,
230
+ hooks: Array.isArray(g?.hooks)
231
+ ? g.hooks.filter((h) => !isOurs(h))
232
+ : g?.hooks,
233
+ }))
234
+ .filter((g) => !Array.isArray(g.hooks) || g.hooks.length > 0)
235
+ if (kept.length) cleaned[event] = kept
236
+ }
237
+ return cleaned
238
+ }
239
+
240
+ async function readSettings() {
241
+ try {
242
+ return JSON.parse(await readFile(settingsPath(), 'utf8'))
243
+ } catch (err) {
244
+ if (/** @type {any} */ (err).code === 'ENOENT') return {}
245
+ throw new Error(
246
+ `Could not read ${settingsPath()} (${/** @type {Error} */ (err).message}). Left it unchanged.`,
247
+ )
248
+ }
249
+ }
250
+
251
+ async function writeSettings(/** @type {object} */ settings) {
252
+ const path = settingsPath()
253
+ await mkdir(dirname(path), { recursive: true })
254
+ if (existsSync(path) && !existsSync(`${path}.bestviber-backup`))
255
+ await copyFile(path, `${path}.bestviber-backup`)
256
+ const tmp = `${path}.bestviber-tmp`
257
+ await writeFile(tmp, JSON.stringify(settings, null, 2) + '\n')
258
+ await rename(tmp, path)
259
+ }
260
+
261
+ export async function installHooks() {
262
+ const settings = await readSettings()
263
+ const command = hookCommand()
264
+ const entry = { type: 'command', command, timeout: 10 }
265
+ const hooks = withoutOurs(settings.hooks)
266
+ /** @type {Array<[string, string | undefined]>} */
267
+ const events = [
268
+ ['SessionStart', undefined],
269
+ ['UserPromptSubmit', undefined],
270
+ ['Stop', undefined],
271
+ ['SessionEnd', 'prompt_input_exit|logout|other'],
272
+ ]
273
+ for (const [event, matcher] of events)
274
+ hooks[event] = [
275
+ ...(hooks[event] ?? []),
276
+ matcher ? { matcher, hooks: [entry] } : { hooks: [entry] },
277
+ ]
278
+ await writeSettings({ ...settings, hooks })
279
+ return { settings: settingsPath(), command }
280
+ }
281
+
282
+ export async function uninstallHooks() {
283
+ if (!existsSync(settingsPath())) return false
284
+ const settings = await readSettings()
285
+ const hooks = withoutOurs(settings.hooks)
286
+ const changed = JSON.stringify(hooks) !== JSON.stringify(settings.hooks ?? {})
287
+ if (!changed) return false
288
+ const next = { ...settings, hooks }
289
+ if (Object.keys(hooks).length === 0) delete next.hooks
290
+ await writeSettings(next)
291
+ return true
292
+ }
293
+
294
+ export async function hooksInstalled() {
295
+ try {
296
+ const hooks = (await readSettings()).hooks ?? {}
297
+ return Object.values(hooks).some(
298
+ (groups) =>
299
+ Array.isArray(groups) && groups.some((g) => g?.hooks?.some?.(isOurs)),
300
+ )
301
+ } catch {
302
+ return false
303
+ }
304
+ }
305
+
306
+ export const __test = { withoutOurs, isOurs, trackerEvent }
@@ -0,0 +1,101 @@
1
+ import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, test } from 'vite-plus/test'
5
+ import {
6
+ __test,
7
+ hooksInstalled,
8
+ installHooks,
9
+ uninstallHooks,
10
+ } from './hooks.mjs'
11
+
12
+ const { trackerEvent } = __test
13
+
14
+ describe('which Claude Code events count', () => {
15
+ test.each([
16
+ [{ hook_event_name: 'SessionStart', source: 'startup' }, 'start'],
17
+ [{ hook_event_name: 'UserPromptSubmit', prompt: 'hi' }, 'prompt'],
18
+ [{ hook_event_name: 'Stop' }, 'reply'],
19
+ [{ hook_event_name: 'SessionEnd', reason: 'prompt_input_exit' }, 'end'],
20
+ [{ hook_event_name: 'SessionEnd', reason: 'logout' }, 'end'],
21
+ // /clear and /resume end a session id but not the work
22
+ [{ hook_event_name: 'SessionEnd', reason: 'clear' }, null],
23
+ [{ hook_event_name: 'SessionEnd', reason: 'resume' }, null],
24
+ [{ hook_event_name: 'PreToolUse' }, null],
25
+ ])('%j', (event, expected) => {
26
+ expect(trackerEvent(event)).toBe(expected)
27
+ })
28
+ })
29
+
30
+ describe('installing into ~/.claude/settings.json', () => {
31
+ let dir = ''
32
+ const saved = process.env.CLAUDE_CONFIG_DIR
33
+ const settings = async () =>
34
+ JSON.parse(await readFile(join(dir, 'settings.json'), 'utf8'))
35
+
36
+ beforeEach(async () => {
37
+ dir = await mkdtemp(join(tmpdir(), 'bv-claude-'))
38
+ process.env.CLAUDE_CONFIG_DIR = dir
39
+ })
40
+ afterEach(() => {
41
+ if (saved === undefined) delete process.env.CLAUDE_CONFIG_DIR
42
+ else process.env.CLAUDE_CONFIG_DIR = saved
43
+ })
44
+
45
+ const theirs = {
46
+ theme: 'dark',
47
+ hooks: {
48
+ Stop: [{ hooks: [{ type: 'command', command: 'notify-send done' }] }],
49
+ PreToolUse: [
50
+ {
51
+ matcher: 'Bash',
52
+ hooks: [{ type: 'command', command: './guard.sh' }],
53
+ },
54
+ ],
55
+ },
56
+ }
57
+
58
+ test('adds our four hooks and keeps everything else', async () => {
59
+ await writeFile(join(dir, 'settings.json'), JSON.stringify(theirs))
60
+ await installHooks()
61
+ const s = await settings()
62
+ expect(s.theme).toBe('dark')
63
+ expect(s.hooks.PreToolUse).toEqual(theirs.hooks.PreToolUse)
64
+ expect(s.hooks.Stop[0]).toEqual(theirs.hooks.Stop[0])
65
+ for (const event of [
66
+ 'SessionStart',
67
+ 'UserPromptSubmit',
68
+ 'Stop',
69
+ 'SessionEnd',
70
+ ])
71
+ expect(JSON.stringify(s.hooks[event])).toMatch(/bestviber.*hook/)
72
+ expect(s.hooks.SessionEnd.at(-1).matcher).toBe(
73
+ 'prompt_input_exit|logout|other',
74
+ )
75
+ expect(await hooksInstalled()).toBe(true)
76
+ })
77
+
78
+ test('installing twice does not duplicate', async () => {
79
+ await installHooks()
80
+ await installHooks()
81
+ const s = await settings()
82
+ expect(s.hooks.UserPromptSubmit).toHaveLength(1)
83
+ })
84
+
85
+ test('uninstall removes only ours', async () => {
86
+ await writeFile(join(dir, 'settings.json'), JSON.stringify(theirs))
87
+ await installHooks()
88
+ expect(await uninstallHooks()).toBe(true)
89
+ const s = await settings()
90
+ expect(s).toEqual(theirs)
91
+ expect(await hooksInstalled()).toBe(false)
92
+ })
93
+
94
+ test('a settings file that is not valid JSON is left alone', async () => {
95
+ await writeFile(join(dir, 'settings.json'), '{ not json')
96
+ await expect(installHooks()).rejects.toThrow(/Left it unchanged/)
97
+ expect(await readFile(join(dir, 'settings.json'), 'utf8')).toBe(
98
+ '{ not json',
99
+ )
100
+ })
101
+ })
@@ -0,0 +1,108 @@
1
+ // Automatic tracking for Claude Code: what to do when a hook fires.
2
+ //
3
+ // Pure: given the saved state, the session the server says is open, the
4
+ // event and the time, return the API calls to make and the next state. The
5
+ // hook runner (hooks.mjs) does the I/O; the tests drive this directly.
6
+ //
7
+ // Rules (agreed with the product owner):
8
+ // - A prompt, or Claude Code starting, makes sure a session is running for
9
+ // the current project. A different project's session is closed first.
10
+ // - Claude finishing a reply counts as activity; no API call is needed.
11
+ // - Quiet under GRACE (reading a reply) still counts as focus.
12
+ // - Quiet from GRACE up to FINISH is recorded as a pause in the same session.
13
+ // - Quiet of FINISH or more ends the session when the quiet began; the next
14
+ // prompt starts a new one.
15
+ // - A session someone started by hand (CLI, app, agent) is left alone.
16
+
17
+ export const GRACE_MS = 2 * 60_000
18
+ export const FINISH_MS = 10 * 60_000
19
+
20
+ /**
21
+ * @typedef {{ sessionId: string | null, project: string | null, lastActivity: string | null }} TrackerState
22
+ * @typedef {{ id: string, status: string, project?: string | null } | null} OpenSession
23
+ * @typedef {'start' | 'prompt' | 'reply' | 'end'} TrackerEvent
24
+ * @typedef {{ name: string, input: Record<string, unknown> }} Call
25
+ */
26
+
27
+ /** @type {TrackerState} */
28
+ export const EMPTY_STATE = {
29
+ sessionId: null,
30
+ project: null,
31
+ lastActivity: null,
32
+ }
33
+
34
+ /**
35
+ * @param {{ state: TrackerState, open: OpenSession, event: TrackerEvent, now: Date, project: string }} input
36
+ * @returns {{ calls: Array<Call>, state: TrackerState }}
37
+ */
38
+ export function decide({ state, open, event, now, project }) {
39
+ const nowIso = now.toISOString()
40
+ const last = state.lastActivity ? new Date(state.lastActivity) : null
41
+ const gap = last ? now.getTime() - last.getTime() : 0
42
+ const ours = open !== null && open.id === state.sessionId
43
+ /** When the work in the open session really stopped. */
44
+ const endedAt = last && gap >= GRACE_MS ? state.lastActivity : nowIso
45
+
46
+ // Someone else's session is open: stay out of its way.
47
+ if (open && !ours)
48
+ return {
49
+ calls: [],
50
+ state: { sessionId: null, project: null, lastActivity: nowIso },
51
+ }
52
+
53
+ if (event === 'reply')
54
+ return { calls: [], state: { ...state, lastActivity: nowIso } }
55
+
56
+ if (event === 'end') {
57
+ if (!ours) return { calls: [], state: EMPTY_STATE }
58
+ return {
59
+ calls: [{ name: 'finish_focus_session', input: { at: endedAt } }],
60
+ state: EMPTY_STATE,
61
+ }
62
+ }
63
+
64
+ // 'start' or 'prompt': make sure this project's session is running.
65
+ /** @type {Array<Call>} */
66
+ const calls = []
67
+ const start = { name: 'start_focus_session', input: { project } }
68
+
69
+ if (!ours) calls.push(start)
70
+ else if (open.project !== project || gap >= FINISH_MS) {
71
+ calls.push({ name: 'finish_focus_session', input: { at: endedAt } }, start)
72
+ } else if (gap >= GRACE_MS) {
73
+ calls.push(
74
+ { name: 'pause_focus_session', input: { at: state.lastActivity } },
75
+ { name: 'resume_focus_session', input: {} },
76
+ )
77
+ }
78
+
79
+ return {
80
+ calls,
81
+ // A new session's id comes back from start_focus_session; the runner
82
+ // fills it in.
83
+ state: {
84
+ sessionId: calls.includes(start) ? null : state.sessionId,
85
+ project,
86
+ lastActivity: nowIso,
87
+ },
88
+ }
89
+ }
90
+
91
+ /**
92
+ * The project for a working directory: "owner/repo" from the git remote,
93
+ * else the repository folder name, else the folder name.
94
+ *
95
+ * @param {{ remote: string | null, toplevel: string | null, cwd: string }} where
96
+ */
97
+ export function projectName({ remote, toplevel, cwd }) {
98
+ if (remote) {
99
+ // git@github.com:owner/repo.git, https://host/owner/repo(.git), ssh://…
100
+ const m = remote
101
+ .trim()
102
+ .replace(/\.git$/, '')
103
+ .match(/[:/]([^/:]+\/[^/:]+)$/)
104
+ if (m?.[1]) return m[1]
105
+ }
106
+ const dir = (toplevel || cwd).replace(/\/+$/, '')
107
+ return dir.slice(dir.lastIndexOf('/') + 1) || dir
108
+ }
@@ -0,0 +1,174 @@
1
+ import { describe, expect, test } from 'vite-plus/test'
2
+ import { EMPTY_STATE, decide, projectName } from './tracker.mjs'
3
+
4
+ const at = (hm) => new Date(`2026-11-02T${hm}:00Z`)
5
+ const iso = (hm) => at(hm).toISOString()
6
+ const P = 'amar/bestviber'
7
+ const mine = (overrides = {}) => ({
8
+ id: 's1',
9
+ status: 'active',
10
+ project: P,
11
+ ...overrides,
12
+ })
13
+ const tracking = (lastHm, overrides = {}) => ({
14
+ sessionId: 's1',
15
+ project: P,
16
+ lastActivity: iso(lastHm),
17
+ ...overrides,
18
+ })
19
+
20
+ describe('automatic tracking', () => {
21
+ test('the first prompt starts a session for the project', () => {
22
+ const r = decide({
23
+ state: EMPTY_STATE,
24
+ open: null,
25
+ event: 'prompt',
26
+ now: at('09:00'),
27
+ project: P,
28
+ })
29
+ expect(r.calls).toEqual([
30
+ { name: 'start_focus_session', input: { project: P } },
31
+ ])
32
+ expect(r.state.lastActivity).toBe(iso('09:00'))
33
+ })
34
+
35
+ test('a quick next prompt changes nothing', () => {
36
+ const r = decide({
37
+ state: tracking('09:00'),
38
+ open: mine(),
39
+ event: 'prompt',
40
+ now: at('09:01'),
41
+ project: P,
42
+ })
43
+ expect(r.calls).toEqual([])
44
+ expect(r.state.sessionId).toBe('s1')
45
+ })
46
+
47
+ test('quiet between 2 and 10 minutes becomes a pause in the same session', () => {
48
+ const r = decide({
49
+ state: tracking('09:00'),
50
+ open: mine(),
51
+ event: 'prompt',
52
+ now: at('09:06'),
53
+ project: P,
54
+ })
55
+ expect(r.calls).toEqual([
56
+ { name: 'pause_focus_session', input: { at: iso('09:00') } },
57
+ { name: 'resume_focus_session', input: {} },
58
+ ])
59
+ expect(r.state.sessionId).toBe('s1')
60
+ })
61
+
62
+ test('quiet of 10 minutes or more ends the session when the quiet began', () => {
63
+ const r = decide({
64
+ state: tracking('09:00'),
65
+ open: mine(),
66
+ event: 'prompt',
67
+ now: at('09:30'),
68
+ project: P,
69
+ })
70
+ expect(r.calls).toEqual([
71
+ { name: 'finish_focus_session', input: { at: iso('09:00') } },
72
+ { name: 'start_focus_session', input: { project: P } },
73
+ ])
74
+ expect(r.state.sessionId).toBeNull()
75
+ })
76
+
77
+ test('switching repositories closes the old project and starts the new one', () => {
78
+ const r = decide({
79
+ state: tracking('09:00'),
80
+ open: mine(),
81
+ event: 'prompt',
82
+ now: at('09:01'),
83
+ project: 'amar/website',
84
+ })
85
+ expect(r.calls).toEqual([
86
+ { name: 'finish_focus_session', input: { at: iso('09:01') } },
87
+ { name: 'start_focus_session', input: { project: 'amar/website' } },
88
+ ])
89
+ })
90
+
91
+ test('a finished reply is activity, with no API call', () => {
92
+ const r = decide({
93
+ state: tracking('09:00'),
94
+ open: mine(),
95
+ event: 'reply',
96
+ now: at('09:04'),
97
+ project: P,
98
+ })
99
+ expect(r.calls).toEqual([])
100
+ expect(r.state.lastActivity).toBe(iso('09:04'))
101
+ })
102
+
103
+ test('Claude Code exiting finishes the session, at the last activity if it was quiet', () => {
104
+ expect(
105
+ decide({
106
+ state: tracking('09:00'),
107
+ open: mine(),
108
+ event: 'end',
109
+ now: at('09:20'),
110
+ project: P,
111
+ }).calls,
112
+ ).toEqual([{ name: 'finish_focus_session', input: { at: iso('09:00') } }])
113
+ expect(
114
+ decide({
115
+ state: tracking('09:00'),
116
+ open: mine(),
117
+ event: 'end',
118
+ now: at('09:01'),
119
+ project: P,
120
+ }).calls,
121
+ ).toEqual([{ name: 'finish_focus_session', input: { at: iso('09:01') } }])
122
+ })
123
+
124
+ test('a session someone started by hand is left alone', () => {
125
+ const r = decide({
126
+ state: EMPTY_STATE,
127
+ open: { id: 'manual', status: 'active', project: null },
128
+ event: 'prompt',
129
+ now: at('09:00'),
130
+ project: P,
131
+ })
132
+ expect(r.calls).toEqual([])
133
+ expect(r.state.sessionId).toBeNull()
134
+ })
135
+
136
+ test('if our session was closed elsewhere, the next prompt starts a new one', () => {
137
+ const r = decide({
138
+ state: tracking('09:00'),
139
+ open: null,
140
+ event: 'prompt',
141
+ now: at('09:01'),
142
+ project: P,
143
+ })
144
+ expect(r.calls).toEqual([
145
+ { name: 'start_focus_session', input: { project: P } },
146
+ ])
147
+ })
148
+ })
149
+
150
+ describe('project names', () => {
151
+ test.each([
152
+ ['git@github.com:amaralazizy/bestviber.git', 'amaralazizy/bestviber'],
153
+ ['https://github.com/amaralazizy/bestviber.git', 'amaralazizy/bestviber'],
154
+ ['https://gitlab.com/team/app', 'team/app'],
155
+ ['ssh://git@host:2222/team/app.git', 'team/app'],
156
+ ])('%s', (remote, expected) => {
157
+ expect(projectName({ remote, toplevel: '/x/y', cwd: '/x/y/z' })).toBe(
158
+ expected,
159
+ )
160
+ })
161
+
162
+ test('falls back to the repository folder, then the working folder', () => {
163
+ expect(
164
+ projectName({
165
+ remote: null,
166
+ toplevel: '/home/a/notes',
167
+ cwd: '/home/a/notes/src',
168
+ }),
169
+ ).toBe('notes')
170
+ expect(
171
+ projectName({ remote: null, toplevel: null, cwd: '/home/a/scratch/' }),
172
+ ).toBe('scratch')
173
+ })
174
+ })