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/commands.mjs
ADDED
|
@@ -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 }
|