feat(raw): add lossless API dump commands
Co-authored-by: Ali Karbassi <ali@karbassi.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8b8fd09fa2
commit
7b288cc922
@ -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.
|
||||
|
||||
40
README.md
40
README.md
@ -1159,6 +1159,15 @@ gog drive unshare <fileId> --permission-id <permissionId>
|
||||
|
||||
# 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 <fileId> --fields "id,name,thumbnailLink,imageMediaMetadata"
|
||||
|
||||
# Raw API dump (lossless JSON for scripting/LLMs)
|
||||
gog drive raw <fileId> # fields=*, sensitive fields redacted by default
|
||||
gog drive raw <fileId> --fields "id,name,thumbnailLink" # honors user-named fields verbatim
|
||||
gog drive raw <fileId> --pretty
|
||||
```
|
||||
|
||||
### Docs / Slides / Sheets
|
||||
@ -1187,6 +1196,8 @@ gog docs write <docId> --file ./body.md --replace --markdown
|
||||
gog docs write <docId> --file ./body.md --append --markdown
|
||||
gog docs find-replace <docId> "old" "new"
|
||||
gog docs find-replace <docId> "old" "new" --tab "Notes"
|
||||
gog docs raw <docId> # Lossless JSON dump of Documents.Get (LLM/scripting)
|
||||
gog docs raw <docId> --pretty
|
||||
|
||||
# Slides
|
||||
gog slides info <presentationId>
|
||||
@ -1204,6 +1215,7 @@ gog slides insert-text <presentationId> <objectId> - < long-content.md
|
||||
gog slides insert-text <presentationId> <objectId> "New body" --replace
|
||||
gog slides replace-text <presentationId> "{{name}}" "Acme Corp"
|
||||
gog slides replace-text <presentationId> "TODO" "DONE" --match-case --page <slideId1> --page <slideId2>
|
||||
gog slides raw <presentationId> # Lossless JSON dump of Presentations.Get
|
||||
|
||||
# Sheets
|
||||
gog sheets copy <spreadsheetId> "My Sheet Copy"
|
||||
@ -1223,8 +1235,36 @@ gog sheets links <spreadsheetId> 'Sheet1!A1:B10'
|
||||
gog sheets add-tab <spreadsheetId> <tabName> --index 0
|
||||
gog sheets rename-tab <spreadsheetId> <oldName> <newName>
|
||||
gog sheets delete-tab <spreadsheetId> <tabName> --force
|
||||
gog sheets raw <spreadsheetId> # Lossless JSON dump of Spreadsheets.Get
|
||||
gog sheets raw <spreadsheetId> --include-grid-data # Include cell-level data (off by default)
|
||||
|
||||
# Other raw dumps (gmail, calendar, people, contacts, tasks, forms)
|
||||
gog gmail raw <messageId> # Lossless JSON dump of Users.Messages.Get (default format=full)
|
||||
gog gmail raw <messageId> --format raw # Gmail's native format=raw (base64url RFC822)
|
||||
gog calendar raw <calendarId> <eventId> # Lossless JSON dump of Events.Get
|
||||
gog people raw people/<resourceName> # Lossless JSON dump of People.Get
|
||||
gog contacts raw people/<resourceName> # Same endpoint, exposed under the contacts group
|
||||
gog tasks raw <tasklistId> <taskId> # Lossless JSON dump of Tasks.Get
|
||||
gog forms raw <formId> # 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)
|
||||
- `<group> 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
|
||||
|
||||
@ -77,6 +77,7 @@ Generated from `gog schema --json`.
|
||||
- [`gog calendar (cal) move (transfer) <calendarId> <eventId> <destinationCalendarId> [flags]`](commands/gog-calendar-move.md) - Move an event to another calendar
|
||||
- [`gog calendar (cal) out-of-office (ooo) --from=STRING --to=STRING [<calendarId>] [flags]`](commands/gog-calendar-out-of-office.md) - Create an Out of Office event
|
||||
- [`gog calendar (cal) propose-time <calendarId> <eventId> [flags]`](commands/gog-calendar-propose-time.md) - Generate URL to propose a new meeting time (browser-only feature)
|
||||
- [`gog calendar (cal) raw <calendarId> <eventId> [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) <calendarId> <eventId> [flags]`](commands/gog-calendar-respond.md) - Respond to an event invitation
|
||||
- [`gog calendar (cal) search (find,query) <query> [flags]`](commands/gog-calendar-search.md) - Search events
|
||||
- [`gog calendar (cal) subscribe (sub,add-calendar) <calendarId> [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 <resourceName>`](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 <query> ... [flags]`](commands/gog-contacts-other-search.md) - Search other contacts
|
||||
- [`gog contacts (contact) raw <identifier> [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 <query> ... [flags]`](commands/gog-contacts-search.md) - Search contacts by name/email/phone
|
||||
- [`gog contacts (contact) update (edit,set) <resourceName> [flags]`](commands/gog-contacts-update.md) - Update a contact
|
||||
- [`gog docs (doc) <command> [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) <docId>`](commands/gog-docs-info.md) - Get Google Doc metadata
|
||||
- [`gog docs (doc) insert <docId> [<content>] [flags]`](commands/gog-docs-insert.md) - Insert text at a specific position
|
||||
- [`gog docs (doc) list-tabs <docId>`](commands/gog-docs-list-tabs.md) - List all tabs in a Google Doc
|
||||
- [`gog docs (doc) raw <docId> [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 <docId> [flags]`](commands/gog-docs-rename-tab.md) - Rename a tab in a Google Doc
|
||||
- [`gog docs (doc) sed <docId> [<expression>] [flags]`](commands/gog-docs-sed.md) - Regex find/replace (sed-style: s/pattern/replacement/g)
|
||||
- [`gog docs (doc) structure (struct) <docId> [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) <fileId> [flags]`](commands/gog-drive-delete.md) - Move a file to trash (use --permanent to delete forever)
|
||||
- [`gog drive (drv) download <fileId> [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 <fileId>`](commands/gog-drive-get.md) - Get file metadata
|
||||
- [`gog drive (drv) get <fileId> [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 <name> [flags]`](commands/gog-drive-mkdir.md) - Create a folder
|
||||
- [`gog drive (drv) move <fileId> [flags]`](commands/gog-drive-move.md) - Move a file to a different folder
|
||||
- [`gog drive (drv) permissions <fileId> [flags]`](commands/gog-drive-permissions.md) - List permissions on a file
|
||||
- [`gog drive (drv) raw <fileId> [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 <fileId> <newName>`](commands/gog-drive-rename.md) - Rename a file or folder
|
||||
- [`gog drive (drv) search <query> ... [flags]`](commands/gog-drive-search.md) - Full-text search across Drive
|
||||
- [`gog drive (drv) share <fileId> [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) <formId> <index>`](commands/gog-forms-delete-question.md) - Delete a question by index
|
||||
- [`gog forms (form) get (info,show) <formId>`](commands/gog-forms-get.md) - Get a form
|
||||
- [`gog forms (form) move-question (move-q,mq) <formId> <oldIndex> <newIndex>`](commands/gog-forms-move-question.md) - Move a question to a new position
|
||||
- [`gog forms (form) raw <formId> [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 <command>`](commands/gog-forms-responses.md) - Form responses
|
||||
- [`gog forms (form) responses get (info,show) <formId> <responseId>`](commands/gog-forms-responses-get.md) - Get a form response
|
||||
- [`gog forms (form) responses list (ls) <formId> [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) <command>`](commands/gog-gmail-messages.md) - Message operations
|
||||
- [`gog gmail (mail,email) messages (message,msg,msgs) modify (update,edit,set) <messageId> [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) <query> ... [flags]`](commands/gog-gmail-messages-search.md) - Search messages using Gmail query syntax
|
||||
- [`gog gmail (mail,email) raw <messageId> [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) <query> ... [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 <command>`](commands/gog-gmail-settings.md) - Settings and admin
|
||||
@ -366,6 +372,7 @@ Generated from `gog schema --json`.
|
||||
- [`gog people (person) <command> [flags]`](commands/gog-people.md) - Google People
|
||||
- [`gog people (person) get (info,show) <userId>`](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 <userId> [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 [<userId>] [flags]`](commands/gog-people-relations.md) - Get user relations
|
||||
- [`gog people (person) search (find,query) <query> ... [flags]`](commands/gog-people-search.md) - Search the Workspace directory
|
||||
- [`gog schema (help-json,helpjson) [<command> ...] [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) <spreadsheetId> <nameOrId> [flags]`](commands/gog-sheets-named-ranges-update.md) - Update a named range
|
||||
- [`gog sheets (sheet) notes <spreadsheetId> <range>`](commands/gog-sheets-notes.md) - Get cell notes from a range
|
||||
- [`gog sheets (sheet) number-format <spreadsheetId> <range> [flags]`](commands/gog-sheets-number-format.md) - Apply number format to a range
|
||||
- [`gog sheets (sheet) raw <spreadsheetId> [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) <spreadsheetId> <range> [flags]`](commands/gog-sheets-read-format.md) - Read cell formatting from a range
|
||||
- [`gog sheets (sheet) rename-tab (rename-sheet) <spreadsheetId> <oldName> <newName>`](commands/gog-sheets-rename-tab.md) - Rename a tab/sheet in a spreadsheet
|
||||
- [`gog sheets (sheet) resize-columns <spreadsheetId> <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) <presentationId>`](commands/gog-slides-info.md) - Get Google Slides presentation metadata
|
||||
- [`gog slides (slide) insert-text <presentationId> <objectId> <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 <presentationId>`](commands/gog-slides-list-slides.md) - List all slides with their object IDs
|
||||
- [`gog slides (slide) raw <presentationId> [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 <presentationId> <slideId>`](commands/gog-slides-read-slide.md) - Read slide content: speaker notes, text elements, and images
|
||||
- [`gog slides (slide) replace-slide <presentationId> <slideId> <image> [flags]`](commands/gog-slides-replace-slide.md) - Replace the image on an existing slide in-place
|
||||
- [`gog slides (slide) replace-text <presentationId> <find> <replacement> [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 <command>`](commands/gog-tasks-lists.md) - List task lists
|
||||
- [`gog tasks (task) lists create (add,new) <title> ...`](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
|
||||
|
||||
@ -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
|
||||
|
||||
43
docs/commands/gog-calendar-raw.md
Normal file
43
docs/commands/gog-calendar-raw.md
Normal file
@ -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)
|
||||
@ -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
|
||||
|
||||
44
docs/commands/gog-contacts-raw.md
Normal file
44
docs/commands/gog-contacts-raw.md
Normal file
@ -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)
|
||||
@ -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
|
||||
|
||||
|
||||
43
docs/commands/gog-docs-raw.md
Normal file
43
docs/commands/gog-docs-raw.md
Normal file
@ -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)
|
||||
@ -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
|
||||
|
||||
@ -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. |
|
||||
|
||||
@ -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. |
|
||||
|
||||
44
docs/commands/gog-drive-raw.md
Normal file
44
docs/commands/gog-drive-raw.md
Normal file
@ -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)
|
||||
@ -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
|
||||
|
||||
43
docs/commands/gog-forms-raw.md
Normal file
43
docs/commands/gog-forms-raw.md
Normal file
@ -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)
|
||||
@ -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)
|
||||
|
||||
44
docs/commands/gog-gmail-raw.md
Normal file
44
docs/commands/gog-gmail-raw.md
Normal file
@ -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)
|
||||
@ -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
|
||||
|
||||
@ -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. |
|
||||
|
||||
44
docs/commands/gog-people-raw.md
Normal file
44
docs/commands/gog-people-raw.md
Normal file
@ -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)
|
||||
@ -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
|
||||
|
||||
|
||||
44
docs/commands/gog-sheets-raw.md
Normal file
44
docs/commands/gog-sheets-raw.md
Normal file
@ -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)
|
||||
@ -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
|
||||
|
||||
43
docs/commands/gog-slides-raw.md
Normal file
43
docs/commands/gog-slides-raw.md
Normal file
@ -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)
|
||||
@ -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
|
||||
|
||||
43
docs/commands/gog-tasks-raw.md
Normal file
43
docs/commands/gog-tasks-raw.md
Normal file
@ -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)
|
||||
@ -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
|
||||
|
||||
|
||||
174
docs/raw-audit.md
Normal file
174
docs/raw-audit.md
Normal file
@ -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.
|
||||
@ -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"`
|
||||
|
||||
52
internal/cmd/calendar_raw.go
Normal file
52
internal/cmd/calendar_raw.go
Normal file
@ -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})
|
||||
}
|
||||
130
internal/cmd/calendar_raw_test.go
Normal file
130
internal/cmd/calendar_raw_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
172
internal/cmd/docs_raw_test.go
Normal file
172
internal/cmd/docs_raw_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
138
internal/cmd/drive_fields_test.go
Normal file
138
internal/cmd/drive_fields_test.go
Normal file
@ -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{}
|
||||
@ -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 {
|
||||
|
||||
181
internal/cmd/drive_raw_test.go
Normal file
181
internal/cmd/drive_raw_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
45
internal/cmd/forms_raw.go
Normal file
45
internal/cmd/forms_raw.go
Normal file
@ -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})
|
||||
}
|
||||
122
internal/cmd/forms_raw_test.go
Normal file
122
internal/cmd/forms_raw_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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"`
|
||||
|
||||
@ -21,6 +21,7 @@ type GmailGetCmd struct {
|
||||
const (
|
||||
gmailFormatFull = "full"
|
||||
gmailFormatMetadata = "metadata"
|
||||
gmailFormatMinimal = "minimal"
|
||||
gmailFormatRaw = "raw"
|
||||
)
|
||||
|
||||
|
||||
64
internal/cmd/gmail_raw.go
Normal file
64
internal/cmd/gmail_raw.go
Normal file
@ -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})
|
||||
}
|
||||
170
internal/cmd/gmail_raw_test.go
Normal file
170
internal/cmd/gmail_raw_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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{}
|
||||
|
||||
76
internal/cmd/people_raw.go
Normal file
76
internal/cmd/people_raw.go
Normal file
@ -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})
|
||||
}
|
||||
138
internal/cmd/people_raw_test.go
Normal file
138
internal/cmd/people_raw_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
189
internal/cmd/sheets_raw_test.go
Normal file
189
internal/cmd/sheets_raw_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
133
internal/cmd/slides_raw_test.go
Normal file
133
internal/cmd/slides_raw_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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)"`
|
||||
}
|
||||
|
||||
54
internal/cmd/tasks_raw.go
Normal file
54
internal/cmd/tasks_raw.go
Normal file
@ -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})
|
||||
}
|
||||
127
internal/cmd/tasks_raw_test.go
Normal file
127
internal/cmd/tasks_raw_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
36
internal/outfmt/raw.go
Normal file
36
internal/outfmt/raw.go
Normal file
@ -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
|
||||
}
|
||||
109
internal/outfmt/raw_test.go
Normal file
109
internal/outfmt/raw_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user