bestviber 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/README.md CHANGED
@@ -1,3 +1,25 @@
1
- # Temporary Holding Version
1
+ # bestviber
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
+ Focus timer and focus analytics for you and your AI agents.
4
+
5
+ ```sh
6
+ npm install -g bestviber
7
+ bestviber login # approve a code in your browser
8
+ bestviber start # start a focus session
9
+ bestviber stop --note "wrote the launch post"
10
+ bestviber week # this week: total, by day, by hour, by tag, vs last week
11
+ bestviber insights # peak hours, best weekday, streaks, trends
12
+ ```
13
+
14
+ Every command takes `--json` for scripts and agents. `bestviber tools` lists
15
+ every action the server offers and `bestviber run <action>` runs any of them,
16
+ so new features work without updating the CLI.
17
+
18
+ ## Agents
19
+
20
+ - `bestviber skill install` adds the bestviber skill for Claude Code and
21
+ other agents that read `~/.agents/skills`.
22
+ - `bestviber mcp` prints how to connect any MCP client to
23
+ `https://www.bestviber.com/api/mcp`.
24
+
25
+ Free. Same account as [bestviber.com](https://www.bestviber.com).
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs'
3
+ import { parse } from '../src/args.mjs'
4
+ import { CliError } from '../src/client.mjs'
5
+ import { commands } from '../src/commands.mjs'
6
+
7
+ const pkg = JSON.parse(
8
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
9
+ )
10
+
11
+ function help() {
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
+ )
16
+ return `bestviber ${pkg.version}: focus timer and focus analytics for you and your agents
17
+
18
+ Usage: bestviber <command> [options]
19
+
20
+ ${lines.join('\n')}
21
+
22
+ Options:
23
+ --json Print raw JSON (for scripts and agents)
24
+ --help Show help for a command
25
+
26
+ Start with \`bestviber login\`. Full docs: https://www.bestviber.com/llms.txt`
27
+ }
28
+
29
+ async function main() {
30
+ const [name, ...rest] = process.argv.slice(2)
31
+ if (!name || name === 'help' || name === '--help' || name === '-h') {
32
+ console.log(help())
33
+ return
34
+ }
35
+ if (name === '--version' || name === '-v') {
36
+ console.log(pkg.version)
37
+ return
38
+ }
39
+ const command = commands[name]
40
+ if (!command)
41
+ throw new CliError(`Unknown command "${name}". Run \`bestviber help\`.`)
42
+ const { flags, args } = parse(rest)
43
+ if (flags.help) {
44
+ console.log(
45
+ `${command.summary}\n\nUsage: bestviber ${command.usage ?? name}`,
46
+ )
47
+ return
48
+ }
49
+ const json = flags.json === true
50
+ delete flags.json
51
+ const result = await command.run({ flags, args, json })
52
+ if (result === undefined) return
53
+ console.log(
54
+ json && typeof result !== 'string'
55
+ ? JSON.stringify(result, null, 2)
56
+ : result,
57
+ )
58
+ }
59
+
60
+ main().catch((err) => {
61
+ if (err instanceof CliError) {
62
+ console.error(`bestviber: ${err.message}`)
63
+ process.exit(err.exitCode)
64
+ }
65
+ console.error(err)
66
+ process.exit(1)
67
+ })
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "bestviber",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
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
+ "keywords": [
6
+ "focus",
7
+ "timer",
8
+ "pomodoro",
9
+ "deep-work",
10
+ "productivity",
11
+ "time-tracking",
12
+ "cli",
13
+ "ai-agent",
14
+ "mcp"
15
+ ],
16
+ "homepage": "https://www.bestviber.com",
17
+ "license": "MIT",
18
+ "type": "module",
19
+ "bin": {
20
+ "bestviber": "bin/bestviber.mjs"
21
+ },
22
+ "files": [
23
+ "bin",
24
+ "src",
25
+ "README.md"
26
+ ],
27
+ "engines": {
28
+ "node": ">=20"
29
+ }
6
30
  }
