bestviber 0.3.1 → 0.3.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/bestviber.mjs CHANGED
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'
3
3
  import { parse } from '../src/args.mjs'
4
4
  import { CliError } from '../src/client.mjs'
5
5
  import { commands } from '../src/commands.mjs'
6
+ import { refreshHooksIfMoved } from '../src/hooks.mjs'
6
7
 
7
8
  const pkg = JSON.parse(
8
9
  readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
@@ -49,6 +50,8 @@ async function main() {
49
50
  const json = flags.json === true
50
51
  delete flags.json
51
52
  const result = await command.run({ flags, args, json })
53
+ // Keep the Claude Code hooks pointing at this copy after an upgrade.
54
+ if (name !== 'hook') await refreshHooksIfMoved()
52
55
  if (result === undefined) return
53
56
  console.log(
54
57
  json && typeof result !== 'string'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bestviber",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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
@@ -502,7 +502,7 @@ export const commands = {
502
502
  return rows
503
503
  .map(
504
504
  (s) =>
505
- `${new Date(s.startedAt).toLocaleDateString()} ${clock(s.startedAt)} ${duration(s.durationMs).padStart(7)}${s.notes ? ` ${s.notes}` : ''}`,
505
+ `${new Date(s.startedAt).toLocaleDateString()} ${clock(s.startedAt)} ${duration(s.durationMs).padStart(7)}${s.project ? ` ${s.project}` : ''}${s.notes ? ` ${s.notes}` : ''}`,
506
506
  )
507
507
  .join('\n')
508
508
  },
package/src/format.mjs CHANGED
@@ -41,7 +41,7 @@ export function stats(stats) {
41
41
  lines.push(`${title} (${range.tz})`)
42
42
  lines.push('')
43
43
  lines.push(` focused ${duration(stats.totalMs)}`)
44
- if (stats.parallelMs > 0)
44
+ if (stats.parallelMs >= 60_000)
45
45
  lines.push(
46
46
  ` in parallel ${duration(stats.parallelMs)} (up to ${stats.maxParallel} sessions at once; ${duration(stats.sessionTimeMs)} of session time)`,
47
47
  )
@@ -89,7 +89,12 @@ export function stats(stats) {
89
89
  }
90
90
 
91
91
  if (stats.byProject?.length) {
92
- lines.push('', ' by project')
92
+ lines.push(
93
+ '',
94
+ stats.parallelMs >= 60_000
95
+ ? ' by project (each counts its own time, so parallel time is in each)'
96
+ : ' by project',
97
+ )
93
98
  const width = Math.max(
94
99
  16,
95
100
  ...stats.byProject.map((/** @type {any} */ p) => p.project.length),
package/src/hooks.mjs CHANGED
@@ -236,12 +236,15 @@ function hookCommand() {
236
236
  } catch {
237
237
  // Not on PATH (for example run through pnpm dlx).
238
238
  }
239
- return onPath ? `${q(onPath)} hook` : `${q(process.execPath)} ${q(BIN)} hook`
239
+ // Never fail at Claude Code: if the CLI moved (an upgrade outside PATH),
240
+ // the hook does nothing until any `bestviber` command repairs the path.
241
+ const run = onPath ? q(onPath) : `${q(process.execPath)} ${q(BIN)}`
242
+ return `${run} hook 2>/dev/null || true`
240
243
  }
241
244
 
242
245
  const isOurs = (/** @type {any} */ h) =>
243
246
  typeof h?.command === 'string' &&
244
- /bestviber(\.mjs)?"?\s+hook$/.test(h.command)
247
+ /bestviber(\.mjs)?"?\s+hook\b/.test(h.command)
245
248
 
246
249
  /** Remove our hook entries from every event, keeping everything else. */
247
250
  function withoutOurs(/** @type {Record<string, any>} */ hooks) {
@@ -307,6 +310,26 @@ export async function installHooks() {
307
310
  return { settings: settingsPath(), command }
308
311
  }
309
312
 
313
+ /**
314
+ * If tracking is on but the hooks point at another copy of the CLI (it was
315
+ * upgraded or moved), point them at this one. Cheap; runs with every command.
316
+ */
317
+ export async function refreshHooksIfMoved() {
318
+ try {
319
+ const hooks = (await readSettings()).hooks ?? {}
320
+ const ours = Object.values(hooks)
321
+ .flatMap((groups) => (Array.isArray(groups) ? groups : []))
322
+ .flatMap((g) => (Array.isArray(g?.hooks) ? g.hooks : []))
323
+ .filter(isOurs)
324
+ if (ours.length === 0) return
325
+ const command = hookCommand()
326
+ if (ours.every((h) => h.command === command)) return
327
+ await installHooks()
328
+ } catch {
329
+ // Best-effort: never break the command the user actually ran.
330
+ }
331
+ }
332
+
310
333
  export async function uninstallHooks() {
311
334
  if (!existsSync(settingsPath())) return false
312
335
  const settings = await readSettings()
@@ -6,6 +6,7 @@ import {
6
6
  __test,
7
7
  hooksInstalled,
8
8
  installHooks,
9
+ refreshHooksIfMoved,
9
10
  uninstallHooks,
10
11
  } from './hooks.mjs'
11
12
 
@@ -91,6 +92,45 @@ describe('installing into ~/.claude/settings.json', () => {
91
92
  expect(await hooksInstalled()).toBe(false)
92
93
  })
93
94
 
95
+ test('hooks never fail at Claude Code, even if the CLI is gone', async () => {
96
+ await installHooks()
97
+ const s = await settings()
98
+ expect(s.hooks.Stop[0].hooks[0].command).toMatch(
99
+ / hook 2>\/dev\/null \|\| true$/,
100
+ )
101
+ })
102
+
103
+ test('after an upgrade moves the CLI, any command repoints the hooks', async () => {
104
+ const stale =
105
+ '"/old/node" "/old/node_modules/bestviber/bin/bestviber.mjs" hook'
106
+ await writeFile(
107
+ join(dir, 'settings.json'),
108
+ JSON.stringify({
109
+ hooks: {
110
+ UserPromptSubmit: [{ hooks: [{ type: 'command', command: stale }] }],
111
+ },
112
+ }),
113
+ )
114
+ await refreshHooksIfMoved()
115
+ const s = await settings()
116
+ const commands = JSON.stringify(s.hooks)
117
+ expect(commands).not.toContain('/old/node')
118
+ expect(s.hooks.UserPromptSubmit).toHaveLength(1)
119
+ expect(s.hooks.SessionEnd).toHaveLength(1)
120
+ })
121
+
122
+ test('hooks written by older versions are still recognised on logout', async () => {
123
+ const old = '"/usr/local/bin/bestviber" hook'
124
+ await writeFile(
125
+ join(dir, 'settings.json'),
126
+ JSON.stringify({
127
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: old }] }] },
128
+ }),
129
+ )
130
+ expect(await uninstallHooks()).toBe(true)
131
+ expect((await settings()).hooks).toBeUndefined()
132
+ })
133
+
94
134
  test('a settings file that is not valid JSON is left alone', async () => {
95
135
  await writeFile(join(dir, 'settings.json'), '{ not json')
96
136
  await expect(installHooks()).rejects.toThrow(/Left it unchanged/)