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.26(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- 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 listgog auth credentials remove [<client>|all]- 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
gogcliby default (keyringServiceName); override withGOG_KEYRING_SERVICE_NAME. - Key format:
token:<client>:<email>(default client usestoken:default:<email>) - Canonical identity key format for new tokens with an OIDC subject:
token-sub:<client>:<sub>. Email-keyed entries remain as compatibility lookup keys. - Legacy key format:
token:<email>(migrated on first read) - Stored payload is JSON (refresh token + metadata like OIDC subject, current email, selected services/scopes).
- Email is treated as display/contact state; Google's OIDC
subis used to detect the same account after an email rename and migrate aliases/defaults/client mappings on reauthorization. - macOS Keychain operations are bounded by a timeout so non-surfacing permission prompts return actionable guidance instead of hanging indefinitely.
- Fallback: if no OS credential store is available, keyring may use its encrypted "file" backend:
- Directory:
$(os.UserConfigDir())/gogcli/keyring/(one file per key; gog-managed key names are encoded for portable filenames) - Password: prompts on TTY; for non-interactive runs set
GOG_KEYRING_PASSWORD
Current minimal management commands (implemented):
gog auth tokens list(keys only; does not decrypt token payloads)gog auth tokens delete <email>gog auth listreports unreadable token entries instead of failing the whole listing, so one bad file-keyring entry does not hide other accounts.
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 ...) - 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
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_KEYRING_SERVICE_NAME=...(override keyring namespace/service name; defaultgogcli)GOG_TIMEZONE=America/New_York(default output timezone; IANA name orUTC;localforces local timezone)GOG_ENABLE_COMMANDS=calendar,tasks,gmail.search(optional allowlist; dot paths allowed)GOG_DISABLE_COMMANDS=gmail.send,gmail.drafts.send(optional denylist; dot paths allowed)GOG_GMAIL_NO_SEND=1(block Gmail send operations)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)config.jsoncan also setgmail_no_sendandno_send_accountsfor send guards
Flag aliases:
--outalso accepts--output.--out-diralso accepts--output-dir(Gmail thread attachment downloads).- Drive download/export commands accept
--out -to write file bytes to stdout;--json --out -is rejected.
#Commands (current + planned)
#Implemented
gog auth credentials <credentials.json|->gog auth credentials listgog auth credentials remove [<client>|all]gog --client <name> auth credentials <credentials.json|->gog auth add <email> [--services user|all|gmail,calendar,chat,classroom,drive,docs,slides,contacts,tasks,sheets,people,forms,appscript,ads,groups,keep,admin] [--readonly] [--drive-scope full|readonly|file] [--gmail-scope full|readonly] [--extra-scopes CSV] [--manual] [--remote] [--step 1|2] [--auth-url URL] [--listen-addr HOST[:PORT]] [--redirect-host HOST] [--timeout DURATION] [--force-consent]gog auth services [--markdown]gog auth manage [--services ...] [--listen-addr HOST[:PORT]] [--redirect-host HOST]gog auth keep <email> --key <service-account.json>(Google Keep; Workspace only)gog auth listgog auth doctor [--check](diagnose keyring/password drift and refresh-token failures)gog 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 [--all] [--parent ID] [--max N] [--page TOKEN] [--query Q] [--[no-]all-drives](--alland--parentare mutually exclusive)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;--format mdexports a Google Doc as Markdown)gog drive upload <localPath> [--name N] [--parent ID] [--convert] [--convert-to doc|sheet|slides] [--keep-frontmatter](Markdown → Google Doc with--convertor--convert-to doc: leading---/---frontmatter is stripped before upload unless--keep-frontmatter; delimiter-based, not a full YAML parse; large non-JSON uploads print progress to stderr)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|commenter] [--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 slides thumbnail <presentationId> <slideId> [--size small|medium|large] [--format png|jpeg] [--out PATH]gog calendar calendarsgog calendar create-calendar <summary> [--description D] [--timezone TZ] [--location L]gog 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 [--start-timezone TZ] [--end-timezone TZ] [--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] [--start-timezone TZ] [--end-timezone TZ] [--description D] [--location L] [--attendees ...] [--add-attendee ...] [--all-day] [--event-type TYPE]gog calendar delete <calendarId> <eventId>gog calendar freebusy [calendarIds] [--cal ID_OR_NAME] [--calendars CSV] [--all] --from RFC3339 --to RFC3339gog calendar conflicts [--cal ID_OR_NAME] [--calendars CSV] [--all] [--from RFC3339|date|relative] [--to RFC3339|date|relative] [--today|--week|--days N]gog 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 contacts dedupe [--match email,phone,name] [--max N]gog gmail search <query> [--max N] [--page TOKEN]gog gmail messages search <query> [--max N] [--page TOKEN] [--include-body] [--body-format text|html] [--full]gog gmail autoreply <query> [--max N] [--subject S] [--body B|--body-file PATH|--body-html HTML] [--from addr] [--reply-to addr] [--label L] [--archive] [--mark-read] [--skip-bulk] [--allow-self]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 forward <messageId> --to a@b.com [--cc ...] [--bcc ...] [--note TEXT|--note-file PATH] [--from addr] [--skip-attachments]gog gmail labels listgog gmail labels get <labelIdOrName>gog gmail labels create <name>gog gmail labels rename <labelIdOrName> <newName>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] [--from addr] [--signature|--signature-from addr|--signature-file path] [--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] [--exact]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 export <people/...|email|name> [--out PATH|-]gog contacts export --query <query> [--max N] [--out PATH|-]gog contacts export --all [--page-size N] [--page TOKEN] [--out PATH|-]gog contacts create --given NAME [--family NAME] [--email addr] [--phone num] [--relation type=person]gog contacts update <people/...> [--given NAME] [--family NAME] [--email addr] [--phone num] [--birthday YYYY-MM-DD] [--notes TEXT] [--relation type=person] [--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> - 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).