@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 +80 -0
- package/package.json +32 -0
- package/src/cli.js +442 -0
- package/src/client.js +164 -0
- package/src/mcp.js +230 -0
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
|
+
}
|