From 7e784d6a8880732b1acd63183885e789ad757e99 Mon Sep 17 00:00:00 2001 From: kiranjd Date: Tue, 27 Jan 2026 21:43:59 +0530 Subject: [PATCH] feat(skills): add ga4 and lemonsqueezy CLI skills - ga4: Query GA4 analytics - users, events, funnels, transcriptions - lemonsqueezy: Query revenue, orders, subscriptions, customers Both use bun scripts and require env vars for auth. Co-Authored-By: Claude Opus 4.5 --- skills/ga4/SKILL.md | 136 ++++++ skills/ga4/scripts/cli.ts | 671 +++++++++++++++++++++++++++++ skills/lemonsqueezy/SKILL.md | 114 +++++ skills/lemonsqueezy/scripts/cli.ts | 395 +++++++++++++++++ 4 files changed, 1316 insertions(+) create mode 100644 skills/ga4/SKILL.md create mode 100644 skills/ga4/scripts/cli.ts create mode 100644 skills/lemonsqueezy/SKILL.md create mode 100644 skills/lemonsqueezy/scripts/cli.ts diff --git a/skills/ga4/SKILL.md b/skills/ga4/SKILL.md new file mode 100644 index 000000000..228ac7131 --- /dev/null +++ b/skills/ga4/SKILL.md @@ -0,0 +1,136 @@ +--- +name: ga4 +description: GA4 Analytics CLI for users, events, funnels, and transcription metrics. +homepage: https://analytics.google.com +metadata: {"clawdbot":{"emoji":"📊","requires":{"bins":["bun"],"env":["GOOGLE_APPLICATION_CREDENTIALS"]},"primaryEnv":"GOOGLE_APPLICATION_CREDENTIALS"}} +--- + +# GA4 Analytics CLI + +Query Google Analytics 4 data - users, events, funnels, transcription metrics. + +## Setup + +Set environment variables: +```bash +export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json +export GA4_PROPERTY_ID=510572418 # Optional, defaults to Speakmac +``` + +Or configure in `~/.clawdbot/clawdbot.json`: +```json +{ + "skills": { + "entries": { + "ga4": { + "env": { + "GOOGLE_APPLICATION_CREDENTIALS": "/path/to/service-account.json", + "GA4_PROPERTY_ID": "510572418" + } + } + } + } +} +``` + +## Commands + +### Overview +```bash +bun {baseDir}/scripts/cli.ts overview # 30-day overview +bun {baseDir}/scripts/cli.ts overview --period 7d # Last 7 days +``` + +### Real-time +```bash +bun {baseDir}/scripts/cli.ts realtime # Active users now +``` + +### Events +```bash +bun {baseDir}/scripts/cli.ts events # Top 50 events +bun {baseDir}/scripts/cli.ts events --limit 20 # Limit results +bun {baseDir}/scripts/cli.ts events --period 7d +``` + +### Funnels +```bash +bun {baseDir}/scripts/cli.ts funnel onboarding # Onboarding completion +bun {baseDir}/scripts/cli.ts funnel transcription # Transcription success +bun {baseDir}/scripts/cli.ts funnel permissions # Permission grants +``` + +### Errors +```bash +bun {baseDir}/scripts/cli.ts errors # All errors (7d) +bun {baseDir}/scripts/cli.ts errors --type transcription +bun {baseDir}/scripts/cli.ts errors --type license +bun {baseDir}/scripts/cli.ts errors --period 14d +``` + +### Transcription Metrics +```bash +bun {baseDir}/scripts/cli.ts transcriptions # By provider +bun {baseDir}/scripts/cli.ts transcriptions --by day # Daily breakdown +bun {baseDir}/scripts/cli.ts transcriptions --period 14d +``` + +### Version Adoption +```bash +bun {baseDir}/scripts/cli.ts versions # App version usage +bun {baseDir}/scripts/cli.ts versions --period 7d +``` + +### Growth Metrics +```bash +bun {baseDir}/scripts/cli.ts growth # Growth trends +bun {baseDir}/scripts/cli.ts growth --period 90d +``` + +### Weekly (Date Range) +```bash +bun {baseDir}/scripts/cli.ts weekly --from 2025-11-01 --to 2026-01-20 +``` + +### Custom Query +```bash +bun {baseDir}/scripts/cli.ts query --dimensions week --metrics activeUsers,sessions --from 2025-11-01 --to 2026-01-20 +bun {baseDir}/scripts/cli.ts query --dimensions date --metrics eventCount --period 30d +``` + +## Periods + +- `today` - Current day +- `yesterday` - Previous day +- `7d` - Last 7 days +- `14d` - Last 14 days +- `28d` - Last 28 days +- `30d` - Last 30 days (default) +- `90d` - Last 90 days + +## Examples + +```bash +# Daily check-in +bun {baseDir}/scripts/cli.ts overview --period 7d +bun {baseDir}/scripts/cli.ts realtime + +# Feature usage +bun {baseDir}/scripts/cli.ts events --limit 20 + +# Onboarding health +bun {baseDir}/scripts/cli.ts funnel onboarding + +# Transcription performance +bun {baseDir}/scripts/cli.ts transcriptions --by provider +bun {baseDir}/scripts/cli.ts errors --type transcription + +# Version rollout +bun {baseDir}/scripts/cli.ts versions --period 7d +``` + +## Notes + +- Service account needs "Viewer" role on GA4 property +- Real-time data shows last 30 minutes +- See `{baseDir}/docs/API_REFERENCE.md` for full API documentation diff --git a/skills/ga4/scripts/cli.ts b/skills/ga4/scripts/cli.ts new file mode 100644 index 000000000..0f8b8b826 --- /dev/null +++ b/skills/ga4/scripts/cli.ts @@ -0,0 +1,671 @@ +#!/usr/bin/env bun +/** + * GA4 Analytics CLI - Speakmac analytics at your fingertips + * Usage: bun skills/ga4/cli.ts [options] + */ + +import { BetaAnalyticsDataClient } from "@google-analytics/data"; + +const CREDENTIALS_PATH = process.env.GOOGLE_APPLICATION_CREDENTIALS; +const PROPERTY_ID = process.env.GA4_PROPERTY_ID || "510572418"; + +if (!CREDENTIALS_PATH) { + console.error("Error: GOOGLE_APPLICATION_CREDENTIALS environment variable required"); + console.error("Point it to your service account JSON file"); + process.exit(1); +} + +let client: BetaAnalyticsDataClient | null = null; + +function getClient() { + if (!client) { + client = new BetaAnalyticsDataClient(); + } + return client; +} + +type DateRange = { startDate: string; endDate: string }; + +function getDateRange(period: string): DateRange { + const ranges: Record = { + today: { startDate: "today", endDate: "today" }, + yesterday: { startDate: "yesterday", endDate: "yesterday" }, + "7d": { startDate: "7daysAgo", endDate: "today" }, + "14d": { startDate: "14daysAgo", endDate: "today" }, + "28d": { startDate: "28daysAgo", endDate: "today" }, + "30d": { startDate: "30daysAgo", endDate: "today" }, + "90d": { startDate: "90daysAgo", endDate: "today" }, + }; + return ranges[period] || ranges["30d"]; +} + +async function runReport( + dimensions: string[], + metrics: string[], + dateRange: DateRange, + filter?: any, + limit = 100 +) { + const c = getClient(); + const request: any = { + property: `properties/${PROPERTY_ID}`, + dateRanges: [dateRange], + dimensions: dimensions.map((d) => ({ name: d })), + metrics: metrics.map((m) => ({ name: m })), + limit, + }; + if (filter) request.dimensionFilter = filter; + + const [response] = await c.runReport(request); + + const results: Record[] = []; + if (response.rows) { + for (const row of response.rows) { + const result: Record = {}; + dimensions.forEach((dim, i) => { + result[dim] = row.dimensionValues?.[i]?.value || "(not set)"; + }); + metrics.forEach((metric, i) => { + result[metric] = row.metricValues?.[i]?.value || "0"; + }); + results.push(result); + } + } + + return { + rowCount: response.rowCount || 0, + results, + totals: + response.totals?.[0]?.metricValues?.map((m, i) => ({ + metric: metrics[i], + value: m.value, + })) || [], + }; +} + +// Commands +async function overview(period: string) { + const dateRange = getDateRange(period); + const data = await runReport( + ["date"], + [ + "activeUsers", + "newUsers", + "sessions", + "eventCount", + "engagementRate", + "averageSessionDuration", + ], + dateRange, + null, + 50 + ); + + // Aggregate totals + let users = 0, + newU = 0, + sessions = 0, + events = 0; + for (const row of data.results) { + users += parseInt(row.activeUsers); + newU += parseInt(row.newUsers); + sessions += parseInt(row.sessions); + events += parseInt(row.eventCount); + } + + console.log(`📊 GA4 Overview (${period})\n`); + console.log(`Active Users: ${users}`); + console.log(`New Users: ${newU}`); + console.log(`Sessions: ${sessions}`); + console.log(`Total Events: ${events}`); + + if (data.totals.length > 0) { + const engRate = data.totals.find((t) => t.metric === "engagementRate"); + const avgDur = data.totals.find( + (t) => t.metric === "averageSessionDuration" + ); + if (engRate) + console.log( + `Engagement: ${(parseFloat(engRate.value || "0") * 100).toFixed(1)}%` + ); + if (avgDur) + console.log( + `Avg Session: ${Math.round(parseFloat(avgDur.value || "0"))}s` + ); + } +} + +async function realtime() { + const c = getClient(); + const [response] = await c.runRealtimeReport({ + property: `properties/${PROPERTY_ID}`, + dimensions: [{ name: "unifiedScreenName" }], + metrics: [{ name: "activeUsers" }, { name: "eventCount" }], + }); + + const totalUsers = + response.totals?.[0]?.metricValues?.[0]?.value || "0"; + + console.log(`⚡ Real-time (last 30 min)\n`); + console.log(`Active Users: ${totalUsers}\n`); + + if (response.rows && response.rows.length > 0) { + console.log("By Screen:"); + for (const row of response.rows.slice(0, 10)) { + const screen = row.dimensionValues?.[0]?.value || "(unknown)"; + const users = row.metricValues?.[0]?.value || "0"; + console.log(` ${users.padStart(3)} | ${screen}`); + } + } +} + +async function events(period: string, limit: number) { + const dateRange = getDateRange(period); + const data = await runReport( + ["eventName"], + ["eventCount", "eventCountPerUser"], + dateRange, + null, + limit + ); + + console.log(`📈 Events (${period})\n`); + console.log("Event".padEnd(45) + "Count".padStart(10) + "Per User".padStart(10)); + console.log("-".repeat(65)); + + for (const row of data.results) { + const name = row.eventName.substring(0, 44); + const count = row.eventCount; + const perUser = parseFloat(row.eventCountPerUser).toFixed(2); + console.log( + name.padEnd(45) + count.toString().padStart(10) + perUser.padStart(10) + ); + } +} + +async function funnel(funnelType: string, period: string) { + const funnels: Record = { + onboarding: [ + "onboarding_started", + "onboarding_step_completed", + "permission_prompt_shown", + "permission_result", + "demo_transcription_started", + "demo_transcription_completed", + "onboarding_completed", + ], + transcription: [ + "transcription_started", + "transcription_completed", + "transcription_failed", + ], + permissions: ["permission_prompt_shown", "permission_result"], + }; + + const events = funnels[funnelType]; + if (!events) { + console.error(`Unknown funnel: ${funnelType}`); + console.error(`Available: ${Object.keys(funnels).join(", ")}`); + process.exit(1); + } + + const dateRange = getDateRange(period); + console.log(`🔄 ${funnelType} Funnel (${period})\n`); + + let firstCount = 0; + for (const event of events) { + const filter = { + filter: { + fieldName: "eventName", + stringFilter: { value: event, matchType: "EXACT" }, + }, + }; + const data = await runReport( + ["eventName"], + ["eventCount", "totalUsers"], + dateRange, + filter, + 1 + ); + + const count = parseInt(data.results[0]?.eventCount || "0"); + const users = parseInt(data.results[0]?.totalUsers || "0"); + + if (firstCount === 0) firstCount = count; + const pct = firstCount > 0 ? ((count / firstCount) * 100).toFixed(0) : "0"; + + console.log( + `${event.padEnd(35)} ${count.toString().padStart(6)} events (${pct}%)` + ); + } +} + +async function errors(errorType: string, period: string) { + const dateRange = getDateRange(period); + + let filter: any; + if (errorType === "transcription") { + filter = { + filter: { + fieldName: "eventName", + stringFilter: { value: "transcription_failed", matchType: "EXACT" }, + }, + }; + } else if (errorType === "license") { + filter = { + filter: { + fieldName: "eventName", + stringFilter: { + value: "license_validation_error", + matchType: "EXACT", + }, + }, + }; + } else { + filter = { + orGroup: { + expressions: [ + { + filter: { + fieldName: "eventName", + stringFilter: { value: "transcription_failed", matchType: "EXACT" }, + }, + }, + { + filter: { + fieldName: "eventName", + stringFilter: { + value: "license_validation_error", + matchType: "EXACT", + }, + }, + }, + ], + }, + }; + } + + const data = await runReport( + ["eventName"], + ["eventCount"], + dateRange, + filter, + 100 + ); + + console.log(`❌ Errors (${period})\n`); + + if (data.results.length === 0) { + console.log("No errors found! 🎉"); + return; + } + + for (const row of data.results) { + console.log(`${row.eventName}: ${row.eventCount}`); + } +} + +async function transcriptions(period: string, breakdownBy: string) { + const dateRange = getDateRange(period); + const filter = { + orGroup: { + expressions: [ + { + filter: { + fieldName: "eventName", + stringFilter: { value: "transcription_started", matchType: "EXACT" }, + }, + }, + { + filter: { + fieldName: "eventName", + stringFilter: { value: "transcription_completed", matchType: "EXACT" }, + }, + }, + { + filter: { + fieldName: "eventName", + stringFilter: { value: "transcription_failed", matchType: "EXACT" }, + }, + }, + ], + }, + }; + + console.log(`🎤 Transcriptions (${period})\n`); + + if (breakdownBy === "day") { + // Group by date + const data = await runReport( + ["date", "eventName"], + ["eventCount"], + dateRange, + filter, + 500 + ); + + const byDate: Record> = {}; + for (const row of data.results) { + const date = row.date; + const event = row.eventName.replace("transcription_", ""); + if (!byDate[date]) byDate[date] = {}; + byDate[date][event] = parseInt(row.eventCount); + } + + console.log("Date".padEnd(12) + "Started".padStart(10) + "Completed".padStart(12) + "Failed".padStart(10) + "Success".padStart(10)); + console.log("-".repeat(54)); + + const dates = Object.entries(byDate).sort(([a], [b]) => b.localeCompare(a)); + for (const [date, events] of dates) { + const started = events.started || 0; + const completed = events.completed || 0; + const failed = events.failed || 0; + const successRate = started > 0 ? `${((completed / started) * 100).toFixed(0)}%` : "N/A"; + console.log( + date.padEnd(12) + + started.toString().padStart(10) + + completed.toString().padStart(12) + + failed.toString().padStart(10) + + successRate.padStart(10) + ); + } + } else { + // Summary totals + const data = await runReport( + ["eventName"], + ["eventCount", "totalUsers"], + dateRange, + filter, + 100 + ); + + let started = 0, completed = 0, failed = 0; + for (const row of data.results) { + if (row.eventName === "transcription_started") started = parseInt(row.eventCount); + if (row.eventName === "transcription_completed") completed = parseInt(row.eventCount); + if (row.eventName === "transcription_failed") failed = parseInt(row.eventCount); + } + + const successRate = started > 0 ? ((completed / started) * 100).toFixed(1) : "N/A"; + console.log(`Started: ${started}`); + console.log(`Completed: ${completed}`); + console.log(`Failed: ${failed}`); + console.log(`Success Rate: ${successRate}%`); + } +} + +async function versions(period: string) { + const dateRange = getDateRange(period); + const data = await runReport( + ["appVersion"], + ["activeUsers", "newUsers", "sessions"], + dateRange, + null, + 20 + ); + + console.log(`📱 Version Adoption (${period})\n`); + console.log( + "Version".padEnd(20) + + "Users".padStart(8) + + "New".padStart(8) + + "Sessions".padStart(10) + ); + console.log("-".repeat(46)); + + for (const row of data.results) { + const ver = row.appVersion || "(unknown)"; + console.log( + ver.padEnd(20) + + row.activeUsers.padStart(8) + + row.newUsers.padStart(8) + + row.sessions.padStart(10) + ); + } +} + +async function growth(period: string) { + const dateRange = getDateRange(period); + const data = await runReport( + ["date"], + ["activeUsers", "newUsers", "totalUsers", "sessions"], + dateRange, + null, + 100 + ); + + // Get latest vs earliest for comparison + const sorted = data.results.sort((a, b) => a.date.localeCompare(b.date)); + + let totalNew = 0, + totalActive = 0; + for (const row of sorted) { + totalNew += parseInt(row.newUsers); + totalActive += parseInt(row.activeUsers); + } + + console.log(`📈 Growth Metrics (${period})\n`); + console.log(`Total New Users: ${totalNew}`); + console.log(`Total Active Users: ${totalActive}`); + console.log(`Days Tracked: ${sorted.length}`); + + if (sorted.length >= 2) { + const first = sorted[0]; + const last = sorted[sorted.length - 1]; + console.log(`\nFirst Day (${first.date}): ${first.activeUsers} active`); + console.log(`Last Day (${last.date}): ${last.activeUsers} active`); + } +} + +async function query( + dimensions: string[], + metrics: string[], + period: string, + from?: string, + to?: string +) { + let dateRange: DateRange; + if (from && to) { + dateRange = { startDate: from, endDate: to }; + } else { + dateRange = getDateRange(period); + } + + const data = await runReport(dimensions, metrics, dateRange, null, 500); + + console.log(`📊 Custom Query (${from || period}${to ? ` to ${to}` : ""})\n`); + console.log(`Rows: ${data.rowCount}\n`); + + // Print header + const header = [...dimensions, ...metrics].map((h) => h.padEnd(15)).join(" | "); + console.log(header); + console.log("-".repeat(header.length)); + + // Print rows + for (const row of data.results) { + const values = [...dimensions, ...metrics] + .map((key) => String(row[key] || "").padEnd(15)) + .join(" | "); + console.log(values); + } + + // Print totals if available + if (data.totals.length > 0) { + console.log("\nTotals:"); + for (const t of data.totals) { + console.log(` ${t.metric}: ${t.value}`); + } + } +} + +async function weekly(from: string, to: string) { + const dateRange = { startDate: from, endDate: to }; + const data = await runReport( + ["week", "date"], + ["activeUsers", "newUsers", "sessions"], + dateRange, + null, + 500 + ); + + // Group by week + const byWeek: Record = {}; + + for (const row of data.results) { + const week = row.week; + if (!byWeek[week]) { + byWeek[week] = { users: 0, newUsers: 0, sessions: 0, dates: [] }; + } + byWeek[week].users += parseInt(row.activeUsers); + byWeek[week].newUsers += parseInt(row.newUsers); + byWeek[week].sessions += parseInt(row.sessions); + byWeek[week].dates.push(row.date); + } + + console.log(`📊 Weekly Traffic (${from} to ${to})\n`); + console.log("Week".padEnd(6) + "Users".padStart(8) + "New".padStart(8) + "Sessions".padStart(10)); + console.log("-".repeat(32)); + + const weeks = Object.entries(byWeek).sort(([a], [b]) => a.localeCompare(b)); + for (const [week, stats] of weeks) { + console.log( + week.padEnd(6) + + stats.users.toString().padStart(8) + + stats.newUsers.toString().padStart(8) + + stats.sessions.toString().padStart(10) + ); + } +} + +// Help +function help() { + console.log(` +GA4 Analytics CLI (Speakmac) + +Usage: ga4 [options] + +Commands: + overview [--period 30d] Key metrics overview + realtime Active users right now + events [--period 30d] [--limit N] Event counts + funnel [--period 30d] Funnel analysis + Types: onboarding, transcription, permissions + errors [--type all] [--period 7d] Error analysis + Types: all, transcription, license + transcriptions [--period 7d] Transcription stats + --by day Daily breakdown + versions [--period 14d] App version adoption + growth [--period 30d] Growth metrics + weekly --from YYYY-MM-DD --to YYYY-MM-DD + Week-by-week traffic breakdown + query --dimensions d1,d2 --metrics m1,m2 [--from DATE --to DATE] + Custom query (like MCP ga4_custom_query) + +Periods: today, yesterday, 7d, 14d, 28d, 30d, 90d + +Environment: + GOOGLE_APPLICATION_CREDENTIALS Path to service account JSON + GA4_PROPERTY_ID GA4 property ID (default: 510572418) + +Examples: + ga4 overview + ga4 realtime + ga4 events --limit 20 + ga4 funnel onboarding + ga4 transcriptions --by day + ga4 versions --period 7d + ga4 weekly --from 2025-11-01 --to 2026-01-20 + ga4 query --dimensions week --metrics activeUsers,sessions --from 2025-11-01 --to 2026-01-20 +`); +} + +// Main +async function main() { + const args = process.argv.slice(2); + const cmd = args[0]; + + // Parse flags + const flags: Record = {}; + let positional: string[] = []; + for (let i = 1; i < args.length; i++) { + if (args[i].startsWith("--")) { + const key = args[i].slice(2); + flags[key] = args[i + 1] || "true"; + i++; + } else { + positional.push(args[i]); + } + } + + const period = flags.period || "30d"; + + try { + switch (cmd) { + case "overview": + await overview(period); + break; + case "realtime": + await realtime(); + break; + case "events": + await events(period, parseInt(flags.limit) || 50); + break; + case "funnel": + const funnelType = positional[0]; + if (!funnelType) { + console.error("Usage: ga4 funnel "); + console.error("Types: onboarding, transcription, permissions"); + process.exit(1); + } + await funnel(funnelType, period); + break; + case "errors": + await errors(flags.type || "all", flags.period || "7d"); + break; + case "transcriptions": + await transcriptions(flags.period || "7d", flags.by || "summary"); + break; + case "versions": + await versions(flags.period || "14d"); + break; + case "growth": + await growth(period); + break; + case "weekly": + if (!flags.from || !flags.to) { + console.error("Usage: ga4 weekly --from YYYY-MM-DD --to YYYY-MM-DD"); + process.exit(1); + } + await weekly(flags.from, flags.to); + break; + case "query": + if (!flags.dimensions || !flags.metrics) { + console.error("Usage: ga4 query --dimensions d1,d2 --metrics m1,m2 [--from DATE --to DATE]"); + process.exit(1); + } + await query( + flags.dimensions.split(","), + flags.metrics.split(","), + period, + flags.from, + flags.to + ); + break; + case "help": + case "--help": + case "-h": + case undefined: + help(); + break; + default: + console.error(`Unknown command: ${cmd}`); + help(); + process.exit(1); + } + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : err}`); + process.exit(1); + } +} + +main(); diff --git a/skills/lemonsqueezy/SKILL.md b/skills/lemonsqueezy/SKILL.md new file mode 100644 index 000000000..be27e5c99 --- /dev/null +++ b/skills/lemonsqueezy/SKILL.md @@ -0,0 +1,114 @@ +--- +name: lemonsqueezy +description: LemonSqueezy CLI for revenue, orders, subscriptions, and customer data. +homepage: https://lemonsqueezy.com +metadata: {"clawdbot":{"emoji":"🍋","requires":{"bins":["bun"],"env":["LEMONSQUEEZY_API_KEY"]},"primaryEnv":"LEMONSQUEEZY_API_KEY"}} +--- + +# LemonSqueezy CLI + +Query revenue, orders, subscriptions, and customers from LemonSqueezy. + +## Setup + +Set the API key: +```bash +export LEMONSQUEEZY_API_KEY="your_api_key" +``` + +Or configure in `~/.clawdbot/clawdbot.json`: +```json +{ + "skills": { + "entries": { + "lemonsqueezy": { + "apiKey": "your_api_key" + } + } + } +} +``` + +## Commands + +### Revenue Overview +```bash +bun {baseDir}/scripts/cli.ts overview +``` + +### Orders +```bash +bun {baseDir}/scripts/cli.ts orders # Recent orders +bun {baseDir}/scripts/cli.ts orders --limit 10 # Limit results +bun {baseDir}/scripts/cli.ts orders --status paid # Filter: paid/refunded/pending +bun {baseDir}/scripts/cli.ts orders --from 2025-11-01 --to 2026-01-20 # Date range with week-by-week summary +``` + +### Order Lookup +```bash +bun {baseDir}/scripts/cli.ts order 12345 # By order number +bun {baseDir}/scripts/cli.ts order customer@email.com # By customer email +``` + +### Subscriptions +```bash +bun {baseDir}/scripts/cli.ts subs # All with status breakdown +bun {baseDir}/scripts/cli.ts subs --status active # Filter by status +``` + +Status values: active, cancelled, expired, on_trial, paused, past_due, unpaid + +### MRR Breakdown +```bash +bun {baseDir}/scripts/cli.ts mrr # MRR by product/variant +``` + +### Customers +```bash +bun {baseDir}/scripts/cli.ts customers # Customer list with LTV +bun {baseDir}/scripts/cli.ts customers --limit 50 +``` + +### Products +```bash +bun {baseDir}/scripts/cli.ts products # All products with URLs +``` + +### Refunds +```bash +bun {baseDir}/scripts/cli.ts refunds # Refunded orders +bun {baseDir}/scripts/cli.ts refunds --limit 10 +``` + +## Output Icons + +- ✓ = paid +- ↩ = refunded +- ○ = pending +- ● = active subscription +- ◐ = other status + +## Examples + +```bash +# Daily revenue check +bun {baseDir}/scripts/cli.ts overview + +# Recent sales +bun {baseDir}/scripts/cli.ts orders --limit 5 + +# Find customer orders +bun {baseDir}/scripts/cli.ts order support@example.com + +# Subscription health +bun {baseDir}/scripts/cli.ts subs --status active + +# Track refunds +bun {baseDir}/scripts/cli.ts refunds +``` + +## Notes + +- Revenue displayed in USD +- API rate limit: 300 calls/minute +- See `{baseDir}/docs/API_REFERENCE.md` for full API documentation diff --git a/skills/lemonsqueezy/scripts/cli.ts b/skills/lemonsqueezy/scripts/cli.ts new file mode 100644 index 000000000..6b3923ae4 --- /dev/null +++ b/skills/lemonsqueezy/scripts/cli.ts @@ -0,0 +1,395 @@ +#!/usr/bin/env bun +/** + * LemonSqueezy CLI - Revenue, orders, subscriptions at your fingertips + * Usage: bun skills/lemonsqueezy/cli.ts [options] + */ + +const API_KEY = process.env.LEMONSQUEEZY_API_KEY; +const BASE_URL = "https://api.lemonsqueezy.com/v1"; + +if (!API_KEY) { + console.error("Error: LEMONSQUEEZY_API_KEY environment variable required"); + console.error("Set it in your shell or source from SECRETS.env"); + process.exit(1); +} + +async function api(endpoint: string, params: Record = {}) { + const url = new URL(`${BASE_URL}${endpoint}`); + Object.entries(params).forEach(([k, v]) => { + if (v) url.searchParams.append(k, v); + }); + + const res = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${API_KEY}`, + Accept: "application/vnd.api+json", + }, + }); + + if (!res.ok) { + throw new Error(`API error: ${res.status} ${res.statusText}`); + } + return res.json(); +} + +function cents(n: number): string { + return `$${(n / 100).toFixed(2)}`; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +// Commands +async function overview() { + const { data } = await api("/stores"); + const store = data[0]?.attributes; + if (!store) { + console.log("No stores found"); + return; + } + + console.log(`📊 ${store.name} Revenue Overview\n`); + console.log(`Total Revenue: ${cents(store.total_revenue)}`); + console.log(`Total Sales: ${store.total_sales}`); + console.log(`30-Day Revenue: ${cents(store.thirty_day_revenue)}`); + console.log(`30-Day Sales: ${store.thirty_day_sales}`); +} + +async function orders(limit = 20, status?: string, from?: string, to?: string) { + const params: Record = { "page[size]": String(limit) }; + if (status) params["filter[status]"] = status; + + const { data } = await api("/orders", params); + + // Filter by date if provided + let filtered = data; + if (from || to) { + const fromDate = from ? new Date(from) : new Date(0); + const toDate = to ? new Date(to) : new Date(); + toDate.setHours(23, 59, 59, 999); // End of day + filtered = data.filter((order: any) => { + const created = new Date(order.attributes.created_at); + return created >= fromDate && created <= toDate; + }); + } + + const dateRange = from || to ? ` (${from || "start"} to ${to || "now"})` : ""; + console.log(`đŸ“Ļ Orders${dateRange} (${filtered.length})\n`); + + // Group by week for summary if date range specified + if (from && to) { + const byWeek: Record = {}; + for (const order of filtered) { + const a = order.attributes; + const date = new Date(a.created_at); + const week = getWeekNumber(date); + const key = `${date.getFullYear()}-W${week.toString().padStart(2, "0")}`; + if (!byWeek[key]) byWeek[key] = { count: 0, revenue: 0 }; + byWeek[key].count++; + byWeek[key].revenue += parseInt(a.total_usd || 0) / 100; + } + + console.log("Week".padEnd(10) + "Orders".padStart(8) + "Revenue".padStart(12)); + console.log("-".repeat(30)); + const weeks = Object.entries(byWeek).sort(([a], [b]) => a.localeCompare(b)); + for (const [week, stats] of weeks) { + console.log( + week.padEnd(10) + + stats.count.toString().padStart(8) + + `$${stats.revenue.toFixed(2)}`.padStart(12) + ); + } + console.log(); + } + + // Individual orders + for (const order of filtered) { + const a = order.attributes; + const statusIcon = a.status === "paid" ? "✓" : a.status === "refunded" ? "↩" : "○"; + console.log( + `${statusIcon} #${a.order_number} | ${a.total_formatted.padEnd(10)} | ${a.user_email} | ${formatDate(a.created_at)}` + ); + } +} + +function getWeekNumber(date: Date): number { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); +} + +async function orderLookup(query: string) { + // Try by order number first, then by email + let orders: any[] = []; + + if (query.includes("@")) { + const { data } = await api("/orders", { "filter[user_email]": query }); + orders = data; + } else { + const { data } = await api("/orders"); + orders = data.filter( + (o: any) => o.attributes.order_number.toString() === query + ); + } + + if (orders.length === 0) { + console.log(`No orders found for: ${query}`); + return; + } + + console.log(`🔍 Orders for ${query}\n`); + for (const order of orders) { + const a = order.attributes; + console.log(`Order #${a.order_number}`); + console.log(` Status: ${a.status}${a.refunded ? " (refunded)" : ""}`); + console.log(` Total: ${a.total_formatted}`); + console.log(` Customer: ${a.user_name} <${a.user_email}>`); + console.log(` Date: ${formatDate(a.created_at)}`); + if (a.refunded_at) console.log(` Refunded: ${formatDate(a.refunded_at)}`); + console.log(); + } +} + +async function subscriptions(status?: string) { + const params: Record = { "page[size]": "100" }; + if (status) params["filter[status]"] = status; + + const { data } = await api("/subscriptions", params); + + // Calculate MRR + let mrr = 0; + const byStatus: Record = {}; + + for (const sub of data) { + const s = sub.attributes; + byStatus[s.status] = (byStatus[s.status] || 0) + 1; + if (s.status === "active") { + // Assuming monthly - would need price endpoint for accuracy + mrr += parseInt(s.first_subscription_item?.price || 0) / 100; + } + } + + console.log(`đŸ’ŗ Subscriptions (${data.length})\n`); + console.log("By Status:"); + for (const [st, count] of Object.entries(byStatus)) { + console.log(` ${st}: ${count}`); + } + console.log(`\nEstimated MRR: $${mrr.toFixed(2)}\n`); + + if (data.length > 0) { + console.log("Recent:"); + for (const sub of data.slice(0, 10)) { + const s = sub.attributes; + const icon = + s.status === "active" ? "●" : s.status === "cancelled" ? "○" : "◐"; + console.log( + ` ${icon} ${s.user_email.padEnd(30)} | ${s.status.padEnd(10)} | ${s.product_name}` + ); + } + } +} + +async function mrr() { + const { data } = await api("/subscriptions", { + "filter[status]": "active", + "page[size]": "100", + }); + + const byProduct: Record = {}; + + for (const sub of data) { + const s = sub.attributes; + const key = `${s.product_name} - ${s.variant_name}`; + if (!byProduct[key]) byProduct[key] = { count: 0, mrr: 0 }; + byProduct[key].count++; + // Price from first_subscription_item + const price = parseInt(s.first_subscription_item?.price || 0) / 100; + byProduct[key].mrr += price; + } + + const sorted = Object.entries(byProduct).sort( + ([, a], [, b]) => b.mrr - a.mrr + ); + const totalMRR = sorted.reduce((sum, [, v]) => sum + v.mrr, 0); + + console.log(`💰 MRR Breakdown\n`); + console.log(`Total MRR: $${totalMRR.toFixed(2)}`); + console.log(`Active Subscribers: ${data.length}\n`); + + for (const [product, stats] of sorted) { + console.log(`${product}`); + console.log(` Subscribers: ${stats.count}`); + console.log(` MRR: $${stats.mrr.toFixed(2)}`); + } +} + +async function customers(limit = 20) { + const { data } = await api("/customers", { "page[size]": String(limit) }); + + let totalLTV = 0; + console.log(`đŸ‘Ĩ Customers (${data.length})\n`); + + for (const cust of data) { + const c = cust.attributes; + const ltv = parseInt(c.total_revenue_currency || 0) / 100; + totalLTV += ltv; + console.log( + `${c.email.padEnd(35)} | LTV: $${ltv.toFixed(2).padStart(8)} | ${c.status}` + ); + } + + console.log(`\nTotal LTV: $${totalLTV.toFixed(2)}`); +} + +async function products() { + const { data } = await api("/products"); + + console.log(`đŸ“Ļ Products (${data.length})\n`); + for (const prod of data) { + const p = prod.attributes; + const statusIcon = p.status === "published" ? "●" : "○"; + console.log(`${statusIcon} ${p.name}`); + console.log(` Price: ${p.price_formatted || "varies"}`); + console.log(` URL: ${p.buy_now_url}`); + console.log(); + } +} + +async function refunds(limit = 20) { + // Fetch all orders and filter for refunded ones client-side + // (LemonSqueezy API filter[refunded] is unreliable) + const { data } = await api("/orders", { + "page[size]": String(Math.min(limit * 5, 100)), // Fetch more to find refunds + }); + + const refunded = data.filter((o: any) => + o.attributes.refunded || o.attributes.status === "refunded" + ).slice(0, limit); + + const total = refunded.reduce( + (sum: number, o: any) => sum + parseInt(o.attributes.total_usd || 0), + 0 + ); + + console.log(`â†Šī¸ Refunds (${refunded.length})\n`); + console.log(`Total Refunded: ${cents(total)}\n`); + + if (refunded.length === 0) { + console.log("No refunds found."); + return; + } + + for (const order of refunded) { + const a = order.attributes; + console.log( + `#${a.order_number} | ${a.total_formatted.padEnd(10)} | ${a.user_email} | ${formatDate(a.refunded_at || a.created_at)}` + ); + } +} + +// Help +function help() { + console.log(` +LemonSqueezy CLI + +Usage: ls [options] + +Commands: + overview Revenue overview (total + 30-day) + orders [--limit N] Recent orders (default: 20) + orders --status X Filter by status (paid/refunded/pending) + orders --from DATE --to DATE + Orders in date range with week-by-week summary + order Lookup by order number or customer email + subs [--status X] Subscriptions list + mrr MRR breakdown by product + customers [--limit N] Customer list with LTV + products All products + refunds [--limit N] Refunded orders + +Environment: + LEMONSQUEEZY_API_KEY API key (required) + +Examples: + ls overview + ls orders --limit 10 + ls orders --from 2025-11-01 --to 2026-01-20 + ls order 12345 + ls order customer@example.com + ls subs --status active + ls mrr +`); +} + +// Main +async function main() { + const args = process.argv.slice(2); + const cmd = args[0]; + + // Parse flags + const flags: Record = {}; + for (let i = 1; i < args.length; i++) { + if (args[i].startsWith("--")) { + const key = args[i].slice(2); + flags[key] = args[i + 1] || "true"; + i++; + } + } + + try { + switch (cmd) { + case "overview": + await overview(); + break; + case "orders": + await orders(parseInt(flags.limit) || 100, flags.status, flags.from, flags.to); + break; + case "order": + const query = args[1]; + if (!query) { + console.error("Usage: ls order "); + process.exit(1); + } + await orderLookup(query); + break; + case "subs": + case "subscriptions": + await subscriptions(flags.status); + break; + case "mrr": + await mrr(); + break; + case "customers": + await customers(parseInt(flags.limit) || 20); + break; + case "products": + await products(); + break; + case "refunds": + await refunds(parseInt(flags.limit) || 20); + break; + case "help": + case "--help": + case "-h": + case undefined: + help(); + break; + default: + console.error(`Unknown command: ${cmd}`); + help(); + process.exit(1); + } + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : err}`); + process.exit(1); + } +} + +main();