@supalytics/cli 0.1.1 → 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 CHANGED
@@ -46,6 +46,14 @@ supalytics realtime # Current visitors
46
46
  supalytics realtime --watch # Auto-refresh every 30s
47
47
  ```
48
48
 
49
+ ### Trend
50
+
51
+ ```bash
52
+ supalytics trend # Daily visitor trend with bar chart
53
+ supalytics trend --period 7d # Last 7 days
54
+ supalytics trend --compact # Compact sparkline only
55
+ ```
56
+
49
57
  ### Breakdowns
50
58
 
51
59
  ```bash
@@ -105,6 +113,21 @@ supalytics default <domain> # Set default site
105
113
  supalytics stats -s other.com # Query specific site
106
114
  ```
107
115
 
116
+ ## Shell Completions
117
+
118
+ Enable tab completion for your shell:
119
+
120
+ ```bash
121
+ # Bash (add to ~/.bashrc)
122
+ eval "$(supalytics completions bash)"
123
+
124
+ # Zsh (add to ~/.zshrc)
125
+ eval "$(supalytics completions zsh)"
126
+
127
+ # Fish
128
+ supalytics completions fish > ~/.config/fish/completions/supalytics.fish
129
+ ```
130
+
108
131
  ## License
109
132
 
110
133
  Apache-2.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supalytics/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "CLI for Supalytics web analytics",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "files": [
10
10
  "src",
11
- "bin"
11
+ "bin",
12
+ "README.md"
12
13
  ],
13
14
  "scripts": {
14
15
  "dev": "bun run src/index.ts"
@@ -0,0 +1,305 @@
1
+ import { Command } from "commander";
2
+
3
+ const BASH_COMPLETION = `# Supalytics CLI Bash Completion
4
+ # Add this to ~/.bashrc or ~/.bash_profile:
5
+ # eval "$(supalytics completions bash)"
6
+
7
+ _supalytics_completions() {
8
+ local cur prev commands opts
9
+ COMPREPLY=()
10
+ cur="\${COMP_WORDS[COMP_CWORD]}"
11
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
12
+
13
+ commands="login logout sites default remove stats pages referrers countries trend query events realtime completions help"
14
+
15
+ # Main command completion
16
+ if [[ \${COMP_CWORD} -eq 1 ]]; then
17
+ COMPREPLY=( $(compgen -W "\${commands}" -- \${cur}) )
18
+ return 0
19
+ fi
20
+
21
+ # Subcommand options
22
+ case "\${COMP_WORDS[1]}" in
23
+ stats)
24
+ opts="today yesterday week month year 7d 14d 30d 90d 12mo all --site --start --end --filter --all --no-revenue --json"
25
+ ;;
26
+ pages|referrers|countries)
27
+ opts="--site --period --start --end --limit --filter --no-revenue --json"
28
+ ;;
29
+ trend)
30
+ opts="--site --period --start --end --filter --no-revenue --compact --json"
31
+ ;;
32
+ query)
33
+ opts="--site --metrics --dimensions --filter --sort --timezone --period --start --end --limit --offset --no-revenue --json"
34
+ ;;
35
+ events)
36
+ opts="--site --period --property --limit --no-revenue --json"
37
+ ;;
38
+ realtime)
39
+ opts="--site --json --watch"
40
+ ;;
41
+ login)
42
+ opts="--domain"
43
+ ;;
44
+ completions)
45
+ opts="bash zsh fish"
46
+ ;;
47
+ *)
48
+ opts=""
49
+ ;;
50
+ esac
51
+
52
+ COMPREPLY=( $(compgen -W "\${opts}" -- \${cur}) )
53
+ return 0
54
+ }
55
+
56
+ complete -F _supalytics_completions supalytics
57
+ `;
58
+
59
+ const ZSH_COMPLETION = `#compdef supalytics
60
+ # Supalytics CLI Zsh Completion
61
+ # Add this to ~/.zshrc:
62
+ # eval "$(supalytics completions zsh)"
63
+
64
+ _supalytics() {
65
+ local -a commands
66
+ commands=(
67
+ 'login:Add a site by verifying its API key'
68
+ 'logout:Remove stored credentials'
69
+ 'sites:List all configured sites'
70
+ 'default:Set the default site'
71
+ 'remove:Remove a site'
72
+ 'stats:Overview stats (pageviews, visitors, bounce rate, revenue)'
73
+ 'pages:Top pages by visitors'
74
+ 'referrers:Top referrers'
75
+ 'countries:Traffic by country'
76
+ 'trend:Daily visitor trend'
77
+ 'query:Flexible query with custom metrics and dimensions'
78
+ 'events:List and explore custom events'
79
+ 'realtime:Live visitors on your site right now'
80
+ 'completions:Generate shell completions'
81
+ 'help:Display help for command'
82
+ )
83
+
84
+ local -a period_opts
85
+ period_opts=(
86
+ 'today:Today only'
87
+ 'yesterday:Yesterday only'
88
+ 'week:This week'
89
+ 'month:This month'
90
+ 'year:This year'
91
+ '7d:Last 7 days'
92
+ '14d:Last 14 days'
93
+ '30d:Last 30 days'
94
+ '90d:Last 90 days'
95
+ '12mo:Last 12 months'
96
+ 'all:All time'
97
+ )
98
+
99
+ _arguments -C \\
100
+ '1: :->command' \\
101
+ '*:: :->args'
102
+
103
+ case \$state in
104
+ command)
105
+ _describe 'command' commands
106
+ ;;
107
+ args)
108
+ case \$words[1] in
109
+ stats)
110
+ _arguments \\
111
+ '1: :->period' \\
112
+ '--site[Site to query]:domain:' \\
113
+ '--start[Start date]:date:' \\
114
+ '--end[End date]:date:' \\
115
+ '*--filter[Filter]:filter:' \\
116
+ '--all[Show detailed breakdown]' \\
117
+ '--no-revenue[Exclude revenue metrics]' \\
118
+ '--json[Output as JSON]'
119
+ case \$state in
120
+ period)
121
+ _describe 'period' period_opts
122
+ ;;
123
+ esac
124
+ ;;
125
+ pages|referrers|countries)
126
+ _arguments \\
127
+ '--site[Site to query]:domain:' \\
128
+ '--period[Time period]:period:(7d 14d 30d 90d 12mo all)' \\
129
+ '--start[Start date]:date:' \\
130
+ '--end[End date]:date:' \\
131
+ '--limit[Number of results]:limit:' \\
132
+ '*--filter[Filter]:filter:' \\
133
+ '--no-revenue[Exclude revenue metrics]' \\
134
+ '--json[Output as JSON]'
135
+ ;;
136
+ trend)
137
+ _arguments \\
138
+ '--site[Site to query]:domain:' \\
139
+ '--period[Time period]:period:(7d 14d 30d 90d 12mo all)' \\
140
+ '--start[Start date]:date:' \\
141
+ '--end[End date]:date:' \\
142
+ '*--filter[Filter]:filter:' \\
143
+ '--no-revenue[Exclude revenue metrics]' \\
144
+ '--compact[Show compact sparkline only]' \\
145
+ '--json[Output as JSON]'
146
+ ;;
147
+ query)
148
+ _arguments \\
149
+ '--site[Site to query]:domain:' \\
150
+ '--metrics[Metrics]:metrics:(visitors bounce_rate avg_session_duration revenue conversions)' \\
151
+ '--dimensions[Dimensions]:dimensions:(page referrer country region city browser os device date hour event)' \\
152
+ '*--filter[Filter]:filter:' \\
153
+ '--sort[Sort by field]:sort:' \\
154
+ '--timezone[Timezone]:timezone:' \\
155
+ '--period[Time period]:period:(7d 14d 30d 90d 12mo all)' \\
156
+ '--start[Start date]:date:' \\
157
+ '--end[End date]:date:' \\
158
+ '--limit[Number of results]:limit:' \\
159
+ '--offset[Skip results]:offset:' \\
160
+ '--no-revenue[Exclude revenue metrics]' \\
161
+ '--json[Output as JSON]'
162
+ ;;
163
+ events)
164
+ _arguments \\
165
+ '1:event name:' \\
166
+ '--site[Site to query]:domain:' \\
167
+ '--period[Time period]:period:(7d 14d 30d 90d 12mo all)' \\
168
+ '--property[Property key]:property:' \\
169
+ '--limit[Number of results]:limit:' \\
170
+ '--no-revenue[Exclude revenue]' \\
171
+ '--json[Output as JSON]'
172
+ ;;
173
+ realtime)
174
+ _arguments \\
175
+ '--site[Site to query]:domain:' \\
176
+ '--json[Output as JSON]' \\
177
+ '--watch[Auto-refresh every 30 seconds]'
178
+ ;;
179
+ login)
180
+ _arguments \\
181
+ '1:API key:' \\
182
+ '--domain[Override detected domain]:domain:'
183
+ ;;
184
+ default|remove)
185
+ _arguments '1:domain:'
186
+ ;;
187
+ completions)
188
+ _arguments '1:shell:(bash zsh fish)'
189
+ ;;
190
+ esac
191
+ ;;
192
+ esac
193
+ }
194
+
195
+ _supalytics
196
+ `;
197
+
198
+ const FISH_COMPLETION = `# Supalytics CLI Fish Completion
199
+ # Add this to ~/.config/fish/completions/supalytics.fish:
200
+ # supalytics completions fish > ~/.config/fish/completions/supalytics.fish
201
+
202
+ # Disable file completion by default
203
+ complete -c supalytics -f
204
+
205
+ # Commands
206
+ complete -c supalytics -n "__fish_use_subcommand" -a "login" -d "Add a site by verifying its API key"
207
+ complete -c supalytics -n "__fish_use_subcommand" -a "logout" -d "Remove stored credentials"
208
+ complete -c supalytics -n "__fish_use_subcommand" -a "sites" -d "List all configured sites"
209
+ complete -c supalytics -n "__fish_use_subcommand" -a "default" -d "Set the default site"
210
+ complete -c supalytics -n "__fish_use_subcommand" -a "remove" -d "Remove a site"
211
+ complete -c supalytics -n "__fish_use_subcommand" -a "stats" -d "Overview stats"
212
+ complete -c supalytics -n "__fish_use_subcommand" -a "pages" -d "Top pages by visitors"
213
+ complete -c supalytics -n "__fish_use_subcommand" -a "referrers" -d "Top referrers"
214
+ complete -c supalytics -n "__fish_use_subcommand" -a "countries" -d "Traffic by country"
215
+ complete -c supalytics -n "__fish_use_subcommand" -a "trend" -d "Daily visitor trend"
216
+ complete -c supalytics -n "__fish_use_subcommand" -a "query" -d "Flexible query with custom metrics"
217
+ complete -c supalytics -n "__fish_use_subcommand" -a "events" -d "List and explore custom events"
218
+ complete -c supalytics -n "__fish_use_subcommand" -a "realtime" -d "Live visitors right now"
219
+ complete -c supalytics -n "__fish_use_subcommand" -a "completions" -d "Generate shell completions"
220
+ complete -c supalytics -n "__fish_use_subcommand" -a "help" -d "Display help for command"
221
+
222
+ # Stats options
223
+ complete -c supalytics -n "__fish_seen_subcommand_from stats" -a "today yesterday week month year 7d 14d 30d 90d 12mo all"
224
+ complete -c supalytics -n "__fish_seen_subcommand_from stats" -l site -s s -d "Site to query"
225
+ complete -c supalytics -n "__fish_seen_subcommand_from stats" -l start -d "Start date"
226
+ complete -c supalytics -n "__fish_seen_subcommand_from stats" -l end -d "End date"
227
+ complete -c supalytics -n "__fish_seen_subcommand_from stats" -l filter -s f -d "Filter"
228
+ complete -c supalytics -n "__fish_seen_subcommand_from stats" -l all -s a -d "Show detailed breakdown"
229
+ complete -c supalytics -n "__fish_seen_subcommand_from stats" -l no-revenue -d "Exclude revenue"
230
+ complete -c supalytics -n "__fish_seen_subcommand_from stats" -l json -d "Output as JSON"
231
+
232
+ # Pages/Referrers/Countries options
233
+ complete -c supalytics -n "__fish_seen_subcommand_from pages referrers countries" -l site -s s -d "Site to query"
234
+ complete -c supalytics -n "__fish_seen_subcommand_from pages referrers countries" -l period -s p -d "Time period" -a "7d 14d 30d 90d 12mo all"
235
+ complete -c supalytics -n "__fish_seen_subcommand_from pages referrers countries" -l start -d "Start date"
236
+ complete -c supalytics -n "__fish_seen_subcommand_from pages referrers countries" -l end -d "End date"
237
+ complete -c supalytics -n "__fish_seen_subcommand_from pages referrers countries" -l limit -s l -d "Number of results"
238
+ complete -c supalytics -n "__fish_seen_subcommand_from pages referrers countries" -l filter -s f -d "Filter"
239
+ complete -c supalytics -n "__fish_seen_subcommand_from pages referrers countries" -l no-revenue -d "Exclude revenue"
240
+ complete -c supalytics -n "__fish_seen_subcommand_from pages referrers countries" -l json -d "Output as JSON"
241
+
242
+ # Trend options
243
+ complete -c supalytics -n "__fish_seen_subcommand_from trend" -l site -s s -d "Site to query"
244
+ complete -c supalytics -n "__fish_seen_subcommand_from trend" -l period -s p -d "Time period" -a "7d 14d 30d 90d 12mo all"
245
+ complete -c supalytics -n "__fish_seen_subcommand_from trend" -l start -d "Start date"
246
+ complete -c supalytics -n "__fish_seen_subcommand_from trend" -l end -d "End date"
247
+ complete -c supalytics -n "__fish_seen_subcommand_from trend" -l filter -s f -d "Filter"
248
+ complete -c supalytics -n "__fish_seen_subcommand_from trend" -l no-revenue -d "Exclude revenue"
249
+ complete -c supalytics -n "__fish_seen_subcommand_from trend" -l compact -d "Show compact sparkline"
250
+ complete -c supalytics -n "__fish_seen_subcommand_from trend" -l json -d "Output as JSON"
251
+
252
+ # Query options
253
+ complete -c supalytics -n "__fish_seen_subcommand_from query" -l site -s s -d "Site to query"
254
+ complete -c supalytics -n "__fish_seen_subcommand_from query" -l metrics -s m -d "Metrics" -a "visitors bounce_rate avg_session_duration revenue conversions"
255
+ complete -c supalytics -n "__fish_seen_subcommand_from query" -l dimensions -s d -d "Dimensions" -a "page referrer country region city browser os device date hour event"
256
+ complete -c supalytics -n "__fish_seen_subcommand_from query" -l filter -s f -d "Filter"
257
+ complete -c supalytics -n "__fish_seen_subcommand_from query" -l sort -d "Sort by field"
258
+ complete -c supalytics -n "__fish_seen_subcommand_from query" -l timezone -d "Timezone"
259
+ complete -c supalytics -n "__fish_seen_subcommand_from query" -l period -s p -d "Time period" -a "7d 14d 30d 90d 12mo all"
260
+ complete -c supalytics -n "__fish_seen_subcommand_from query" -l start -d "Start date"
261
+ complete -c supalytics -n "__fish_seen_subcommand_from query" -l end -d "End date"
262
+ complete -c supalytics -n "__fish_seen_subcommand_from query" -l limit -s l -d "Number of results"
263
+ complete -c supalytics -n "__fish_seen_subcommand_from query" -l offset -d "Skip results"
264
+ complete -c supalytics -n "__fish_seen_subcommand_from query" -l no-revenue -d "Exclude revenue"
265
+ complete -c supalytics -n "__fish_seen_subcommand_from query" -l json -d "Output as JSON"
266
+
267
+ # Events options
268
+ complete -c supalytics -n "__fish_seen_subcommand_from events" -l site -s s -d "Site to query"
269
+ complete -c supalytics -n "__fish_seen_subcommand_from events" -l period -s p -d "Time period" -a "7d 14d 30d 90d 12mo all"
270
+ complete -c supalytics -n "__fish_seen_subcommand_from events" -l property -d "Property key"
271
+ complete -c supalytics -n "__fish_seen_subcommand_from events" -l limit -s l -d "Number of results"
272
+ complete -c supalytics -n "__fish_seen_subcommand_from events" -l no-revenue -d "Exclude revenue"
273
+ complete -c supalytics -n "__fish_seen_subcommand_from events" -l json -d "Output as JSON"
274
+
275
+ # Realtime options
276
+ complete -c supalytics -n "__fish_seen_subcommand_from realtime" -l site -s s -d "Site to query"
277
+ complete -c supalytics -n "__fish_seen_subcommand_from realtime" -l json -d "Output as JSON"
278
+ complete -c supalytics -n "__fish_seen_subcommand_from realtime" -l watch -s w -d "Auto-refresh"
279
+
280
+ # Login options
281
+ complete -c supalytics -n "__fish_seen_subcommand_from login" -l domain -d "Override detected domain"
282
+
283
+ # Completions command
284
+ complete -c supalytics -n "__fish_seen_subcommand_from completions" -a "bash zsh fish"
285
+ `;
286
+
287
+ export const completionsCommand = new Command("completions")
288
+ .description("Generate shell completions")
289
+ .argument("<shell>", "Shell type: bash, zsh, or fish")
290
+ .action((shell: string) => {
291
+ switch (shell.toLowerCase()) {
292
+ case "bash":
293
+ console.log(BASH_COMPLETION);
294
+ break;
295
+ case "zsh":
296
+ console.log(ZSH_COMPLETION);
297
+ break;
298
+ case "fish":
299
+ console.log(FISH_COMPLETION);
300
+ break;
301
+ default:
302
+ console.error(`Unknown shell: ${shell}. Use bash, zsh, or fish.`);
303
+ process.exit(1);
304
+ }
305
+ });
@@ -9,14 +9,14 @@ Examples:
9
9
  # List all events
10
10
  supalytics events
11
11
 
12
- # List properties for an event
12
+ # Show event stats and properties
13
13
  supalytics events signup
14
14
 
15
15
  # Get breakdown of a property
16
16
  supalytics events signup --property plan
17
17
 
18
- # With revenue
19
- supalytics events signup --property plan --revenue`;
18
+ # Without revenue
19
+ supalytics events signup --property plan --no-revenue`;
20
20
 
21
21
  function displayPropertyKeys(response: PropertyKeysResponse, event: string, json: boolean) {
22
22
  if (json) {
@@ -48,7 +48,7 @@ export const eventsCommand = new Command("events")
48
48
  .option("-p, --period <period>", "Time period: 7d, 14d, 30d, 90d, 12mo, all", "30d")
49
49
  .option("--property <key>", "Get breakdown for a specific property")
50
50
  .option("-l, --limit <number>", "Number of results", "20")
51
- .option("--revenue", "Include revenue in property breakdown")
51
+ .option("--no-revenue", "Exclude revenue metrics")
52
52
  .option("--json", "Output as JSON")
53
53
  .action(async (event, options) => {
54
54
  const site = options.site || (await getDefaultSite());
@@ -94,7 +94,7 @@ export const eventsCommand = new Command("events")
94
94
  options.property,
95
95
  options.period,
96
96
  parseInt(options.limit),
97
- options.revenue
97
+ options.revenue !== false
98
98
  );
99
99
 
100
100
  if (options.json) {
@@ -113,7 +113,7 @@ export const eventsCommand = new Command("events")
113
113
 
114
114
  for (const v of response.data) {
115
115
  let line = ` ${chalk.cyan(v.value)} ${formatNumber(v.visitors)} visitors ${formatNumber(v.count)} events`;
116
- if (options.revenue && v.revenue !== null) {
116
+ if (options.revenue !== false && v.revenue !== null) {
117
117
  line += ` ${chalk.green("$" + (v.revenue / 100).toFixed(2))}`;
118
118
  }
119
119
  console.log(line);
@@ -122,9 +122,47 @@ export const eventsCommand = new Command("events")
122
122
  return;
123
123
  }
124
124
 
125
- // If just event name, show properties
126
- const response = await getEventProperties(site, event, options.period);
127
- displayPropertyKeys(response, event, options.json);
125
+ // If just event name, show event stats + properties
126
+ const [eventsResponse, propsResponse] = await Promise.all([
127
+ listEvents(site, options.period, 100), // Get all events to find this one
128
+ getEventProperties(site, event, options.period),
129
+ ]);
130
+
131
+ // Find the specific event stats
132
+ const eventData = eventsResponse.data.find((e) => e.name === event);
133
+
134
+ if (options.json) {
135
+ console.log(JSON.stringify({
136
+ event: eventData || { name: event, count: 0, visitors: 0, has_properties: false },
137
+ properties: propsResponse.data,
138
+ meta: propsResponse.meta,
139
+ }, null, 2));
140
+ return;
141
+ }
142
+
143
+ const [startDate, endDate] = propsResponse.meta.date_range;
144
+ console.log();
145
+ console.log(chalk.bold(event), chalk.dim(`${startDate} → ${endDate}`));
146
+ console.log();
147
+
148
+ // Show event stats
149
+ if (eventData) {
150
+ console.log(` ${formatNumber(eventData.visitors)} visitors ${formatNumber(eventData.count)} events`);
151
+ } else {
152
+ console.log(chalk.dim(" No data for this event"));
153
+ }
154
+ console.log();
155
+
156
+ // Show properties
157
+ if (propsResponse.data.length > 0) {
158
+ console.log(chalk.dim(" PROPERTIES"));
159
+ for (const key of propsResponse.data) {
160
+ console.log(` ${chalk.cyan(key)}`);
161
+ }
162
+ console.log();
163
+ console.log(chalk.dim(` Use: supalytics events ${event} --property <key>`));
164
+ }
165
+ console.log();
128
166
 
129
167
  } catch (error) {
130
168
  console.error(chalk.red(`Error: ${(error as Error).message}`));
@@ -2,6 +2,7 @@ import { Command } from "commander";
2
2
  import chalk from "chalk";
3
3
  import { query, formatNumber, formatPercent, formatDuration } from "../api";
4
4
  import { getDefaultSite } from "../config";
5
+ import { table, type TableColumn } from "../ui";
5
6
 
6
7
  const queryDescription = `Flexible query with custom metrics and dimensions.
