@kobbe/cli 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 ADDED
@@ -0,0 +1,80 @@
1
+ # Kobbe CLI
2
+
3
+ Kobbe CLI gives you terminal and MCP access to your Kobbe analytics workspace.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @kobbe/cli
9
+ ```
10
+
11
+ ## Login
12
+
13
+ Create a personal access token in Kobbe under `Account -> Agent access`, then run:
14
+
15
+ ```bash
16
+ kobbe login --token kbpat_...
17
+ ```
18
+
19
+ You can also avoid writing config by using environment variables:
20
+
21
+ ```bash
22
+ KOBBE_TOKEN=kbpat_... kobbe sites
23
+ ```
24
+
25
+ ## CLI Commands
26
+
27
+ ```bash
28
+ kobbe me
29
+ kobbe sites
30
+ kobbe overview --site site_x --range today
31
+ kobbe revenue --site site_x --range today
32
+ kobbe top-pages --site site_x --range today --limit 5
33
+ kobbe sources --site site_x --range today --limit 5
34
+ kobbe next --site site_x --range today
35
+ kobbe setup-health --site site_x
36
+ ```
37
+
38
+ Management commands require write scopes:
39
+
40
+ ```bash
41
+ kobbe create-site --name "My site" --domain example.com
42
+ kobbe update-site --site site_x --name "New name"
43
+ kobbe rotate-token --site site_x
44
+ kobbe delete-site --site site_x --confirm "DELETE example.com"
45
+ kobbe reset-stats --site site_x --confirm "RESET example.com"
46
+ ```
47
+
48
+ Use `--json` for raw API responses, or `--plain` for human output without symbols.
49
+
50
+ ## MCP
51
+
52
+ Add this server to your MCP client:
53
+
54
+ ```json
55
+ {
56
+ "mcpServers": {
57
+ "kobbe": {
58
+ "command": "kobbe",
59
+ "args": ["mcp"]
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ For local development:
66
+
67
+ ```json
68
+ {
69
+ "mcpServers": {
70
+ "kobbe": {
71
+ "command": "node",
72
+ "args": [
73
+ "/path/to/app.kobbe.io/packages/kobbe-cli/src/cli.js",
74
+ "mcp"
75
+ ]
76
+ }
77
+ }
78
+ }
79
+ ```
80
+
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@kobbe/cli",
3
+ "version": "0.1.0",
4
+ "description": "Kobbe CLI and MCP server for AI agents.",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "homepage": "https://kobbe.io/docs/ai-agent-cli",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/michael-andreuzza/analytics.git",
11
+ "directory": "packages/kobbe-cli"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/michael-andreuzza/analytics/issues"
15
+ },
16
+ "keywords": [
17
+ "analytics",
18
+ "kobbe",
19
+ "cli",
20
+ "mcp",
21
+ "model-context-protocol"
22
+ ],
23
+ "bin": {
24
+ "kobbe": "./src/cli.js"
25
+ },
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "files": [
30
+ "src"
31
+ ]
32
+ }
package/src/cli.js ADDED
@@ -0,0 +1,442 @@
1
+ #!/usr/bin/env node
2
+ import { KobbeClient, loadConfig, saveConfig } from "./client.js"
3
+ import { startMcpServer } from "./mcp.js"
4
+
5
+ function argsToOptions(args) {
6
+ const out = { _: [] }
7
+ for (let i = 0; i < args.length; i += 1) {
8
+ const arg = args[i]
9
+ if (!arg.startsWith("--")) {
10
+ out._.push(arg)
11
+ continue
12
+ }
13
+ const key = arg.slice(2)
14
+ const next = args[i + 1]
15
+ if (!next || next.startsWith("--")) {
16
+ out[key] = true
17
+ continue
18
+ }
19
+ out[key] = next
20
+ i += 1
21
+ }
22
+ return out
23
+ }
24
+
25
+ function printJson(value) {
26
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`)
27
+ }
28
+
29
+ const icons = {
30
+ account: "@",
31
+ sites: "#",
32
+ money: "$",
33
+ range: "~",
34
+ overview: ">",
35
+ pages: "::",
36
+ sources: "<",
37
+ setup: "+",
38
+ next: ">>",
39
+ orders: "#",
40
+ attributed: "=",
41
+ percent: "%",
42
+ good: "[ok]",
43
+ warn: "[!]",
44
+ quiet: "[-]",
45
+ }
46
+
47
+ function icon(name, options = {}) {
48
+ return options.plain ? "" : `${icons[name] || ""} `
49
+ }
50
+
51
+ function money(amount, currency) {
52
+ if (amount == null) return "-"
53
+ const value = Number(amount) / 100
54
+ if (!currency) return value.toFixed(2)
55
+ try {
56
+ return new Intl.NumberFormat("en", {
57
+ style: "currency",
58
+ currency,
59
+ }).format(value)
60
+ } catch {
61
+ return `${value.toFixed(2)} ${currency}`
62
+ }
63
+ }
64
+
65
+ function rangeLabel(value) {
66
+ const raw = String(value || "").trim()
67
+ if (!raw) return "Selected range"
68
+ const labels = {
69
+ today: "Today",
70
+ yesterday: "Yesterday",
71
+ "7d": "Last 7 days",
72
+ "30d": "Last 30 days",
73
+ "90d": "Last 90 days",
74
+ all: "All time",
75
+ }
76
+ return labels[raw] || raw
77
+ }
78
+
79
+ function optionalPositiveInt(value) {
80
+ if (value == null || value === true || value === "") return undefined
81
+ const parsed = Number(value)
82
+ if (!Number.isInteger(parsed) || parsed < 1) {
83
+ throw new Error("Pass --limit as a positive number.")
84
+ }
85
+ return parsed
86
+ }
87
+
88
+ function line(label, value) {
89
+ return `${label.padEnd(18)} ${value ?? "-"}`
90
+ }
91
+
92
+ function bullet(label, value, iconName, options = {}) {
93
+ const marker = options.plain ? "-" : icon(iconName, options).trim()
94
+ return `${marker} ${label}: ${value ?? "-"}`
95
+ }
96
+
97
+ function printRows(rows, columns) {
98
+ if (!rows?.length) {
99
+ process.stdout.write("No results yet.\n")
100
+ return
101
+ }
102
+ const widths = columns.map((column) =>
103
+ Math.max(
104
+ column.label.length,
105
+ ...rows.map((row) => String(column.value(row) ?? "-").length)
106
+ )
107
+ )
108
+ process.stdout.write(
109
+ `${columns
110
+ .map((column, index) => column.label.padEnd(widths[index]))
111
+ .join(" ")}\n`
112
+ )
113
+ process.stdout.write(`${widths.map((width) => "-".repeat(width)).join(" ")}\n`)
114
+ for (const row of rows) {
115
+ process.stdout.write(
116
+ `${columns
117
+ .map((column, index) => String(column.value(row) ?? "-").padEnd(widths[index]))
118
+ .join(" ")}\n`
119
+ )
120
+ }
121
+ }
122
+
123
+ function siteLabel(site) {
124
+ if (!site) return "Kobbe"
125
+ return site.domain || site.name || site.id
126
+ }
127
+
128
+ function printMe(data, options) {
129
+ process.stdout.write(`${icon("account", options)}Kobbe account\n\n`)
130
+ process.stdout.write(line("Workspace", data.workspace?.name || data.workspaceId))
131
+ process.stdout.write("\n")
132
+ process.stdout.write(line("Token scopes", data.scopes?.join(", ") || "none"))
133
+ process.stdout.write("\n")
134
+ }
135
+
136
+ function printSites(data, options) {
137
+ process.stdout.write(`${icon("sites", options)}Kobbe sites (${data.sites?.length ?? 0})\n\n`)
138
+ printRows(data.sites || [], [
139
+ { label: "Name", value: (row) => row.name },
140
+ { label: "Domain", value: (row) => row.domain || "-" },
141
+ { label: "Site ID", value: (row) => row.id },
142
+ ])
143
+ }
144
+
145
+ function printRevenue(data, options) {
146
+ const revenue = data.revenue
147
+ process.stdout.write(`${icon("money", options)}Money map for ${siteLabel(data.site)}\n`)
148
+ process.stdout.write(`${icon("range", options)}Range: ${data.range || rangeLabel(options.range)}\n\n`)
149
+ process.stdout.write(bullet("Orders", revenue.orders, "orders", options))
150
+ process.stdout.write("\n")
151
+ process.stdout.write(
152
+ bullet("Revenue", money(revenue.amount, revenue.currency), "money", options)
153
+ )
154
+ process.stdout.write("\n")
155
+ process.stdout.write(
156
+ bullet(
157
+ "Attributed",
158
+ money(revenue.attributedAmount, revenue.currency),
159
+ "attributed",
160
+ options
161
+ )
162
+ )
163
+ process.stdout.write("\n")
164
+ process.stdout.write(
165
+ bullet("Match rate", revenue.attributedPercent, "percent", options)
166
+ )
167
+ process.stdout.write("\n")
168
+ if (revenue.multipleCurrencies) {
169
+ process.stdout.write(
170
+ `\n${icon("warn", options)}Mixed currencies detected, so totals are raw cents.\n`
171
+ )
172
+ } else if (revenue.orders > 0 && revenue.attributedPercent === "100%") {
173
+ process.stdout.write(
174
+ `\n${icon("good", options)}Every sale has a trail. Very tidy.\n`
175
+ )
176
+ } else if (revenue.orders > 0) {
177
+ process.stdout.write(
178
+ `\n${icon("warn", options)}Some sales are missing attribution. Worth a look.\n`
179
+ )
180
+ } else {
181
+ process.stdout.write(`\n${icon("quiet", options)}No revenue in this range yet.\n`)
182
+ }
183
+ }
184
+
185
+ function printOverview(data, options) {
186
+ const overview = data.overview
187
+ process.stdout.write(`${icon("overview", options)}${siteLabel(data.site)} at a glance\n`)
188
+ process.stdout.write(`${icon("range", options)}Range: ${overview.range}\n\n`)
189
+ process.stdout.write(line("Visitors", overview.kpis.visitors))
190
+ process.stdout.write("\n")
191
+ process.stdout.write(line("Visits", overview.kpis.visits))
192
+ process.stdout.write("\n")
193
+ process.stdout.write(line("Views", overview.kpis.views))
194
+ process.stdout.write("\n")
195
+ process.stdout.write(line("Bounce rate", overview.kpis.bounceRate))
196
+ process.stdout.write("\n")
197
+ process.stdout.write(line("Session time", overview.kpis.sessionTime))
198
+ process.stdout.write("\n")
199
+ process.stdout.write(line("Online now", overview.kpis.online))
200
+ process.stdout.write("\n\n")
201
+ process.stdout.write(
202
+ `Revenue: ${money(overview.revenue.amount, overview.revenue.currency)} from ${overview.revenue.orders} orders (${overview.revenue.attributedPercent} attributed)\n\n`
203
+ )
204
+ process.stdout.write(`${icon("pages", options)}Top pages\n`)
205
+ printRows(overview.topPages.slice(0, 5), [
206
+ { label: "Path", value: (row) => row.path },
207
+ { label: "Visitors", value: (row) => row.visitors },
208
+ { label: "Views", value: (row) => row.views },
209
+ ])
210
+ }
211
+
212
+ function printTopPages(data, options) {
213
+ process.stdout.write(`${icon("pages", options)}Top pages for ${siteLabel(data.site)}\n`)
214
+ if (data.range || options.range) {
215
+ process.stdout.write(`${icon("range", options)}Range: ${data.range || rangeLabel(options.range)}\n`)
216
+ }
217
+ process.stdout.write("\n")
218
+ printRows(data.topPages || data.pages || [], [
219
+ { label: "Path", value: (row) => row.path },
220
+ { label: "Visitors", value: (row) => row.visitors },
221
+ { label: "Views", value: (row) => row.views },
222
+ ])
223
+ }
224
+
225
+ function printSources(data, options) {
226
+ process.stdout.write(`${icon("sources", options)}Traffic sources for ${siteLabel(data.site)}\n`)
227
+ if (data.range || options.range) {
228
+ process.stdout.write(`${icon("range", options)}Range: ${data.range || rangeLabel(options.range)}\n`)
229
+ }
230
+ process.stdout.write("\n")
231
+ printRows(data.sources || [], [
232
+ { label: "Source", value: (row) => row.source },
233
+ { label: "Visitors", value: (row) => row.visitors },
234
+ { label: "Views", value: (row) => row.views },
235
+ ])
236
+ }
237
+
238
+ function printSetupHealth(data, options) {
239
+ const health = data.setupHealth || data.health
240
+ process.stdout.write(`${icon("setup", options)}Setup check for ${siteLabel(data.site)}\n\n`)
241
+ process.stdout.write(line("Tracker", health.trackerInstalled ? "installed" : "missing"))
242
+ process.stdout.write("\n")
243
+ process.stdout.write(line("Pageviews", health.pageviewsAllTime))
244
+ process.stdout.write("\n")
245
+ process.stdout.write(
246
+ line("Revenue", health.revenueConfigured ? "connected" : "not connected")
247
+ )
248
+ process.stdout.write("\n")
249
+ process.stdout.write(line("Orders", health.revenueOrdersAllTime))
250
+ process.stdout.write("\n")
251
+ }
252
+
253
+ function printNextActions(data, options) {
254
+ const actions = data.nextActions || data.actions || data
255
+ process.stdout.write(`${icon("next", options)}Next best moves\n\n`)
256
+ if (!actions.length) {
257
+ process.stdout.write(`${icon("quiet", options)}Nothing urgent. Kobbe is calm today.\n`)
258
+ return
259
+ }
260
+ actions.forEach((action, index) => {
261
+ const marker = action.priority === "high" ? icon("warn", options) : ""
262
+ process.stdout.write(`${index + 1}. ${marker}${action.priority}: ${action.title}\n`)
263
+ process.stdout.write(` ${action.reason}\n`)
264
+ })
265
+ }
266
+
267
+ function printCreateSite(data, options) {
268
+ process.stdout.write(`${icon("good", options)}Site created\n\n`)
269
+ process.stdout.write(line("Site ID", data.siteId))
270
+ process.stdout.write("\n")
271
+ process.stdout.write(line("Tracker token", data.token))
272
+ process.stdout.write("\n\nStore this token now. Kobbe only shows tracker tokens once.\n")
273
+ }
274
+
275
+ function printUpdateSite(data, options) {
276
+ process.stdout.write(`${icon("good", options)}Site updated\n`)
277
+ }
278
+
279
+ function printRotateToken(data, options) {
280
+ process.stdout.write(`${icon("warn", options)}Tracker token rotated\n\n`)
281
+ process.stdout.write(line("New token", data.token))
282
+ process.stdout.write("\n\nUpdate your site tracker with this token. Kobbe only shows it once.\n")
283
+ }
284
+
285
+ function printDeleteSite(data, options) {
286
+ process.stdout.write(`${icon("good", options)}Site deleted\n`)
287
+ if (data.deleted) process.stdout.write(line("Site ID", data.deleted) + "\n")
288
+ }
289
+
290
+ function printResetStats(data, options) {
291
+ process.stdout.write(`${icon("good", options)}Stats reset\n`)
292
+ if (data.reset) process.stdout.write(line("Site ID", data.reset) + "\n")
293
+ }
294
+
295
+ function printHuman(command, data, options) {
296
+ if (command === "me") return printMe(data, options)
297
+ if (command === "sites") return printSites(data, options)
298
+ if (command === "overview") return printOverview(data, options)
299
+ if (command === "revenue") return printRevenue(data, options)
300
+ if (command === "top-pages") return printTopPages(data, options)
301
+ if (command === "sources") return printSources(data, options)
302
+ if (command === "setup-health") return printSetupHealth(data, options)
303
+ if (command === "next") return printNextActions(data, options)
304
+ if (command === "create-site") return printCreateSite(data, options)
305
+ if (command === "update-site") return printUpdateSite(data, options)
306
+ if (command === "rotate-token") return printRotateToken(data, options)
307
+ if (command === "delete-site") return printDeleteSite(data, options)
308
+ if (command === "reset-stats") return printResetStats(data, options)
309
+ return printJson(data)
310
+ }
311
+
312
+ function printOutput(command, data, options) {
313
+ if (options.json) return printJson(data)
314
+ return printHuman(command, data, options)
315
+ }
316
+
317
+ function usage() {
318
+ process.stdout.write(`Kobbe CLI
319
+
320
+ Usage:
321
+ kobbe login --token kbpat_... [--api https://app.kobbe.io]
322
+ kobbe me
323
+ kobbe sites
324
+ kobbe overview --site <site-id> [--range today]
325
+ kobbe revenue --site <site-id> [--range today]
326
+ kobbe top-pages --site <site-id> [--range today] [--limit 10]
327
+ kobbe sources --site <site-id> [--range today] [--limit 10]
328
+ kobbe next --site <site-id> [--range today]
329
+ kobbe setup-health --site <site-id>
330
+ kobbe create-site --name "My site" [--domain example.com]
331
+ kobbe update-site --site <site-id> [--name "New name"] [--domain example.com]
332
+ kobbe rotate-token --site <site-id>
333
+ kobbe delete-site --site <site-id> --confirm "DELETE example.com"
334
+ kobbe reset-stats --site <site-id> --confirm "RESET example.com"
335
+ kobbe mcp
336
+
337
+ Add --json to print raw API responses.
338
+ Add --plain to hide icons in human output.
339
+ Use KOBBE_TOKEN and KOBBE_API_BASE for environment-based config.
340
+ `)
341
+ }
342
+
343
+ async function main() {
344
+ const [command, ...rest] = process.argv.slice(2)
345
+ const options = argsToOptions(rest)
346
+ const client = new KobbeClient()
347
+
348
+ if (!command || command === "help" || command === "--help") {
349
+ usage()
350
+ return
351
+ }
352
+
353
+ if (command === "login") {
354
+ if (!options.token) throw new Error("Pass --token kbpat_...")
355
+ const config = loadConfig()
356
+ config.token = options.token
357
+ if (options.api) config.apiBase = options.api
358
+ saveConfig(config)
359
+ process.stdout.write("Kobbe token saved.\n")
360
+ return
361
+ }
362
+
363
+ if (command === "mcp") {
364
+ await startMcpServer()
365
+ return
366
+ }
367
+
368
+ if (command === "me")
369
+ return printOutput(command, await client.me(), options)
370
+ if (command === "sites")
371
+ return printOutput(command, await client.sites(), options)
372
+
373
+ const site = options.site
374
+ const range = options.range || "today"
375
+ const limit = optionalPositiveInt(options.limit)
376
+ if (command === "overview")
377
+ return printOutput(command, await client.overview(site, range), {
378
+ ...options,
379
+ range,
380
+ })
381
+ if (command === "revenue")
382
+ return printOutput(command, await client.revenue(site, range), {
383
+ ...options,
384
+ range,
385
+ })
386
+ if (command === "top-pages")
387
+ return printOutput(command, await client.topPages(site, range, limit), {
388
+ ...options,
389
+ range,
390
+ })
391
+ if (command === "sources")
392
+ return printOutput(command, await client.sources(site, range, limit), {
393
+ ...options,
394
+ range,
395
+ })
396
+ if (command === "setup-health")
397
+ return printOutput(command, await client.setupHealth(site), options)
398
+ if (command === "next")
399
+ return printOutput(command, await client.nextActions(site, range), {
400
+ ...options,
401
+ range,
402
+ })
403
+ if (command === "create-site") {
404
+ return printOutput(
405
+ command,
406
+ await client.createSite({
407
+ name: options.name || "My site",
408
+ domain: options.domain || null,
409
+ }),
410
+ options
411
+ )
412
+ }
413
+ if (command === "update-site") {
414
+ return printOutput(
415
+ command,
416
+ await client.updateSite(site, {
417
+ ...(options.name ? { name: options.name } : {}),
418
+ ...(Object.prototype.hasOwnProperty.call(options, "domain")
419
+ ? { domain: options.domain }
420
+ : {}),
421
+ }),
422
+ options
423
+ )
424
+ }
425
+ if (command === "rotate-token")
426
+ return printOutput(command, await client.rotateToken(site), options)
427
+ if (command === "delete-site")
428
+ return printOutput(command, await client.deleteSite(site, options.confirm), options)
429
+ if (command === "reset-stats")
430
+ return printOutput(command, await client.resetStats(site, options.confirm), options)
431
+
432
+ usage()
433
+ process.exitCode = 1
434
+ }
435
+
436
+ main().catch((error) => {
437
+ process.stderr.write(`${error.message}\n`)
438
+ if (error.response) {
439
+ process.stderr.write(`${JSON.stringify(error.response, null, 2)}\n`)
440
+ }
441
+ process.exitCode = 1
442
+ })
package/src/client.js ADDED
@@ -0,0 +1,164 @@
1
+ import fs from "node:fs"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+
5
+ const DEFAULT_API_BASE = "https://app.kobbe.io"
6
+ const CONFIG_DIR = path.join(os.homedir(), ".kobbe")
7
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json")
8
+
9
+ export function loadConfig() {
10
+ try {
11
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"))
12
+ } catch {
13
+ return {}
14
+ }
15
+ }
16
+
17
+ export function saveConfig(config) {
18
+ fs.mkdirSync(CONFIG_DIR, { recursive: true })
19
+ fs.writeFileSync(CONFIG_FILE, `${JSON.stringify(config, null, 2)}\n`, {
20
+ mode: 0o600,
21
+ })
22
+ }
23
+
24
+ export function apiBase(config = loadConfig()) {
25
+ return process.env.KOBBE_API_BASE || config.apiBase || DEFAULT_API_BASE
26
+ }
27
+
28
+ export function apiToken(config = loadConfig()) {
29
+ return process.env.KOBBE_TOKEN || config.token || null
30
+ }
31
+
32
+ function queryString(params = {}) {
33
+ const query = new URLSearchParams()
34
+ for (const [key, value] of Object.entries(params)) {
35
+ if (value == null || value === "") continue
36
+ query.set(key, String(value))
37
+ }
38
+ const value = query.toString()
39
+ return value ? `?${value}` : ""
40
+ }
41
+
42
+ export class KobbeClient {
43
+ constructor(options = {}) {
44
+ const config = loadConfig()
45
+ this.baseUrl = options.baseUrl || apiBase(config)
46
+ this.token = options.token || apiToken(config)
47
+ }
48
+
49
+ async request(method, endpoint, body) {
50
+ if (!this.token) {
51
+ throw new Error(
52
+ "Missing token. Run `kobbe login --token kbpat_...` first."
53
+ )
54
+ }
55
+ const init = {
56
+ method,
57
+ headers: {
58
+ authorization: `Bearer ${this.token}`,
59
+ "content-type": "application/json",
60
+ },
61
+ }
62
+ if (body != null) {
63
+ init.body = JSON.stringify(body)
64
+ }
65
+ const response = await fetch(`${this.baseUrl}${endpoint}`, init)
66
+ const data = await response.json().catch(() => ({}))
67
+ if (!response.ok || data.ok === false) {
68
+ const message = data.error || `HTTP ${response.status}`
69
+ const error = new Error(message)
70
+ error.response = data
71
+ throw error
72
+ }
73
+ return data
74
+ }
75
+
76
+ me() {
77
+ return this.request("GET", "/api/agent/me")
78
+ }
79
+
80
+ sites() {
81
+ return this.request("GET", "/api/agent/sites")
82
+ }
83
+
84
+ createSite(input) {
85
+ return this.request("POST", "/api/agent/sites", input)
86
+ }
87
+
88
+ getSite(siteId) {
89
+ return this.request("GET", `/api/agent/sites/${encodeURIComponent(siteId)}`)
90
+ }
91
+
92
+ updateSite(siteId, input) {
93
+ return this.request(
94
+ "PATCH",
95
+ `/api/agent/sites/${encodeURIComponent(siteId)}`,
96
+ input
97
+ )
98
+ }
99
+
100
+ deleteSite(siteId, confirm) {
101
+ return this.request(
102
+ "DELETE",
103
+ `/api/agent/sites/${encodeURIComponent(siteId)}`,
104
+ { confirm }
105
+ )
106
+ }
107
+
108
+ overview(siteId, range = "today") {
109
+ return this.request(
110
+ "GET",
111
+ `/api/agent/sites/${encodeURIComponent(siteId)}/overview${queryString({ range })}`
112
+ )
113
+ }
114
+
115
+ topPages(siteId, range = "today", limit) {
116
+ return this.request(
117
+ "GET",
118
+ `/api/agent/sites/${encodeURIComponent(siteId)}/top-pages${queryString({ range, limit })}`
119
+ )
120
+ }
121
+
122
+ sources(siteId, range = "today", limit) {
123
+ return this.request(
124
+ "GET",
125
+ `/api/agent/sites/${encodeURIComponent(siteId)}/sources${queryString({ range, limit })}`
126
+ )
127
+ }
128
+
129
+ revenue(siteId, range = "today") {
130
+ return this.request(
131
+ "GET",
132
+ `/api/agent/sites/${encodeURIComponent(siteId)}/revenue${queryString({ range })}`
133
+ )
134
+ }
135
+
136
+ setupHealth(siteId) {
137
+ return this.request(
138
+ "GET",
139
+ `/api/agent/sites/${encodeURIComponent(siteId)}/setup-health`
140
+ )
141
+ }
142
+
143
+ nextActions(siteId, range = "today") {
144
+ return this.request(
145
+ "GET",
146
+ `/api/agent/sites/${encodeURIComponent(siteId)}/next-actions${queryString({ range })}`
147
+ )
148
+ }
149
+
150
+ rotateToken(siteId) {
151
+ return this.request(
152
+ "POST",
153
+ `/api/agent/sites/${encodeURIComponent(siteId)}/rotate-token`
154
+ )
155
+ }
156
+
157
+ resetStats(siteId, confirm) {
158
+ return this.request(
159
+ "POST",
160
+ `/api/agent/sites/${encodeURIComponent(siteId)}/reset-stats`,
161
+ { confirm }
162
+ )
163
+ }
164
+ }
package/src/mcp.js ADDED
@@ -0,0 +1,230 @@
1
+ import readline from "node:readline"
2
+ import { KobbeClient } from "./client.js"
3
+
4
+ const client = new KobbeClient()
5
+
6
+ const tools = [
7
+ {
8
+ name: "list_sites",
9
+ description: "List Kobbe sites in the authenticated workspace.",
10
+ inputSchema: { type: "object", properties: {} },
11
+ },
12
+ {
13
+ name: "get_overview",
14
+ description:
15
+ "Get overview KPIs, top pages, sources, and revenue for a site.",
16
+ inputSchema: {
17
+ type: "object",
18
+ properties: {
19
+ siteId: { type: "string" },
20
+ range: { type: "string", default: "today" },
21
+ },
22
+ required: ["siteId"],
23
+ },
24
+ },
25
+ {
26
+ name: "get_revenue",
27
+ description: "Get revenue totals and attribution health for a site.",
28
+ inputSchema: {
29
+ type: "object",
30
+ properties: {
31
+ siteId: { type: "string" },
32
+ range: { type: "string", default: "today" },
33
+ },
34
+ required: ["siteId"],
35
+ },
36
+ },
37
+ {
38
+ name: "get_top_pages",
39
+ description: "Get top pages for a site by visitors and views.",
40
+ inputSchema: {
41
+ type: "object",
42
+ properties: {
43
+ siteId: { type: "string" },
44
+ range: { type: "string", default: "today" },
45
+ limit: { type: "number", default: 10 },
46
+ },
47
+ required: ["siteId"],
48
+ },
49
+ },
50
+ {
51
+ name: "get_sources",
52
+ description: "Get top traffic sources for a site by visitors and views.",
53
+ inputSchema: {
54
+ type: "object",
55
+ properties: {
56
+ siteId: { type: "string" },
57
+ range: { type: "string", default: "today" },
58
+ limit: { type: "number", default: 10 },
59
+ },
60
+ required: ["siteId"],
61
+ },
62
+ },
63
+ {
64
+ name: "get_next_actions",
65
+ description: "Find the next useful actions for a site.",
66
+ inputSchema: {
67
+ type: "object",
68
+ properties: {
69
+ siteId: { type: "string" },
70
+ range: { type: "string", default: "today" },
71
+ },
72
+ required: ["siteId"],
73
+ },
74
+ },
75
+ {
76
+ name: "get_setup_health",
77
+ description: "Inspect tracker and revenue setup health for a site.",
78
+ inputSchema: {
79
+ type: "object",
80
+ properties: { siteId: { type: "string" } },
81
+ required: ["siteId"],
82
+ },
83
+ },
84
+ {
85
+ name: "create_site",
86
+ description: "Create a Kobbe site and return a one-time tracker token.",
87
+ inputSchema: {
88
+ type: "object",
89
+ properties: {
90
+ name: { type: "string" },
91
+ domain: { type: "string" },
92
+ },
93
+ required: ["name"],
94
+ },
95
+ },
96
+ {
97
+ name: "update_site",
98
+ description: "Update a site name or domain.",
99
+ inputSchema: {
100
+ type: "object",
101
+ properties: {
102
+ siteId: { type: "string" },
103
+ name: { type: "string" },
104
+ domain: { type: "string" },
105
+ },
106
+ required: ["siteId"],
107
+ },
108
+ },
109
+ {
110
+ name: "rotate_site_token",
111
+ description:
112
+ "Mint a new tracker token for a site. This changes the token used by the tracker.",
113
+ inputSchema: {
114
+ type: "object",
115
+ properties: { siteId: { type: "string" } },
116
+ required: ["siteId"],
117
+ },
118
+ },
119
+ {
120
+ name: "delete_site",
121
+ description:
122
+ "Delete a site. Requires confirm: DELETE <site domain or name>.",
123
+ inputSchema: {
124
+ type: "object",
125
+ properties: {
126
+ siteId: { type: "string" },
127
+ confirm: { type: "string" },
128
+ },
129
+ required: ["siteId", "confirm"],
130
+ },
131
+ },
132
+ {
133
+ name: "reset_site_stats",
134
+ description:
135
+ "Delete analytics events for a site. Requires confirm: RESET <site domain or name>.",
136
+ inputSchema: {
137
+ type: "object",
138
+ properties: {
139
+ siteId: { type: "string" },
140
+ confirm: { type: "string" },
141
+ },
142
+ required: ["siteId", "confirm"],
143
+ },
144
+ },
145
+ ]
146
+
147
+ async function callTool(name, args = {}) {
148
+ if (name === "list_sites") return client.sites()
149
+ if (name === "get_overview")
150
+ return client.overview(args.siteId, args.range || "today")
151
+ if (name === "get_revenue")
152
+ return client.revenue(args.siteId, args.range || "today")
153
+ if (name === "get_top_pages")
154
+ return client.topPages(args.siteId, args.range || "today", args.limit)
155
+ if (name === "get_sources")
156
+ return client.sources(args.siteId, args.range || "today", args.limit)
157
+ if (name === "get_next_actions")
158
+ return client.nextActions(args.siteId, args.range || "today")
159
+ if (name === "get_setup_health") return client.setupHealth(args.siteId)
160
+ if (name === "create_site")
161
+ return client.createSite({ name: args.name, domain: args.domain || null })
162
+ if (name === "update_site")
163
+ return client.updateSite(args.siteId, {
164
+ name: args.name,
165
+ domain: args.domain,
166
+ })
167
+ if (name === "rotate_site_token") return client.rotateToken(args.siteId)
168
+ if (name === "delete_site")
169
+ return client.deleteSite(args.siteId, args.confirm)
170
+ if (name === "reset_site_stats")
171
+ return client.resetStats(args.siteId, args.confirm)
172
+ throw new Error(`Unknown tool: ${name}`)
173
+ }
174
+
175
+ function send(message) {
176
+ process.stdout.write(`${JSON.stringify(message)}\n`)
177
+ }
178
+
179
+ export async function startMcpServer() {
180
+ const rl = readline.createInterface({ input: process.stdin })
181
+ rl.on("line", async (line) => {
182
+ let request
183
+ try {
184
+ request = JSON.parse(line)
185
+ } catch {
186
+ return
187
+ }
188
+ try {
189
+ if (request.method === "initialize") {
190
+ send({
191
+ jsonrpc: "2.0",
192
+ id: request.id,
193
+ result: {
194
+ protocolVersion: "2024-11-05",
195
+ capabilities: { tools: {} },
196
+ serverInfo: { name: "@kobbe/cli", version: "0.1.0" },
197
+ },
198
+ })
199
+ return
200
+ }
201
+ if (request.method === "tools/list") {
202
+ send({ jsonrpc: "2.0", id: request.id, result: { tools } })
203
+ return
204
+ }
205
+ if (request.method === "tools/call") {
206
+ const result = await callTool(
207
+ request.params?.name,
208
+ request.params?.arguments || {}
209
+ )
210
+ send({
211
+ jsonrpc: "2.0",
212
+ id: request.id,
213
+ result: {
214
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
215
+ },
216
+ })
217
+ return
218
+ }
219
+ if (request.id != null) {
220
+ send({ jsonrpc: "2.0", id: request.id, result: {} })
221
+ }
222
+ } catch (error) {
223
+ send({
224
+ jsonrpc: "2.0",
225
+ id: request.id,
226
+ error: { code: -32000, message: error.message || "Kobbe MCP error" },
227
+ })
228
+ }
229
+ })
230
+ }