# 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`, `~/.gdcli` state - Running an MCP server (this is a CLI) ## Language/runtime - Go `1.25` (see `go.mod`) ## CLI framework - `github.com/alecthomas/kong` - Root command: `gog` - Global flag: - `--color=auto|always|never` (default `auto`) - `--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: true` and print errors ourselves (colored when possible). - `NO_COLOR` is respected. Environment: - `GOG_COLOR=auto|always|never` (default `auto`, 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/termenv` is used to detect rich TTY capabilities and render colored output. - Colors are enabled when: - output is a rich terminal and `--color=auto`, and `NO_COLOR` is not set; or - `--color=always` - Colors are disabled when: - `--color=never`; or - `NO_COLOR` is 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 ` - Supports Google’s downloaded JSON format: - `installed.client_id/client_secret` or `web.client_id/client_secret` Implementation: `internal/config/*`. ### Refresh tokens (secrets) - Stored in OS credential store via `github.com/99designs/keyring`. - Key namespace is `gogcli` (keyring `ServiceName`). - Key format: `token:` - 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` Current minimal management commands (implemented): - `gog auth tokens list` (keys only) - `gog auth tokens delete ` 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-consent` to force the consent prompt when Google doesn't return a refresh token - uses `include_granted_scopes=true` to 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 use `gog auth add --readonly` where applicable. ## 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/.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 `--account` is 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; use `file` to avoid Keychain prompts and pair with `GOG_KEYRING_PASSWORD` for non-interactive) - `config.json` can also set `keyring_backend` (JSON5; env vars take precedence) Flag aliases: - `--out` also accepts `--output`. - `--out-dir` also accepts `--output-dir` (Gmail thread attachment downloads). ## Commands (current + planned) ### Implemented - `gog auth credentials ` - `gog auth add [--services user|all|gmail,calendar,drive,docs,contacts,tasks,sheets,people,groups] [--readonly] [--drive-scope full|readonly|file] [--manual] [--force-consent]` - `gog auth services [--markdown]` - `gog auth keep --key ` (Google Keep; Workspace only) - `gog auth list` - `gog auth status` - `gog auth remove ` - `gog auth tokens list` - `gog auth tokens delete ` - `gog drive ls [--parent ID] [--max N] [--page TOKEN] [--query Q]` - `gog drive search [--max N] [--page TOKEN]` - `gog drive get ` - `gog drive download [--out PATH]` - `gog drive upload [--name N] [--parent ID]` - `gog drive mkdir [--parent ID]` - `gog drive delete ` - `gog drive move --parent ID` - `gog drive rename ` - `gog drive share [--anyone | --email addr] [--role reader|writer] [--discoverable]` - `gog drive permissions [--max N] [--page TOKEN]` - `gog drive unshare ` - `gog drive url ` - `gog drive drives [--max N] [--page TOKEN] [--query Q]` - `gog calendar calendars` - `gog calendar acl ` - `gog calendar events [--from RFC3339] [--to RFC3339] [--max N] [--page TOKEN] [--query Q]` - `gog calendar event ` - `gog calendar create --summary S --from DT --to DT [--description D] [--location L] [--attendees a@b.com,c@d.com] [--all-day]` - `gog calendar update [--summary S] [--from DT] [--to DT] [--description D] [--location L] [--attendees ...] [--add-attendee ...] [--all-day]` - `gog calendar delete ` - `gog calendar freebusy --from RFC3339 --to RFC3339` - `gog calendar respond --status accepted|declined|tentative [--send-updates all|none|externalOnly]` - `gog gmail search [--max N] [--page TOKEN]` - `gog gmail thread get [--download]` - `gog gmail thread modify [--add ...] [--remove ...]` - `gog gmail get [--format full|metadata|raw] [--headers ...]` - `gog gmail attachment [--out PATH] [--name NAME]` - `gog gmail url ` - `gog gmail labels list` - `gog gmail labels get ` - `gog gmail labels create ` - `gog gmail labels modify [--add ...] [--remove ...]` - `gog gmail send --to a@b.com --subject S [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id ] [--reply-to addr] [--attach ...]` - `gog gmail drafts list [--max N] [--page TOKEN]` - `gog gmail drafts get [--download]` - `gog gmail drafts create --subject S [--to a@b.com] [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id ] [--reply-to addr] [--attach ...]` - `gog gmail drafts update --subject S [--to a@b.com] [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id ] [--reply-to addr] [--attach ...]` - `gog gmail drafts send ` - `gog gmail drafts delete ` - `gog gmail watch start|status|renew|stop|serve` - `gog gmail history --since ` - `gog tasks lists [--max N] [--page TOKEN]` - `gog tasks lists create ` - `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): - `gmail` - `calendar` - `drive` - `contacts` - `tasks` - `people` ## Google API dependencies (planned) - `golang.org/x/oauth2` - `golang.org/x/oauth2/google` - `google.golang.org/api/option` - `google.golang.org/api/gmail/v1` - `google.golang.org/api/calendar/v3` - `google.golang.org/api/drive/v3` - `google.golang.org/api/people/v1` - `google.golang.org/api/tasks/v1` ## Scopes (planned) We store a single refresh token per Google account email. - `gog auth add` requests 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-consent` to 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/contacts` - `https://www.googleapis.com/auth/contacts.other.readonly` - `https://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 `--json` and `--plain`. We avoid heavy table deps unless we decide we need them. ## Code layout (current) - `cmd/gog/main.go` — binary entrypoint - `internal/cmd/*` — kong command structs - `internal/ui/*` — color + printing - `internal/config/*` — config paths + credential parsing/writing - `internal/secrets/*` — keyring store ## Formatting, linting, tests ### Formatting Pinned tools, installed into local `.tools/` via `make tools`: - `mvdan.cc/gofumpt@v0.7.0` - `golang.org/x/tools/cmd/goimports@v0.38.0` - `github.com/golangci/golangci-lint/cmd/golangci-lint@v1.62.2` Commands: - `make fmt` — applies `goimports` + `gofumpt` - `make fmt-check` — formats and fails if Go files or `go.mod/go.sum` change ### Lint - `golangci-lint` with config in `.golangci.yml` - `make lint` ### Tests - stdlib `testing` (+ `httptest` when 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` via `gog auth credentials ...` - refresh token in keyring via `gog auth add <email>` - 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-go` with `go-version-file: go.mod` - runs: - `make tools` - `make fmt-check` - `go test ./...` - `golangci-lint` (pinned `v1.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).