package/src/args.mjs ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `--key value`, `--key=value`, `--flag`, `--no-flag` (sets both `flag:
3
+ * false` and `no-flag: true`); a repeated key becomes an array. Everything
4
+ * else is positional.
5
+ * @param {string[]} argv
6
+ */
7
+ export function parse(argv) {
8
+ /** @type {Record<string, any>} */
9
+ const flags = {}
10
+ /** @type {string[]} */
11
+ const args = []
12
+ const set = (/** @type {string} */ key, /** @type {unknown} */ value) => {
13
+ flags[key] = key in flags ? [...[flags[key]].flat(), value] : value
14
+ }
15
+ for (let i = 0; i < argv.length; i++) {
16
+ const a = argv[i]
17
+ if (!a.startsWith('--')) {
18
+ args.push(a)
19
+ continue
20
+ }
21
+ const body = a.slice(2)
22
+ const eq = body.indexOf('=')
23
+ if (eq !== -1) set(body.slice(0, eq), body.slice(eq + 1))
24
+ else if (body.startsWith('no-')) {
25
+ flags[body.slice(3)] = false
26
+ flags[body] = true
27
+ } else if (i + 1 < argv.length && !argv[i + 1].startsWith('--'))
28
+ set(body, argv[++i])
29
+ else set(body, true)
30
+ }
31
+ return { flags, args }
32
+ }
package/src/client.mjs ADDED
@@ -0,0 +1,136 @@
1
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { homedir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ export const DEFAULT_URL = 'https://www.bestviber.com'
6
+ export const CLIENT_ID = 'bestviber-cli'
7
+
8
+ export class CliError extends Error {
9
+ /** @param {string} message @param {number} [exitCode] */
10
+ constructor(message, exitCode = 1) {
11
+ super(message)
12
+ this.exitCode = exitCode
13
+ }
14
+ }
15
+
16
+ // ───── config ─────
17
+
18
+ export function configPath() {
19
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
20
+ return join(base, 'bestviber', 'config.json')
21
+ }
22
+
23
+ /** @returns {Promise<{ token?: string, url?: string, email?: string }>} */
24
+ export async function readConfig() {
25
+ try {
26
+ return JSON.parse(await readFile(configPath(), 'utf8'))
27
+ } catch {
28
+ return {}
29
+ }
30
+ }
31
+
32
+ /** @param {{ token?: string, url?: string, email?: string }} config */
33
+ export async function writeConfig(config) {
34
+ const path = configPath()
35
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 })
36
+ // The token is a bearer credential: owner-only.
37
+ await writeFile(path, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 })
38
+ }
39
+
40
+ export async function clearConfig() {
41
+ await rm(configPath(), { force: true })
42
+ }
43
+
44
+ export async function baseUrl() {
45
+ const config = await readConfig()
46
+ return (process.env.BESTVIBER_URL || config.url || DEFAULT_URL).replace(
47
+ /\/+$/,
48
+ '',
49
+ )
50
+ }
51
+
52
+ export async function token() {
53
+ return process.env.BESTVIBER_TOKEN || (await readConfig()).token
54
+ }
55
+
56
+ export function localTimeZone() {
57
+ return Intl.DateTimeFormat().resolvedOptions().timeZone
58
+ }
59
+
60
+ // ───── http ─────
61
+
62
+ /**
63
+ * @param {string} path
64
+ * @param {{ method?: string, body?: unknown, auth?: boolean }} [opts]
65
+ */
66
+ export async function request(path, opts = {}) {
67
+ /** @type {Record<string, string>} */
68
+ const headers = {
69
+ 'Content-Type': 'application/json',
70
+ 'X-Timezone': localTimeZone(),
71
+ 'User-Agent': 'bestviber-cli',
72
+ }
73
+ if (opts.auth !== false) {
74
+ const t = await token()
75
+ if (!t) throw new CliError('Not signed in. Run `bestviber login` first.', 2)
76
+ headers.Authorization = `Bearer ${t}`
77
+ }
78
+ let res
79
+ try {
80
+ res = await fetch(`${await baseUrl()}${path}`, {
81
+ method: opts.method ?? (opts.body === undefined ? 'GET' : 'POST'),
82
+ headers,
83
+ body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
84
+ })
85
+ } catch (err) {
86
+ throw new CliError(
87
+ `Could not reach bestviber: ${/** @type {Error} */ (err).message}`,
88
+ )
89
+ }
90
+ const text = await res.text()
91
+ let data
92
+ try {
93
+ data = text ? JSON.parse(text) : null
94
+ } catch {
95
+ data = { error: text }
96
+ }
97
+ return { status: res.status, ok: res.ok, data }
98
+ }
99
+
100
+ /** Fetches the live action list; new server actions appear here. */
101
+ export async function manifest() {
102
+ const res = await request('/api/v1/actions', { auth: false })
103
+ if (!res.ok)
104
+ throw new CliError(`Could not load actions (HTTP ${res.status}).`)
105
+ return /** @type {Array<{ name: string, title: string, description: string, inputSchema: any, readOnly: boolean, consequential: boolean }>} */ (
106
+ res.data.actions
107
+ )
108
+ }
109
+
110
+ /**
111
+ * Runs an action on the server and returns its result.
112
+ * @param {string} name @param {Record<string, unknown>} [input]
113
+ */
114
+ export async function call(name, input = {}) {
115
+ const res = await request(`/api/v1/actions/${encodeURIComponent(name)}`, {
116
+ body: input,
117
+ })
118
+ if (res.status === 401)
119
+ throw new CliError(
120
+ 'Not signed in, or the session expired. Run `bestviber login`.',
121
+ 2,
122
+ )
123
+ if (!res.ok || !res.data?.ok) {
124
+ const issues = res.data?.issues
125
+ ? '\n' +
126
+ res.data.issues
127
+ .map(
128
+ (/** @type {any} */ i) =>
129
+ ` ${i.path?.join('.') || 'input'}: ${i.message}`,
130
+ )
131
+ .join('\n')
132
+ : ''
133
+ throw new CliError(`${res.data?.error ?? `HTTP ${res.status}`}${issues}`)
134
+ }
135
+ return res.data.result
136
+ }
@@ -0,0 +1,524 @@
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
+
22
+ /**
23
+ * @typedef {{ flags: Record<string, any>, args: string[], json: boolean }} Ctx
24
+ * @typedef {{
25
+ * summary: string,
26
+ * usage?: string,
27
+ * actions: string[],
28
+ * run: (ctx: Ctx) => Promise<unknown>,
29
+ * }} Command
30
+ */
31
+
32
+ const sleep = (/** @type {number} */ ms) =>
33
+ new Promise((r) => setTimeout(r, ms))
34
+
35
+ function openBrowser(/** @type {string} */ url) {
36
+ const cmd =
37
+ process.platform === 'darwin'
38
+ ? 'open'
39
+ : process.platform === 'win32'
40
+ ? 'cmd'
41
+ : 'xdg-open'
42
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
43
+ try {
44
+ spawn(cmd, args, { stdio: 'ignore', detached: true })
45
+ .on('error', () => {})
46
+ .unref()
47
+ } catch {
48
+ // No browser; the URL is printed anyway.
49
+ }
50
+ }
51
+
52
+ /** Prints `human` normally, `data` as JSON with --json. */
53
+ function out(
54
+ /** @type {Ctx} */ ctx,
55
+ /** @type {unknown} */ data,
56
+ /** @type {string} */ human,
57
+ ) {
58
+ return ctx.json ? data : human
59
+ }
60
+
61
+ async function tagIdsByName(/** @type {string[]} */ names) {
62
+ if (names.length === 0) return undefined
63
+ /** @type {Array<{id: string, name: string}>} */
64
+ const tags = await call('list_tags')
65
+ return names.map((name) => {
66
+ const t = tags.find((x) => x.name.toLowerCase() === name.toLowerCase())
67
+ if (!t)
68
+ throw new CliError(
69
+ `No tag "${name}". Tags: ${tags.map((x) => x.name).join(', ') || 'none'}. Create it with \`bestviber tags add ${name}\`.`,
70
+ )
71
+ return t.id
72
+ })
73
+ }
74
+
75
+ const asList = (/** @type {unknown} */ v) =>
76
+ v === undefined ? [] : Array.isArray(v) ? v : [v]
77
+
78
+ /** @type {Record<string, Command>} */
79
+ export const commands = {
80
+ // ───── account ─────
81
+ login: {
82
+ summary: 'Sign in by approving a code in your browser',
83
+ usage: 'login [--no-browser]',
84
+ actions: [],
85
+ async run(ctx) {
86
+ const start = await request('/api/auth/device/code', {
87
+ auth: false,
88
+ body: { client_id: CLIENT_ID },
89
+ })
90
+ if (!start.ok)
91
+ throw new CliError(
92
+ `Could not start sign-in: ${start.data?.error_description ?? start.status}`,
93
+ )
94
+ const d = start.data
95
+ const code = `${d.user_code.slice(0, 4)}-${d.user_code.slice(4)}`
96
+ process.stderr.write(
97
+ `\nOpen ${d.verification_uri_complete}\nand approve the code ${code}\n\nWaiting for approval…`,
98
+ )
99
+ if (!ctx.flags['no-browser']) openBrowser(d.verification_uri_complete)
100
+
101
+ let interval = (d.interval ?? 5) * 1000
102
+ const deadline = Date.now() + d.expires_in * 1000
103
+ while (Date.now() < deadline) {
104
+ await sleep(interval)
105
+ const res = await request('/api/auth/device/token', {
106
+ auth: false,
107
+ body: {
108
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
109
+ device_code: d.device_code,
110
+ client_id: CLIENT_ID,
111
+ },
112
+ })
113
+ if (res.ok && res.data?.access_token) {
114
+ const t = res.data.access_token
115
+ const me = await fetch(`${await baseUrl()}/api/auth/get-session`, {
116
+ headers: { Authorization: `Bearer ${t}` },
117
+ }).then((r) => r.json())
118
+ await writeConfig({
119
+ ...(await readConfig()),
120
+ token: t,
121
+ email: me?.user?.email,
122
+ })
123
+ process.stderr.write(' approved.\n')
124
+ return out(
125
+ ctx,
126
+ { ok: true, email: me?.user?.email },
127
+ `Signed in as ${me?.user?.email ?? 'you'}.`,
128
+ )
129
+ }
130
+ const err = res.data?.error
131
+ if (err === 'authorization_pending') continue
132
+ if (err === 'slow_down') {
133
+ interval += 5000
134
+ continue
135
+ }
136
+ process.stderr.write('\n')
137
+ if (err === 'access_denied') throw new CliError('Sign-in was denied.')
138
+ if (err === 'expired_token')
139
+ throw new CliError('The code expired. Run `bestviber login` again.')
140
+ throw new CliError(
141
+ `Sign-in failed: ${res.data?.error_description ?? err ?? res.status}`,
142
+ )
143
+ }
144
+ throw new CliError('The code expired. Run `bestviber login` again.')
145
+ },
146
+ },
147
+
148
+ logout: {
149
+ summary: 'Sign out and forget the token on this machine',
150
+ actions: [],
151
+ async run(ctx) {
152
+ if (await token())
153
+ await request('/api/auth/sign-out', { body: {} }).catch(() => {})
154
+ await clearConfig()
155
+ return out(ctx, { ok: true }, 'Signed out.')
156
+ },
157
+ },
158
+
159
+ whoami: {
160
+ summary: 'Show the signed-in account',
161
+ actions: [],
162
+ async run(ctx) {
163
+ const res = await request('/api/auth/get-session')
164
+ const email = res.data?.user?.email
165
+ if (!email) throw new CliError('Not signed in. Run `bestviber login`.', 2)
166
+ return out(ctx, { email, server: await baseUrl() }, email)
167
+ },
168
+ },
169
+
170
+ token: {
171
+ summary: 'Print the API token (for MCP or scripts). Keep it secret.',
172
+ actions: [],
173
+ async run() {
174
+ const t = await token()
175
+ if (!t) throw new CliError('Not signed in. Run `bestviber login`.', 2)
176
+ return t
177
+ },
178
+ },
179
+
180
+ mcp: {
181
+ summary: 'Show how to connect an AI agent over MCP',
182
+ actions: [],
183
+ async run(ctx) {
184
+ const url = `${await baseUrl()}/api/mcp`
185
+ const config = {
186
+ mcpServers: {
187
+ bestviber: {
188
+ type: 'http',
189
+ url,
190
+ headers: {
191
+ Authorization: 'Bearer <run: bestviber token>',
192
+ 'X-Timezone': localTimeZone(),
193
+ },
194
+ },
195
+ },
196
+ }
197
+ if (ctx.json) return config
198
+ return [
199
+ 'Claude Code:',
200
+ ` claude mcp add --transport http bestviber ${url} \\`,
201
+ ' --header "Authorization: Bearer $(bestviber token)" \\',
202
+ ` --header "X-Timezone: ${localTimeZone()}"`,
203
+ '',
204
+ 'Other clients (JSON config; paste the output of `bestviber token`):',
205
+ JSON.stringify(config, null, 2)
206
+ .split('\n')
207
+ .map((l) => ` ${l}`)
208
+ .join('\n'),
209
+ ].join('\n')
210
+ },
211
+ },
212
+
213
+ skill: {
214
+ summary: 'Install the bestviber skill for Claude Code and other agents',
215
+ usage: 'skill install [--dir <skills-folder>]',
216
+ actions: [],
217
+ async run(ctx) {
218
+ if (ctx.args[0] !== 'install')
219
+ 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
+ }),
239
+ )
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
+ return out(
248
+ ctx,
249
+ { installed: written },
250
+ `Installed the bestviber skill to:\n${written.map((d) => ` ${d}`).join('\n')}\nRun this again any time to update it.`,
251
+ )
252
+ },
253
+ },
254
+
255
+ // ───── timer ─────
256
+ start: {
257
+ summary: 'Start a focus session',
258
+ usage: 'start [--tag <name>]... [--room <id>]',
259
+ actions: ['start_focus_session', 'list_tags'],
260
+ async run(ctx) {
261
+ const row = await call('start_focus_session', {
262
+ tagIds: await tagIdsByName(asList(ctx.flags.tag)),
263
+ roomId: ctx.flags.room,
264
+ })
265
+ return out(ctx, row, `Focus session started at ${clock(row.startedAt)}.`)
266
+ },
267
+ },
268
+
269
+ pause: {
270
+ summary: 'Pause the running session',
271
+ actions: ['pause_focus_session'],
272
+ async run(ctx) {
273
+ return out(ctx, await call('pause_focus_session'), 'Paused.')
274
+ },
275
+ },
276
+
277
+ resume: {
278
+ summary: 'Resume the paused session',
279
+ actions: ['resume_focus_session'],
280
+ async run(ctx) {
281
+ return out(ctx, await call('resume_focus_session'), 'Resumed.')
282
+ },
283
+ },
284
+
285
+ stop: {
286
+ summary: 'Finish the session and record it',
287
+ usage: 'stop [--note <text>]',
288
+ actions: ['finish_focus_session'],
289
+ async run(ctx) {
290
+ const res = await call('finish_focus_session', { notes: ctx.flags.note })
291
+ return out(ctx, res, `Recorded ${duration(res.durationMs)} of focus.`)
292
+ },
293
+ },
294
+
295
+ status: {
296
+ summary: 'Show the open session and today so far',
297
+ actions: ['get_active_session', 'get_focus_totals'],
298
+ async run(ctx) {
299
+ const [active, totals] = await Promise.all([
300
+ call('get_active_session'),
301
+ call('get_focus_totals'),
302
+ ])
303
+ if (ctx.json) return { active, totals }
304
+ const running = active ? elapsed(active) : 0
305
+ const lines = [
306
+ active
307
+ ? `${active.status === 'paused' ? 'Paused' : 'Focusing'}: ${duration(running)} (started ${clock(active.startedAt)})`
308
+ : 'No session running.',
309
+ `Today: ${duration(totals.today + running)} last 7 days: ${duration(totals.week + running)}`,
310
+ ]
311
+ return lines.join('\n')
312
+ },
313
+ },
314
+
315
+ // ───── stats ─────
316
+ today: periodCommand('day', 'today', 'Stats for today'),
317
+ yesterday: periodCommand('day', 'yesterday', 'Stats for yesterday'),
318
+ week: periodCommand('week', 'today', 'Stats for this week (from Monday)'),
319
+ month: periodCommand('month', 'today', 'Stats for this month'),
320
+ year: periodCommand('year', 'today', 'Stats for this year'),
321
+
322
+ stats: {
323
+ summary: 'Stats for any period or range',
324
+ usage:
325
+ 'stats [--period day|week|month|year] [--date <YYYY-MM-DD>] | [--from <date> --to <date>]',
326
+ actions: ['get_stats'],
327
+ async run(ctx) {
328
+ const s = await call('get_stats', {
329
+ period: ctx.flags.period,
330
+ date: ctx.flags.date,
331
+ from: ctx.flags.from,
332
+ to: ctx.flags.to,
333
+ })
334
+ return out(ctx, s, stats(s))
335
+ },
336
+ },
337
+
338
+ streak: {
339
+ summary: 'Current and longest streak of focus days',
340
+ actions: ['get_streaks'],
341
+ async run(ctx) {
342
+ const s = await call('get_streaks')
343
+ return out(
344
+ ctx,
345
+ s,
346
+ [
347
+ `Current streak: ${s.currentDays} day${s.currentDays === 1 ? '' : 's'}${s.currentDays && !s.focusedToday ? ' (focus today to keep it)' : ''}`,
348
+ `Longest: ${s.longestDays} day${s.longestDays === 1 ? '' : 's'}${s.longest ? ` (${s.longest.from} → ${s.longest.to})` : ''}`,
349
+ `Active days in total: ${s.totalActiveDays}`,
350
+ ].join('\n'),
351
+ )
352
+ },
353
+ },
354
+
355
+ insights: {
356
+ summary: 'Findings about when and how you focus',
357
+ actions: ['get_insights'],
358
+ async run(ctx) {
359
+ const r = await call('get_insights')
360
+ return out(
361
+ ctx,
362
+ r,
363
+ r.insights.map((/** @type {any} */ i) => `• ${i.text}`).join('\n'),
364
+ )
365
+ },
366
+ },
367
+
368
+ sessions: {
369
+ summary: 'List finished sessions for a day or range',
370
+ usage:
371
+ 'sessions [--date today|yesterday|<YYYY-MM-DD>] | [--from <date> --to <date>]',
372
+ actions: ['list_sessions'],
373
+ async run(ctx) {
374
+ /** @type {any[]} */
375
+ const rows = await call('list_sessions', {
376
+ date: ctx.flags.date,
377
+ from: ctx.flags.from,
378
+ to: ctx.flags.to,
379
+ })
380
+ if (ctx.json) return rows
381
+ if (rows.length === 0) return 'No sessions.'
382
+ return rows
383
+ .map(
384
+ (s) =>
385
+ `${new Date(s.startedAt).toLocaleDateString()} ${clock(s.startedAt)} ${duration(s.durationMs).padStart(7)}${s.notes ? ` ${s.notes}` : ''}`,
386
+ )
387
+ .join('\n')
388
+ },
389
+ },
390
+
391
+ // ───── tags & rooms ─────
392
+ tags: {
393
+ summary: 'List tags, or `tags add <name>` / `tags rm <name>`',
394
+ usage: 'tags [add <name> | rm <name>]',
395
+ actions: ['list_tags', 'create_tag', 'delete_tag'],
396
+ async run(ctx) {
397
+ const [sub, ...rest] = ctx.args
398
+ const name = rest.join(' ')
399
+ if (sub === 'add') {
400
+ if (!name) throw new CliError('Usage: bestviber tags add <name>')
401
+ return out(
402
+ ctx,
403
+ await call('create_tag', { name }),
404
+ `Added tag "${name}".`,
405
+ )
406
+ }
407
+ if (sub === 'rm' || sub === 'remove') {
408
+ const [id] = (await tagIdsByName([name])) ?? []
409
+ return out(
410
+ ctx,
411
+ await call('delete_tag', { id }),
412
+ `Removed tag "${name}".`,
413
+ )
414
+ }
415
+ if (sub) throw new CliError(`Unknown subcommand "tags ${sub}".`)
416
+ /** @type {any[]} */
417
+ const tags = await call('list_tags')
418
+ return out(
419
+ ctx,
420
+ tags,
421
+ tags.length ? tags.map((t) => t.name).join('\n') : 'No tags yet.',
422
+ )
423
+ },
424
+ },
425
+
426
+ rooms: {
427
+ summary: 'List your focus rooms',
428
+ actions: ['list_rooms'],
429
+ async run(ctx) {
430
+ /** @type {any[]} */
431
+ const rooms = await call('list_rooms')
432
+ return out(
433
+ ctx,
434
+ rooms,
435
+ rooms.length
436
+ ? rooms.map((r) => `${r.id} ${r.name}`).join('\n')
437
+ : 'No rooms yet.',
438
+ )
439
+ },
440
+ },
441
+
442
+ // ───── everything else ─────
443
+ tools: {
444
+ summary: 'List every action the server offers (always up to date)',
445
+ actions: [],
446
+ async run(ctx) {
447
+ const list = await manifest()
448
+ if (ctx.json) return list
449
+ const width = Math.max(...list.map((a) => a.name.length))
450
+ return list
451
+ .map((a) => `${a.name.padEnd(width)} ${a.title}`)
452
+ .join('\n')
453
+ .concat(
454
+ '\n\nRun one with: bestviber run <name> [--key value]... or --input <json>',
455
+ )
456
+ },
457
+ },
458
+
459
+ run: {
460
+ summary: 'Run any action by name',
461
+ usage:
462
+ 'run <action> [--key value]... | run <action> --input \'{"json":true}\'',
463
+ actions: [],
464
+ async run(ctx) {
465
+ const [name] = ctx.args
466
+ if (!name)
467
+ throw new CliError(
468
+ 'Usage: bestviber run <action>. See `bestviber tools`.',
469
+ )
470
+ const { input: raw, ...rest } = ctx.flags
471
+ let input = {}
472
+ if (raw) {
473
+ try {
474
+ input = JSON.parse(raw)
475
+ } catch {
476
+ throw new CliError('--input must be JSON.')
477
+ }
478
+ } else {
479
+ const action = (await manifest()).find((a) => a.name === name)
480
+ if (!action)
481
+ throw new CliError(
482
+ `Unknown action "${name}". See \`bestviber tools\`.`,
483
+ )
484
+ input = coerce(rest, action.inputSchema)
485
+ }
486
+ const result = await call(name, input)
487
+ return ctx.json ? result : JSON.stringify(result, null, 2)
488
+ },
489
+ },
490
+ }
491
+
492
+ /** Turns `--key value` strings into the types the JSON Schema asks for. */
493
+ function coerce(
494
+ /** @type {Record<string, any>} */ flags,
495
+ /** @type {any} */ schema,
496
+ ) {
497
+ /** @type {Record<string, unknown>} */
498
+ const input = {}
499
+ for (const [key, value] of Object.entries(flags)) {
500
+ const type = schema?.properties?.[key]?.type
501
+ if (type === 'number' || type === 'integer') input[key] = Number(value)
502
+ else if (type === 'boolean') input[key] = value === true || value === 'true'
503
+ else if (type === 'array') input[key] = asList(value)
504
+ else input[key] = value
505
+ }
506
+ return input
507
+ }
508
+
509
+ function periodCommand(
510
+ /** @type {string} */ period,
511
+ /** @type {string} */ date,
512
+ /** @type {string} */ summary,
513
+ ) {
514
+ return /** @type {Command} */ ({
515
+ summary,
516
+ actions: ['get_stats'],
517
+ async run(ctx) {
518
+ const s = await call('get_stats', { period, date })
519
+ return out(ctx, s, stats(s))
520
+ },
521
+ })
522
+ }
523
+
524
+ export { configPath }
package/src/format.mjs ADDED
@@ -0,0 +1,87 @@
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
+ return lines.join('\n')
87
+ }