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 +24 -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 +524 -0
- package/src/format.mjs +87 -0
package/README.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
-
#
|
|
1
|
+
# bestviber
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|
package/src/commands.mjs
ADDED
|
@@ -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
|
+
}
|