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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
![GitHub Repo Banner](https://ghrb.waren.build/banner?header=gogcli%F0%9F%A7%AD&subheader=Google+in+your+terminal&bg=f3f4f6&color=1f2937&support=true)
<!-- Created with GitHub Repo Banner by Waren Gonzaga: https://ghrb.waren.build -->

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

## Features

Expand All @@ -17,6 +17,7 @@ Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Sli
- **Tasks** - manage tasklists and tasks: get/create/add/update/done/undo/delete/clear, plus repeat schedule materialization with RRULE aliases
- **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
- **Meet** - create/get/update meeting spaces, end active calls, view call history and participants
- **Apps Script** - create/get/bind projects, inspect content, and run functions
- **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
Expand Down Expand Up @@ -88,6 +89,7 @@ Before adding an account, create OAuth2 credentials from Google Cloud Console:
- Google Drive API: https://console.cloud.google.com/apis/api/drive.googleapis.com
- Google Classroom API: https://console.cloud.google.com/apis/api/classroom.googleapis.com
- Google Keep API: https://console.cloud.google.com/apis/api/keep.googleapis.com
- Google Meet API: https://console.cloud.google.com/apis/api/meet.googleapis.com
- People API (Contacts): https://console.cloud.google.com/apis/api/people.googleapis.com
- Google Tasks API: https://console.cloud.google.com/apis/api/tasks.googleapis.com
- Google Sheets API: https://console.cloud.google.com/apis/api/sheets.googleapis.com
Expand Down Expand Up @@ -394,9 +396,11 @@ Service scope matrix (auto-generated; run `go run scripts/gen-auth-services-md.g
| sheets | yes | Sheets API, Drive API | `https://www.googleapis.com/auth/drive`<br>`https://www.googleapis.com/auth/spreadsheets` | Export via Drive |
| people | yes | People API | `profile` | OIDC profile scope |
| forms | yes | Forms API | `https://www.googleapis.com/auth/forms.body`<br>`https://www.googleapis.com/auth/forms.responses.readonly` | |
| meet | yes | Meet REST API | `https://www.googleapis.com/auth/meetings.space.created`<br>`https://www.googleapis.com/auth/meetings.space.readonly`<br>`https://www.googleapis.com/auth/meetings.space.settings` | |
| appscript | yes | Apps Script API | `https://www.googleapis.com/auth/script.projects`<br>`https://www.googleapis.com/auth/script.deployments`<br>`https://www.googleapis.com/auth/script.processes` | |
| 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) |
| admin | no | Admin SDK Directory API | `https://www.googleapis.com/auth/admin.directory.user`<br>`https://www.googleapis.com/auth/admin.directory.group`<br>`https://www.googleapis.com/auth/admin.directory.group.member` | Workspace only; service account with domain-wide delegation required |
<!-- auth-services:end -->

### Service Accounts (Workspace only)
Expand All @@ -408,7 +412,7 @@ In `gog`, service accounts are an **optional auth method** that can be configure
#### 1) Create a Service Account (Google Cloud)

1. Create (or pick) a Google Cloud project.
2. Enable the APIs you’ll use (e.g. Gmail, Calendar, Drive, Sheets, Docs, People, Tasks, Cloud Identity).
2. Enable the APIs you’ll use (e.g. Gmail, Calendar, Drive, Sheets, Docs, People, Tasks, Meet, Cloud Identity).
3. Go to **IAM & Admin → Service Accounts** and create a service account.
4. In the service account details, enable **Domain-wide delegation**.
5. Create a key (**Keys → Add key → Create new key → JSON**) and download the JSON key file.
Expand Down Expand Up @@ -1125,6 +1129,34 @@ gog forms watch renew <formId> <watchId>
gog forms watch delete <formId> <watchId>
```

### Meet

```bash
# Create a meeting space (default: trusted access)
gog meet create
gog meet create --open # create and open in browser
gog meet create --access open # anyone can join
gog meet create --access restricted # invite-only
gog meet create --json # JSON output with full config

# Get meeting details
gog meet get <meeting-code>

# Update meeting settings
gog meet update <meeting-code> --access open
gog meet update <meeting-code> --access restricted

# End an active call
gog meet end <meeting-code>

# List past calls in a meeting
gog meet history <meeting-code>

# Show who was in the most recent call
gog meet participants <meeting-code>
gog meet participants <meeting-code> --conference <id> # specific past call
```

### Apps Script

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

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"google.golang.org/api/meet/v2"
"google.golang.org/api/option"
)

func newTestMeetService(t *testing.T, handler http.Handler) {
t.Helper()

origNew := newMeetService
t.Cleanup(func() { newMeetService = origNew })

srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)

svc, err := meet.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}

newMeetService = func(context.Context, string) (*meet.Service, error) { return svc, nil }
}

func meetSpaceResponse() map[string]any {
return map[string]any{
"name": "spaces/abc123",
"meetingUri": "https://meet.google.com/abc-defg-hij",
"meetingCode": "abc-defg-hij",
"config": map[string]any{
"accessType": "TRUSTED",
"entryPointAccess": "ALL",
},
}
}

func TestExecute_MeetCreate_JSON(t *testing.T) {
newTestMeetService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !(r.URL.Path == "/v2/spaces" && r.Method == http.MethodPost) {
http.NotFound(w, r)
return
}

w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(meetSpaceResponse())
}))

out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "meet", "create"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})

var parsed struct {
Created bool `json:"created"`
MeetingURI string `json:"meeting_uri"`
MeetingCode string `json:"meeting_code"`
}

if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}

if !parsed.Created {
t.Fatal("expected created=true")
}

if parsed.MeetingCode != "abc-defg-hij" {
t.Fatalf("unexpected meeting_code: %q", parsed.MeetingCode)
}

if parsed.MeetingURI != "https://meet.google.com/abc-defg-hij" {
t.Fatalf("unexpected meeting_uri: %q", parsed.MeetingURI)
}
}

func TestExecute_MeetCreate_Text(t *testing.T) {
newTestMeetService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !(r.URL.Path == "/v2/spaces" && r.Method == http.MethodPost) {
http.NotFound(w, r)
return
}

w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(meetSpaceResponse())
}))

out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--plain", "--account", "a@b.com", "meet", "create"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})

if !strings.Contains(out, "meeting_code\tabc-defg-hij") {
t.Fatalf("expected meeting_code in output, got: %q", out)
}

if !strings.Contains(out, "meeting_uri\thttps://meet.google.com/abc-defg-hij") {
t.Fatalf("expected meeting_uri in output, got: %q", out)
}

if !strings.Contains(out, "access\ttrusted") {
t.Fatalf("expected access in output, got: %q", out)
}
}

func TestExecute_MeetCreate_DryRun(t *testing.T) {
newTestMeetService(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("should not call API in dry-run mode")
http.Error(w, "unexpected", http.StatusInternalServerError)
}))

out := captureStdout(t, func() {
_ = captureStderr(t, func() {
err := Execute([]string{"--json", "--dry-run", "--account", "a@b.com", "meet", "create"})
if err != nil && ExitCode(err) != 0 {
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("json parse: %v\nout=%q", err, out)
}

if !parsed.DryRun {
t.Fatal("expected dry_run=true")
}

if parsed.Op != "meet.spaces.create" {
t.Fatalf("unexpected op: %q", parsed.Op)
}
}

func TestExecute_MeetGet_JSON(t *testing.T) {
newTestMeetService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !(r.URL.Path == "/v2/spaces/abc-defg-hij" && r.Method == http.MethodGet) {
http.NotFound(w, r)
return
}

w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(meetSpaceResponse())
}))

out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "meet", "get", "abc-defg-hij"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})

var parsed struct {
MeetingCode string `json:"meeting_code"`
}

if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}

if parsed.MeetingCode != "abc-defg-hij" {
t.Fatalf("unexpected meeting_code: %q", parsed.MeetingCode)
}
}

func TestExecute_MeetHistory_JSON(t *testing.T) {
newTestMeetService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/v2/spaces/abc-defg-hij" && r.Method == http.MethodGet:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(meetSpaceResponse())
case r.URL.Path == "/v2/conferenceRecords" && r.Method == http.MethodGet:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"conferenceRecords": []map[string]any{
{
"name": "conferenceRecords/rec1",
"space": "spaces/abc123",
"startTime": "2026-03-20T10:00:00Z",
"endTime": "2026-03-20T11:00:00Z",
},
},
})
default:
http.NotFound(w, r)
}
}))

out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "meet", "history", "abc-defg-hij"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})

var parsed struct {
MeetingCode string `json:"meeting_code"`
Conferences []struct {
Name string `json:"name"`
} `json:"conferences"`
}

if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}

if parsed.MeetingCode != "abc-defg-hij" {
t.Fatalf("unexpected meeting_code: %q", parsed.MeetingCode)
}

if len(parsed.Conferences) != 1 || parsed.Conferences[0].Name != "conferenceRecords/rec1" {
t.Fatalf("unexpected conferences: %#v", parsed.Conferences)
}
}

func TestExecute_MeetParticipants_JSON(t *testing.T) {
newTestMeetService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/v2/spaces/abc-defg-hij" && r.Method == http.MethodGet:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(meetSpaceResponse())
case r.URL.Path == "/v2/conferenceRecords" && r.Method == http.MethodGet:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"conferenceRecords": []map[string]any{
{
"name": "conferenceRecords/rec1",
"space": "spaces/abc123",
"startTime": "2026-03-20T10:00:00Z",
"endTime": "2026-03-20T11:00:00Z",
},
},
})
case r.URL.Path == "/v2/conferenceRecords/rec1/participants" && r.Method == http.MethodGet:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"participants": []map[string]any{
{
"name": "conferenceRecords/rec1/participants/p1",
"earliestStartTime": "2026-03-20T10:00:00Z",
"latestEndTime": "2026-03-20T11:00:00Z",
"signedinUser": map[string]any{
"displayName": "Dan Wager",
"user": "users/123",
},
},
},
})
default:
http.NotFound(w, r)
}
}))

out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "meet", "participants", "abc-defg-hij"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})

var parsed struct {
MeetingCode string `json:"meeting_code"`
Participants []struct {
SignedinUser struct {
DisplayName string `json:"displayName"`
} `json:"signedinUser"`
} `json:"participants"`
}

if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}

if parsed.MeetingCode != "abc-defg-hij" {
t.Fatalf("unexpected meeting_code: %q", parsed.MeetingCode)
}

if len(parsed.Participants) != 1 || parsed.Participants[0].SignedinUser.DisplayName != "Dan Wager" {
t.Fatalf("unexpected participants: %#v", parsed.Participants)
}
}
Loading