diff --git a/.agents/skills/gog-sheets/SKILL.md b/.agents/skills/gog-sheets/SKILL.md index 2f0ccd865..b079630e3 100644 --- a/.agents/skills/gog-sheets/SKILL.md +++ b/.agents/skills/gog-sheets/SKILL.md @@ -38,6 +38,7 @@ gog --readonly --account user@example.com sheets get SHEET_ID 'Sheet1!A1:D20' -- | `copy` | Copy a Google Sheet | | `copy-paste` | Copy a range's values/formulas/format to another range (tiles to fill down/across) | | `create` | Create a new spreadsheet | +| `datasource` | Inspect Connected Sheets data sources and extracts | | `delete-dimension` | Delete rows or columns while preserving intersecting tables | | `delete-tab` | Delete a tab/sheet from a spreadsheet (use --force to skip confirmation) | | `export` | Export a Google Sheet (pdf\|xlsx\|csv) via Drive | diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f238b2ab..796a17090 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Sheets: add read-only Connected Sheets discovery, full data-source descriptions and execution status, plus bounded reads for anchored data-source tables (extracts) behind an explicitly opted-in BigQuery scope. (#938) — thanks @ryo-touch. + ## v0.36.0 - 2026-08-13 **Highlight:** Gmail grows full draft-side reply workflows — reply, reply-all, diff --git a/docs/commands.generated.md b/docs/commands.generated.md index e1435c166..c1b5998fb 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -567,6 +567,13 @@ Generated from `gog schema --json`. - [`gog sheets (sheet) copy (cp,duplicate) [flags]`](commands/gog-sheets-copy.md) - Copy a Google Sheet - [`gog sheets (sheet) copy-paste (fill,copy-range) <spreadsheetId> <source> <dest> [flags]`](commands/gog-sheets-copy-paste.md) - Copy a range's values/formulas/format to another range (tiles to fill down/across) - [`gog sheets (sheet) create (new) <title> [flags]`](commands/gog-sheets-create.md) - Create a new spreadsheet + - [`gog sheets (sheet) datasource (data-source,data-sources,connected-sheets) <command>`](commands/gog-sheets-datasource.md) - Inspect Connected Sheets data sources and extracts + - [`gog sheets (sheet) datasource (data-source,data-sources,connected-sheets) describe (get,show,info) <spreadsheetId> <dataSourceId>`](commands/gog-sheets-datasource-describe.md) - Describe a Connected Sheets data source + - [`gog sheets (sheet) datasource (data-source,data-sources,connected-sheets) list <spreadsheetId>`](commands/gog-sheets-datasource-list.md) - List Connected Sheets data sources + - [`gog sheets (sheet) datasource (data-source,data-sources,connected-sheets) table (tables,extract,extracts) <command>`](commands/gog-sheets-datasource-table.md) - Inspect Connected Sheets data-source tables (extracts) + - [`gog sheets (sheet) datasource (data-source,data-sources,connected-sheets) table (tables,extract,extracts) describe (get,show,info) <spreadsheetId> <anchor>`](commands/gog-sheets-datasource-table-describe.md) - Describe a data-source table at an anchor cell + - [`gog sheets (sheet) datasource (data-source,data-sources,connected-sheets) table (tables,extract,extracts) list <spreadsheetId> [flags]`](commands/gog-sheets-datasource-table-list.md) - List data-source tables (extracts) + - [`gog sheets (sheet) datasource (data-source,data-sources,connected-sheets) table (tables,extract,extracts) read (values) <spreadsheetId> <anchor> [flags]`](commands/gog-sheets-datasource-table-read.md) - Read values from a data-source table - [`gog sheets (sheet) delete-dimension (delete-dim) --dimension=STRING <spreadsheetId> <rangeOrSheet> [flags]`](commands/gog-sheets-delete-dimension.md) - Delete rows or columns while preserving intersecting tables - [`gog sheets (sheet) delete-tab (delete-sheet) <spreadsheetId> <tabName>`](commands/gog-sheets-delete-tab.md) - Delete a tab/sheet from a spreadsheet (use --force to skip confirmation) - [`gog sheets (sheet) export (download,dl) <spreadsheetId> [flags]`](commands/gog-sheets-export.md) - Export a Google Sheet (pdf|xlsx|csv) via Drive diff --git a/docs/commands/README.md b/docs/commands/README.md index 3ec57c491..92c7d3fc0 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: 712. +Generated pages: 719. ## Top-level Commands @@ -620,6 +620,13 @@ Generated pages: 712. - [gog sheets copy](gog-sheets-copy.md) - Copy a Google Sheet - [gog sheets copy-paste](gog-sheets-copy-paste.md) - Copy a range's values/formulas/format to another range (tiles to fill down/across) - [gog sheets create](gog-sheets-create.md) - Create a new spreadsheet + - [gog sheets datasource](gog-sheets-datasource.md) - Inspect Connected Sheets data sources and extracts + - [gog sheets datasource describe](gog-sheets-datasource-describe.md) - Describe a Connected Sheets data source + - [gog sheets datasource list](gog-sheets-datasource-list.md) - List Connected Sheets data sources + - [gog sheets datasource table](gog-sheets-datasource-table.md) - Inspect Connected Sheets data-source tables (extracts) + - [gog sheets datasource table describe](gog-sheets-datasource-table-describe.md) - Describe a data-source table at an anchor cell + - [gog sheets datasource table list](gog-sheets-datasource-table-list.md) - List data-source tables (extracts) + - [gog sheets datasource table read](gog-sheets-datasource-table-read.md) - Read values from a data-source table - [gog sheets delete-dimension](gog-sheets-delete-dimension.md) - Delete rows or columns while preserving intersecting tables - [gog sheets delete-tab](gog-sheets-delete-tab.md) - Delete a tab/sheet from a spreadsheet (use --force to skip confirmation) - [gog sheets export](gog-sheets-export.md) - Export a Google Sheet (pdf|xlsx|csv) via Drive diff --git a/docs/commands/gog-sheets-datasource-describe.md b/docs/commands/gog-sheets-datasource-describe.md new file mode 100644 index 000000000..8ad50942c --- /dev/null +++ b/docs/commands/gog-sheets-datasource-describe.md @@ -0,0 +1,46 @@ +# `gog sheets datasource describe` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Describe a Connected Sheets data source + +## Usage + +```bash +gog sheets (sheet) datasource (data-source,data-sources,connected-sheets) describe (get,show,info) <spreadsheetId> <dataSourceId> +``` + +## Parent + +- [gog sheets datasource](gog-sheets-datasource.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`<br>`--account`<br>`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands | +| `--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`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`<br>`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog sheets datasource](gog-sheets-datasource.md) +- [Command index](README.md) diff --git a/docs/commands/gog-sheets-datasource-list.md b/docs/commands/gog-sheets-datasource-list.md new file mode 100644 index 000000000..98671796c --- /dev/null +++ b/docs/commands/gog-sheets-datasource-list.md @@ -0,0 +1,46 @@ +# `gog sheets datasource list` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +List Connected Sheets data sources + +## Usage + +```bash +gog sheets (sheet) datasource (data-source,data-sources,connected-sheets) list <spreadsheetId> +``` + +## Parent + +- [gog sheets datasource](gog-sheets-datasource.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`<br>`--account`<br>`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands | +| `--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`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`<br>`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog sheets datasource](gog-sheets-datasource.md) +- [Command index](README.md) diff --git a/docs/commands/gog-sheets-datasource-table-describe.md b/docs/commands/gog-sheets-datasource-table-describe.md new file mode 100644 index 000000000..2b9713550 --- /dev/null +++ b/docs/commands/gog-sheets-datasource-table-describe.md @@ -0,0 +1,46 @@ +# `gog sheets datasource table describe` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Describe a data-source table at an anchor cell + +## Usage + +```bash +gog sheets (sheet) datasource (data-source,data-sources,connected-sheets) table (tables,extract,extracts) describe (get,show,info) <spreadsheetId> <anchor> +``` + +## Parent + +- [gog sheets datasource table](gog-sheets-datasource-table.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`<br>`--account`<br>`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands | +| `--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`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`<br>`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog sheets datasource table](gog-sheets-datasource-table.md) +- [Command index](README.md) diff --git a/docs/commands/gog-sheets-datasource-table-list.md b/docs/commands/gog-sheets-datasource-table-list.md new file mode 100644 index 000000000..6370deabe --- /dev/null +++ b/docs/commands/gog-sheets-datasource-table-list.md @@ -0,0 +1,47 @@ +# `gog sheets datasource table list` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +List data-source tables (extracts) + +## Usage + +```bash +gog sheets (sheet) datasource (data-source,data-sources,connected-sheets) table (tables,extract,extracts) list <spreadsheetId> [flags] +``` + +## Parent + +- [gog sheets datasource table](gog-sheets-datasource-table.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`<br>`--account`<br>`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--data-source-id` | `string` | | Only tables belonging to this data source ID | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`<br>`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog sheets datasource table](gog-sheets-datasource-table.md) +- [Command index](README.md) diff --git a/docs/commands/gog-sheets-datasource-table-read.md b/docs/commands/gog-sheets-datasource-table-read.md new file mode 100644 index 000000000..fdf9bb7ab --- /dev/null +++ b/docs/commands/gog-sheets-datasource-table-read.md @@ -0,0 +1,48 @@ +# `gog sheets datasource table read` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Read values from a data-source table + +## Usage + +```bash +gog sheets (sheet) datasource (data-source,data-sources,connected-sheets) table (tables,extract,extracts) read (values) <spreadsheetId> <anchor> [flags] +``` + +## Parent + +- [gog sheets datasource table](gog-sheets-datasource-table.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`<br>`--account`<br>`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands | +| `--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`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--max-rows` | `int` | 1000 | Maximum data rows to read (header row is returned separately) | +| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes | +| `--render` | `string` | FORMATTED_VALUE | Value render option: FORMATTED_VALUE, UNFORMATTED_VALUE, or FORMULA | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`<br>`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog sheets datasource table](gog-sheets-datasource-table.md) +- [Command index](README.md) diff --git a/docs/commands/gog-sheets-datasource-table.md b/docs/commands/gog-sheets-datasource-table.md new file mode 100644 index 000000000..c98335118 --- /dev/null +++ b/docs/commands/gog-sheets-datasource-table.md @@ -0,0 +1,52 @@ +# `gog sheets datasource table` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Inspect Connected Sheets data-source tables (extracts) + +## Usage + +```bash +gog sheets (sheet) datasource (data-source,data-sources,connected-sheets) table (tables,extract,extracts) <command> +``` + +## Parent + +- [gog sheets datasource](gog-sheets-datasource.md) + +## Subcommands + +- [gog sheets datasource table describe](gog-sheets-datasource-table-describe.md) - Describe a data-source table at an anchor cell +- [gog sheets datasource table list](gog-sheets-datasource-table-list.md) - List data-source tables (extracts) +- [gog sheets datasource table read](gog-sheets-datasource-table-read.md) - Read values from a data-source table + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`<br>`--account`<br>`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands | +| `--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`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`<br>`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog sheets datasource](gog-sheets-datasource.md) +- [Command index](README.md) diff --git a/docs/commands/gog-sheets-datasource.md b/docs/commands/gog-sheets-datasource.md new file mode 100644 index 000000000..ce92dd808 --- /dev/null +++ b/docs/commands/gog-sheets-datasource.md @@ -0,0 +1,52 @@ +# `gog sheets datasource` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Inspect Connected Sheets data sources and extracts + +## Usage + +```bash +gog sheets (sheet) datasource (data-source,data-sources,connected-sheets) <command> +``` + +## Parent + +- [gog sheets](gog-sheets.md) + +## Subcommands + +- [gog sheets datasource describe](gog-sheets-datasource-describe.md) - Describe a Connected Sheets data source +- [gog sheets datasource list](gog-sheets-datasource-list.md) - List Connected Sheets data sources +- [gog sheets datasource table](gog-sheets-datasource-table.md) - Inspect Connected Sheets data-source tables (extracts) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`<br>`--account`<br>`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands | +| `--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`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`<br>`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog sheets](gog-sheets.md) +- [Command index](README.md) diff --git a/docs/commands/gog-sheets.md b/docs/commands/gog-sheets.md index 682c4d3f0..15236d650 100644 --- a/docs/commands/gog-sheets.md +++ b/docs/commands/gog-sheets.md @@ -26,6 +26,7 @@ gog sheets (sheet) <command> [flags] - [gog sheets copy](gog-sheets-copy.md) - Copy a Google Sheet - [gog sheets copy-paste](gog-sheets-copy-paste.md) - Copy a range's values/formulas/format to another range (tiles to fill down/across) - [gog sheets create](gog-sheets-create.md) - Create a new spreadsheet +- [gog sheets datasource](gog-sheets-datasource.md) - Inspect Connected Sheets data sources and extracts - [gog sheets delete-dimension](gog-sheets-delete-dimension.md) - Delete rows or columns while preserving intersecting tables - [gog sheets delete-tab](gog-sheets-delete-tab.md) - Delete a tab/sheet from a spreadsheet (use --force to skip confirmation) - [gog sheets export](gog-sheets-export.md) - Export a Google Sheet (pdf|xlsx|csv) via Drive diff --git a/docs/index.md b/docs/index.md index 34fef8210..c6e7fdc7f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -62,6 +62,7 @@ live binary. - **Selecting private Photos media.** [Photos Picker](photos-picker.md) keeps access limited to items the user explicitly chooses. - **Managing YouTube.** [YouTube](youtube.md) covers API-key reads, account OAuth, subscriptions, playlists, and mutation safety. - **Grouping Docs edits atomically.** [Google Docs request batches](docs-batch.md) covers persisted, revision-locked request queues and explicit recovery modes. +- **Inspecting BigQuery-backed Sheets.** [Connected Sheets](sheets-connected.md) covers opt-in authorization, data-source status, and bounded extract reads. - **Verifying real API behavior.** [Live testing](live-testing.md) covers the dedicated-account smoke suite, cleanup, retries, and optional infrastructure. - **Looking up a flag.** The [Command Index](commands/) has a generated page for every subcommand. - **Comparing Discovery-driven CLIs.** Reproduce the [gog and gws evaluation](gws-comparison.md) instead of relying on a stale feature table. diff --git a/docs/sheets-connected.md b/docs/sheets-connected.md new file mode 100644 index 000000000..e7ac555a2 --- /dev/null +++ b/docs/sheets-connected.md @@ -0,0 +1,57 @@ +--- +title: Connected Sheets +description: Inspect BigQuery and Looker data sources, execution status, and anchored Connected Sheets extracts without mutating the spreadsheet. +--- + +# Connected Sheets + +`gog sheets datasource` provides a read-only view of external data sources in a spreadsheet. It can list sources, return the complete source specification and execution status, discover anchored data-source tables (called extracts in the Sheets editor), and read a bounded number of extract rows. + +## Authorize BigQuery access explicitly + +Google requires `https://www.googleapis.com/auth/bigquery.readonly` whenever a Sheets API response contains BigQuery Connected Sheets data. Ordinary `sheets` authorization intentionally does not request that scope. + +Re-authorize the account with its existing service selection, append the scope, and force the consent screen. For a Sheets-only token: + +```bash +gog auth add you@example.com \ + --services sheets \ + --extra-scopes https://www.googleapis.com/auth/bigquery.readonly \ + --force-consent +``` + +If the account token covers more services, keep that existing `--services` selection instead of narrowing it to `sheets`. Domain-wide delegated service accounts must also have both the Sheets read-only and BigQuery read-only scopes approved by the Workspace administrator; the Connected Sheets client requests only those two scopes. + +Looker data sources reuse the account's existing Looker link, but the same commands and output shape apply. + +## List and describe data sources + +```bash +gog --readonly --account you@example.com \ + sheets datasource list <spreadsheetId> + +gog --readonly --account you@example.com \ + sheets datasource describe <spreadsheetId> <dataSourceId> --json +``` + +`list` returns a compact source summary joined with its `DATA_SOURCE` sheet and current `DataExecutionStatus`. It deliberately does not print custom SQL. `describe` returns the complete API `DataSource`, associated sheet properties, execution status, and refresh schedules, so its JSON can include a BigQuery raw query and error messages. + +## Discover and read extracts + +A data-source table has no standalone ID in the Sheets API. Its definition lives only on the table's top-left anchor cell, so the CLI identifies extracts with an A1 anchor that includes the sheet name. + +```bash +gog --readonly --account you@example.com \ + sheets datasource table list <spreadsheetId> + +gog --readonly --account you@example.com \ + sheets datasource table describe <spreadsheetId> 'Extracts!B3' --json + +gog --readonly --account you@example.com \ + sheets datasource table read <spreadsheetId> 'Extracts!B3' \ + --max-rows 250 --json +``` + +Table discovery asks `spreadsheets.get` only for anchor definitions and related sheet metadata. `read` then uses the selected table's configured columns and row limit to construct a bounded `spreadsheets.values.get` request. The default is at most 1,000 data rows plus the header; JSON output reports `truncated: true` when the configured extract can contain more rows. Use `--render FORMULA` or `--render UNFORMATTED_VALUE` when formatted display values are not suitable. + +These commands do not create, update, refresh, or delete data sources. Connected Sheets refresh remains asynchronous, and `list` or `describe` can be polled until `state` is `SUCCEEDED` or `FAILED`. diff --git a/internal/app/runtime.go b/internal/app/runtime.go index 88a947002..a4c4f53c7 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -117,6 +117,7 @@ type Services struct { PhotosPicker PhotosPickerServiceFactory SearchConsole SearchConsoleServiceFactory Sheets SheetsServiceFactory + ConnectedSheets SheetsServiceFactory SitesDrive DriveServiceFactory Slides SlidesServiceFactory Tasks TasksServiceFactory diff --git a/internal/cmd/runtime_services.go b/internal/cmd/runtime_services.go index caf43bf7f..33e035ce8 100644 --- a/internal/cmd/runtime_services.go +++ b/internal/cmd/runtime_services.go @@ -122,6 +122,9 @@ func composeRuntimeGoogleServices(runtime *app.Runtime, factory googleapi.Factor if services.Sheets == nil { services.Sheets = factory.Sheets } + if services.ConnectedSheets == nil { + services.ConnectedSheets = factory.ConnectedSheets + } if services.SitesDrive == nil { services.SitesDrive = factory.SitesDrive } @@ -385,6 +388,22 @@ func sheetsService(ctx context.Context, account string) (*sheets.Service, error) return runtime.Services.Sheets(ctx, account) } +func connectedSheetsService(ctx context.Context, account string) (*sheets.Service, error) { + runtime, err := runtimeWithService(ctx, "Connected Sheets") + if err != nil { + return nil, serviceError(err, "Connected Sheets") + } + if runtime.Services.ConnectedSheets != nil { + return runtime.Services.ConnectedSheets(ctx, account) + } + // Tests and embedders predating the dedicated factory can inject the regular + // Sheets client. Production-managed runtimes always install ConnectedSheets. + if runtime.Services.Sheets != nil { + return runtime.Services.Sheets(ctx, account) + } + return nil, serviceError(nil, "Connected Sheets") +} + func sitesDriveService(ctx context.Context, account string) (*drive.Service, error) { runtime, err := runtimeWithService(ctx, "sites drive") if err != nil || runtime.Services.SitesDrive == nil { diff --git a/internal/cmd/service_helpers.go b/internal/cmd/service_helpers.go index 5372b0225..ae46f3eb3 100644 --- a/internal/cmd/service_helpers.go +++ b/internal/cmd/service_helpers.go @@ -54,6 +54,10 @@ func requireSheetsService(ctx context.Context, flags *RootFlags) (string, *sheet return requireGoogleService(ctx, flags, sheetsService) } +func requireConnectedSheetsService(ctx context.Context, flags *RootFlags) (string, *sheets.Service, error) { + return requireGoogleService(ctx, flags, connectedSheetsService) +} + func requireGoogleService[T any](ctx context.Context, flags *RootFlags, newService func(context.Context, string) (*T, error)) (string, *T, error) { account, err := requireAccount(flags) if err != nil { diff --git a/internal/cmd/sheets.go b/internal/cmd/sheets.go index 6a625d648..8250ad961 100644 --- a/internal/cmd/sheets.go +++ b/internal/cmd/sheets.go @@ -57,6 +57,7 @@ type SheetsCmd struct { Links SheetsLinksCmd `cmd:"" name:"links" aliases:"hyperlinks" help:"Get or set cell hyperlinks"` Named SheetsNamedRangesCmd `cmd:"" name:"named-ranges" aliases:"namedranges,nr" help:"Manage named ranges"` Table SheetsTableCmd `cmd:"" name:"table" aliases:"tables" help:"Manage Google Sheets tables"` + DataSource SheetsDataSourceCmd `cmd:"" name:"datasource" aliases:"data-source,data-sources,connected-sheets" help:"Inspect Connected Sheets data sources and extracts"` Metadata SheetsMetadataCmd `cmd:"" name:"metadata" aliases:"info" help:"Get spreadsheet metadata"` Raw SheetsRawCmd `cmd:"" name:"raw" help:"Dump raw Google Sheets API response as JSON (Spreadsheets.Get; lossless; for scripting and LLM consumption)"` Create SheetsCreateCmd `cmd:"" name:"create" aliases:"new" help:"Create a new spreadsheet"` @@ -752,11 +753,13 @@ func (c *SheetsMetadataCmd) Run(ctx context.Context, flags *RootFlags) error { if outfmt.IsJSON(ctx) { return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{ - "spreadsheetId": resp.SpreadsheetId, - "title": resp.Properties.Title, - "locale": resp.Properties.Locale, - "timeZone": resp.Properties.TimeZone, - "sheets": resp.Sheets, + "spreadsheetId": resp.SpreadsheetId, + "title": resp.Properties.Title, + "locale": resp.Properties.Locale, + "timeZone": resp.Properties.TimeZone, + "sheets": resp.Sheets, + "dataSources": resp.DataSources, + "dataSourceSchedules": resp.DataSourceSchedules, }) } diff --git a/internal/cmd/sheets_datasource.go b/internal/cmd/sheets_datasource.go new file mode 100644 index 000000000..f3f6c01cb --- /dev/null +++ b/internal/cmd/sheets_datasource.go @@ -0,0 +1,585 @@ +package cmd + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" + + "google.golang.org/api/googleapi" + "google.golang.org/api/sheets/v4" + + "github.com/openclaw/gogcli/internal/errfmt" + "github.com/openclaw/gogcli/internal/outfmt" + "github.com/openclaw/gogcli/internal/sheetsa1" + "github.com/openclaw/gogcli/internal/ui" +) + +const connectedSheetsBigQueryScope = "https://www.googleapis.com/auth/bigquery.readonly" + +type SheetsDataSourceCmd struct { + List SheetsDataSourceListCmd `cmd:"" default:"withargs" help:"List Connected Sheets data sources"` + Describe SheetsDataSourceDescribeCmd `cmd:"" name:"describe" aliases:"get,show,info" help:"Describe a Connected Sheets data source"` + Table SheetsDataSourceTableCmd `cmd:"" name:"table" aliases:"tables,extract,extracts" help:"Inspect Connected Sheets data-source tables (extracts)"` +} + +type SheetsDataSourceTableCmd struct { + List SheetsDataSourceTableListCmd `cmd:"" default:"withargs" help:"List data-source tables (extracts)"` + Describe SheetsDataSourceTableDescribeCmd `cmd:"" name:"describe" aliases:"get,show,info" help:"Describe a data-source table at an anchor cell"` + Read SheetsDataSourceTableReadCmd `cmd:"" name:"read" aliases:"values" help:"Read values from a data-source table"` +} + +type SheetsDataSourceListCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` +} + +type SheetsDataSourceDescribeCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + DataSourceID string `arg:"" name:"dataSourceId" help:"Data source ID"` +} + +type SheetsDataSourceTableListCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + DataSourceID string `name:"data-source-id" help:"Only tables belonging to this data source ID"` +} + +type SheetsDataSourceTableDescribeCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + Anchor string `arg:"" name:"anchor" help:"Table anchor cell including sheet name (for example Extract!A1)"` +} + +type SheetsDataSourceTableReadCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + Anchor string `arg:"" name:"anchor" help:"Table anchor cell including sheet name (for example Extract!A1)"` + MaxRows int `name:"max-rows" help:"Maximum data rows to read (header row is returned separately)" default:"1000"` + ValueRenderOption string `name:"render" help:"Value render option: FORMATTED_VALUE, UNFORMATTED_VALUE, or FORMULA" default:"FORMATTED_VALUE" enum:"FORMATTED_VALUE,UNFORMATTED_VALUE,FORMULA"` +} + +type sheetsDataSourceItem struct { + DataSourceID string `json:"dataSourceId"` + SheetID int64 `json:"sheetId"` + SheetTitle string `json:"sheetTitle,omitempty"` + Provider string `json:"provider"` + ProjectID string `json:"projectId,omitempty"` + Source string `json:"source,omitempty"` + State string `json:"state,omitempty"` + LastRefreshTime string `json:"lastRefreshTime,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` +} + +type sheetsDataSourceTableItem struct { + Anchor string `json:"anchor"` + SheetID int64 `json:"sheetId"` + SheetTitle string `json:"sheetTitle"` + DataSourceID string `json:"dataSourceId"` + ColumnSelectionType string `json:"columnSelectionType,omitempty"` + Columns []string `json:"columns"` + RowLimit int64 `json:"rowLimit,omitempty"` + State string `json:"state,omitempty"` + LastRefreshTime string `json:"lastRefreshTime,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + Table *sheets.DataSourceTable `json:"dataSourceTable,omitempty"` + row int + column int +} + +type sheetsDataSourceSnapshot struct { + DataSources []*sheets.DataSource + Schedules []*sheets.DataSourceRefreshSchedule + Sheets []*sheets.Sheet +} + +func (c *SheetsDataSourceListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID)) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + + account, svc, err := requireConnectedSheetsService(ctx, flags) + if err != nil { + return err + } + snapshot, err := fetchSheetsDataSourceSnapshot(ctx, svc, spreadsheetID) + if err != nil { + return wrapConnectedSheetsReadError(err, account) + } + + items := make([]sheetsDataSourceItem, 0, len(snapshot.DataSources)) + for _, source := range snapshot.DataSources { + if source != nil { + items = append(items, sheetsDataSourceToItem(source, snapshot.Sheets)) + } + } + sort.Slice(items, func(i, j int) bool { return items[i].DataSourceID < items[j].DataSourceID }) + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{ + "spreadsheetId": spreadsheetID, + "dataSources": items, + }) + } + if len(items) == 0 { + u.Err().Println("No Connected Sheets data sources") + return nil + } + return outfmt.WriteTable(ctx, stdoutWriter(ctx), items, sheetsDataSourceColumns()) +} + +func (c *SheetsDataSourceDescribeCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID)) + dataSourceID := strings.TrimSpace(c.DataSourceID) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + if dataSourceID == "" { + return usage("empty dataSourceId") + } + + account, svc, err := requireConnectedSheetsService(ctx, flags) + if err != nil { + return err + } + snapshot, err := fetchSheetsDataSourceSnapshot(ctx, svc, spreadsheetID) + if err != nil { + return wrapConnectedSheetsReadError(err, account) + } + source := findSheetsDataSource(snapshot.DataSources, dataSourceID) + if source == nil { + return usagef("data source %q not found", dataSourceID) + } + sheet := findSheetsDataSourceSheet(snapshot.Sheets, source) + + if outfmt.IsJSON(ctx) { + var properties *sheets.SheetProperties + if sheet != nil { + properties = sheet.Properties + } + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{ + "spreadsheetId": spreadsheetID, + "dataSource": source, + "sheet": properties, + "dataSourceSchedules": snapshot.Schedules, + }) + } + + item := sheetsDataSourceToItem(source, snapshot.Sheets) + u.Out().Linef("dataSourceId\t%s", item.DataSourceID) + u.Out().Linef("provider\t%s", item.Provider) + u.Out().Linef("sheetId\t%d", item.SheetID) + u.Out().Linef("sheetTitle\t%s", item.SheetTitle) + u.Out().Linef("projectId\t%s", item.ProjectID) + u.Out().Linef("source\t%s", item.Source) + u.Out().Linef("state\t%s", item.State) + u.Out().Linef("lastRefreshTime\t%s", item.LastRefreshTime) + if item.ErrorCode != "" || item.ErrorMessage != "" { + u.Out().Linef("error\t%s\t%s", item.ErrorCode, item.ErrorMessage) + } + return nil +} + +func (c *SheetsDataSourceTableListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID)) + dataSourceID := strings.TrimSpace(c.DataSourceID) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + + account, svc, err := requireConnectedSheetsService(ctx, flags) + if err != nil { + return err + } + resp, err := fetchSheetsDataSourceTables(ctx, svc, spreadsheetID, "") + if err != nil { + return wrapConnectedSheetsReadError(err, account) + } + items := collectSheetsDataSourceTables(resp) + if dataSourceID != "" { + filtered := items[:0] + for _, item := range items { + if item.DataSourceID == dataSourceID { + filtered = append(filtered, item) + } + } + items = filtered + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{ + "spreadsheetId": spreadsheetID, + "tables": items, + }) + } + if len(items) == 0 { + u.Err().Println("No Connected Sheets data-source tables") + return nil + } + return outfmt.WriteTable(ctx, stdoutWriter(ctx), items, sheetsDataSourceTableColumns()) +} + +func (c *SheetsDataSourceTableDescribeCmd) Run(ctx context.Context, flags *RootFlags) error { + spreadsheetID, anchor, err := validateSheetsDataSourceTableArgs(c.SpreadsheetID, c.Anchor) + if err != nil { + return err + } + account, svc, err := requireConnectedSheetsService(ctx, flags) + if err != nil { + return err + } + resp, err := fetchSheetsDataSourceTables(ctx, svc, spreadsheetID, anchor) + if err != nil { + return wrapConnectedSheetsReadError(err, account) + } + item := findSheetsDataSourceTable(collectSheetsDataSourceTables(resp), anchor) + if item == nil { + return usagef("data-source table not found at %q", anchor) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{ + "spreadsheetId": spreadsheetID, + "anchor": item.Anchor, + "sheetId": item.SheetID, + "sheetTitle": item.SheetTitle, + "dataSourceTable": item.Table, + }) + } + + u := ui.FromContext(ctx) + u.Out().Linef("anchor\t%s", item.Anchor) + u.Out().Linef("dataSourceId\t%s", item.DataSourceID) + u.Out().Linef("selection\t%s", item.ColumnSelectionType) + u.Out().Linef("columns\t%s", strings.Join(item.Columns, ",")) + u.Out().Linef("rowLimit\t%d", item.RowLimit) + u.Out().Linef("state\t%s", item.State) + u.Out().Linef("lastRefreshTime\t%s", item.LastRefreshTime) + if item.ErrorCode != "" || item.ErrorMessage != "" { + u.Out().Linef("error\t%s\t%s", item.ErrorCode, item.ErrorMessage) + } + return nil +} + +func (c *SheetsDataSourceTableReadCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + spreadsheetID, anchor, err := validateSheetsDataSourceTableArgs(c.SpreadsheetID, c.Anchor) + if err != nil { + return err + } + if c.MaxRows <= 0 { + return usage("--max-rows must be greater than 0") + } + + account, svc, err := requireConnectedSheetsService(ctx, flags) + if err != nil { + return err + } + resp, err := fetchSheetsDataSourceTables(ctx, svc, spreadsheetID, anchor) + if err != nil { + return wrapConnectedSheetsReadError(err, account) + } + item := findSheetsDataSourceTable(collectSheetsDataSourceTables(resp), anchor) + if item == nil { + return usagef("data-source table not found at %q", anchor) + } + columnCount := len(item.Columns) + if columnCount == 0 { + columnCount = dataSourceColumnCount(resp.Sheets, item.DataSourceID) + } + if columnCount == 0 { + return usagef("cannot determine columns for data-source table at %q", anchor) + } + + rows := c.MaxRows + truncated := item.RowLimit == 0 || item.RowLimit > int64(rows) + if item.RowLimit > 0 && item.RowLimit < int64(rows) { + rows = int(item.RowLimit) + truncated = false + } + end := sheetsa1.FormatCell(item.SheetTitle, item.row+rows, item.column+columnCount-1) + readRange := item.Anchor + ":" + strings.TrimPrefix(end, sheetsa1.SheetPrefix(item.SheetTitle)) + valuesCall := svc.Spreadsheets.Values.Get(spreadsheetID, readRange). + MajorDimension("ROWS"). + ValueRenderOption(c.ValueRenderOption). + Context(ctx) + values, err := valuesCall.Do() + if err != nil { + return wrapConnectedSheetsReadError(err, account) + } + rowsOut := values.Values + if rowsOut == nil { + rowsOut = [][]interface{}{} + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{ + "spreadsheetId": spreadsheetID, + "anchor": item.Anchor, + "range": values.Range, + "dataSourceId": item.DataSourceID, + "state": item.State, + "truncated": truncated, + "values": rowsOut, + }) + } + if len(rowsOut) == 0 { + u.Err().Println("No data found") + return nil + } + w, flush := tableWriter(ctx) + defer flush() + for _, row := range rowsOut { + cells := make([]string, len(row)) + for i, cell := range row { + cells[i] = fmt.Sprintf("%v", cell) + } + fmt.Fprintln(w, strings.Join(cells, "\t")) + } + return nil +} + +func fetchSheetsDataSourceSnapshot(ctx context.Context, svc *sheets.Service, spreadsheetID string) (*sheetsDataSourceSnapshot, error) { + resp, err := svc.Spreadsheets.Get(spreadsheetID). + Fields(googleapi.Field("spreadsheetId,dataSources,dataSourceSchedules,sheets(properties(sheetId,title,index,sheetType,gridProperties(rowCount,columnCount),dataSourceSheetProperties))")). + Context(ctx). + Do() + if err != nil { + return nil, err + } + return &sheetsDataSourceSnapshot{ + DataSources: resp.DataSources, + Schedules: resp.DataSourceSchedules, + Sheets: resp.Sheets, + }, nil +} + +func fetchSheetsDataSourceTables(ctx context.Context, svc *sheets.Service, spreadsheetID, anchor string) (*sheets.Spreadsheet, error) { + call := svc.Spreadsheets.Get(spreadsheetID). + IncludeGridData(true). + Fields(googleapi.Field("spreadsheetId,sheets(properties(sheetId,title,index,sheetType,gridProperties(rowCount,columnCount),dataSourceSheetProperties),data(startRow,startColumn,rowData(values(dataSourceTable))))")). + Context(ctx) + if anchor != "" { + call = call.Ranges(anchor) + } + return call.Do() +} + +func sheetsDataSourceToItem(source *sheets.DataSource, allSheets []*sheets.Sheet) sheetsDataSourceItem { + item := sheetsDataSourceItem{ + DataSourceID: source.DataSourceId, + SheetID: source.SheetId, + Provider: "UNKNOWN", + } + if source.Spec != nil { + switch { + case source.Spec.BigQuery != nil: + item.Provider = "BIGQUERY" + item.ProjectID = source.Spec.BigQuery.ProjectId + switch { + case source.Spec.BigQuery.TableSpec != nil: + table := source.Spec.BigQuery.TableSpec + tableProject := table.TableProjectId + if tableProject == "" { + tableProject = item.ProjectID + } + item.Source = strings.Join([]string{tableProject, table.DatasetId, table.TableId}, ".") + case source.Spec.BigQuery.QuerySpec != nil: + item.Source = "query" + } + case source.Spec.Looker != nil: + item.Provider = "LOOKER" + item.Source = strings.Join([]string{source.Spec.Looker.InstanceUri, source.Spec.Looker.Model, source.Spec.Looker.Explore}, "/") + } + } + if sheet := findSheetsDataSourceSheet(allSheets, source); sheet != nil && sheet.Properties != nil { + item.SheetTitle = sheet.Properties.Title + status := sheet.Properties.DataSourceSheetProperties + if status != nil { + setSheetsDataExecutionStatus(&item.State, &item.LastRefreshTime, &item.ErrorCode, &item.ErrorMessage, status.DataExecutionStatus) + } + } + return item +} + +func findSheetsDataSource(sources []*sheets.DataSource, dataSourceID string) *sheets.DataSource { + for _, source := range sources { + if source != nil && source.DataSourceId == dataSourceID { + return source + } + } + return nil +} + +func findSheetsDataSourceSheet(allSheets []*sheets.Sheet, source *sheets.DataSource) *sheets.Sheet { + if source == nil { + return nil + } + for _, sheet := range allSheets { + if sheet == nil || sheet.Properties == nil { + continue + } + properties := sheet.Properties + if properties.SheetId == source.SheetId { + return sheet + } + if properties.DataSourceSheetProperties != nil && properties.DataSourceSheetProperties.DataSourceId == source.DataSourceId { + return sheet + } + } + return nil +} + +func collectSheetsDataSourceTables(resp *sheets.Spreadsheet) []sheetsDataSourceTableItem { + items := make([]sheetsDataSourceTableItem, 0) + if resp == nil { + return items + } + for _, sheet := range resp.Sheets { + if sheet == nil || sheet.Properties == nil { + continue + } + for _, grid := range sheet.Data { + if grid == nil { + continue + } + for rowOffset, rowData := range grid.RowData { + if rowData == nil { + continue + } + for columnOffset, cell := range rowData.Values { + if cell == nil || cell.DataSourceTable == nil { + continue + } + row := int(grid.StartRow) + rowOffset + 1 + column := int(grid.StartColumn) + columnOffset + 1 + items = append(items, sheetsDataSourceTableToItem(sheet.Properties, row, column, cell.DataSourceTable)) + } + } + } + } + sort.Slice(items, func(i, j int) bool { + if items[i].SheetID != items[j].SheetID { + return items[i].SheetID < items[j].SheetID + } + if items[i].row != items[j].row { + return items[i].row < items[j].row + } + return items[i].column < items[j].column + }) + return items +} + +func sheetsDataSourceTableToItem(properties *sheets.SheetProperties, row, column int, table *sheets.DataSourceTable) sheetsDataSourceTableItem { + columns := make([]string, 0, len(table.Columns)) + for _, reference := range table.Columns { + if reference != nil { + columns = append(columns, reference.Name) + } + } + item := sheetsDataSourceTableItem{ + Anchor: sheetsa1.FormatCell(properties.Title, row, column), + SheetID: properties.SheetId, + SheetTitle: properties.Title, + DataSourceID: table.DataSourceId, + ColumnSelectionType: table.ColumnSelectionType, + Columns: columns, + RowLimit: table.RowLimit, + Table: table, + row: row, + column: column, + } + setSheetsDataExecutionStatus(&item.State, &item.LastRefreshTime, &item.ErrorCode, &item.ErrorMessage, table.DataExecutionStatus) + return item +} + +func setSheetsDataExecutionStatus(state, lastRefreshTime, errorCode, errorMessage *string, status *sheets.DataExecutionStatus) { + if status == nil { + return + } + *state = status.State + *lastRefreshTime = status.LastRefreshTime + *errorCode = status.ErrorCode + *errorMessage = status.ErrorMessage +} + +func validateSheetsDataSourceTableArgs(rawSpreadsheetID, rawAnchor string) (string, string, error) { + spreadsheetID := normalizeGoogleID(strings.TrimSpace(rawSpreadsheetID)) + if spreadsheetID == "" { + return "", "", usage("empty spreadsheetId") + } + parsed, err := sheetsa1.Parse(cleanRange(strings.TrimSpace(rawAnchor))) + if err != nil { + return "", "", usagef("invalid anchor: %v", err) + } + if parsed.SheetName == "" || parsed.StartRow == 0 || parsed.StartCol == 0 || parsed.StartRow != parsed.EndRow || parsed.StartCol != parsed.EndCol { + return "", "", usage("anchor must be one cell and include a sheet name (for example Extract!A1)") + } + return spreadsheetID, sheetsa1.FormatCell(parsed.SheetName, parsed.StartRow, parsed.StartCol), nil +} + +func findSheetsDataSourceTable(items []sheetsDataSourceTableItem, anchor string) *sheetsDataSourceTableItem { + for i := range items { + if items[i].Anchor == anchor { + return &items[i] + } + } + return nil +} + +func dataSourceColumnCount(allSheets []*sheets.Sheet, dataSourceID string) int { + for _, sheet := range allSheets { + if sheet == nil || sheet.Properties == nil || sheet.Properties.DataSourceSheetProperties == nil { + continue + } + properties := sheet.Properties.DataSourceSheetProperties + if properties.DataSourceId == dataSourceID { + return len(properties.Columns) + } + } + return 0 +} + +func wrapConnectedSheetsReadError(err error, account string) error { + if err == nil { + return nil + } + errText := strings.ToLower(err.Error()) + if !strings.Contains(errText, "insufficient authentication scopes") && + !strings.Contains(errText, "access_token_scope_insufficient") && + !strings.Contains(errText, "insufficientpermissions") { + return err + } + return errfmt.NewUserFacingError( + fmt.Sprintf("Connected Sheets BigQuery reads require OAuth scope %s; re-authenticate while preserving this account's existing --services selection and append --extra-scopes %s --force-consent (for a Sheets-only token: gog auth add %s --services sheets --extra-scopes %s --force-consent)", connectedSheetsBigQueryScope, connectedSheetsBigQueryScope, account, connectedSheetsBigQueryScope), + err, + ) +} + +func sheetsDataSourceColumns() []outfmt.Column[sheetsDataSourceItem] { + return []outfmt.Column[sheetsDataSourceItem]{ + {Header: "DATA_SOURCE_ID", Value: func(item sheetsDataSourceItem) string { return item.DataSourceID }}, + {Header: "PROVIDER", Value: func(item sheetsDataSourceItem) string { return item.Provider }}, + {Header: "SHEET", Value: func(item sheetsDataSourceItem) string { return item.SheetTitle }}, + {Header: "SOURCE", Value: func(item sheetsDataSourceItem) string { return item.Source }}, + {Header: "STATE", Value: func(item sheetsDataSourceItem) string { return item.State }}, + {Header: "LAST_REFRESH", Value: func(item sheetsDataSourceItem) string { return item.LastRefreshTime }}, + {Header: "ERROR", Value: func(item sheetsDataSourceItem) string { return item.ErrorCode }}, + } +} + +func sheetsDataSourceTableColumns() []outfmt.Column[sheetsDataSourceTableItem] { + return []outfmt.Column[sheetsDataSourceTableItem]{ + {Header: "ANCHOR", Value: func(item sheetsDataSourceTableItem) string { return item.Anchor }}, + {Header: "DATA_SOURCE_ID", Value: func(item sheetsDataSourceTableItem) string { return item.DataSourceID }}, + {Header: "SELECTION", Value: func(item sheetsDataSourceTableItem) string { return item.ColumnSelectionType }}, + {Header: "COLUMNS", Value: func(item sheetsDataSourceTableItem) string { return strconv.Itoa(len(item.Columns)) }}, + {Header: "ROW_LIMIT", Value: func(item sheetsDataSourceTableItem) string { return strconv.FormatInt(item.RowLimit, 10) }}, + {Header: "STATE", Value: func(item sheetsDataSourceTableItem) string { return item.State }}, + {Header: "LAST_REFRESH", Value: func(item sheetsDataSourceTableItem) string { return item.LastRefreshTime }}, + {Header: "ERROR", Value: func(item sheetsDataSourceTableItem) string { return item.ErrorCode }}, + } +} diff --git a/internal/cmd/sheets_datasource_test.go b/internal/cmd/sheets_datasource_test.go new file mode 100644 index 000000000..cf2eadef3 --- /dev/null +++ b/internal/cmd/sheets_datasource_test.go @@ -0,0 +1,205 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +func TestSheetsDataSourceListAndDescribe(t *testing.T) { + srv, queries := newConnectedSheetsFixtureServer(t) + defer srv.Close() + svc := newSheetsServiceFromServer(t, srv) + + listResult := executeWithSheetsTestService(t, []string{ + "--json", "--account", "services@openclaw.org", + "sheets", "datasource", "list", "connected1", + }, svc) + if listResult.err != nil { + t.Fatalf("list data sources: %v", listResult.err) + } + var list struct { + SpreadsheetID string `json:"spreadsheetId"` + DataSources []struct { + DataSourceID string `json:"dataSourceId"` + Provider string `json:"provider"` + Source string `json:"source"` + State string `json:"state"` + ErrorCode string `json:"errorCode"` + } `json:"dataSources"` + } + if err := json.Unmarshal([]byte(listResult.stdout), &list); err != nil { + t.Fatalf("decode list JSON: %v\n%s", err, listResult.stdout) + } + if list.SpreadsheetID != "connected1" || len(list.DataSources) != 2 { + t.Fatalf("unexpected list: %#v", list) + } + if list.DataSources[0].DataSourceID != "ds-query" || list.DataSources[0].State != "FAILED" || list.DataSources[0].ErrorCode != "ENGINE" { + t.Fatalf("query data source summary = %#v", list.DataSources[0]) + } + if list.DataSources[1].Provider != "BIGQUERY" || list.DataSources[1].Source != "bigquery-public-data.samples.shakespeare" { + t.Fatalf("table data source summary = %#v", list.DataSources[1]) + } + if strings.Contains(listResult.stdout, "SELECT corpus") { + t.Fatalf("list output should not expose raw query text: %s", listResult.stdout) + } + + describeResult := executeWithSheetsTestService(t, []string{ + "--json", "--account", "services@openclaw.org", + "sheets", "datasource", "describe", "connected1", "ds-query", + }, svc) + if describeResult.err != nil { + t.Fatalf("describe data source: %v", describeResult.err) + } + if !strings.Contains(describeResult.stdout, `"rawQuery": "SELECT corpus`) || + !strings.Contains(describeResult.stdout, `"sheetType": "DATA_SOURCE"`) || + !strings.Contains(describeResult.stdout, `"dataSourceSchedules"`) { + t.Fatalf("describe output missing full source/sheet/schedule detail: %s", describeResult.stdout) + } + if len(*queries) < 2 || !strings.Contains((*queries)[0], "dataSources") || !strings.Contains((*queries)[0], "dataSourceSheetProperties") { + t.Fatalf("unexpected field mask queries: %#v", *queries) + } +} + +func TestSheetsDataSourceTableListDescribeAndRead(t *testing.T) { + srv, queries := newConnectedSheetsFixtureServer(t) + defer srv.Close() + svc := newSheetsServiceFromServer(t, srv) + + listResult := executeWithSheetsTestService(t, []string{ + "--json", "--account", "services@openclaw.org", + "sheets", "datasource", "table", "list", "connected1", "--data-source-id", "ds-table", + }, svc) + if listResult.err != nil { + t.Fatalf("list data-source tables: %v", listResult.err) + } + if !strings.Contains(listResult.stdout, `"anchor": "Extracts!B3"`) || + !strings.Contains(listResult.stdout, `"rowLimit": 5`) || + !strings.Contains(listResult.stdout, `"state": "SUCCEEDED"`) { + t.Fatalf("unexpected table list: %s", listResult.stdout) + } + + describeResult := executeWithSheetsTestService(t, []string{ + "--json", "--account", "services@openclaw.org", + "sheets", "datasource", "table", "describe", "connected1", "Extracts!B3", + }, svc) + if describeResult.err != nil { + t.Fatalf("describe data-source table: %v", describeResult.err) + } + if !strings.Contains(describeResult.stdout, `"columnSelectionType": "SELECTED"`) || + !strings.Contains(describeResult.stdout, `"dataSourceId": "ds-table"`) { + t.Fatalf("unexpected table description: %s", describeResult.stdout) + } + + readResult := executeWithSheetsTestService(t, []string{ + "--json", "--account", "services@openclaw.org", + "sheets", "datasource", "table", "read", "connected1", "Extracts!B3", "--max-rows", "3", + }, svc) + if readResult.err != nil { + t.Fatalf("read data-source table: %v", readResult.err) + } + var read struct { + Anchor string `json:"anchor"` + Range string `json:"range"` + DataSourceID string `json:"dataSourceId"` + Truncated bool `json:"truncated"` + Values [][]interface{} `json:"values"` + } + if err := json.Unmarshal([]byte(readResult.stdout), &read); err != nil { + t.Fatalf("decode read JSON: %v\n%s", err, readResult.stdout) + } + if read.Anchor != "Extracts!B3" || read.Range != "Extracts!B3:C6" || read.DataSourceID != "ds-table" || !read.Truncated || len(read.Values) != 4 { + t.Fatalf("unexpected table read: %#v", read) + } + + joinedQueries := strings.Join(*queries, "\n") + if !strings.Contains(joinedQueries, "includeGridData=true") || !strings.Contains(joinedQueries, "dataSourceTable") { + t.Fatalf("table discovery did not request anchor definitions: %s", joinedQueries) + } +} + +func TestSheetsDataSourceTableValidation(t *testing.T) { + for _, test := range []struct { + name string + anchor string + want string + }{ + {name: "missing sheet", anchor: "A1", want: "include a sheet name"}, + {name: "range", anchor: "Extracts!A1:B2", want: "one cell"}, + {name: "invalid", anchor: "Extracts!nope", want: "invalid anchor"}, + } { + t.Run(test.name, func(t *testing.T) { + _, _, err := validateSheetsDataSourceTableArgs("connected1", test.anchor) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + if ExitCode(err) != 2 { + t.Fatalf("ExitCode = %d, want 2", ExitCode(err)) + } + }) + } +} + +func TestWrapConnectedSheetsReadError(t *testing.T) { + cause := errors.New("Request had insufficient authentication scopes") + err := wrapConnectedSheetsReadError(cause, "services@openclaw.org") + if err == nil || !strings.Contains(err.Error(), connectedSheetsBigQueryScope) || !strings.Contains(err.Error(), "--extra-scopes") { + t.Fatalf("missing reauthorization guidance: %v", err) + } + plain := errors.New("permission denied for BigQuery table") + if got := wrapConnectedSheetsReadError(plain, "services@openclaw.org"); !errors.Is(got, plain) { + t.Fatalf("ordinary permission error should be preserved: %v", got) + } +} + +func TestSheetsMetadataIncludesConnectedSheets(t *testing.T) { + srv, _ := newConnectedSheetsFixtureServer(t) + defer srv.Close() + svc := newSheetsServiceFromServer(t, srv) + var out bytes.Buffer + ctx := withSheetsTestService(newCmdRuntimeJSONOutputContext(t, &out, io.Discard), svc) + if err := (&SheetsMetadataCmd{SpreadsheetID: "connected1"}).Run(ctx, &RootFlags{Account: "services@openclaw.org"}); err != nil { + t.Fatalf("metadata: %v", err) + } + if !strings.Contains(out.String(), `"dataSources"`) || !strings.Contains(out.String(), `"dataSourceSchedules"`) { + t.Fatalf("metadata omitted Connected Sheets fields: %s", out.String()) + } +} + +func newConnectedSheetsFixtureServer(t *testing.T) (*httptest.Server, *[]string) { + t.Helper() + fixture, err := os.ReadFile("testdata/sheets_connected_sheets.json") + if err != nil { + t.Fatalf("read Connected Sheets fixture: %v", err) + } + queries := make([]string, 0) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + queries = append(queries, r.URL.RawQuery) + w.Header().Set("Content-Type", "application/json") + path := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/sheets/v4"), "/v4") + switch { + case strings.Contains(path, "/spreadsheets/connected1/values/") && r.Method == http.MethodGet: + _ = json.NewEncoder(w).Encode(map[string]any{ + "range": "Extracts!B3:C6", + "majorDimension": "ROWS", + "values": [][]any{ + {"word", "word_count"}, + {"love", 2019}, + {"the", 33201}, + {"king", 1500}, + }, + }) + case strings.HasPrefix(path, "/spreadsheets/connected1") && r.Method == http.MethodGet: + _, _ = w.Write(fixture) + default: + http.NotFound(w, r) + } + })) + return srv, &queries +} diff --git a/internal/cmd/testdata/sheets_connected_sheets.json b/internal/cmd/testdata/sheets_connected_sheets.json new file mode 100644 index 000000000..2063029db --- /dev/null +++ b/internal/cmd/testdata/sheets_connected_sheets.json @@ -0,0 +1,134 @@ +{ + "spreadsheetId": "connected1", + "properties": { + "title": "Connected Sheets fixture" + }, + "dataSources": [ + { + "dataSourceId": "ds-table", + "sheetId": 101, + "spec": { + "bigQuery": { + "projectId": "billing-project", + "tableSpec": { + "tableProjectId": "bigquery-public-data", + "datasetId": "samples", + "tableId": "shakespeare" + } + } + } + }, + { + "dataSourceId": "ds-query", + "sheetId": 102, + "spec": { + "bigQuery": { + "projectId": "billing-project", + "querySpec": { + "rawQuery": "SELECT corpus, SUM(word_count) AS total FROM `bigquery-public-data.samples.shakespeare` GROUP BY corpus" + } + } + } + } + ], + "dataSourceSchedules": [ + { + "enabled": true, + "refreshScope": "ALL_DATA_SOURCES", + "dailySchedule": { + "startTime": {"hours": 8} + } + } + ], + "sheets": [ + { + "properties": { + "sheetId": 101, + "title": "Shakespeare Preview", + "index": 0, + "sheetType": "DATA_SOURCE", + "gridProperties": { + "rowCount": 500, + "columnCount": 4 + }, + "dataSourceSheetProperties": { + "dataSourceId": "ds-table", + "columns": [ + {"reference": {"name": "word"}}, + {"reference": {"name": "word_count"}}, + {"reference": {"name": "corpus"}}, + {"reference": {"name": "corpus_date"}} + ], + "dataExecutionStatus": { + "state": "SUCCEEDED", + "lastRefreshTime": "2026-08-13T16:30:00Z" + } + } + } + }, + { + "properties": { + "sheetId": 102, + "title": "Query Preview", + "index": 1, + "sheetType": "DATA_SOURCE", + "gridProperties": { + "rowCount": 500, + "columnCount": 2 + }, + "dataSourceSheetProperties": { + "dataSourceId": "ds-query", + "columns": [ + {"reference": {"name": "corpus"}}, + {"reference": {"name": "total"}} + ], + "dataExecutionStatus": { + "state": "FAILED", + "lastRefreshTime": "2026-08-12T15:00:00Z", + "errorCode": "ENGINE", + "errorMessage": "fixture query failure" + } + } + } + }, + { + "properties": { + "sheetId": 201, + "title": "Extracts", + "index": 2, + "sheetType": "GRID", + "gridProperties": { + "rowCount": 1000, + "columnCount": 26 + } + }, + "data": [ + { + "startRow": 2, + "startColumn": 1, + "rowData": [ + { + "values": [ + { + "dataSourceTable": { + "dataSourceId": "ds-table", + "columnSelectionType": "SELECTED", + "columns": [ + {"name": "word"}, + {"name": "word_count"} + ], + "rowLimit": 5, + "dataExecutionStatus": { + "state": "SUCCEEDED", + "lastRefreshTime": "2026-08-13T16:31:00Z" + } + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/internal/googleapi/factory.go b/internal/googleapi/factory.go index f7575e302..a169774c1 100644 --- a/internal/googleapi/factory.go +++ b/internal/googleapi/factory.go @@ -162,6 +162,10 @@ func (f Factory) Sheets(ctx context.Context, account string) (*sheets.Service, e return NewSheets(f.withAuth(ctx), account) } +func (f Factory) ConnectedSheets(ctx context.Context, account string) (*sheets.Service, error) { + return NewConnectedSheets(f.withAuth(ctx), account) +} + func (f Factory) SitesDrive(ctx context.Context, account string) (*drive.Service, error) { return NewSitesDrive(f.withAuth(ctx), account) } diff --git a/internal/googleapi/services_more_test.go b/internal/googleapi/services_more_test.go index 2b1e1251d..d241bbe42 100644 --- a/internal/googleapi/services_more_test.go +++ b/internal/googleapi/services_more_test.go @@ -53,6 +53,10 @@ func TestNewServicesWithStoredToken(t *testing.T) { t.Fatalf("NewSheets: %v", err) } + if _, err := NewConnectedSheets(ctx, "a@b.com"); err != nil { + t.Fatalf("NewConnectedSheets: %v", err) + } + if _, err := NewTasks(ctx, "a@b.com"); err != nil { t.Fatalf("NewTasks: %v", err) } @@ -86,6 +90,42 @@ func TestNewServicesWithStoredToken(t *testing.T) { } } +func TestNewConnectedSheetsRequestsReadOnlySheetsAndBigQueryScopes(t *testing.T) { + var gotScopes []string + ctx := WithAuthDependencies(context.Background(), AuthDependencies{ + Mode: AuthModeADC, + ADCTokenSource: func(_ context.Context, scopes ...string) (oauth2.TokenSource, error) { + gotScopes = append([]string(nil), scopes...) + return oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "adc-token"}), nil + }, + }) + + if _, err := NewConnectedSheets(ctx, "adc"); err != nil { + t.Fatalf("NewConnectedSheets: %v", err) + } + + want := map[string]bool{ + scopeSpreadsheetsReadOnly: false, + scopeBigQueryReadOnly: false, + } + + for _, scope := range gotScopes { + if _, ok := want[scope]; ok { + want[scope] = true + } + } + + for scope, found := range want { + if !found { + t.Fatalf("missing scope %q in %v", scope, gotScopes) + } + } + + if len(gotScopes) != len(want) { + t.Fatalf("unexpected extra scopes: %v", gotScopes) + } +} + func TestNewKeepWithServiceAccountErrors(t *testing.T) { _, err := NewKeepWithServiceAccount(context.Background(), filepath.Join(t.TempDir(), "missing.json"), "a@b.com") if err == nil { diff --git a/internal/googleapi/sheets.go b/internal/googleapi/sheets.go index f60045edb..db15456f4 100644 --- a/internal/googleapi/sheets.go +++ b/internal/googleapi/sheets.go @@ -9,6 +9,11 @@ import ( "github.com/openclaw/gogcli/internal/googleauth" ) +const ( + scopeSpreadsheetsReadOnly = "https://www.googleapis.com/auth/spreadsheets.readonly" + scopeBigQueryReadOnly = "https://www.googleapis.com/auth/bigquery.readonly" +) + func NewSheets(ctx context.Context, email string) (*sheets.Service, error) { slog.Debug("creating sheets service", "email", email) @@ -22,3 +27,27 @@ func NewSheets(ctx context.Context, email string) (*sheets.Service, error) { return svc, nil } + +// NewConnectedSheets creates a read-only Sheets client whose token also has +// the BigQuery scope required when a response contains Connected Sheets data. +// Keeping this separate from NewSheets avoids broadening ordinary Sheets auth. +func NewConnectedSheets(ctx context.Context, email string) (*sheets.Service, error) { + slog.Debug("creating Connected Sheets service", "email", email) + + svc, err := newGoogleServiceForScopes( + ctx, + email, + string(googleauth.ServiceSheets), + "Connected Sheets", + []string{scopeSpreadsheetsReadOnly, scopeBigQueryReadOnly}, + sheets.NewService, + ) + if err != nil { + slog.Error("failed to create Connected Sheets service", "email", email, "error", err) + return nil, err + } + + slog.Debug("Connected Sheets service created successfully", "email", email) + + return svc, nil +}