bestviber 0.0.0-stage → 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 +42 -2
- package/bin/bestviber.mjs +67 -0
- package/package.json +27 -3
- package/src/args.mjs +32 -0
- package/src/client.mjs +136 -0
- package/src/commands.mjs +644 -0
- package/src/format.mjs +97 -0
- package/src/hooks.mjs +306 -0
- package/src/hooks.test.mjs +101 -0
- package/src/tracker.mjs +108 -0
- package/src/tracker.test.mjs +174 -0
package/src/format.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Human-readable output. `--json` bypasses all of this.
|
|
2
|
+
|
|
3
|
+
/** @param {number} ms */
|
|
4
|
+
export function duration(ms) {
|
|
5
|
+
const totalMin = Math.round(ms / 60000)
|
|
6
|
+
if (ms > 0 && totalMin === 0) return `${Math.round(ms / 1000)}s`
|
|
7
|
+
const h = Math.floor(totalMin / 60)
|
|
8
|
+
const m = totalMin % 60
|
|
9
|
+
if (h === 0) return `${m}m`
|
|
10
|
+
return m ? `${h}h ${m}m` : `${h}h`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Elapsed focus on an open session, pauses removed. */
|
|
14
|
+
export function elapsed(/** @type {any} */ s, now = Date.now()) {
|
|
15
|
+
const start = Date.parse(s.startedAt)
|
|
16
|
+
const openPause = s.pausedAt ? now - Date.parse(s.pausedAt) : 0
|
|
17
|
+
return Math.max(0, now - start - s.pausedMs - openPause)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @param {string} iso */
|
|
21
|
+
export function clock(iso) {
|
|
22
|
+
return new Date(iso).toLocaleTimeString([], {
|
|
23
|
+
hour: '2-digit',
|
|
24
|
+
minute: '2-digit',
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @param {number} value @param {number} max @param {number} width */
|
|
29
|
+
function bar(value, max, width = 24) {
|
|
30
|
+
if (max <= 0) return ''
|
|
31
|
+
const n = Math.round((value / max) * width)
|
|
32
|
+
return '█'.repeat(n) + '·'.repeat(width - n)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @param {any} stats */
|
|
36
|
+
export function stats(stats) {
|
|
37
|
+
const { range } = stats
|
|
38
|
+
const lines = []
|
|
39
|
+
const title =
|
|
40
|
+
range.from === range.to ? range.from : `${range.from} → ${range.to}`
|
|
41
|
+
lines.push(`${title} (${range.tz})`)
|
|
42
|
+
lines.push('')
|
|
43
|
+
lines.push(` focused ${duration(stats.totalMs)}`)
|
|
44
|
+
lines.push(` sessions ${stats.sessionCount}`)
|
|
45
|
+
if (stats.sessionCount) {
|
|
46
|
+
lines.push(` average ${duration(stats.averageSessionMs)}`)
|
|
47
|
+
lines.push(` longest ${duration(stats.longestSession.durationMs)}`)
|
|
48
|
+
}
|
|
49
|
+
const change = stats.changeVsPrevious
|
|
50
|
+
if (change.percent !== null)
|
|
51
|
+
lines.push(
|
|
52
|
+
` vs previous ${change.percent >= 0 ? '+' : ''}${change.percent}% (${duration(stats.previous.totalMs)} before)`,
|
|
53
|
+
)
|
|
54
|
+
if (stats.byDay.length > 1) {
|
|
55
|
+
lines.push(` active days ${stats.activeDays}/${stats.byDay.length}`)
|
|
56
|
+
if (stats.bestDay)
|
|
57
|
+
lines.push(
|
|
58
|
+
` best day ${stats.bestDay.date} (${duration(stats.bestDay.ms)})`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (stats.byDay.length > 1 && stats.byDay.length <= 31) {
|
|
63
|
+
const max = Math.max(...stats.byDay.map((/** @type {any} */ d) => d.ms))
|
|
64
|
+
lines.push('', ' by day')
|
|
65
|
+
for (const d of stats.byDay)
|
|
66
|
+
lines.push(
|
|
67
|
+
` ${d.date.slice(5)} ${bar(d.ms, max)} ${d.ms ? duration(d.ms) : ''}`,
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const hours = stats.byHour.filter((/** @type {any} */ h) => h.ms > 0)
|
|
72
|
+
if (hours.length > 1) {
|
|
73
|
+
const max = Math.max(...hours.map((/** @type {any} */ h) => h.ms))
|
|
74
|
+
lines.push('', ' by hour')
|
|
75
|
+
for (const h of hours)
|
|
76
|
+
lines.push(
|
|
77
|
+
` ${String(h.hour).padStart(2, '0')}:00 ${bar(h.ms, max)} ${duration(h.ms)}`,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (stats.byTag.length > 1 || stats.byTag[0]?.tag !== 'untagged') {
|
|
82
|
+
lines.push('', ' by tag')
|
|
83
|
+
for (const t of stats.byTag)
|
|
84
|
+
lines.push(` ${t.tag.padEnd(16)} ${duration(t.ms)}`)
|
|
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
|
+
}
|
|
96
|
+
return lines.join('\n')
|
|
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
|
+
})
|
package/src/tracker.mjs
ADDED
|
@@ -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
|
+
}
|