Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 0.12.0 - Unreleased

### Added
- Analytics/Search Console: add `analytics accounts|report` and `searchconsole sites|query` command groups for GA4 + GSC reporting. (#402) — thanks @haresh-seenivasagan.
- Sheets: add `sheets insert` to insert rows/columns into a sheet. (#203) — thanks @andybergon.
- Gmail: add `watch serve --history-types` filtering (`messageAdded|messageDeleted|labelAdded|labelRemoved`) and include `deletedMessageIds` in webhook payloads. (#168) — thanks @salmonumbrella.
- Contacts: support `--org`, `--title`, `--url`, `--note`, and `--custom` on create/update; include custom fields in get output with deterministic ordering. (#199) — thanks @phuctm97.
Expand Down
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
![GitHub Repo Banner](https://ghrb.waren.build/banner?header=gogcli%F0%9F%A7%AD&subheader=Google+in+your+terminal&bg=f3f4f6&color=1f2937&support=true)
<!-- Created with GitHub Repo Banner by Waren Gonzaga: https://ghrb.waren.build -->

Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Slides, Sheets, Forms, Apps Script, Contacts, Tasks, People, Groups (Workspace), and Keep (Workspace-only). JSON-first output, multiple accounts, and least-privilege auth built in.
Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Slides, Sheets, Forms, Apps Script, Analytics, Search Console, Contacts, Tasks, People, Groups (Workspace), and Keep (Workspace-only). JSON-first output, multiple accounts, and least-privilege auth built in.

## Features

Expand All @@ -18,6 +18,8 @@ Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Sli
- **Sheets** - read/write/update spreadsheets, insert rows/cols, format cells, read notes, create new sheets (and export via Drive)
- **Forms** - create/get forms and inspect responses
- **Apps Script** - create/get projects, inspect content, and run functions
- **Analytics** - list GA4 account summaries and run reports via the Analytics Data API
- **Search Console** - list properties and run Search Analytics queries
- **Docs/Slides** - export to PDF/DOCX/PPTX via Drive (plus create/copy, docs-to-text, and **sedmat** sed-style document editing with Markdown formatting, images, and tables)
- **People** - access profile information
- **Keep (Workspace only)** - list/get/search notes and download attachments (service account + domain-wide delegation)
Expand Down Expand Up @@ -86,6 +88,9 @@ Before adding an account, create OAuth2 credentials from Google Cloud Console:
- Google Sheets API: https://console.cloud.google.com/apis/api/sheets.googleapis.com
- Google Forms API: https://console.cloud.google.com/apis/api/forms.googleapis.com
- Apps Script API: https://console.cloud.google.com/apis/api/script.googleapis.com
- Google Analytics Admin API: https://console.cloud.google.com/apis/api/analyticsadmin.googleapis.com
- Google Analytics Data API: https://console.cloud.google.com/apis/api/analyticsdata.googleapis.com
- Google Search Console API: https://console.cloud.google.com/apis/api/searchconsole.googleapis.com
- Cloud Identity API (Groups): https://console.cloud.google.com/apis/api/cloudidentity.googleapis.com
3. Configure OAuth consent screen: https://console.cloud.google.com/auth/branding
4. If your app is in "Testing", add test users: https://console.cloud.google.com/auth/audience
Expand Down Expand Up @@ -354,6 +359,8 @@ Service scope matrix (auto-generated; run `go run scripts/gen-auth-services-md.g
| people | yes | People API | `profile` | OIDC profile scope |
| forms | yes | Forms API | `https://www.googleapis.com/auth/forms.body`<br>`https://www.googleapis.com/auth/forms.responses.readonly` | |
| appscript | yes | Apps Script API | `https://www.googleapis.com/auth/script.projects`<br>`https://www.googleapis.com/auth/script.deployments`<br>`https://www.googleapis.com/auth/script.processes` | |
| analytics | yes | Analytics Admin API, Analytics Data API | `https://www.googleapis.com/auth/analytics.readonly` | GA4 account summaries + reporting |
| searchconsole | yes | Search Console API | `https://www.googleapis.com/auth/webmasters.readonly` | |
| groups | no | Cloud Identity API | `https://www.googleapis.com/auth/cloud-identity.groups.readonly` | Workspace only |
| keep | no | Keep API | `https://www.googleapis.com/auth/keep.readonly` | Workspace only; service account (domain-wide delegation) |
<!-- auth-services:end -->
Expand Down Expand Up @@ -1010,6 +1017,29 @@ gog appscript run <scriptId> myFunction --params '["arg1", 123, true]'
gog appscript run <scriptId> myFunction --dev-mode
```

### Analytics

```bash
# Account summaries (helps discover property IDs)
gog analytics accounts
gog analytics accounts --all

# GA4 reports
gog analytics report 123456789 --from 2026-02-01 --to 2026-02-07 --dimensions date,country --metrics activeUsers,sessions
gog analytics report properties/123456789 --from 7daysAgo --to today --dimensions date --metrics totalUsers,newUsers --max 200
```

### Search Console

```bash
# Sites/properties
gog searchconsole sites

# Search Analytics query
gog searchconsole query sc-domain:example.com --from 2026-02-01 --to 2026-02-07 --dimensions query,page --type WEB --max 1000
gog searchconsole query https://example.com/ --from 2026-02-01 --to 2026-02-07 --dimensions date --type WEB
```

### People

```bash
Expand Down
275 changes: 275 additions & 0 deletions internal/cmd/analytics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
package cmd

import (
"context"
"fmt"
"os"
"strings"

analyticsadmin "google.golang.org/api/analyticsadmin/v1beta"
analyticsdata "google.golang.org/api/analyticsdata/v1beta"

"github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)

var (
newAnalyticsAdminService = googleapi.NewAnalyticsAdmin
newAnalyticsDataService = googleapi.NewAnalyticsData
)

type AnalyticsCmd struct {
Accounts AnalyticsAccountsCmd `cmd:"" name:"accounts" aliases:"list,ls" default:"withargs" help:"List GA4 account summaries"`
Report AnalyticsReportCmd `cmd:"" name:"report" help:"Run a GA4 report (Analytics Data API)"`
}

type AnalyticsAccountsCmd struct {
Max int64 `name:"max" aliases:"limit" help:"Max account summaries per page (API max 200)" default:"50"`
Page string `name:"page" aliases:"cursor" help:"Page token"`
All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"`
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"`
}

func (c *AnalyticsAccountsCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
if c.Max <= 0 {
return usage("--max must be > 0")
}

svc, err := newAnalyticsAdminService(ctx, account)
if err != nil {
return err
}

fetch := func(pageToken string) ([]*analyticsadmin.GoogleAnalyticsAdminV1betaAccountSummary, string, error) {
call := svc.AccountSummaries.List().PageSize(c.Max).Context(ctx)
if strings.TrimSpace(pageToken) != "" {
call = call.PageToken(pageToken)
}
resp, callErr := call.Do()
if callErr != nil {
return nil, "", callErr
}
return resp.AccountSummaries, resp.NextPageToken, nil
}

var items []*analyticsadmin.GoogleAnalyticsAdminV1betaAccountSummary
nextPageToken := ""
if c.All {
all, collectErr := collectAllPages(c.Page, fetch)
if collectErr != nil {
return collectErr
}
items = all
} else {
items, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
}

if outfmt.IsJSON(ctx) {
if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"account_summaries": items,
"nextPageToken": nextPageToken,
}); err != nil {
return err
}
if len(items) == 0 {
return failEmptyExit(c.FailEmpty)
}
return nil
}

if len(items) == 0 {
u.Err().Println("No Analytics accounts")
return failEmptyExit(c.FailEmpty)
}

w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "ACCOUNT\tDISPLAY_NAME\tPROPERTIES")
for _, item := range items {
if item == nil {
continue
}
fmt.Fprintf(w, "%s\t%s\t%d\n",
sanitizeTab(analyticsResourceID(item.Account)),
sanitizeTab(item.DisplayName),
len(item.PropertySummaries),
)
}
printNextPageHint(u, nextPageToken)
return nil
}

type AnalyticsReportCmd struct {
Property string `arg:"" name:"property" help:"GA4 property ID or resource (e.g. 123456789 or properties/123456789)"`
From string `name:"from" help:"Start date (YYYY-MM-DD or GA relative date like 7daysAgo)" default:"7daysAgo"`
To string `name:"to" help:"End date (YYYY-MM-DD or GA relative date like today)" default:"today"`
Dimensions string `name:"dimensions" help:"Comma-separated dimensions (e.g. date,country)" default:"date"`
Metrics string `name:"metrics" help:"Comma-separated metrics (e.g. activeUsers,sessions)" default:"activeUsers"`
Max int64 `name:"max" aliases:"limit" help:"Max rows to return (1-250000)" default:"100"`
Offset int64 `name:"offset" help:"Row offset for pagination" default:"0"`
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no rows"`
}

func (c *AnalyticsReportCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}

property := normalizeAnalyticsProperty(c.Property)
if property == "" {
return usage("empty property")
}
metrics := splitCommaList(c.Metrics)
if len(metrics) == 0 {
return usage("empty --metrics")
}
dimensions := splitCommaList(c.Dimensions)
if c.Max <= 0 {
return usage("--max must be > 0")
}
if c.Offset < 0 {
return usage("--offset must be >= 0")
}

svc, err := newAnalyticsDataService(ctx, account)
if err != nil {
return err
}

req := &analyticsdata.RunReportRequest{
DateRanges: []*analyticsdata.DateRange{{
StartDate: strings.TrimSpace(c.From),
EndDate: strings.TrimSpace(c.To),
}},
Metrics: analyticsMetrics(metrics),
Limit: c.Max,
Offset: c.Offset,
}
if len(dimensions) > 0 {
req.Dimensions = analyticsDimensions(dimensions)
}

resp, err := svc.Properties.RunReport(property, req).Context(ctx).Do()
if err != nil {
return err
}

if outfmt.IsJSON(ctx) {
if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"property": property,
"from": req.DateRanges[0].StartDate,
"to": req.DateRanges[0].EndDate,
"dimensions": dimensions,
"metrics": metrics,
"row_count": resp.RowCount,
"dimensionHeaders": resp.DimensionHeaders,
"metricHeaders": resp.MetricHeaders,
"rows": resp.Rows,
}); err != nil {
return err
}
if len(resp.Rows) == 0 {
return failEmptyExit(c.FailEmpty)
}
return nil
}

if len(resp.Rows) == 0 {
u.Err().Println("No analytics rows")
return failEmptyExit(c.FailEmpty)
}

headers := make([]string, 0, len(dimensions)+len(metrics))
for _, d := range dimensions {
headers = append(headers, strings.ToUpper(d))
}
for _, m := range metrics {
headers = append(headers, strings.ToUpper(m))
}

w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, strings.Join(headers, "\t"))
for _, row := range resp.Rows {
values := make([]string, 0, len(dimensions)+len(metrics))
for i := range dimensions {
values = append(values, sanitizeTab(analyticsDimensionValue(row, i)))
}
for i := range metrics {
values = append(values, sanitizeTab(analyticsMetricValue(row, i)))
}
fmt.Fprintln(w, strings.Join(values, "\t"))
}
return nil
}

