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/README.md
CHANGED
|
@@ -1,3 +1,43 @@
|
|
|
1
|
-
#
|
|
1
|
+
# bestviber
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Focus timer and focus analytics for you and your AI agents.
|
|
4
|
+
|
|
5
|
+
## Claude Code, in two commands
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install -g bestviber
|
|
9
|
+
bestviber login
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`login` opens your browser to approve a short code, installs the agent skill,
|
|
13
|
+
and asks whether to track your Claude Code sessions automatically. Say yes and
|
|
14
|
+
there is nothing else to do: every Claude Code session is timed and filed
|
|
15
|
+
under the repository you work in. Quiet stretches are recorded as pauses, and
|
|
16
|
+
ten quiet minutes end the session. Then ask your agent "how was my week?", or
|
|
17
|
+
run `bestviber week`.
|
|
18
|
+
|
|
19
|
+
`bestviber logout` signs out and removes the tracking.
|
|
20
|
+
|
|
21
|
+
## By hand
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
bestviber start # start a focus session
|
|
25
|
+
bestviber stop --note "wrote the launch post"
|
|
26
|
+
bestviber week # this week: total, by day, by hour, by tag and project
|
|
27
|
+
bestviber insights # peak hours, best weekday, streaks, trends
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Every command takes `--json` for scripts and agents. `bestviber tools` lists
|
|
31
|
+
every action the server offers and `bestviber run <action>` runs any of them,
|
|
32
|
+
so new features work without updating the CLI.
|
|
33
|
+
|
|
34
|
+
## Other agents
|
|
35
|
+
|
|
36
|
+
- The skill goes to `~/.claude/skills/bestviber`, and also to
|
|
37
|
+
`~/.agents/skills/bestviber` if you have an `~/.agents` folder. Reinstall or
|
|
38
|
+
update it with `bestviber skill install`.
|
|
39
|
+
- `bestviber mcp` prints how to connect any MCP client to
|
|
40
|
+
`https://www.bestviber.com/api/mcp`. Sign in first: the setup line uses
|
|
41
|
+
your token.
|
|
42
|
+
|
|
43
|
+
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)
|
|
14
|
+
.filter(([, c]) => !c.hidden)
|
|
15
|
+
.map(([name, c]) => ` ${name.padEnd(width)} ${c.summary}`)
|
|
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.2.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
|
+
}
|