diff --git a/README.md b/README.md
index 13fd0f56..12d7e4b9 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, Meet, Apps Script, Contacts, Tasks, People, Admin, Groups (Workspace), and Keep (Workspace-only). JSON-first output, multiple accounts, and flexible auth built in.
## Features
@@ -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
@@ -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
@@ -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`
`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`
`https://www.googleapis.com/auth/forms.responses.readonly` | |
+| meet | yes | Meet REST API | `https://www.googleapis.com/auth/meetings.space.created`
`https://www.googleapis.com/auth/meetings.space.readonly`
`https://www.googleapis.com/auth/meetings.space.settings` | |
| 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` | |
| 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`
`https://www.googleapis.com/auth/admin.directory.group`
`https://www.googleapis.com/auth/admin.directory.group.member` | Workspace only; service account with domain-wide delegation required |
### Service Accounts (Workspace only)
@@ -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.
@@ -1125,6 +1129,34 @@ gog forms watch renew
gog forms watch delete
```
+### 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
+
+# Update meeting settings
+gog meet update --access open
+gog meet update --access restricted
+
+# End an active call
+gog meet end
+
+# List past calls in a meeting
+gog meet history
+
+# Show who was in the most recent call
+gog meet participants
+gog meet participants --conference # specific past call
+```
+
### Apps Script
```bash
diff --git a/internal/cmd/execute_meet_test.go b/internal/cmd/execute_meet_test.go
new file mode 100644
index 00000000..6fc224ff
--- /dev/null
+++ b/internal/cmd/execute_meet_test.go
@@ -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)
+ }
+}
diff --git a/internal/cmd/help_snapshot_test.go b/internal/cmd/help_snapshot_test.go
index 55d8fb19..c57de7b4 100644
--- a/internal/cmd/help_snapshot_test.go
+++ b/internal/cmd/help_snapshot_test.go
@@ -66,6 +66,18 @@ func TestHelpSnapshot_Forms(t *testing.T) {
)
}
+func TestHelpSnapshot_Meet(t *testing.T) {
+ out := captureHelpOutput(t, "meet", "--help")
+ requireHelpContains(t, out,
+ "\n create",
+ "\n get",
+ "\n update",
+ "\n end",
+ "\n history",
+ "\n participants",
+ )
+}
+
func TestHelpSnapshot_Admin(t *testing.T) {
out := captureHelpOutput(t, "admin", "--help")
requireHelpContains(t, out,
diff --git a/internal/cmd/meet.go b/internal/cmd/meet.go
new file mode 100644
index 00000000..e948ab75
--- /dev/null
+++ b/internal/cmd/meet.go
@@ -0,0 +1,213 @@
+package cmd
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "runtime"
+ "strings"
+
+ "google.golang.org/api/meet/v2"
+
+ "github.com/steipete/gogcli/internal/googleapi"
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+// openMeetBrowser opens the meeting URL in the default browser.
+var openMeetBrowser = func(url string) error {
+ var cmd *exec.Cmd
+
+ switch runtime.GOOS {
+ case "darwin":
+ cmd = exec.Command("open", url) //nolint:gosec // executable is fixed; arg is meeting URL
+ case "windows":
+ cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) //nolint:gosec // executable is fixed; arg is meeting URL
+ default:
+ cmd = exec.Command("xdg-open", url) //nolint:gosec // executable is fixed; arg is meeting URL
+ }
+
+ return cmd.Start()
+}
+
+var newMeetService = googleapi.NewMeet
+
+type MeetCmd struct {
+ Create MeetCreateCmd `cmd:"" name:"create" aliases:"new" help:"Create a meeting space"`
+ Get MeetGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a meeting space"`
+ Update MeetUpdateCmd `cmd:"" name:"update" aliases:"edit,set" help:"Update space config"`
+ End MeetEndCmd `cmd:"" name:"end" aliases:"stop" help:"End active conference"`
+ History MeetHistoryCmd `cmd:"" name:"history" aliases:"calls,past" help:"List past calls in a meeting"`
+ Participants MeetParticipantsCmd `cmd:"" name:"participants" aliases:"people,attendees,who" help:"List participants from the latest call"`
+}
+
+// MeetCreateCmd creates a new meeting space.
+type MeetCreateCmd struct {
+ Access string `name:"access" aliases:"access-type" help:"Access type: open, trusted, or restricted" default:"trusted"`
+ EntryPoint string `name:"entry-point" aliases:"entry-point-access" help:"Entry point access: all or creator-only" default:"all" hidden:""`
+ Open bool `name:"open" aliases:"browser" help:"Open the meeting in a browser after creation"`
+}
+
+func (c *MeetCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ accessType, err := parseMeetAccessType(c.Access)
+ if err != nil {
+ return err
+ }
+
+ entryPointAccess, err := parseMeetEntryPointAccess(c.EntryPoint)
+ if err != nil {
+ return err
+ }
+
+ if dryRunErr := dryRunExit(ctx, flags, "meet.spaces.create", map[string]any{
+ "access_type": accessType,
+ "entry_point_access": entryPointAccess,
+ }); dryRunErr != nil {
+ return dryRunErr
+ }
+
+ _, svc, err := requireMeetService(ctx, flags)
+ if err != nil {
+ return wrapMeetError(err)
+ }
+
+ space := &meet.Space{
+ Config: &meet.SpaceConfig{
+ AccessType: accessType,
+ EntryPointAccess: entryPointAccess,
+ },
+ }
+
+ created, err := svc.Spaces.Create(space).Context(ctx).Do()
+ if err != nil {
+ return wrapMeetError(err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
+ "created": true,
+ "name": created.Name,
+ "meeting_uri": created.MeetingUri,
+ "meeting_code": created.MeetingCode,
+ "config": created.Config,
+ }); err != nil {
+ return err
+ }
+
+ return openMeetingIfRequested(c.Open, created.MeetingUri)
+ }
+
+ u := ui.FromContext(ctx)
+ printMeetSpace(u, created)
+
+ return openMeetingIfRequested(c.Open, created.MeetingUri)
+}
+
+// MeetGetCmd gets a meeting space by meeting code.
+type MeetGetCmd struct {
+ MeetingCode string `arg:"" name:"meeting-code" help:"Meeting code (e.g. abc-defg-hij)"`
+}
+
+func (c *MeetGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ spaceName := normalizeMeetSpaceName(c.MeetingCode)
+ if spaceName == "" {
+ return usage("empty meeting code")
+ }
+
+ _, svc, err := requireMeetService(ctx, flags)
+ if err != nil {
+ return wrapMeetError(err)
+ }
+
+ space, err := svc.Spaces.Get(spaceName).Context(ctx).Do()
+ if err != nil {
+ return wrapMeetError(err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
+ "name": space.Name,
+ "meeting_uri": space.MeetingUri,
+ "meeting_code": space.MeetingCode,
+ "config": space.Config,
+ })
+ }
+
+ u := ui.FromContext(ctx)
+ printMeetSpace(u, space)
+
+ return nil
+}
+
+func printMeetSpace(u *ui.UI, space *meet.Space) {
+ if u == nil || space == nil {
+ return
+ }
+
+ u.Out().Printf("meeting_code\t%s", space.MeetingCode)
+ u.Out().Printf("meeting_uri\t%s", space.MeetingUri)
+
+ if space.Config != nil {
+ u.Out().Printf("access\t%s", strings.ToLower(space.Config.AccessType))
+ }
+
+ if space.ActiveConference != nil && space.ActiveConference.ConferenceRecord != "" {
+ u.Out().Printf("active_conference\t%s", space.ActiveConference.ConferenceRecord)
+ }
+}
+
+// normalizeMeetSpaceName accepts either a full resource name ("spaces/xxx")
+// or a bare meeting code ("xxx-yyyy-zzz") and returns a value suitable for
+// the spaces.get API, which accepts both formats.
+func normalizeMeetSpaceName(input string) string {
+ input = strings.TrimSpace(input)
+ if input == "" {
+ return ""
+ }
+
+ if strings.HasPrefix(input, "spaces/") {
+ return input
+ }
+
+ return "spaces/" + input
+}
+
+// resolveMeetSpace resolves a meeting code to the full Space resource.
+// This is needed because some API methods (patch, endActiveConference)
+// require the canonical resource name (e.g. "spaces/KP0uKCifZgYB"),
+// which differs from the meeting code (e.g. "abc-defg-hij").
+func resolveMeetSpace(ctx context.Context, svc *meet.Service, input string) (*meet.Space, error) {
+ return svc.Spaces.Get(normalizeMeetSpaceName(input)).Context(ctx).Do()
+}
+
+func openMeetingIfRequested(shouldOpen bool, meetingURI string) error {
+ if !shouldOpen || strings.TrimSpace(meetingURI) == "" {
+ return nil
+ }
+
+ return openMeetBrowser(meetingURI)
+}
+
+func parseMeetAccessType(s string) (string, error) {
+ switch strings.TrimSpace(strings.ToLower(s)) {
+ case "trusted", "":
+ return "TRUSTED", nil
+ case "open":
+ return "OPEN", nil
+ case "restricted":
+ return "RESTRICTED", nil
+ default:
+ return "", usagef("invalid --access %q (expected open, trusted, or restricted)", s)
+ }
+}
+
+func parseMeetEntryPointAccess(s string) (string, error) {
+ switch strings.TrimSpace(strings.ToLower(s)) {
+ case literalAll, "":
+ return "ALL", nil
+ case "creator-only", "creator_only", "creatoronly":
+ return "CREATOR_APP_ONLY", nil
+ default:
+ return "", usagef("invalid --entry-point %q (expected all or creator-only)", s)
+ }
+}
diff --git a/internal/cmd/meet_helpers.go b/internal/cmd/meet_helpers.go
new file mode 100644
index 00000000..a13ce6d2
--- /dev/null
+++ b/internal/cmd/meet_helpers.go
@@ -0,0 +1,57 @@
+package cmd
+
+import (
+ "strings"
+
+ "google.golang.org/api/meet/v2"
+
+ "github.com/steipete/gogcli/internal/errfmt"
+)
+
+// wrapMeetError provides helpful error messages for common Meet API issues.
+func wrapMeetError(err error) error {
+ if err == nil {
+ return nil
+ }
+
+ errStr := err.Error()
+
+ if strings.Contains(errStr, "accessNotConfigured") ||
+ strings.Contains(errStr, "Meet REST API has not been used") {
+ return errfmt.NewUserFacingError(
+ "Meet REST API is not enabled; enable it at: https://console.developers.google.com/apis/api/meet.googleapis.com/overview",
+ err,
+ )
+ }
+
+ if strings.Contains(errStr, "insufficientPermissions") ||
+ strings.Contains(errStr, "insufficient authentication scopes") {
+ return errfmt.NewUserFacingError(
+ "Insufficient permissions for Meet API; re-authenticate with: gog auth add --services meet",
+ err,
+ )
+ }
+
+ return err
+}
+
+// participantDisplayName extracts a human-readable name from a participant.
+func participantDisplayName(p *meet.Participant) string {
+ if p == nil {
+ return ""
+ }
+
+ if p.SignedinUser != nil && p.SignedinUser.DisplayName != "" {
+ return p.SignedinUser.DisplayName
+ }
+
+ if p.AnonymousUser != nil && p.AnonymousUser.DisplayName != "" {
+ return p.AnonymousUser.DisplayName
+ }
+
+ if p.PhoneUser != nil && p.PhoneUser.DisplayName != "" {
+ return p.PhoneUser.DisplayName
+ }
+
+ return p.Name
+}
diff --git a/internal/cmd/meet_history.go b/internal/cmd/meet_history.go
new file mode 100644
index 00000000..37c44d1d
--- /dev/null
+++ b/internal/cmd/meet_history.go
@@ -0,0 +1,127 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "google.golang.org/api/meet/v2"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+// MeetHistoryCmd lists past conferences (calls) for a meeting.
+type MeetHistoryCmd struct {
+ MeetingCode string `arg:"" name:"meeting-code" help:"Meeting code (e.g. abc-defg-hij)"`
+ Max int `name:"max" aliases:"limit" help:"Max results" default:"20"`
+ 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 *MeetHistoryCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+
+ if strings.TrimSpace(c.MeetingCode) == "" {
+ return usage("empty meeting code")
+ }
+
+ if c.Max <= 0 {
+ return usage("--max must be > 0")
+ }
+
+ _, svc, err := requireMeetService(ctx, flags)
+ if err != nil {
+ return wrapMeetError(err)
+ }
+
+ // Resolve the meeting code to the canonical space name for filtering.
+ space, err := resolveMeetSpace(ctx, svc, c.MeetingCode)
+ if err != nil {
+ return wrapMeetError(err)
+ }
+
+ filter := fmt.Sprintf("space.name=%s", space.Name)
+
+ fetch := func(pageToken string) ([]*meet.ConferenceRecord, string, error) {
+ call := svc.ConferenceRecords.List().
+ PageSize(int64(c.Max)).
+ Filter(filter).
+ Context(ctx)
+ if strings.TrimSpace(pageToken) != "" {
+ call = call.PageToken(pageToken)
+ }
+
+ resp, err := call.Do()
+ if err != nil {
+ return nil, "", wrapMeetError(err)
+ }
+
+ return resp.ConferenceRecords, resp.NextPageToken, nil
+ }
+
+ var records []*meet.ConferenceRecord
+
+ nextPageToken := ""
+
+ if c.All {
+ all, err := collectAllPages(c.Page, fetch)
+ if err != nil {
+ return err
+ }
+
+ records = all
+ } else {
+ var err error
+
+ records, nextPageToken, err = fetch(c.Page)
+ if err != nil {
+ return err
+ }
+ }
+
+ if outfmt.IsJSON(ctx) {
+ if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
+ "meeting_code": c.MeetingCode,
+ "conferences": records,
+ "nextPageToken": nextPageToken,
+ }); err != nil {
+ return err
+ }
+
+ if len(records) == 0 {
+ return failEmptyExit(c.FailEmpty)
+ }
+
+ return nil
+ }
+
+ if len(records) == 0 {
+ u.Err().Printf("No past calls found for %s", c.MeetingCode)
+
+ return failEmptyExit(c.FailEmpty)
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+
+ fmt.Fprintln(w, "START\tEND\tCONFERENCE")
+
+ for _, r := range records {
+ if r == nil {
+ continue
+ }
+
+ fmt.Fprintf(w, "%s\t%s\t%s\n",
+ sanitizeTab(r.StartTime),
+ sanitizeTab(r.EndTime),
+ sanitizeTab(strings.TrimPrefix(r.Name, "conferenceRecords/")),
+ )
+ }
+
+ printNextPageHint(u, nextPageToken)
+
+ return nil
+}
diff --git a/internal/cmd/meet_participants.go b/internal/cmd/meet_participants.go
new file mode 100644
index 00000000..dbc23167
--- /dev/null
+++ b/internal/cmd/meet_participants.go
@@ -0,0 +1,164 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "google.golang.org/api/meet/v2"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+// MeetParticipantsCmd lists participants from the most recent call in a meeting,
+// or from a specific conference if --conference is provided.
+type MeetParticipantsCmd struct {
+ MeetingCode string `arg:"" name:"meeting-code" help:"Meeting code (e.g. abc-defg-hij)"`
+ Conference string `name:"conference" help:"Specific conference ID (default: most recent call)"`
+ Max int `name:"max" aliases:"limit" help:"Max results" 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 *MeetParticipantsCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+
+ if strings.TrimSpace(c.MeetingCode) == "" {
+ return usage("empty meeting code")
+ }
+
+ if c.Max <= 0 {
+ return usage("--max must be > 0")
+ }
+
+ _, svc, err := requireMeetService(ctx, flags)
+ if err != nil {
+ return wrapMeetError(err)
+ }
+
+ // Determine which conference to list participants for.
+ conferenceName, err := resolveConference(ctx, svc, c.MeetingCode, c.Conference)
+ if err != nil {
+ return err
+ }
+
+ fetch := func(pageToken string) ([]*meet.Participant, string, error) {
+ call := svc.ConferenceRecords.Participants.List(conferenceName).
+ PageSize(int64(c.Max)).
+ Context(ctx)
+ if strings.TrimSpace(pageToken) != "" {
+ call = call.PageToken(pageToken)
+ }
+
+ resp, err := call.Do()
+ if err != nil {
+ return nil, "", wrapMeetError(err)
+ }
+
+ return resp.Participants, resp.NextPageToken, nil
+ }
+
+ var participants []*meet.Participant
+
+ nextPageToken := ""
+
+ if c.All {
+ all, err := collectAllPages(c.Page, fetch)
+ if err != nil {
+ return err
+ }
+
+ participants = all
+ } else {
+ var err error
+
+ participants, nextPageToken, err = fetch(c.Page)
+ if err != nil {
+ return err
+ }
+ }
+
+ if outfmt.IsJSON(ctx) {
+ if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
+ "meeting_code": c.MeetingCode,
+ "conference": conferenceName,
+ "participants": participants,
+ "nextPageToken": nextPageToken,
+ }); err != nil {
+ return err
+ }
+
+ if len(participants) == 0 {
+ return failEmptyExit(c.FailEmpty)
+ }
+
+ return nil
+ }
+
+ if len(participants) == 0 {
+ u.Err().Printf("No participants found for %s", c.MeetingCode)
+
+ return failEmptyExit(c.FailEmpty)
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+
+ fmt.Fprintln(w, "DISPLAY_NAME\tJOINED\tLEFT")
+
+ for _, p := range participants {
+ if p == nil {
+ continue
+ }
+
+ fmt.Fprintf(w, "%s\t%s\t%s\n",
+ sanitizeTab(participantDisplayName(p)),
+ sanitizeTab(p.EarliestStartTime),
+ sanitizeTab(p.LatestEndTime),
+ )
+ }
+
+ printNextPageHint(u, nextPageToken)
+
+ return nil
+}
+
+// resolveConference determines the conference record name to use.
+// If an explicit conference ID is provided, it is used directly.
+// Otherwise, the most recent conference for the meeting is looked up.
+func resolveConference(ctx context.Context, svc *meet.Service, meetingCode, conferenceOverride string) (string, error) {
+ if conferenceOverride != "" {
+ name := strings.TrimSpace(conferenceOverride)
+ if !strings.HasPrefix(name, "conferenceRecords/") {
+ name = "conferenceRecords/" + name
+ }
+
+ return name, nil
+ }
+
+ // Look up the most recent conference for this meeting.
+ space, err := resolveMeetSpace(ctx, svc, meetingCode)
+ if err != nil {
+ return "", wrapMeetError(err)
+ }
+
+ filter := fmt.Sprintf("space.name=%s", space.Name)
+
+ resp, err := svc.ConferenceRecords.List().
+ Filter(filter).
+ PageSize(1).
+ Context(ctx).
+ Do()
+ if err != nil {
+ return "", wrapMeetError(err)
+ }
+
+ if len(resp.ConferenceRecords) == 0 {
+ return "", usagef("no past calls found for %s", meetingCode)
+ }
+
+ return resp.ConferenceRecords[0].Name, nil
+}
diff --git a/internal/cmd/meet_update_end.go b/internal/cmd/meet_update_end.go
new file mode 100644
index 00000000..069130d5
--- /dev/null
+++ b/internal/cmd/meet_update_end.go
@@ -0,0 +1,139 @@
+package cmd
+
+import (
+ "context"
+ "os"
+ "strings"
+
+ "google.golang.org/api/meet/v2"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+// MeetUpdateCmd updates the configuration of a meeting space.
+type MeetUpdateCmd struct {
+ MeetingCode string `arg:"" name:"meeting-code" help:"Meeting code (e.g. abc-defg-hij)"`
+ Access string `name:"access" aliases:"access-type" help:"Access type: open, trusted, or restricted"`
+ EntryPoint string `name:"entry-point" aliases:"entry-point-access" help:"Entry point access: all or creator-only" hidden:""`
+}
+
+func (c *MeetUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ if strings.TrimSpace(c.MeetingCode) == "" {
+ return usage("empty meeting code")
+ }
+
+ var updateMask []string
+
+ patch := &meet.Space{Config: &meet.SpaceConfig{}}
+
+ if c.Access != "" {
+ accessType, err := parseMeetAccessType(c.Access)
+ if err != nil {
+ return err
+ }
+
+ patch.Config.AccessType = accessType
+ updateMask = append(updateMask, "config.accessType")
+ }
+
+ if c.EntryPoint != "" {
+ entryPointAccess, err := parseMeetEntryPointAccess(c.EntryPoint)
+ if err != nil {
+ return err
+ }
+
+ patch.Config.EntryPointAccess = entryPointAccess
+ updateMask = append(updateMask, "config.entryPointAccess")
+ }
+
+ if len(updateMask) == 0 {
+ return usage("at least one of --access or --entry-point is required")
+ }
+
+ _, svc, err := requireMeetService(ctx, flags)
+ if err != nil {
+ return wrapMeetError(err)
+ }
+
+ space, err := resolveMeetSpace(ctx, svc, c.MeetingCode)
+ if err != nil {
+ return wrapMeetError(err)
+ }
+
+ if dryRunErr := dryRunExit(ctx, flags, "meet.spaces.patch", map[string]any{
+ "meeting_code": c.MeetingCode,
+ "update_mask": strings.Join(updateMask, ","),
+ "config": patch.Config,
+ }); dryRunErr != nil {
+ return dryRunErr
+ }
+
+ updated, err := svc.Spaces.Patch(space.Name, patch).
+ UpdateMask(strings.Join(updateMask, ",")).
+ Context(ctx).
+ Do()
+ if err != nil {
+ return wrapMeetError(err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
+ "updated": true,
+ "name": updated.Name,
+ "meeting_uri": updated.MeetingUri,
+ "meeting_code": updated.MeetingCode,
+ "config": updated.Config,
+ })
+ }
+
+ u := ui.FromContext(ctx)
+ printMeetSpace(u, updated)
+
+ return nil
+}
+
+// MeetEndCmd ends an active conference in a meeting space.
+type MeetEndCmd struct {
+ MeetingCode string `arg:"" name:"meeting-code" help:"Meeting code (e.g. abc-defg-hij)"`
+}
+
+func (c *MeetEndCmd) Run(ctx context.Context, flags *RootFlags) error {
+ if strings.TrimSpace(c.MeetingCode) == "" {
+ return usage("empty meeting code")
+ }
+
+ _, svc, err := requireMeetService(ctx, flags)
+ if err != nil {
+ return wrapMeetError(err)
+ }
+
+ space, err := resolveMeetSpace(ctx, svc, c.MeetingCode)
+ if err != nil {
+ return wrapMeetError(err)
+ }
+
+ if confirmErr := confirmDestructive(ctx, flags, "end active conference in "+c.MeetingCode); confirmErr != nil {
+ return confirmErr
+ }
+
+ req := &meet.EndActiveConferenceRequest{}
+
+ _, err = svc.Spaces.EndActiveConference(space.Name, req).Context(ctx).Do()
+ if err != nil {
+ return wrapMeetError(err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
+ "ended": true,
+ "meeting_code": c.MeetingCode,
+ })
+ }
+
+ u := ui.FromContext(ctx)
+ u.Out().Printf("ended\ttrue")
+ u.Out().Printf("meeting_code\t%s", c.MeetingCode)
+
+ return nil
+}
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index c898444b..c94ab509 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/meet/appscript)" 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}"`
@@ -78,6 +78,7 @@ type CLI struct {
Keep KeepCmd `cmd:"" help:"Google Keep (Workspace only)"`
Sheets SheetsCmd `cmd:"" aliases:"sheet" help:"Google Sheets"`
Forms FormsCmd `cmd:"" aliases:"form" help:"Google Forms"`
+ Meet MeetCmd `cmd:"" aliases:"meeting" help:"Google Meet"`
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')"`
@@ -333,7 +334,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/Meet/App Script"
}
func helpDescription() string {
diff --git a/internal/cmd/service_helpers.go b/internal/cmd/service_helpers.go
index 811445d2..75c65c96 100644
--- a/internal/cmd/service_helpers.go
+++ b/internal/cmd/service_helpers.go
@@ -8,6 +8,7 @@ import (
"google.golang.org/api/docs/v1"
"google.golang.org/api/drive/v3"
"google.golang.org/api/gmail/v1"
+ "google.golang.org/api/meet/v2"
"google.golang.org/api/sheets/v4"
)
@@ -35,6 +36,10 @@ func requireClassroomService(ctx context.Context, flags *RootFlags) (string, *cl
return requireGoogleService(ctx, flags, newClassroomService)
}
+func requireMeetService(ctx context.Context, flags *RootFlags) (string, *meet.Service, error) {
+ return requireGoogleService(ctx, flags, newMeetService)
+}
+
func requireSheetsService(ctx context.Context, flags *RootFlags) (string, *sheets.Service, error) {
return requireGoogleService(ctx, flags, newSheetsService)
}
diff --git a/internal/googleapi/meet.go b/internal/googleapi/meet.go
new file mode 100644
index 00000000..01ac60fc
--- /dev/null
+++ b/internal/googleapi/meet.go
@@ -0,0 +1,20 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ "google.golang.org/api/meet/v2"
+
+ "github.com/steipete/gogcli/internal/googleauth"
+)
+
+func NewMeet(ctx context.Context, email string) (*meet.Service, error) {
+ if opts, err := optionsForAccount(ctx, googleauth.ServiceMeet, email); err != nil {
+ return nil, fmt.Errorf("meet options: %w", err)
+ } else if svc, err := meet.NewService(ctx, opts...); err != nil {
+ return nil, fmt.Errorf("create meet service: %w", err)
+ } else {
+ return svc, nil
+ }
+}
diff --git a/internal/googleauth/service.go b/internal/googleauth/service.go
index 92e001ca..8b42abf0 100644
--- a/internal/googleauth/service.go
+++ b/internal/googleauth/service.go
@@ -22,6 +22,7 @@ const (
ServicePeople Service = "people"
ServiceSheets Service = "sheets"
ServiceForms Service = "forms"
+ ServiceMeet Service = "meet"
ServiceAppScript Service = "appscript"
ServiceGroups Service = "groups"
ServiceKeep Service = "keep"
@@ -82,6 +83,7 @@ var serviceOrder = []Service{
ServiceSheets,
ServicePeople,
ServiceForms,
+ ServiceMeet,
ServiceAppScript,
ServiceGroups,
ServiceKeep,
@@ -194,6 +196,15 @@ var serviceInfoByService = map[Service]serviceInfo{
user: true,
apis: []string{"Forms API"},
},
+ ServiceMeet: {
+ scopes: []string{
+ "https://www.googleapis.com/auth/meetings.space.created",
+ "https://www.googleapis.com/auth/meetings.space.readonly",
+ "https://www.googleapis.com/auth/meetings.space.settings",
+ },
+ user: true,
+ apis: []string{"Meet REST API"},
+ },
ServiceAppScript: {
scopes: []string{
"https://www.googleapis.com/auth/script.projects",
@@ -543,6 +554,12 @@ func scopesForServiceWithOptions(service Service, opts ScopeOptions) ([]string,
formBodyScope,
"https://www.googleapis.com/auth/forms.responses.readonly",
}, nil
+ case ServiceMeet:
+ if opts.Readonly {
+ return []string{"https://www.googleapis.com/auth/meetings.space.readonly"}, nil
+ }
+
+ return Scopes(service)
case ServiceAppScript:
if opts.Readonly {
return []string{
diff --git a/internal/googleauth/service_test.go b/internal/googleauth/service_test.go
index 3a664f98..26b027ae 100644
--- a/internal/googleauth/service_test.go
+++ b/internal/googleauth/service_test.go
@@ -20,6 +20,7 @@ func TestParseService(t *testing.T) {
{"people", ServicePeople},
{"sheets", ServiceSheets},
{"forms", ServiceForms},
+ {"meet", ServiceMeet},
{"appscript", ServiceAppScript},
{"groups", ServiceGroups},
{"keep", ServiceKeep},
@@ -65,7 +66,7 @@ func TestExtractCodeAndState_Errors(t *testing.T) {
func TestAllServices(t *testing.T) {
svcs := AllServices()
- if len(svcs) != 16 {
+ if len(svcs) != 17 {
t.Fatalf("unexpected: %v", svcs)
}
seen := make(map[Service]bool)
@@ -74,7 +75,7 @@ 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, ServiceMeet, ServiceAppScript, ServiceGroups, ServiceKeep, ServiceAdmin} {
if !seen[want] {
t.Fatalf("missing %q", want)
}
@@ -83,7 +84,7 @@ func TestAllServices(t *testing.T) {
func TestUserServices(t *testing.T) {
svcs := UserServices()
- if len(svcs) != 13 {
+ if len(svcs) != 14 {
t.Fatalf("unexpected: %v", svcs)
}
@@ -113,7 +114,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,meet,appscript"
if got := UserServiceCSV(); got != want {
t.Fatalf("unexpected user services csv: %q", got)
}
diff --git a/scripts/live-test.sh b/scripts/live-test.sh
index 9b35b0dd..60b61f94 100755
--- a/scripts/live-test.sh
+++ b/scripts/live-test.sh
@@ -158,6 +158,7 @@ source "$ROOT_DIR/scripts/live-tests/contacts.sh"
source "$ROOT_DIR/scripts/live-tests/people.sh"
source "$ROOT_DIR/scripts/live-tests/workspace.sh"
source "$ROOT_DIR/scripts/live-tests/classroom.sh"
+source "$ROOT_DIR/scripts/live-tests/meet.sh"
ensure_test_account
@@ -186,5 +187,6 @@ run_contacts_tests
run_people_tests
run_workspace_tests
run_classroom_tests
+run_meet_tests
echo "Live tests complete."
diff --git a/scripts/live-tests/meet.sh b/scripts/live-tests/meet.sh
new file mode 100755
index 00000000..0e462408
--- /dev/null
+++ b/scripts/live-tests/meet.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+run_meet_tests() {
+ if skip "meet"; then
+ echo "==> meet (skipped)"
+ return 0
+ fi
+
+ local space_json meeting_code
+ echo "==> meet create"
+ space_json=$(gog meet create --json)
+ meeting_code=$(echo "$space_json" | "$PY" -c "import sys,json; print(json.load(sys.stdin)['meeting_code'])")
+ [ -n "$meeting_code" ] || { echo "Failed to parse meeting code" >&2; exit 1; }
+
+ run_required "meet" "meet get" gog meet get "$meeting_code" --json >/dev/null
+ run_required "meet" "meet update" gog meet update "$meeting_code" --access open --json >/dev/null
+ run_required "meet" "meet history" gog meet history "$meeting_code" --json --max 1 >/dev/null
+}