7
8
 
@@ -36,7 +37,7 @@ Examples:
36
37
  export const queryCommand = new Command("query")
37
38
  .description(queryDescription)
38
39
  .option("-s, --site <site>", "Site to query")
39
- .option("-m, --metrics <metrics>", "Metrics: visitors, bounce_rate, avg_session_duration, revenue, conversions", "visitors")
40
+ .option("-m, --metrics <metrics>", "Metrics: visitors, bounce_rate, avg_session_duration, revenue, conversions", "visitors,revenue")
40
41
  .option("-d, --dimensions <dimensions>", "Dimensions: page, referrer, country, region, city, browser, os, device, date, hour, event, utm_* (max 2, event cannot combine)")
41
42
  .option("-f, --filter <filters...>", "Filters: field:operator:value (e.g., 'page:contains:/blog', 'event:is:signup', 'event_property:is:plan:premium')")
42
43
  .option("--sort <sort>", "Sort by field:order (e.g., 'revenue:desc', 'visitors:asc')")
@@ -126,34 +127,56 @@ export const queryCommand = new Command("query")
126
127
  return;
127
128
  }
128
129
 
129
- // Display results
130
- for (const result of response.data) {
131
- const parts: string[] = [];
132
-
133
- // Dimensions
134
- if (result.dimensions) {
135
- for (const [key, value] of Object.entries(result.dimensions)) {
136
- parts.push(`${chalk.dim(key)}=${value}`);
137
- }
130
+ // Build columns from data
131
+ const columns: TableColumn[] = [];
132
+
133
+ // Add dimension columns
134
+ if (response.data[0]?.dimensions) {
135
+ for (const key of Object.keys(response.data[0].dimensions)) {
136
+ columns.push({
137
+ key: `dim_${key}`,
138
+ label: key.toUpperCase(),
139
+ align: "left",
140
+ });
138
141
  }
142
+ }
143
+
144
+ // Add metric columns
145
+ for (const key of Object.keys(response.data[0]?.metrics || {})) {
146
+ columns.push({
147
+ key: `met_${key}`,
148
+ label: key.toUpperCase().replaceAll("_", " "),
149
+ align: "right",
150
+ format: (val) => {
151
+ if (val === null || val === undefined) return "-";
152
+ const v = val as number;
153
+ if (key === "bounce_rate" || key === "conversion_rate") {
154
+ return formatPercent(v);
155
+ } else if (key === "avg_session_duration") {
156
+ return formatDuration(v);
157
+ } else if (key === "revenue") {
158
+ return chalk.green("$" + (v / 100).toFixed(2));
159
+ }
160
+ return formatNumber(v);
161
+ },
162
+ });
163
+ }
139
164
 
140
- // Metrics
141
- for (const [key, value] of Object.entries(result.metrics)) {
142
- let formatted: string;
143
- if (key === "bounce_rate" || key === "conversion_rate") {
144
- formatted = formatPercent(value);
145
- } else if (key === "avg_session_duration") {
146
- formatted = formatDuration(value);
147
- } else if (key === "revenue") {
148
- formatted = chalk.green("$" + (value / 100).toFixed(2));
149
- } else {
150
- formatted = formatNumber(value);
165
+ // Transform data for table
166
+ const tableData = response.data.map((r) => {
167
+ const row: Record<string, unknown> = {};
168
+ if (r.dimensions) {
169
+ for (const [k, v] of Object.entries(r.dimensions)) {
170
+ row[`dim_${k}`] = v || "(none)";
151
171
  }
152
- parts.push(`${chalk.dim(key)}=${formatted}`);
153
172
  }
173
+ for (const [k, v] of Object.entries(r.metrics)) {
174
+ row[`met_${k}`] = v;
175
+ }
176
+ return row;
177
+ });
154
178
 
155
- console.log(` ${parts.join(" ")}`);
156
- }
179
+ console.log(table(tableData, columns));
157
180
  console.log();
158
181
  } catch (error) {
159
182
  console.error(chalk.red(`Error: ${(error as Error).message}`));
@@ -1,8 +1,8 @@
1
1
  import { Command } from "commander";
2
2
  import chalk from "chalk";
3
- import { query, formatNumber, formatPercent, formatDuration } from "../api";
3
+ import { query, formatNumber, formatPercent, formatDuration, listEvents } from "../api";
4
4
  import { getDefaultSite } from "../config";
5
- import { logo, parsePeriod } from "../ui";
5
+ import { logo, parsePeriod, truncate } from "../ui";
6
6
 
7
7
  export const statsCommand = new Command("stats")
8
8
  .description("Overview stats: pageviews, visitors, bounce rate, revenue")
@@ -11,6 +11,7 @@ export const statsCommand = new Command("stats")
11
11
  .option("--start <date>", "Start date (YYYY-MM-DD)")
12
12
  .option("--end <date>", "End date (YYYY-MM-DD)")
13
13
  .option("-f, --filter <filters...>", "Filters in format 'field:operator:value'")
14
+ .option("-a, --all", "Show detailed breakdown (pages, referrers, countries, etc.)")
14
15
  .option("--no-revenue", "Exclude revenue metrics")
15
16
  .option("--json", "Output as JSON")
16
17
  .action(async (period, options) => {
@@ -43,15 +44,46 @@ export const statsCommand = new Command("stats")
43
44
  metrics.push("revenue", "conversions", "conversion_rate");
44
45
  }
45
46
 
46
- const response = await query(site, {
47
- metrics,
48
- filters,
49
- date_range: dateRange,
50
- include_revenue: options.revenue !== false,
51
- });
47
+ // Build API calls
48
+ const apiCalls: Promise<unknown>[] = [
49
+ query(site, {
50
+ metrics,
51
+ filters,
52
+ date_range: dateRange,
53
+ include_revenue: options.revenue !== false,
54
+ }),
55
+ ];
56
+
57
+ // Add breakdown calls if --all flag
58
+ if (options.all) {
59
+ const breakdownOpts = { filters, date_range: dateRange, limit: 10 };
60
+ apiCalls.push(
61
+ query(site, { metrics: ["visitors"], dimensions: ["page"], ...breakdownOpts }),
62
+ query(site, { metrics: ["visitors"], dimensions: ["referrer"], ...breakdownOpts }),
63
+ query(site, { metrics: ["visitors"], dimensions: ["country"], ...breakdownOpts }),
64
+ query(site, { metrics: ["visitors"], dimensions: ["browser"], ...breakdownOpts }),
65
+ query(site, { metrics: ["visitors"], dimensions: ["utm_source"], ...breakdownOpts }),
66
+ listEvents(site, typeof dateRange === "string" ? dateRange : "30d", 10)
67
+ );
68
+ }
69
+
70
+ const results = await Promise.all(apiCalls);
71
+ const response = results[0] as Awaited<ReturnType<typeof query>>;
52
72
 
53
73
  if (options.json) {
54
- console.log(JSON.stringify(response, null, 2));
74
+ if (options.all) {
75
+ console.log(JSON.stringify({
76
+ stats: results[0],
77
+ pages: results[1],
78
+ referrers: results[2],
79
+ countries: results[3],
80
+ browsers: results[4],
81
+ utm_sources: results[5],
82
+ events: results[6],
83
+ }, null, 2));
84
+ } else {
85
+ console.log(JSON.stringify(response, null, 2));
86
+ }
55
87
  return;
56
88
  }
57
89
 
@@ -96,6 +128,59 @@ export const statsCommand = new Command("stats")
96
128
  console.log(` ${chalk.bold(convRate)}`);
97
129
  }
98
130
 
131
+ // Detailed breakdowns if --all
132
+ if (options.all) {
133
+ const pagesRes = results[1] as Awaited<ReturnType<typeof query>>;
134
+ const referrersRes = results[2] as Awaited<ReturnType<typeof query>>;
135
+ const countriesRes = results[3] as Awaited<ReturnType<typeof query>>;
136
+ const browsersRes = results[4] as Awaited<ReturnType<typeof query>>;
137
+ const utmRes = results[5] as Awaited<ReturnType<typeof query>>;
138
+ const eventsRes = results[6] as Awaited<ReturnType<typeof listEvents>>;
139
+
140
+ const renderBreakdown = (
141
+ title: string,
142
+ data: { dimensions?: Record<string, string>; metrics: Record<string, number> }[],
143
+ dimKey: string,
144
+ metricKey: string = "visitors",
145
+ metricLabel: string = "VISITORS"
146
+ ) => {
147
+ console.log();
148
+ console.log(chalk.dim(" ────────────────────────────────────────"));
149
+ console.log();
150
+ console.log(` ${chalk.dim(title.padEnd(36))}${chalk.dim(metricLabel)}`);
151
+ if (data.length === 0) {
152
+ console.log(chalk.dim(" (no data)"));
153
+ } else {
154
+ for (const row of data) {
155
+ const dim = truncate(row.dimensions?.[dimKey] || "(direct)", 34);
156
+ const val = formatNumber(row.metrics[metricKey] || 0);
157
+ console.log(` ${dim.padEnd(36)}${val.padStart(8)}`);
158
+ }
159
+ }
160
+ };
161
+
162
+ renderBreakdown("PAGES", pagesRes.data, "page");
163
+ renderBreakdown("REFERRERS", referrersRes.data, "referrer");
164
+ renderBreakdown("COUNTRIES", countriesRes.data, "country");
165
+ renderBreakdown("BROWSERS", browsersRes.data, "browser");
166
+ renderBreakdown("UTM SOURCES", utmRes.data, "utm_source");
167
+
168
+ // Events have different structure
169
+ console.log();
170
+ console.log(chalk.dim(" ────────────────────────────────────────"));
171
+ console.log();
172
+ console.log(` ${chalk.dim("EVENTS".padEnd(36))}${chalk.dim("COUNT")}`);
173
+ if (eventsRes.data.length === 0) {
174
+ console.log(chalk.dim(" (no events)"));
175
+ } else {
176
+ for (const event of eventsRes.data) {
177
+ const name = truncate(event.name, 34);
178
+ const count = formatNumber(event.count);
179
+ console.log(` ${name.padEnd(36)}${count.padStart(8)}`);
180
+ }
181
+ }
182
+ }
183
+
99
184
  console.log();
100
185
  } catch (error) {
101
186
  console.error(chalk.red(`Error: ${(error as Error).message}`));
@@ -2,6 +2,7 @@ import { Command } from "commander";
2
2
  import chalk from "chalk";
3
3
  import { query, formatNumber } from "../api";
4
4
  import { getDefaultSite } from "../config";
5
+ import { coloredSparkline } from "../ui";
5
6
 
6
7
  export const trendCommand = new Command("trend")
7
8
  .description("Daily visitor trend")
@@ -11,6 +12,7 @@ export const trendCommand = new Command("trend")
11
12
  .option("--end <date>", "End date (YYYY-MM-DD)")
12
13
  .option("-f, --filter <filters...>", "Filters in format 'field:operator:value' (e.g., 'is:country:US')")
13
14
  .option("--no-revenue", "Exclude revenue metrics")
15
+ .option("--compact", "Show compact sparkline only")
14
16
  .option("--json", "Output as JSON")
15
17
  .action(async (options) => {
16
18
  const site = options.site || (await getDefaultSite());
@@ -68,8 +70,18 @@ export const trendCommand = new Command("trend")
68
70
  return;
69
71
  }
70
72
 
73
+ const visitors = response.data.map((r) => r.metrics.visitors || 0);
74
+ const total = visitors.reduce((a, b) => a + b, 0);
75
+
76
+ // Compact mode - just sparkline
77
+ if (options.compact) {
78
+ console.log(` ${coloredSparkline(visitors)} ${formatNumber(total)} visitors`);
79
+ console.log();
80
+ return;
81
+ }
82
+
71
83
  // Find max for sparkline scaling
72
- const maxVisitors = Math.max(...response.data.map((r) => r.metrics.visitors || 0));
84
+ const maxVisitors = Math.max(...visitors);
73
85
 
74
86
  for (const result of response.data) {
75
87
  const date = result.dimensions?.date || "";
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import { trendCommand } from "./commands/trend";
11
11
  import { queryCommand } from "./commands/query";
12
12
  import { eventsCommand } from "./commands/events";
13
13
  import { realtimeCommand } from "./commands/realtime";
14
+ import { completionsCommand } from "./commands/completions";
14
15
 
15
16
  const description = `CLI for Supalytics web analytics.
16
17
 
@@ -54,10 +55,13 @@ Output:
54
55
  --json Raw JSON output (useful for piping to other tools or AI)
55
56
  --no-revenue Exclude revenue metrics from output`;
56
57
 
58
+ // Read version from package.json
59
+ const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json();
60
+
57
61
  program
58
62
  .name("supalytics")
59
63
  .description(description)
60
- .version("0.1.0");
64
+ .version(pkg.version);
61
65
 
62
66
  // Auth & site management commands
63
67
  program.addCommand(loginCommand);
@@ -75,5 +79,6 @@ program.addCommand(trendCommand);
75
79
  program.addCommand(queryCommand);
76
80
  program.addCommand(eventsCommand);
77
81
  program.addCommand(realtimeCommand);
82
+ program.addCommand(completionsCommand);
78
83
 
79
84
  program.parse();
package/src/ui.ts CHANGED
@@ -77,3 +77,114 @@ export function formatRevenue(cents: number): string {
77
77
  }
78
78
  return chalk.green("$" + (cents / 100).toFixed(0));
79
79
  }
80
+
81
+ // Sparkline characters from lowest to highest
82
+ const SPARK_CHARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
83
+
84
+ /**
85
+ * Generate a sparkline from an array of numbers
86
+ */
87
+ export function sparkline(values: number[]): string {
88
+ if (values.length === 0) return "";
89
+
90
+ const min = Math.min(...values);
91
+ const max = Math.max(...values);
92
+ const range = max - min || 1;
93
+
94
+ return values
95
+ .map((v) => {
96
+ const normalized = (v - min) / range;
97
+ const index = Math.min(Math.floor(normalized * SPARK_CHARS.length), SPARK_CHARS.length - 1);
98
+ return SPARK_CHARS[index];
99
+ })
100
+ .join("");
101
+ }
102
+
103
+ /**
104
+ * Generate a colored sparkline (green for up, red for down trend)
105
+ */
106
+ export function coloredSparkline(values: number[]): string {
107
+ if (values.length === 0) return "";
108
+
109
+ const spark = sparkline(values);
110
+ const first = values[0];
111
+ const last = values[values.length - 1];
112
+
113
+ if (last > first) {
114
+ return chalk.green(spark);
115
+ } else if (last < first) {
116
+ return chalk.red(spark);
117
+ }
118
+ return chalk.dim(spark);
119
+ }
120
+
121
+ export interface TableColumn {
122
+ key: string;
123
+ label?: string;
124
+ align?: "left" | "right";
125
+ width?: number;
126
+ format?: (value: unknown) => string;
127
+ }
128
+
129
+ export interface TableOptions {
130
+ indent?: number;
131
+ showHeader?: boolean;
132
+ headerColor?: (s: string) => string;
133
+ }
134
+
135
+ /**
136
+ * Render a table with proper column alignment
137
+ */
138
+ export function table(
139
+ data: Record<string, unknown>[],
140
+ columns: TableColumn[],
141
+ options: TableOptions = {}
142
+ ): string {
143
+ const { indent = 2, showHeader = true, headerColor = chalk.dim } = options;
144
+ const prefix = " ".repeat(indent);
145
+ const lines: string[] = [];
146
+
147
+ // Calculate column widths
148
+ const widths = columns.map((col) => {
149
+ const label = col.label || col.key;
150
+ const dataWidth = Math.max(
151
+ ...data.map((row) => {
152
+ const val = row[col.key];
153
+ const formatted = col.format ? col.format(val) : String(val ?? "");
154
+ // Strip ANSI codes for width calculation
155
+ return formatted.replace(/\x1b\[[0-9;]*m/g, "").length;
156
+ })
157
+ );
158
+ return col.width || Math.max(label.length, dataWidth);
159
+ });
160
+
161
+ // Header row
162
+ if (showHeader) {
163
+ const headerParts = columns.map((col, i) => {
164
+ const label = col.label || col.key.toUpperCase();
165
+ const width = widths[i];
166
+ return col.align === "right" ? label.padStart(width) : label.padEnd(width);
167
+ });
168
+ lines.push(prefix + headerColor(headerParts.join(" ")));
169
+ }
170
+
171
+ // Data rows
172
+ for (const row of data) {
173
+ const parts = columns.map((col, i) => {
174
+ const val = row[col.key];
175
+ const formatted = col.format ? col.format(val) : String(val ?? "");
176
+ const width = widths[i];
177
+ // Get display length (without ANSI codes)
178
+ const displayLen = formatted.replace(/\x1b\[[0-9;]*m/g, "").length;
179
+ const padding = Math.max(0, width - displayLen);
180
+
181
+ if (col.align === "right") {
182
+ return " ".repeat(padding) + formatted;
183
+ }
184
+ return formatted + " ".repeat(padding);
185
+ });
186
+ lines.push(prefix + parts.join(" "));
187
+ }
188
+
189
+ return lines.join("\n");
190
+ }