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 @@
## Unreleased

### Added
- Analytics/Search Console: add `analytics accounts|report`; add `searchconsole sites|get`, `searchconsole searchanalytics query`, and `searchconsole sitemaps list|get|submit|delete` for GA4 + GSC reporting and sitemap management. (#402) — thanks @haresh-seenivasagan.
- Gmail: add `gmail autoreply` to reply once to matching messages, label the thread for dedupe, and optionally archive/mark read. Includes docs and regression coverage for skip/reply flows.

## 0.12.0 - 2026-03-09
Expand Down
40 changes: 39 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, Admin, Groups (Workspace), and Keep (Workspace-only). JSON-first output, multiple accounts, and flexible 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, Admin, Groups (Workspace), and Keep (Workspace-only). JSON-first output, multiple accounts, and flexible 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, manage tabs and named ranges, format/merge/freeze/resize cells, read/write notes, inspect formats, find/replace text, list links, and create/export sheets
- **Forms** - create/update forms, manage questions, inspect responses, and manage watches
- **Apps Script** - create/get/bind projects, inspect content, and run functions
- **Analytics** - list GA4 account summaries and run reports via the Analytics Data API
- **Search Console** - list properties, run Search Analytics queries, and manage sitemaps
- **Docs/Slides** - create/copy/export docs/slides, edit Docs by tab, import Markdown, do richer find-replace, export Docs as Markdown/HTML, and generate Slides from Markdown or templates
- **People** - profile lookup and directory search helpers
- **Keep (Workspace only)** - list/get/search/create/delete notes and download attachments (service account + domain-wide delegation)
Expand Down Expand Up @@ -93,6 +95,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
- Google Slides API: https://console.cloud.google.com/apis/api/slides.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
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
5. Create OAuth client:
Expand Down Expand Up @@ -395,6 +400,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` | Search Analytics + sitemap management |
| 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` | Workspace only; service account (domain-wide delegation) |
<!-- auth-services:end -->
Expand Down Expand Up @@ -1139,6 +1146,37 @@ 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
gog searchconsole sites get sc-domain:example.com

# 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
gog searchconsole searchanalytics query sc-domain:example.com --from 2026-02-01 --to 2026-02-07 --filter query:contains:gog --aggregation BY_PAGE

# Sitemaps
gog searchconsole sitemaps sc-domain:example.com
gog searchconsole sitemaps get sc-domain:example.com https://example.com/sitemap.xml
gog searchconsole sitemaps submit sc-domain:example.com https://example.com/sitemap.xml
gog searchconsole sitemaps delete sc-domain:example.com https://example.com/sitemap.xml --force
```

### 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