From 7b288cc92278c6da9ef535e1825afa9108e59504 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 07:55:15 +0100 Subject: [PATCH] feat(raw): add lossless API dump commands Co-authored-by: Ali Karbassi Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 2 + README.md | 40 +++++++ docs/commands.generated.md | 12 +- docs/commands/README.md | 12 +- docs/commands/gog-calendar-raw.md | 43 +++++++ docs/commands/gog-calendar.md | 1 + docs/commands/gog-contacts-raw.md | 44 +++++++ docs/commands/gog-contacts.md | 1 + docs/commands/gog-docs-raw.md | 43 +++++++ docs/commands/gog-docs.md | 1 + docs/commands/gog-drive-get.md | 3 +- docs/commands/gog-drive-ls.md | 1 + docs/commands/gog-drive-raw.md | 44 +++++++ docs/commands/gog-drive.md | 1 + docs/commands/gog-forms-raw.md | 43 +++++++ docs/commands/gog-forms.md | 1 + docs/commands/gog-gmail-raw.md | 44 +++++++ docs/commands/gog-gmail.md | 1 + docs/commands/gog-ls.md | 1 + docs/commands/gog-people-raw.md | 44 +++++++ docs/commands/gog-people.md | 1 + docs/commands/gog-sheets-raw.md | 44 +++++++ docs/commands/gog-sheets.md | 1 + docs/commands/gog-slides-raw.md | 43 +++++++ docs/commands/gog-slides.md | 1 + docs/commands/gog-tasks-raw.md | 43 +++++++ docs/commands/gog-tasks.md | 1 + docs/raw-audit.md | 174 +++++++++++++++++++++++++++ internal/cmd/calendar.go | 1 + internal/cmd/calendar_raw.go | 52 ++++++++ internal/cmd/calendar_raw_test.go | 130 ++++++++++++++++++++ internal/cmd/contacts.go | 1 + internal/cmd/docs.go | 38 ++++++ internal/cmd/docs_raw_test.go | 172 +++++++++++++++++++++++++++ internal/cmd/drive.go | 98 +++++++++++++++- internal/cmd/drive_fields_test.go | 138 ++++++++++++++++++++++ internal/cmd/drive_listing.go | 9 +- internal/cmd/drive_raw_test.go | 181 ++++++++++++++++++++++++++++ internal/cmd/forms.go | 1 + internal/cmd/forms_raw.go | 45 +++++++ internal/cmd/forms_raw_test.go | 122 +++++++++++++++++++ internal/cmd/gmail.go | 1 + internal/cmd/gmail_get.go | 1 + internal/cmd/gmail_raw.go | 64 ++++++++++ internal/cmd/gmail_raw_test.go | 170 +++++++++++++++++++++++++++ internal/cmd/people.go | 1 + internal/cmd/people_raw.go | 76 ++++++++++++ internal/cmd/people_raw_test.go | 138 ++++++++++++++++++++++ internal/cmd/sheets.go | 48 ++++++++ internal/cmd/sheets_raw_test.go | 189 ++++++++++++++++++++++++++++++ internal/cmd/slides.go | 39 ++++++ internal/cmd/slides_raw_test.go | 133 +++++++++++++++++++++ internal/cmd/tasks.go | 1 + internal/cmd/tasks_raw.go | 54 +++++++++ internal/cmd/tasks_raw_test.go | 127 ++++++++++++++++++++ internal/outfmt/raw.go | 36 ++++++ internal/outfmt/raw_test.go | 109 +++++++++++++++++ 57 files changed, 2860 insertions(+), 5 deletions(-) create mode 100644 docs/commands/gog-calendar-raw.md create mode 100644 docs/commands/gog-contacts-raw.md create mode 100644 docs/commands/gog-docs-raw.md create mode 100644 docs/commands/gog-drive-raw.md create mode 100644 docs/commands/gog-forms-raw.md create mode 100644 docs/commands/gog-gmail-raw.md create mode 100644 docs/commands/gog-people-raw.md create mode 100644 docs/commands/gog-sheets-raw.md create mode 100644 docs/commands/gog-slides-raw.md create mode 100644 docs/commands/gog-tasks-raw.md create mode 100644 docs/raw-audit.md create mode 100644 internal/cmd/calendar_raw.go create mode 100644 internal/cmd/calendar_raw_test.go create mode 100644 internal/cmd/docs_raw_test.go create mode 100644 internal/cmd/drive_fields_test.go create mode 100644 internal/cmd/drive_raw_test.go create mode 100644 internal/cmd/forms_raw.go create mode 100644 internal/cmd/forms_raw_test.go create mode 100644 internal/cmd/gmail_raw.go create mode 100644 internal/cmd/gmail_raw_test.go create mode 100644 internal/cmd/people_raw.go create mode 100644 internal/cmd/people_raw_test.go create mode 100644 internal/cmd/sheets_raw_test.go create mode 100644 internal/cmd/slides_raw_test.go create mode 100644 internal/cmd/tasks_raw.go create mode 100644 internal/cmd/tasks_raw_test.go create mode 100644 internal/outfmt/raw.go create mode 100644 internal/outfmt/raw_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 45e4038..a2102b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added - Install: publish a GHCR Docker image for release tags, with a non-root runtime image and file-keyring docs for container automation. (#539, #444) — thanks @HuckOps and @rdehuyss. - Gmail: add `--sanitize-content` (`--safe`) to `gmail get` and `gmail thread get` for agent-oriented sanitized content output without raw Gmail payloads in JSON. (#238, #220) — thanks @urasmutlu. +- Raw API dumps: add `docs raw`, `sheets raw`, `slides raw`, `drive raw`, `gmail raw`, `calendar raw`, `people raw`, `contacts raw`, `tasks raw`, and `forms raw` subcommands for lossless Google API JSON output, with `--pretty`, Drive raw redaction defaults, Sheets grid-data warnings, and a raw-output security audit. (#495, #496) — thanks @karbassi. +- Drive: add `--fields` to `drive ls` and `drive get` so callers can pass Drive API field masks for fields beyond the default JSON set. (#495) — thanks @karbassi. - Agent safety: add baked safety-profile builds for fail-closed agent binaries, with `agent-safe`, `readonly`, and `full` profiles, filtered help/schema output, docs, and build tooling. (#366, #239) — thanks @drewburchfield. - Calendar: add `--with-meet` to `calendar update` for adding Google Meet conferencing to existing events. (#538) — thanks @alexisperumal. - Calendar: add `calendar move` / `calendar transfer` to move an event to another calendar and change its organizer. (#448) — thanks @markusbkoch. diff --git a/README.md b/README.md index 7e67643..04086ff 100644 --- a/README.md +++ b/README.md @@ -1159,6 +1159,15 @@ gog drive unshare --permission-id # Shared drives (Team Drives) gog drive drives --max 100 + +# Request extra fields from the Drive API (closes #486) +gog drive ls --fields "files(id,name,thumbnailLink),nextPageToken" +gog drive get --fields "id,name,thumbnailLink,imageMediaMetadata" + +# Raw API dump (lossless JSON for scripting/LLMs) +gog drive raw # fields=*, sensitive fields redacted by default +gog drive raw --fields "id,name,thumbnailLink" # honors user-named fields verbatim +gog drive raw --pretty ``` ### Docs / Slides / Sheets @@ -1187,6 +1196,8 @@ gog docs write --file ./body.md --replace --markdown gog docs write --file ./body.md --append --markdown gog docs find-replace "old" "new" gog docs find-replace "old" "new" --tab "Notes" +gog docs raw # Lossless JSON dump of Documents.Get (LLM/scripting) +gog docs raw --pretty # Slides gog slides info @@ -1204,6 +1215,7 @@ gog slides insert-text - < long-content.md gog slides insert-text "New body" --replace gog slides replace-text "{{name}}" "Acme Corp" gog slides replace-text "TODO" "DONE" --match-case --page --page +gog slides raw # Lossless JSON dump of Presentations.Get # Sheets gog sheets copy "My Sheet Copy" @@ -1223,8 +1235,36 @@ gog sheets links 'Sheet1!A1:B10' gog sheets add-tab --index 0 gog sheets rename-tab gog sheets delete-tab --force +gog sheets raw # Lossless JSON dump of Spreadsheets.Get +gog sheets raw --include-grid-data # Include cell-level data (off by default) + +# Other raw dumps (gmail, calendar, people, contacts, tasks, forms) +gog gmail raw # Lossless JSON dump of Users.Messages.Get (default format=full) +gog gmail raw --format raw # Gmail's native format=raw (base64url RFC822) +gog calendar raw # Lossless JSON dump of Events.Get +gog people raw people/ # Lossless JSON dump of People.Get +gog contacts raw people/ # Same endpoint, exposed under the contacts group +gog tasks raw # Lossless JSON dump of Tasks.Get +gog forms raw # Lossless JSON dump of Forms.Get ``` +**Raw vs other read subcommands.** Use `raw` when you need the full +canonical Google API response as JSON (e.g. feeding a doc into an LLM, +or scripting against structural fields `cat`/`structure` drop). Other +read commands are lossier on purpose: + +- `docs info`, `sheets metadata`, `slides info`, `drive get` → metadata only (cheap) +- `docs cat`, `docs structure` → plain text / simplified structure +- `docs export`, `sheets export`, `slides export`, `drive download` → converted file formats (pdf/docx/xlsx/pptx/md) +- ` raw` → full API response as JSON (verbose, lossless) + +`drive raw` defaults to `fields=*` and redacts a small set of +capability/token-shaped fields (thumbnailLink, webContentLink, +exportLinks, resourceKey, appProperties, properties, +contentHints.thumbnail.image). When `--fields` is supplied the response +is returned verbatim — the user named the field, they get it. See +[`docs/raw-audit.md`](docs/raw-audit.md) for the redaction rationale. + ### Contacts ```bash diff --git a/docs/commands.generated.md b/docs/commands.generated.md index 244e2ad..00a4b90 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -77,6 +77,7 @@ Generated from `gog schema --json`. - [`gog calendar (cal) move (transfer) [flags]`](commands/gog-calendar-move.md) - Move an event to another calendar - [`gog calendar (cal) out-of-office (ooo) --from=STRING --to=STRING [] [flags]`](commands/gog-calendar-out-of-office.md) - Create an Out of Office event - [`gog calendar (cal) propose-time [flags]`](commands/gog-calendar-propose-time.md) - Generate URL to propose a new meeting time (browser-only feature) + - [`gog calendar (cal) raw [flags]`](commands/gog-calendar-raw.md) - Dump raw Google Calendar API response as JSON (Events.Get; lossless; for scripting and LLM consumption) - [`gog calendar (cal) respond (rsvp,reply) [flags]`](commands/gog-calendar-respond.md) - Respond to an event invitation - [`gog calendar (cal) search (find,query) [flags]`](commands/gog-calendar-search.md) - Search events - [`gog calendar (cal) subscribe (sub,add-calendar) [flags]`](commands/gog-calendar-subscribe.md) - Add a calendar to your calendar list @@ -200,6 +201,7 @@ Generated from `gog schema --json`. - [`gog contacts (contact) other delete `](commands/gog-contacts-other-delete.md) - Delete an other contact - [`gog contacts (contact) other list [flags]`](commands/gog-contacts-other-list.md) - List other contacts - [`gog contacts (contact) other search ... [flags]`](commands/gog-contacts-other-search.md) - Search other contacts + - [`gog contacts (contact) raw [flags]`](commands/gog-contacts-raw.md) - Dump raw People API response as JSON (People.Get; lossless; for scripting and LLM consumption) - [`gog contacts (contact) search ... [flags]`](commands/gog-contacts-search.md) - Search contacts by name/email/phone - [`gog contacts (contact) update (edit,set) [flags]`](commands/gog-contacts-update.md) - Update a contact - [`gog docs (doc) [flags]`](commands/gog-docs.md) - Google Docs (export via Drive) @@ -223,6 +225,7 @@ Generated from `gog schema --json`. - [`gog docs (doc) info (get,show) `](commands/gog-docs-info.md) - Get Google Doc metadata - [`gog docs (doc) insert [] [flags]`](commands/gog-docs-insert.md) - Insert text at a specific position - [`gog docs (doc) list-tabs `](commands/gog-docs-list-tabs.md) - List all tabs in a Google Doc + - [`gog docs (doc) raw [flags]`](commands/gog-docs-raw.md) - Dump raw Google Docs API response as JSON (Documents.Get; lossless; for scripting and LLM consumption) - [`gog docs (doc) rename-tab [flags]`](commands/gog-docs-rename-tab.md) - Rename a tab in a Google Doc - [`gog docs (doc) sed [] [flags]`](commands/gog-docs-sed.md) - Regex find/replace (sed-style: s/pattern/replacement/g) - [`gog docs (doc) structure (struct) [flags]`](commands/gog-docs-structure.md) - Show document structure with numbered paragraphs @@ -241,11 +244,12 @@ Generated from `gog schema --json`. - [`gog drive (drv) delete (rm,del) [flags]`](commands/gog-drive-delete.md) - Move a file to trash (use --permanent to delete forever) - [`gog drive (drv) download [flags]`](commands/gog-drive-download.md) - Download a file (exports Google Docs formats) - [`gog drive (drv) drives [flags]`](commands/gog-drive-drives.md) - List shared drives (Team Drives) - - [`gog drive (drv) get `](commands/gog-drive-get.md) - Get file metadata + - [`gog drive (drv) get [flags]`](commands/gog-drive-get.md) - Get file metadata - [`gog drive (drv) ls [flags]`](commands/gog-drive-ls.md) - List files in a folder (default: root) - [`gog drive (drv) mkdir [flags]`](commands/gog-drive-mkdir.md) - Create a folder - [`gog drive (drv) move [flags]`](commands/gog-drive-move.md) - Move a file to a different folder - [`gog drive (drv) permissions [flags]`](commands/gog-drive-permissions.md) - List permissions on a file + - [`gog drive (drv) raw [flags]`](commands/gog-drive-raw.md) - Dump raw Google Drive API response as JSON (Files.Get; lossless; for scripting and LLM consumption) - [`gog drive (drv) rename `](commands/gog-drive-rename.md) - Rename a file or folder - [`gog drive (drv) search ... [flags]`](commands/gog-drive-search.md) - Full-text search across Drive - [`gog drive (drv) share [flags]`](commands/gog-drive-share.md) - Share a file or folder @@ -259,6 +263,7 @@ Generated from `gog schema --json`. - [`gog forms (form) delete-question (delete-q,dq,rm-q) `](commands/gog-forms-delete-question.md) - Delete a question by index - [`gog forms (form) get (info,show) `](commands/gog-forms-get.md) - Get a form - [`gog forms (form) move-question (move-q,mq) `](commands/gog-forms-move-question.md) - Move a question to a new position + - [`gog forms (form) raw [flags]`](commands/gog-forms-raw.md) - Dump raw Google Forms API response as JSON (Forms.Get; lossless; for scripting and LLM consumption) - [`gog forms (form) responses `](commands/gog-forms-responses.md) - Form responses - [`gog forms (form) responses get (info,show) `](commands/gog-forms-responses-get.md) - Get a form response - [`gog forms (form) responses list (ls) [flags]`](commands/gog-forms-responses-list.md) - List form responses @@ -297,6 +302,7 @@ Generated from `gog schema --json`. - [`gog gmail (mail,email) messages (message,msg,msgs) `](commands/gog-gmail-messages.md) - Message operations - [`gog gmail (mail,email) messages (message,msg,msgs) modify (update,edit,set) [flags]`](commands/gog-gmail-messages-modify.md) - Modify labels on a single message - [`gog gmail (mail,email) messages (message,msg,msgs) search (find,query,ls,list) ... [flags]`](commands/gog-gmail-messages-search.md) - Search messages using Gmail query syntax + - [`gog gmail (mail,email) raw [flags]`](commands/gog-gmail-raw.md) - Dump raw Gmail API response as JSON (Users.Messages.Get; lossless; for scripting and LLM consumption) - [`gog gmail (mail,email) search (find,query,ls,list) ... [flags]`](commands/gog-gmail-search.md) - Search threads using Gmail query syntax - [`gog gmail (mail,email) send [flags]`](commands/gog-gmail-send.md) - Send an email - [`gog gmail (mail,email) settings `](commands/gog-gmail-settings.md) - Settings and admin @@ -366,6 +372,7 @@ Generated from `gog schema --json`. - [`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 - [`gog people (person) me`](commands/gog-people-me.md) - Show your profile (people/me) + - [`gog people (person) raw [flags]`](commands/gog-people-raw.md) - Dump raw People API response as JSON (People.Get; lossless; for scripting and LLM consumption) - [`gog people (person) relations [] [flags]`](commands/gog-people-relations.md) - Get user relations - [`gog people (person) search (find,query) ... [flags]`](commands/gog-people-search.md) - Search the Workspace directory - [`gog schema (help-json,helpjson) [ ...] [flags]`](commands/gog-schema.md) - Machine-readable command/flag schema @@ -401,6 +408,7 @@ Generated from `gog schema --json`. - [`gog sheets (sheet) named-ranges (namedranges,nr) update (edit,set) [flags]`](commands/gog-sheets-named-ranges-update.md) - Update a named range - [`gog sheets (sheet) notes `](commands/gog-sheets-notes.md) - Get cell notes from a range - [`gog sheets (sheet) number-format [flags]`](commands/gog-sheets-number-format.md) - Apply number format to a range + - [`gog sheets (sheet) raw [flags]`](commands/gog-sheets-raw.md) - Dump raw Google Sheets API response as JSON (Spreadsheets.Get; lossless; for scripting and LLM consumption) - [`gog sheets (sheet) read-format (get-format,format-read) [flags]`](commands/gog-sheets-read-format.md) - Read cell formatting from a range - [`gog sheets (sheet) rename-tab (rename-sheet) `](commands/gog-sheets-rename-tab.md) - Rename a tab/sheet in a spreadsheet - [`gog sheets (sheet) resize-columns [flags]`](commands/gog-sheets-resize-columns.md) - Resize sheet columns @@ -419,6 +427,7 @@ Generated from `gog schema --json`. - [`gog slides (slide) info (get,show) `](commands/gog-slides-info.md) - Get Google Slides presentation metadata - [`gog slides (slide) insert-text [flags]`](commands/gog-slides-insert-text.md) - Insert text into an existing page element (shape or table) by objectId - [`gog slides (slide) list-slides `](commands/gog-slides-list-slides.md) - List all slides with their object IDs + - [`gog slides (slide) raw [flags]`](commands/gog-slides-raw.md) - Dump raw Google Slides API response as JSON (Presentations.Get; lossless; for scripting and LLM consumption) - [`gog slides (slide) read-slide `](commands/gog-slides-read-slide.md) - Read slide content: speaker notes, text elements, and images - [`gog slides (slide) replace-slide [flags]`](commands/gog-slides-replace-slide.md) - Replace the image on an existing slide in-place - [`gog slides (slide) replace-text [flags]`](commands/gog-slides-replace-text.md) - Find-and-replace text across a presentation @@ -435,6 +444,7 @@ Generated from `gog schema --json`. - [`gog tasks (task) lists `](commands/gog-tasks-lists.md) - List task lists - [`gog tasks (task) lists create (add,new) ...`](commands/gog-tasks-lists-create.md) - Create a task list - [`gog tasks (task) lists list [flags]`](commands/gog-tasks-lists-list.md) - List task lists + - [`gog tasks (task) raw <tasklistId> <taskId> [flags]`](commands/gog-tasks-raw.md) - Dump raw Google Tasks API response as JSON (Tasks.Get; lossless; for scripting and LLM consumption) - [`gog tasks (task) undo (uncomplete,undone) <tasklistId> <taskId>`](commands/gog-tasks-undo.md) - Mark task needs action - [`gog tasks (task) update (edit,set) <tasklistId> <taskId> [flags]`](commands/gog-tasks-update.md) - Update a task - [`gog time <command> [flags]`](commands/gog-time.md) - Local time utilities diff --git a/docs/commands/README.md b/docs/commands/README.md index 685b9e7..c3909d4 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: 440. +Generated pages: 450. ## Top-level Commands @@ -120,6 +120,7 @@ Generated pages: 440. - [gog calendar move](gog-calendar-move.md) - Move an event to another calendar - [gog calendar out-of-office](gog-calendar-out-of-office.md) - Create an Out of Office event - [gog calendar propose-time](gog-calendar-propose-time.md) - Generate URL to propose a new meeting time (browser-only feature) + - [gog calendar raw](gog-calendar-raw.md) - Dump raw Google Calendar API response as JSON (Events.Get; lossless; for scripting and LLM consumption) - [gog calendar respond](gog-calendar-respond.md) - Respond to an event invitation - [gog calendar search](gog-calendar-search.md) - Search events - [gog calendar subscribe](gog-calendar-subscribe.md) - Add a calendar to your calendar list @@ -243,6 +244,7 @@ Generated pages: 440. - [gog contacts other delete](gog-contacts-other-delete.md) - Delete an other contact - [gog contacts other list](gog-contacts-other-list.md) - List other contacts - [gog contacts other search](gog-contacts-other-search.md) - Search other contacts + - [gog contacts raw](gog-contacts-raw.md) - Dump raw People API response as JSON (People.Get; lossless; for scripting and LLM consumption) - [gog contacts search](gog-contacts-search.md) - Search contacts by name/email/phone - [gog contacts update](gog-contacts-update.md) - Update a contact - [gog docs](gog-docs.md) - Google Docs (export via Drive) @@ -266,6 +268,7 @@ Generated pages: 440. - [gog docs info](gog-docs-info.md) - Get Google Doc metadata - [gog docs insert](gog-docs-insert.md) - Insert text at a specific position - [gog docs list-tabs](gog-docs-list-tabs.md) - List all tabs in a Google Doc + - [gog docs raw](gog-docs-raw.md) - Dump raw Google Docs API response as JSON (Documents.Get; lossless; for scripting and LLM consumption) - [gog docs rename-tab](gog-docs-rename-tab.md) - Rename a tab in a Google Doc - [gog docs sed](gog-docs-sed.md) - Regex find/replace (sed-style: s/pattern/replacement/g) - [gog docs structure](gog-docs-structure.md) - Show document structure with numbered paragraphs @@ -289,6 +292,7 @@ Generated pages: 440. - [gog drive mkdir](gog-drive-mkdir.md) - Create a folder - [gog drive move](gog-drive-move.md) - Move a file to a different folder - [gog drive permissions](gog-drive-permissions.md) - List permissions on a file + - [gog drive raw](gog-drive-raw.md) - Dump raw Google Drive API response as JSON (Files.Get; lossless; for scripting and LLM consumption) - [gog drive rename](gog-drive-rename.md) - Rename a file or folder - [gog drive search](gog-drive-search.md) - Full-text search across Drive - [gog drive share](gog-drive-share.md) - Share a file or folder @@ -302,6 +306,7 @@ Generated pages: 440. - [gog forms delete-question](gog-forms-delete-question.md) - Delete a question by index - [gog forms get](gog-forms-get.md) - Get a form - [gog forms move-question](gog-forms-move-question.md) - Move a question to a new position + - [gog forms raw](gog-forms-raw.md) - Dump raw Google Forms API response as JSON (Forms.Get; lossless; for scripting and LLM consumption) - [gog forms responses](gog-forms-responses.md) - Form responses - [gog forms responses get](gog-forms-responses-get.md) - Get a form response - [gog forms responses list](gog-forms-responses-list.md) - List form responses @@ -340,6 +345,7 @@ Generated pages: 440. - [gog gmail messages](gog-gmail-messages.md) - Message operations - [gog gmail messages modify](gog-gmail-messages-modify.md) - Modify labels on a single message - [gog gmail messages search](gog-gmail-messages-search.md) - Search messages using Gmail query syntax + - [gog gmail raw](gog-gmail-raw.md) - Dump raw Gmail API response as JSON (Users.Messages.Get; lossless; for scripting and LLM consumption) - [gog gmail search](gog-gmail-search.md) - Search threads using Gmail query syntax - [gog gmail send](gog-gmail-send.md) - Send an email - [gog gmail settings](gog-gmail-settings.md) - Settings and admin @@ -409,6 +415,7 @@ Generated pages: 440. - [gog people](gog-people.md) - Google People - [gog people get](gog-people-get.md) - Get a user profile by ID - [gog people me](gog-people-me.md) - Show your profile (people/me) + - [gog people raw](gog-people-raw.md) - Dump raw People API response as JSON (People.Get; lossless; for scripting and LLM consumption) - [gog people relations](gog-people-relations.md) - Get user relations - [gog people search](gog-people-search.md) - Search the Workspace directory - [gog schema](gog-schema.md) - Machine-readable command/flag schema @@ -444,6 +451,7 @@ Generated pages: 440. - [gog sheets named-ranges update](gog-sheets-named-ranges-update.md) - Update a named range - [gog sheets notes](gog-sheets-notes.md) - Get cell notes from a range - [gog sheets number-format](gog-sheets-number-format.md) - Apply number format to a range + - [gog sheets raw](gog-sheets-raw.md) - Dump raw Google Sheets API response as JSON (Spreadsheets.Get; lossless; for scripting and LLM consumption) - [gog sheets read-format](gog-sheets-read-format.md) - Read cell formatting from a range - [gog sheets rename-tab](gog-sheets-rename-tab.md) - Rename a tab/sheet in a spreadsheet - [gog sheets resize-columns](gog-sheets-resize-columns.md) - Resize sheet columns @@ -462,6 +470,7 @@ Generated pages: 440. - [gog slides info](gog-slides-info.md) - Get Google Slides presentation metadata - [gog slides insert-text](gog-slides-insert-text.md) - Insert text into an existing page element (shape or table) by objectId - [gog slides list-slides](gog-slides-list-slides.md) - List all slides with their object IDs + - [gog slides raw](gog-slides-raw.md) - Dump raw Google Slides API response as JSON (Presentations.Get; lossless; for scripting and LLM consumption) - [gog slides read-slide](gog-slides-read-slide.md) - Read slide content: speaker notes, text elements, and images - [gog slides replace-slide](gog-slides-replace-slide.md) - Replace the image on an existing slide in-place - [gog slides replace-text](gog-slides-replace-text.md) - Find-and-replace text across a presentation @@ -478,6 +487,7 @@ Generated pages: 440. - [gog tasks lists](gog-tasks-lists.md) - List task lists - [gog tasks lists create](gog-tasks-lists-create.md) - Create a task list - [gog tasks lists list](gog-tasks-lists-list.md) - List task lists + - [gog tasks raw](gog-tasks-raw.md) - Dump raw Google Tasks API response as JSON (Tasks.Get; lossless; for scripting and LLM consumption) - [gog tasks undo](gog-tasks-undo.md) - Mark task needs action - [gog tasks update](gog-tasks-update.md) - Update a task - [gog time](gog-time.md) - Local time utilities diff --git a/docs/commands/gog-calendar-raw.md b/docs/commands/gog-calendar-raw.md new file mode 100644 index 0000000..0ef2ebe --- /dev/null +++ b/docs/commands/gog-calendar-raw.md @@ -0,0 +1,43 @@ +# `gog calendar raw` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Dump raw Google Calendar API response as JSON (Events.Get; lossless; for scripting and LLM consumption) + +## Usage + +```bash +gog calendar (cal) raw <calendarId> <eventId> [flags] +``` + +## Parent + +- [gog calendar](gog-calendar.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 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`<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 commands; dot paths allowed (restricts CLI) | +| `-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. | +| `-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) | +| `--pretty` | `bool` | | Pretty-print JSON (default: compact single-line) | +| `--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 | + +## See Also + +- [gog calendar](gog-calendar.md) +- [Command index](README.md) diff --git a/docs/commands/gog-calendar.md b/docs/commands/gog-calendar.md index 64b8bae..5eb3361 100644 --- a/docs/commands/gog-calendar.md +++ b/docs/commands/gog-calendar.md @@ -31,6 +31,7 @@ gog calendar (cal) <command> [flags] - [gog calendar move](gog-calendar-move.md) - Move an event to another calendar - [gog calendar out-of-office](gog-calendar-out-of-office.md) - Create an Out of Office event - [gog calendar propose-time](gog-calendar-propose-time.md) - Generate URL to propose a new meeting time (browser-only feature) +- [gog calendar raw](gog-calendar-raw.md) - Dump raw Google Calendar API response as JSON (Events.Get; lossless; for scripting and LLM consumption) - [gog calendar respond](gog-calendar-respond.md) - Respond to an event invitation - [gog calendar search](gog-calendar-search.md) - Search events - [gog calendar subscribe](gog-calendar-subscribe.md) - Add a calendar to your calendar list diff --git a/docs/commands/gog-contacts-raw.md b/docs/commands/gog-contacts-raw.md new file mode 100644 index 0000000..cbc3a41 --- /dev/null +++ b/docs/commands/gog-contacts-raw.md @@ -0,0 +1,44 @@ +# `gog contacts raw` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Dump raw People API response as JSON (People.Get; lossless; for scripting and LLM consumption) + +## Usage + +```bash +gog contacts (contact) raw <identifier> [flags] +``` + +## Parent + +- [gog contacts](gog-contacts.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 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`<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 commands; dot paths allowed (restricts CLI) | +| `-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. | +| `-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) | +| `--person-fields` | `string` | | People API personFields mask (default: broad set) | +| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--pretty` | `bool` | | Pretty-print JSON (default: compact single-line) | +| `--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 | + +## See Also + +- [gog contacts](gog-contacts.md) +- [Command index](README.md) diff --git a/docs/commands/gog-contacts.md b/docs/commands/gog-contacts.md index a3b5397..9fba4db 100644 --- a/docs/commands/gog-contacts.md +++ b/docs/commands/gog-contacts.md @@ -23,6 +23,7 @@ gog contacts (contact) <command> [flags] - [gog contacts get](gog-contacts-get.md) - Get a contact - [gog contacts list](gog-contacts-list.md) - List contacts - [gog contacts other](gog-contacts-other.md) - Other contacts +- [gog contacts raw](gog-contacts-raw.md) - Dump raw People API response as JSON (People.Get; lossless; for scripting and LLM consumption) - [gog contacts search](gog-contacts-search.md) - Search contacts by name/email/phone - [gog contacts update](gog-contacts-update.md) - Update a contact diff --git a/docs/commands/gog-docs-raw.md b/docs/commands/gog-docs-raw.md new file mode 100644 index 0000000..fea1aaf --- /dev/null +++ b/docs/commands/gog-docs-raw.md @@ -0,0 +1,43 @@ +# `gog docs raw` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Dump raw Google Docs API response as JSON (Documents.Get; lossless; for scripting and LLM consumption) + +## Usage + +```bash +gog docs (doc) raw <docId> [flags] +``` + +## Parent + +- [gog docs](gog-docs.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 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`<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 commands; dot paths allowed (restricts CLI) | +| `-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. | +| `-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) | +| `--pretty` | `bool` | | Pretty-print JSON (default: compact single-line) | +| `--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 | + +## See Also + +- [gog docs](gog-docs.md) +- [Command index](README.md) diff --git a/docs/commands/gog-docs.md b/docs/commands/gog-docs.md index 3c11352..ee52694 100644 --- a/docs/commands/gog-docs.md +++ b/docs/commands/gog-docs.md @@ -30,6 +30,7 @@ gog docs (doc) <command> [flags] - [gog docs info](gog-docs-info.md) - Get Google Doc metadata - [gog docs insert](gog-docs-insert.md) - Insert text at a specific position - [gog docs list-tabs](gog-docs-list-tabs.md) - List all tabs in a Google Doc +- [gog docs raw](gog-docs-raw.md) - Dump raw Google Docs API response as JSON (Documents.Get; lossless; for scripting and LLM consumption) - [gog docs rename-tab](gog-docs-rename-tab.md) - Rename a tab in a Google Doc - [gog docs sed](gog-docs-sed.md) - Regex find/replace (sed-style: s/pattern/replacement/g) - [gog docs structure](gog-docs-structure.md) - Show document structure with numbered paragraphs diff --git a/docs/commands/gog-drive-get.md b/docs/commands/gog-drive-get.md index 1c6491d..b57c1fe 100644 --- a/docs/commands/gog-drive-get.md +++ b/docs/commands/gog-drive-get.md @@ -7,7 +7,7 @@ Get file metadata ## Usage ```bash -gog drive (drv) get <fileId> +gog drive (drv) get <fileId> [flags] ``` ## Parent @@ -25,6 +25,7 @@ gog drive (drv) get <fileId> | `--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 commands; dot paths allowed (restricts CLI) | +| `--fields` | `string` | | Drive API field mask (overrides the default set; e.g. 'id,name,thumbnailLink') | | `-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. | diff --git a/docs/commands/gog-drive-ls.md b/docs/commands/gog-drive-ls.md index 4816844..c63d9ab 100644 --- a/docs/commands/gog-drive-ls.md +++ b/docs/commands/gog-drive-ls.md @@ -27,6 +27,7 @@ gog drive (drv) ls [flags] | `--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 commands; dot paths allowed (restricts CLI) | +| `--fields` | `string` | | Drive API field mask (overrides the default set; e.g. 'files(id,name,thumbnailLink),nextPageToken') | | `-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. | diff --git a/docs/commands/gog-drive-raw.md b/docs/commands/gog-drive-raw.md new file mode 100644 index 0000000..d3b3b0b --- /dev/null +++ b/docs/commands/gog-drive-raw.md @@ -0,0 +1,44 @@ +# `gog drive raw` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Dump raw Google Drive API response as JSON (Files.Get; lossless; for scripting and LLM consumption) + +## Usage + +```bash +gog drive (drv) raw <fileId> [flags] +``` + +## Parent + +- [gog drive](gog-drive.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 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`<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 commands; dot paths allowed (restricts CLI) | +| `--fields` | `string` | | Drive API field mask (default: * with sensitive fields redacted client-side). Set explicitly to disable redaction. | +| `-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. | +| `-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) | +| `--pretty` | `bool` | | Pretty-print JSON (default: compact single-line) | +| `--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 | + +## See Also + +- [gog drive](gog-drive.md) +- [Command index](README.md) diff --git a/docs/commands/gog-drive.md b/docs/commands/gog-drive.md index 3e98a12..8dd3caf 100644 --- a/docs/commands/gog-drive.md +++ b/docs/commands/gog-drive.md @@ -26,6 +26,7 @@ gog drive (drv) <command> [flags] - [gog drive mkdir](gog-drive-mkdir.md) - Create a folder - [gog drive move](gog-drive-move.md) - Move a file to a different folder - [gog drive permissions](gog-drive-permissions.md) - List permissions on a file +- [gog drive raw](gog-drive-raw.md) - Dump raw Google Drive API response as JSON (Files.Get; lossless; for scripting and LLM consumption) - [gog drive rename](gog-drive-rename.md) - Rename a file or folder - [gog drive search](gog-drive-search.md) - Full-text search across Drive - [gog drive share](gog-drive-share.md) - Share a file or folder diff --git a/docs/commands/gog-forms-raw.md b/docs/commands/gog-forms-raw.md new file mode 100644 index 0000000..3a6be1a --- /dev/null +++ b/docs/commands/gog-forms-raw.md @@ -0,0 +1,43 @@ +# `gog forms raw` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Dump raw Google Forms API response as JSON (Forms.Get; lossless; for scripting and LLM consumption) + +## Usage + +```bash +gog forms (form) raw <formId> [flags] +``` + +## Parent + +- [gog forms](gog-forms.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 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`<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 commands; dot paths allowed (restricts CLI) | +| `-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. | +| `-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) | +| `--pretty` | `bool` | | Pretty-print JSON (default: compact single-line) | +| `--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 | + +## See Also + +- [gog forms](gog-forms.md) +- [Command index](README.md) diff --git a/docs/commands/gog-forms.md b/docs/commands/gog-forms.md index ad93ae5..0241cda 100644 --- a/docs/commands/gog-forms.md +++ b/docs/commands/gog-forms.md @@ -21,6 +21,7 @@ gog forms (form) <command> [flags] - [gog forms delete-question](gog-forms-delete-question.md) - Delete a question by index - [gog forms get](gog-forms-get.md) - Get a form - [gog forms move-question](gog-forms-move-question.md) - Move a question to a new position +- [gog forms raw](gog-forms-raw.md) - Dump raw Google Forms API response as JSON (Forms.Get; lossless; for scripting and LLM consumption) - [gog forms responses](gog-forms-responses.md) - Form responses - [gog forms update](gog-forms-update.md) - Update form title, description, or settings - [gog forms watch](gog-forms-watch.md) - Response watches (push notifications) diff --git a/docs/commands/gog-gmail-raw.md b/docs/commands/gog-gmail-raw.md new file mode 100644 index 0000000..919c171 --- /dev/null +++ b/docs/commands/gog-gmail-raw.md @@ -0,0 +1,44 @@ +# `gog gmail raw` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Dump raw Gmail API response as JSON (Users.Messages.Get; lossless; for scripting and LLM consumption) + +## Usage + +```bash +gog gmail (mail,email) raw <messageId> [flags] +``` + +## Parent + +- [gog gmail](gog-gmail.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 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`<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 commands; dot paths allowed (restricts CLI) | +| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--format` | `string` | full | Gmail format: full\|metadata\|minimal\|raw (default: full; note: 'raw' here means Gmail's base64url RFC822 blob, NOT the gog raw subcommand sense) | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `-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) | +| `--pretty` | `bool` | | Pretty-print JSON (default: compact single-line) | +| `--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 | + +## See Also + +- [gog gmail](gog-gmail.md) +- [Command index](README.md) diff --git a/docs/commands/gog-gmail.md b/docs/commands/gog-gmail.md index 4a30820..f416255 100644 --- a/docs/commands/gog-gmail.md +++ b/docs/commands/gog-gmail.md @@ -27,6 +27,7 @@ gog gmail (mail,email) <command> [flags] - [gog gmail labels](gog-gmail-labels.md) - Label operations - [gog gmail mark-read](gog-gmail-mark-read.md) - Mark messages as read - [gog gmail messages](gog-gmail-messages.md) - Message operations +- [gog gmail raw](gog-gmail-raw.md) - Dump raw Gmail API response as JSON (Users.Messages.Get; lossless; for scripting and LLM consumption) - [gog gmail search](gog-gmail-search.md) - Search threads using Gmail query syntax - [gog gmail send](gog-gmail-send.md) - Send an email - [gog gmail settings](gog-gmail-settings.md) - Settings and admin diff --git a/docs/commands/gog-ls.md b/docs/commands/gog-ls.md index 4d6dbea..41d8ec7 100644 --- a/docs/commands/gog-ls.md +++ b/docs/commands/gog-ls.md @@ -27,6 +27,7 @@ gog ls (list) [flags] | `--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 commands; dot paths allowed (restricts CLI) | +| `--fields` | `string` | | Drive API field mask (overrides the default set; e.g. 'files(id,name,thumbnailLink),nextPageToken') | | `-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. | diff --git a/docs/commands/gog-people-raw.md b/docs/commands/gog-people-raw.md new file mode 100644 index 0000000..316bb68 --- /dev/null +++ b/docs/commands/gog-people-raw.md @@ -0,0 +1,44 @@ +# `gog people raw` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Dump raw People API response as JSON (People.Get; lossless; for scripting and LLM consumption) + +## Usage + +```bash +gog people (person) raw <userId> [flags] +``` + +## Parent + +- [gog people](gog-people.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 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`<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 commands; dot paths allowed (restricts CLI) | +| `-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. | +| `-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) | +| `--person-fields` | `string` | | People API personFields mask (default: broad set; pass a narrower list to reduce output) | +| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--pretty` | `bool` | | Pretty-print JSON (default: compact single-line) | +| `--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 | + +## See Also + +- [gog people](gog-people.md) +- [Command index](README.md) diff --git a/docs/commands/gog-people.md b/docs/commands/gog-people.md index 3802d1b..e985e24 100644 --- a/docs/commands/gog-people.md +++ b/docs/commands/gog-people.md @@ -18,6 +18,7 @@ gog people (person) <command> [flags] - [gog people get](gog-people-get.md) - Get a user profile by ID - [gog people me](gog-people-me.md) - Show your profile (people/me) +- [gog people raw](gog-people-raw.md) - Dump raw People API response as JSON (People.Get; lossless; for scripting and LLM consumption) - [gog people relations](gog-people-relations.md) - Get user relations - [gog people search](gog-people-search.md) - Search the Workspace directory diff --git a/docs/commands/gog-sheets-raw.md b/docs/commands/gog-sheets-raw.md new file mode 100644 index 0000000..68215be --- /dev/null +++ b/docs/commands/gog-sheets-raw.md @@ -0,0 +1,44 @@ +# `gog sheets raw` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Dump raw Google Sheets API response as JSON (Spreadsheets.Get; lossless; for scripting and LLM consumption) + +## Usage + +```bash +gog sheets (sheet) raw <spreadsheetId> [flags] +``` + +## Parent + +- [gog sheets](gog-sheets.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 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`<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 commands; dot paths allowed (restricts CLI) | +| `-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. | +| `--include-grid-data` | `bool` | | Include cell-level grid data in the response (off by default; payloads can be large and may contain secrets in formulas) | +| `-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) | +| `--pretty` | `bool` | | Pretty-print JSON (default: compact single-line) | +| `--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 | + +## 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 5a1411f..6155d39 100644 --- a/docs/commands/gog-sheets.md +++ b/docs/commands/gog-sheets.md @@ -35,6 +35,7 @@ gog sheets (sheet) <command> [flags] - [gog sheets named-ranges](gog-sheets-named-ranges.md) - Manage named ranges - [gog sheets notes](gog-sheets-notes.md) - Get cell notes from a range - [gog sheets number-format](gog-sheets-number-format.md) - Apply number format to a range +- [gog sheets raw](gog-sheets-raw.md) - Dump raw Google Sheets API response as JSON (Spreadsheets.Get; lossless; for scripting and LLM consumption) - [gog sheets read-format](gog-sheets-read-format.md) - Read cell formatting from a range - [gog sheets rename-tab](gog-sheets-rename-tab.md) - Rename a tab/sheet in a spreadsheet - [gog sheets resize-columns](gog-sheets-resize-columns.md) - Resize sheet columns diff --git a/docs/commands/gog-slides-raw.md b/docs/commands/gog-slides-raw.md new file mode 100644 index 0000000..0d2c6e6 --- /dev/null +++ b/docs/commands/gog-slides-raw.md @@ -0,0 +1,43 @@ +# `gog slides raw` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Dump raw Google Slides API response as JSON (Presentations.Get; lossless; for scripting and LLM consumption) + +## Usage + +```bash +gog slides (slide) raw <presentationId> [flags] +``` + +## Parent + +- [gog slides](gog-slides.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 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`<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 commands; dot paths allowed (restricts CLI) | +| `-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. | +| `-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) | +| `--pretty` | `bool` | | Pretty-print JSON (default: compact single-line) | +| `--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 | + +## See Also + +- [gog slides](gog-slides.md) +- [Command index](README.md) diff --git a/docs/commands/gog-slides.md b/docs/commands/gog-slides.md index a7971ee..2ab6c0c 100644 --- a/docs/commands/gog-slides.md +++ b/docs/commands/gog-slides.md @@ -26,6 +26,7 @@ gog slides (slide) <command> [flags] - [gog slides info](gog-slides-info.md) - Get Google Slides presentation metadata - [gog slides insert-text](gog-slides-insert-text.md) - Insert text into an existing page element (shape or table) by objectId - [gog slides list-slides](gog-slides-list-slides.md) - List all slides with their object IDs +- [gog slides raw](gog-slides-raw.md) - Dump raw Google Slides API response as JSON (Presentations.Get; lossless; for scripting and LLM consumption) - [gog slides read-slide](gog-slides-read-slide.md) - Read slide content: speaker notes, text elements, and images - [gog slides replace-slide](gog-slides-replace-slide.md) - Replace the image on an existing slide in-place - [gog slides replace-text](gog-slides-replace-text.md) - Find-and-replace text across a presentation diff --git a/docs/commands/gog-tasks-raw.md b/docs/commands/gog-tasks-raw.md new file mode 100644 index 0000000..030c513 --- /dev/null +++ b/docs/commands/gog-tasks-raw.md @@ -0,0 +1,43 @@ +# `gog tasks raw` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Dump raw Google Tasks API response as JSON (Tasks.Get; lossless; for scripting and LLM consumption) + +## Usage + +```bash +gog tasks (task) raw <tasklistId> <taskId> [flags] +``` + +## Parent + +- [gog tasks](gog-tasks.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 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`<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 commands; dot paths allowed (restricts CLI) | +| `-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. | +| `-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) | +| `--pretty` | `bool` | | Pretty-print JSON (default: compact single-line) | +| `--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 | + +## See Also + +- [gog tasks](gog-tasks.md) +- [Command index](README.md) diff --git a/docs/commands/gog-tasks.md b/docs/commands/gog-tasks.md index 9c78b3f..4a7f53c 100644 --- a/docs/commands/gog-tasks.md +++ b/docs/commands/gog-tasks.md @@ -23,6 +23,7 @@ gog tasks (task) <command> [flags] - [gog tasks get](gog-tasks-get.md) - Get a task - [gog tasks list](gog-tasks-list.md) - List tasks - [gog tasks lists](gog-tasks-lists.md) - List task lists +- [gog tasks raw](gog-tasks-raw.md) - Dump raw Google Tasks API response as JSON (Tasks.Get; lossless; for scripting and LLM consumption) - [gog tasks undo](gog-tasks-undo.md) - Mark task needs action - [gog tasks update](gog-tasks-update.md) - Update a task diff --git a/docs/raw-audit.md b/docs/raw-audit.md new file mode 100644 index 0000000..e71bb37 --- /dev/null +++ b/docs/raw-audit.md @@ -0,0 +1,174 @@ +# `gog <group> raw` — Sensitive Field Audit + +This document records the security audit performed before shipping the +`gog <group> raw <id>` subcommands, which dump the canonical Google API +response as JSON for programmatic / LLM consumption. + +## Redaction rule + +`raw` applies field-level redaction **only when the user did not explicitly +name a field via `--fields`**. Rationale: + +- The default (implicit `fields=*` for Drive, or no mask for the other + APIs) pulls in capability URLs and third-party–stashed metadata that + callers rarely want and can leak if piped into an LLM, shared in a bug + report, or committed to a repo. +- When a caller writes `--fields "id,name,thumbnailLink"`, they named + `thumbnailLink` deliberately. Redacting a user-named field would be + surprising and user-hostile. + +Summary: **redact what the user didn't ask for; honor what they did.** + +## Per-endpoint Findings: Workspace Content + +### 1. `docs.Documents.Get` — `gog docs raw` + +REST ref: <https://developers.google.com/docs/api/reference/rest/v1/documents/get> +Go type: <https://pkg.go.dev/google.golang.org/api/docs/v1#Document> + +| Field | Risk | Default handling | +|---|---|---| +| `inlineObjects.*.embeddedObject.imageProperties.contentUri` | Short-lived (~30 min) bearer-style authenticated image URL | **Ship as-is** | +| `inlineObjects.*.embeddedObject.imageProperties.sourceUri` | May reference private source URLs | **Ship as-is** | + +**Why not redact:** the Docs API has no field mask, so the principled +"redact only what the user didn't name" rule has no escape hatch. +These image URIs are short-lived (~30 min) and require the caller's +auth anyway — substantially lower risk than Drive's `thumbnailLink`. +The lossless guarantee is valued more than the marginal hardening. + +No credentials, tokens, or OAuth metadata in the response. + +### 2. `sheets.Spreadsheets.Get` — `gog sheets raw` + +REST ref: <https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/get> +Go type: <https://pkg.go.dev/google.golang.org/api/sheets/v4#Spreadsheet> + +| Field | Risk | Default handling | +|---|---|---| +| `developerMetadata` | Third-party apps may stash arbitrary KV including secrets | **Warn on stderr if present**, do not redact | +| `sheets[].data.rowData.values[].userEnteredValue.formulaValue` (only with `--include-grid-data`) | Formulas can embed API keys via `IMPORTRANGE`, hardcoded tokens in cells | **Warn on stderr when `--include-grid-data` is set**, do not redact | + +`--include-grid-data` is **off by default** because grid payloads can be +multi-MB and are the primary leakage vector. + +No redaction applied to Sheets output; warnings only. Redacting cell +content would defeat the purpose of a lossless dump. + +### 3. `slides.Presentations.Get` — `gog slides raw` + +REST ref: <https://developers.google.com/slides/api/reference/rest/v1/presentations/get> +Go type: <https://pkg.go.dev/google.golang.org/api/slides/v1#Presentation> + +| Field | Risk | Default handling | +|---|---|---| +| `slides[].pageElements[].image.contentUrl` | Short-lived authenticated image URL (same class as Docs `contentUri`) | **Ship as-is** | +| `slides[].pageElements[].image.sourceUrl` | Possibly private origin URL | **Ship as-is** | +| `slides[].pageElements[].video.url` | Drive video refs may carry signed access | **Ship as-is** | + +Same rationale as Docs: no field mask, short-lived URLs, auth-gated, +lower risk than Drive. Lossless guarantee preferred. + +### 4. `drive.Files.Get` with `fields=*` — `gog drive raw` *(highest risk)* + +REST ref: <https://developers.google.com/drive/api/reference/rest/v3/files/get> +Go type: <https://pkg.go.dev/google.golang.org/api/drive/v3#File> + +| Field | Risk | Default handling | +|---|---|---| +| `thumbnailLink` | Time-limited signed URL that bypasses normal auth for ~hours. Classic leak vector. | Redact | +| `webContentLink` | Direct download URL; capability URL | Redact | +| `exportLinks` | Per-MIME authenticated export URLs | Redact | +| `resourceKey` | Capability token for link-shared files; effectively a shared secret | Redact | +| `appProperties` | Arbitrary app-stashed KV; apps commonly misuse for secrets | Redact | +| `properties` | Public custom properties, still frequently (mis)used for tokens | Redact | +| `contentHints.thumbnail.image` | Base64 thumbnail bytes; large and unnecessary | Redact | +| `permissions[].emailAddress`, `owners[].emailAddress`, `sharingUser`, `lastModifyingUser`, `trashingUser` | Non-collaborator emails (PII) when `fields=*` enumerates full ACL | Not redacted — caller already has access to the file; enumeration is a conscious `--fields` choice | + +**Reminder:** all of the above are redacted **only when `--fields` is not +set**. Passing `--fields "id,name,thumbnailLink"` returns `thumbnailLink` +verbatim — the user named it. + +## Per-endpoint Findings: Other Services + +### 5. `gmail.Users.Messages.Get` — `gog gmail raw` + +REST ref: <https://developers.google.com/gmail/api/reference/rest/v1/users.messages/get> +Go type: <https://pkg.go.dev/google.golang.org/api/gmail/v1#Message> + +The subcommand name "raw" collides with Gmail's native `format=raw` +(base64url-encoded RFC822 blob). `gog gmail raw` defaults to +`format=FULL` (full parsed Message struct) and exposes `--format +full|metadata|minimal|raw` for users who want Gmail's RAW. Help text +documents both senses. + +| Field | Risk | Default handling | +|---|---|---| +| `payload.body.data` (base64url) | Email body; user already has read access | Ship as-is | +| `payload.headers` | May contain `Received-SPF`, `DKIM-Signature`, routing metadata | Ship as-is | +| `raw` (when `--format=raw`) | Full RFC822 source including original attachments | Ship as-is — user asked for it | + +No credential leakage risk. Caller already holds the Gmail scope. + +### 6. `calendar.Events.Get` — `gog calendar raw` + +REST ref: <https://developers.google.com/calendar/api/v3/reference/events/get> +Go type: <https://pkg.go.dev/google.golang.org/api/calendar/v3#Event> + +| Field | Risk | Default handling | +|---|---|---| +| `attendees[].email` | Attendee emails (PII) | Ship as-is — caller already on the event ACL | +| `conferenceData.entryPoints[].uri` | Meeting URLs (Meet/Zoom) including passwords in params | Ship as-is — user asked for the event | +| `extendedProperties.private` / `extendedProperties.shared` | App-stashed KV; third-party apps may use for secrets | Ship as-is (same rationale as Sheets `developerMetadata`) | + +No redaction. The risk surface here is fundamentally the same as +simply reading the event in the Calendar UI. + +### 7. `people.People.Get` — `gog people raw` / `gog contacts raw` + +REST ref: <https://developers.google.com/people/api/rest/v1/people/get> +Go type: <https://pkg.go.dev/google.golang.org/api/people/v1#Person> + +Both `gog people raw` and `gog contacts raw` call the same underlying +`people.Get` endpoint. Requires `--person-fields` (Google's field mask +for the People API, required on every request). + +| Field | Risk | Default handling | +|---|---|---| +| `emailAddresses`, `phoneNumbers`, `addresses`, `biographies` | PII; user's own contacts | Ship as-is | +| `userDefined[]` | Arbitrary KV custom fields; may store secrets | Ship as-is | +| `metadata.sources[].profileMetadata.userTypes` | Account type disclosure | Ship as-is | + +### 8. `tasks.Tasks.Get` — `gog tasks raw` + +REST ref: <https://developers.google.com/tasks/reference/rest/v1/tasks/get> +Go type: <https://pkg.go.dev/google.golang.org/api/tasks/v1#Task> + +| Field | Risk | Default handling | +|---|---|---| +| `notes` | User-entered free text | Ship as-is | +| `links[].link` | External URLs attached to a task | Ship as-is | + +No sensitivity concerns beyond the caller's own task data. + +### 9. `forms.Forms.Get` — `gog forms raw` + +REST ref: <https://developers.google.com/forms/api/reference/rest/v1/forms/get> +Go type: <https://pkg.go.dev/google.golang.org/api/forms/v1#Form> + +| Field | Risk | Default handling | +|---|---|---| +| `items[].questionItem.question.grading` | Correct answers for graded forms | Ship as-is — caller is the form owner | +| `linkedSheetId` | ID of the responses spreadsheet | Ship as-is | + +No redaction. The form owner already has access to everything here. + +## Cross-cutting observations + +- Google APIs never return OAuth access tokens, refresh tokens, or client + secrets in resource responses. The risk is capability URLs and + app-stashed custom metadata, not credential disclosure in the API + contract itself. +- `gog drive raw` is the most dangerous command; the others are modest in + comparison. +- This audit covers all currently shipped `raw` subcommands. diff --git a/internal/cmd/calendar.go b/internal/cmd/calendar.go index ba5cb6f..63c335a 100644 --- a/internal/cmd/calendar.go +++ b/internal/cmd/calendar.go @@ -8,6 +8,7 @@ type CalendarCmd struct { Alias CalendarAliasCmd `cmd:"" name:"alias" help:"Manage calendar aliases"` Events CalendarEventsCmd `cmd:"" name:"events" aliases:"list,ls" help:"List events from a calendar or all calendars"` Event CalendarEventCmd `cmd:"" name:"event" aliases:"get,info,show" help:"Get event"` + Raw CalendarRawCmd `cmd:"" name:"raw" help:"Dump raw Google Calendar API response as JSON (Events.Get; lossless; for scripting and LLM consumption)"` Create CalendarCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create an event"` Update CalendarUpdateCmd `cmd:"" name:"update" aliases:"edit,set" help:"Update an event"` Move CalendarMoveCmd `cmd:"" name:"move" aliases:"transfer" help:"Move an event to another calendar"` diff --git a/internal/cmd/calendar_raw.go b/internal/cmd/calendar_raw.go new file mode 100644 index 0000000..7679a83 --- /dev/null +++ b/internal/cmd/calendar_raw.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "context" + "errors" + "os" + + "github.com/steipete/gogcli/internal/outfmt" +) + +// CalendarRawCmd dumps the full Events.Get response as JSON, using the +// existing calendar-resolution helper so short names, "primary", and email +// aliases all work the same as they do for `calendar event`. +// +// REST reference: https://developers.google.com/calendar/api/v3/reference/events/get +// Go type: https://pkg.go.dev/google.golang.org/api/calendar/v3#Event +type CalendarRawCmd struct { + CalendarID string `arg:"" name:"calendarId" help:"Calendar ID (e.g. 'primary', an email, or a calendar ID)"` + EventID string `arg:"" name:"eventId" help:"Event ID"` + Pretty bool `name:"pretty" help:"Pretty-print JSON (default: compact single-line)"` +} + +func (c *CalendarRawCmd) Run(ctx context.Context, flags *RootFlags) error { + eventID := normalizeCalendarEventID(c.EventID) + if eventID == "" { + return usage("empty eventId") + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + svc, err := newCalendarService(ctx, account) + if err != nil { + return err + } + + calendarID, err := resolveCalendarSelector(ctx, svc, c.CalendarID, false) + if err != nil { + return err + } + + event, err := svc.Events.Get(calendarID, eventID).Context(ctx).Do() + if err != nil { + return err + } + if event == nil { + return errors.New("event not found") + } + + return outfmt.WriteRaw(ctx, os.Stdout, event, outfmt.RawOptions{Pretty: c.Pretty}) +} diff --git a/internal/cmd/calendar_raw_test.go b/internal/cmd/calendar_raw_test.go new file mode 100644 index 0000000..60f6615 --- /dev/null +++ b/internal/cmd/calendar_raw_test.go @@ -0,0 +1,130 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/calendar/v3" + "google.golang.org/api/option" +) + +func newCalendarRawTestServer(t *testing.T, status int, body map[string]any) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Calendar API uses /calendars/{calId}/events/{evId} for Events.Get + // and /users/me/calendarList for list operations used by the resolver. + path := strings.TrimPrefix(r.URL.Path, "/calendar/v3") + if strings.HasPrefix(path, "/users/me/calendarList") { + // Respond with an empty list so the resolver treats the input as a literal ID. + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"items": []any{}}) + return + } + if !strings.Contains(path, "/events/") || r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + if status != 0 { + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{"code": status, "message": "mock error"}, + }) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body) + })) +} + +func installMockCalendarService(t *testing.T, srv *httptest.Server) { + t.Helper() + orig := newCalendarService + t.Cleanup(func() { newCalendarService = orig }) + + svc, err := calendar.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil } +} + +func fullCalendarEventResponse(id string) map[string]any { + return map[string]any{ + "id": id, + "summary": "Lunch", + "start": map[string]any{"dateTime": "2026-04-08T12:00:00Z"}, + "end": map[string]any{"dateTime": "2026-04-08T13:00:00Z"}, + "attendees": []map[string]any{ + {"email": "a@b.com", "responseStatus": "accepted"}, + }, + } +} + +func TestCalendarRaw_HappyPath(t *testing.T) { + srv := newCalendarRawTestServer(t, 0, fullCalendarEventResponse("ev1")) + defer srv.Close() + installMockCalendarService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + out := captureStdout(t, func() { + if err := runKong(t, &CalendarRawCmd{}, []string{"primary", "ev1"}, ctx, flags); err != nil { + t.Fatalf("run: %v", err) + } + }) + + var got map[string]any + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("invalid JSON: %v\nraw: %s", err, out) + } + if got["id"] != "ev1" { + t.Fatalf("expected id=ev1, got: %v", got["id"]) + } + if _, ok := got["attendees"]; !ok { + t.Fatalf("expected attendees in raw output") + } +} + +func TestCalendarRaw_APIError(t *testing.T) { + srv := newCalendarRawTestServer(t, http.StatusInternalServerError, nil) + defer srv.Close() + installMockCalendarService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + _ = captureStdout(t, func() { + if err := runKong(t, &CalendarRawCmd{}, []string{"primary", "ev1"}, ctx, flags); err == nil { + t.Fatalf("expected error on 500") + } + }) +} + +func TestCalendarRaw_NotFound(t *testing.T) { + srv := newCalendarRawTestServer(t, http.StatusNotFound, nil) + defer srv.Close() + installMockCalendarService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + _ = captureStdout(t, func() { + if err := runKong(t, &CalendarRawCmd{}, []string{"primary", "ev1"}, ctx, flags); err == nil { + t.Fatalf("expected error on 404") + } + }) +} + +func TestCalendarRaw_EmptyEventID(t *testing.T) { + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + if err := (&CalendarRawCmd{CalendarID: "primary"}).Run(ctx, flags); err == nil { + t.Fatalf("expected error on empty eventId") + } +} diff --git a/internal/cmd/contacts.go b/internal/cmd/contacts.go index 28d2366..f4aacc4 100644 --- a/internal/cmd/contacts.go +++ b/internal/cmd/contacts.go @@ -22,6 +22,7 @@ type ContactsCmd struct { Delete ContactsDeleteCmd `cmd:"" name:"delete" aliases:"rm,del,remove" help:"Delete a contact"` Directory ContactsDirectoryCmd `cmd:"" name:"directory" help:"Directory contacts"` Other ContactsOtherCmd `cmd:"" name:"other" help:"Other contacts"` + Raw ContactsRawCmd `cmd:"" name:"raw" help:"Dump raw People API response as JSON (People.Get; lossless; for scripting and LLM consumption)"` } type ContactsSearchCmd struct { diff --git a/internal/cmd/docs.go b/internal/cmd/docs.go index 9a2780b..954d3ea 100644 --- a/internal/cmd/docs.go +++ b/internal/cmd/docs.go @@ -37,6 +37,44 @@ type DocsCmd struct { Sed DocsSedCmd `cmd:"" name:"sed" help:"Regex find/replace (sed-style: s/pattern/replacement/g)"` Clear DocsClearCmd `cmd:"" name:"clear" help:"Clear all content from a Google Doc"` Structure DocsStructureCmd `cmd:"" name:"structure" aliases:"struct" help:"Show document structure with numbered paragraphs"` + Raw DocsRawCmd `cmd:"" name:"raw" help:"Dump raw Google Docs API response as JSON (Documents.Get; lossless; for scripting and LLM consumption)"` +} + +// DocsRawCmd dumps the full Documents.Get response as JSON, with no Fields +// restriction. Intended for programmatic / LLM consumption where the caller +// wants the canonical Google Docs API tree (tables, suggestions, per-run +// styling, list nesting, named ranges, inline objects) that `info` drops. +// +// REST reference: https://developers.google.com/docs/api/reference/rest/v1/documents/get +// Go type: https://pkg.go.dev/google.golang.org/api/docs/v1#Document +type DocsRawCmd struct { + DocID string `arg:"" name:"docId" help:"Doc ID"` + Pretty bool `name:"pretty" help:"Pretty-print JSON (default: compact single-line)"` +} + +func (c *DocsRawCmd) Run(ctx context.Context, flags *RootFlags) error { + id := strings.TrimSpace(c.DocID) + if id == "" { + return usage("empty docId") + } + + svc, err := requireDocsService(ctx, flags) + if err != nil { + return err + } + + doc, err := svc.Documents.Get(id).Context(ctx).Do() + if err != nil { + if isDocsNotFound(err) { + return fmt.Errorf("doc not found or not a Google Doc (id=%s)", id) + } + return err + } + if doc == nil { + return errors.New("doc not found") + } + + return outfmt.WriteRaw(ctx, os.Stdout, doc, outfmt.RawOptions{Pretty: c.Pretty}) } type DocsExportCmd struct { diff --git a/internal/cmd/docs_raw_test.go b/internal/cmd/docs_raw_test.go new file mode 100644 index 0000000..2782da8 --- /dev/null +++ b/internal/cmd/docs_raw_test.go @@ -0,0 +1,172 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/docs/v1" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/ui" +) + +// fullDocResponse returns a richer Document payload than DocsInfoCmd would +// ever request (DocsInfoCmd restricts via Fields). This proves `raw` drops +// that restriction and exposes the full API tree. +func fullDocResponse(id string) map[string]any { + return map[string]any{ + "documentId": id, + "title": "Full Doc", + "revisionId": "rev1", + "body": map[string]any{ + "content": []any{ + map[string]any{ + "startIndex": 1, + "endIndex": 10, + "paragraph": map[string]any{ + "elements": []any{ + map[string]any{ + "textRun": map[string]any{ + "content": "hello world\n", + }, + }, + }, + }, + }, + }, + }, + "namedStyles": map[string]any{ + "styles": []any{map[string]any{"namedStyleType": "NORMAL_TEXT"}}, + }, + } +} + +// newDocsRawTestServer builds a test Docs server; if status != 0 it returns +// that status instead of a successful response. +func newDocsRawTestServer(t *testing.T, status int, body map[string]any) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/v1/documents/") || r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + if status != 0 { + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{"code": status, "message": "mock error"}, + }) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body) + })) +} + +func installMockDocsService(t *testing.T, srv *httptest.Server) { + t.Helper() + orig := newDocsService + t.Cleanup(func() { newDocsService = orig }) + + docSvc, err := docs.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil } +} + +func rawTestContext(t *testing.T) context.Context { + t.Helper() + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + return ui.WithUI(context.Background(), u) +} + +func TestDocsRaw_HappyPath(t *testing.T) { + srv := newDocsRawTestServer(t, 0, fullDocResponse("doc1")) + defer srv.Close() + installMockDocsService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + cmd := &DocsRawCmd{} + if err := runKong(t, cmd, []string{"doc1"}, ctx, flags); err != nil { + t.Fatalf("run: %v", err) + } + }) + + var got map[string]any + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("output is not valid JSON: %v\nraw: %s", err, out) + } + // Bare struct: top-level keys must be Document fields, not a wrapper. + if got["documentId"] != "doc1" { + t.Fatalf("expected documentId=doc1, got: %v", got["documentId"]) + } + // The whole point of raw: body.content must be present (info -j drops it). + body, ok := got["body"].(map[string]any) + if !ok { + t.Fatalf("expected body object in output, got: %v", got["body"]) + } + if _, ok := body["content"]; !ok { + t.Fatalf("expected body.content in raw output") + } +} + +func TestDocsRaw_APIError(t *testing.T) { + srv := newDocsRawTestServer(t, http.StatusInternalServerError, nil) + defer srv.Close() + installMockDocsService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + + _ = captureStdout(t, func() { + cmd := &DocsRawCmd{} + err := runKong(t, cmd, []string{"doc1"}, ctx, flags) + if err == nil { + t.Fatalf("expected error on 500, got nil") + } + }) +} + +func TestDocsRaw_NotFound(t *testing.T) { + srv := newDocsRawTestServer(t, http.StatusNotFound, nil) + defer srv.Close() + installMockDocsService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + + _ = captureStdout(t, func() { + cmd := &DocsRawCmd{} + err := runKong(t, cmd, []string{"doc1"}, ctx, flags) + if err == nil { + t.Fatalf("expected error on 404") + } + if !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected 'not found' in error, got: %v", err) + } + }) +} + +func TestDocsRaw_EmptyDocID(t *testing.T) { + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + err := (&DocsRawCmd{}).Run(ctx, flags) + if err == nil { + t.Fatalf("expected error on empty docId") + } +} diff --git a/internal/cmd/drive.go b/internal/cmd/drive.go index d307a46..73582fc 100644 --- a/internal/cmd/drive.go +++ b/internal/cmd/drive.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -12,6 +13,7 @@ import ( "strings" "google.golang.org/api/drive/v3" + gapi "google.golang.org/api/googleapi" "github.com/steipete/gogcli/internal/googleapi" "github.com/steipete/gogcli/internal/outfmt" @@ -82,6 +84,94 @@ type DriveCmd struct { URL DriveURLCmd `cmd:"" name:"url" help:"Print web URLs for files"` Comments DriveCommentsCmd `cmd:"" name:"comments" help:"Manage comments on files"` Drives DriveDrivesCmd `cmd:"" name:"drives" help:"List shared drives (Team Drives)"` + Raw DriveRawCmd `cmd:"" name:"raw" help:"Dump raw Google Drive API response as JSON (Files.Get; lossless; for scripting and LLM consumption)"` +} + +// driveRawSensitiveFields is the set of top-level File fields redacted from +// `gog drive raw` output when the user did not name them via --fields. See +// docs/raw-audit.md for the rationale per field. +var driveRawSensitiveFields = []string{ + "thumbnailLink", + "webContentLink", + "exportLinks", + "resourceKey", + "appProperties", + "properties", +} + +// DriveRawCmd dumps the full Files.Get response as JSON. Uses fields=* by +// default to expose the entire File resource. When --fields is absent the +// command redacts a small set of capability/token-shaped fields (see +// driveRawSensitiveFields); when --fields is explicitly set the response is +// returned verbatim, honoring exactly what the user asked for. This means +// passing `--fields "id,name,thumbnailLink"` returns thumbnailLink as +// requested. +// +// REST reference: https://developers.google.com/drive/api/reference/rest/v3/files/get +// Go type: https://pkg.go.dev/google.golang.org/api/drive/v3#File +type DriveRawCmd struct { + FileID string `arg:"" name:"fileId" help:"File ID"` + Fields string `name:"fields" help:"Drive API field mask (default: * with sensitive fields redacted client-side). Set explicitly to disable redaction."` + Pretty bool `name:"pretty" help:"Pretty-print JSON (default: compact single-line)"` +} + +func (c *DriveRawCmd) Run(ctx context.Context, flags *RootFlags) error { + fileID := strings.TrimSpace(c.FileID) + if fileID == "" { + return usage("empty fileId") + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + svc, err := newDriveService(ctx, account) + if err != nil { + return err + } + + userSetFields := strings.TrimSpace(c.Fields) != "" + mask := "*" + if userSetFields { + mask = c.Fields + } + + f, err := svc.Files.Get(fileID). + SupportsAllDrives(true). + Fields(gapi.Field(mask)). + Context(ctx). + Do() + if err != nil { + return err + } + if f == nil { + return errors.New("file not found") + } + + // Round-trip through JSON so we can redact by key when needed. + raw, err := json.Marshal(f) + if err != nil { + return fmt.Errorf("marshal drive file: %w", err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + return fmt.Errorf("unmarshal drive file: %w", err) + } + + // Redact only when the user did not explicitly request fields. + if !userSetFields { + for _, key := range driveRawSensitiveFields { + delete(m, key) + } + // contentHints.thumbnail.image is the one nested leak. + if hints, ok := m["contentHints"].(map[string]any); ok { + if thumb, ok := hints["thumbnail"].(map[string]any); ok { + delete(thumb, "image") + } + } + } + + return outfmt.WriteRaw(ctx, os.Stdout, m, outfmt.RawOptions{Pretty: c.Pretty}) } type DriveLsCmd struct { @@ -91,6 +181,7 @@ type DriveLsCmd struct { Parent string `name:"parent" help:"Folder ID to list (default: root)"` All bool `name:"all" aliases:"global" help:"List all accessible files (mutually exclusive with --parent)"` AllDrives bool `name:"all-drives" help:"Include shared drives (default: true; use --no-all-drives for My Drive only)" default:"true" negatable:"_"` + Fields string `name:"fields" help:"Drive API field mask (overrides the default set; e.g. 'files(id,name,thumbnailLink),nextPageToken')"` } type DriveSearchCmd struct { @@ -105,6 +196,7 @@ type DriveSearchCmd struct { type DriveGetCmd struct { FileID string `arg:"" name:"fileId" help:"File ID"` + Fields string `name:"fields" help:"Drive API field mask (overrides the default set; e.g. 'id,name,thumbnailLink')"` } func (c *DriveGetCmd) Run(ctx context.Context, flags *RootFlags) error { @@ -123,9 +215,13 @@ func (c *DriveGetCmd) Run(ctx context.Context, flags *RootFlags) error { return err } + mask := driveFileGetFields + if strings.TrimSpace(c.Fields) != "" { + mask = c.Fields + } f, err := svc.Files.Get(fileID). SupportsAllDrives(true). - Fields(driveFileGetFields). + Fields(gapi.Field(mask)). Context(ctx). Do() if err != nil { diff --git a/internal/cmd/drive_fields_test.go b/internal/cmd/drive_fields_test.go new file mode 100644 index 0000000..699a573 --- /dev/null +++ b/internal/cmd/drive_fields_test.go @@ -0,0 +1,138 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "google.golang.org/api/drive/v3" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type driveFieldsHit struct { + lastFields atomic.Value // string +} + +func newDriveFieldsTestServer(t *testing.T, handler func(r *http.Request) map[string]any, hit *driveFieldsHit) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if hit != nil { + hit.lastFields.Store(r.URL.Query().Get("fields")) + } + body := handler(r) + if body == nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body) + })) +} + +// TestDriveGet_FieldsFlag proves --fields on `gog drive get` maps to the +// Drive API fields parameter, enabling requests for fields the hard-coded +// default did not include (e.g. thumbnailLink). Closes #486. +func TestDriveGet_FieldsFlag(t *testing.T) { + hit := &driveFieldsHit{} + srv := newDriveFieldsTestServer(t, func(r *http.Request) map[string]any { + if !strings.Contains(r.URL.Path, "/files/f1") { + return nil + } + return map[string]any{ + "id": "f1", + "name": "photo.png", + "mimeType": "image/png", + "thumbnailLink": "https://drive.google.com/thumb/f1", + } + }, hit) + defer srv.Close() + installMockDriveService(t, srv) + + u, err := ui.New(ui.Options{Stdout: nil, Stderr: nil, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := runKong(t, &DriveGetCmd{}, []string{"f1", "--fields", "id,name,thumbnailLink"}, ctx, flags); err != nil { + t.Fatalf("run: %v", err) + } + }) + + got, _ := hit.lastFields.Load().(string) + if !strings.Contains(got, "thumbnailLink") { + t.Fatalf("expected Drive API fields param to contain thumbnailLink, got: %q", got) + } + // Output should wrap under "file" per existing drive get -j contract. + var envelope map[string]any + if err := json.Unmarshal([]byte(out), &envelope); err != nil { + t.Fatalf("invalid JSON: %v\nraw: %s", err, out) + } + file, ok := envelope["file"].(map[string]any) + if !ok { + t.Fatalf("expected file envelope, got: %v", envelope) + } + if file["thumbnailLink"] != "https://drive.google.com/thumb/f1" { + t.Fatalf("expected thumbnailLink passthrough, got: %v", file["thumbnailLink"]) + } +} + +// TestDriveLs_FieldsFlag proves --fields on `gog drive ls` maps through to +// the Drive API list fields parameter so consumers can request fields not +// in the hard-coded default set. +func TestDriveLs_FieldsFlag(t *testing.T) { + hit := &driveFieldsHit{} + srv := newDriveFieldsTestServer(t, func(r *http.Request) map[string]any { + path := strings.TrimPrefix(r.URL.Path, "/drive/v3") + if path != "/files" { + return nil + } + return map[string]any{ + "files": []map[string]any{ + { + "id": "f1", + "name": "photo.png", + "mimeType": "image/png", + "thumbnailLink": "https://drive.google.com/thumb/f1", + }, + }, + } + }, hit) + defer srv.Close() + installMockDriveService(t, srv) + + u, err := ui.New(ui.Options{Stdout: nil, Stderr: nil, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := runKong(t, &DriveLsCmd{}, []string{"--fields", "files(id,name,thumbnailLink)"}, ctx, flags); err != nil { + t.Fatalf("run: %v", err) + } + }) + + got, _ := hit.lastFields.Load().(string) + if !strings.Contains(got, "thumbnailLink") { + t.Fatalf("expected Drive API fields param to contain thumbnailLink, got: %q", got) + } + if !strings.Contains(out, "thumbnailLink") { + t.Fatalf("expected thumbnailLink in output, got: %q", out) + } +} + +// Silence unused package warning when the test only references drive.Service +// indirectly through installMockDriveService. +var _ = drive.Service{} diff --git a/internal/cmd/drive_listing.go b/internal/cmd/drive_listing.go index c4ac52f..cef512c 100644 --- a/internal/cmd/drive_listing.go +++ b/internal/cmd/drive_listing.go @@ -7,6 +7,7 @@ import ( "strings" "google.golang.org/api/drive/v3" + gapi "google.golang.org/api/googleapi" "github.com/steipete/gogcli/internal/outfmt" "github.com/steipete/gogcli/internal/ui" @@ -18,6 +19,7 @@ type driveFileListOptions struct { page string allDrives bool driveID string + fields string // optional field mask override } func (c *DriveLsCmd) Run(ctx context.Context, flags *RootFlags) error { @@ -45,6 +47,7 @@ func (c *DriveLsCmd) Run(ctx context.Context, flags *RootFlags) error { max: c.Max, page: c.Page, allDrives: c.AllDrives, + fields: c.Fields, }) if err != nil { return err @@ -99,7 +102,11 @@ func listDriveFiles(ctx context.Context, svc *drive.Service, opts driveFileListO PageToken(opts.page). OrderBy("modifiedTime desc") call = driveFilesListCallWithDriveSupport(call, opts.allDrives, opts.driveID) - return call.Fields(driveFileListFields).Context(ctx).Do() + mask := driveFileListFields + if strings.TrimSpace(opts.fields) != "" { + mask = opts.fields + } + return call.Fields(gapi.Field(mask)).Context(ctx).Do() } func writeDriveFileList(ctx context.Context, resp *drive.FileList, emptyMessage string) error { diff --git a/internal/cmd/drive_raw_test.go b/internal/cmd/drive_raw_test.go new file mode 100644 index 0000000..319688d --- /dev/null +++ b/internal/cmd/drive_raw_test.go @@ -0,0 +1,181 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "google.golang.org/api/drive/v3" + "google.golang.org/api/option" +) + +type driveRawHit struct { + lastFields atomic.Value // string +} + +func newDriveRawTestServer(t *testing.T, status int, body map[string]any, hit *driveRawHit) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/drive/v3") + if !strings.HasPrefix(path, "/files/") || r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + if hit != nil { + hit.lastFields.Store(r.URL.Query().Get("fields")) + } + if status != 0 { + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{"code": status, "message": "mock error"}, + }) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body) + })) +} + +func installMockDriveService(t *testing.T, srv *httptest.Server) { + t.Helper() + orig := newDriveService + t.Cleanup(func() { newDriveService = orig }) + + svc, err := drive.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newDriveService = func(context.Context, string) (*drive.Service, error) { return svc, nil } +} + +// sensitiveDriveFile returns a File response containing every sensitive +// field the audit says should be redacted by default. +func sensitiveDriveFile(id string) map[string]any { + return map[string]any{ + "id": id, + "name": "secrets.txt", + "mimeType": "text/plain", + "thumbnailLink": "https://drive.google.com/thumb/XYZ?token=LEAK", + "webContentLink": "https://drive.google.com/download?id=XYZ", + "exportLinks": map[string]any{"application/pdf": "https://drive.google.com/export?id=XYZ"}, + "resourceKey": "rk-CAPABILITY-SECRET", + "appProperties": map[string]any{"api_token": "sk-live-0000"}, + "properties": map[string]any{"webhook": "https://hooks.example/private"}, + "webViewLink": "https://docs.google.com/open?id=XYZ", + "createdTime": "2026-01-01T00:00:00Z", + "modifiedTime": "2026-01-02T00:00:00Z", + } +} + +func TestDriveRaw_DefaultRedactsSensitiveFields(t *testing.T) { + hit := &driveRawHit{} + srv := newDriveRawTestServer(t, 0, sensitiveDriveFile("f1"), hit) + defer srv.Close() + installMockDriveService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + out := captureStdout(t, func() { + if err := runKong(t, &DriveRawCmd{}, []string{"f1"}, ctx, flags); err != nil { + t.Fatalf("run: %v", err) + } + }) + + // Default must request fields=* from the API. + if got, _ := hit.lastFields.Load().(string); got != "*" { + t.Fatalf("expected fields=* by default, got: %q", got) + } + + var fileOut map[string]any + if err := json.Unmarshal([]byte(out), &fileOut); err != nil { + t.Fatalf("invalid JSON: %v\nraw: %s", err, out) + } + // Safe fields remain. + if fileOut["id"] != "f1" { + t.Fatalf("expected id=f1, got: %v", fileOut["id"]) + } + if fileOut["name"] != "secrets.txt" { + t.Fatalf("expected name present, got: %v", fileOut["name"]) + } + // Sensitive fields stripped. + for _, key := range []string{"thumbnailLink", "webContentLink", "exportLinks", "resourceKey", "appProperties", "properties"} { + if _, present := fileOut[key]; present { + t.Fatalf("expected %q to be redacted from default output, got: %v", key, fileOut[key]) + } + } +} + +func TestDriveRaw_ExplicitFieldsHonorsUserChoice(t *testing.T) { + hit := &driveRawHit{} + srv := newDriveRawTestServer(t, 0, sensitiveDriveFile("f1"), hit) + defer srv.Close() + installMockDriveService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + out := captureStdout(t, func() { + if err := runKong(t, &DriveRawCmd{}, []string{"f1", "--fields", "id,name,thumbnailLink"}, ctx, flags); err != nil { + t.Fatalf("run: %v", err) + } + }) + + // The Google client library can munge the fields value slightly, but it + // must contain the user's requested field names. + got, _ := hit.lastFields.Load().(string) + if !strings.Contains(got, "thumbnailLink") { + t.Fatalf("expected thumbnailLink in fields query, got: %q", got) + } + + var fileOut map[string]any + if err := json.Unmarshal([]byte(out), &fileOut); err != nil { + t.Fatalf("invalid JSON: %v\nraw: %s", err, out) + } + // User-named field must NOT be redacted. + if fileOut["thumbnailLink"] != "https://drive.google.com/thumb/XYZ?token=LEAK" { + t.Fatalf("expected thumbnailLink to be preserved when user named it, got: %v", fileOut["thumbnailLink"]) + } +} + +func TestDriveRaw_APIError(t *testing.T) { + srv := newDriveRawTestServer(t, http.StatusInternalServerError, nil, nil) + defer srv.Close() + installMockDriveService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + _ = captureStdout(t, func() { + if err := runKong(t, &DriveRawCmd{}, []string{"f1"}, ctx, flags); err == nil { + t.Fatalf("expected error on 500") + } + }) +} + +func TestDriveRaw_NotFound(t *testing.T) { + srv := newDriveRawTestServer(t, http.StatusNotFound, nil, nil) + defer srv.Close() + installMockDriveService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + _ = captureStdout(t, func() { + if err := runKong(t, &DriveRawCmd{}, []string{"f1"}, ctx, flags); err == nil { + t.Fatalf("expected error on 404") + } + }) +} + +func TestDriveRaw_EmptyID(t *testing.T) { + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + if err := (&DriveRawCmd{}).Run(ctx, flags); err == nil { + t.Fatalf("expected error on empty id") + } +} diff --git a/internal/cmd/forms.go b/internal/cmd/forms.go index 9a14a03..b42ceb7 100644 --- a/internal/cmd/forms.go +++ b/internal/cmd/forms.go @@ -24,6 +24,7 @@ type FormsCmd struct { MoveQuestion FormsMoveQuestionCmd `cmd:"" name:"move-question" aliases:"move-q,mq" help:"Move a question to a new position"` Responses FormsResponsesCmd `cmd:"" name:"responses" help:"Form responses"` Watch FormsWatchCmd `cmd:"" name:"watch" aliases:"watches" help:"Response watches (push notifications)"` + Raw FormsRawCmd `cmd:"" name:"raw" help:"Dump raw Google Forms API response as JSON (Forms.Get; lossless; for scripting and LLM consumption)"` } type FormsResponsesCmd struct { diff --git a/internal/cmd/forms_raw.go b/internal/cmd/forms_raw.go new file mode 100644 index 0000000..ef78cbf --- /dev/null +++ b/internal/cmd/forms_raw.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "context" + "errors" + "os" + "strings" + + "github.com/steipete/gogcli/internal/outfmt" +) + +// FormsRawCmd dumps the full Forms.Get response as JSON. +// +// REST reference: https://developers.google.com/forms/api/reference/rest/v1/forms/get +// Go type: https://pkg.go.dev/google.golang.org/api/forms/v1#Form +type FormsRawCmd struct { + FormID string `arg:"" name:"formId" help:"Form ID"` + Pretty bool `name:"pretty" help:"Pretty-print JSON (default: compact single-line)"` +} + +func (c *FormsRawCmd) Run(ctx context.Context, flags *RootFlags) error { + formID := strings.TrimSpace(c.FormID) + if formID == "" { + return usage("empty formId") + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + svc, err := newFormsService(ctx, account) + if err != nil { + return err + } + + form, err := svc.Forms.Get(formID).Context(ctx).Do() + if err != nil { + return err + } + if form == nil { + return errors.New("form not found") + } + + return outfmt.WriteRaw(ctx, os.Stdout, form, outfmt.RawOptions{Pretty: c.Pretty}) +} diff --git a/internal/cmd/forms_raw_test.go b/internal/cmd/forms_raw_test.go new file mode 100644 index 0000000..cf1cf6c --- /dev/null +++ b/internal/cmd/forms_raw_test.go @@ -0,0 +1,122 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + formsapi "google.golang.org/api/forms/v1" + "google.golang.org/api/option" +) + +func newFormsRawTestServer(t *testing.T, status int, body map[string]any) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/forms/") || r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + if status != 0 { + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{"code": status, "message": "mock error"}, + }) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body) + })) +} + +func installMockFormsService(t *testing.T, srv *httptest.Server) { + t.Helper() + orig := newFormsService + t.Cleanup(func() { newFormsService = orig }) + + svc, err := formsapi.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newFormsService = func(context.Context, string) (*formsapi.Service, error) { return svc, nil } +} + +func fullFormResponse(id string) map[string]any { + return map[string]any{ + "formId": id, + "info": map[string]any{ + "title": "Survey", + "description": "Tell us what you think", + }, + "items": []map[string]any{ + {"itemId": "q1", "title": "How are you?"}, + }, + } +} + +func TestFormsRaw_HappyPath(t *testing.T) { + srv := newFormsRawTestServer(t, 0, fullFormResponse("form1")) + defer srv.Close() + installMockFormsService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + out := captureStdout(t, func() { + if err := runKong(t, &FormsRawCmd{}, []string{"form1"}, ctx, flags); err != nil { + t.Fatalf("run: %v", err) + } + }) + + var got map[string]any + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("invalid JSON: %v\nraw: %s", err, out) + } + if got["formId"] != "form1" { + t.Fatalf("expected formId=form1, got: %v", got["formId"]) + } + if _, ok := got["items"]; !ok { + t.Fatalf("expected items in raw output") + } +} + +func TestFormsRaw_APIError(t *testing.T) { + srv := newFormsRawTestServer(t, http.StatusInternalServerError, nil) + defer srv.Close() + installMockFormsService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + _ = captureStdout(t, func() { + if err := runKong(t, &FormsRawCmd{}, []string{"form1"}, ctx, flags); err == nil { + t.Fatalf("expected error on 500") + } + }) +} + +func TestFormsRaw_NotFound(t *testing.T) { + srv := newFormsRawTestServer(t, http.StatusNotFound, nil) + defer srv.Close() + installMockFormsService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + _ = captureStdout(t, func() { + if err := runKong(t, &FormsRawCmd{}, []string{"form1"}, ctx, flags); err == nil { + t.Fatalf("expected error on 404") + } + }) +} + +func TestFormsRaw_EmptyID(t *testing.T) { + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + if err := (&FormsRawCmd{}).Run(ctx, flags); err == nil { + t.Fatalf("expected error on empty id") + } +} diff --git a/internal/cmd/gmail.go b/internal/cmd/gmail.go index 4be0772..c3d1322 100644 --- a/internal/cmd/gmail.go +++ b/internal/cmd/gmail.go @@ -9,6 +9,7 @@ type GmailCmd struct { Messages GmailMessagesCmd `cmd:"" name:"messages" aliases:"message,msg,msgs" group:"Read" help:"Message operations"` Thread GmailThreadCmd `cmd:"" name:"thread" aliases:"threads,read" group:"Organize" help:"Thread operations (get, modify)"` Get GmailGetCmd `cmd:"" name:"get" aliases:"info,show" group:"Read" help:"Get a message (full|metadata|raw)"` + Raw GmailRawCmd `cmd:"" name:"raw" group:"Read" help:"Dump raw Gmail API response as JSON (Users.Messages.Get; lossless; for scripting and LLM consumption)"` Attachment GmailAttachmentCmd `cmd:"" name:"attachment" group:"Read" help:"Download a single attachment"` URL GmailURLCmd `cmd:"" name:"url" group:"Read" help:"Print Gmail web URLs for threads"` History GmailHistoryCmd `cmd:"" name:"history" group:"Read" help:"Gmail history"` diff --git a/internal/cmd/gmail_get.go b/internal/cmd/gmail_get.go index e2e58f7..00d9217 100644 --- a/internal/cmd/gmail_get.go +++ b/internal/cmd/gmail_get.go @@ -21,6 +21,7 @@ type GmailGetCmd struct { const ( gmailFormatFull = "full" gmailFormatMetadata = "metadata" + gmailFormatMinimal = "minimal" gmailFormatRaw = "raw" ) diff --git a/internal/cmd/gmail_raw.go b/internal/cmd/gmail_raw.go new file mode 100644 index 0000000..7bd8ae4 --- /dev/null +++ b/internal/cmd/gmail_raw.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/steipete/gogcli/internal/outfmt" +) + +// GmailRawCmd dumps the full Users.Messages.Get response as JSON. Note the +// naming collision: "raw" is both the gog-side subcommand name (meaning +// "dump the full API response") and a Gmail API `format=raw` value meaning +// "base64url-encoded RFC822 source". This command defaults to +// `format=full` (the structured parsed message). Pass `--format raw` to +// get Gmail's native RAW — the base64url blob will still appear as the +// `raw` field inside the JSON response. +// +// REST reference: https://developers.google.com/gmail/api/reference/rest/v1/users.messages/get +// Go type: https://pkg.go.dev/google.golang.org/api/gmail/v1#Message +type GmailRawCmd struct { + MessageID string `arg:"" name:"messageId" help:"Message ID"` + Format string `name:"format" help:"Gmail format: full|metadata|minimal|raw (default: full; note: 'raw' here means Gmail's base64url RFC822 blob, NOT the gog raw subcommand sense)" default:"full"` + Pretty bool `name:"pretty" help:"Pretty-print JSON (default: compact single-line)"` +} + +func (c *GmailRawCmd) Run(ctx context.Context, flags *RootFlags) error { + messageID := normalizeGmailMessageID(strings.TrimSpace(c.MessageID)) + if messageID == "" { + return usage("empty messageId") + } + + format := strings.TrimSpace(c.Format) + if format == "" { + format = gmailFormatFull + } + + switch format { + case gmailFormatFull, gmailFormatMetadata, gmailFormatMinimal, gmailFormatRaw: + default: + return fmt.Errorf("invalid --format: %q (expected full|metadata|minimal|raw)", format) + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + svc, err := newGmailService(ctx, account) + if err != nil { + return err + } + + msg, err := svc.Users.Messages.Get("me", messageID).Format(format).Context(ctx).Do() + if err != nil { + return err + } + if msg == nil { + return errors.New("message not found") + } + + return outfmt.WriteRaw(ctx, os.Stdout, msg, outfmt.RawOptions{Pretty: c.Pretty}) +} diff --git a/internal/cmd/gmail_raw_test.go b/internal/cmd/gmail_raw_test.go new file mode 100644 index 0000000..e7b2b8a --- /dev/null +++ b/internal/cmd/gmail_raw_test.go @@ -0,0 +1,170 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "google.golang.org/api/gmail/v1" + "google.golang.org/api/option" +) + +type gmailRawHit struct { + lastFormat atomic.Value // string +} + +func newGmailRawTestServer(t *testing.T, status int, body map[string]any, hit *gmailRawHit) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/users/me/messages/") || r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + if hit != nil { + hit.lastFormat.Store(r.URL.Query().Get("format")) + } + if status != 0 { + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{"code": status, "message": "mock error"}, + }) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body) + })) +} + +func installMockGmailService(t *testing.T, srv *httptest.Server) { + t.Helper() + orig := newGmailService + t.Cleanup(func() { newGmailService = orig }) + + svc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } +} + +func fullGmailMessageResponse(id string) map[string]any { + return map[string]any{ + "id": id, + "threadId": "t1", + "labelIds": []string{"INBOX"}, + "snippet": "hello world", + "payload": map[string]any{ + "mimeType": "text/plain", + "headers": []map[string]any{ + {"name": "From", "value": "a@b.com"}, + {"name": "Subject", "value": "hi"}, + }, + }, + } +} + +func TestGmailRaw_HappyPath_DefaultFormatFull(t *testing.T) { + hit := &gmailRawHit{} + srv := newGmailRawTestServer(t, 0, fullGmailMessageResponse("m1"), hit) + defer srv.Close() + installMockGmailService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + out := captureStdout(t, func() { + if err := runKong(t, &GmailRawCmd{}, []string{"m1"}, ctx, flags); err != nil { + t.Fatalf("run: %v", err) + } + }) + + if got, _ := hit.lastFormat.Load().(string); got != "full" { + t.Fatalf("expected default format=full, got: %q", got) + } + + var fileOut map[string]any + if err := json.Unmarshal([]byte(out), &fileOut); err != nil { + t.Fatalf("invalid JSON: %v\nraw: %s", err, out) + } + if fileOut["id"] != "m1" { + t.Fatalf("expected id=m1, got: %v", fileOut["id"]) + } + // Bare struct — no wrapper. + if _, wrapped := fileOut["message"]; wrapped { + t.Fatalf("raw output must not be wrapped, got: %v", fileOut) + } +} + +func TestGmailRaw_FormatPropagation(t *testing.T) { + for _, fmt := range []string{"full", "metadata", "minimal", "raw"} { + t.Run(fmt, func(t *testing.T) { + hit := &gmailRawHit{} + srv := newGmailRawTestServer(t, 0, fullGmailMessageResponse("m1"), hit) + defer srv.Close() + installMockGmailService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + _ = captureStdout(t, func() { + if err := runKong(t, &GmailRawCmd{}, []string{"m1", "--format", fmt}, ctx, flags); err != nil { + t.Fatalf("run: %v", err) + } + }) + if got, _ := hit.lastFormat.Load().(string); got != fmt { + t.Fatalf("expected format=%s in request, got: %q", fmt, got) + } + }) + } +} + +func TestGmailRaw_InvalidFormat(t *testing.T) { + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + err := (&GmailRawCmd{MessageID: "m1", Format: "bogus"}).Run(ctx, flags) + if err == nil { + t.Fatalf("expected error on invalid format") + } +} + +func TestGmailRaw_APIError(t *testing.T) { + srv := newGmailRawTestServer(t, http.StatusInternalServerError, nil, nil) + defer srv.Close() + installMockGmailService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + _ = captureStdout(t, func() { + if err := runKong(t, &GmailRawCmd{}, []string{"m1"}, ctx, flags); err == nil { + t.Fatalf("expected error on 500") + } + }) +} + +func TestGmailRaw_NotFound(t *testing.T) { + srv := newGmailRawTestServer(t, http.StatusNotFound, nil, nil) + defer srv.Close() + installMockGmailService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + _ = captureStdout(t, func() { + if err := runKong(t, &GmailRawCmd{}, []string{"m1"}, ctx, flags); err == nil { + t.Fatalf("expected error on 404") + } + }) +} + +func TestGmailRaw_EmptyID(t *testing.T) { + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + if err := (&GmailRawCmd{}).Run(ctx, flags); err == nil { + t.Fatalf("expected error on empty id") + } +} diff --git a/internal/cmd/people.go b/internal/cmd/people.go index 3d7274d..cf8c27b 100644 --- a/internal/cmd/people.go +++ b/internal/cmd/people.go @@ -13,6 +13,7 @@ type PeopleCmd struct { Get PeopleGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a user profile by ID"` Search PeopleSearchCmd `cmd:"" name:"search" aliases:"find,query" help:"Search the Workspace directory"` Relations PeopleRelationsCmd `cmd:"" name:"relations" help:"Get user relations"` + Raw PeopleRawCmd `cmd:"" name:"raw" help:"Dump raw People API response as JSON (People.Get; lossless; for scripting and LLM consumption)"` } type PeopleMeCmd struct{} diff --git a/internal/cmd/people_raw.go b/internal/cmd/people_raw.go new file mode 100644 index 0000000..97a0edb --- /dev/null +++ b/internal/cmd/people_raw.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "context" + "errors" + "os" + "strings" + + "github.com/steipete/gogcli/internal/outfmt" +) + +// defaultPeopleRawMask is the field mask used when the user does not +// supply --person-fields. Covers the commonly useful Person fields. +const defaultPeopleRawMask = "names,emailAddresses,phoneNumbers,organizations,urls,addresses,biographies,birthdays,photos,metadata,relations,userDefined,memberships,events,imClients,interests,locales,nicknames,occupations,skills" + +// PeopleRawCmd dumps the full People.Get response as JSON. Requires the +// People API field mask (set via --person-fields). Defaults to a broad +// set covering commonly useful Person resource fields. +// +// REST reference: https://developers.google.com/people/api/rest/v1/people/get +// Go type: https://pkg.go.dev/google.golang.org/api/people/v1#Person +type PeopleRawCmd struct { + UserID string `arg:"" name:"userId" help:"Person resource name (people/...) or email"` + PersonFields string `name:"person-fields" help:"People API personFields mask (default: broad set; pass a narrower list to reduce output)"` + Pretty bool `name:"pretty" help:"Pretty-print JSON (default: compact single-line)"` +} + +func (c *PeopleRawCmd) Run(ctx context.Context, flags *RootFlags) error { + return runPeopleRaw(ctx, flags, c.UserID, c.PersonFields, c.Pretty) +} + +// ContactsRawCmd mirrors PeopleRawCmd but lives under the `contacts` group +// for users who think of these operations in contact terms. Wraps the +// same underlying People.Get call. +// +// REST reference: https://developers.google.com/people/api/rest/v1/people/get +type ContactsRawCmd struct { + Identifier string `arg:"" name:"identifier" help:"Contact resource name (people/...) or email"` + PersonFields string `name:"person-fields" help:"People API personFields mask (default: broad set)"` + Pretty bool `name:"pretty" help:"Pretty-print JSON (default: compact single-line)"` +} + +func (c *ContactsRawCmd) Run(ctx context.Context, flags *RootFlags) error { + return runPeopleRaw(ctx, flags, c.Identifier, c.PersonFields, c.Pretty) +} + +func runPeopleRaw(ctx context.Context, flags *RootFlags, id, fields string, pretty bool) error { + resource := normalizePeopleResource(id) + if resource == "" { + return usage("required: resource name or email") + } + + mask := defaultPeopleRawMask + if trimmed := strings.TrimSpace(fields); trimmed != "" { + mask = trimmed + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + svc, err := newPeopleContactsService(ctx, account) + if err != nil { + return wrapPeopleAPIError(err) + } + + person, err := svc.People.Get(resource).PersonFields(mask).Context(ctx).Do() + if err != nil { + return wrapPeopleAPIError(err) + } + if person == nil { + return errors.New("person not found") + } + + return outfmt.WriteRaw(ctx, os.Stdout, person, outfmt.RawOptions{Pretty: pretty}) +} diff --git a/internal/cmd/people_raw_test.go b/internal/cmd/people_raw_test.go new file mode 100644 index 0000000..248bd6e --- /dev/null +++ b/internal/cmd/people_raw_test.go @@ -0,0 +1,138 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/option" + "google.golang.org/api/people/v1" +) + +func newPeopleRawTestServer(t *testing.T, status int, body map[string]any) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/people/") || r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + if status != 0 { + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{"code": status, "message": "mock error"}, + }) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body) + })) +} + +func installMockPeopleContactsService(t *testing.T, srv *httptest.Server) { + t.Helper() + orig := newPeopleContactsService + t.Cleanup(func() { newPeopleContactsService = orig }) + + svc, err := people.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newPeopleContactsService = func(context.Context, string) (*people.Service, error) { return svc, nil } +} + +func fullPersonResponse(id string) map[string]any { + return map[string]any{ + "resourceName": id, + "etag": "abc", + "names": []map[string]any{ + {"displayName": "Ada Lovelace", "givenName": "Ada", "familyName": "Lovelace"}, + }, + "emailAddresses": []map[string]any{ + {"value": "ada@example.com"}, + }, + } +} + +func TestPeopleRaw_HappyPath(t *testing.T) { + srv := newPeopleRawTestServer(t, 0, fullPersonResponse("people/c1")) + defer srv.Close() + installMockPeopleContactsService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + out := captureStdout(t, func() { + if err := runKong(t, &PeopleRawCmd{}, []string{"people/c1"}, ctx, flags); err != nil { + t.Fatalf("run: %v", err) + } + }) + + var got map[string]any + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("invalid JSON: %v\nraw: %s", err, out) + } + if got["resourceName"] != "people/c1" { + t.Fatalf("expected resourceName=people/c1, got: %v", got["resourceName"]) + } + if _, ok := got["names"]; !ok { + t.Fatalf("expected names in raw output") + } +} + +func TestContactsRaw_HappyPath(t *testing.T) { + srv := newPeopleRawTestServer(t, 0, fullPersonResponse("people/c1")) + defer srv.Close() + installMockPeopleContactsService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + out := captureStdout(t, func() { + if err := runKong(t, &ContactsRawCmd{}, []string{"people/c1"}, ctx, flags); err != nil { + t.Fatalf("run: %v", err) + } + }) + + var got map[string]any + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("invalid JSON: %v\nraw: %s", err, out) + } + if got["resourceName"] != "people/c1" { + t.Fatalf("expected resourceName=people/c1, got: %v", got["resourceName"]) + } +} + +func TestPeopleRaw_APIError(t *testing.T) { + srv := newPeopleRawTestServer(t, http.StatusInternalServerError, nil) + defer srv.Close() + installMockPeopleContactsService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + _ = captureStdout(t, func() { + if err := runKong(t, &PeopleRawCmd{}, []string{"people/c1"}, ctx, flags); err == nil { + t.Fatalf("expected error on 500") + } + }) +} + +func TestPeopleRaw_EmptyID(t *testing.T) { + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + if err := (&PeopleRawCmd{}).Run(ctx, flags); err == nil { + t.Fatalf("expected error on empty id") + } +} + +func TestContactsRaw_EmptyID(t *testing.T) { + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + if err := (&ContactsRawCmd{}).Run(ctx, flags); err == nil { + t.Fatalf("expected error on empty id") + } +} diff --git a/internal/cmd/sheets.go b/internal/cmd/sheets.go index 25c5d01..bb7c85d 100644 --- a/internal/cmd/sheets.go +++ b/internal/cmd/sheets.go @@ -3,6 +3,7 @@ package cmd import ( "context" "encoding/json" + "errors" "fmt" "os" "strings" @@ -43,6 +44,7 @@ type SheetsCmd struct { Links SheetsLinksCmd `cmd:"" name:"links" aliases:"hyperlinks" help:"Get cell hyperlinks from a range"` Named SheetsNamedRangesCmd `cmd:"" name:"named-ranges" aliases:"namedranges,nr" help:"Manage named ranges"` 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"` Copy SheetsCopyCmd `cmd:"" name:"copy" aliases:"cp,duplicate" help:"Copy a Google Sheet"` Export SheetsExportCmd `cmd:"" name:"export" aliases:"download,dl" help:"Export a Google Sheet (pdf|xlsx|csv) via Drive"` @@ -416,6 +418,52 @@ func (c *SheetsClearCmd) Run(ctx context.Context, flags *RootFlags) error { return nil } +// SheetsRawCmd dumps the full Spreadsheets.Get response as JSON, with no +// Fields restriction. `--include-grid-data` opts into returning cell-level +// data; it is off by default because grid payloads can be multi-MB and are +// the primary leakage vector (formulas may embed API keys or tokens). +// +// REST reference: https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/get +// Go type: https://pkg.go.dev/google.golang.org/api/sheets/v4#Spreadsheet +type SheetsRawCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + IncludeGridData bool `name:"include-grid-data" help:"Include cell-level grid data in the response (off by default; payloads can be large and may contain secrets in formulas)"` + Pretty bool `name:"pretty" help:"Pretty-print JSON (default: compact single-line)"` +} + +func (c *SheetsRawCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID)) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + + _, svc, err := requireSheetsService(ctx, flags) + if err != nil { + return err + } + + call := svc.Spreadsheets.Get(spreadsheetID).Context(ctx) + if c.IncludeGridData { + call = call.IncludeGridData(true) + u.Err().Println("warning: --include-grid-data may expose cell-level formulas that contain API keys or hardcoded secrets") + } + + resp, err := call.Do() + if err != nil { + return err + } + if resp == nil { + return errors.New("spreadsheet not found") + } + + if len(resp.DeveloperMetadata) > 0 { + u.Err().Println("warning: response contains developerMetadata which may hold third-party app secrets") + } + + return outfmt.WriteRaw(ctx, os.Stdout, resp, outfmt.RawOptions{Pretty: c.Pretty}) +} + type SheetsMetadataCmd struct { SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` } diff --git a/internal/cmd/sheets_raw_test.go b/internal/cmd/sheets_raw_test.go new file mode 100644 index 0000000..c489aa0 --- /dev/null +++ b/internal/cmd/sheets_raw_test.go @@ -0,0 +1,189 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "google.golang.org/api/option" + "google.golang.org/api/sheets/v4" + + "github.com/steipete/gogcli/internal/ui" +) + +type sheetsRawHit struct { + includeGridData atomic.Bool +} + +func newSheetsRawTestServer(t *testing.T, status int, body map[string]any, hit *sheetsRawHit) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/sheets/v4") + path = strings.TrimPrefix(path, "/v4") + if !strings.HasPrefix(path, "/spreadsheets/") || r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + if hit != nil && r.URL.Query().Get("includeGridData") == "true" { + hit.includeGridData.Store(true) + } + if status != 0 { + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{"code": status, "message": "mock error"}, + }) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body) + })) +} + +func installMockSheetsService(t *testing.T, srv *httptest.Server) { + t.Helper() + orig := newSheetsService + t.Cleanup(func() { newSheetsService = orig }) + + svc, err := sheets.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil } +} + +func fullSheetResponse(id string) map[string]any { + return map[string]any{ + "spreadsheetId": id, + "spreadsheetUrl": "http://example.com/" + id, + "properties": map[string]any{ + "title": "Full Sheet", + "locale": "en_US", + "timeZone": "UTC", + }, + "sheets": []map[string]any{ + { + "properties": map[string]any{ + "sheetId": 1, + "title": "Sheet1", + "gridProperties": map[string]any{ + "rowCount": 100, + "columnCount": 26, + }, + }, + }, + }, + } +} + +func rawContextWithStderr(t *testing.T, stderr io.Writer) context.Context { + t.Helper() + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + return ui.WithUI(context.Background(), u) +} + +func TestSheetsRaw_HappyPath_NoGridDataByDefault(t *testing.T) { + hit := &sheetsRawHit{} + srv := newSheetsRawTestServer(t, 0, fullSheetResponse("s1"), hit) + defer srv.Close() + installMockSheetsService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := runKong(t, &SheetsRawCmd{}, []string{"s1"}, ctx, flags); err != nil { + t.Fatalf("run: %v", err) + } + }) + + if hit.includeGridData.Load() { + t.Fatalf("--include-grid-data should not be set by default") + } + + var got map[string]any + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("invalid JSON: %v\nraw: %s", err, out) + } + if got["spreadsheetId"] != "s1" { + t.Fatalf("expected spreadsheetId=s1, got: %v", got["spreadsheetId"]) + } + if _, ok := got["sheets"]; !ok { + t.Fatalf("expected sheets in raw output") + } +} + +func TestSheetsRaw_IncludeGridDataFlag(t *testing.T) { + hit := &sheetsRawHit{} + srv := newSheetsRawTestServer(t, 0, fullSheetResponse("s1"), hit) + defer srv.Close() + installMockSheetsService(t, srv) + + var stderr bytes.Buffer + ctx := rawContextWithStderr(t, &stderr) + flags := &RootFlags{Account: "a@b.com"} + + _ = captureStdout(t, func() { + if err := runKong(t, &SheetsRawCmd{}, []string{"s1", "--include-grid-data"}, ctx, flags); err != nil { + t.Fatalf("run: %v", err) + } + }) + + if !hit.includeGridData.Load() { + t.Fatalf("expected includeGridData=true in request") + } + // Audit requires a stderr warning when grid data is included. + if !strings.Contains(stderr.String(), "grid") { + t.Fatalf("expected stderr warning mentioning 'grid', got: %q", stderr.String()) + } +} + +func TestSheetsRaw_APIError(t *testing.T) { + srv := newSheetsRawTestServer(t, http.StatusInternalServerError, nil, nil) + defer srv.Close() + installMockSheetsService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + _ = captureStdout(t, func() { + err := runKong(t, &SheetsRawCmd{}, []string{"s1"}, ctx, flags) + if err == nil { + t.Fatalf("expected error on 500") + } + }) +} + +func TestSheetsRaw_NotFound(t *testing.T) { + srv := newSheetsRawTestServer(t, http.StatusNotFound, nil, nil) + defer srv.Close() + installMockSheetsService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + _ = captureStdout(t, func() { + err := runKong(t, &SheetsRawCmd{}, []string{"s1"}, ctx, flags) + if err == nil { + t.Fatalf("expected error on 404") + } + }) +} + +func TestSheetsRaw_EmptyID(t *testing.T) { + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + if err := (&SheetsRawCmd{}).Run(ctx, flags); err == nil { + t.Fatalf("expected error on empty id") + } +} diff --git a/internal/cmd/slides.go b/internal/cmd/slides.go index e2d8873..4e8dd9f 100644 --- a/internal/cmd/slides.go +++ b/internal/cmd/slides.go @@ -35,6 +35,45 @@ type SlidesCmd struct { ReplaceSlide SlidesReplaceSlideCmd `cmd:"" name:"replace-slide" help:"Replace the image on an existing slide in-place"` InsertText SlidesInsertTextCmd `cmd:"" name:"insert-text" help:"Insert text into an existing page element (shape or table) by objectId"` ReplaceText SlidesReplaceTextCmd `cmd:"" name:"replace-text" help:"Find-and-replace text across a presentation"` + Raw SlidesRawCmd `cmd:"" name:"raw" help:"Dump raw Google Slides API response as JSON (Presentations.Get; lossless; for scripting and LLM consumption)"` +} + +// SlidesRawCmd dumps the full Presentations.Get response as JSON. The +// Slides API has no field mask, so output is unconditionally lossless. +// Note: response may contain short-lived authenticated image/video URLs +// (see docs/raw-audit.md for the risk assessment). +// +// REST reference: https://developers.google.com/slides/api/reference/rest/v1/presentations/get +// Go type: https://pkg.go.dev/google.golang.org/api/slides/v1#Presentation +type SlidesRawCmd struct { + PresentationID string `arg:"" name:"presentationId" help:"Presentation ID"` + Pretty bool `name:"pretty" help:"Pretty-print JSON (default: compact single-line)"` +} + +func (c *SlidesRawCmd) Run(ctx context.Context, flags *RootFlags) error { + id := strings.TrimSpace(c.PresentationID) + if id == "" { + return usage("empty presentationId") + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + svc, err := newSlidesService(ctx, account) + if err != nil { + return err + } + + pres, err := svc.Presentations.Get(id).Context(ctx).Do() + if err != nil { + return err + } + if pres == nil { + return errors.New("presentation not found") + } + + return outfmt.WriteRaw(ctx, os.Stdout, pres, outfmt.RawOptions{Pretty: c.Pretty}) } type SlidesExportCmd struct { diff --git a/internal/cmd/slides_raw_test.go b/internal/cmd/slides_raw_test.go new file mode 100644 index 0000000..15c190a --- /dev/null +++ b/internal/cmd/slides_raw_test.go @@ -0,0 +1,133 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/option" + "google.golang.org/api/slides/v1" +) + +func newSlidesRawTestServer(t *testing.T, status int, body map[string]any) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/slides/v1") + path = strings.TrimPrefix(path, "/v1") + if !strings.HasPrefix(path, "/presentations/") || r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + if status != 0 { + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{"code": status, "message": "mock error"}, + }) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body) + })) +} + +func installMockSlidesService(t *testing.T, srv *httptest.Server) { + t.Helper() + orig := newSlidesService + t.Cleanup(func() { newSlidesService = orig }) + + svc, err := slides.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newSlidesService = func(context.Context, string) (*slides.Service, error) { return svc, nil } +} + +func fullPresentationResponse(id string) map[string]any { + return map[string]any{ + "presentationId": id, + "title": "Full Deck", + "slides": []map[string]any{ + { + "objectId": "slide1", + "pageElements": []map[string]any{ + { + "objectId": "e1", + "shape": map[string]any{ + "shapeType": "TEXT_BOX", + }, + }, + }, + }, + }, + "masters": []map[string]any{{"objectId": "master1"}}, + "layouts": []map[string]any{{"objectId": "layout1"}}, + } +} + +func TestSlidesRaw_HappyPath(t *testing.T) { + srv := newSlidesRawTestServer(t, 0, fullPresentationResponse("p1")) + defer srv.Close() + installMockSlidesService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + out := captureStdout(t, func() { + if err := runKong(t, &SlidesRawCmd{}, []string{"p1"}, ctx, flags); err != nil { + t.Fatalf("run: %v", err) + } + }) + + var got map[string]any + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("invalid JSON: %v\nraw: %s", err, out) + } + if got["presentationId"] != "p1" { + t.Fatalf("expected presentationId=p1, got: %v", got["presentationId"]) + } + if _, ok := got["slides"]; !ok { + t.Fatalf("expected slides in raw output") + } +} + +func TestSlidesRaw_APIError(t *testing.T) { + srv := newSlidesRawTestServer(t, http.StatusInternalServerError, nil) + defer srv.Close() + installMockSlidesService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + _ = captureStdout(t, func() { + if err := runKong(t, &SlidesRawCmd{}, []string{"p1"}, ctx, flags); err == nil { + t.Fatalf("expected error on 500") + } + }) +} + +func TestSlidesRaw_NotFound(t *testing.T) { + srv := newSlidesRawTestServer(t, http.StatusNotFound, nil) + defer srv.Close() + installMockSlidesService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + _ = captureStdout(t, func() { + if err := runKong(t, &SlidesRawCmd{}, []string{"p1"}, ctx, flags); err == nil { + t.Fatalf("expected error on 404") + } + }) +} + +func TestSlidesRaw_EmptyID(t *testing.T) { + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + if err := (&SlidesRawCmd{}).Run(ctx, flags); err == nil { + t.Fatalf("expected error on empty id") + } +} diff --git a/internal/cmd/tasks.go b/internal/cmd/tasks.go index 2c479a3..52736ee 100644 --- a/internal/cmd/tasks.go +++ b/internal/cmd/tasks.go @@ -16,4 +16,5 @@ type TasksCmd struct { Undo TasksUndoCmd `cmd:"" name:"undo" help:"Mark task needs action" aliases:"uncomplete,undone"` Delete TasksDeleteCmd `cmd:"" name:"delete" aliases:"rm,del,remove" help:"Delete a task"` Clear TasksClearCmd `cmd:"" name:"clear" help:"Clear completed tasks"` + Raw TasksRawCmd `cmd:"" name:"raw" help:"Dump raw Google Tasks API response as JSON (Tasks.Get; lossless; for scripting and LLM consumption)"` } diff --git a/internal/cmd/tasks_raw.go b/internal/cmd/tasks_raw.go new file mode 100644 index 0000000..f14ae02 --- /dev/null +++ b/internal/cmd/tasks_raw.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "context" + "errors" + "os" + "strings" + + "github.com/steipete/gogcli/internal/outfmt" +) + +// TasksRawCmd dumps the full Tasks.Get response as JSON. +// +// REST reference: https://developers.google.com/tasks/reference/rest/v1/tasks/get +// Go type: https://pkg.go.dev/google.golang.org/api/tasks/v1#Task +type TasksRawCmd struct { + TasklistID string `arg:"" name:"tasklistId" help:"Task list ID"` + TaskID string `arg:"" name:"taskId" help:"Task ID"` + Pretty bool `name:"pretty" help:"Pretty-print JSON (default: compact single-line)"` +} + +func (c *TasksRawCmd) Run(ctx context.Context, flags *RootFlags) error { + tasklistID := strings.TrimSpace(c.TasklistID) + taskID := strings.TrimSpace(c.TaskID) + if tasklistID == "" { + return usage("empty tasklistId") + } + if taskID == "" { + return usage("empty taskId") + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + svc, err := newTasksService(ctx, account) + if err != nil { + return err + } + tasklistID, err = resolveTasklistID(ctx, svc, tasklistID) + if err != nil { + return err + } + + task, err := svc.Tasks.Get(tasklistID, taskID).Context(ctx).Do() + if err != nil { + return err + } + if task == nil { + return errors.New("task not found") + } + + return outfmt.WriteRaw(ctx, os.Stdout, task, outfmt.RawOptions{Pretty: c.Pretty}) +} diff --git a/internal/cmd/tasks_raw_test.go b/internal/cmd/tasks_raw_test.go new file mode 100644 index 0000000..f460d3c --- /dev/null +++ b/internal/cmd/tasks_raw_test.go @@ -0,0 +1,127 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/option" + "google.golang.org/api/tasks/v1" +) + +func newTasksRawTestServer(t *testing.T, status int, body map[string]any) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // resolveTasklistID may list tasklists; return an empty list so the resolver falls through to the literal ID. + if strings.HasSuffix(r.URL.Path, "/users/@me/lists") && r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"items": []any{}}) + return + } + if !strings.Contains(r.URL.Path, "/lists/") || !strings.Contains(r.URL.Path, "/tasks/") || r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + if status != 0 { + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{"code": status, "message": "mock error"}, + }) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body) + })) +} + +func installMockTasksService(t *testing.T, srv *httptest.Server) { + t.Helper() + orig := newTasksService + t.Cleanup(func() { newTasksService = orig }) + + svc, err := tasks.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newTasksService = func(context.Context, string) (*tasks.Service, error) { return svc, nil } +} + +func fullTaskResponse(id string) map[string]any { + return map[string]any{ + "id": id, + "title": "Buy milk", + "status": "needsAction", + "notes": "2% if possible", + } +} + +func TestTasksRaw_HappyPath(t *testing.T) { + srv := newTasksRawTestServer(t, 0, fullTaskResponse("t1")) + defer srv.Close() + installMockTasksService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + out := captureStdout(t, func() { + if err := runKong(t, &TasksRawCmd{}, []string{"list1", "t1"}, ctx, flags); err != nil { + t.Fatalf("run: %v", err) + } + }) + + var got map[string]any + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("invalid JSON: %v\nraw: %s", err, out) + } + if got["id"] != "t1" { + t.Fatalf("expected id=t1, got: %v", got["id"]) + } + if got["notes"] != "2% if possible" { + t.Fatalf("expected notes passthrough, got: %v", got["notes"]) + } +} + +func TestTasksRaw_APIError(t *testing.T) { + srv := newTasksRawTestServer(t, http.StatusInternalServerError, nil) + defer srv.Close() + installMockTasksService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + _ = captureStdout(t, func() { + if err := runKong(t, &TasksRawCmd{}, []string{"list1", "t1"}, ctx, flags); err == nil { + t.Fatalf("expected error on 500") + } + }) +} + +func TestTasksRaw_NotFound(t *testing.T) { + srv := newTasksRawTestServer(t, http.StatusNotFound, nil) + defer srv.Close() + installMockTasksService(t, srv) + + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + _ = captureStdout(t, func() { + if err := runKong(t, &TasksRawCmd{}, []string{"list1", "t1"}, ctx, flags); err == nil { + t.Fatalf("expected error on 404") + } + }) +} + +func TestTasksRaw_EmptyIDs(t *testing.T) { + ctx := rawTestContext(t) + flags := &RootFlags{Account: "a@b.com"} + if err := (&TasksRawCmd{}).Run(ctx, flags); err == nil { + t.Fatalf("expected error on empty tasklistId") + } + if err := (&TasksRawCmd{TasklistID: "list1"}).Run(ctx, flags); err == nil { + t.Fatalf("expected error on empty taskId") + } +} diff --git a/internal/outfmt/raw.go b/internal/outfmt/raw.go new file mode 100644 index 0000000..c9d6408 --- /dev/null +++ b/internal/outfmt/raw.go @@ -0,0 +1,36 @@ +package outfmt + +import ( + "context" + "encoding/json" + "fmt" + "io" +) + +// RawOptions configures WriteRaw. +type RawOptions struct { + // Pretty emits indented JSON (2-space). Default is compact single-line. + Pretty bool +} + +// WriteRaw marshals v as JSON and writes it to w, emitting the value bare +// (no envelope/wrapper). Intended for `gog <group> raw` subcommands that +// expose the canonical Google API response for programmatic consumption. +// +// Compact by default; pass RawOptions{Pretty: true} for indented output. +// Always appends a trailing newline for pipe friendliness. +// HTML escaping is disabled so URLs with & survive unchanged. +func WriteRaw(_ context.Context, w io.Writer, v any, opts RawOptions) error { + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + + if opts.Pretty { + enc.SetIndent("", " ") + } + + if err := enc.Encode(v); err != nil { + return fmt.Errorf("encode raw json: %w", err) + } + + return nil +} diff --git a/internal/outfmt/raw_test.go b/internal/outfmt/raw_test.go new file mode 100644 index 0000000..5a1f66d --- /dev/null +++ b/internal/outfmt/raw_test.go @@ -0,0 +1,109 @@ +package outfmt + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" +) + +func TestWriteRaw_CompactByDefault(t *testing.T) { + var buf bytes.Buffer + + in := map[string]any{"id": "abc", "nested": map[string]any{"k": "v"}} + + if err := WriteRaw(context.Background(), &buf, in, RawOptions{}); err != nil { + t.Fatalf("err: %v", err) + } + + out := buf.String() + if strings.Contains(out, " ") || strings.Contains(out, "\n ") { + t.Fatalf("expected compact output, got: %q", out) + } + // Must still end with newline for pipe friendliness. + if !strings.HasSuffix(out, "\n") { + t.Fatalf("expected trailing newline, got: %q", out) + } + + var round map[string]any + + if err := json.Unmarshal([]byte(out), &round); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + + if round["id"] != "abc" { + t.Fatalf("unexpected id: %v", round["id"]) + } +} + +func TestWriteRaw_Pretty(t *testing.T) { + var buf bytes.Buffer + + in := map[string]any{"id": "abc", "nested": map[string]any{"k": "v"}} + + if err := WriteRaw(context.Background(), &buf, in, RawOptions{Pretty: true}); err != nil { + t.Fatalf("err: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "\n ") { + t.Fatalf("expected indented output, got: %q", out) + } + + var round map[string]any + if err := json.Unmarshal([]byte(out), &round); err != nil { + t.Fatalf("pretty output is not valid JSON: %v", err) + } +} + +func TestWriteRaw_NoHTMLEscape(t *testing.T) { + var buf bytes.Buffer + + in := map[string]any{"url": "https://example.com/?a=1&b=2"} + + if err := WriteRaw(context.Background(), &buf, in, RawOptions{}); err != nil { + t.Fatalf("err: %v", err) + } + + if strings.Contains(buf.String(), "\\u0026") { + t.Fatalf("expected raw & in output, got: %q", buf.String()) + } +} + +func TestWriteRaw_BareStruct_NoWrapper(t *testing.T) { + // raw must emit the value as-is, with no envelope/wrapper added. + type apiResp struct { + DocumentId string `json:"documentId"` + Title string `json:"title"` + } + var buf bytes.Buffer + + if err := WriteRaw(context.Background(), &buf, apiResp{DocumentId: "d1", Title: "t"}, RawOptions{}); err != nil { + t.Fatalf("err: %v", err) + } + + var round map[string]any + + if err := json.Unmarshal(buf.Bytes(), &round); err != nil { + t.Fatalf("invalid json: %v", err) + } + + if _, hasWrapper := round["document"]; hasWrapper { + t.Fatalf("raw output must not be wrapped under a key, got: %v", round) + } + + if round["documentId"] != "d1" { + t.Fatalf("expected documentId=d1, got: %v", round["documentId"]) + } +} + +func TestWriteRaw_MarshalError(t *testing.T) { + var buf bytes.Buffer + + // channels cannot be marshaled to JSON. + err := WriteRaw(context.Background(), &buf, make(chan int), RawOptions{}) + if err == nil { + t.Fatalf("expected error marshaling channel") + } +}