* fix(gmail): decode ISO-2022-JP bodies * fix(gmail): include cc/bcc in get output * feat(calendar): allow selecting calendars in events * test(gmail): add edge case tests for ISO-2022-JP decoding Add tests for edge cases in ISO-2022-JP body decoding: - Mixed ASCII and Japanese text (e.g., "Hello こんにちは World") - Empty content with ISO-2022-JP charset header - Malformed ISO-2022-JP sequences (graceful degradation) - Truncated escape sequences These tests verify the graceful fallback behavior in decodeBodyCharset which returns original data if decoding fails. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(calendar): validate unknown calendar names in resolveCalendarIDs When a calendar name doesn't match any known calendar (not in bySummary or byID maps), return an error listing the unrecognized names instead of treating them as raw calendar IDs which causes cryptic Google API errors. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(calendar): validate unknown and ambiguous calendar name resolutions --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
20 KiB
gogcli spec
Goal
Build a single, clean, modern Go CLI that talks to:
- Gmail API
- Google Calendar API
- Google Chat API
- Google Classroom API
- Google Drive API
- Google Docs API
- Google Sheets API
- Google Forms API
- Apps Script API
- Google Tasks API
- Cloud Identity API (Groups)
- Google People API (Contacts + directory)
- Google Keep API (Workspace-only, service account)
This replaces the existing separate CLIs (gmcli, gccli, gdcli) and the Python contacts server conceptually, but:
- no backwards compatibility
- no migration tooling
Non-goals
- Preserving legacy command names/flags/output formats
- Importing existing
~/.gmcli,~/.gccli,~/.gdclistate - Running an MCP server (this is a CLI)
Language/runtime
- Go
1.25(seego.mod)
CLI framework
github.com/alecthomas/kong- Root command:
gog - Global flag:
--color=auto|always|never(defaultauto)--json(JSON output to stdout)--plain(TSV output to stdout; stable/parseable; disables colors)--force(skip confirmations for destructive commands)--no-input(never prompt; fail instead)--version(print version)
Notes:
- We run
SilenceUsage: trueand print errors ourselves (colored when possible). NO_COLORis respected.
Environment:
GOG_COLOR=auto|always|never(defaultauto, overridden by--color)GOG_JSON=1(default JSON output; overridden by flags)GOG_PLAIN=1(default plain output; overridden by flags)
Output (TTY-aware colors)
github.com/muesli/termenvis used to detect rich TTY capabilities and render colored output.- Colors are enabled when:
- output is a rich terminal and
--color=auto, andNO_COLORis not set; or --color=always
- output is a rich terminal and
- Colors are disabled when:
--color=never; orNO_COLORis set
Implementation: internal/ui/ui.go.
Auth + secret storage
OAuth client credentials (non-secret-ish)
- Stored on disk in the per-user config directory:
$(os.UserConfigDir())/gogcli/credentials.json(default client)$(os.UserConfigDir())/gogcli/credentials-<client>.json(named clients)
- Written with mode
0600. - Command:
gog auth credentials <credentials.json>gog --client <name> auth credentials <credentials.json>gog auth credentials list
- Supports Google’s downloaded JSON format:
installed.client_id/client_secretorweb.client_id/client_secret
Implementation: internal/config/*.
Refresh tokens (secrets)
- Stored in OS credential store via
github.com/99designs/keyring. - Key namespace is
gogcli(keyringServiceName). - Key format:
token:<client>:<email>(default client usestoken:default:<email>) - Legacy key format:
token:<email>(migrated on first read) - Stored payload is JSON (refresh token + metadata like selected services/scopes).
- Fallback: if no OS credential store is available, keyring may use its encrypted "file" backend:
- Directory:
$(os.UserConfigDir())/gogcli/keyring/(one file per key) - Password: prompts on TTY; for non-interactive runs set
GOG_KEYRING_PASSWORD
- Directory:
Current minimal management commands (implemented):
gog auth tokens list(keys only)gog auth tokens delete <email>
Implementation: internal/secrets/store.go.
OAuth flow
- Desktop OAuth 2.0 flow using local HTTP redirect on an ephemeral port.
- Supports a browserless/manual flow (paste redirect URL) for headless environments.
- Supports a remote/server-friendly 2-step manual flow:
- Step 1 prints an auth URL (
gog auth add ... --remote --step 1) - Step 2 exchanges the pasted redirect URL and requires
statevalidation (--remote --step 2 --auth-url ...)
- Step 1 prints an auth URL (
- Refresh token issuance:
- requests
access_type=offline - supports
--force-consentto force the consent prompt when Google doesn't return a refresh token - uses
include_granted_scopes=trueto support incremental auth re-runs
- requests
Scope selection note:
- The consent screen shows the scopes the CLI requested.
- Users cannot selectively un-check individual requested scopes in the consent screen; they either approve all requested scopes or cancel.
- To request fewer scopes, choose fewer services via
gog auth add --services ...or usegog auth add --readonlywhere applicable.
Config layout
- Base config dir:
$(os.UserConfigDir())/gogcli/ - Files:
config.json(JSON5; comments and trailing commas allowed)credentials.json(OAuth client id/secret; default client)credentials-<client>.json(OAuth client id/secret; named clients)
- State:
state/gmail-watch/<account>.json(Gmail watch state)oauth-manual-state-<state>.json(temporary manual OAuth state cache; expires quickly; no tokens)
- Secrets:
- refresh tokens in keyring
We intentionally avoid storing refresh tokens in plain JSON on disk.
Environment:
GOG_ACCOUNT=you@gmail.com(email or alias; used when--accountis not set; otherwise uses keyring default or a single stored token)GOG_CLIENT=work(select OAuth client bucket; see--client)GOG_KEYRING_PASSWORD=...(used when keyring falls back to encrypted file backend in non-interactive environments)GOG_KEYRING_BACKEND={auto|keychain|file}(force backend; usefileto avoid Keychain prompts and pair withGOG_KEYRING_PASSWORDfor non-interactive)GOG_TIMEZONE=America/New_York(default output timezone; IANA name orUTC;localforces local timezone)GOG_ENABLE_COMMANDS=calendar,tasks(optional allowlist of top-level commands)config.jsoncan also setkeyring_backend(JSON5; env vars take precedence)config.jsoncan also setdefault_timezone(IANA name orUTC)config.jsoncan also setaccount_aliasesforgog auth alias(JSON5)config.jsoncan also setaccount_clients(email -> client) andclient_domains(domain -> client)
Flag aliases:
--outalso accepts--output.--out-diralso accepts--output-dir(Gmail thread attachment downloads).
Commands (current + planned)
Implemented
gog auth credentials <credentials.json|->gog auth credentials listgog --client <name> auth credentials <credentials.json|->gog auth add <email> [--services user|all|gmail,calendar,classroom,drive,docs,contacts,tasks,sheets,people,groups] [--readonly] [--drive-scope full|readonly|file] [--manual] [--remote] [--step 1|2] [--auth-url URL] [--timeout DURATION] [--force-consent]gog auth services [--markdown]gog auth keep <email> --key <service-account.json>(Google Keep; Workspace only)gog auth listgog auth alias listgog auth alias set <alias> <email>gog auth alias unset <alias>gog auth statusgog auth remove <email>gog auth tokens listgog auth tokens delete <email>gog config get <key>gog config keysgog config listgog config pathgog config set <key> <value>gog config unset <key>gog versiongog drive ls [--parent ID] [--max N] [--page TOKEN] [--query Q] [--[no-]all-drives]gog drive search <text> [--raw-query] [--max N] [--page TOKEN] [--[no-]all-drives]gog drive get <fileId>gog drive download <fileId> [--out PATH] [--format F](--formatonly applies to Google Workspace files)gog drive upload <localPath> [--name N] [--parent ID] [--convert] [--convert-to doc|sheet|slides]gog drive mkdir <name> [--parent ID]gog drive delete <fileId> [--permanent]gog drive move <fileId> --parent IDgog drive rename <fileId> <newName>gog drive share <fileId> --to anyone|user|domain [--email addr] [--domain example.com] [--role reader|writer] [--discoverable]gog drive permissions <fileId> [--max N] [--page TOKEN]gog drive unshare <fileId> <permissionId>gog drive url <fileIds...>gog drive drives [--max N] [--page TOKEN] [--query Q]gog calendar calendarsgog calendar acl <calendarId>gog calendar events <calendarId> [--cal ID_OR_NAME] [--calendars CSV] [--all] [--from RFC3339] [--to RFC3339] [--max N] [--page TOKEN] [--query Q] [--weekday]gog calendar event|get <calendarId> <eventId>GOG_CALENDAR_WEEKDAY=1defaults--weekdayforgog calendar eventsgog calendar create <calendarId> --summary S --from DT --to DT [--description D] [--location L] [--attendees a@b.com,c@d.com] [--all-day] [--event-type TYPE]gog calendar update <calendarId> <eventId> [--summary S] [--from DT] [--to DT] [--description D] [--location L] [--attendees ...] [--add-attendee ...] [--all-day] [--event-type TYPE]gog calendar delete <calendarId> <eventId>gog calendar freebusy <calendarIds> --from RFC3339 --to RFC3339gog calendar respond <calendarId> <eventId> --status accepted|declined|tentative [--send-updates all|none|externalOnly]gog time now [--timezone TZ]gog classroom courses [--state ...] [--max N] [--page TOKEN]gog classroom courses get <courseId>gog classroom courses create --name NAME [--owner me] [--state ACTIVE|...]gog classroom courses update <courseId> [--name ...] [--state ...]gog classroom courses delete <courseId>gog classroom courses archive <courseId>gog classroom courses unarchive <courseId>gog classroom courses join <courseId> [--role student|teacher] [--user me]gog classroom courses leave <courseId> [--role student|teacher] [--user me]gog classroom courses url <courseId...>gog classroom students <courseId> [--max N] [--page TOKEN]gog classroom students get <courseId> <userId>gog classroom students add <courseId> <userId> [--enrollment-code CODE]gog classroom students remove <courseId> <userId>gog classroom teachers <courseId> [--max N] [--page TOKEN]gog classroom teachers get <courseId> <userId>gog classroom teachers add <courseId> <userId>gog classroom teachers remove <courseId> <userId>gog classroom roster <courseId> [--students] [--teachers]gog classroom coursework <courseId> [--state ...] [--topic TOPIC_ID] [--scan-pages N] [--max N] [--page TOKEN]gog classroom coursework get <courseId> <courseworkId>gog classroom coursework create <courseId> --title TITLE [--type ASSIGNMENT|...]gog classroom coursework update <courseId> <courseworkId> [--title ...]gog classroom coursework delete <courseId> <courseworkId>gog classroom coursework assignees <courseId> <courseworkId> [--mode ...] [--add-student ...]gog classroom materials <courseId> [--state ...] [--topic TOPIC_ID] [--scan-pages N] [--max N] [--page TOKEN]gog classroom materials get <courseId> <materialId>gog classroom materials create <courseId> --title TITLEgog classroom materials update <courseId> <materialId> [--title ...]gog classroom materials delete <courseId> <materialId>gog classroom submissions <courseId> <courseworkId> [--state ...] [--max N] [--page TOKEN]gog classroom submissions get <courseId> <courseworkId> <submissionId>gog classroom submissions turn-in <courseId> <courseworkId> <submissionId>gog classroom submissions reclaim <courseId> <courseworkId> <submissionId>gog classroom submissions return <courseId> <courseworkId> <submissionId>gog classroom submissions grade <courseId> <courseworkId> <submissionId> [--draft N] [--assigned N]gog classroom announcements <courseId> [--state ...] [--max N] [--page TOKEN]gog classroom announcements get <courseId> <announcementId>gog classroom announcements create <courseId> --text TEXTgog classroom announcements update <courseId> <announcementId> [--text ...]gog classroom announcements delete <courseId> <announcementId>gog classroom announcements assignees <courseId> <announcementId> [--mode ...]gog classroom topics <courseId> [--max N] [--page TOKEN]gog classroom topics get <courseId> <topicId>gog classroom topics create <courseId> --name NAMEgog classroom topics update <courseId> <topicId> --name NAMEgog classroom topics delete <courseId> <topicId>gog classroom invitations [--course ID] [--user ID]gog classroom invitations get <invitationId>gog classroom invitations create <courseId> <userId> --role STUDENT|TEACHER|OWNERgog classroom invitations accept <invitationId>gog classroom invitations delete <invitationId>gog classroom guardians <studentId> [--max N] [--page TOKEN]gog classroom guardians get <studentId> <guardianId>gog classroom guardians delete <studentId> <guardianId>gog classroom guardian-invitations <studentId> [--state ...] [--max N] [--page TOKEN]gog classroom guardian-invitations get <studentId> <invitationId>gog classroom guardian-invitations create <studentId> --email EMAILgog classroom profile [userId]gog gmail search <query> [--max N] [--page TOKEN]gog gmail messages search <query> [--max N] [--page TOKEN] [--include-body]gog gmail thread get <threadId> [--download]gog gmail thread modify <threadId> [--add ...] [--remove ...]gog gmail get <messageId> [--format full|metadata|raw] [--headers ...]gog gmail attachment <messageId> <attachmentId> [--out PATH] [--name NAME]gog gmail url <threadIds...>gog gmail labels listgog gmail labels get <labelIdOrName>gog gmail labels create <name>gog gmail labels modify <threadIds...> [--add ...] [--remove ...]gog gmail send --to a@b.com --subject S [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id <messageId>] [--reply-to addr] [--attach <file>...]gog gmail drafts list [--max N] [--page TOKEN]gog gmail drafts get <draftId> [--download]gog gmail drafts create --subject S [--to a@b.com] [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id <messageId>] [--reply-to addr] [--attach <file>...]gog gmail drafts update <draftId> --subject S [--to a@b.com] [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id <messageId>] [--reply-to addr] [--attach <file>...]gog gmail drafts send <draftId>gog gmail drafts delete <draftId>gog gmail watch start|status|renew|stop|servegog gmail history --since <historyId>gog chat spaces list [--max N] [--page TOKEN]gog chat spaces find <displayName> [--max N]gog chat spaces create <displayName> [--member email,...]gog chat messages list <space> [--max N] [--page TOKEN] [--order ORDER] [--thread THREAD] [--unread]gog chat messages send <space> --text TEXT [--thread THREAD]gog chat threads list <space> [--max N] [--page TOKEN]gog chat dm space <email>gog chat dm send <email> --text TEXT [--thread THREAD]gog tasks lists [--max N] [--page TOKEN]gog tasks lists create <title>gog tasks list <tasklistId> [--max N] [--page TOKEN]gog tasks get <tasklistId> <taskId>gog tasks add <tasklistId> --title T [--notes N] [--due RFC3339|YYYY-MM-DD] [--repeat daily|weekly|monthly|yearly] [--repeat-count N] [--repeat-until DT] [--parent ID] [--previous ID]gog tasks update <tasklistId> <taskId> [--title T] [--notes N] [--due RFC3339|YYYY-MM-DD] [--status needsAction|completed]gog tasks done <tasklistId> <taskId>gog tasks undo <tasklistId> <taskId>gog tasks delete <tasklistId> <taskId>gog tasks clear <tasklistId>gog contacts search <query> [--max N]gog contacts list [--max N] [--page TOKEN]gog contacts get <people/...|email>gog contacts create --given NAME [--family NAME] [--email addr] [--phone num]gog contacts update <people/...> [--given NAME] [--family NAME] [--email addr] [--phone num] [--birthday YYYY-MM-DD] [--notes TEXT] [--from-file PATH|-] [--ignore-etag]gog contacts delete <people/...>gog contacts directory list [--max N] [--page TOKEN]gog contacts directory search <query> [--max N] [--page TOKEN]gog contacts other list [--max N] [--page TOKEN]gog contacts other search <query> [--max N]gog people megog people get <people/...|userId>gog people search <query> [--max N] [--page TOKEN]gog people relations [<people/...|userId>] [--type TYPE]
Date/time input conventions (shared parser):
- Date-only:
YYYY-MM-DD - Datetime:
RFC3339/RFC3339Nano/YYYY-MM-DDTHH:MM[:SS]/YYYY-MM-DD HH:MM[:SS] - Numeric timezone offset accepted:
YYYY-MM-DDTHH:MM:SS-0800 - Calendar range flags also accept relatives:
now,today,tomorrow,yesterday, weekday names (monday,next friday) - Tracking
--sincealso accepts durations like24h
Planned high-level command tree
gog auth …gog auth credentials <credentials.json>gog auth credentials listgog --client <name> auth credentials <credentials.json>
gog gmail …gog chat …gog calendar …gog drive …gog contacts …gog tasks …gog people …
Planned service identifiers (canonical):
gmailcalendarchatdrivecontactstaskspeople
Google API dependencies (planned)
golang.org/x/oauth2golang.org/x/oauth2/googlegoogle.golang.org/api/optiongoogle.golang.org/api/gmail/v1google.golang.org/api/calendar/v3google.golang.org/api/chat/v1google.golang.org/api/drive/v3google.golang.org/api/people/v1google.golang.org/api/tasks/v1
Scopes (planned)
We store a single refresh token per Google account email.
-
gog auth addrequests a union of scopes based on--services. -
Each API client refreshes an access token for the subset of scopes needed for that service.
-
If you later want additional services, re-run
gog auth add <email> --services ...(may require--force-consentto mint a new refresh token). -
Gmail:
https://mail.google.com/(or narrower scopes if we decide later) -
Calendar:
https://www.googleapis.com/auth/calendar -
Chat:
https://www.googleapis.com/auth/chat.spaceshttps://www.googleapis.com/auth/chat.messageshttps://www.googleapis.com/auth/chat.membershipshttps://www.googleapis.com/auth/chat.users.readstate.readonly
-
Drive:
https://www.googleapis.com/auth/drive -
Contacts/Directory:
https://www.googleapis.com/auth/contactshttps://www.googleapis.com/auth/contacts.other.readonlyhttps://www.googleapis.com/auth/directory.readonly
-
People:
profile(OIDC)
Output formats
Default: human-friendly tables (stdlib text/tabwriter).
- Parseable stdout:
--json: JSON objects/arrays suitable for scripting--plain: stable TSV (tabs preserved; no alignment; no colors)
- Human-facing hints/progress are written to stderr so stdout can be safely captured.
- Colors are only used for human-facing output and are disabled automatically for
--jsonand--plain.
We avoid heavy table deps unless we decide we need them.
Code layout (current)
cmd/gog/main.go— binary entrypointinternal/cmd/*— kong command structsinternal/ui/*— color + printinginternal/config/*— config paths + credential parsing/writinginternal/secrets/*— keyring store
Formatting, linting, tests
Formatting
Pinned tools, installed into local .tools/ via make tools:
mvdan.cc/gofumpt@v0.7.0golang.org/x/tools/cmd/goimports@v0.38.0github.com/golangci/golangci-lint/cmd/golangci-lint@v1.62.2
Commands:
make fmt— appliesgoimports+gofumptmake fmt-check— formats and fails if Go files orgo.mod/go.sumchange
Lint
golangci-lintwith config in.golangci.ymlmake lint
Tests
- stdlib
testing(+httptestwhen we add OAuth/API tests) make test
Integration tests (local only)
There is an opt-in integration test suite guarded by build tags (not run in CI).
- Requires:
- stored
credentials.json(orcredentials-<client>.json) viagog auth credentials ... - refresh token in keyring via
gog auth add <email>
- stored
- Run:
GOG_IT_ACCOUNT=you@gmail.com go test -tags=integration ./internal/integration- optional:
GOG_CLIENT=workto select a non-default OAuth client
CI (GitHub Actions)
Workflow: .github/workflows/ci.yml
- runs on push + PR
- uses
actions/setup-gowithgo-version-file: go.mod - runs:
make toolsmake fmt-checkgo test ./...golangci-lint(pinnedv1.62.2)
Next implementation steps
- Expand Gmail further (labels by name everywhere, richer body rendering, compose edge cases).
- Improve People updates (multi-field + richer contact data).
- Harden UX (consistent output formats, retries/backoff on specific transient errors).