diff --git a/CHANGELOG.md b/CHANGELOG.md index 509272777..a86eb0499 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Meet: add `meet create/get/update/end/history/participants` commands for Google Meet meeting spaces and conference records. (#468) — thanks @regaw-leinad. - Forms: add `forms publish` to publish/unpublish existing forms and return the responder URL for automated form creation flows. (#565 / #564) — thanks @bogdanovich. ### Fixed diff --git a/README.md b/README.md index b15ff9355..8df49d20e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # gogcli `gog` is a script-friendly Google CLI for Gmail, Calendar, Drive, Docs, Sheets, -Slides, Forms, Apps Script, Contacts, Tasks, People, Classroom, Chat, and +Slides, Forms, Meet, Apps Script, Contacts, Tasks, People, Classroom, Chat, and Workspace admin flows. It is built for terminals, shell scripts, CI, and coding agents: @@ -303,7 +303,7 @@ accounts. Common user services: -- Gmail, Calendar, Drive, Docs, Sheets, Slides, Forms, Apps Script +- Gmail, Calendar, Drive, Docs, Sheets, Slides, Forms, Meet, Apps Script - Contacts, People, Tasks, Classroom - Chat for Workspace accounts - Backup and local utility commands @@ -331,6 +331,7 @@ Generated service scope table: | 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` | | | ads | yes | Google Ads API | `https://www.googleapis.com/auth/adwords` | OAuth scope only | | groups | no | Cloud Identity API | `https://www.googleapis.com/auth/cloud-identity.groups.readonly` | Workspace only | diff --git a/docs/commands.generated.md b/docs/commands.generated.md index fea8a1ad9..fffe0de0f 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -374,6 +374,13 @@ Generated from `gog schema --json`. - [`gog logout [flags]`](commands/gog-logout.md) - Remove a stored refresh token (alias for 'auth remove') - [`gog ls (list) [flags]`](commands/gog-ls.md) - List Drive files (alias for 'drive ls') - [`gog me [flags]`](commands/gog-me.md) - Show your profile (alias for 'people me') + - [`gog meet (meeting) [flags]`](commands/gog-meet.md) - Google Meet + - [`gog meet (meeting) create (new) [flags]`](commands/gog-meet-create.md) - Create a meeting space + - [`gog meet (meeting) end (stop) `](commands/gog-meet-end.md) - End active conference + - [`gog meet (meeting) get (info,show) `](commands/gog-meet-get.md) - Get a meeting space + - [`gog meet (meeting) history (calls,past) [flags]`](commands/gog-meet-history.md) - List past calls in a meeting + - [`gog meet (meeting) participants (people,attendees,who) [flags]`](commands/gog-meet-participants.md) - List participants from the latest call + - [`gog meet (meeting) update (edit,set) [flags]`](commands/gog-meet-update.md) - Update space config - [`gog open (browse) [flags]`](commands/gog-open.md) - Print a best-effort web URL for a Google URL/ID (offline) - [`gog people (person) [flags]`](commands/gog-people.md) - Google People - [`gog people (person) get (info,show) `](commands/gog-people-get.md) - Get a user profile by ID diff --git a/docs/commands/README.md b/docs/commands/README.md index 271c7b19f..df2e3b2e6 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2,7 +2,7 @@ Every `gog` command has a generated docs page. The source of truth is the live CLI schema; run `make docs-commands` after changing command names, flags, help text, aliases, or arguments. -Generated pages: 471. +Generated pages: 478. ## Top-level Commands @@ -29,6 +29,7 @@ Generated pages: 471. - [gog logout](gog-logout.md) - Remove a stored refresh token (alias for 'auth remove') - [gog ls](gog-ls.md) - List Drive files (alias for 'drive ls') - [gog me](gog-me.md) - Show your profile (alias for 'people me') +- [gog meet](gog-meet.md) - Google Meet - [gog open](gog-open.md) - Print a best-effort web URL for a Google URL/ID (offline) - [gog people](gog-people.md) - Google People - [gog schema](gog-schema.md) - Machine-readable command/flag schema @@ -417,6 +418,13 @@ Generated pages: 471. - [gog logout](gog-logout.md) - Remove a stored refresh token (alias for 'auth remove') - [gog ls](gog-ls.md) - List Drive files (alias for 'drive ls') - [gog me](gog-me.md) - Show your profile (alias for 'people me') + - [gog meet](gog-meet.md) - Google Meet + - [gog meet create](gog-meet-create.md) - Create a meeting space + - [gog meet end](gog-meet-end.md) - End active conference + - [gog meet get](gog-meet-get.md) - Get a meeting space + - [gog meet history](gog-meet-history.md) - List past calls in a meeting + - [gog meet participants](gog-meet-participants.md) - List participants from the latest call + - [gog meet update](gog-meet-update.md) - Update space config - [gog open](gog-open.md) - Print a best-effort web URL for a Google URL/ID (offline) - [gog people](gog-people.md) - Google People - [gog people get](gog-people-get.md) - Get a user profile by ID diff --git a/docs/commands/gog-auth-add.md b/docs/commands/gog-auth-add.md index 013b0c0c4..16d064439 100644 --- a/docs/commands/gog-auth-add.md +++ b/docs/commands/gog-auth-add.md @@ -44,7 +44,7 @@ gog auth add [flags] | `--remote` | `bool` | | Remote/server-friendly manual flow (print URL, then exchange code) | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | -| `--services` | `string` | user | Services to authorize: user\|all or comma-separated gmail,calendar,chat,classroom,drive,docs,slides,contacts,tasks,sheets,people,forms,appscript,ads (Keep uses service account: gog auth service-account set) | +| `--services` | `string` | user | Services to authorize: user\|all or comma-separated gmail,calendar,chat,classroom,drive,docs,slides,contacts,tasks,sheets,people,forms,meet,appscript,ads (Keep uses service account: gog auth service-account set) | | `--step` | `int` | | Remote auth step: 1=print URL, 2=exchange code | | `--timeout` | `time.Duration` | | Authorization timeout (manual flows default to 5m) | | `-v`
`--verbose` | `bool` | | Enable verbose logging | diff --git a/docs/commands/gog-auth-manage.md b/docs/commands/gog-auth-manage.md index e134eac3f..ace0a6908 100644 --- a/docs/commands/gog-auth-manage.md +++ b/docs/commands/gog-auth-manage.md @@ -36,7 +36,7 @@ gog auth manage (login) [flags] | `--redirect-host` | `string` | | Hostname for OAuth callback; builds https://{host}/oauth2/callback | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | -| `--services` | `string` | user | Services to authorize: user\|all or comma-separated gmail,calendar,chat,classroom,drive,docs,slides,contacts,tasks,sheets,people,forms,appscript,ads (Keep uses service account: gog auth service-account set) | +| `--services` | `string` | user | Services to authorize: user\|all or comma-separated gmail,calendar,chat,classroom,drive,docs,slides,contacts,tasks,sheets,people,forms,meet,appscript,ads (Keep uses service account: gog auth service-account set) | | `--timeout` | `time.Duration` | 10m | Server timeout duration | | `-v`
`--verbose` | `bool` | | Enable verbose logging | | `--version` | `kong.VersionFlag` | | Print version and exit | diff --git a/docs/commands/gog-login.md b/docs/commands/gog-login.md index 44d3db9ff..445090579 100644 --- a/docs/commands/gog-login.md +++ b/docs/commands/gog-login.md @@ -44,7 +44,7 @@ gog login [flags] | `--remote` | `bool` | | Remote/server-friendly manual flow (print URL, then exchange code) | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | -| `--services` | `string` | user | Services to authorize: user\|all or comma-separated gmail,calendar,chat,classroom,drive,docs,slides,contacts,tasks,sheets,people,forms,appscript,ads (Keep uses service account: gog auth service-account set) | +| `--services` | `string` | user | Services to authorize: user\|all or comma-separated gmail,calendar,chat,classroom,drive,docs,slides,contacts,tasks,sheets,people,forms,meet,appscript,ads (Keep uses service account: gog auth service-account set) | | `--step` | `int` | | Remote auth step: 1=print URL, 2=exchange code | | `--timeout` | `time.Duration` | | Authorization timeout (manual flows default to 5m) | | `-v`
`--verbose` | `bool` | | Enable verbose logging | diff --git a/docs/commands/gog-meet-create.md b/docs/commands/gog-meet-create.md new file mode 100644 index 000000000..eba8d8ba8 --- /dev/null +++ b/docs/commands/gog-meet-create.md @@ -0,0 +1,44 @@ +# `gog meet create` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Create a meeting space + +## Usage + +```bash +gog meet (meeting) create (new) [flags] +``` + +## Parent + +- [gog meet](gog-meet.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access`
`--access-type` | `string` | trusted | Access type: open, trusted, or restricted | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript/ads) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `--open`
`--browser` | `bool` | | Open the meeting in a browser after creation | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | + +## See Also + +- [gog meet](gog-meet.md) +- [Command index](README.md) diff --git a/docs/commands/gog-meet-end.md b/docs/commands/gog-meet-end.md new file mode 100644 index 000000000..2836f39f3 --- /dev/null +++ b/docs/commands/gog-meet-end.md @@ -0,0 +1,42 @@ +# `gog meet end` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +End active conference + +## Usage + +```bash +gog meet (meeting) end (stop) +``` + +## Parent + +- [gog meet](gog-meet.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript/ads) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | + +## See Also + +- [gog meet](gog-meet.md) +- [Command index](README.md) diff --git a/docs/commands/gog-meet-get.md b/docs/commands/gog-meet-get.md new file mode 100644 index 000000000..b652a5437 --- /dev/null +++ b/docs/commands/gog-meet-get.md @@ -0,0 +1,42 @@ +# `gog meet get` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Get a meeting space + +## Usage + +```bash +gog meet (meeting) get (info,show) +``` + +## Parent + +- [gog meet](gog-meet.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript/ads) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | + +## See Also + +- [gog meet](gog-meet.md) +- [Command index](README.md) diff --git a/docs/commands/gog-meet-history.md b/docs/commands/gog-meet-history.md new file mode 100644 index 000000000..088600557 --- /dev/null +++ b/docs/commands/gog-meet-history.md @@ -0,0 +1,46 @@ +# `gog meet history` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +List past calls in a meeting + +## Usage + +```bash +gog meet (meeting) history (calls,past) [flags] +``` + +## Parent + +- [gog meet](gog-meet.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript/ads) | +| `--all`
`--all-pages`
`--allpages` | `bool` | | Fetch all pages | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) | +| `--fail-empty`
`--non-empty`
`--require-results` | `bool` | | Exit with code 3 if no results | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--max`
`--limit` | `int` | 20 | Max results | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `--page`
`--cursor` | `string` | | Page token | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | + +## See Also + +- [gog meet](gog-meet.md) +- [Command index](README.md) diff --git a/docs/commands/gog-meet-participants.md b/docs/commands/gog-meet-participants.md new file mode 100644 index 000000000..34494cb8f --- /dev/null +++ b/docs/commands/gog-meet-participants.md @@ -0,0 +1,47 @@ +# `gog meet participants` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +List participants from the latest call + +## Usage + +```bash +gog meet (meeting) participants (people,attendees,who) [flags] +``` + +## Parent + +- [gog meet](gog-meet.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript/ads) | +| `--all`
`--all-pages`
`--allpages` | `bool` | | Fetch all pages | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--conference` | `string` | | Specific conference ID (default: most recent call) | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) | +| `--fail-empty`
`--non-empty`
`--require-results` | `bool` | | Exit with code 3 if no results | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--max`
`--limit` | `int` | 50 | Max results | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `--page`
`--cursor` | `string` | | Page token | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | + +## See Also + +- [gog meet](gog-meet.md) +- [Command index](README.md) diff --git a/docs/commands/gog-meet-update.md b/docs/commands/gog-meet-update.md new file mode 100644 index 000000000..e857b313d --- /dev/null +++ b/docs/commands/gog-meet-update.md @@ -0,0 +1,43 @@ +# `gog meet update` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Update space config + +## Usage + +```bash +gog meet (meeting) update (edit,set) [flags] +``` + +## Parent + +- [gog meet](gog-meet.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access`
`--access-type` | `string` | | Access type: open, trusted, or restricted | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript/ads) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | + +## See Also + +- [gog meet](gog-meet.md) +- [Command index](README.md) diff --git a/docs/commands/gog-meet.md b/docs/commands/gog-meet.md new file mode 100644 index 000000000..635388ceb --- /dev/null +++ b/docs/commands/gog-meet.md @@ -0,0 +1,51 @@ +# `gog meet` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Google Meet + +## Usage + +```bash +gog meet (meeting) [flags] +``` + +## Parent + +- [gog](gog.md) + +## Subcommands + +- [gog meet create](gog-meet-create.md) - Create a meeting space +- [gog meet end](gog-meet-end.md) - End active conference +- [gog meet get](gog-meet-get.md) - Get a meeting space +- [gog meet history](gog-meet-history.md) - List past calls in a meeting +- [gog meet participants](gog-meet-participants.md) - List participants from the latest call +- [gog meet update](gog-meet-update.md) - Update space config + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript/ads) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | + +## See Also + +- [gog](gog.md) +- [Command index](README.md) diff --git a/docs/commands/gog.md b/docs/commands/gog.md index 57798833e..c9c9923a2 100644 --- a/docs/commands/gog.md +++ b/docs/commands/gog.md @@ -39,6 +39,7 @@ gog [flags] - [gog logout](gog-logout.md) - Remove a stored refresh token (alias for 'auth remove') - [gog ls](gog-ls.md) - List Drive files (alias for 'drive ls') - [gog me](gog-me.md) - Show your profile (alias for 'people me') +- [gog meet](gog-meet.md) - Google Meet - [gog open](gog-open.md) - Print a best-effort web URL for a Google URL/ID (offline) - [gog people](gog-people.md) - Google People - [gog schema](gog-schema.md) - Machine-readable command/flag schema diff --git a/internal/cmd/execute_meet_test.go b/internal/cmd/execute_meet_test.go new file mode 100644 index 000000000..915c705db --- /dev/null +++ b/internal/cmd/execute_meet_test.go @@ -0,0 +1,312 @@ +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: + if got, want := r.URL.Query().Get("filter"), `space.name = "spaces/abc123"`; got != want { + t.Fatalf("unexpected filter: %q, want %q", got, want) + } + + 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: + if got, want := r.URL.Query().Get("filter"), `space.name = "spaces/abc123"`; got != want { + t.Fatalf("unexpected filter: %q, want %q", got, want) + } + + 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 ebfbae757..40894f8a1 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 000000000..e948ab75a --- /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 000000000..c8fef20fc --- /dev/null +++ b/internal/cmd/meet_helpers.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "fmt" + "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 +} + +func meetSpaceNameFilter(spaceName string) string { + return fmt.Sprintf("space.name = %q", spaceName) +} + +// 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 000000000..7ef551ee6 --- /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 := meetSpaceNameFilter(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 000000000..3c2a13395 --- /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 := meetSpaceNameFilter(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 000000000..069130d5e --- /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 fb7a92a75..8d44b7a34 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -81,6 +81,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')"` diff --git a/internal/cmd/service_helpers.go b/internal/cmd/service_helpers.go index 811445d2b..75c65c961 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 000000000..01ac60fc0 --- /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 2c8a36a75..e4d3fe653 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" ServiceAds Service = "ads" ServiceGroups Service = "groups" @@ -83,6 +84,7 @@ var serviceOrder = []Service{ ServiceSheets, ServicePeople, ServiceForms, + ServiceMeet, ServiceAppScript, ServiceAds, ServiceGroups, @@ -196,6 +198,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", @@ -551,6 +562,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 5217d897c..a7ed1f663 100644 --- a/internal/googleauth/service_test.go +++ b/internal/googleauth/service_test.go @@ -66,7 +66,7 @@ func TestExtractCodeAndState_Errors(t *testing.T) { func TestAllServices(t *testing.T) { svcs := AllServices() - if len(svcs) != 17 { + if len(svcs) != 18 { t.Fatalf("unexpected: %v", svcs) } seen := make(map[Service]bool) @@ -75,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, ServiceAds, ServiceGroups, ServiceKeep, ServiceAdmin} { + for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceChat, ServiceClassroom, ServiceDrive, ServiceDocs, ServiceSlides, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets, ServiceForms, ServiceMeet, ServiceAppScript, ServiceAds, ServiceGroups, ServiceKeep, ServiceAdmin} { if !seen[want] { t.Fatalf("missing %q", want) } @@ -84,7 +84,7 @@ func TestAllServices(t *testing.T) { func TestUserServices(t *testing.T) { svcs := UserServices() - if len(svcs) != 14 { + if len(svcs) != 15 { t.Fatalf("unexpected: %v", svcs) } @@ -97,7 +97,7 @@ func TestUserServices(t *testing.T) { seenDocs = true case ServiceSlides: seenSlides = true - case ServiceForms, ServiceAppScript, ServiceAds: + case ServiceForms, ServiceMeet, ServiceAppScript, ServiceAds: // expected user services case ServiceKeep: t.Fatalf("unexpected keep in user services") @@ -114,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,ads" + want := "gmail,calendar,chat,classroom,drive,docs,slides,contacts,tasks,sheets,people,forms,meet,appscript,ads" 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 0bac98d84..ea18cc7c9 100755 --- a/scripts/live-test.sh +++ b/scripts/live-test.sh @@ -159,6 +159,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 @@ -187,5 +188,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 000000000..0e4624086 --- /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 +}