diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7bfb9e66..83d95fed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/README.md b/README.md
index 80abeadf..9a7f5f4a 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@

-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
@@ -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)
@@ -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
@@ -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`
`https://www.googleapis.com/auth/forms.responses.readonly` | |
| appscript | yes | Apps Script API | `https://www.googleapis.com/auth/script.projects`
`https://www.googleapis.com/auth/script.deployments`
`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) |
@@ -1010,6 +1017,29 @@ gog appscript run myFunction --params '["arg1", 123, true]'
gog appscript run 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
diff --git a/internal/cmd/analytics.go b/internal/cmd/analytics.go
new file mode 100644
index 00000000..7890435b
--- /dev/null
+++ b/internal/cmd/analytics.go
@@ -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
+}
diff --git a/internal/cmd/execute_analytics_searchconsole_test.go b/internal/cmd/execute_analytics_searchconsole_test.go
new file mode 100644
index 00000000..728271e5
--- /dev/null
+++ b/internal/cmd/execute_analytics_searchconsole_test.go
@@ -0,0 +1,754 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ analyticsadminapi "google.golang.org/api/analyticsadmin/v1beta"
+ analyticsdataapi "google.golang.org/api/analyticsdata/v1beta"
+ "google.golang.org/api/option"
+ searchconsoleapi "google.golang.org/api/searchconsole/v1"
+)
+
+func TestExecute_AnalyticsAccounts_JSON(t *testing.T) {
+ origNew := newAnalyticsAdminService
+ t.Cleanup(func() { newAnalyticsAdminService = origNew })
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !(r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1beta/accountSummaries")) {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "accountSummaries": []map[string]any{
+ {
+ "account": "accounts/123",
+ "displayName": "Demo Account",
+ "propertySummaries": []map[string]any{
+ {"property": "properties/999", "displayName": "Main Property"},
+ },
+ },
+ },
+ "nextPageToken": "next123",
+ })
+ }))
+ defer srv.Close()
+
+ svc, err := analyticsadminapi.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newAnalyticsAdminService = func(context.Context, string) (*analyticsadminapi.Service, error) { return svc, nil }
+
+ out := captureStdout(t, func() {
+ _ = captureStderr(t, func() {
+ if err := Execute([]string{"--json", "--account", "a@b.com", "analytics", "accounts", "--max", "1"}); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ })
+ })
+
+ var parsed struct {
+ AccountSummaries []struct {
+ Account string `json:"account"`
+ } `json:"account_summaries"`
+ NextPageToken string `json:"nextPageToken"`
+ }
+ if err := json.Unmarshal([]byte(out), &parsed); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(parsed.AccountSummaries) != 1 || parsed.AccountSummaries[0].Account != "accounts/123" || parsed.NextPageToken != "next123" {
+ t.Fatalf("unexpected payload: %#v", parsed)
+ }
+}
+
+func TestExecute_AnalyticsAccounts_Text(t *testing.T) {
+ origNew := newAnalyticsAdminService
+ t.Cleanup(func() { newAnalyticsAdminService = origNew })
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !(r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1beta/accountSummaries")) {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "accountSummaries": []map[string]any{
+ {
+ "account": "accounts/123",
+ "displayName": "Demo Account",
+ "propertySummaries": []map[string]any{
+ {"property": "properties/999", "displayName": "Main Property"},
+ },
+ },
+ },
+ "nextPageToken": "next123",
+ })
+ }))
+ defer srv.Close()
+
+ svc, err := analyticsadminapi.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newAnalyticsAdminService = func(context.Context, string) (*analyticsadminapi.Service, error) { return svc, nil }
+
+ out := captureStdout(t, func() {
+ _ = captureStderr(t, func() {
+ if err := Execute([]string{"--account", "a@b.com", "analytics", "accounts", "--max", "1"}); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ })
+ })
+ if !strings.Contains(out, "ACCOUNT") ||
+ !strings.Contains(out, "DISPLAY_NAME") ||
+ !strings.Contains(out, "PROPERTIES") ||
+ !strings.Contains(out, "123") ||
+ !strings.Contains(out, "Demo Account") ||
+ !strings.Contains(out, "1") {
+ t.Fatalf("unexpected out=%q", out)
+ }
+}
+
+func TestExecute_AnalyticsAccounts_AllPages_JSON(t *testing.T) {
+ origNew := newAnalyticsAdminService
+ t.Cleanup(func() { newAnalyticsAdminService = origNew })
+
+ page1Calls := 0
+ page2Calls := 0
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !(r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1beta/accountSummaries")) {
+ http.NotFound(w, r)
+ return
+ }
+ if got := r.URL.Query().Get("pageSize"); got != "1" {
+ t.Fatalf("expected pageSize=1, got %q", got)
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Query().Get("pageToken") {
+ case "":
+ page1Calls++
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "accountSummaries": []map[string]any{
+ {"account": "accounts/111", "displayName": "One"},
+ },
+ "nextPageToken": "p2",
+ })
+ case "p2":
+ page2Calls++
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "accountSummaries": []map[string]any{
+ {"account": "accounts/222", "displayName": "Two"},
+ },
+ "nextPageToken": "",
+ })
+ default:
+ t.Fatalf("unexpected pageToken=%q", r.URL.Query().Get("pageToken"))
+ }
+ }))
+ defer srv.Close()
+
+ svc, err := analyticsadminapi.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newAnalyticsAdminService = func(context.Context, string) (*analyticsadminapi.Service, error) { return svc, nil }
+
+ out := captureStdout(t, func() {
+ _ = captureStderr(t, func() {
+ if err := Execute([]string{
+ "--json",
+ "--account", "a@b.com",
+ "analytics", "accounts",
+ "--all",
+ "--max", "1",
+ }); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ })
+ })
+
+ var parsed struct {
+ AccountSummaries []struct {
+ Account string `json:"account"`
+ } `json:"account_summaries"`
+ NextPageToken string `json:"nextPageToken"`
+ }
+ if err := json.Unmarshal([]byte(out), &parsed); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(parsed.AccountSummaries) != 2 ||
+ parsed.AccountSummaries[0].Account != "accounts/111" ||
+ parsed.AccountSummaries[1].Account != "accounts/222" ||
+ parsed.NextPageToken != "" {
+ t.Fatalf("unexpected payload: %#v", parsed)
+ }
+ if page1Calls != 1 || page2Calls != 1 {
+ t.Fatalf("unexpected page calls: page1=%d page2=%d", page1Calls, page2Calls)
+ }
+}
+
+func TestExecute_AnalyticsAccounts_ServiceError(t *testing.T) {
+ origNew := newAnalyticsAdminService
+ t.Cleanup(func() { newAnalyticsAdminService = origNew })
+ newAnalyticsAdminService = func(context.Context, string) (*analyticsadminapi.Service, error) {
+ return nil, errors.New("analytics admin service down")
+ }
+
+ _ = captureStderr(t, func() {
+ err := Execute([]string{"--account", "a@b.com", "analytics", "accounts"})
+ if err == nil || !strings.Contains(err.Error(), "analytics admin service down") {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ })
+}
+
+func TestExecute_AnalyticsReport_Text(t *testing.T) {
+ origNew := newAnalyticsDataService
+ t.Cleanup(func() { newAnalyticsDataService = origNew })
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !(r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v1beta/properties/123:runReport")) {
+ http.NotFound(w, r)
+ return
+ }
+ var req map[string]any
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ t.Fatalf("decode request: %v", err)
+ }
+ if req["limit"] != "10" {
+ t.Fatalf("unexpected report limit payload: %#v", req)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "dimensionHeaders": []map[string]any{{"name": "date"}, {"name": "country"}},
+ "metricHeaders": []map[string]any{{"name": "activeUsers"}, {"name": "sessions"}},
+ "rowCount": 1,
+ "rows": []map[string]any{
+ {
+ "dimensionValues": []map[string]any{{"value": "2026-02-01"}, {"value": "US"}},
+ "metricValues": []map[string]any{{"value": "42"}, {"value": "11"}},
+ },
+ },
+ })
+ }))
+ defer srv.Close()
+
+ svc, err := analyticsdataapi.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newAnalyticsDataService = func(context.Context, string) (*analyticsdataapi.Service, error) { return svc, nil }
+
+ out := captureStdout(t, func() {
+ _ = captureStderr(t, func() {
+ if err := Execute([]string{
+ "--account", "a@b.com",
+ "analytics", "report", "123",
+ "--from", "2026-02-01",
+ "--to", "2026-02-01",
+ "--dimensions", "date,country",
+ "--metrics", "activeUsers,sessions",
+ "--max", "10",
+ }); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ })
+ })
+ if !strings.Contains(out, "DATE") ||
+ !strings.Contains(out, "COUNTRY") ||
+ !strings.Contains(out, "ACTIVEUSERS") ||
+ !strings.Contains(out, "SESSIONS") ||
+ !strings.Contains(out, "2026-02-01") ||
+ !strings.Contains(out, "US") ||
+ !strings.Contains(out, "42") ||
+ !strings.Contains(out, "11") {
+ t.Fatalf("unexpected out=%q", out)
+ }
+}
+
+func TestExecute_AnalyticsReport_JSON(t *testing.T) {
+ origNew := newAnalyticsDataService
+ t.Cleanup(func() { newAnalyticsDataService = origNew })
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !(r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v1beta/properties/123:runReport")) {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "dimensionHeaders": []map[string]any{{"name": "date"}},
+ "metricHeaders": []map[string]any{{"name": "activeUsers"}},
+ "rowCount": 1,
+ "rows": []map[string]any{
+ {
+ "dimensionValues": []map[string]any{{"value": "2026-02-01"}},
+ "metricValues": []map[string]any{{"value": "42"}},
+ },
+ },
+ })
+ }))
+ defer srv.Close()
+
+ svc, err := analyticsdataapi.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newAnalyticsDataService = func(context.Context, string) (*analyticsdataapi.Service, error) { return svc, nil }
+
+ out := captureStdout(t, func() {
+ _ = captureStderr(t, func() {
+ if err := Execute([]string{
+ "--json",
+ "--account", "a@b.com",
+ "analytics", "report", "123",
+ "--from", "2026-02-01",
+ "--to", "2026-02-01",
+ "--dimensions", "date",
+ "--metrics", "activeUsers",
+ }); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ })
+ })
+
+ var parsed struct {
+ Property string `json:"property"`
+ From string `json:"from"`
+ To string `json:"to"`
+ RowCount int64 `json:"row_count"`
+ Rows []struct {
+ DimensionValues []struct {
+ Value string `json:"value"`
+ } `json:"dimensionValues"`
+ MetricValues []struct {
+ Value string `json:"value"`
+ } `json:"metricValues"`
+ } `json:"rows"`
+ }
+ if err := json.Unmarshal([]byte(out), &parsed); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if parsed.Property != "properties/123" || parsed.From != "2026-02-01" || parsed.To != "2026-02-01" || parsed.RowCount != 1 || len(parsed.Rows) != 1 {
+ t.Fatalf("unexpected payload: %#v", parsed)
+ }
+}
+
+func TestExecute_AnalyticsReport_FailEmpty_JSON(t *testing.T) {
+ origNew := newAnalyticsDataService
+ t.Cleanup(func() { newAnalyticsDataService = origNew })
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !(r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v1beta/properties/123:runReport")) {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "dimensionHeaders": []map[string]any{{"name": "date"}},
+ "metricHeaders": []map[string]any{{"name": "activeUsers"}},
+ "rowCount": 0,
+ "rows": []map[string]any{},
+ })
+ }))
+ defer srv.Close()
+
+ svc, err := analyticsdataapi.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newAnalyticsDataService = func(context.Context, string) (*analyticsdataapi.Service, error) { return svc, nil }
+
+ var execErr error
+ out := captureStdout(t, func() {
+ _ = captureStderr(t, func() {
+ execErr = Execute([]string{
+ "--json",
+ "--account", "a@b.com",
+ "analytics", "report", "123",
+ "--from", "2026-02-01",
+ "--to", "2026-02-01",
+ "--dimensions", "date",
+ "--metrics", "activeUsers",
+ "--fail-empty",
+ })
+ })
+ })
+ if execErr == nil {
+ t.Fatalf("expected error")
+ }
+ if got := ExitCode(execErr); got != emptyResultsExitCode {
+ t.Fatalf("expected exit code %d, got %d", emptyResultsExitCode, got)
+ }
+
+ var parsed struct {
+ Property string `json:"property"`
+ RowCount int64 `json:"row_count"`
+ Rows []map[string]any `json:"rows"`
+ }
+ if err := json.Unmarshal([]byte(out), &parsed); err != nil {
+ t.Fatalf("unmarshal: %v\nout=%q", err, out)
+ }
+ if parsed.Property != "properties/123" || parsed.RowCount != 0 || len(parsed.Rows) != 0 {
+ t.Fatalf("unexpected payload: %#v", parsed)
+ }
+}
+
+func TestExecute_AnalyticsReport_ServiceError(t *testing.T) {
+ origNew := newAnalyticsDataService
+ t.Cleanup(func() { newAnalyticsDataService = origNew })
+ newAnalyticsDataService = func(context.Context, string) (*analyticsdataapi.Service, error) {
+ return nil, errors.New("analytics data service down")
+ }
+
+ _ = captureStderr(t, func() {
+ err := Execute([]string{
+ "--account", "a@b.com",
+ "analytics", "report", "123",
+ "--from", "2026-02-01",
+ "--to", "2026-02-01",
+ "--metrics", "activeUsers",
+ })
+ if err == nil || !strings.Contains(err.Error(), "analytics data service down") {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ })
+}
+
+func TestExecute_AnalyticsReport_ValidatesMetricsBeforeServiceCall(t *testing.T) {
+ origNew := newAnalyticsDataService
+ t.Cleanup(func() { newAnalyticsDataService = origNew })
+ newAnalyticsDataService = func(context.Context, string) (*analyticsdataapi.Service, error) {
+ t.Fatalf("expected validation to fail before creating analytics data service")
+ return nil, errors.New("unexpected analytics data service call")
+ }
+
+ _ = captureStderr(t, func() {
+ err := Execute([]string{
+ "--account", "a@b.com",
+ "analytics", "report", "123",
+ "--from", "2026-02-01",
+ "--to", "2026-02-01",
+ "--metrics", "",
+ })
+ if err == nil || !strings.Contains(err.Error(), "empty --metrics") {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ })
+}
+
+func TestExecute_SearchConsoleSites_Text(t *testing.T) {
+ origNew := newSearchConsoleService
+ t.Cleanup(func() { newSearchConsoleService = origNew })
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !(r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/webmasters/v3/sites")) {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "siteEntry": []map[string]any{
+ {"siteUrl": "sc-domain:example.com", "permissionLevel": "SITE_OWNER"},
+ },
+ })
+ }))
+ defer srv.Close()
+
+ svc, err := searchconsoleapi.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newSearchConsoleService = func(context.Context, string) (*searchconsoleapi.Service, error) { return svc, nil }
+
+ out := captureStdout(t, func() {
+ _ = captureStderr(t, func() {
+ if err := Execute([]string{"--account", "a@b.com", "searchconsole", "sites"}); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ })
+ })
+ if !strings.Contains(out, "SITE") || !strings.Contains(out, "PERMISSION") || !strings.Contains(out, "sc-domain:example.com") || !strings.Contains(out, "SITE_OWNER") {
+ t.Fatalf("unexpected out=%q", out)
+ }
+}
+
+func TestExecute_SearchConsoleSites_JSON(t *testing.T) {
+ origNew := newSearchConsoleService
+ t.Cleanup(func() { newSearchConsoleService = origNew })
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !(r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/webmasters/v3/sites")) {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "siteEntry": []map[string]any{
+ {"siteUrl": "sc-domain:example.com", "permissionLevel": "SITE_OWNER"},
+ },
+ })
+ }))
+ defer srv.Close()
+
+ svc, err := searchconsoleapi.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newSearchConsoleService = func(context.Context, string) (*searchconsoleapi.Service, error) { return svc, nil }
+
+ out := captureStdout(t, func() {
+ _ = captureStderr(t, func() {
+ if err := Execute([]string{"--json", "--account", "a@b.com", "searchconsole", "sites"}); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ })
+ })
+
+ var parsed struct {
+ Sites []struct {
+ SiteURL string `json:"siteUrl"`
+ PermissionLevel string `json:"permissionLevel"`
+ } `json:"sites"`
+ }
+ if err := json.Unmarshal([]byte(out), &parsed); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(parsed.Sites) != 1 || parsed.Sites[0].SiteURL != "sc-domain:example.com" || parsed.Sites[0].PermissionLevel != "SITE_OWNER" {
+ t.Fatalf("unexpected payload: %#v", parsed)
+ }
+}
+
+func TestExecute_SearchConsoleSites_ServiceError(t *testing.T) {
+ origNew := newSearchConsoleService
+ t.Cleanup(func() { newSearchConsoleService = origNew })
+ newSearchConsoleService = func(context.Context, string) (*searchconsoleapi.Service, error) {
+ return nil, errors.New("search console service down")
+ }
+
+ _ = captureStderr(t, func() {
+ err := Execute([]string{"--account", "a@b.com", "searchconsole", "sites"})
+ if err == nil || !strings.Contains(err.Error(), "search console service down") {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ })
+}
+
+func TestExecute_SearchConsoleQuery_JSON(t *testing.T) {
+ origNew := newSearchConsoleService
+ t.Cleanup(func() { newSearchConsoleService = origNew })
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !(r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/searchAnalytics/query")) {
+ http.NotFound(w, r)
+ return
+ }
+ var req map[string]any
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ t.Fatalf("decode request: %v", err)
+ }
+ if req["startDate"] != "2026-02-01" || req["endDate"] != "2026-02-07" || req["type"] != "WEB" {
+ t.Fatalf("unexpected request payload: %#v", req)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "responseAggregationType": "AUTO",
+ "rows": []map[string]any{
+ {
+ "keys": []string{"gog cli", "https://example.com/docs"},
+ "clicks": 12,
+ "impressions": 300,
+ "ctr": 0.04,
+ "position": 7.3,
+ },
+ },
+ })
+ }))
+ defer srv.Close()
+
+ svc, err := searchconsoleapi.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newSearchConsoleService = func(context.Context, string) (*searchconsoleapi.Service, error) { return svc, nil }
+
+ out := captureStdout(t, func() {
+ _ = captureStderr(t, func() {
+ if err := Execute([]string{
+ "--json",
+ "--account", "a@b.com",
+ "searchconsole", "query", "sc-domain:example.com",
+ "--from", "2026-02-01",
+ "--to", "2026-02-07",
+ "--dimensions", "query,page",
+ "--type", "web",
+ "--max", "10",
+ }); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ })
+ })
+
+ var parsed struct {
+ SiteURL string `json:"site_url"`
+ Type string `json:"type"`
+ Rows []struct {
+ Keys []string `json:"keys"`
+ } `json:"rows"`
+ }
+ if err := json.Unmarshal([]byte(out), &parsed); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if parsed.SiteURL != "sc-domain:example.com" || parsed.Type != "WEB" || len(parsed.Rows) != 1 || len(parsed.Rows[0].Keys) != 2 {
+ t.Fatalf("unexpected payload: %#v", parsed)
+ }
+}
+
+func TestExecute_SearchConsoleQuery_Text(t *testing.T) {
+ origNew := newSearchConsoleService
+ t.Cleanup(func() { newSearchConsoleService = origNew })
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !(r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/searchAnalytics/query")) {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "responseAggregationType": "AUTO",
+ "rows": []map[string]any{
+ {
+ "keys": []string{"gog cli", "https://example.com/docs"},
+ "clicks": 12,
+ "impressions": 300,
+ "ctr": 0.04,
+ "position": 7.3,
+ },
+ },
+ })
+ }))
+ defer srv.Close()
+
+ svc, err := searchconsoleapi.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newSearchConsoleService = func(context.Context, string) (*searchconsoleapi.Service, error) { return svc, nil }
+
+ out := captureStdout(t, func() {
+ _ = captureStderr(t, func() {
+ if err := Execute([]string{
+ "--account", "a@b.com",
+ "searchconsole", "query", "sc-domain:example.com",
+ "--from", "2026-02-01",
+ "--to", "2026-02-07",
+ "--dimensions", "query,page",
+ "--type", "web",
+ "--max", "10",
+ }); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ })
+ })
+ if !strings.Contains(out, "QUERY") ||
+ !strings.Contains(out, "PAGE") ||
+ !strings.Contains(out, "CLICKS") ||
+ !strings.Contains(out, "IMPRESSIONS") ||
+ !strings.Contains(out, "CTR") ||
+ !strings.Contains(out, "POSITION") ||
+ !strings.Contains(out, "gog cli") ||
+ !strings.Contains(out, "https://example.com/docs") ||
+ !strings.Contains(out, "12") ||
+ !strings.Contains(out, "300") {
+ t.Fatalf("unexpected out=%q", out)
+ }
+}
+
+func TestExecute_SearchConsoleQuery_ServiceError(t *testing.T) {
+ origNew := newSearchConsoleService
+ t.Cleanup(func() { newSearchConsoleService = origNew })
+ newSearchConsoleService = func(context.Context, string) (*searchconsoleapi.Service, error) {
+ return nil, errors.New("search console service down")
+ }
+
+ _ = captureStderr(t, func() {
+ err := Execute([]string{
+ "--account", "a@b.com",
+ "searchconsole", "query", "sc-domain:example.com",
+ "--from", "2026-02-01",
+ "--to", "2026-02-07",
+ })
+ if err == nil || !strings.Contains(err.Error(), "search console service down") {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ })
+}
+
+func TestExecute_SearchConsoleQuery_ValidatesDateBeforeServiceCall(t *testing.T) {
+ origNew := newSearchConsoleService
+ t.Cleanup(func() { newSearchConsoleService = origNew })
+ newSearchConsoleService = func(context.Context, string) (*searchconsoleapi.Service, error) {
+ t.Fatalf("expected validation to fail before creating search console service")
+ return nil, errors.New("unexpected search console service call")
+ }
+
+ _ = captureStderr(t, func() {
+ err := Execute([]string{
+ "--account", "a@b.com",
+ "searchconsole", "query", "sc-domain:example.com",
+ "--from", "2026/02/01",
+ "--to", "2026-02-07",
+ })
+ if err == nil || !strings.Contains(err.Error(), "invalid --from") {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ })
+}
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index d301db26..d95cf1f1 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -29,7 +29,7 @@ const (
type RootFlags struct {
Color string `help:"Color output: auto|always|never" default:"${color}"`
- Account string `help:"Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript)" aliases:"acct" short:"a"`
+ Account string `help:"Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript/analytics/searchconsole)" aliases:"acct" short:"a"`
Client string `help:"OAuth client name (selects stored credentials + token bucket)" default:"${client}"`
EnableCommands string `help:"Comma-separated list of enabled top-level commands (restricts CLI)" default:"${enabled_commands}"`
JSON bool `help:"Output JSON to stdout (best for scripting)" default:"${json}" aliases:"machine" short:"j"`
@@ -60,30 +60,32 @@ type CLI struct {
Me PeopleMeCmd `cmd:"" name:"me" help:"Show your profile (alias for 'people me')"`
Whoami PeopleMeCmd `cmd:"" name:"whoami" aliases:"who-am-i" help:"Show your profile (alias for 'people me')"`
- Auth AuthCmd `cmd:"" help:"Auth and credentials"`
- Groups GroupsCmd `cmd:"" aliases:"group" help:"Google Groups"`
- Drive DriveCmd `cmd:"" aliases:"drv" help:"Google Drive"`
- Docs DocsCmd `cmd:"" aliases:"doc" help:"Google Docs (export via Drive)"`
- Slides SlidesCmd `cmd:"" aliases:"slide" help:"Google Slides"`
- Calendar CalendarCmd `cmd:"" aliases:"cal" help:"Google Calendar"`
- Classroom ClassroomCmd `cmd:"" aliases:"class" help:"Google Classroom"`
- Time TimeCmd `cmd:"" help:"Local time utilities"`
- Gmail GmailCmd `cmd:"" aliases:"mail,email" help:"Gmail"`
- Chat ChatCmd `cmd:"" help:"Google Chat"`
- Contacts ContactsCmd `cmd:"" aliases:"contact" help:"Google Contacts"`
- Tasks TasksCmd `cmd:"" aliases:"task" help:"Google Tasks"`
- People PeopleCmd `cmd:"" aliases:"person" help:"Google People"`
- Keep KeepCmd `cmd:"" help:"Google Keep (Workspace only)"`
- Sheets SheetsCmd `cmd:"" aliases:"sheet" help:"Google Sheets"`
- Forms FormsCmd `cmd:"" aliases:"form" help:"Google Forms"`
- AppScript AppScriptCmd `cmd:"" name:"appscript" aliases:"script,apps-script" help:"Google Apps Script"`
- Config ConfigCmd `cmd:"" help:"Manage configuration"`
- ExitCodes AgentExitCodesCmd `cmd:"" name:"exit-codes" aliases:"exitcodes" help:"Print stable exit codes (alias for 'agent exit-codes')"`
- Agent AgentCmd `cmd:"" help:"Agent-friendly helpers"`
- Schema SchemaCmd `cmd:"" help:"Machine-readable command/flag schema" aliases:"help-json,helpjson"`
- VersionCmd VersionCmd `cmd:"" name:"version" help:"Print version"`
- Completion CompletionCmd `cmd:"" help:"Generate shell completion scripts"`
- Complete CompletionInternalCmd `cmd:"" name:"__complete" hidden:"" help:"Internal completion helper"`
+ Auth AuthCmd `cmd:"" help:"Auth and credentials"`
+ Groups GroupsCmd `cmd:"" aliases:"group" help:"Google Groups"`
+ Drive DriveCmd `cmd:"" aliases:"drv" help:"Google Drive"`
+ Docs DocsCmd `cmd:"" aliases:"doc" help:"Google Docs (export via Drive)"`
+ Slides SlidesCmd `cmd:"" aliases:"slide" help:"Google Slides"`
+ Calendar CalendarCmd `cmd:"" aliases:"cal" help:"Google Calendar"`
+ Classroom ClassroomCmd `cmd:"" aliases:"class" help:"Google Classroom"`
+ Time TimeCmd `cmd:"" help:"Local time utilities"`
+ Gmail GmailCmd `cmd:"" aliases:"mail,email" help:"Gmail"`
+ Chat ChatCmd `cmd:"" help:"Google Chat"`
+ Contacts ContactsCmd `cmd:"" aliases:"contact" help:"Google Contacts"`
+ Tasks TasksCmd `cmd:"" aliases:"task" help:"Google Tasks"`
+ People PeopleCmd `cmd:"" aliases:"person" help:"Google People"`
+ Keep KeepCmd `cmd:"" help:"Google Keep (Workspace only)"`
+ Sheets SheetsCmd `cmd:"" aliases:"sheet" help:"Google Sheets"`
+ Forms FormsCmd `cmd:"" aliases:"form" help:"Google Forms"`
+ AppScript AppScriptCmd `cmd:"" name:"appscript" aliases:"script,apps-script" help:"Google Apps Script"`
+ Analytics AnalyticsCmd `cmd:"" aliases:"ga" help:"Google Analytics"`
+ SearchConsole SearchConsoleCmd `cmd:"" name:"searchconsole" aliases:"gsc,search-console" help:"Google Search Console"`
+ Config ConfigCmd `cmd:"" help:"Manage configuration"`
+ ExitCodes AgentExitCodesCmd `cmd:"" name:"exit-codes" aliases:"exitcodes" help:"Print stable exit codes (alias for 'agent exit-codes')"`
+ Agent AgentCmd `cmd:"" help:"Agent-friendly helpers"`
+ Schema SchemaCmd `cmd:"" help:"Machine-readable command/flag schema" aliases:"help-json,helpjson"`
+ VersionCmd VersionCmd `cmd:"" name:"version" help:"Print version"`
+ Completion CompletionCmd `cmd:"" help:"Generate shell completion scripts"`
+ Complete CompletionInternalCmd `cmd:"" name:"__complete" hidden:"" help:"Internal completion helper"`
}
type exitPanic struct{ code int }
@@ -324,7 +326,7 @@ func newParser(description string) (*kong.Kong, *CLI, error) {
}
func baseDescription() string {
- return "Google CLI for Gmail/Calendar/Chat/Classroom/Drive/Contacts/Tasks/Sheets/Docs/Slides/People/Forms/App Script"
+ return "Google CLI for Gmail/Calendar/Chat/Classroom/Drive/Contacts/Tasks/Sheets/Docs/Slides/People/Forms/App Script/Analytics/Search Console"
}
func helpDescription() string {
diff --git a/internal/cmd/searchconsole.go b/internal/cmd/searchconsole.go
new file mode 100644
index 00000000..7696fe4b
--- /dev/null
+++ b/internal/cmd/searchconsole.go
@@ -0,0 +1,239 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ searchconsoleapi "google.golang.org/api/searchconsole/v1"
+
+ "github.com/steipete/gogcli/internal/googleapi"
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+var newSearchConsoleService = googleapi.NewSearchConsole
+
+type SearchConsoleCmd struct {
+ Sites SearchConsoleSitesCmd `cmd:"" name:"sites" aliases:"list,ls" default:"withargs" help:"List Search Console sites"`
+ Query SearchConsoleQueryCmd `cmd:"" name:"query" aliases:"report" help:"Run a Search Analytics query"`
+}
+
+type SearchConsoleSitesCmd struct {
+ FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"`
+}
+
+func (c *SearchConsoleSitesCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newSearchConsoleService(ctx, account)
+ if err != nil {
+ return err
+ }
+ resp, err := svc.Sites.List().Context(ctx).Do()
+ if err != nil {
+ return err
+ }
+
+ rows := resp.SiteEntry
+ if outfmt.IsJSON(ctx) {
+ if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
+ "sites": rows,
+ }); err != nil {
+ return err
+ }
+ if len(rows) == 0 {
+ return failEmptyExit(c.FailEmpty)
+ }
+ return nil
+ }
+
+ if len(rows) == 0 {
+ u.Err().Println("No Search Console sites")
+ return failEmptyExit(c.FailEmpty)
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "SITE\tPERMISSION")
+ for _, item := range rows {
+ if item == nil {
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%s\n", sanitizeTab(item.SiteUrl), sanitizeTab(item.PermissionLevel))
+ }
+ return nil
+}
+
+type SearchConsoleQueryCmd struct {
+ SiteURL string `arg:"" name:"siteUrl" help:"Search Console property URL (e.g. https://example.com/ or sc-domain:example.com)"`
+ From string `name:"from" required:"" help:"Start date (YYYY-MM-DD)"`
+ To string `name:"to" required:"" help:"End date (YYYY-MM-DD)"`
+ Dimensions string `name:"dimensions" help:"Comma-separated dimensions (DATE,QUERY,PAGE,COUNTRY,DEVICE,SEARCH_APPEARANCE,HOUR)" default:"QUERY"`
+ Type string `name:"type" help:"Search type (WEB,IMAGE,VIDEO,NEWS,DISCOVER,GOOGLE_NEWS)" default:"WEB"`
+ Max int64 `name:"max" aliases:"limit" help:"Max rows to return (1-25000)" default:"1000"`
+ 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 *SearchConsoleQueryCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ siteURL := strings.TrimSpace(c.SiteURL)
+ if siteURL == "" {
+ return usage("empty siteUrl")
+ }
+
+ from, err := parseSearchConsoleDate(c.From, "--from")
+ if err != nil {
+ return err
+ }
+ to, err := parseSearchConsoleDate(c.To, "--to")
+ if err != nil {
+ return err
+ }
+ if to < from {
+ return usage("--to must be on or after --from")
+ }
+
+ if c.Max <= 0 || c.Max > 25000 {
+ return usage("--max must be between 1 and 25000")
+ }
+ if c.Offset < 0 {
+ return usage("--offset must be >= 0")
+ }
+
+ dimensions, err := normalizeSearchConsoleDimensions(c.Dimensions)
+ if err != nil {
+ return err
+ }
+ searchType, err := normalizeSearchConsoleType(c.Type)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newSearchConsoleService(ctx, account)
+ if err != nil {
+ return err
+ }
+ resp, err := svc.Searchanalytics.Query(siteURL, &searchconsoleapi.SearchAnalyticsQueryRequest{
+ StartDate: from,
+ EndDate: to,
+ Dimensions: dimensions,
+ Type: searchType,
+ RowLimit: c.Max,
+ StartRow: c.Offset,
+ }).Context(ctx).Do()
+ if err != nil {
+ return err
+ }
+
+ if outfmt.IsJSON(ctx) {
+ if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
+ "site_url": siteURL,
+ "from": c.From,
+ "to": c.To,
+ "type": searchType,
+ "dimensions": dimensions,
+ "response_aggregation_type": resp.ResponseAggregationType,
+ "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 Search Console rows")
+ return failEmptyExit(c.FailEmpty)
+ }
+
+ headers := make([]string, 0, len(dimensions)+4)
+ headers = append(headers, dimensions...)
+ headers = append(headers, "CLICKS", "IMPRESSIONS", "CTR", "POSITION")
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, strings.Join(headers, "\t"))
+ for _, row := range resp.Rows {
+ if row == nil {
+ continue
+ }
+ values := make([]string, 0, len(dimensions)+4)
+ for i := range dimensions {
+ values = append(values, sanitizeTab(searchConsoleKey(row, i)))
+ }
+ values = append(values,
+ strconv.FormatFloat(row.Clicks, 'f', -1, 64),
+ strconv.FormatFloat(row.Impressions, 'f', -1, 64),
+ strconv.FormatFloat(row.Ctr, 'f', -1, 64),
+ strconv.FormatFloat(row.Position, 'f', -1, 64),
+ )
+ fmt.Fprintln(w, strings.Join(values, "\t"))
+ }
+ return nil
+}
+
+func parseSearchConsoleDate(value string, flagName string) (string, error) {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return "", usagef("empty %s", flagName)
+ }
+ if _, err := time.Parse("2006-01-02", value); err != nil {
+ return "", usagef("invalid %s (expected YYYY-MM-DD)", flagName)
+ }
+ return value, nil
+}
+
+func normalizeSearchConsoleType(raw string) (string, error) {
+ v := strings.ToUpper(strings.TrimSpace(raw))
+ if v == "" {
+ return "", usage("empty --type")
+ }
+ switch v {
+ case "WEB", "IMAGE", "VIDEO", "NEWS", "DISCOVER", "GOOGLE_NEWS":
+ return v, nil
+ default:
+ return "", usagef("invalid --type %q (expected WEB|IMAGE|VIDEO|NEWS|DISCOVER|GOOGLE_NEWS)", raw)
+ }
+}
+
+func normalizeSearchConsoleDimensions(raw string) ([]string, error) {
+ parts := splitCommaList(raw)
+ if len(parts) == 0 {
+ return nil, nil
+ }
+
+ out := make([]string, 0, len(parts))
+ for _, part := range parts {
+ v := strings.ToUpper(strings.TrimSpace(part))
+ switch v {
+ case "DATE", "QUERY", "PAGE", "COUNTRY", "DEVICE", "SEARCH_APPEARANCE", "HOUR":
+ out = append(out, v)
+ default:
+ return nil, usagef("invalid dimension %q (expected DATE|QUERY|PAGE|COUNTRY|DEVICE|SEARCH_APPEARANCE|HOUR)", part)
+ }
+ }
+ return out, nil
+}
+
+func searchConsoleKey(row *searchconsoleapi.ApiDataRow, index int) string {
+ if row == nil || index < 0 || index >= len(row.Keys) {
+ return ""
+ }
+ return row.Keys[index]
+}
diff --git a/internal/googleapi/analytics.go b/internal/googleapi/analytics.go
new file mode 100644
index 00000000..22d39c8c
--- /dev/null
+++ b/internal/googleapi/analytics.go
@@ -0,0 +1,31 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ analyticsadmin "google.golang.org/api/analyticsadmin/v1beta"
+ analyticsdata "google.golang.org/api/analyticsdata/v1beta"
+
+ "github.com/steipete/gogcli/internal/googleauth"
+)
+
+func NewAnalyticsAdmin(ctx context.Context, email string) (*analyticsadmin.Service, error) {
+ if opts, err := optionsForAccount(ctx, googleauth.ServiceAnalytics, email); err != nil {
+ return nil, fmt.Errorf("analyticsadmin options: %w", err)
+ } else if svc, err := analyticsadmin.NewService(ctx, opts...); err != nil {
+ return nil, fmt.Errorf("create analyticsadmin service: %w", err)
+ } else {
+ return svc, nil
+ }
+}
+
+func NewAnalyticsData(ctx context.Context, email string) (*analyticsdata.Service, error) {
+ if opts, err := optionsForAccount(ctx, googleauth.ServiceAnalytics, email); err != nil {
+ return nil, fmt.Errorf("analyticsdata options: %w", err)
+ } else if svc, err := analyticsdata.NewService(ctx, opts...); err != nil {
+ return nil, fmt.Errorf("create analyticsdata service: %w", err)
+ } else {
+ return svc, nil
+ }
+}
diff --git a/internal/googleapi/searchconsole.go b/internal/googleapi/searchconsole.go
new file mode 100644
index 00000000..6c2539cd
--- /dev/null
+++ b/internal/googleapi/searchconsole.go
@@ -0,0 +1,20 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ searchconsoleapi "google.golang.org/api/searchconsole/v1"
+
+ "github.com/steipete/gogcli/internal/googleauth"
+)
+
+func NewSearchConsole(ctx context.Context, email string) (*searchconsoleapi.Service, error) {
+ if opts, err := optionsForAccount(ctx, googleauth.ServiceSearchConsole, email); err != nil {
+ return nil, fmt.Errorf("searchconsole options: %w", err)
+ } else if svc, err := searchconsoleapi.NewService(ctx, opts...); err != nil {
+ return nil, fmt.Errorf("create searchconsole service: %w", err)
+ } else {
+ return svc, nil
+ }
+}
diff --git a/internal/googleapi/services_more_test.go b/internal/googleapi/services_more_test.go
index e305de8c..3808b392 100644
--- a/internal/googleapi/services_more_test.go
+++ b/internal/googleapi/services_more_test.go
@@ -62,6 +62,18 @@ func TestNewServicesWithStoredToken(t *testing.T) {
t.Fatalf("NewTasks: %v", err)
}
+ if _, err := NewAnalyticsAdmin(ctx, "a@b.com"); err != nil {
+ t.Fatalf("NewAnalyticsAdmin: %v", err)
+ }
+
+ if _, err := NewAnalyticsData(ctx, "a@b.com"); err != nil {
+ t.Fatalf("NewAnalyticsData: %v", err)
+ }
+
+ if _, err := NewSearchConsole(ctx, "a@b.com"); err != nil {
+ t.Fatalf("NewSearchConsole: %v", err)
+ }
+
if _, err := NewKeep(ctx, "a@b.com"); err != nil {
t.Fatalf("NewKeep: %v", err)
}
diff --git a/internal/googleauth/service.go b/internal/googleauth/service.go
index f464af91..233e69ca 100644
--- a/internal/googleauth/service.go
+++ b/internal/googleauth/service.go
@@ -10,21 +10,23 @@ import (
type Service string
const (
- ServiceGmail Service = "gmail"
- ServiceCalendar Service = "calendar"
- ServiceChat Service = "chat"
- ServiceClassroom Service = "classroom"
- ServiceDrive Service = "drive"
- ServiceDocs Service = "docs"
- ServiceSlides Service = "slides"
- ServiceContacts Service = "contacts"
- ServiceTasks Service = "tasks"
- ServicePeople Service = "people"
- ServiceSheets Service = "sheets"
- ServiceForms Service = "forms"
- ServiceAppScript Service = "appscript"
- ServiceGroups Service = "groups"
- ServiceKeep Service = "keep"
+ ServiceGmail Service = "gmail"
+ ServiceCalendar Service = "calendar"
+ ServiceChat Service = "chat"
+ ServiceClassroom Service = "classroom"
+ ServiceDrive Service = "drive"
+ ServiceDocs Service = "docs"
+ ServiceSlides Service = "slides"
+ ServiceContacts Service = "contacts"
+ ServiceTasks Service = "tasks"
+ ServicePeople Service = "people"
+ ServiceSheets Service = "sheets"
+ ServiceForms Service = "forms"
+ ServiceAppScript Service = "appscript"
+ ServiceAnalytics Service = "analytics"
+ ServiceSearchConsole Service = "searchconsole"
+ ServiceGroups Service = "groups"
+ ServiceKeep Service = "keep"
)
const (
@@ -72,6 +74,8 @@ var serviceOrder = []Service{
ServicePeople,
ServiceForms,
ServiceAppScript,
+ ServiceAnalytics,
+ ServiceSearchConsole,
ServiceGroups,
ServiceKeep,
}
@@ -191,6 +195,17 @@ var serviceInfoByService = map[Service]serviceInfo{
user: true,
apis: []string{"Apps Script API"},
},
+ ServiceAnalytics: {
+ scopes: []string{"https://www.googleapis.com/auth/analytics.readonly"},
+ user: true,
+ apis: []string{"Analytics Admin API", "Analytics Data API"},
+ note: "GA4 account summaries + reporting",
+ },
+ ServiceSearchConsole: {
+ scopes: []string{"https://www.googleapis.com/auth/webmasters.readonly"},
+ user: true,
+ apis: []string{"Search Console API"},
+ },
ServiceGroups: {
scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
user: false,
@@ -517,6 +532,10 @@ func scopesForServiceWithOptions(service Service, opts ScopeOptions) ([]string,
}, nil
}
+ return Scopes(service)
+ case ServiceAnalytics:
+ return Scopes(service)
+ case ServiceSearchConsole:
return Scopes(service)
case ServiceGroups:
return Scopes(service)
diff --git a/internal/googleauth/service_test.go b/internal/googleauth/service_test.go
index 445de2d1..3acdd826 100644
--- a/internal/googleauth/service_test.go
+++ b/internal/googleauth/service_test.go
@@ -21,6 +21,8 @@ func TestParseService(t *testing.T) {
{"sheets", ServiceSheets},
{"forms", ServiceForms},
{"appscript", ServiceAppScript},
+ {"analytics", ServiceAnalytics},
+ {"searchconsole", ServiceSearchConsole},
{"groups", ServiceGroups},
{"keep", ServiceKeep},
}
@@ -65,7 +67,7 @@ func TestExtractCodeAndState_Errors(t *testing.T) {
func TestAllServices(t *testing.T) {
svcs := AllServices()
- if len(svcs) != 15 {
+ if len(svcs) != 17 {
t.Fatalf("unexpected: %v", svcs)
}
seen := make(map[Service]bool)
@@ -74,7 +76,25 @@ func TestAllServices(t *testing.T) {
seen[s] = true
}
- for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceChat, ServiceClassroom, ServiceDrive, ServiceDocs, ServiceSlides, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets, ServiceForms, ServiceAppScript, ServiceGroups, ServiceKeep} {
+ for _, want := range []Service{
+ ServiceGmail,
+ ServiceCalendar,
+ ServiceChat,
+ ServiceClassroom,
+ ServiceDrive,
+ ServiceDocs,
+ ServiceSlides,
+ ServiceContacts,
+ ServiceTasks,
+ ServicePeople,
+ ServiceSheets,
+ ServiceForms,
+ ServiceAppScript,
+ ServiceAnalytics,
+ ServiceSearchConsole,
+ ServiceGroups,
+ ServiceKeep,
+ } {
if !seen[want] {
t.Fatalf("missing %q", want)
}
@@ -83,7 +103,7 @@ func TestAllServices(t *testing.T) {
func TestUserServices(t *testing.T) {
svcs := UserServices()
- if len(svcs) != 13 {
+ if len(svcs) != 15 {
t.Fatalf("unexpected: %v", svcs)
}
@@ -98,6 +118,8 @@ func TestUserServices(t *testing.T) {
seenSlides = true
case ServiceForms, ServiceAppScript:
// expected user services
+ case ServiceAnalytics, ServiceSearchConsole:
+ // expected user services
case ServiceKeep:
t.Fatalf("unexpected keep in user services")
}
@@ -113,7 +135,7 @@ func TestUserServices(t *testing.T) {
}
func TestUserServiceCSV(t *testing.T) {
- want := "gmail,calendar,chat,classroom,drive,docs,slides,contacts,tasks,sheets,people,forms,appscript"
+ want := "gmail,calendar,chat,classroom,drive,docs,slides,contacts,tasks,sheets,people,forms,appscript,analytics,searchconsole"
if got := UserServiceCSV(); got != want {
t.Fatalf("unexpected user services csv: %q", got)
}
@@ -229,7 +251,20 @@ func TestScopesForServices_UnionSorted(t *testing.T) {
}
func TestScopesForManageWithOptions_Readonly(t *testing.T) {
- scopes, err := ScopesForManageWithOptions([]Service{ServiceGmail, ServiceDrive, ServiceCalendar, ServiceContacts, ServiceTasks, ServiceSheets, ServiceDocs, ServicePeople, ServiceForms, ServiceAppScript}, ScopeOptions{
+ scopes, err := ScopesForManageWithOptions([]Service{
+ ServiceGmail,
+ ServiceDrive,
+ ServiceCalendar,
+ ServiceContacts,
+ ServiceTasks,
+ ServiceSheets,
+ ServiceDocs,
+ ServicePeople,
+ ServiceForms,
+ ServiceAppScript,
+ ServiceAnalytics,
+ ServiceSearchConsole,
+ }, ScopeOptions{
Readonly: true,
DriveScope: DriveScopeFull,
})
@@ -252,6 +287,8 @@ func TestScopesForManageWithOptions_Readonly(t *testing.T) {
"https://www.googleapis.com/auth/forms.responses.readonly",
"https://www.googleapis.com/auth/script.projects.readonly",
"https://www.googleapis.com/auth/script.deployments.readonly",
+ "https://www.googleapis.com/auth/analytics.readonly",
+ "https://www.googleapis.com/auth/webmasters.readonly",
"profile",
}
for _, w := range want {