gogcli/docs/spec.md
Peter Steinberger 62a7257aba
feat(contacts): add dedupe preview (#555)
Adds a read-only contacts dedupe preview command with JSON/table output, generated command docs, README/spec docs, and changelog credit for the extracted idea from #116.\n\nCo-authored-by: Rohan Patnaik <rohan-patnaik@users.noreply.github.com>
2026-05-05 06:37:36 +01:00

481 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`, `~/.gdcli` state
- Running an MCP server (this is a CLI)
## Language/runtime
- Go `1.26` (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` (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`
- `gog auth credentials remove [<client>|all]`
- Supports Googles 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` by default (keyring `ServiceName`); override with `GOG_KEYRING_SERVICE_NAME`.
- Key format: `token:<client>:<email>` (default client uses `token: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 `sub` is 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 list` reports 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 `state` validation (`--remote --step 2 --auth-url ...`)
- 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; 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 `--account` is 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; use `file` to avoid Keychain prompts and pair with `GOG_KEYRING_PASSWORD` for non-interactive)
- `GOG_KEYRING_SERVICE_NAME=...` (override keyring namespace/service name; default `gogcli`)
- `GOG_TIMEZONE=America/New_York` (default output timezone; IANA name or `UTC`; `local` forces 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.json` can also set `keyring_backend` (JSON5; env vars take precedence)
- `config.json` can also set `default_timezone` (IANA name or `UTC`)
- `config.json` can also set `account_aliases` for `gog auth alias` (JSON5)
- `config.json` can also set `account_clients` (email -> client) and `client_domains` (domain -> client)
- `config.json` can also set `gmail_no_send` and `no_send_accounts` for send guards
Flag aliases:
- `--out` also accepts `--output`.
- `--out-dir` also 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 list`
- `gog 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 list`
- `gog auth doctor [--check]` (diagnose keyring/password drift and refresh-token failures)
- `gog auth alias list`
- `gog auth alias set <alias> <email>`
- `gog auth alias unset <alias>`
- `gog auth status`
- `gog auth remove <email>`
- `gog auth tokens list`
- `gog auth tokens delete <email>`
- `gog config get <key>`
- `gog config keys`
- `gog config list`
- `gog config path`
- `gog config set <key> <value>`
- `gog config unset <key>`
- `gog version`
- `gog drive ls [--all] [--parent ID] [--max N] [--page TOKEN] [--query Q] [--[no-]all-drives]` (`--all` and `--parent` are 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]` (`--format` only applies to Google Workspace files; `--format md` exports 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 `--convert` or `--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 ID`
- `gog 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 calendars`
- `gog 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=1` defaults `--weekday` for `gog calendar events`
- `gog 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 RFC3339`
- `gog 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 TITLE`
- `gog 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 TEXT`
- `gog 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 NAME`
- `gog classroom topics update <courseId> <topicId> --name NAME`
- `gog 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|OWNER`
- `gog 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 EMAIL`
- `gog 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 list`
- `gog 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|serve`
- `gog 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 me`
- `gog 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 `--since` also accepts durations like `24h`
### Planned high-level command tree
- `gog auth …`
- `gog auth credentials <credentials.json>`
- `gog auth credentials list`
- `gog --client <name> auth credentials <credentials.json>`
- `gog gmail …`
- `gog chat …`
- `gog calendar …`
- `gog drive …`
- `gog contacts …`
- `gog tasks …`
- `gog people …`
Planned service identifiers (canonical):
- `gmail`
- `calendar`
- `chat`
- `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/chat/v1`
- `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`
- Chat:
- `https://www.googleapis.com/auth/chat.spaces`
- `https://www.googleapis.com/auth/chat.messages`
- `https://www.googleapis.com/auth/chat.memberships`
- `https://www.googleapis.com/auth/chat.users.readstate.readonly`
- 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` (or `credentials-<client>.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`
- optional: `GOG_CLIENT=work` to select a non-default OAuth client
## 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).