329 lines
12 KiB
Markdown
329 lines
12 KiB
Markdown
# 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 <credentials.json>`
|
||
- 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:<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`
|
||
|
||
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-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/<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 `--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 <credentials.json|->`
|
||
- `gog auth add <email> [--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 <email> --key <service-account.json>` (Google Keep; Workspace only)
|
||
- `gog auth list`
|
||
- `gog auth status`
|
||
- `gog auth remove <email>`
|
||
- `gog auth tokens list`
|
||
- `gog 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 ID`
|
||
- `gog 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 drive drives [--max N] [--page TOKEN] [--query Q]`
|
||
- `gog calendar calendars`
|
||
- `gog 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 RFC3339`
|
||
- `gog 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 list`
|
||
- `gog 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|serve`
|
||
- `gog 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):
|
||
|
||
- `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).
|