12 KiB
gogcli spec
Goal
Build a single, clean, modern Go CLI that talks to:
- Gmail API
- Google Calendar API
- Google Drive API
- Google People API (Contacts + directory)
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
- Written with mode
0600. - Command:
gog auth credentials <credentials.json>
- 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:<email> - 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.
- 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 ....
Config layout
- Base config dir:
$(os.UserConfigDir())/gogcli/ - Files:
config.json(JSON5; comments and trailing commas allowed)credentials.json(OAuth client id/secret)
- State:
state/gmail-watch/<account>.json(Gmail watch state)
- Secrets:
- refresh tokens in keyring
We intentionally avoid storing refresh tokens in plain JSON on disk.
Environment:
GOG_ACCOUNT=you@gmail.com(used when--accountis not set; otherwise uses keyring default or a single stored token)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)config.jsoncan also setkeyring_backend(JSON5; env vars take precedence)
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 add <email> [--services user|all|gmail,calendar,drive,docs,contacts,tasks,sheets,people,groups] [--manual] [--force-consent]gog auth services [--markdown]gog auth keep <email> --key <service-account.json>(Google Keep; Workspace only)gog auth listgog auth statusgog auth remove <email>gog auth tokens listgog auth tokens delete <email>gog drive ls [--parent ID] [--max N] [--page TOKEN] [--query Q]gog drive search <text> [--max N] [--page TOKEN]gog drive get <fileId>gog drive download <fileId> [--out PATH]gog drive upload <localPath> [--name N] [--parent ID]gog drive mkdir <name> [--parent ID]gog drive delete <fileId>gog drive move <fileId> --parent IDgog drive rename <fileId> <newName>gog drive share <fileId> [--anyone | --email addr] [--role reader|writer] [--discoverable]gog drive permissions <fileId> [--max N] [--page TOKEN]gog drive unshare <fileId> <permissionId>gog drive url <fileIds...>gog calendar calendarsgog calendar acl <calendarId>gog calendar events <calendarId> [--from RFC3339] [--to RFC3339] [--max N] [--page TOKEN] [--query Q]gog calendar event <calendarId> <eventId>gog calendar create <calendarId> --summary S --from DT --to DT [--description D] [--location L] [--attendees a@b.com,c@d.com] [--all-day]gog calendar update <calendarId> <eventId> [--summary S] [--from DT] [--to DT] [--description D] [--location L] [--attendees ...] [--add-attendee ...] [--all-day]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 gmail search <query> [--max N] [--page TOKEN]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 --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 update <draftId> --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 send <draftId>gog gmail drafts delete <draftId>gog gmail watch start|status|renew|stop|servegog gmail history --since <historyId>gog tasks lists [--max N] [--page TOKEN]gog tasks lists create <title>gog tasks list <tasklistId> [--max N] [--page TOKEN]gog tasks add <tasklistId> --title T [--notes N] [--due RFC3339] [--parent ID] [--previous ID]gog tasks update <tasklistId> <taskId> [--title T] [--notes N] [--due RFC3339] [--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]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 me
Planned high-level command tree
gog auth …gog auth credentials <credentials.json>
gog gmail …gog calendar …gog drive …gog contacts …gog tasks …gog people …
Planned service identifiers (canonical):
gmailcalendardrivecontactstaskspeople
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/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 -
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.jsonviagog 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
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).