diff --git a/CHANGELOG.md b/CHANGELOG.md
index 87614640..3465332a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 13fd0f56..2a9f2824 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, 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
@@ -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)
@@ -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:
@@ -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`
`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` | 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) |
@@ -1139,6 +1146,37 @@ 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
+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
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..7065154b
--- /dev/null
+++ b/internal/cmd/execute_analytics_searchconsole_test.go
@@ -0,0 +1,758 @@
+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)
+ }
+ dimensions, ok := req["dimensions"].([]any)
+ if !ok || len(dimensions) != 2 || dimensions[0] != "query" || dimensions[1] != "page" {
+ t.Fatalf("unexpected dimensions payload: %#v", req["dimensions"])
+ }
+ 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 c898444b..dd131334 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}"`
AccessToken string `help:"Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h)" env:"GOG_ACCESS_TOKEN"` //nolint:gosec // CLI/env input, not an embedded secret
EnableCommands string `help:"Comma-separated list of enabled top-level commands (restricts CLI)" default:"${enabled_commands}"`
@@ -61,31 +61,33 @@ 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"`
- Admin AdminCmd `cmd:"" help:"Google Workspace Admin (Directory API) - requires domain-wide delegation"`
- 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"`
+ Admin AdminCmd `cmd:"" help:"Google Workspace Admin (Directory API) - requires domain-wide delegation"`
+ 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,webmasters" 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 }
@@ -333,7 +335,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/Admin"
}
func helpDescription() string {
diff --git a/internal/cmd/searchconsole.go b/internal/cmd/searchconsole.go
new file mode 100644
index 00000000..7505bb66
--- /dev/null
+++ b/internal/cmd/searchconsole.go
@@ -0,0 +1,882 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ gapi "google.golang.org/api/googleapi"
+ searchconsoleapi "google.golang.org/api/searchconsole/v1"
+
+ "github.com/steipete/gogcli/internal/config"
+ "github.com/steipete/gogcli/internal/googleapi"
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+var newSearchConsoleService = googleapi.NewSearchConsole
+
+const (
+ defaultSearchConsoleRowLimit = int64(1000)
+ maxSearchConsoleRowLimit = int64(25000)
+)
+
+type SearchConsoleCmd struct {
+ Sites SearchConsoleSitesCmd `cmd:"" name:"sites" aliases:"list,ls" help:"List and inspect Search Console sites"`
+ SearchAnalytics SearchConsoleSearchAnalyticsCmd `cmd:"" name:"searchanalytics" aliases:"analytics" help:"Search Analytics queries"`
+ Query SearchConsoleQueryCmd `cmd:"" name:"query" aliases:"report" help:"Run a Search Analytics query"`
+ Sitemaps SearchConsoleSitemapsCmd `cmd:"" name:"sitemaps" help:"List/get/submit/delete sitemaps"`
+}
+
+type SearchConsoleSitesCmd struct {
+ List SearchConsoleSitesListCmd `cmd:"" default:"withargs" aliases:"ls" help:"List accessible Search Console sites"`
+ Get SearchConsoleSitesGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a specific Search Console site"`
+}
+
+type SearchConsoleSitesListCmd struct {
+ FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"`
+}
+
+func (c *SearchConsoleSitesListCmd) 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 wrapSearchConsoleError(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 SearchConsoleSitesGetCmd struct {
+ SiteURL string `arg:"" name:"siteUrl" help:"Search Console property URL (e.g. https://example.com/ or sc-domain:example.com)"`
+}
+
+func (c *SearchConsoleSitesGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ siteURL := strings.TrimSpace(c.SiteURL)
+ if siteURL == "" {
+ return usage("empty siteUrl")
+ }
+
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newSearchConsoleService(ctx, account)
+ if err != nil {
+ return err
+ }
+ site, err := svc.Sites.Get(siteURL).Context(ctx).Do()
+ if err != nil {
+ return wrapSearchConsoleError(err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
+ "site": site,
+ })
+ }
+
+ return writeResult(ctx, u,
+ kv("site_url", site.SiteUrl),
+ kv("permission_level", site.PermissionLevel),
+ )
+}
+
+type SearchConsoleSearchAnalyticsCmd struct {
+ Query SearchConsoleQueryCmd `cmd:"" name:"query" default:"withargs" aliases:"run" help:"Run a Search Analytics query"`
+}
+
+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" aliases:"start" help:"Start date (YYYY-MM-DD)"`
+ To string `name:"to" aliases:"end" 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"`
+ Aggregation string `name:"aggregation" help:"Aggregation type (AUTO,BY_PROPERTY,BY_PAGE,BY_NEWS_SHOWCASE_PANEL)"`
+ DataState string `name:"data-state" help:"Data state (FINAL,ALL,HOURLY_ALL)"`
+ Max int64 `name:"max" aliases:"limit" help:"Max rows to return (1-25000)" default:"1000"`
+ Offset int64 `name:"offset" aliases:"start-row" help:"Row offset for pagination" default:"0"`
+ Filter []string `name:"filter" help:"Dimension filter, repeatable: dimension:operator:expression"`
+ Request string `name:"request" help:"SearchAnalyticsQueryRequest JSON spec. Accepts @file, a plain file path, -, or inline JSON."`
+ 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")
+ }
+
+ req, err := c.buildRequest()
+ if err != nil {
+ return err
+ }
+
+ svc, err := newSearchConsoleService(ctx, account)
+ if err != nil {
+ return err
+ }
+ resp, err := svc.Searchanalytics.Query(siteURL, req).Context(ctx).Do()
+ if err != nil {
+ return wrapSearchConsoleError(err)
+ }
+
+ queryType := req.Type
+ if queryType == "" {
+ queryType = req.SearchType
+ }
+
+ if outfmt.IsJSON(ctx) {
+ if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
+ "site_url": siteURL,
+ "from": req.StartDate,
+ "to": req.EndDate,
+ "type": queryType,
+ "dimensions": req.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 := requestSearchConsoleDimensions(req, resp.Rows)
+ 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(headers))
+ for i := range headers[:len(headers)-4] {
+ values = append(values, sanitizeTab(searchConsoleKey(row, i)))
+ }
+ values = append(values,
+ formatSearchConsoleMetric(row.Clicks, 0),
+ formatSearchConsoleMetric(row.Impressions, 0),
+ formatSearchConsoleMetric(row.Ctr, 4),
+ formatSearchConsoleMetric(row.Position, 2),
+ )
+ fmt.Fprintln(w, strings.Join(values, "\t"))
+ }
+ return nil
+}
+
+func (c *SearchConsoleQueryCmd) buildRequest() (*searchconsoleapi.SearchAnalyticsQueryRequest, error) {
+ requestSpec := strings.TrimSpace(c.Request)
+ if requestSpec != "" {
+ return buildSearchConsoleRequestFromSpec(requestSpec)
+ }
+
+ from, err := parseSearchConsoleDate(c.From, "--from")
+ if err != nil {
+ return nil, err
+ }
+ to, err := parseSearchConsoleDate(c.To, "--to")
+ if err != nil {
+ return nil, err
+ }
+ if err := validateSearchConsoleDateRange(from, to); err != nil {
+ return nil, err
+ }
+
+ if c.Max <= 0 || c.Max > maxSearchConsoleRowLimit {
+ return nil, usagef("--max must be between 1 and %d", maxSearchConsoleRowLimit)
+ }
+ if c.Offset < 0 {
+ return nil, usage("--offset must be >= 0")
+ }
+
+ dimensions, err := normalizeSearchConsoleDimensions(c.Dimensions)
+ if err != nil {
+ return nil, err
+ }
+ searchType, err := normalizeSearchConsoleType(c.Type)
+ if err != nil {
+ return nil, err
+ }
+
+ req := &searchconsoleapi.SearchAnalyticsQueryRequest{
+ StartDate: from,
+ EndDate: to,
+ Dimensions: dimensions,
+ Type: searchType,
+ RowLimit: c.Max,
+ StartRow: c.Offset,
+ }
+
+ if v := strings.TrimSpace(c.Aggregation); v != "" {
+ aggregation, err := normalizeSearchConsoleAggregation(v)
+ if err != nil {
+ return nil, err
+ }
+ req.AggregationType = aggregation
+ }
+ if v := strings.TrimSpace(c.DataState); v != "" {
+ dataState, err := normalizeSearchConsoleDataState(v)
+ if err != nil {
+ return nil, err
+ }
+ req.DataState = dataState
+ }
+
+ if len(c.Filter) > 0 {
+ filters := make([]*searchconsoleapi.ApiDimensionFilter, 0, len(c.Filter))
+ for _, raw := range c.Filter {
+ filter, err := parseSearchConsoleFilter(raw)
+ if err != nil {
+ return nil, err
+ }
+ filters = append(filters, filter)
+ }
+ req.DimensionFilterGroups = []*searchconsoleapi.ApiDimensionFilterGroup{
+ {
+ GroupType: "and",
+ Filters: filters,
+ },
+ }
+ }
+
+ return req, nil
+}
+
+type SearchConsoleSitemapsCmd struct {
+ List SearchConsoleSitemapsListCmd `cmd:"" default:"withargs" aliases:"ls" help:"List sitemaps for a site"`
+ Get SearchConsoleSitemapsGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a sitemap"`
+ Submit SearchConsoleSitemapsSubmitCmd `cmd:"" name:"submit" help:"Submit a sitemap"`
+ Delete SearchConsoleSitemapsDeleteCmd `cmd:"" name:"delete" aliases:"rm,remove" help:"Delete a sitemap"`
+}
+
+type SearchConsoleSitemapsListCmd struct {
+ SiteURL string `arg:"" name:"siteUrl" help:"Search Console property URL (e.g. https://example.com/ or sc-domain:example.com)"`
+ SitemapIndex string `name:"sitemap-index" help:"Filter to a sitemap index URL"`
+ FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"`
+}
+
+func (c *SearchConsoleSitemapsListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ siteURL := strings.TrimSpace(c.SiteURL)
+ if siteURL == "" {
+ return usage("empty siteUrl")
+ }
+
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newSearchConsoleService(ctx, account)
+ if err != nil {
+ return err
+ }
+ call := svc.Sitemaps.List(siteURL).Context(ctx)
+ if v := strings.TrimSpace(c.SitemapIndex); v != "" {
+ call = call.SitemapIndex(v)
+ }
+ resp, err := call.Do()
+ if err != nil {
+ return wrapSearchConsoleError(err)
+ }
+
+ rows := resp.Sitemap
+ if outfmt.IsJSON(ctx) {
+ if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
+ "sitemaps": 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 sitemaps")
+ return failEmptyExit(c.FailEmpty)
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "PATH\tTYPE\tPENDING\tWARNINGS\tERRORS\tLAST_SUBMITTED\tLAST_DOWNLOADED\tCONTENTS")
+ for _, item := range rows {
+ if item == nil {
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%s\t%t\t%d\t%d\t%s\t%s\t%s\n",
+ sanitizeTab(item.Path),
+ sanitizeTab(item.Type),
+ item.IsPending,
+ item.Warnings,
+ item.Errors,
+ sanitizeTab(item.LastSubmitted),
+ sanitizeTab(item.LastDownloaded),
+ sanitizeTab(formatSearchConsoleSitemapContents(item.Contents)),
+ )
+ }
+ return nil
+}
+
+type SearchConsoleSitemapsGetCmd struct {
+ SiteURL string `arg:"" name:"siteUrl" help:"Search Console property URL (e.g. https://example.com/ or sc-domain:example.com)"`
+ FeedPath string `arg:"" name:"feedpath" help:"Sitemap URL"`
+}
+
+func (c *SearchConsoleSitemapsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ siteURL := strings.TrimSpace(c.SiteURL)
+ if siteURL == "" {
+ return usage("empty siteUrl")
+ }
+ feedPath := strings.TrimSpace(c.FeedPath)
+ if feedPath == "" {
+ return usage("empty feedpath")
+ }
+
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newSearchConsoleService(ctx, account)
+ if err != nil {
+ return err
+ }
+ sitemap, err := svc.Sitemaps.Get(siteURL, feedPath).Context(ctx).Do()
+ if err != nil {
+ return wrapSearchConsoleError(err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
+ "sitemap": sitemap,
+ })
+ }
+
+ return writeResult(ctx, u,
+ kv("path", sitemap.Path),
+ kv("type", sitemap.Type),
+ kv("pending", sitemap.IsPending),
+ kv("warnings", sitemap.Warnings),
+ kv("errors", sitemap.Errors),
+ kv("last_submitted", sitemap.LastSubmitted),
+ kv("last_downloaded", sitemap.LastDownloaded),
+ kv("contents", formatSearchConsoleSitemapContents(sitemap.Contents)),
+ )
+}
+
+type SearchConsoleSitemapsSubmitCmd struct {
+ SiteURL string `arg:"" name:"siteUrl" help:"Search Console property URL (e.g. https://example.com/ or sc-domain:example.com)"`
+ FeedPath string `arg:"" name:"feedpath" help:"Sitemap URL"`
+}
+
+func (c *SearchConsoleSitemapsSubmitCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ siteURL := strings.TrimSpace(c.SiteURL)
+ if siteURL == "" {
+ return usage("empty siteUrl")
+ }
+ feedPath := strings.TrimSpace(c.FeedPath)
+ if feedPath == "" {
+ return usage("empty feedpath")
+ }
+
+ if err := dryRunExit(ctx, flags, "searchconsole.sitemaps.submit", map[string]any{
+ "site_url": siteURL,
+ "feed_path": feedPath,
+ }); err != nil {
+ return err
+ }
+
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newSearchConsoleService(ctx, account)
+ if err != nil {
+ return err
+ }
+ if err := svc.Sitemaps.Submit(siteURL, feedPath).Context(ctx).Do(); err != nil {
+ return wrapSearchConsoleError(err)
+ }
+
+ return writeResult(ctx, u,
+ kv("submitted", true),
+ kv("site_url", siteURL),
+ kv("feed_path", feedPath),
+ )
+}
+
+type SearchConsoleSitemapsDeleteCmd struct {
+ SiteURL string `arg:"" name:"siteUrl" help:"Search Console property URL (e.g. https://example.com/ or sc-domain:example.com)"`
+ FeedPath string `arg:"" name:"feedpath" help:"Sitemap URL"`
+}
+
+func (c *SearchConsoleSitemapsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ siteURL := strings.TrimSpace(c.SiteURL)
+ if siteURL == "" {
+ return usage("empty siteUrl")
+ }
+ feedPath := strings.TrimSpace(c.FeedPath)
+ if feedPath == "" {
+ return usage("empty feedpath")
+ }
+
+ if err := dryRunAndConfirmDestructive(ctx, flags, "searchconsole.sitemaps.delete", map[string]any{
+ "site_url": siteURL,
+ "feed_path": feedPath,
+ }, fmt.Sprintf("delete sitemap %s", feedPath)); err != nil {
+ return err
+ }
+
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newSearchConsoleService(ctx, account)
+ if err != nil {
+ return err
+ }
+ if err := svc.Sitemaps.Delete(siteURL, feedPath).Context(ctx).Do(); err != nil {
+ return wrapSearchConsoleError(err)
+ }
+
+ return writeResult(ctx, u,
+ kv("deleted", true),
+ kv("site_url", siteURL),
+ kv("feed_path", feedPath),
+ )
+}
+
+func buildSearchConsoleRequestFromSpec(spec string) (*searchconsoleapi.SearchAnalyticsQueryRequest, error) {
+ b, err := readSearchConsoleRequestBytes(spec)
+ if err != nil {
+ return nil, err
+ }
+
+ var req searchconsoleapi.SearchAnalyticsQueryRequest
+ if err := json.Unmarshal(b, &req); err != nil {
+ return nil, fmt.Errorf("decode search console request: %w", err)
+ }
+
+ if req.RowLimit == 0 {
+ req.RowLimit = defaultSearchConsoleRowLimit
+ }
+ if req.RowLimit < 1 || req.RowLimit > maxSearchConsoleRowLimit {
+ return nil, usagef("request.rowLimit must be between 1 and %d", maxSearchConsoleRowLimit)
+ }
+ if req.StartRow < 0 {
+ return nil, usage("request.startRow must be >= 0")
+ }
+ if err := validateSearchConsoleDateRange(req.StartDate, req.EndDate); err != nil {
+ return nil, err
+ }
+
+ if len(req.Dimensions) > 0 {
+ dimensions, err := normalizeSearchConsoleDimensionList(req.Dimensions)
+ if err != nil {
+ return nil, err
+ }
+ req.Dimensions = dimensions
+ }
+
+ if req.Type == "" && req.SearchType != "" {
+ req.Type = req.SearchType
+ }
+ if req.Type == "" {
+ req.Type = "web"
+ }
+ searchType, err := normalizeSearchConsoleType(req.Type)
+ if err != nil {
+ return nil, err
+ }
+ req.Type = searchType
+ req.SearchType = searchType
+
+ if v := strings.TrimSpace(req.AggregationType); v != "" {
+ aggregation, err := normalizeSearchConsoleAggregation(v)
+ if err != nil {
+ return nil, err
+ }
+ req.AggregationType = aggregation
+ }
+ if v := strings.TrimSpace(req.DataState); v != "" {
+ dataState, err := normalizeSearchConsoleDataState(v)
+ if err != nil {
+ return nil, err
+ }
+ req.DataState = dataState
+ }
+
+ for _, group := range req.DimensionFilterGroups {
+ if group == nil {
+ continue
+ }
+ if strings.TrimSpace(group.GroupType) == "" {
+ group.GroupType = "and"
+ }
+ if !strings.EqualFold(strings.TrimSpace(group.GroupType), "and") {
+ return nil, usagef("invalid request.groupType %q (expected and)", group.GroupType)
+ }
+ group.GroupType = "and"
+ for _, filter := range group.Filters {
+ if filter == nil {
+ continue
+ }
+ dimension, err := normalizeSearchConsoleDimension(filter.Dimension)
+ if err != nil {
+ return nil, err
+ }
+ operator, err := normalizeSearchConsoleFilterOperator(filter.Operator)
+ if err != nil {
+ return nil, err
+ }
+ if strings.TrimSpace(filter.Expression) == "" {
+ return nil, usage("request filter expression cannot be empty")
+ }
+ filter.Dimension = dimension
+ filter.Operator = operator
+ }
+ }
+
+ return &req, nil
+}
+
+func readSearchConsoleRequestBytes(spec string) ([]byte, error) {
+ spec = strings.TrimSpace(spec)
+ switch {
+ case spec == "", spec == "-", strings.HasPrefix(spec, "@"), strings.HasPrefix(spec, "{"), strings.HasPrefix(spec, "["):
+ return resolveInlineOrFileBytes(spec)
+ default:
+ path, err := config.ExpandPath(spec)
+ if err != nil {
+ return nil, err
+ }
+ return os.ReadFile(path) //nolint:gosec // user-provided path
+ }
+}
+
+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 validateSearchConsoleDateRange(from string, to string) error {
+ start, err := time.Parse("2006-01-02", strings.TrimSpace(from))
+ if err != nil {
+ return usage("invalid start date (expected YYYY-MM-DD)")
+ }
+ end, err := time.Parse("2006-01-02", strings.TrimSpace(to))
+ if err != nil {
+ return usage("invalid end date (expected YYYY-MM-DD)")
+ }
+ if end.Before(start) {
+ return usage("--to must be on or after --from")
+ }
+ return nil
+}
+
+func normalizeSearchConsoleType(raw string) (string, error) {
+ v := strings.TrimSpace(raw)
+ switch {
+ case strings.EqualFold(v, "web"):
+ return "web", nil
+ case strings.EqualFold(v, "image"):
+ return "image", nil
+ case strings.EqualFold(v, "video"):
+ return "video", nil
+ case strings.EqualFold(v, "news"):
+ return "news", nil
+ case strings.EqualFold(v, "discover"):
+ return "discover", nil
+ case strings.EqualFold(strings.ReplaceAll(v, "_", ""), "googleNews"),
+ strings.EqualFold(strings.ReplaceAll(v, "-", ""), "googleNews"):
+ return "googleNews", nil
+ case v == "":
+ return "", usage("empty --type")
+ default:
+ return "", usagef("invalid --type %q (expected WEB|IMAGE|VIDEO|NEWS|DISCOVER|GOOGLE_NEWS)", raw)
+ }
+}
+
+func normalizeSearchConsoleAggregation(raw string) (string, error) {
+ v := strings.TrimSpace(raw)
+ switch {
+ case v == "":
+ return "", nil
+ case strings.EqualFold(v, "auto"):
+ return "auto", nil
+ case strings.EqualFold(strings.ReplaceAll(strings.ReplaceAll(v, "_", ""), "-", ""), "byProperty"):
+ return "byProperty", nil
+ case strings.EqualFold(strings.ReplaceAll(strings.ReplaceAll(v, "_", ""), "-", ""), "byPage"):
+ return "byPage", nil
+ case strings.EqualFold(strings.ReplaceAll(strings.ReplaceAll(v, "_", ""), "-", ""), "byNewsShowcasePanel"):
+ return "byNewsShowcasePanel", nil
+ default:
+ return "", usagef("invalid --aggregation %q (expected AUTO|BY_PROPERTY|BY_PAGE|BY_NEWS_SHOWCASE_PANEL)", raw)
+ }
+}
+
+func normalizeSearchConsoleDataState(raw string) (string, error) {
+ v := strings.TrimSpace(raw)
+ switch {
+ case v == "":
+ return "", nil
+ case strings.EqualFold(v, "final"):
+ return "final", nil
+ case strings.EqualFold(v, "all"):
+ return "all", nil
+ case strings.EqualFold(strings.ReplaceAll(v, "-", "_"), "hourly_all"):
+ return "hourly_all", nil
+ default:
+ return "", usagef("invalid --data-state %q (expected FINAL|ALL|HOURLY_ALL)", raw)
+ }
+}
+
+func normalizeSearchConsoleDimensions(raw string) ([]string, error) {
+ parts := splitCommaList(raw)
+ if len(parts) == 0 {
+ return nil, nil
+ }
+ return normalizeSearchConsoleDimensionList(parts)
+}
+
+func normalizeSearchConsoleDimensionList(parts []string) ([]string, error) {
+ out := make([]string, 0, len(parts))
+ for _, part := range parts {
+ v, err := normalizeSearchConsoleDimension(part)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, v)
+ }
+ return out, nil
+}
+
+func normalizeSearchConsoleDimension(raw string) (string, error) {
+ v := strings.TrimSpace(raw)
+ switch {
+ case strings.EqualFold(v, "date"):
+ return "date", nil
+ case strings.EqualFold(v, "query"):
+ return "query", nil
+ case strings.EqualFold(v, "page"):
+ return "page", nil
+ case strings.EqualFold(v, "country"):
+ return "country", nil
+ case strings.EqualFold(v, "device"):
+ return "device", nil
+ case strings.EqualFold(strings.ReplaceAll(strings.ReplaceAll(v, "_", ""), "-", ""), "searchAppearance"):
+ return "searchAppearance", nil
+ case strings.EqualFold(v, "hour"):
+ return "hour", nil
+ case v == "":
+ return "", usage("empty dimension")
+ default:
+ return "", usagef("invalid dimension %q (expected DATE|QUERY|PAGE|COUNTRY|DEVICE|SEARCH_APPEARANCE|HOUR)", raw)
+ }
+}
+
+func parseSearchConsoleFilter(raw string) (*searchconsoleapi.ApiDimensionFilter, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil, usage("empty --filter")
+ }
+
+ first := strings.Index(raw, ":")
+ if first <= 0 {
+ return nil, usagef("invalid --filter %q (expected dimension:operator:expression)", raw)
+ }
+ rest := raw[first+1:]
+ second := strings.Index(rest, ":")
+ if second < 0 {
+ return nil, usagef("invalid --filter %q (expected dimension:operator:expression)", raw)
+ }
+
+ dimension, err := normalizeSearchConsoleDimension(raw[:first])
+ if err != nil {
+ return nil, err
+ }
+ operator, err := normalizeSearchConsoleFilterOperator(rest[:second])
+ if err != nil {
+ return nil, err
+ }
+ expression := strings.TrimSpace(rest[second+1:])
+ if expression == "" {
+ return nil, usagef("invalid --filter %q (expected dimension:operator:expression)", raw)
+ }
+
+ return &searchconsoleapi.ApiDimensionFilter{
+ Dimension: dimension,
+ Operator: operator,
+ Expression: expression,
+ }, nil
+}
+
+func normalizeSearchConsoleFilterOperator(raw string) (string, error) {
+ v := strings.TrimSpace(raw)
+ switch {
+ case v == "":
+ return "", usage("empty filter operator")
+ case strings.EqualFold(v, "equals"):
+ return "equals", nil
+ case strings.EqualFold(strings.ReplaceAll(strings.ReplaceAll(v, "_", ""), "-", ""), "notEquals"):
+ return "notEquals", nil
+ case strings.EqualFold(v, "contains"):
+ return "contains", nil
+ case strings.EqualFold(strings.ReplaceAll(strings.ReplaceAll(v, "_", ""), "-", ""), "notContains"):
+ return "notContains", nil
+ case strings.EqualFold(strings.ReplaceAll(strings.ReplaceAll(v, "_", ""), "-", ""), "includingRegex"):
+ return "includingRegex", nil
+ case strings.EqualFold(strings.ReplaceAll(strings.ReplaceAll(v, "_", ""), "-", ""), "excludingRegex"):
+ return "excludingRegex", nil
+ default:
+ return "", usagef("invalid filter operator %q (expected EQUALS|NOT_EQUALS|CONTAINS|NOT_CONTAINS|INCLUDING_REGEX|EXCLUDING_REGEX)", raw)
+ }
+}
+
+func requestSearchConsoleDimensions(
+ req *searchconsoleapi.SearchAnalyticsQueryRequest,
+ rows []*searchconsoleapi.ApiDataRow,
+) []string {
+ if req != nil && len(req.Dimensions) > 0 {
+ out := make([]string, 0, len(req.Dimensions))
+ for _, dim := range req.Dimensions {
+ out = append(out, strings.ToUpper(strings.TrimSpace(dim)))
+ }
+ return out
+ }
+
+ keyCount := 0
+ for _, row := range rows {
+ if row != nil && len(row.Keys) > keyCount {
+ keyCount = len(row.Keys)
+ }
+ }
+
+ out := make([]string, 0, keyCount)
+ for i := 0; i < keyCount; i++ {
+ out = append(out, "KEY_"+strconv.Itoa(i+1))
+ }
+ return out
+}
+
+func searchConsoleKey(row *searchconsoleapi.ApiDataRow, index int) string {
+ if row == nil || index < 0 || index >= len(row.Keys) {
+ return ""
+ }
+ return row.Keys[index]
+}
+
+func formatSearchConsoleMetric(v float64, decimals int) string {
+ if decimals <= 0 {
+ return strconv.FormatFloat(v, 'f', 0, 64)
+ }
+ return strconv.FormatFloat(v, 'f', decimals, 64)
+}
+
+func formatSearchConsoleSitemapContents(contents []*searchconsoleapi.WmxSitemapContent) string {
+ if len(contents) == 0 {
+ return ""
+ }
+
+ parts := make([]string, 0, len(contents))
+ for _, content := range contents {
+ if content == nil {
+ continue
+ }
+ parts = append(parts, fmt.Sprintf("%s:%d/%d", content.Type, content.Indexed, content.Submitted))
+ }
+ return strings.Join(parts, ",")
+}
+
+func wrapSearchConsoleError(err error) error {
+ var apiErr *gapi.Error
+ if !errors.As(err, &apiErr) {
+ return err
+ }
+ if apiErr.Code != 403 {
+ return err
+ }
+
+ message := strings.ToLower(apiErr.Message)
+ switch {
+ case strings.Contains(message, "accessnotconfigured"), strings.Contains(message, "api has not been used"):
+ return fmt.Errorf("Search Console API is not enabled for this OAuth project. Enable it at https://console.cloud.google.com/apis/api/searchconsole.googleapis.com")
+ case strings.Contains(message, "insufficientpermissions"), strings.Contains(message, "insufficient permission"):
+ return fmt.Errorf("insufficient permissions for Search Console API. Re-authorize with: gog auth add --services searchconsole")
+ default:
+ return err
+ }
+}
diff --git a/internal/cmd/searchconsole_more_test.go b/internal/cmd/searchconsole_more_test.go
new file mode 100644
index 00000000..a9c0fcd4
--- /dev/null
+++ b/internal/cmd/searchconsole_more_test.go
@@ -0,0 +1,310 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/option"
+ searchconsoleapi "google.golang.org/api/searchconsole/v1"
+)
+
+func TestSearchConsoleQueryCmd_BuildRequest(t *testing.T) {
+ cmd := &SearchConsoleQueryCmd{
+ From: "2026-02-01",
+ To: "2026-02-28",
+ Dimensions: "query,page",
+ Type: "web",
+ Aggregation: "by_page",
+ DataState: "final",
+ Max: 250,
+ Offset: 10,
+ Filter: []string{"query:contains:buy shoes", "country:equals:usa"},
+ }
+
+ req, err := cmd.buildRequest()
+ if err != nil {
+ t.Fatalf("buildRequest: %v", err)
+ }
+
+ if req.StartDate != "2026-02-01" || req.EndDate != "2026-02-28" {
+ t.Fatalf("unexpected date range: %s - %s", req.StartDate, req.EndDate)
+ }
+ if req.RowLimit != 250 || req.StartRow != 10 {
+ t.Fatalf("unexpected pagination: limit=%d startRow=%d", req.RowLimit, req.StartRow)
+ }
+ if len(req.Dimensions) != 2 || req.Dimensions[0] != "query" || req.Dimensions[1] != "page" {
+ t.Fatalf("unexpected dimensions: %#v", req.Dimensions)
+ }
+ if req.Type != "web" || req.AggregationType != "byPage" || req.DataState != "final" {
+ t.Fatalf("unexpected query options: %#v", req)
+ }
+ if len(req.DimensionFilterGroups) != 1 || req.DimensionFilterGroups[0].GroupType != "and" {
+ t.Fatalf("unexpected filter groups: %#v", req.DimensionFilterGroups)
+ }
+ if len(req.DimensionFilterGroups[0].Filters) != 2 {
+ t.Fatalf("unexpected filter count: %#v", req.DimensionFilterGroups[0].Filters)
+ }
+ if req.DimensionFilterGroups[0].Filters[0].Dimension != "query" || req.DimensionFilterGroups[0].Filters[0].Operator != "contains" {
+ t.Fatalf("unexpected first filter: %#v", req.DimensionFilterGroups[0].Filters[0])
+ }
+}
+
+func TestSearchConsoleQueryCmd_BuildRequestFromJSON(t *testing.T) {
+ withStdin(t, `{
+ "startDate":"2026-02-01",
+ "endDate":"2026-02-10",
+ "rowLimit":50,
+ "searchType":"IMAGE",
+ "dimensions":["QUERY","search_appearance"],
+ "dimensionFilterGroups":[{"groupType":"AND","filters":[{"dimension":"PAGE","operator":"NOT_CONTAINS","expression":"draft"}]}]
+ }`, func() {
+ cmd := &SearchConsoleQueryCmd{Request: "-"}
+ req, err := cmd.buildRequest()
+ if err != nil {
+ t.Fatalf("buildRequest: %v", err)
+ }
+ if req.RowLimit != 50 {
+ t.Fatalf("unexpected rowLimit: %d", req.RowLimit)
+ }
+ if req.Type != "image" || req.SearchType != "image" {
+ t.Fatalf("unexpected type fields: type=%q searchType=%q", req.Type, req.SearchType)
+ }
+ if len(req.Dimensions) != 2 || req.Dimensions[0] != "query" || req.Dimensions[1] != "searchAppearance" {
+ t.Fatalf("unexpected dimensions: %#v", req.Dimensions)
+ }
+ if len(req.DimensionFilterGroups) != 1 || req.DimensionFilterGroups[0].GroupType != "and" {
+ t.Fatalf("unexpected filter groups: %#v", req.DimensionFilterGroups)
+ }
+ filter := req.DimensionFilterGroups[0].Filters[0]
+ if filter.Dimension != "page" || filter.Operator != "notContains" || filter.Expression != "draft" {
+ t.Fatalf("unexpected filter: %#v", filter)
+ }
+ })
+}
+
+func TestExecute_SearchConsoleSitesGet_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{
+ "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", "get", "sc-domain:example.com",
+ }); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ })
+ })
+
+ var parsed struct {
+ Site struct {
+ SiteURL string `json:"siteUrl"`
+ PermissionLevel string `json:"permissionLevel"`
+ } `json:"site"`
+ }
+ if err := json.Unmarshal([]byte(out), &parsed); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if parsed.Site.SiteURL != "sc-domain:example.com" || parsed.Site.PermissionLevel != "SITE_OWNER" {
+ t.Fatalf("unexpected payload: %#v", parsed)
+ }
+}
+
+func TestExecute_SearchConsoleSearchAnalyticsQuery_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["aggregationType"] != "byPage" || req["dataState"] != "final" || req["type"] != "web" {
+ t.Fatalf("unexpected request payload: %#v", req)
+ }
+ filterGroups, ok := req["dimensionFilterGroups"].([]any)
+ if !ok || len(filterGroups) != 1 {
+ t.Fatalf("unexpected filter groups: %#v", req["dimensionFilterGroups"])
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "responseAggregationType": "BY_PAGE",
+ "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", "searchanalytics", "query", "sc-domain:example.com",
+ "--from", "2026-02-01",
+ "--to", "2026-02-07",
+ "--dimensions", "query,page",
+ "--type", "web",
+ "--aggregation", "by_page",
+ "--data-state", "final",
+ "--filter", "query:contains:gog",
+ "--max", "10",
+ }); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ })
+ })
+
+ var parsed struct {
+ Type string `json:"type"`
+ ResponseAggregationType string `json:"response_aggregation_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.Type != "web" || parsed.ResponseAggregationType != "BY_PAGE" || len(parsed.Rows) != 1 {
+ t.Fatalf("unexpected payload: %#v", parsed)
+ }
+}
+
+func TestExecute_SearchConsoleSitemapsList_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, "/sitemaps")) {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "sitemap": []map[string]any{
+ {
+ "path": "https://example.com/sitemap.xml",
+ "type": "SITEMAP",
+ "isPending": false,
+ "warnings": "1",
+ "errors": "0",
+ "lastSubmitted": "2026-02-01",
+ "lastDownloaded": "2026-02-02",
+ },
+ },
+ })
+ }))
+ 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", "sitemaps", "sc-domain:example.com",
+ }); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ })
+ })
+
+ var parsed struct {
+ Sitemaps []struct {
+ Path string `json:"path"`
+ Type string `json:"type"`
+ } `json:"sitemaps"`
+ }
+ if err := json.Unmarshal([]byte(out), &parsed); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(parsed.Sitemaps) != 1 || parsed.Sitemaps[0].Path != "https://example.com/sitemap.xml" || parsed.Sitemaps[0].Type != "SITEMAP" {
+ t.Fatalf("unexpected payload: %#v", parsed)
+ }
+}
+
+func TestExecute_SearchConsoleSitemapsSubmit_DryRun_JSON(t *testing.T) {
+ out := captureStdout(t, func() {
+ _ = captureStderr(t, func() {
+ if err := Execute([]string{
+ "--json",
+ "--dry-run",
+ "searchconsole", "sitemaps", "submit",
+ "sc-domain:example.com",
+ "https://example.com/sitemap.xml",
+ }); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ })
+ })
+
+ var parsed struct {
+ DryRun bool `json:"dry_run"`
+ Op string `json:"op"`
+ }
+ if err := json.Unmarshal([]byte(out), &parsed); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if !parsed.DryRun || parsed.Op != "searchconsole.sitemaps.submit" {
+ t.Fatalf("unexpected payload: %#v", parsed)
+ }
+}
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 92e001ca..698b0c65 100644
--- a/internal/googleauth/service.go
+++ b/internal/googleauth/service.go
@@ -10,22 +10,24 @@ 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"
- ServiceAdmin Service = "admin"
+ 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"
+ ServiceAdmin Service = "admin"
)
const (
@@ -83,6 +85,8 @@ var serviceOrder = []Service{
ServicePeople,
ServiceForms,
ServiceAppScript,
+ ServiceAnalytics,
+ ServiceSearchConsole,
ServiceGroups,
ServiceKeep,
ServiceAdmin,
@@ -203,6 +207,18 @@ 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"},
+ user: true,
+ apis: []string{"Search Console API"},
+ note: "Search Analytics + sitemap management",
+ },
ServiceGroups: {
scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
user: false,
@@ -551,6 +567,13 @@ func scopesForServiceWithOptions(service Service, opts ScopeOptions) ([]string,
}, nil
}
+ return Scopes(service)
+ case ServiceAnalytics:
+ return Scopes(service)
+ case ServiceSearchConsole:
+ if opts.Readonly {
+ return []string{"https://www.googleapis.com/auth/webmasters.readonly"}, nil
+ }
return Scopes(service)
case ServiceGroups:
return Scopes(service)
diff --git a/internal/googleauth/service_test.go b/internal/googleauth/service_test.go
index 3a664f98..eb7e5525 100644
--- a/internal/googleauth/service_test.go
+++ b/internal/googleauth/service_test.go
@@ -21,8 +21,11 @@ func TestParseService(t *testing.T) {
{"sheets", ServiceSheets},
{"forms", ServiceForms},
{"appscript", ServiceAppScript},
+ {"analytics", ServiceAnalytics},
+ {"searchconsole", ServiceSearchConsole},
{"groups", ServiceGroups},
{"keep", ServiceKeep},
+ {"admin", ServiceAdmin},
}
for _, tt := range tests {
got, err := ParseService(tt.in)
@@ -65,7 +68,7 @@ func TestExtractCodeAndState_Errors(t *testing.T) {
func TestAllServices(t *testing.T) {
svcs := AllServices()
- if len(svcs) != 16 {
+ if len(svcs) != 18 {
t.Fatalf("unexpected: %v", svcs)
}
seen := make(map[Service]bool)
@@ -74,7 +77,26 @@ 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, ServiceAdmin} {
+ for _, want := range []Service{
+ ServiceGmail,
+ ServiceCalendar,
+ ServiceChat,
+ ServiceClassroom,
+ ServiceDrive,
+ ServiceDocs,
+ ServiceSlides,
+ ServiceContacts,
+ ServiceTasks,
+ ServicePeople,
+ ServiceSheets,
+ ServiceForms,
+ ServiceAppScript,
+ ServiceAnalytics,
+ ServiceSearchConsole,
+ ServiceGroups,
+ ServiceKeep,
+ ServiceAdmin,
+ } {
if !seen[want] {
t.Fatalf("missing %q", want)
}
@@ -83,7 +105,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 +120,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 +137,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 +253,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 +289,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 {
@@ -271,6 +310,7 @@ func TestScopesForManageWithOptions_Readonly(t *testing.T) {
"https://www.googleapis.com/auth/tasks",
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/documents",
+ "https://www.googleapis.com/auth/webmasters",
}
for _, nw := range notWant {
if containsScope(scopes, nw) {
@@ -317,6 +357,28 @@ func TestScopes_ServiceKeep_DefaultIsReadonly(t *testing.T) {
}
}
+func TestScopes_ServiceSearchConsole_DefaultIsWritable(t *testing.T) {
+ scopes, err := Scopes(ServiceSearchConsole)
+ if err != nil {
+ t.Fatalf("Scopes: %v", err)
+ }
+
+ if len(scopes) != 1 || scopes[0] != "https://www.googleapis.com/auth/webmasters" {
+ t.Fatalf("unexpected searchconsole scopes: %#v", scopes)
+ }
+}
+
+func TestScopesForServiceWithOptions_SearchConsole_Readonly(t *testing.T) {
+ scopes, err := scopesForServiceWithOptions(ServiceSearchConsole, ScopeOptions{Readonly: true})
+ if err != nil {
+ t.Fatalf("scopesForServiceWithOptions: %v", err)
+ }
+
+ if len(scopes) != 1 || scopes[0] != "https://www.googleapis.com/auth/webmasters.readonly" {
+ t.Fatalf("unexpected searchconsole readonly scopes: %#v", scopes)
+ }
+}
+
func TestScopesForServiceWithOptions_ServiceKeep_Readonly(t *testing.T) {
scopes, err := scopesForServiceWithOptions(ServiceKeep, ScopeOptions{Readonly: true})
if err != nil {