func normalizeAnalyticsProperty(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if strings.HasPrefix(raw, "properties/") {
return raw
}
return "properties/" + strings.TrimPrefix(raw, "/")
}

func analyticsDimensions(names []string) []*analyticsdata.Dimension {
out := make([]*analyticsdata.Dimension, 0, len(names))
for _, n := range names {
n = strings.TrimSpace(n)
if n == "" {
continue
}
out = append(out, &analyticsdata.Dimension{Name: n})
}
return out
}

func analyticsMetrics(names []string) []*analyticsdata.Metric {
out := make([]*analyticsdata.Metric, 0, len(names))
for _, n := range names {
n = strings.TrimSpace(n)
if n == "" {
continue
}
out = append(out, &analyticsdata.Metric{Name: n})
}
return out
}

func analyticsDimensionValue(row *analyticsdata.Row, index int) string {
if row == nil || index < 0 || index >= len(row.DimensionValues) || row.DimensionValues[index] == nil {
return ""
}
return row.DimensionValues[index].Value
}

func analyticsMetricValue(row *analyticsdata.Row, index int) string {
if row == nil || index < 0 || index >= len(row.MetricValues) || row.MetricValues[index] == nil {
return ""
}
return row.MetricValues[index].Value
}

func analyticsResourceID(resource string) string {
resource = strings.TrimSpace(resource)
if resource == "" {
return ""
}
if i := strings.LastIndex(resource, "/"); i >= 0 && i+1 < len(resource) {
return resource[i+1:]
}
return resource
}
Loading