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.
@@ -0,0 +1,644 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { mkdir, writeFile } from 'node:fs/promises'
4
+ import { homedir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import {
7
+ baseUrl,
8
+ call,
9
+ clearConfig,
10
+ CLIENT_ID,
11
+ CliError,
12
+ configPath,
13
+ localTimeZone,
14
+ manifest,
15
+ readConfig,
16
+ request,
17
+ token,
18
+ writeConfig,
19
+ } from './client.mjs'
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'
30
+
31
+ /**
32
+ * @typedef {{ flags: Record<string, any>, args: string[], json: boolean }} Ctx
33
+ * @typedef {{
34
+ * summary: string,
35
+ * usage?: string,
36
+ * actions: string[],
37
+ * hidden?: boolean,
38
+ * run: (ctx: Ctx) => Promise<unknown>,
39
+ * }} Command
40
+ */
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
+
111
+ const sleep = (/** @type {number} */ ms) =>
112
+ new Promise((r) => setTimeout(r, ms))
113
+
114
+ function openBrowser(/** @type {string} */ url) {
115
+ const cmd =
116
+ process.platform === 'darwin'
117
+ ? 'open'
118
+ : process.platform === 'win32'
119
+ ? 'cmd'
120
+ : 'xdg-open'
121
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
122
+ try {
123
+ spawn(cmd, args, { stdio: 'ignore', detached: true })
124
+ .on('error', () => {})
125
+ .unref()
126
+ } catch {
127
+ // No browser; the URL is printed anyway.
128
+ }
129
+ }
130
+
131
+ /** Prints `human` normally, `data` as JSON with --json. */
132
+ function out(
133
+ /** @type {Ctx} */ ctx,
134
+ /** @type {unknown} */ data,
135
+ /** @type {string} */ human,
136
+ ) {
137
+ return ctx.json ? data : human
138
+ }
139
+
140
+ async function tagIdsByName(/** @type {string[]} */ names) {
141
+ if (names.length === 0) return undefined
142
+ /** @type {Array<{id: string, name: string}>} */
143
+ const tags = await call('list_tags')
144
+ return names.map((name) => {
145
+ const t = tags.find((x) => x.name.toLowerCase() === name.toLowerCase())
146
+ if (!t)
147
+ throw new CliError(
148
+ `No tag "${name}". Tags: ${tags.map((x) => x.name).join(', ') || 'none'}. Create it with \`bestviber tags add ${name}\`.`,
149
+ )
150
+ return t.id
151
+ })
152
+ }
153
+
154
+ const asList = (/** @type {unknown} */ v) =>
155
+ v === undefined ? [] : Array.isArray(v) ? v : [v]
156
+
157
+ /** @type {Record<string, Command>} */
158
+ export const commands = {
159
+ // ───── account ─────
160
+ login: {
161
+ summary: 'Sign in by approving a code in your browser',
162
+ usage: 'login [--no-browser]',
163
+ actions: [],
164
+ async run(ctx) {
165
+ const start = await request('/api/auth/device/code', {
166
+ auth: false,
167
+ body: { client_id: CLIENT_ID },
168
+ })
169
+ if (!start.ok)
170
+ throw new CliError(
171
+ `Could not start sign-in: ${start.data?.error_description ?? start.status}`,
172
+ )
173
+ const d = start.data
174
+ const code = `${d.user_code.slice(0, 4)}-${d.user_code.slice(4)}`
175
+ process.stderr.write(
176
+ `\nOpen ${d.verification_uri_complete}\nand approve the code ${code}\n\nWaiting for approval…`,
177
+ )
178
+ if (!ctx.flags['no-browser']) openBrowser(d.verification_uri_complete)
179
+
180
+ let interval = (d.interval ?? 5) * 1000
181
+ const deadline = Date.now() + d.expires_in * 1000
182
+ while (Date.now() < deadline) {
183
+ await sleep(interval)
184
+ const res = await request('/api/auth/device/token', {
185
+ auth: false,
186
+ body: {
187
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
188
+ device_code: d.device_code,
189
+ client_id: CLIENT_ID,
190
+ },
191
+ })
192
+ if (res.ok && res.data?.access_token) {
193
+ const t = res.data.access_token
194
+ const me = await fetch(`${await baseUrl()}/api/auth/get-session`, {
195
+ headers: { Authorization: `Bearer ${t}` },
196
+ }).then((r) => r.json())
197
+ await writeConfig({
198
+ ...(await readConfig()),
199
+ token: t,
200
+ email: me?.user?.email,
201
+ })
202
+ process.stderr.write(' approved.\n')
203
+ const setup = await setUpAgents()
204
+ return out(
205
+ ctx,
206
+ { ok: true, email: me?.user?.email, setup },
207
+ [`Signed in as ${me?.user?.email ?? 'you'}.`, ...setup].join('\n'),
208
+ )
209
+ }
210
+ const err = res.data?.error
211
+ if (err === 'authorization_pending') continue
212
+ if (err === 'slow_down') {
213
+ interval += 5000
214
+ continue
215
+ }
216
+ process.stderr.write('\n')
217
+ if (err === 'access_denied') throw new CliError('Sign-in was denied.')
218
+ if (err === 'expired_token')
219
+ throw new CliError('The code expired. Run `bestviber login` again.')
220
+ throw new CliError(
221
+ `Sign-in failed: ${res.data?.error_description ?? err ?? res.status}`,
222
+ )
223
+ }
224
+ throw new CliError('The code expired. Run `bestviber login` again.')
225
+ },
226
+ },
227
+
228
+ logout: {
229
+ summary: 'Sign out and forget the token on this machine',
230
+ actions: [],
231
+ async run(ctx) {
232
+ if (await token())
233
+ await request('/api/auth/sign-out', { body: {} }).catch(() => {})
234
+ await clearConfig()
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
+ )
244
+ },
245
+ },
246
+
247
+ whoami: {
248
+ summary: 'Show the signed-in account',
249
+ actions: [],
250
+ async run(ctx) {
251
+ const res = await request('/api/auth/get-session')
252
+ const email = res.data?.user?.email
253
+ if (!email) throw new CliError('Not signed in. Run `bestviber login`.', 2)
254
+ return out(ctx, { email, server: await baseUrl() }, email)
255
+ },
256
+ },
257
+
258
+ token: {
259
+ summary: 'Print the API token (for MCP or scripts). Keep it secret.',
260
+ actions: [],
261
+ async run() {
262
+ const t = await token()
263
+ if (!t) throw new CliError('Not signed in. Run `bestviber login`.', 2)
264
+ return t
265
+ },
266
+ },
267
+
268
+ mcp: {
269
+ summary: 'Show how to connect an AI agent over MCP',
270
+ actions: [],
271
+ async run(ctx) {
272
+ const url = `${await baseUrl()}/api/mcp`
273
+ const config = {
274
+ mcpServers: {
275
+ bestviber: {
276
+ type: 'http',
277
+ url,
278
+ headers: {
279
+ Authorization: 'Bearer <run: bestviber token>',
280
+ 'X-Timezone': localTimeZone(),
281
+ },
282
+ },
283
+ },
284
+ }
285
+ if (ctx.json) return config
286
+ return [
287
+ ...((await token())
288
+ ? []
289
+ : [
290
+ 'Not signed in yet: run `bestviber login` first, or the token below is empty.',
291
+ '',
292
+ ]),
293
+ 'Claude Code:',
294
+ ` claude mcp add --transport http bestviber ${url} \\`,
295
+ ' --header "Authorization: Bearer $(bestviber token)" \\',
296
+ ` --header "X-Timezone: ${localTimeZone()}"`,
297
+ '',
298
+ 'Other clients (JSON config; paste the output of `bestviber token`):',
299
+ JSON.stringify(config, null, 2)
300
+ .split('\n')
301
+ .map((l) => ` ${l}`)
302
+ .join('\n'),
303
+ ].join('\n')
304
+ },
305
+ },
306
+
307
+ skill: {
308
+ summary: 'Install the bestviber skill for Claude Code and other agents',
309
+ usage: 'skill install [--dir <skills-folder>]',
310
+ actions: [],
311
+ async run(ctx) {
312
+ if (ctx.args[0] !== 'install')
313
+ throw new CliError('Usage: bestviber skill install [--dir <folder>]')
314
+ const written = await installSkill(
315
+ ctx.flags.dir ? String(ctx.flags.dir) : undefined,
316
+ )
317
+ return out(
318
+ ctx,
319
+ { installed: written },
320
+ `Installed the bestviber skill to:\n${written.map((d) => ` ${d}`).join('\n')}\nRun this again any time to update it.`,
321
+ )
322
+ },
323
+ },
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
+
375
+ // ───── timer ─────
376
+ start: {
377
+ summary: 'Start a focus session',
378
+ usage: 'start [--tag <name>]... [--room <id>]',
379
+ actions: ['start_focus_session', 'list_tags'],
380
+ async run(ctx) {
381
+ const row = await call('start_focus_session', {
382
+ tagIds: await tagIdsByName(asList(ctx.flags.tag)),
383
+ roomId: ctx.flags.room,
384
+ })
385
+ return out(ctx, row, `Focus session started at ${clock(row.startedAt)}.`)
386
+ },
387
+ },
388
+
389
+ pause: {
390
+ summary: 'Pause the running session',
391
+ actions: ['pause_focus_session'],
392
+ async run(ctx) {
393
+ return out(ctx, await call('pause_focus_session'), 'Paused.')
394
+ },
395
+ },
396
+
397
+ resume: {
398
+ summary: 'Resume the paused session',
399
+ actions: ['resume_focus_session'],
400
+ async run(ctx) {
401
+ return out(ctx, await call('resume_focus_session'), 'Resumed.')
402
+ },
403
+ },
404
+
405
+ stop: {
406
+ summary: 'Finish the session and record it',
407
+ usage: 'stop [--note <text>]',
408
+ actions: ['finish_focus_session'],
409
+ async run(ctx) {
410
+ const res = await call('finish_focus_session', { notes: ctx.flags.note })
411
+ return out(ctx, res, `Recorded ${duration(res.durationMs)} of focus.`)
412
+ },
413
+ },
414
+
415
+ status: {
416
+ summary: 'Show the open session and today so far',
417
+ actions: ['get_active_session', 'get_focus_totals'],
418
+ async run(ctx) {
419
+ const [active, totals] = await Promise.all([
420
+ call('get_active_session'),
421
+ call('get_focus_totals'),
422
+ ])
423
+ if (ctx.json) return { active, totals }
424
+ const running = active ? elapsed(active) : 0
425
+ const lines = [
426
+ active
427
+ ? `${active.status === 'paused' ? 'Paused' : 'Focusing'}: ${duration(running)} (started ${clock(active.startedAt)})`
428
+ : 'No session running.',
429
+ `Today: ${duration(totals.today + running)} last 7 days: ${duration(totals.week + running)}`,
430
+ ]
431
+ return lines.join('\n')
432
+ },
433
+ },
434
+
435
+ // ───── stats ─────
436
+ today: periodCommand('day', 'today', 'Stats for today'),
437
+ yesterday: periodCommand('day', 'yesterday', 'Stats for yesterday'),
438
+ week: periodCommand('week', 'today', 'Stats for this week (from Monday)'),
439
+ month: periodCommand('month', 'today', 'Stats for this month'),
440
+ year: periodCommand('year', 'today', 'Stats for this year'),
441
+
442
+ stats: {
443
+ summary: 'Stats for any period or range',
444
+ usage:
445
+ 'stats [--period day|week|month|year] [--date <YYYY-MM-DD>] | [--from <date> --to <date>]',
446
+ actions: ['get_stats'],
447
+ async run(ctx) {
448
+ const s = await call('get_stats', {
449
+ period: ctx.flags.period,
450
+ date: ctx.flags.date,
451
+ from: ctx.flags.from,
452
+ to: ctx.flags.to,
453
+ })
454
+ return out(ctx, s, stats(s))
455
+ },
456
+ },
457
+
458
+ streak: {
459
+ summary: 'Current and longest streak of focus days',
460
+ actions: ['get_streaks'],
461
+ async run(ctx) {
462
+ const s = await call('get_streaks')
463
+ return out(
464
+ ctx,
465
+ s,
466
+ [
467
+ `Current streak: ${s.currentDays} day${s.currentDays === 1 ? '' : 's'}${s.currentDays && !s.focusedToday ? ' (focus today to keep it)' : ''}`,
468
+ `Longest: ${s.longestDays} day${s.longestDays === 1 ? '' : 's'}${s.longest ? ` (${s.longest.from} → ${s.longest.to})` : ''}`,
469
+ `Active days in total: ${s.totalActiveDays}`,
470
+ ].join('\n'),
471
+ )
472
+ },
473
+ },
474
+
475
+ insights: {
476
+ summary: 'Findings about when and how you focus',
477
+ actions: ['get_insights'],
478
+ async run(ctx) {
479
+ const r = await call('get_insights')
480
+ return out(
481
+ ctx,
482
+ r,
483
+ r.insights.map((/** @type {any} */ i) => `• ${i.text}`).join('\n'),
484
+ )
485
+ },
486
+ },
487
+
488
+ sessions: {
489
+ summary: 'List finished sessions for a day or range',
490
+ usage:
491
+ 'sessions [--date today|yesterday|<YYYY-MM-DD>] | [--from <date> --to <date>]',
492
+ actions: ['list_sessions'],
493
+ async run(ctx) {
494
+ /** @type {any[]} */
495
+ const rows = await call('list_sessions', {
496
+ date: ctx.flags.date,
497
+ from: ctx.flags.from,
498
+ to: ctx.flags.to,
499
+ })
500
+ if (ctx.json) return rows
501
+ if (rows.length === 0) return 'No sessions.'
502
+ return rows
503
+ .map(
504
+ (s) =>
505
+ `${new Date(s.startedAt).toLocaleDateString()} ${clock(s.startedAt)} ${duration(s.durationMs).padStart(7)}${s.notes ? ` ${s.notes}` : ''}`,
506
+ )
507
+ .join('\n')
508
+ },
509
+ },
510
+
511
+ // ───── tags & rooms ─────
512
+ tags: {
513
+ summary: 'List tags, or `tags add <name>` / `tags rm <name>`',
514
+ usage: 'tags [add <name> | rm <name>]',
515
+ actions: ['list_tags', 'create_tag', 'delete_tag'],
516
+ async run(ctx) {
517
+ const [sub, ...rest] = ctx.args
518
+ const name = rest.join(' ')
519
+ if (sub === 'add') {
520
+ if (!name) throw new CliError('Usage: bestviber tags add <name>')
521
+ return out(
522
+ ctx,
523
+ await call('create_tag', { name }),
524
+ `Added tag "${name}".`,
525
+ )
526
+ }
527
+ if (sub === 'rm' || sub === 'remove') {
528
+ const [id] = (await tagIdsByName([name])) ?? []
529
+ return out(
530
+ ctx,
531
+ await call('delete_tag', { id }),
532
+ `Removed tag "${name}".`,
533
+ )
534
+ }
535
+ if (sub) throw new CliError(`Unknown subcommand "tags ${sub}".`)
536
+ /** @type {any[]} */
537
+ const tags = await call('list_tags')
538
+ return out(
539
+ ctx,
540
+ tags,
541
+ tags.length ? tags.map((t) => t.name).join('\n') : 'No tags yet.',
542
+ )
543
+ },
544
+ },
545
+
546
+ rooms: {
547
+ summary: 'List your focus rooms',
548
+ actions: ['list_rooms'],
549
+ async run(ctx) {
550
+ /** @type {any[]} */
551
+ const rooms = await call('list_rooms')
552
+ return out(
553
+ ctx,
554
+ rooms,
555
+ rooms.length
556
+ ? rooms.map((r) => `${r.id} ${r.name}`).join('\n')
557
+ : 'No rooms yet.',
558
+ )
559
+ },
560
+ },
561
+
562
+ // ───── everything else ─────
563
+ tools: {
564
+ summary: 'List every action the server offers (always up to date)',
565
+ actions: [],
566
+ async run(ctx) {
567
+ const list = await manifest()
568
+ if (ctx.json) return list
569
+ const width = Math.max(...list.map((a) => a.name.length))
570
+ return list
571
+ .map((a) => `${a.name.padEnd(width)} ${a.title}`)
572
+ .join('\n')
573
+ .concat(
574
+ '\n\nRun one with: bestviber run <name> [--key value]... or --input <json>',
575
+ )
576
+ },
577
+ },
578
+
579
+ run: {
580
+ summary: 'Run any action by name',
581
+ usage:
582
+ 'run <action> [--key value]... | run <action> --input \'{"json":true}\'',
583
+ actions: [],
584
+ async run(ctx) {
585
+ const [name] = ctx.args
586
+ if (!name)
587
+ throw new CliError(
588
+ 'Usage: bestviber run <action>. See `bestviber tools`.',
589
+ )
590
+ const { input: raw, ...rest } = ctx.flags
591
+ let input = {}
592
+ if (raw) {
593
+ try {
594
+ input = JSON.parse(raw)
595
+ } catch {
596
+ throw new CliError('--input must be JSON.')
597
+ }
598
+ } else {
599
+ const action = (await manifest()).find((a) => a.name === name)
600
+ if (!action)
601
+ throw new CliError(
602
+ `Unknown action "${name}". See \`bestviber tools\`.`,
603
+ )
604
+ input = coerce(rest, action.inputSchema)
605
+ }
606
+ const result = await call(name, input)
607
+ return ctx.json ? result : JSON.stringify(result, null, 2)
608
+ },
609
+ },
610
+ }
611
+
612
+ /** Turns `--key value` strings into the types the JSON Schema asks for. */
613
+ function coerce(
614
+ /** @type {Record<string, any>} */ flags,
615
+ /** @type {any} */ schema,
616
+ ) {
617
+ /** @type {Record<string, unknown>} */
618
+ const input = {}
619
+ for (const [key, value] of Object.entries(flags)) {
620
+ const type = schema?.properties?.[key]?.type
621
+ if (type === 'number' || type === 'integer') input[key] = Number(value)
622
+ else if (type === 'boolean') input[key] = value === true || value === 'true'
623
+ else if (type === 'array') input[key] = asList(value)
624
+ else input[key] = value
625
+ }
626
+ return input
627
+ }
628
+
629
+ function periodCommand(
630
+ /** @type {string} */ period,
631
+ /** @type {string} */ date,
632
+ /** @type {string} */ summary,
633
+ ) {
634
+ return /** @type {Command} */ ({
635
+ summary,
636
+ actions: ['get_stats'],
637
+ async run(ctx) {
638
+ const s = await call('get_stats', { period, date })
639
+ return out(ctx, s, stats(s))
640
+ },
641
+ })
642
+ }
643
+
644
+ export { configPath }