Unify Google CLI with auth, services, and CI

This commit is contained in:
Peter Steinberger 2025-12-12 14:18:38 +00:00
parent 7984c5b9e4
commit bfbc6e4323
49 changed files with 6253 additions and 0 deletions

27
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,27 @@
name: ci
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Install tools
run: make tools
- name: Format check
run: make fmt-check
- name: Test
run: go test ./...
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: v1.62.2
args: --timeout=5m

3
.gitignore vendored
View File

@ -30,3 +30,6 @@ go.work.sum
# Editor/IDE
# .idea/
# .vscode/
# Local tools
.tools/

24
.golangci.yml Normal file
View File

@ -0,0 +1,24 @@
run:
timeout: 5m
linters:
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- typecheck
- unused
linters-settings:
govet:
enable-all: true
disable:
- fieldalignment
issues:
exclude-rules:
- path: _test\.go
linters:
- errcheck

31
Makefile Normal file
View File

@ -0,0 +1,31 @@
SHELL := /bin/bash
.PHONY: fmt fmt-check lint test ci tools
TOOLS_DIR := $(CURDIR)/.tools
GOFUMPT := $(TOOLS_DIR)/gofumpt
GOIMPORTS := $(TOOLS_DIR)/goimports
GOLANGCI_LINT := $(TOOLS_DIR)/golangci-lint
tools:
@mkdir -p $(TOOLS_DIR)
@GOBIN=$(TOOLS_DIR) go install mvdan.cc/gofumpt@v0.7.0
@GOBIN=$(TOOLS_DIR) go install golang.org/x/tools/cmd/goimports@v0.38.0
@GOBIN=$(TOOLS_DIR) go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.62.2
fmt: tools
@$(GOIMPORTS) -w .
@$(GOFUMPT) -w .
fmt-check: tools
@$(GOIMPORTS) -w .
@$(GOFUMPT) -w .
@git diff --exit-code -- '*.go' go.mod go.sum
lint: tools
@$(GOLANGCI_LINT) run
test:
@go test ./...
ci: fmt-check lint test

38
README.md Normal file
View File

@ -0,0 +1,38 @@
# gogcli
Minimal Google (Gmail/Calendar/Drive/Contacts) CLI in Go.
## Setup
1. Create OAuth credentials in Google Cloud Console (Desktop app) and enable the APIs you need.
2. Store credentials:
- `gog auth credentials ~/path/to/credentials.json`
3. Authorize (stores a refresh token in Keychain via keyring):
- `gog auth add you@gmail.com` (default: all services)
- or: `gog auth add you@gmail.com --services drive,calendar`
Most API commands require `--account you@gmail.com`.
### Output
- `--output=text` writes plain text to stdout (designed to be script-friendly).
- `--output=json` writes JSON to stdout (best for scripting).
- Human-facing hints/progress go to stderr.
### Environment
- `GOG_ACCOUNT=you@gmail.com` (used if `--account` is omitted)
- `GOG_COLOR=auto|always|never` (default `auto`)
- `GOG_OUTPUT=text|json` (default `text`)
### Integration tests (local only)
Run smoke tests against real APIs (not in CI):
- `GOG_IT_ACCOUNT=you@gmail.com go test -tags=integration ./internal/integration`
## Development
- Format: `make fmt`
- Lint: `make lint`
- Test: `make test`

13
cmd/gog/main.go Normal file
View File

@ -0,0 +1,13 @@
package main
import (
"os"
"github.com/steipete/gogcli/internal/cmd"
)
func main() {
if err := cmd.Execute(os.Args[1:]); err != nil {
os.Exit(1)
}
}

279
docs/spec.md Normal file
View File

@ -0,0 +1,279 @@
# 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/spf13/cobra`
- Root command: `gog`
- Global flag:
- `--color=auto|always|never` (default `auto`)
- `--output=text|json` (default `text`)
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_OUTPUT=text|json` (default `text`, overridden by `--output`)
## 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 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` (keyring `ServiceName`).
- Key format: `token:<email>`
- Stored payload is JSON (refresh token + metadata like selected services/scopes).
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 ...`.
## Config layout
- Base config dir: `$(os.UserConfigDir())/gogcli/`
- Files:
- `credentials.json` (OAuth client id/secret)
- 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)
## Commands (current + planned)
### Implemented
- `gog auth credentials <credentials.json>`
- `gog auth add <email> [--services all|gmail,calendar,drive,contacts] [--manual] [--force-consent]`
- `gog auth list`
- `gog auth remove <email>`
- `gog auth tokens list`
- `gog auth tokens delete <email>`
- `gog drive ls [folderId] [--max N] [--page TOKEN] [--query Q]`
- `gog drive search <text> [--max N] [--page TOKEN]`
- `gog drive get <fileId>`
- `gog drive download <fileId> [destPath]`
- `gog drive upload <localPath> [--name N] [--folder ID]`
- `gog drive mkdir <name> [--parent ID]`
- `gog drive delete <fileId>`
- `gog drive move <fileId> <newParentId>`
- `gog drive rename <fileId> <newName>`
- `gog drive share <fileId> [--anyone | --email addr] [--role reader|writer] [--discoverable]`
- `gog drive permissions <fileId>`
- `gog drive unshare <fileId> <permissionId>`
- `gog drive url <fileIds...>`
- `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 --start DT --end DT [--description D] [--location L] [--attendees a@b.com,c@d.com] [--all-day]`
- `gog calendar update <calendarId> <eventId> [--summary S] [--start DT] [--end DT] [--description D] [--location L] [--attendees ...] [--all-day]`
- `gog calendar delete <calendarId> <eventId>`
- `gog calendar freebusy <calendarIds> --from RFC3339 --to RFC3339`
- `gog gmail search <query> [--max N] [--page TOKEN]`
- `gog gmail thread <threadId> [--download]`
- `gog gmail url <threadIds...>`
- `gog gmail labels list`
- `gog gmail labels modify <threadIds...> [--add ...] [--remove ...]`
- `gog gmail send --to a@b.com --subject S --body B [--cc ...] [--bcc ...] [--reply-to <messageId>] [--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 [--cc ...] [--bcc ...] [--reply-to <messageId>] [--attach <file>...]`
- `gog gmail drafts send <draftId>`
- `gog gmail drafts delete <draftId>`
- `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]`
### Planned high-level command tree
- `gog auth …`
- `gog auth credentials <credentials.json>`
- `gog gmail …`
- `gog calendar …`
- `gog drive …`
- `gog contacts …`
Planned service identifiers (canonical):
- `gmail`
- `calendar`
- `drive`
- `contacts`
## 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`
## 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/directory.readonly`
## Output formats (planned)
Default: machine-friendly tabular output using stdlib `text/tabwriter`.
- Keep stdout stable and parseable:
- `--output=text`: plain text, typically tab-separated for list/search style commands
- `--output=json`: JSON objects/arrays suitable for scripting
- 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 `--output=json`.
We avoid heavy table deps unless we decide we need them.
## Code layout (current)
- `cmd/gog/main.go` — binary entrypoint
- `internal/cmd/*` — cobra commands
- `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).

49
go.mod Normal file
View File

@ -0,0 +1,49 @@
module github.com/steipete/gogcli
go 1.25
require (
github.com/99designs/keyring v1.2.2
github.com/muesli/termenv v0.16.0
github.com/spf13/cobra v1.10.2
golang.org/x/oauth2 v0.34.0
google.golang.org/api v0.257.0
)
require (
cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/danieljoos/wincred v1.1.2 // indirect
github.com/dvsekhvalnov/jose2go v1.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mtibben/percent v0.2.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
google.golang.org/grpc v1.77.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

129
go.sum Normal file
View File

@ -0,0 +1,129 @@
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=
github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0=
github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM=
github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0=
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs=
github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As=
github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.257.0 h1:8Y0lzvHlZps53PEaw+G29SsQIkuKrumGWs9puiexNAA=
google.golang.org/api v0.257.0/go.mod h1:4eJrr+vbVaZSqs7vovFd1Jb/A6ml6iw2e6FBYf3GAO4=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

17
internal/cmd/account.go Normal file
View File

@ -0,0 +1,17 @@
package cmd
import (
"errors"
"os"
"strings"
)
func requireAccount(flags *rootFlags) (string, error) {
if v := strings.TrimSpace(flags.Account); v != "" {
return v, nil
}
if v := strings.TrimSpace(os.Getenv("GOG_ACCOUNT")); v != "" {
return v, nil
}
return "", errors.New("missing --account (or set GOG_ACCOUNT)")
}

299
internal/cmd/auth.go Normal file
View File

@ -0,0 +1,299 @@
package cmd
import (
"os"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/googleauth"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/secrets"
"github.com/steipete/gogcli/internal/ui"
)
func newAuthCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "Authentication and accounts",
}
cmd.AddCommand(newAuthCredentialsCmd())
cmd.AddCommand(newAuthAddCmd())
cmd.AddCommand(newAuthListCmd())
cmd.AddCommand(newAuthRemoveCmd())
cmd.AddCommand(newAuthTokensCmd())
return cmd
}
func newAuthCredentialsCmd() *cobra.Command {
return &cobra.Command{
Use: "credentials <credentials.json>",
Short: "Store OAuth client credentials",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
inPath := args[0]
b, err := os.ReadFile(inPath)
if err != nil {
return err
}
creds, err := config.ParseGoogleOAuthClientJSON(b)
if err != nil {
return err
}
if err := config.WriteClientCredentials(creds); err != nil {
return err
}
outPath, _ := config.ClientCredentialsPath()
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"saved": true,
"path": outPath,
})
}
u.Out().Printf("path\t%s", outPath)
return nil
},
}
}
func newAuthTokensCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "tokens",
Short: "Manage stored refresh tokens",
}
cmd.AddCommand(&cobra.Command{
Use: "list",
Short: "List stored tokens (by key only)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
store, err := secrets.OpenDefault()
if err != nil {
return err
}
keys, err := store.Keys()
if err != nil {
return err
}
if len(keys) == 0 {
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"keys": []string{}})
}
u.Err().Println("No tokens stored")
return nil
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"keys": keys})
}
for _, k := range keys {
u.Out().Println(k)
}
return nil
},
})
cmd.AddCommand(&cobra.Command{
Use: "delete <email>",
Short: "Delete a stored refresh token",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
store, err := secrets.OpenDefault()
if err != nil {
return err
}
email := args[0]
if err := store.DeleteToken(email); err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"deleted": true,
"email": email,
})
}
u.Out().Printf("deleted\ttrue")
u.Out().Printf("email\t%s", email)
return nil
},
})
return cmd
}
func newAuthAddCmd() *cobra.Command {
var manual bool
var forceConsent bool
var servicesCSV string
cmd := &cobra.Command{
Use: "add <email>",
Short: "Authorize and store a refresh token",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
email := args[0]
var services []googleauth.Service
if strings.EqualFold(strings.TrimSpace(servicesCSV), "") || strings.EqualFold(strings.TrimSpace(servicesCSV), "all") {
services = googleauth.AllServices()
} else {
parts := strings.Split(servicesCSV, ",")
seen := make(map[googleauth.Service]struct{})
for _, p := range parts {
svc, err := googleauth.ParseService(p)
if err != nil {
return err
}
if _, ok := seen[svc]; ok {
continue
}
seen[svc] = struct{}{}
services = append(services, svc)
}
}
if len(services) == 0 {
return cmd.Help()
}
scopes, err := googleauth.ScopesForServices(services)
if err != nil {
return err
}
refreshToken, err := googleauth.Authorize(cmd.Context(), googleauth.AuthorizeOptions{
Services: services,
Scopes: scopes,
Manual: manual,
ForceConsent: forceConsent,
})
if err != nil {
return err
}
store, err := secrets.OpenDefault()
if err != nil {
return err
}
serviceNames := make([]string, 0, len(services))
for _, svc := range services {
serviceNames = append(serviceNames, string(svc))
}
sort.Strings(serviceNames)
if err := store.SetToken(email, secrets.Token{
Email: email,
Services: serviceNames,
Scopes: scopes,
RefreshToken: refreshToken,
}); err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"stored": true,
"email": email,
"services": serviceNames,
})
}
u.Out().Printf("email\t%s", email)
u.Out().Printf("services\t%s", strings.Join(serviceNames, ","))
return nil
},
}
cmd.Flags().BoolVar(&manual, "manual", false, "Browserless auth flow (paste redirect URL)")
cmd.Flags().BoolVar(&forceConsent, "force-consent", false, "Force consent screen to obtain a refresh token")
cmd.Flags().StringVar(&servicesCSV, "services", "all", "Services to authorize: all or comma-separated gmail,calendar,drive,contacts")
return cmd
}
func newAuthListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List stored accounts",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
store, err := secrets.OpenDefault()
if err != nil {
return err
}
tokens, err := store.ListTokens()
if err != nil {
return err
}
sort.Slice(tokens, func(i, j int) bool { return tokens[i].Email < tokens[j].Email })
if outfmt.IsJSON(cmd.Context()) {
type item struct {
Email string `json:"email"`
Services []string `json:"services,omitempty"`
Scopes []string `json:"scopes,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
}
out := make([]item, 0, len(tokens))
for _, t := range tokens {
created := ""
if !t.CreatedAt.IsZero() {
created = t.CreatedAt.UTC().Format("2006-01-02T15:04:05Z07:00")
}
out = append(out, item{
Email: t.Email,
Services: t.Services,
Scopes: t.Scopes,
CreatedAt: created,
})
}
return outfmt.WriteJSON(os.Stdout, map[string]any{"accounts": out})
}
if len(tokens) == 0 {
u.Err().Println("No tokens stored")
return nil
}
for _, t := range tokens {
created := ""
if !t.CreatedAt.IsZero() {
created = t.CreatedAt.UTC().Format("2006-01-02T15:04:05Z07:00")
}
u.Out().Printf("%s\t%s\t%s", t.Email, strings.Join(t.Services, ","), created)
}
return nil
},
}
}
func newAuthRemoveCmd() *cobra.Command {
return &cobra.Command{
Use: "remove <email>",
Short: "Remove a stored refresh token",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
email := args[0]
store, err := secrets.OpenDefault()
if err != nil {
return err
}
if err := store.DeleteToken(email); err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"deleted": true,
"email": email,
})
}
u.Out().Printf("deleted\ttrue")
u.Out().Printf("email\t%s", email)
return nil
},
}
}

591
internal/cmd/calendar.go Normal file
View File

@ -0,0 +1,591 @@
package cmd
import (
"errors"
"fmt"
"os"
"strings"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
"google.golang.org/api/calendar/v3"
)
func newCalendarCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "calendar",
Short: "Google Calendar",
}
cmd.AddCommand(newCalendarCalendarsCmd(flags))
cmd.AddCommand(newCalendarAclCmd(flags))
cmd.AddCommand(newCalendarEventsCmd(flags))
cmd.AddCommand(newCalendarEventCmd(flags))
cmd.AddCommand(newCalendarCreateCmd(flags))
cmd.AddCommand(newCalendarUpdateCmd(flags))
cmd.AddCommand(newCalendarDeleteCmd(flags))
cmd.AddCommand(newCalendarFreeBusyCmd(flags))
return cmd
}
func newCalendarCalendarsCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "calendars",
Short: "List calendars",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := googleapi.NewCalendar(cmd.Context(), account)
if err != nil {
return err
}
resp, err := svc.CalendarList.List().Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"calendars": resp.Items})
}
if len(resp.Items) == 0 {
u.Err().Println("No calendars")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tROLE")
for _, c := range resp.Items {
fmt.Fprintf(tw, "%s\t%s\t%s\n", c.Id, c.Summary, c.AccessRole)
}
_ = tw.Flush()
return nil
},
}
}
func newCalendarAclCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "acl <calendarId>",
Short: "List access control rules for a calendar",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
calendarID := args[0]
svc, err := googleapi.NewCalendar(cmd.Context(), account)
if err != nil {
return err
}
resp, err := svc.Acl.List(calendarID).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"rules": resp.Items})
}
if len(resp.Items) == 0 {
u.Err().Println("No ACL rules")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "SCOPE_TYPE\tSCOPE_VALUE\tROLE")
for _, rule := range resp.Items {
scopeType := ""
scopeValue := ""
if rule.Scope != nil {
scopeType = rule.Scope.Type
scopeValue = rule.Scope.Value
}
fmt.Fprintf(tw, "%s\t%s\t%s\n", scopeType, scopeValue, rule.Role)
}
_ = tw.Flush()
return nil
},
}
}
func newCalendarEventsCmd(flags *rootFlags) *cobra.Command {
var from string
var to string
var max int64
var page string
var query string
cmd := &cobra.Command{
Use: "events <calendarId>",
Short: "List events from a calendar",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
calendarID := args[0]
now := time.Now().UTC()
oneWeekLater := now.Add(7 * 24 * time.Hour)
if strings.TrimSpace(from) == "" {
from = now.Format(time.RFC3339)
}
if strings.TrimSpace(to) == "" {
to = oneWeekLater.Format(time.RFC3339)
}
svc, err := googleapi.NewCalendar(cmd.Context(), account)
if err != nil {
return err
}
call := svc.Events.List(calendarID).
TimeMin(from).
TimeMax(to).
MaxResults(max).
PageToken(page).
SingleEvents(true).
OrderBy("startTime")
if strings.TrimSpace(query) != "" {
call = call.Q(query)
}
resp, err := call.Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"events": resp.Items,
"nextPageToken": resp.NextPageToken,
})
}
if len(resp.Items) == 0 {
u.Err().Println("No events")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tSTART\tEND\tSUMMARY")
for _, e := range resp.Items {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", e.Id, eventStart(e), eventEnd(e), e.Summary)
}
_ = tw.Flush()
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
}
return nil
},
}
cmd.Flags().StringVar(&from, "from", "", "Start time (RFC3339; default: now)")
cmd.Flags().StringVar(&to, "to", "", "End time (RFC3339; default: +7d)")
cmd.Flags().Int64Var(&max, "max", 10, "Max results")
cmd.Flags().StringVar(&page, "page", "", "Page token")
cmd.Flags().StringVar(&query, "query", "", "Free text search")
return cmd
}
func newCalendarEventCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "event <calendarId> <eventId>",
Short: "Get event details",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
calendarID := args[0]
eventID := args[1]
svc, err := googleapi.NewCalendar(cmd.Context(), account)
if err != nil {
return err
}
e, err := svc.Events.Get(calendarID, eventID).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"event": e})
}
u.Out().Printf("id\t%s", e.Id)
u.Out().Printf("summary\t%s", orEmpty(e.Summary, "(no title)"))
u.Out().Printf("start\t%s", eventStart(e))
u.Out().Printf("end\t%s", eventEnd(e))
if e.Location != "" {
u.Out().Printf("location\t%s", e.Location)
}
if e.Description != "" {
u.Out().Printf("description\t%s", e.Description)
}
if len(e.Attendees) > 0 {
addrs := make([]string, 0, len(e.Attendees))
for _, a := range e.Attendees {
if a != nil && a.Email != "" {
addrs = append(addrs, a.Email)
}
}
if len(addrs) > 0 {
u.Out().Printf("attendees\t%s", strings.Join(addrs, ", "))
}
}
if e.Status != "" {
u.Out().Printf("status\t%s", e.Status)
}
if e.HtmlLink != "" {
u.Out().Printf("link\t%s", e.HtmlLink)
}
return nil
},
}
}
func newCalendarCreateCmd(flags *rootFlags) *cobra.Command {
var summary string
var start string
var end string
var description string
var location string
var attendees string
var allDay bool
cmd := &cobra.Command{
Use: "create <calendarId>",
Short: "Create a new event",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
calendarID := args[0]
if strings.TrimSpace(summary) == "" || strings.TrimSpace(start) == "" || strings.TrimSpace(end) == "" {
return errors.New("required: --summary, --start, --end")
}
svc, err := googleapi.NewCalendar(cmd.Context(), account)
if err != nil {
return err
}
event := &calendar.Event{
Summary: summary,
Description: description,
Location: location,
Start: buildEventDateTime(start, allDay),
End: buildEventDateTime(end, allDay),
Attendees: buildAttendees(attendees),
}
created, err := svc.Events.Insert(calendarID, event).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"event": created})
}
u.Out().Printf("id\t%s", created.Id)
if created.HtmlLink != "" {
u.Out().Printf("link\t%s", created.HtmlLink)
}
return nil
},
}
cmd.Flags().StringVar(&summary, "summary", "", "Event title (required)")
cmd.Flags().StringVar(&start, "start", "", "Start time/date (required)")
cmd.Flags().StringVar(&end, "end", "", "End time/date (required)")
cmd.Flags().StringVar(&description, "description", "", "Event description")
cmd.Flags().StringVar(&location, "location", "", "Event location")
cmd.Flags().StringVar(&attendees, "attendees", "", "Attendees (comma-separated)")
cmd.Flags().BoolVar(&allDay, "all-day", false, "Create all-day event (use YYYY-MM-DD for start/end)")
return cmd
}
func newCalendarUpdateCmd(flags *rootFlags) *cobra.Command {
var summary string
var start string
var end string
var description string
var location string
var attendees string
var allDay bool
cmd := &cobra.Command{
Use: "update <calendarId> <eventId>",
Short: "Update an existing event",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
calendarID := args[0]
eventID := args[1]
svc, err := googleapi.NewCalendar(cmd.Context(), account)
if err != nil {
return err
}
existing, err := svc.Events.Get(calendarID, eventID).Do()
if err != nil {
return err
}
targetAllDay := isAllDayEvent(existing)
if cmd.Flags().Changed("all-day") {
targetAllDay = allDay
// Converting between all-day and timed needs explicit start/end.
if !cmd.Flags().Changed("start") || !cmd.Flags().Changed("end") {
return errors.New("when changing --all-day, also provide --start and --end")
}
}
changed := false
if cmd.Flags().Changed("summary") {
existing.Summary = summary
changed = true
}
if cmd.Flags().Changed("description") {
existing.Description = description
changed = true
}
if cmd.Flags().Changed("location") {
existing.Location = location
changed = true
}
if cmd.Flags().Changed("start") {
existing.Start = buildEventDateTime(start, targetAllDay)
changed = true
}
if cmd.Flags().Changed("end") {
existing.End = buildEventDateTime(end, targetAllDay)
changed = true
}
if cmd.Flags().Changed("attendees") {
existing.Attendees = buildAttendees(attendees)
changed = true
}
if !changed {
return errors.New("no updates provided")
}
updated, err := svc.Events.Update(calendarID, eventID, existing).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"event": updated})
}
u.Out().Printf("id\t%s", updated.Id)
if updated.HtmlLink != "" {
u.Out().Printf("link\t%s", updated.HtmlLink)
}
return nil
},
}
cmd.Flags().StringVar(&summary, "summary", "", "Event title")
cmd.Flags().StringVar(&start, "start", "", "Start time/date (RFC3339 or YYYY-MM-DD)")
cmd.Flags().StringVar(&end, "end", "", "End time/date (RFC3339 or YYYY-MM-DD)")
cmd.Flags().StringVar(&description, "description", "", "Event description")
cmd.Flags().StringVar(&location, "location", "", "Event location")
cmd.Flags().StringVar(&attendees, "attendees", "", "Attendees (comma-separated)")
cmd.Flags().BoolVar(&allDay, "all-day", false, "Treat start/end as all-day (YYYY-MM-DD)")
return cmd
}
func newCalendarDeleteCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "delete <calendarId> <eventId>",
Short: "Delete an event",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
calendarID := args[0]
eventID := args[1]
svc, err := googleapi.NewCalendar(cmd.Context(), account)
if err != nil {
return err
}
if err := svc.Events.Delete(calendarID, eventID).Do(); err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"deleted": true,
"calendarId": calendarID,
"eventId": eventID,
})
}
u.Out().Printf("deleted\ttrue")
u.Out().Printf("calendar_id\t%s", calendarID)
u.Out().Printf("event_id\t%s", eventID)
return nil
},
}
}
func newCalendarFreeBusyCmd(flags *rootFlags) *cobra.Command {
var from string
var to string
cmd := &cobra.Command{
Use: "freebusy <calendarIds>",
Short: "Check free/busy status for calendars (comma-separated IDs)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
calendarIDs := splitCSV(args[0])
if len(calendarIDs) == 0 {
return errors.New("no calendar IDs provided")
}
if strings.TrimSpace(from) == "" || strings.TrimSpace(to) == "" {
return errors.New("required: --from and --to")
}
svc, err := googleapi.NewCalendar(cmd.Context(), account)
if err != nil {
return err
}
items := make([]*calendar.FreeBusyRequestItem, 0, len(calendarIDs))
for _, id := range calendarIDs {
items = append(items, &calendar.FreeBusyRequestItem{Id: id})
}
resp, err := svc.Freebusy.Query(&calendar.FreeBusyRequest{
TimeMin: from,
TimeMax: to,
Items: items,
}).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"calendars": resp.Calendars})
}
if len(resp.Calendars) == 0 {
u.Err().Println("No data")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "CALENDAR\tSTART\tEND")
for id, data := range resp.Calendars {
for _, b := range data.Busy {
fmt.Fprintf(tw, "%s\t%s\t%s\n", id, b.Start, b.End)
}
}
_ = tw.Flush()
return nil
},
}
cmd.Flags().StringVar(&from, "from", "", "Start time (RFC3339, required)")
cmd.Flags().StringVar(&to, "to", "", "End time (RFC3339, required)")
return cmd
}
func buildEventDateTime(value string, allDay bool) *calendar.EventDateTime {
value = strings.TrimSpace(value)
if allDay {
return &calendar.EventDateTime{Date: value}
}
return &calendar.EventDateTime{DateTime: value}
}
func buildAttendees(csv string) []*calendar.EventAttendee {
addrs := splitCSV(csv)
if len(addrs) == 0 {
return nil
}
out := make([]*calendar.EventAttendee, 0, len(addrs))
for _, a := range addrs {
out = append(out, &calendar.EventAttendee{Email: a})
}
return out
}
func splitCSV(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func eventStart(e *calendar.Event) string {
if e == nil || e.Start == nil {
return ""
}
if e.Start.DateTime != "" {
return e.Start.DateTime
}
return e.Start.Date
}
func eventEnd(e *calendar.Event) string {
if e == nil || e.End == nil {
return ""
}
if e.End.DateTime != "" {
return e.End.DateTime
}
return e.End.Date
}
func isAllDayEvent(e *calendar.Event) bool {
return e != nil && e.Start != nil && e.Start.Date != ""
}
func orEmpty(s string, fallback string) string {
if strings.TrimSpace(s) == "" {
return fallback
}
return s
}

View File

@ -0,0 +1,37 @@
package cmd
import (
"testing"
"google.golang.org/api/calendar/v3"
)
func TestSplitCSV(t *testing.T) {
if got := splitCSV(""); got != nil {
t.Fatalf("unexpected: %#v", got)
}
got := splitCSV(" a@b.com, c@d.com ,,")
if len(got) != 2 || got[0] != "a@b.com" || got[1] != "c@d.com" {
t.Fatalf("unexpected: %#v", got)
}
}
func TestBuildEventDateTime(t *testing.T) {
allDay := buildEventDateTime("2025-01-01", true)
if allDay.Date != "2025-01-01" || allDay.DateTime != "" {
t.Fatalf("unexpected: %#v", allDay)
}
timed := buildEventDateTime("2025-01-01T10:00:00Z", false)
if timed.DateTime != "2025-01-01T10:00:00Z" || timed.Date != "" {
t.Fatalf("unexpected: %#v", timed)
}
}
func TestIsAllDayEvent(t *testing.T) {
if isAllDayEvent(nil) {
t.Fatalf("expected false")
}
if !isAllDayEvent(&calendar.Event{Start: &calendar.EventDateTime{Date: "2025-01-01"}}) {
t.Fatalf("expected true")
}
}

134
internal/cmd/contacts.go Normal file
View File

@ -0,0 +1,134 @@
package cmd
import (
"fmt"
"os"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
"google.golang.org/api/people/v1"
)
func newContactsCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "contacts",
Short: "Google Contacts (People API)",
}
cmd.AddCommand(newContactsSearchCmd(flags))
cmd.AddCommand(newContactsListCmd(flags))
cmd.AddCommand(newContactsGetCmd(flags))
cmd.AddCommand(newContactsCreateCmd(flags))
cmd.AddCommand(newContactsUpdateCmd(flags))
cmd.AddCommand(newContactsDeleteCmd(flags))
cmd.AddCommand(newContactsDirectoryCmd(flags))
cmd.AddCommand(newContactsOtherCmd(flags))
return cmd
}
func newContactsSearchCmd(flags *rootFlags) *cobra.Command {
var max int64
cmd := &cobra.Command{
Use: "search <query>",
Short: "Search contacts by name/email/phone",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
query := strings.Join(args, " ")
svc, err := googleapi.NewPeople(cmd.Context(), account)
if err != nil {
return err
}
resp, err := svc.People.SearchContacts().
Query(query).
PageSize(max).
ReadMask("names,emailAddresses,phoneNumbers").
Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
type item struct {
Resource string `json:"resource"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Phone string `json:"phone,omitempty"`
}
items := make([]item, 0, len(resp.Results))
for _, r := range resp.Results {
p := r.Person
if p == nil {
continue
}
items = append(items, item{
Resource: p.ResourceName,
Name: primaryName(p),
Email: primaryEmail(p),
Phone: primaryPhone(p),
})
}
return outfmt.WriteJSON(os.Stdout, map[string]any{"contacts": items})
}
if len(resp.Results) == 0 {
u.Err().Println("No results")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "RESOURCE\tNAME\tEMAIL\tPHONE")
for _, r := range resp.Results {
p := r.Person
if p == nil {
continue
}
fmt.Fprintf(
tw,
"%s\t%s\t%s\t%s\n",
p.ResourceName,
sanitizeTab(primaryName(p)),
sanitizeTab(primaryEmail(p)),
sanitizeTab(primaryPhone(p)),
)
}
_ = tw.Flush()
return nil
},
}
cmd.Flags().Int64Var(&max, "max", 50, "Max results")
return cmd
}
func primaryName(p *people.Person) string {
if p == nil || len(p.Names) == 0 || p.Names[0] == nil {
return ""
}
if p.Names[0].DisplayName != "" {
return p.Names[0].DisplayName
}
return strings.TrimSpace(strings.Join([]string{p.Names[0].GivenName, p.Names[0].FamilyName}, " "))
}
func primaryEmail(p *people.Person) string {
if p == nil || len(p.EmailAddresses) == 0 || p.EmailAddresses[0] == nil {
return ""
}
return p.EmailAddresses[0].Value
}
func primaryPhone(p *people.Person) string {
if p == nil || len(p.PhoneNumbers) == 0 || p.PhoneNumbers[0] == nil {
return ""
}
return p.PhoneNumbers[0].Value
}

View File

@ -0,0 +1,358 @@
package cmd
import (
"errors"
"fmt"
"os"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
"google.golang.org/api/people/v1"
)
const contactsReadMask = "names,emailAddresses,phoneNumbers"
func newContactsListCmd(flags *rootFlags) *cobra.Command {
var max int64
var page string
cmd := &cobra.Command{
Use: "list",
Short: "List contacts",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := googleapi.NewPeople(cmd.Context(), account)
if err != nil {
return err
}
resp, err := svc.People.Connections.List("people/me").
PersonFields(contactsReadMask).
PageSize(max).
PageToken(page).
Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
type item struct {
Resource string `json:"resource"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Phone string `json:"phone,omitempty"`
}
items := make([]item, 0, len(resp.Connections))
for _, p := range resp.Connections {
if p == nil {
continue
}
items = append(items, item{
Resource: p.ResourceName,
Name: primaryName(p),
Email: primaryEmail(p),
Phone: primaryPhone(p),
})
}
return outfmt.WriteJSON(os.Stdout, map[string]any{
"contacts": items,
"nextPageToken": resp.NextPageToken,
})
}
if len(resp.Connections) == 0 {
u.Err().Println("No contacts")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "RESOURCE\tNAME\tEMAIL\tPHONE")
for _, p := range resp.Connections {
if p == nil {
continue
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
p.ResourceName,
sanitizeTab(primaryName(p)),
sanitizeTab(primaryEmail(p)),
sanitizeTab(primaryPhone(p)),
)
}
_ = tw.Flush()
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
}
return nil
},
}
cmd.Flags().Int64Var(&max, "max", 100, "Max results")
cmd.Flags().StringVar(&page, "page", "", "Page token")
return cmd
}
func newContactsGetCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "get <resourceName|email>",
Short: "Get a contact by resource name (people/...) or email",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
identifier := strings.TrimSpace(args[0])
if identifier == "" {
return errors.New("empty identifier")
}
svc, err := googleapi.NewPeople(cmd.Context(), account)
if err != nil {
return err
}
var p *people.Person
if strings.HasPrefix(identifier, "people/") {
p, err = svc.People.Get(identifier).PersonFields(contactsReadMask).Do()
if err != nil {
return err
}
} else {
// Fallback: search and pick first match.
resp, err := svc.People.SearchContacts().
Query(identifier).
PageSize(10).
ReadMask(contactsReadMask).
Do()
if err != nil {
return err
}
for _, r := range resp.Results {
if r.Person == nil {
continue
}
if strings.EqualFold(primaryEmail(r.Person), identifier) {
p = r.Person
break
}
if p == nil {
p = r.Person
}
}
if p == nil {
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"found": false})
}
u.Err().Println("Not found")
return nil
}
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"contact": p})
}
u.Out().Printf("resource\t%s", p.ResourceName)
u.Out().Printf("name\t%s", primaryName(p))
if e := primaryEmail(p); e != "" {
u.Out().Printf("email\t%s", e)
}
if ph := primaryPhone(p); ph != "" {
u.Out().Printf("phone\t%s", ph)
}
return nil
},
}
}
func newContactsCreateCmd(flags *rootFlags) *cobra.Command {
var given string
var family string
var email string
var phone string
cmd := &cobra.Command{
Use: "create",
Short: "Create a new contact",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
if strings.TrimSpace(given) == "" {
return errors.New("required: --given")
}
svc, err := googleapi.NewPeople(cmd.Context(), account)
if err != nil {
return err
}
p := &people.Person{
Names: []*people.Name{{
GivenName: strings.TrimSpace(given),
FamilyName: strings.TrimSpace(family),
}},
}
if strings.TrimSpace(email) != "" {
p.EmailAddresses = []*people.EmailAddress{{Value: strings.TrimSpace(email)}}
}
if strings.TrimSpace(phone) != "" {
p.PhoneNumbers = []*people.PhoneNumber{{Value: strings.TrimSpace(phone)}}
}
created, err := svc.People.CreateContact(p).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"contact": created})
}
u.Out().Printf("resource\t%s", created.ResourceName)
return nil
},
}
cmd.Flags().StringVar(&given, "given", "", "Given name (required)")
cmd.Flags().StringVar(&family, "family", "", "Family name")
cmd.Flags().StringVar(&email, "email", "", "Email address")
cmd.Flags().StringVar(&phone, "phone", "", "Phone number")
return cmd
}
func newContactsUpdateCmd(flags *rootFlags) *cobra.Command {
var given string
var family string
var email string
var phone string
cmd := &cobra.Command{
Use: "update <resourceName>",
Short: "Update an existing contact",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
resourceName := strings.TrimSpace(args[0])
if !strings.HasPrefix(resourceName, "people/") {
return errors.New("resourceName must start with people/")
}
svc, err := googleapi.NewPeople(cmd.Context(), account)
if err != nil {
return err
}
existing, err := svc.People.Get(resourceName).PersonFields(contactsReadMask).Do()
if err != nil {
return err
}
updateFields := make([]string, 0, 3)
if cmd.Flags().Changed("given") || cmd.Flags().Changed("family") {
curGiven := ""
curFamily := ""
if len(existing.Names) > 0 && existing.Names[0] != nil {
curGiven = existing.Names[0].GivenName
curFamily = existing.Names[0].FamilyName
}
if cmd.Flags().Changed("given") {
curGiven = strings.TrimSpace(given)
}
if cmd.Flags().Changed("family") {
curFamily = strings.TrimSpace(family)
}
name := &people.Name{GivenName: curGiven, FamilyName: curFamily}
existing.Names = []*people.Name{name}
updateFields = append(updateFields, "names")
}
if cmd.Flags().Changed("email") {
if strings.TrimSpace(email) == "" {
existing.EmailAddresses = nil
} else {
existing.EmailAddresses = []*people.EmailAddress{{Value: strings.TrimSpace(email)}}
}
updateFields = append(updateFields, "emailAddresses")
}
if cmd.Flags().Changed("phone") {
if strings.TrimSpace(phone) == "" {
existing.PhoneNumbers = nil
} else {
existing.PhoneNumbers = []*people.PhoneNumber{{Value: strings.TrimSpace(phone)}}
}
updateFields = append(updateFields, "phoneNumbers")
}
if len(updateFields) == 0 {
return errors.New("no updates provided")
}
updated, err := svc.People.UpdateContact(resourceName, existing).
UpdatePersonFields(strings.Join(updateFields, ",")).
Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"contact": updated})
}
u.Out().Printf("resource\t%s", updated.ResourceName)
return nil
},
}
cmd.Flags().StringVar(&given, "given", "", "Given name")
cmd.Flags().StringVar(&family, "family", "", "Family name")
cmd.Flags().StringVar(&email, "email", "", "Email address (empty clears)")
cmd.Flags().StringVar(&phone, "phone", "", "Phone number (empty clears)")
return cmd
}
func newContactsDeleteCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "delete <resourceName>",
Short: "Delete a contact",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
resourceName := strings.TrimSpace(args[0])
if !strings.HasPrefix(resourceName, "people/") {
return errors.New("resourceName must start with people/")
}
svc, err := googleapi.NewPeople(cmd.Context(), account)
if err != nil {
return err
}
if _, err := svc.People.DeleteContact(resourceName).Do(); err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"deleted": true, "resource": resourceName})
}
u.Out().Printf("deleted\ttrue")
u.Out().Printf("resource\t%s", resourceName)
return nil
},
}
}

View File

@ -0,0 +1,365 @@
package cmd
import (
"fmt"
"os"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
const directoryReadMask = "names,emailAddresses"
func newContactsDirectoryCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "directory",
Short: "Google Workspace directory",
}
cmd.AddCommand(newContactsDirectoryListCmd(flags))
cmd.AddCommand(newContactsDirectorySearchCmd(flags))
return cmd
}
func newContactsDirectoryListCmd(flags *rootFlags) *cobra.Command {
var max int64
var page string
cmd := &cobra.Command{
Use: "list",
Short: "List people from the Workspace directory",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := googleapi.NewPeople(cmd.Context(), account)
if err != nil {
return err
}
resp, err := svc.People.ListDirectoryPeople().
ReadMask(directoryReadMask).
PageSize(max).
PageToken(page).
Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
type item struct {
Resource string `json:"resource"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
}
items := make([]item, 0, len(resp.People))
for _, p := range resp.People {
if p == nil {
continue
}
items = append(items, item{
Resource: p.ResourceName,
Name: primaryName(p),
Email: primaryEmail(p),
})
}
return outfmt.WriteJSON(os.Stdout, map[string]any{
"people": items,
"nextPageToken": resp.NextPageToken,
})
}
if len(resp.People) == 0 {
u.Err().Println("No results")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "RESOURCE\tNAME\tEMAIL")
for _, p := range resp.People {
if p == nil {
continue
}
fmt.Fprintf(tw, "%s\t%s\t%s\n",
p.ResourceName,
sanitizeTab(primaryName(p)),
sanitizeTab(primaryEmail(p)),
)
}
_ = tw.Flush()
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
}
return nil
},
}
cmd.Flags().Int64Var(&max, "max", 50, "Max results")
cmd.Flags().StringVar(&page, "page", "", "Page token")
return cmd
}
func newContactsDirectorySearchCmd(flags *rootFlags) *cobra.Command {
var max int64
var page string
cmd := &cobra.Command{
Use: "search <query>",
Short: "Search people in the Workspace directory",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
query := strings.Join(args, " ")
svc, err := googleapi.NewPeople(cmd.Context(), account)
if err != nil {
return err
}
resp, err := svc.People.SearchDirectoryPeople().
Query(query).
ReadMask(directoryReadMask).
PageSize(max).
PageToken(page).
Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
type item struct {
Resource string `json:"resource"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
}
items := make([]item, 0, len(resp.People))
for _, p := range resp.People {
if p == nil {
continue
}
items = append(items, item{
Resource: p.ResourceName,
Name: primaryName(p),
Email: primaryEmail(p),
})
}
return outfmt.WriteJSON(os.Stdout, map[string]any{
"people": items,
"nextPageToken": resp.NextPageToken,
})
}
if len(resp.People) == 0 {
u.Err().Println("No results")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "RESOURCE\tNAME\tEMAIL")
for _, p := range resp.People {
if p == nil {
continue
}
fmt.Fprintf(tw, "%s\t%s\t%s\n",
p.ResourceName,
sanitizeTab(primaryName(p)),
sanitizeTab(primaryEmail(p)),
)
}
_ = tw.Flush()
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
}
return nil
},
}
cmd.Flags().Int64Var(&max, "max", 50, "Max results")
cmd.Flags().StringVar(&page, "page", "", "Page token")
return cmd
}
func newContactsOtherCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "other",
Short: "Other contacts (people you've interacted with)",
}
cmd.AddCommand(newContactsOtherListCmd(flags))
cmd.AddCommand(newContactsOtherSearchCmd(flags))
return cmd
}
func newContactsOtherListCmd(flags *rootFlags) *cobra.Command {
var max int64
var page string
cmd := &cobra.Command{
Use: "list",
Short: "List other contacts",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := googleapi.NewPeople(cmd.Context(), account)
if err != nil {
return err
}
resp, err := svc.OtherContacts.List().
ReadMask(contactsReadMask).
PageSize(max).
PageToken(page).
Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
type item struct {
Resource string `json:"resource"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Phone string `json:"phone,omitempty"`
}
items := make([]item, 0, len(resp.OtherContacts))
for _, p := range resp.OtherContacts {
if p == nil {
continue
}
items = append(items, item{
Resource: p.ResourceName,
Name: primaryName(p),
Email: primaryEmail(p),
Phone: primaryPhone(p),
})
}
return outfmt.WriteJSON(os.Stdout, map[string]any{
"contacts": items,
"nextPageToken": resp.NextPageToken,
})
}
if len(resp.OtherContacts) == 0 {
u.Err().Println("No results")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "RESOURCE\tNAME\tEMAIL\tPHONE")
for _, p := range resp.OtherContacts {
if p == nil {
continue
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
p.ResourceName,
sanitizeTab(primaryName(p)),
sanitizeTab(primaryEmail(p)),
sanitizeTab(primaryPhone(p)),
)
}
_ = tw.Flush()
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
}
return nil
},
}
cmd.Flags().Int64Var(&max, "max", 100, "Max results")
cmd.Flags().StringVar(&page, "page", "", "Page token")
return cmd
}
func newContactsOtherSearchCmd(flags *rootFlags) *cobra.Command {
var max int64
cmd := &cobra.Command{
Use: "search <query>",
Short: "Search other contacts",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
query := strings.Join(args, " ")
svc, err := googleapi.NewPeople(cmd.Context(), account)
if err != nil {
return err
}
resp, err := svc.OtherContacts.Search().
Query(query).
ReadMask(contactsReadMask).
PageSize(max).
Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
type item struct {
Resource string `json:"resource"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Phone string `json:"phone,omitempty"`
}
items := make([]item, 0, len(resp.Results))
for _, r := range resp.Results {
p := r.Person
if p == nil {
continue
}
items = append(items, item{
Resource: p.ResourceName,
Name: primaryName(p),
Email: primaryEmail(p),
Phone: primaryPhone(p),
})
}
return outfmt.WriteJSON(os.Stdout, map[string]any{"contacts": items})
}
if len(resp.Results) == 0 {
u.Err().Println("No results")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "RESOURCE\tNAME\tEMAIL\tPHONE")
for _, r := range resp.Results {
p := r.Person
if p == nil {
continue
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
p.ResourceName,
sanitizeTab(primaryName(p)),
sanitizeTab(primaryEmail(p)),
sanitizeTab(primaryPhone(p)),
)
}
_ = tw.Flush()
return nil
},
}
cmd.Flags().Int64Var(&max, "max", 50, "Max results")
return cmd
}

View File

@ -0,0 +1,35 @@
package cmd
import (
"testing"
"google.golang.org/api/people/v1"
)
func TestPrimaryName(t *testing.T) {
p := &people.Person{
Names: []*people.Name{
{DisplayName: "Ada Lovelace", GivenName: "Ada", FamilyName: "Lovelace"},
},
}
if got := primaryName(p); got != "Ada Lovelace" {
t.Fatalf("unexpected: %q", got)
}
p.Names[0].DisplayName = ""
if got := primaryName(p); got != "Ada Lovelace" {
t.Fatalf("unexpected: %q", got)
}
}
func TestPrimaryEmailPhone(t *testing.T) {
p := &people.Person{
EmailAddresses: []*people.EmailAddress{{Value: "a@b.com"}},
PhoneNumbers: []*people.PhoneNumber{{Value: "+1 555 0100"}},
}
if got := primaryEmail(p); got != "a@b.com" {
t.Fatalf("unexpected: %q", got)
}
if got := primaryPhone(p); got != "+1 555 0100" {
t.Fatalf("unexpected: %q", got)
}
}

945
internal/cmd/drive.go Normal file
View File

@ -0,0 +1,945 @@
package cmd
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
"google.golang.org/api/drive/v3"
gapi "google.golang.org/api/googleapi"
)
func newDriveCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "drive",
Short: "Google Drive",
}
cmd.AddCommand(newDriveLsCmd(flags))
cmd.AddCommand(newDriveSearchCmd(flags))
cmd.AddCommand(newDriveGetCmd(flags))
cmd.AddCommand(newDriveDownloadCmd(flags))
cmd.AddCommand(newDriveUploadCmd(flags))
cmd.AddCommand(newDriveMkdirCmd(flags))
cmd.AddCommand(newDriveDeleteCmd(flags))
cmd.AddCommand(newDriveMoveCmd(flags))
cmd.AddCommand(newDriveRenameCmd(flags))
cmd.AddCommand(newDriveShareCmd(flags))
cmd.AddCommand(newDriveUnshareCmd(flags))
cmd.AddCommand(newDrivePermissionsCmd(flags))
cmd.AddCommand(newDriveURLCmd(flags))
return cmd
}
func newDriveLsCmd(flags *rootFlags) *cobra.Command {
var max int64
var page string
var query string
cmd := &cobra.Command{
Use: "ls [folderId]",
Short: "List files in a folder (default: root)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
folderID := "root"
if len(args) == 1 {
folderID = args[0]
}
svc, err := googleapi.NewDrive(cmd.Context(), account)
if err != nil {
return err
}
q := buildDriveListQuery(folderID, query)
resp, err := svc.Files.List().
Q(q).
PageSize(max).
PageToken(page).
OrderBy("modifiedTime desc").
SupportsAllDrives(true).
IncludeItemsFromAllDrives(true).
Fields("nextPageToken, files(id, name, mimeType, size, modifiedTime, parents, webViewLink)").
Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"files": resp.Files,
"nextPageToken": resp.NextPageToken,
})
}
if len(resp.Files) == 0 {
u.Err().Println("No files")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tTYPE\tSIZE\tMODIFIED")
for _, f := range resp.Files {
fmt.Fprintf(
tw,
"%s\t%s\t%s\t%s\t%s\n",
f.Id,
f.Name,
driveType(f.MimeType),
formatDriveSize(f.Size),
formatDateTime(f.ModifiedTime),
)
}
_ = tw.Flush()
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
}
return nil
},
}
cmd.Flags().Int64Var(&max, "max", 20, "Max results")
cmd.Flags().StringVar(&page, "page", "", "Page token")
cmd.Flags().StringVar(&query, "query", "", "Drive query filter")
return cmd
}
func newDriveSearchCmd(flags *rootFlags) *cobra.Command {
var max int64
var page string
cmd := &cobra.Command{
Use: "search <text>",
Short: "Full-text search across Drive",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
text := strings.Join(args, " ")
svc, err := googleapi.NewDrive(cmd.Context(), account)
if err != nil {
return err
}
resp, err := svc.Files.List().
Q(buildDriveSearchQuery(text)).
PageSize(max).
PageToken(page).
OrderBy("modifiedTime desc").
SupportsAllDrives(true).
IncludeItemsFromAllDrives(true).
Fields("nextPageToken, files(id, name, mimeType, size, modifiedTime, parents, webViewLink)").
Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"files": resp.Files,
"nextPageToken": resp.NextPageToken,
})
}
if len(resp.Files) == 0 {
u.Err().Println("No results")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tTYPE\tSIZE\tMODIFIED")
for _, f := range resp.Files {
fmt.Fprintf(
tw,
"%s\t%s\t%s\t%s\t%s\n",
f.Id,
f.Name,
driveType(f.MimeType),
formatDriveSize(f.Size),
formatDateTime(f.ModifiedTime),
)
}
_ = tw.Flush()
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
}
return nil
},
}
cmd.Flags().Int64Var(&max, "max", 20, "Max results")
cmd.Flags().StringVar(&page, "page", "", "Page token")
return cmd
}
func newDriveGetCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "get <fileId>",
Short: "Get file metadata",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := args[0]
svc, err := googleapi.NewDrive(cmd.Context(), account)
if err != nil {
return err
}
f, err := svc.Files.Get(fileID).
SupportsAllDrives(true).
Fields("id, name, mimeType, size, modifiedTime, createdTime, parents, webViewLink, description, starred").
Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"file": f})
}
u.Out().Printf("id\t%s", f.Id)
u.Out().Printf("name\t%s", f.Name)
u.Out().Printf("type\t%s", f.MimeType)
u.Out().Printf("size\t%s", formatDriveSize(f.Size))
u.Out().Printf("created\t%s", f.CreatedTime)
u.Out().Printf("modified\t%s", f.ModifiedTime)
if f.Description != "" {
u.Out().Printf("description\t%s", f.Description)
}
u.Out().Printf("starred\t%t", f.Starred)
if f.WebViewLink != "" {
u.Out().Printf("link\t%s", f.WebViewLink)
}
return nil
},
}
}
func newDriveDownloadCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "download <fileId> [destPath]",
Short: "Download a file (Google Docs exported)",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := args[0]
destPath := ""
if len(args) == 2 {
destPath = args[1]
}
svc, err := googleapi.NewDrive(cmd.Context(), account)
if err != nil {
return err
}
meta, err := svc.Files.Get(fileID).
SupportsAllDrives(true).
Fields("id, name, mimeType").
Do()
if err != nil {
return err
}
if meta.Name == "" {
return errors.New("file has no name")
}
if destPath == "" {
dir, dirErr := config.EnsureDriveDownloadsDir()
if dirErr != nil {
return dirErr
}
destPath = filepath.Join(dir, fmt.Sprintf("%s_%s", fileID, meta.Name))
}
outPath, size, err := downloadDriveFile(cmd.Context(), svc, meta, destPath)
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"path": outPath,
"size": size,
})
}
u.Out().Printf("path\t%s", outPath)
u.Out().Printf("size\t%s", formatDriveSize(size))
return nil
},
}
}
func newDriveUploadCmd(flags *rootFlags) *cobra.Command {
var name string
var folderID string
cmd := &cobra.Command{
Use: "upload <localPath>",
Short: "Upload a file",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
localPath := args[0]
f, err := os.Open(localPath)
if err != nil {
return err
}
defer f.Close()
fileName := name
if fileName == "" {
fileName = filepath.Base(localPath)
}
svc, err := googleapi.NewDrive(cmd.Context(), account)
if err != nil {
return err
}
meta := &drive.File{Name: fileName}
if folderID != "" {
meta.Parents = []string{folderID}
}
mimeType := guessMimeType(localPath)
created, err := svc.Files.Create(meta).
SupportsAllDrives(true).
Media(f, gapi.ContentType(mimeType)).
Fields("id, name, mimeType, size, webViewLink").
Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"file": created})
}
u.Out().Printf("id\t%s", created.Id)
u.Out().Printf("name\t%s", created.Name)
if created.WebViewLink != "" {
u.Out().Printf("link\t%s", created.WebViewLink)
}
return nil
},
}
cmd.Flags().StringVar(&name, "name", "", "Override filename")
cmd.Flags().StringVar(&folderID, "folder", "", "Destination folder ID")
return cmd
}
func newDriveMkdirCmd(flags *rootFlags) *cobra.Command {
var parent string
cmd := &cobra.Command{
Use: "mkdir <name>",
Short: "Create a folder",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
name := args[0]
svc, err := googleapi.NewDrive(cmd.Context(), account)
if err != nil {
return err
}
f := &drive.File{
Name: name,
MimeType: "application/vnd.google-apps.folder",
}
if parent != "" {
f.Parents = []string{parent}
}
created, err := svc.Files.Create(f).
SupportsAllDrives(true).
Fields("id, name, webViewLink").
Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"folder": created})
}
u.Out().Printf("id\t%s", created.Id)
u.Out().Printf("name\t%s", created.Name)
if created.WebViewLink != "" {
u.Out().Printf("link\t%s", created.WebViewLink)
}
return nil
},
}
cmd.Flags().StringVar(&parent, "parent", "", "Parent folder ID")
return cmd
}
func newDriveDeleteCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "delete <fileId>",
Short: "Delete a file (moves to trash)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := args[0]
svc, err := googleapi.NewDrive(cmd.Context(), account)
if err != nil {
return err
}
if err := svc.Files.Delete(fileID).SupportsAllDrives(true).Do(); err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"deleted": true,
"id": fileID,
})
}
u.Out().Printf("deleted\ttrue")
u.Out().Printf("id\t%s", fileID)
return nil
},
}
}
func newDriveMoveCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "move <fileId> <newParentId>",
Short: "Move a file to a different folder",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := args[0]
newParentID := args[1]
svc, err := googleapi.NewDrive(cmd.Context(), account)
if err != nil {
return err
}
meta, err := svc.Files.Get(fileID).
SupportsAllDrives(true).
Fields("id, name, parents").
Do()
if err != nil {
return err
}
call := svc.Files.Update(fileID, &drive.File{}).
SupportsAllDrives(true).
AddParents(newParentID).
Fields("id, name, parents, webViewLink")
if len(meta.Parents) > 0 {
call = call.RemoveParents(strings.Join(meta.Parents, ","))
}
updated, err := call.Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"file": updated})
}
u.Out().Printf("id\t%s", updated.Id)
u.Out().Printf("name\t%s", updated.Name)
return nil
},
}
}
func newDriveRenameCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "rename <fileId> <newName>",
Short: "Rename a file or folder",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := args[0]
newName := args[1]
svc, err := googleapi.NewDrive(cmd.Context(), account)
if err != nil {
return err
}
updated, err := svc.Files.Update(fileID, &drive.File{Name: newName}).
SupportsAllDrives(true).
Fields("id, name").
Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"file": updated})
}
u.Out().Printf("id\t%s", updated.Id)
u.Out().Printf("name\t%s", updated.Name)
return nil
},
}
}
func newDriveShareCmd(flags *rootFlags) *cobra.Command {
var anyone bool
var email string
var role string
var discoverable bool
cmd := &cobra.Command{
Use: "share <fileId>",
Short: "Share a file or folder",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := args[0]
if !anyone && email == "" {
return errors.New("must specify --anyone or --email")
}
if role == "" {
role = "reader"
}
if role != "reader" && role != "writer" {
return errors.New("invalid --role (expected reader|writer)")
}
svc, err := googleapi.NewDrive(cmd.Context(), account)
if err != nil {
return err
}
perm := &drive.Permission{Role: role}
if anyone {
perm.Type = "anyone"
perm.AllowFileDiscovery = discoverable
} else {
perm.Type = "user"
perm.EmailAddress = email
}
created, err := svc.Permissions.Create(fileID, perm).
SupportsAllDrives(true).
SendNotificationEmail(false).
Fields("id, type, role, emailAddress").
Do()
if err != nil {
return err
}
link, err := driveWebLink(svc, fileID)
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"link": link,
"permissionId": created.Id,
"permission": created,
})
}
u.Out().Printf("link\t%s", link)
u.Out().Printf("permission_id\t%s", created.Id)
return nil
},
}
cmd.Flags().BoolVar(&anyone, "anyone", false, "Make publicly accessible")
cmd.Flags().StringVar(&email, "email", "", "Share with specific user")
cmd.Flags().StringVar(&role, "role", "reader", "Permission: reader|writer")
cmd.Flags().BoolVar(&discoverable, "discoverable", false, "Allow file discovery in search (anyone/domain only)")
return cmd
}
func newDriveUnshareCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "unshare <fileId> <permissionId>",
Short: "Remove a permission from a file",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := args[0]
permissionID := args[1]
svc, err := googleapi.NewDrive(cmd.Context(), account)
if err != nil {
return err
}
if err := svc.Permissions.Delete(fileID, permissionID).SupportsAllDrives(true).Do(); err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"removed": true,
"fileId": fileID,
"permissionId": permissionID,
})
}
u.Out().Printf("removed\ttrue")
u.Out().Printf("file_id\t%s", fileID)
u.Out().Printf("permission_id\t%s", permissionID)
return nil
},
}
}
func newDrivePermissionsCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "permissions <fileId>",
Short: "List permissions on a file",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := args[0]
svc, err := googleapi.NewDrive(cmd.Context(), account)
if err != nil {
return err
}
resp, err := svc.Permissions.List(fileID).
SupportsAllDrives(true).
Fields("permissions(id, type, role, emailAddress)").
Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"permissions": resp.Permissions})
}
if len(resp.Permissions) == 0 {
u.Err().Println("No permissions")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tTYPE\tROLE\tEMAIL")
for _, p := range resp.Permissions {
email := p.EmailAddress
if email == "" {
email = "-"
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", p.Id, p.Type, p.Role, email)
}
_ = tw.Flush()
return nil
},
}
}
func newDriveURLCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "url <fileIds...>",
Short: "Print web URLs for files",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := googleapi.NewDrive(cmd.Context(), account)
if err != nil {
return err
}
for _, id := range args {
link, err := driveWebLink(svc, id)
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
// collected below
} else {
u.Out().Printf("%s\t%s", id, link)
}
}
if outfmt.IsJSON(cmd.Context()) {
urls := make([]map[string]string, 0, len(args))
for _, id := range args {
link, err := driveWebLink(svc, id)
if err != nil {
return err
}
urls = append(urls, map[string]string{"id": id, "url": link})
}
return outfmt.WriteJSON(os.Stdout, map[string]any{"urls": urls})
}
return nil
},
}
}
func buildDriveListQuery(folderID string, userQuery string) string {
q := strings.TrimSpace(userQuery)
parent := fmt.Sprintf("'%s' in parents", folderID)
if q != "" {
q = q + " and " + parent
} else {
q = parent
}
if !strings.Contains(q, "trashed") {
q = q + " and trashed = false"
}
return q
}
func buildDriveSearchQuery(text string) string {
q := fmt.Sprintf("fullText contains '%s'", escapeDriveQueryString(text))
return q + " and trashed = false"
}
func escapeDriveQueryString(s string) string {
return strings.ReplaceAll(s, "'", "\\'")
}
func driveType(mimeType string) string {
if mimeType == "application/vnd.google-apps.folder" {
return "folder"
}
return "file"
}
func formatDateTime(iso string) string {
if iso == "" {
return "-"
}
if len(iso) >= 16 {
return strings.ReplaceAll(iso[:16], "T", " ")
}
return iso
}
func formatDriveSize(bytes int64) string {
if bytes <= 0 {
return "-"
}
const unit = 1024.0
b := float64(bytes)
units := []string{"B", "KB", "MB", "GB", "TB"}
i := 0
for b >= unit && i < len(units)-1 {
b /= unit
i++
}
if i == 0 {
return fmt.Sprintf("%d B", bytes)
}
return fmt.Sprintf("%.1f %s", b, units[i])
}
func guessMimeType(path string) string {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".pdf":
return "application/pdf"
case ".doc":
return "application/msword"
case ".docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
case ".xls":
return "application/vnd.ms-excel"
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case ".ppt":
return "application/vnd.ms-powerpoint"
case ".pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".txt":
return "text/plain"
case ".html":
return "text/html"
case ".css":
return "text/css"
case ".js":
return "application/javascript"
case ".json":
return "application/json"
case ".zip":
return "application/zip"
case ".csv":
return "text/csv"
case ".md":
return "text/markdown"
default:
return "application/octet-stream"
}
}
func downloadDriveFile(ctx context.Context, svc *drive.Service, meta *drive.File, destPath string) (string, int64, error) {
isGoogleDoc := strings.HasPrefix(meta.MimeType, "application/vnd.google-apps.")
var (
resp *http.Response
outPath string
err error
)
if isGoogleDoc {
exportMimeType := driveExportMimeType(meta.MimeType)
outPath = replaceExt(destPath, driveExportExtension(exportMimeType))
resp, err = svc.Files.Export(meta.Id, exportMimeType).Context(ctx).Download()
} else {
outPath = destPath
resp, err = svc.Files.Get(meta.Id).SupportsAllDrives(true).Context(ctx).Download()
}
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return "", 0, fmt.Errorf("download failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
f, err := os.Create(outPath)
if err != nil {
return "", 0, err
}
defer f.Close()
n, err := io.Copy(f, resp.Body)
if err != nil {
return "", 0, err
}
return outPath, n, nil
}
func replaceExt(path string, ext string) string {
base := strings.TrimSuffix(path, filepath.Ext(path))
return base + ext
}
func driveExportMimeType(googleMimeType string) string {
switch googleMimeType {
case "application/vnd.google-apps.document":
return "application/pdf"
case "application/vnd.google-apps.spreadsheet":
return "text/csv"
case "application/vnd.google-apps.presentation":
return "application/pdf"
case "application/vnd.google-apps.drawing":
return "image/png"
default:
return "application/pdf"
}
}
func driveExportExtension(mimeType string) string {
switch mimeType {
case "application/pdf":
return ".pdf"
case "text/csv":
return ".csv"
case "image/png":
return ".png"
case "text/plain":
return ".txt"
default:
return ".pdf"
}
}
func driveWebLink(svc *drive.Service, fileID string) (string, error) {
f, err := svc.Files.Get(fileID).SupportsAllDrives(true).Fields("webViewLink").Do()
if err != nil {
return "", err
}
if f.WebViewLink != "" {
return f.WebViewLink, nil
}
return fmt.Sprintf("https://drive.google.com/file/d/%s/view", fileID), nil
}

View File

@ -0,0 +1,52 @@
package cmd
import "testing"
func TestBuildDriveListQuery(t *testing.T) {
t.Run("adds parent and trashed", func(t *testing.T) {
got := buildDriveListQuery("root", "")
if got != "'root' in parents and trashed = false" {
t.Fatalf("unexpected: %q", got)
}
})
t.Run("combines with user query", func(t *testing.T) {
got := buildDriveListQuery("abc", "mimeType='image/png'")
if got != "mimeType='image/png' and 'abc' in parents and trashed = false" {
t.Fatalf("unexpected: %q", got)
}
})
t.Run("does not force trashed when user sets it", func(t *testing.T) {
got := buildDriveListQuery("abc", "trashed = true")
if got != "trashed = true and 'abc' in parents" {
t.Fatalf("unexpected: %q", got)
}
})
}
func TestBuildDriveSearchQuery(t *testing.T) {
got := buildDriveSearchQuery("hello world")
if got != "fullText contains 'hello world' and trashed = false" {
t.Fatalf("unexpected: %q", got)
}
}
func TestEscapeDriveQueryString(t *testing.T) {
got := escapeDriveQueryString("a'b")
if got != "a\\'b" {
t.Fatalf("unexpected: %q", got)
}
}
func TestFormatDriveSize(t *testing.T) {
if got := formatDriveSize(0); got != "-" {
t.Fatalf("unexpected: %q", got)
}
if got := formatDriveSize(1); got != "1 B" {
t.Fatalf("unexpected: %q", got)
}
if got := formatDriveSize(1024); got != "1.0 KB" {
t.Fatalf("unexpected: %q", got)
}
}

186
internal/cmd/gmail.go Normal file
View File

@ -0,0 +1,186 @@
package cmd
import (
"fmt"
"net/mail"
"os"
"strings"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
"google.golang.org/api/gmail/v1"
)
func newGmailCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "gmail",
Short: "Gmail",
}
cmd.AddCommand(newGmailSearchCmd(flags))
cmd.AddCommand(newGmailThreadCmd(flags))
cmd.AddCommand(newGmailURLCmd(flags))
cmd.AddCommand(newGmailLabelsCmd(flags))
cmd.AddCommand(newGmailSendCmd(flags))
cmd.AddCommand(newGmailDraftsCmd(flags))
return cmd
}
func newGmailSearchCmd(flags *rootFlags) *cobra.Command {
var max int64
var page string
cmd := &cobra.Command{
Use: "search <query>",
Short: "Search threads using Gmail query syntax",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
query := strings.Join(args, " ")
svc, err := googleapi.NewGmail(cmd.Context(), account)
if err != nil {
return err
}
resp, err := svc.Users.Threads.List("me").
Q(query).
MaxResults(max).
PageToken(page).
Do()
if err != nil {
return err
}
idToName, err := fetchLabelIDToName(svc)
if err != nil {
return err
}
type item struct {
ID string `json:"id"`
Date string `json:"date,omitempty"`
From string `json:"from,omitempty"`
Subject string `json:"subject,omitempty"`
Labels []string `json:"labels,omitempty"`
}
items := make([]item, 0, len(resp.Threads))
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
for _, t := range resp.Threads {
if t.Id == "" {
continue
}
thread, err := svc.Users.Threads.Get("me", t.Id).
Format("metadata").
MetadataHeaders("From", "Subject", "Date").
Do()
if err != nil {
return err
}
msg := firstMessage(thread)
date := ""
from := ""
subject := ""
var labels []string
if msg != nil {
date = formatGmailDate(headerValue(msg.Payload, "Date"))
from = headerValue(msg.Payload, "From")
subject = headerValue(msg.Payload, "Subject")
if len(msg.LabelIds) > 0 {
names := make([]string, 0, len(msg.LabelIds))
for _, id := range msg.LabelIds {
if n, ok := idToName[id]; ok {
names = append(names, n)
} else {
names = append(names, id)
}
}
labels = names
}
}
items = append(items, item{
ID: t.Id,
Date: date,
From: sanitizeTab(from),
Subject: sanitizeTab(subject),
Labels: labels,
})
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"threads": items,
"nextPageToken": resp.NextPageToken,
})
}
if len(items) == 0 {
u.Err().Println("No results")
return nil
}
fmt.Fprintln(tw, "ID\tDATE\tFROM\tSUBJECT\tLABELS")
for _, it := range items {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", it.ID, it.Date, it.From, it.Subject, strings.Join(it.Labels, ","))
}
_ = tw.Flush()
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
}
return nil
},
}
cmd.Flags().Int64Var(&max, "max", 10, "Max results")
cmd.Flags().StringVar(&page, "page", "", "Page token")
return cmd
}
func firstMessage(t *gmail.Thread) *gmail.Message {
if t == nil || len(t.Messages) == 0 {
return nil
}
return t.Messages[0]
}
func headerValue(p *gmail.MessagePart, name string) string {
if p == nil {
return ""
}
for _, h := range p.Headers {
if strings.EqualFold(h.Name, name) {
return h.Value
}
}
return ""
}
func formatGmailDate(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if t, err := mailParseDate(raw); err == nil {
return t.Format("2006-01-02 15:04")
}
return raw
}
func sanitizeTab(s string) string {
return strings.ReplaceAll(s, "\t", " ")
}
func mailParseDate(s string) (time.Time, error) {
// net/mail has the most compatible Date parser, but we keep this isolated for easier tests/mocks later.
return mail.ParseDate(s)
}

View File

@ -0,0 +1,379 @@
package cmd
import (
"encoding/base64"
"errors"
"fmt"
"os"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
"google.golang.org/api/gmail/v1"
)
func newGmailDraftsCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "drafts",
Short: "Manage drafts",
}
cmd.AddCommand(newGmailDraftsListCmd(flags))
cmd.AddCommand(newGmailDraftsGetCmd(flags))
cmd.AddCommand(newGmailDraftsDeleteCmd(flags))
cmd.AddCommand(newGmailDraftsSendCmd(flags))
cmd.AddCommand(newGmailDraftsCreateCmd(flags))
return cmd
}
func newGmailDraftsListCmd(flags *rootFlags) *cobra.Command {
var max int64
var page string
cmd := &cobra.Command{
Use: "list",
Short: "List drafts",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := googleapi.NewGmail(cmd.Context(), account)
if err != nil {
return err
}
resp, err := svc.Users.Drafts.List("me").MaxResults(max).PageToken(page).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
type item struct {
ID string `json:"id"`
MessageID string `json:"messageId,omitempty"`
ThreadID string `json:"threadId,omitempty"`
}
items := make([]item, 0, len(resp.Drafts))
for _, d := range resp.Drafts {
if d == nil {
continue
}
var msgID, threadID string
if d.Message != nil {
msgID = d.Message.Id
threadID = d.Message.ThreadId
}
items = append(items, item{ID: d.Id, MessageID: msgID, ThreadID: threadID})
}
return outfmt.WriteJSON(os.Stdout, map[string]any{
"drafts": items,
"nextPageToken": resp.NextPageToken,
})
}
if len(resp.Drafts) == 0 {
u.Err().Println("No drafts")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tMESSAGE_ID")
for _, d := range resp.Drafts {
msgID := ""
if d.Message != nil {
msgID = d.Message.Id
}
fmt.Fprintf(tw, "%s\t%s\n", d.Id, msgID)
}
_ = tw.Flush()
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
}
return nil
},
}
cmd.Flags().Int64Var(&max, "max", 20, "Max results")
cmd.Flags().StringVar(&page, "page", "", "Page token")
return cmd
}
func newGmailDraftsGetCmd(flags *rootFlags) *cobra.Command {
var download bool
cmd := &cobra.Command{
Use: "get <draftId>",
Short: "Get draft details",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
draftID := args[0]
svc, err := googleapi.NewGmail(cmd.Context(), account)
if err != nil {
return err
}
draft, err := svc.Users.Drafts.Get("me", draftID).Format("full").Do()
if err != nil {
return err
}
if draft.Message == nil {
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"draft": draft})
}
u.Err().Println("Empty draft")
return nil
}
msg := draft.Message
if outfmt.IsJSON(cmd.Context()) {
out := map[string]any{"draft": draft}
if download {
attachDir, err := config.EnsureGmailAttachmentsDir()
if err != nil {
return err
}
type dl struct {
MessageID string `json:"messageId"`
AttachmentID string `json:"attachmentId"`
Filename string `json:"filename"`
Path string `json:"path"`
Cached bool `json:"cached"`
}
downloaded := make([]dl, 0)
for _, a := range collectAttachments(msg.Payload) {
outPath, cached, err := downloadAttachment(cmd, svc, msg.Id, a, attachDir)
if err != nil {
return err
}
downloaded = append(downloaded, dl{
MessageID: msg.Id,
AttachmentID: a.AttachmentID,
Filename: a.Filename,
Path: outPath,
Cached: cached,
})
}
out["downloaded"] = downloaded
}
return outfmt.WriteJSON(os.Stdout, out)
}
u.Out().Printf("Draft-ID: %s", draft.Id)
u.Out().Printf("Message-ID: %s", msg.Id)
u.Out().Printf("To: %s", headerValue(msg.Payload, "To"))
u.Out().Printf("Cc: %s", headerValue(msg.Payload, "Cc"))
u.Out().Printf("Subject: %s", headerValue(msg.Payload, "Subject"))
u.Out().Println("")
body := bestBodyText(msg.Payload)
if body != "" {
u.Out().Println(body)
u.Out().Println("")
}
attachments := collectAttachments(msg.Payload)
if len(attachments) > 0 {
u.Out().Println("Attachments:")
for _, a := range attachments {
u.Out().Printf(" - %s (%d bytes)", a.Filename, a.Size)
}
u.Out().Println("")
}
if download && msg.Id != "" && len(attachments) > 0 {
attachDir, err := config.EnsureGmailAttachmentsDir()
if err != nil {
return err
}
for _, a := range attachments {
outPath, cached, err := downloadAttachment(cmd, svc, msg.Id, a, attachDir)
if err != nil {
return err
}
if cached {
u.Out().Printf("Cached: %s", outPath)
} else {
u.Out().Successf("Saved: %s", outPath)
}
}
}
return nil
},
}
cmd.Flags().BoolVar(&download, "download", false, "Download draft attachments")
return cmd
}
func newGmailDraftsDeleteCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "delete <draftId>",
Short: "Delete a draft",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
draftID := args[0]
svc, err := googleapi.NewGmail(cmd.Context(), account)
if err != nil {
return err
}
if err := svc.Users.Drafts.Delete("me", draftID).Do(); err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"deleted": true, "draftId": draftID})
}
u.Out().Printf("deleted\ttrue")
u.Out().Printf("draft_id\t%s", draftID)
return nil
},
}
}
func newGmailDraftsSendCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "send <draftId>",
Short: "Send a draft",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
draftID := args[0]
svc, err := googleapi.NewGmail(cmd.Context(), account)
if err != nil {
return err
}
msg, err := svc.Users.Drafts.Send("me", &gmail.Draft{Id: draftID}).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"messageId": msg.Id,
"threadId": msg.ThreadId,
})
}
u.Out().Printf("message_id\t%s", msg.Id)
if msg.ThreadId != "" {
u.Out().Printf("thread_id\t%s", msg.ThreadId)
}
return nil
},
}
}
func newGmailDraftsCreateCmd(flags *rootFlags) *cobra.Command {
var to string
var cc string
var bcc string
var subject string
var body string
var replyTo string
var attach []string
cmd := &cobra.Command{
Use: "create",
Short: "Create a draft",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
if strings.TrimSpace(to) == "" || strings.TrimSpace(subject) == "" || strings.TrimSpace(body) == "" {
return errors.New("required: --to, --subject, --body")
}
svc, err := googleapi.NewGmail(cmd.Context(), account)
if err != nil {
return err
}
inReplyTo, references, threadID, err := replyHeaders(cmd, svc, replyTo)
if err != nil {
return err
}
atts := make([]mailAttachment, 0, len(attach))
for _, p := range attach {
atts = append(atts, mailAttachment{Path: p})
}
raw, err := buildRFC822(mailOptions{
From: account,
To: splitCSV(to),
Cc: splitCSV(cc),
Bcc: splitCSV(bcc),
Subject: subject,
Body: body,
InReplyTo: inReplyTo,
References: references,
Attachments: atts,
})
if err != nil {
return err
}
msg := &gmail.Message{
Raw: base64.RawURLEncoding.EncodeToString(raw),
}
if threadID != "" {
msg.ThreadId = threadID
}
draft, err := svc.Users.Drafts.Create("me", &gmail.Draft{Message: msg}).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"draftId": draft.Id,
"message": draft.Message,
"threadId": threadID,
})
}
u.Out().Printf("draft_id\t%s", draft.Id)
if draft.Message != nil && draft.Message.Id != "" {
u.Out().Printf("message_id\t%s", draft.Message.Id)
}
if threadID != "" {
u.Out().Printf("thread_id\t%s", threadID)
}
return nil
},
}
cmd.Flags().StringVar(&to, "to", "", "Recipients (comma-separated, required)")
cmd.Flags().StringVar(&cc, "cc", "", "CC recipients (comma-separated)")
cmd.Flags().StringVar(&bcc, "bcc", "", "BCC recipients (comma-separated)")
cmd.Flags().StringVar(&subject, "subject", "", "Subject (required)")
cmd.Flags().StringVar(&body, "body", "", "Body (required)")
cmd.Flags().StringVar(&replyTo, "reply-to", "", "Reply to message ID (sets In-Reply-To/References and thread)")
cmd.Flags().StringSliceVar(&attach, "attach", nil, "Attachment file path (repeatable)")
return cmd
}

View File

@ -0,0 +1,192 @@
package cmd
import (
"errors"
"fmt"
"os"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
"google.golang.org/api/gmail/v1"
)
func newGmailLabelsCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "labels",
Short: "List and modify labels",
}
cmd.AddCommand(newGmailLabelsListCmd(flags))
cmd.AddCommand(newGmailLabelsModifyCmd(flags))
return cmd
}
func newGmailLabelsListCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List labels",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := googleapi.NewGmail(cmd.Context(), account)
if err != nil {
return err
}
resp, err := svc.Users.Labels.List("me").Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"labels": resp.Labels})
}
if len(resp.Labels) == 0 {
u.Err().Println("No labels")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tTYPE")
for _, l := range resp.Labels {
fmt.Fprintf(tw, "%s\t%s\t%s\n", l.Id, l.Name, l.Type)
}
_ = tw.Flush()
return nil
},
}
}
func newGmailLabelsModifyCmd(flags *rootFlags) *cobra.Command {
var add string
var remove string
cmd := &cobra.Command{
Use: "modify <threadIds...>",
Short: "Modify labels on threads",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
threadIDs := args
addLabels := splitCSV(add)
removeLabels := splitCSV(remove)
if len(addLabels) == 0 && len(removeLabels) == 0 {
return errors.New("must specify --add and/or --remove")
}
svc, err := googleapi.NewGmail(cmd.Context(), account)
if err != nil {
return err
}
idMap, err := fetchLabelNameToID(svc)
if err != nil {
return err
}
addIDs := resolveLabelIDs(addLabels, idMap)
removeIDs := resolveLabelIDs(removeLabels, idMap)
type result struct {
ThreadID string `json:"threadId"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
results := make([]result, 0, len(threadIDs))
for _, tid := range threadIDs {
_, err := svc.Users.Threads.Modify("me", tid, &gmail.ModifyThreadRequest{
AddLabelIds: addIDs,
RemoveLabelIds: removeIDs,
}).Do()
if err != nil {
results = append(results, result{ThreadID: tid, Success: false, Error: err.Error()})
if !outfmt.IsJSON(cmd.Context()) {
u.Err().Errorf("%s: %s", tid, err.Error())
}
continue
}
results = append(results, result{ThreadID: tid, Success: true})
if !outfmt.IsJSON(cmd.Context()) {
u.Out().Printf("%s\tok", tid)
}
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"results": results})
}
return nil
},
}
cmd.Flags().StringVar(&add, "add", "", "Labels to add (comma-separated, name or ID)")
cmd.Flags().StringVar(&remove, "remove", "", "Labels to remove (comma-separated, name or ID)")
return cmd
}
func fetchLabelNameToID(svc *gmail.Service) (map[string]string, error) {
resp, err := svc.Users.Labels.List("me").Do()
if err != nil {
return nil, err
}
m := make(map[string]string, len(resp.Labels))
for _, l := range resp.Labels {
if l.Id == "" {
continue
}
m[strings.ToLower(l.Id)] = l.Id
if l.Name != "" {
m[strings.ToLower(l.Name)] = l.Id
}
}
return m, nil
}
func fetchLabelIDToName(svc *gmail.Service) (map[string]string, error) {
resp, err := svc.Users.Labels.List("me").Do()
if err != nil {
return nil, err
}
m := make(map[string]string, len(resp.Labels))
for _, l := range resp.Labels {
if l.Id == "" {
continue
}
if l.Name != "" {
m[l.Id] = l.Name
} else {
m[l.Id] = l.Id
}
}
return m, nil
}
func resolveLabelIDs(values []string, nameToID map[string]string) []string {
if len(values) == 0 {
return nil
}
out := make([]string, 0, len(values))
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" {
continue
}
if id, ok := nameToID[strings.ToLower(v)]; ok {
out = append(out, id)
} else {
out = append(out, v)
}
}
return out
}

View File

@ -0,0 +1,23 @@
package cmd
import "testing"
func TestResolveLabelIDs(t *testing.T) {
m := map[string]string{
"inbox": "INBOX",
"custom": "Label_123",
}
got := resolveLabelIDs([]string{"INBOX", "custom", "Label_999"}, m)
if len(got) != 3 {
t.Fatalf("unexpected: %#v", got)
}
if got[0] != "INBOX" || got[1] != "Label_123" || got[2] != "Label_999" {
t.Fatalf("unexpected: %#v", got)
}
}
func TestFetchLabelIDToNameBehavior(t *testing.T) {
// Unit tests for the actual API call live in integration; here we just ensure
// the helper exists and returns a map. (Compile-time coverage.)
_ = fetchLabelIDToName
}

222
internal/cmd/gmail_mime.go Normal file
View File

@ -0,0 +1,222 @@
package cmd
import (
"bytes"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"mime"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
type mailAttachment struct {
Path string
Filename string
MIMEType string
Data []byte
}
type mailOptions struct {
From string
To []string
Cc []string
Bcc []string
Subject string
Body string
InReplyTo string
References string
AdditionalHeaders map[string]string
Attachments []mailAttachment
}
func buildRFC822(opts mailOptions) ([]byte, error) {
if strings.TrimSpace(opts.From) == "" {
return nil, errors.New("missing From")
}
if len(opts.To) == 0 {
return nil, errors.New("missing To")
}
if strings.TrimSpace(opts.Subject) == "" {
return nil, errors.New("missing Subject")
}
var b bytes.Buffer
if err := validateHeaderValue(opts.From); err != nil {
return nil, fmt.Errorf("invalid From: %w", err)
}
for _, a := range append(append([]string{}, opts.To...), append(opts.Cc, opts.Bcc...)...) {
if err := validateHeaderValue(a); err != nil {
return nil, fmt.Errorf("invalid address: %w", err)
}
}
writeHeader(&b, "From", opts.From)
writeHeader(&b, "To", strings.Join(opts.To, ", "))
if len(opts.Cc) > 0 {
writeHeader(&b, "Cc", strings.Join(opts.Cc, ", "))
}
if len(opts.Bcc) > 0 {
writeHeader(&b, "Bcc", strings.Join(opts.Bcc, ", "))
}
if err := validateHeaderValue(opts.Subject); err != nil {
return nil, fmt.Errorf("invalid Subject: %w", err)
}
writeHeader(&b, "Subject", encodeHeaderIfNeeded(opts.Subject))
writeHeader(&b, "Date", time.Now().Format(time.RFC1123Z))
writeHeader(&b, "MIME-Version", "1.0")
if strings.TrimSpace(opts.InReplyTo) != "" {
if err := validateHeaderValue(opts.InReplyTo); err != nil {
return nil, fmt.Errorf("invalid In-Reply-To: %w", err)
}
writeHeader(&b, "In-Reply-To", strings.TrimSpace(opts.InReplyTo))
}
if strings.TrimSpace(opts.References) != "" {
if err := validateHeaderValue(opts.References); err != nil {
return nil, fmt.Errorf("invalid References: %w", err)
}
writeHeader(&b, "References", strings.TrimSpace(opts.References))
}
for k, v := range opts.AdditionalHeaders {
if strings.TrimSpace(k) != "" && strings.TrimSpace(v) != "" {
if err := validateHeaderValue(v); err != nil {
return nil, fmt.Errorf("invalid header %s: %w", k, err)
}
writeHeader(&b, k, v)
}
}
if len(opts.Attachments) == 0 {
writeHeader(&b, "Content-Type", "text/plain; charset=\"utf-8\"")
writeHeader(&b, "Content-Transfer-Encoding", "7bit")
b.WriteString("\r\n")
b.WriteString(opts.Body)
if !strings.HasSuffix(opts.Body, "\r\n") {
b.WriteString("\r\n")
}
return b.Bytes(), nil
}
boundary, err := randomBoundary()
if err != nil {
return nil, err
}
writeHeader(&b, "Content-Type", fmt.Sprintf("multipart/mixed; boundary=%q", boundary))
b.WriteString("\r\n")
// Body part
b.WriteString(fmt.Sprintf("--%s\r\n", boundary))
b.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n")
b.WriteString("Content-Transfer-Encoding: 7bit\r\n\r\n")
b.WriteString(opts.Body)
if !strings.HasSuffix(opts.Body, "\r\n") {
b.WriteString("\r\n")
}
// Attachments
for _, a := range opts.Attachments {
if a.Filename == "" {
a.Filename = filepath.Base(a.Path)
}
if a.MIMEType == "" {
a.MIMEType = mime.TypeByExtension(strings.ToLower(filepath.Ext(a.Filename)))
if a.MIMEType == "" {
a.MIMEType = "application/octet-stream"
}
}
if len(a.Data) == 0 {
data, err := os.ReadFile(a.Path)
if err != nil {
return nil, err
}
a.Data = data
}
b.WriteString(fmt.Sprintf("\r\n--%s\r\n", boundary))
b.WriteString(fmt.Sprintf("Content-Type: %s\r\n", a.MIMEType))
b.WriteString("Content-Transfer-Encoding: base64\r\n")
b.WriteString(fmt.Sprintf("Content-Disposition: attachment; %s\r\n\r\n", contentDispositionFilename(a.Filename)))
b.WriteString(wrapBase64(a.Data))
b.WriteString("\r\n")
}
b.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
return b.Bytes(), nil
}
func writeHeader(b *bytes.Buffer, name, value string) {
b.WriteString(name)
b.WriteString(": ")
b.WriteString(value)
b.WriteString("\r\n")
}
func wrapBase64(b []byte) string {
s := base64.StdEncoding.EncodeToString(b)
const width = 76
var out strings.Builder
for len(s) > width {
out.WriteString(s[:width])
out.WriteString("\r\n")
s = s[width:]
}
if len(s) > 0 {
out.WriteString(s)
}
return out.String()
}
func randomBoundary() (string, error) {
var b [18]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return "gogcli_" + base64.RawURLEncoding.EncodeToString(b[:]), nil
}
func validateHeaderValue(v string) error {
if strings.Contains(v, "\r") || strings.Contains(v, "\n") {
return errors.New("header value contains newline")
}
return nil
}
func encodeHeaderIfNeeded(v string) string {
if isASCII(v) {
return v
}
return mime.QEncoding.Encode("utf-8", v)
}
func isASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] >= 0x80 {
return false
}
}
return true
}
func contentDispositionFilename(filename string) string {
filename = strings.TrimSpace(filename)
if filename == "" {
return `filename="attachment"`
}
if isASCII(filename) {
return fmt.Sprintf("filename=%q", filename)
}
// RFC 5987 / RFC 2231 style.
return "filename*=UTF-8''" + rfc5987Encode(filename)
}
func rfc5987Encode(s string) string {
// url.QueryEscape uses '+' for spaces; RFC 5987 wants %20.
esc := url.QueryEscape(s)
return strings.ReplaceAll(esc, "+", "%20")
}

View File

@ -0,0 +1,67 @@
package cmd
import (
"strings"
"testing"
)
func TestBuildRFC822Plain(t *testing.T) {
raw, err := buildRFC822(mailOptions{
From: "a@b.com",
To: []string{"c@d.com"},
Subject: "Hi",
Body: "Hello",
})
if err != nil {
t.Fatalf("err: %v", err)
}
s := string(raw)
if !strings.Contains(s, "Content-Type: text/plain") {
t.Fatalf("missing content-type: %q", s)
}
if !strings.Contains(s, "\r\n\r\nHello\r\n") {
t.Fatalf("missing body: %q", s)
}
}
func TestBuildRFC822WithAttachment(t *testing.T) {
raw, err := buildRFC822(mailOptions{
From: "a@b.com",
To: []string{"c@d.com"},
Subject: "Hi",
Body: "Hello",
Attachments: []mailAttachment{
{Filename: "x.txt", MIMEType: "text/plain", Data: []byte("abc")},
},
})
if err != nil {
t.Fatalf("err: %v", err)
}
s := string(raw)
if !strings.Contains(s, "multipart/mixed") {
t.Fatalf("expected multipart: %q", s)
}
if !strings.Contains(s, "Content-Disposition: attachment; filename=\"x.txt\"") {
t.Fatalf("missing attachment header: %q", s)
}
}
func TestEncodeHeaderIfNeeded(t *testing.T) {
if got := encodeHeaderIfNeeded("Hello"); got != "Hello" {
t.Fatalf("unexpected: %q", got)
}
got := encodeHeaderIfNeeded("Grüße")
if got == "Grüße" || !strings.Contains(got, "=?utf-8?") {
t.Fatalf("expected encoded-word, got: %q", got)
}
}
func TestContentDispositionFilename(t *testing.T) {
if got := contentDispositionFilename("a.txt"); got != "filename=\"a.txt\"" {
t.Fatalf("unexpected: %q", got)
}
got := contentDispositionFilename("Grüße.txt")
if !strings.HasPrefix(got, "filename*=UTF-8''") {
t.Fatalf("unexpected: %q", got)
}
}

131
internal/cmd/gmail_send.go Normal file
View File

@ -0,0 +1,131 @@
package cmd
import (
"encoding/base64"
"errors"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
"google.golang.org/api/gmail/v1"
)
func newGmailSendCmd(flags *rootFlags) *cobra.Command {
var to string
var cc string
var bcc string
var subject string
var body string
var replyTo string
var attach []string
cmd := &cobra.Command{
Use: "send",
Short: "Send an email",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
if strings.TrimSpace(to) == "" || strings.TrimSpace(subject) == "" || strings.TrimSpace(body) == "" {
return errors.New("required: --to, --subject, --body")
}
svc, err := googleapi.NewGmail(cmd.Context(), account)
if err != nil {
return err
}
inReplyTo, references, threadID, err := replyHeaders(cmd, svc, replyTo)
if err != nil {
return err
}
atts := make([]mailAttachment, 0, len(attach))
for _, p := range attach {
atts = append(atts, mailAttachment{Path: p})
}
raw, err := buildRFC822(mailOptions{
From: account,
To: splitCSV(to),
Cc: splitCSV(cc),
Bcc: splitCSV(bcc),
Subject: subject,
Body: body,
InReplyTo: inReplyTo,
References: references,
Attachments: atts,
})
if err != nil {
return err
}
msg := &gmail.Message{
Raw: base64.RawURLEncoding.EncodeToString(raw),
}
if threadID != "" {
msg.ThreadId = threadID
}
sent, err := svc.Users.Messages.Send("me", msg).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"messageId": sent.Id,
"threadId": sent.ThreadId,
})
}
u.Out().Printf("message_id\t%s", sent.Id)
if sent.ThreadId != "" {
u.Out().Printf("thread_id\t%s", sent.ThreadId)
}
return nil
},
}
cmd.Flags().StringVar(&to, "to", "", "Recipients (comma-separated, required)")
cmd.Flags().StringVar(&cc, "cc", "", "CC recipients (comma-separated)")
cmd.Flags().StringVar(&bcc, "bcc", "", "BCC recipients (comma-separated)")
cmd.Flags().StringVar(&subject, "subject", "", "Subject (required)")
cmd.Flags().StringVar(&body, "body", "", "Body (required)")
cmd.Flags().StringVar(&replyTo, "reply-to", "", "Reply to message ID (sets In-Reply-To/References and thread)")
cmd.Flags().StringSliceVar(&attach, "attach", nil, "Attachment file path (repeatable)")
return cmd
}
func replyHeaders(cmd *cobra.Command, svc *gmail.Service, replyToMessageID string) (inReplyTo string, references string, threadID string, err error) {
replyToMessageID = strings.TrimSpace(replyToMessageID)
if replyToMessageID == "" {
return "", "", "", nil
}
msg, err := svc.Users.Messages.Get("me", replyToMessageID).
Format("metadata").
MetadataHeaders("Message-ID", "Message-Id", "References", "In-Reply-To").
Context(cmd.Context()).
Do()
if err != nil {
return "", "", "", err
}
threadID = msg.ThreadId
// Prefer Message-ID and References from the original message.
messageID := headerValue(msg.Payload, "Message-ID")
if messageID == "" {
messageID = headerValue(msg.Payload, "Message-Id")
}
inReplyTo = messageID
references = strings.TrimSpace(headerValue(msg.Payload, "References"))
if references == "" {
references = messageID
} else if messageID != "" && !strings.Contains(references, messageID) {
references = references + " " + messageID
}
return inReplyTo, references, threadID, nil
}

View File

@ -0,0 +1,41 @@
package cmd
import (
"testing"
"google.golang.org/api/gmail/v1"
)
func TestHeaderValue(t *testing.T) {
p := &gmail.MessagePart{
Headers: []*gmail.MessagePartHeader{
{Name: "From", Value: "a@example.com"},
{Name: "Subject", Value: "Hello"},
},
}
if got := headerValue(p, "from"); got != "a@example.com" {
t.Fatalf("unexpected: %q", got)
}
if got := headerValue(p, "subject"); got != "Hello" {
t.Fatalf("unexpected: %q", got)
}
if got := headerValue(p, "date"); got != "" {
t.Fatalf("unexpected: %q", got)
}
}
func TestSanitizeTab(t *testing.T) {
if got := sanitizeTab("a\tb"); got != "a b" {
t.Fatalf("unexpected: %q", got)
}
}
func TestFormatGmailDate(t *testing.T) {
got := formatGmailDate("Mon, 02 Jan 2006 15:04:05 -0700")
if got != "2006-01-02 15:04" {
t.Fatalf("unexpected: %q", got)
}
if got := formatGmailDate("not a date"); got != "not a date" {
t.Fatalf("unexpected: %q", got)
}
}

View File

@ -0,0 +1,276 @@
package cmd
import (
"encoding/base64"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
"google.golang.org/api/gmail/v1"
)
func newGmailThreadCmd(flags *rootFlags) *cobra.Command {
var download bool
cmd := &cobra.Command{
Use: "thread <threadId>",
Short: "Get a thread with all messages (optionally download attachments)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
threadID := args[0]
svc, err := googleapi.NewGmail(cmd.Context(), account)
if err != nil {
return err
}
thread, err := svc.Users.Threads.Get("me", threadID).Format("full").Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
type downloaded struct {
MessageID string `json:"messageId"`
AttachmentID string `json:"attachmentId"`
Filename string `json:"filename"`
MimeType string `json:"mimeType,omitempty"`
Size int64 `json:"size,omitempty"`
Path string `json:"path"`
Cached bool `json:"cached"`
DownloadError string `json:"error,omitempty"`
}
downloadedFiles := make([]downloaded, 0)
if download && thread != nil {
d, err := config.EnsureGmailAttachmentsDir()
if err != nil {
return err
}
for _, msg := range thread.Messages {
if msg == nil || msg.Id == "" {
continue
}
for _, a := range collectAttachments(msg.Payload) {
outPath, cached, err := downloadAttachment(cmd, svc, msg.Id, a, d)
if err != nil {
return err
}
df := downloaded{
MessageID: msg.Id,
AttachmentID: a.AttachmentID,
Filename: a.Filename,
MimeType: a.MimeType,
Size: a.Size,
Path: outPath,
Cached: cached,
}
downloadedFiles = append(downloadedFiles, df)
}
}
}
return outfmt.WriteJSON(os.Stdout, map[string]any{
"thread": thread,
"downloaded": downloadedFiles,
})
}
if thread == nil || len(thread.Messages) == 0 {
u.Err().Println("Empty thread")
return nil
}
var attachDir string
if download {
d, err := config.EnsureGmailAttachmentsDir()
if err != nil {
return err
}
attachDir = d
}
for _, msg := range thread.Messages {
if msg == nil {
continue
}
u.Out().Printf("Message: %s", msg.Id)
u.Out().Printf("From: %s", headerValue(msg.Payload, "From"))
u.Out().Printf("To: %s", headerValue(msg.Payload, "To"))
u.Out().Printf("Subject: %s", headerValue(msg.Payload, "Subject"))
u.Out().Printf("Date: %s", headerValue(msg.Payload, "Date"))
u.Out().Println("")
body := bestBodyText(msg.Payload)
if body != "" {
u.Out().Println(body)
u.Out().Println("")
}
attachments := collectAttachments(msg.Payload)
if len(attachments) > 0 {
u.Out().Println("Attachments:")
for _, a := range attachments {
u.Out().Printf(" - %s (%d bytes)", a.Filename, a.Size)
}
u.Out().Println("")
}
if download && len(attachments) > 0 {
for _, a := range attachments {
outPath, cached, err := downloadAttachment(cmd, svc, msg.Id, a, attachDir)
if err != nil {
return err
}
if cached {
u.Out().Printf("Cached: %s", outPath)
} else {
u.Out().Successf("Saved: %s", outPath)
}
}
u.Out().Println("")
}
}
return nil
},
}
cmd.Flags().BoolVar(&download, "download", false, "Download attachments")
return cmd
}
func newGmailURLCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "url <threadIds...>",
Short: "Print Gmail web URLs for threads",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
urls := make([]map[string]string, 0, len(args))
for _, id := range args {
urls = append(urls, map[string]string{
"id": id,
"url": fmt.Sprintf("https://mail.google.com/mail/?authuser=%s#all/%s", url.QueryEscape(account), id),
})
}
return outfmt.WriteJSON(os.Stdout, map[string]any{"urls": urls})
}
for _, id := range args {
url := fmt.Sprintf("https://mail.google.com/mail/?authuser=%s#all/%s", url.QueryEscape(account), id)
u.Out().Printf("%s\t%s", id, url)
}
return nil
},
}
}
type attachmentInfo struct {
Filename string
Size int64
MimeType string
AttachmentID string
}
func collectAttachments(p *gmail.MessagePart) []attachmentInfo {
if p == nil {
return nil
}
var out []attachmentInfo
if p.Filename != "" && p.Body != nil && p.Body.AttachmentId != "" {
out = append(out, attachmentInfo{
Filename: p.Filename,
Size: p.Body.Size,
MimeType: p.MimeType,
AttachmentID: p.Body.AttachmentId,
})
}
for _, part := range p.Parts {
out = append(out, collectAttachments(part)...)
}
return out
}
func bestBodyText(p *gmail.MessagePart) string {
if p == nil {
return ""
}
plain := findPartBody(p, "text/plain")
if plain != "" {
return plain
}
html := findPartBody(p, "text/html")
return html
}
func findPartBody(p *gmail.MessagePart, mimeType string) string {
if p == nil {
return ""
}
if p.MimeType == mimeType && p.Body != nil && p.Body.Data != "" {
s, err := decodeBase64URL(p.Body.Data)
if err == nil {
return s
}
}
for _, part := range p.Parts {
if s := findPartBody(part, mimeType); s != "" {
return s
}
}
return ""
}
func decodeBase64URL(s string) (string, error) {
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return "", err
}
return string(b), nil
}
func downloadAttachment(cmd *cobra.Command, svc *gmail.Service, messageID string, a attachmentInfo, dir string) (string, bool, error) {
if strings.TrimSpace(messageID) == "" || strings.TrimSpace(a.AttachmentID) == "" {
return "", false, errors.New("missing messageID/attachmentID")
}
shortID := a.AttachmentID
if len(shortID) > 8 {
shortID = shortID[:8]
}
filename := fmt.Sprintf("%s_%s_%s", messageID, shortID, a.Filename)
outPath := filepath.Join(dir, filename)
if st, err := os.Stat(outPath); err == nil && st.Size() == a.Size && a.Size > 0 {
return outPath, true, nil
}
body, err := svc.Users.Messages.Attachments.Get("me", messageID, a.AttachmentID).Context(cmd.Context()).Do()
if err != nil {
return "", false, err
}
if body == nil || body.Data == "" {
return "", false, errors.New("empty attachment data")
}
data, err := base64.RawURLEncoding.DecodeString(body.Data)
if err != nil {
return "", false, err
}
if err := os.WriteFile(outPath, data, 0o600); err != nil {
return "", false, err
}
return outPath, false, nil
}

84
internal/cmd/root.go Normal file
View File

@ -0,0 +1,84 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/errfmt"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
type rootFlags struct {
Color string
Account string
Output string
}
func Execute(args []string) error {
flags := rootFlags{Color: envOr("GOG_COLOR", "auto")}
flags.Output = envOr("GOG_OUTPUT", "text")
root := &cobra.Command{
Use: "gog",
SilenceUsage: true,
SilenceErrors: true,
CompletionOptions: cobra.CompletionOptions{
DisableDefaultCmd: true,
},
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
mode, err := outfmt.Parse(flags.Output)
if err != nil {
return err
}
cmd.SetContext(outfmt.WithMode(cmd.Context(), mode))
u, err := ui.New(ui.Options{
Stdout: os.Stdout,
Stderr: os.Stderr,
Color: func() string {
if outfmt.IsJSON(cmd.Context()) {
return "never"
}
return flags.Color
}(),
})
if err != nil {
return err
}
cmd.SetContext(ui.WithUI(cmd.Context(), u))
return nil
},
}
root.SetArgs(args)
root.PersistentFlags().StringVar(&flags.Color, "color", flags.Color, "Color output: auto|always|never")
root.PersistentFlags().StringVar(&flags.Account, "account", "", "Account email for API commands")
root.PersistentFlags().StringVar(&flags.Output, "output", flags.Output, "Output format: text|json")
root.AddCommand(newAuthCmd())
root.AddCommand(newDriveCmd(&flags))
root.AddCommand(newCalendarCmd(&flags))
root.AddCommand(newGmailCmd(&flags))
root.AddCommand(newContactsCmd(&flags))
err := root.Execute()
if err == nil {
return nil
}
if u := ui.FromContext(root.Context()); u != nil {
u.Err().Error(errfmt.Format(err))
return err
}
_, _ = fmt.Fprintln(os.Stderr, errfmt.Format(err))
return err
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View File

@ -0,0 +1,100 @@
package config
import (
"encoding/json"
"errors"
"os"
)
type ClientCredentials struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
}
type googleCredentialsFile struct {
Installed *struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
} `json:"installed"`
Web *struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
} `json:"web"`
}
func ParseGoogleOAuthClientJSON(b []byte) (ClientCredentials, error) {
var f googleCredentialsFile
if err := json.Unmarshal(b, &f); err != nil {
return ClientCredentials{}, err
}
var clientID, clientSecret string
if f.Installed != nil {
clientID, clientSecret = f.Installed.ClientID, f.Installed.ClientSecret
} else if f.Web != nil {
clientID, clientSecret = f.Web.ClientID, f.Web.ClientSecret
}
if clientID == "" || clientSecret == "" {
return ClientCredentials{}, errors.New("invalid credentials.json (expected installed/web client_id and client_secret)")
}
return ClientCredentials{ClientID: clientID, ClientSecret: clientSecret}, nil
}
func WriteClientCredentials(c ClientCredentials) error {
_, err := EnsureDir()
if err != nil {
return err
}
path, err := ClientCredentialsPath()
if err != nil {
return err
}
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
return err
}
return nil
}
func ReadClientCredentials() (ClientCredentials, error) {
path, err := ClientCredentialsPath()
if err != nil {
return ClientCredentials{}, err
}
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return ClientCredentials{}, &CredentialsMissingError{Path: path, Cause: err}
}
return ClientCredentials{}, err
}
var c ClientCredentials
if err := json.Unmarshal(b, &c); err != nil {
return ClientCredentials{}, err
}
if c.ClientID == "" || c.ClientSecret == "" {
return ClientCredentials{}, errors.New("stored credentials.json is missing client_id/client_secret")
}
return c, nil
}
type CredentialsMissingError struct {
Path string
Cause error
}
func (e *CredentialsMissingError) Error() string {
return "oauth credentials missing"
}
func (e *CredentialsMissingError) Unwrap() error {
return e.Cause
}

View File

@ -0,0 +1,32 @@
package config
import "testing"
func TestParseGoogleOAuthClientJSON(t *testing.T) {
t.Run("installed", func(t *testing.T) {
got, err := ParseGoogleOAuthClientJSON([]byte(`{"installed":{"client_id":"id","client_secret":"sec"}}`))
if err != nil {
t.Fatalf("err: %v", err)
}
if got.ClientID != "id" || got.ClientSecret != "sec" {
t.Fatalf("unexpected: %#v", got)
}
})
t.Run("web", func(t *testing.T) {
got, err := ParseGoogleOAuthClientJSON([]byte(`{"web":{"client_id":"id","client_secret":"sec"}}`))
if err != nil {
t.Fatalf("err: %v", err)
}
if got.ClientID != "id" || got.ClientSecret != "sec" {
t.Fatalf("unexpected: %#v", got)
}
})
t.Run("invalid", func(t *testing.T) {
_, err := ParseGoogleOAuthClientJSON([]byte(`{"nope":{}}`))
if err == nil {
t.Fatalf("expected error")
}
})
}

73
internal/config/paths.go Normal file
View File

@ -0,0 +1,73 @@
package config
import (
"os"
"path/filepath"
)
const AppName = "gogcli"
func Dir() (string, error) {
base, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(base, AppName), nil
}
func EnsureDir() (string, error) {
dir, err := Dir()
if err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}
func ClientCredentialsPath() (string, error) {
dir, err := Dir()
if err != nil {
return "", err
}
return filepath.Join(dir, "credentials.json"), nil
}
func DriveDownloadsDir() (string, error) {
dir, err := Dir()
if err != nil {
return "", err
}
return filepath.Join(dir, "drive-downloads"), nil
}
func EnsureDriveDownloadsDir() (string, error) {
dir, err := DriveDownloadsDir()
if err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}
func GmailAttachmentsDir() (string, error) {
dir, err := Dir()
if err != nil {
return "", err
}
return filepath.Join(dir, "gmail-attachments"), nil
}
func EnsureGmailAttachmentsDir() (string, error) {
dir, err := GmailAttachmentsDir()
if err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}

55
internal/errfmt/errfmt.go Normal file
View File

@ -0,0 +1,55 @@
package errfmt
import (
"errors"
"fmt"
"os"
"github.com/99designs/keyring"
ggoogleapi "google.golang.org/api/googleapi"
"github.com/steipete/gogcli/internal/config"
gogapi "github.com/steipete/gogcli/internal/googleapi"
)
func Format(err error) string {
if err == nil {
return ""
}
var authErr *gogapi.AuthRequiredError
if errors.As(err, &authErr) {
return fmt.Sprintf("No refresh token for %s %s. Run: gog auth add %s --services %s", authErr.Service, authErr.Email, authErr.Email, authErr.Service)
}
var scopesErr *gogapi.MissingScopesError
if errors.As(err, &scopesErr) {
return fmt.Sprintf("Missing scopes for %s %s. Re-run: gog auth add %s --services %s --force-consent", scopesErr.Service, scopesErr.Email, scopesErr.Email, scopesErr.Service)
}
var credErr *config.CredentialsMissingError
if errors.As(err, &credErr) {
return fmt.Sprintf("OAuth credentials missing. Run: gog auth credentials <credentials.json> (expected at %s)", credErr.Path)
}
if errors.Is(err, keyring.ErrKeyNotFound) {
return "Secret not found in keyring (refresh token missing). Run: gog auth add <email>"
}
if errors.Is(err, os.ErrNotExist) {
return err.Error()
}
var gerr *ggoogleapi.Error
if errors.As(err, &gerr) {
reason := ""
if len(gerr.Errors) > 0 && gerr.Errors[0].Reason != "" {
reason = gerr.Errors[0].Reason
}
if reason != "" {
return fmt.Sprintf("Google API error (%d %s): %s", gerr.Code, reason, gerr.Message)
}
return fmt.Sprintf("Google API error (%d): %s", gerr.Code, gerr.Message)
}
return err.Error()
}

View File

@ -0,0 +1,17 @@
package googleapi
import (
"context"
"google.golang.org/api/calendar/v3"
"github.com/steipete/gogcli/internal/googleauth"
)
func NewCalendar(ctx context.Context, email string) (*calendar.Service, error) {
opts, err := optionsForAccount(ctx, googleauth.ServiceCalendar, email)
if err != nil {
return nil, err
}
return calendar.NewService(ctx, opts...)
}

View File

@ -0,0 +1,71 @@
package googleapi
import (
"context"
"github.com/99designs/keyring"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/option"
"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/googleauth"
"github.com/steipete/gogcli/internal/secrets"
)
func tokenSourceForAccount(ctx context.Context, service googleauth.Service, email string) (oauth2.TokenSource, error) {
creds, err := config.ReadClientCredentials()
if err != nil {
return nil, err
}
requiredScopes, err := googleauth.Scopes(service)
if err != nil {
return nil, err
}
store, err := secrets.OpenDefault()
if err != nil {
return nil, err
}
tok, err := store.GetToken(email)
if err != nil {
if err == keyring.ErrKeyNotFound {
return nil, &AuthRequiredError{Service: string(service), Email: email, Cause: err}
}
return nil, err
}
if len(tok.Scopes) > 0 {
have := make(map[string]struct{}, len(tok.Scopes))
for _, s := range tok.Scopes {
have[s] = struct{}{}
}
missing := make([]string, 0)
for _, want := range requiredScopes {
if _, ok := have[want]; !ok {
missing = append(missing, want)
}
}
if len(missing) > 0 {
return nil, &MissingScopesError{Service: string(service), Email: email, Missing: missing}
}
}
cfg := oauth2.Config{
ClientID: creds.ClientID,
ClientSecret: creds.ClientSecret,
Endpoint: google.Endpoint,
Scopes: requiredScopes,
}
return cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: tok.RefreshToken}), nil
}
func optionsForAccount(ctx context.Context, service googleauth.Service, email string) ([]option.ClientOption, error) {
ts, err := tokenSourceForAccount(ctx, service, email)
if err != nil {
return nil, err
}
return []option.ClientOption{option.WithTokenSource(ts)}, nil
}

View File

@ -0,0 +1,17 @@
package googleapi
import (
"context"
"google.golang.org/api/drive/v3"
"github.com/steipete/gogcli/internal/googleauth"
)
func NewDrive(ctx context.Context, email string) (*drive.Service, error) {
opts, err := optionsForAccount(ctx, googleauth.ServiceDrive, email)
if err != nil {
return nil, err
}
return drive.NewService(ctx, opts...)
}

View File

@ -0,0 +1,27 @@
package googleapi
import "fmt"
type AuthRequiredError struct {
Service string
Email string
Cause error
}
func (e *AuthRequiredError) Error() string {
return fmt.Sprintf("auth required for %s %s", e.Service, e.Email)
}
func (e *AuthRequiredError) Unwrap() error {
return e.Cause
}
type MissingScopesError struct {
Service string
Email string
Missing []string
}
func (e *MissingScopesError) Error() string {
return fmt.Sprintf("missing scopes for %s %s", e.Service, e.Email)
}

View File

@ -0,0 +1,17 @@
package googleapi
import (
"context"
"google.golang.org/api/gmail/v1"
"github.com/steipete/gogcli/internal/googleauth"
)
func NewGmail(ctx context.Context, email string) (*gmail.Service, error) {
opts, err := optionsForAccount(ctx, googleauth.ServiceGmail, email)
if err != nil {
return nil, err
}
return gmail.NewService(ctx, opts...)
}

View File

@ -0,0 +1,17 @@
package googleapi
import (
"context"
"google.golang.org/api/people/v1"
"github.com/steipete/gogcli/internal/googleauth"
)
func NewPeople(ctx context.Context, email string) (*people.Service, error) {
opts, err := optionsForAccount(ctx, googleauth.ServiceContacts, email)
if err != nil {
return nil, err
}
return people.NewService(ctx, opts...)
}

View File

@ -0,0 +1,225 @@
package googleauth
import (
"bufio"
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"github.com/steipete/gogcli/internal/config"
)
type AuthorizeOptions struct {
Services []Service
Scopes []string
Manual bool
ForceConsent bool
Timeout time.Duration
}
func Authorize(ctx context.Context, opts AuthorizeOptions) (string, error) {
if opts.Timeout <= 0 {
opts.Timeout = 2 * time.Minute
}
if len(opts.Scopes) == 0 {
return "", errors.New("missing scopes")
}
creds, err := config.ReadClientCredentials()
if err != nil {
return "", err
}
state, err := randomState()
if err != nil {
return "", err
}
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
defer cancel()
if opts.Manual {
redirectURI := "http://localhost:1"
cfg := oauth2.Config{
ClientID: creds.ClientID,
ClientSecret: creds.ClientSecret,
Endpoint: google.Endpoint,
RedirectURL: redirectURI,
Scopes: opts.Scopes,
}
authURL := cfg.AuthCodeURL(state, authURLParams(opts.ForceConsent)...)
fmt.Fprintln(os.Stderr, "Visit this URL to authorize:")
fmt.Fprintln(os.Stderr, authURL)
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "After authorizing, you'll be redirected to a localhost URL that won't load.")
fmt.Fprintln(os.Stderr, "Copy the URL from your browser's address bar and paste it here.")
fmt.Fprintln(os.Stderr)
fmt.Fprint(os.Stderr, "Paste redirect URL: ")
line, readErr := bufio.NewReader(os.Stdin).ReadString('\n')
if readErr != nil && !errors.Is(readErr, os.ErrClosed) {
return "", readErr
}
line = strings.TrimSpace(line)
code, gotState, parseErr := extractCodeAndState(line)
if parseErr != nil {
return "", parseErr
}
if gotState != "" && gotState != state {
return "", errors.New("state mismatch")
}
tok, exchangeErr := cfg.Exchange(ctx, code)
if exchangeErr != nil {
return "", exchangeErr
}
if tok.RefreshToken == "" {
return "", errors.New("no refresh token received; try again with --force-consent")
}
return tok.RefreshToken, nil
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", err
}
defer ln.Close()
port := ln.Addr().(*net.TCPAddr).Port
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/oauth2/callback", port)
cfg := oauth2.Config{
ClientID: creds.ClientID,
ClientSecret: creds.ClientSecret,
Endpoint: google.Endpoint,
RedirectURL: redirectURI,
Scopes: opts.Scopes,
}
codeCh := make(chan string, 1)
errCh := make(chan error, 1)
srv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/oauth2/callback" {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if q.Get("error") != "" {
select {
case errCh <- fmt.Errorf("authorization error: %s", q.Get("error")):
default:
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("Authorization cancelled. You can close this window."))
return
}
if q.Get("state") != state {
select {
case errCh <- errors.New("state mismatch"):
default:
}
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("State mismatch. You can close this window."))
return
}
code := q.Get("code")
if code == "" {
select {
case errCh <- errors.New("missing code"):
default:
}
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("Missing code. You can close this window."))
return
}
select {
case codeCh <- code:
default:
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("Success! You can close this window."))
}),
}
go func() {
<-ctx.Done()
_ = srv.Close()
}()
go func() {
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
select {
case errCh <- err:
default:
}
}
}()
authURL := cfg.AuthCodeURL(state, authURLParams(opts.ForceConsent)...)
fmt.Fprintln(os.Stderr, "Opening browser for authorization…")
fmt.Fprintln(os.Stderr, "If the browser doesn't open, visit this URL:")
fmt.Fprintln(os.Stderr, authURL)
_ = openBrowser(authURL)
select {
case code := <-codeCh:
_ = srv.Close()
tok, exchangeErr := cfg.Exchange(ctx, code)
if exchangeErr != nil {
return "", exchangeErr
}
if tok.RefreshToken == "" {
return "", errors.New("no refresh token received; try again with --force-consent")
}
return tok.RefreshToken, nil
case err := <-errCh:
_ = srv.Close()
return "", err
case <-ctx.Done():
_ = srv.Close()
return "", ctx.Err()
}
}
func authURLParams(forceConsent bool) []oauth2.AuthCodeOption {
opts := []oauth2.AuthCodeOption{
oauth2.AccessTypeOffline,
oauth2.SetAuthURLParam("include_granted_scopes", "true"),
}
if forceConsent {
opts = append(opts, oauth2.SetAuthURLParam("prompt", "consent"))
}
return opts
}
func randomState() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func extractCodeAndState(rawURL string) (code string, state string, err error) {
parsed, err := url.Parse(rawURL)
if err != nil {
return "", "", err
}
q := parsed.Query()
code = q.Get("code")
if code == "" {
return "", "", errors.New("no code found in URL")
}
return code, q.Get("state"), nil
}

View File

@ -0,0 +1,19 @@
package googleauth
import (
"os/exec"
"runtime"
)
func openBrowser(u string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", u)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", u)
default:
cmd = exec.Command("xdg-open", u)
}
return cmd.Start()
}

View File

@ -0,0 +1,68 @@
package googleauth
import (
"errors"
"fmt"
"sort"
"strings"
)
type Service string
const (
ServiceGmail Service = "gmail"
ServiceCalendar Service = "calendar"
ServiceDrive Service = "drive"
ServiceContacts Service = "contacts"
)
func ParseService(s string) (Service, error) {
switch Service(strings.ToLower(strings.TrimSpace(s))) {
case ServiceGmail, ServiceCalendar, ServiceDrive, ServiceContacts:
return Service(strings.ToLower(strings.TrimSpace(s))), nil
default:
return "", fmt.Errorf("unknown service %q (expected gmail|calendar|drive|contacts)", s)
}
}
func AllServices() []Service {
return []Service{ServiceGmail, ServiceCalendar, ServiceDrive, ServiceContacts}
}
func Scopes(service Service) ([]string, error) {
switch service {
case ServiceGmail:
return []string{"https://mail.google.com/"}, nil
case ServiceCalendar:
return []string{"https://www.googleapis.com/auth/calendar"}, nil
case ServiceDrive:
return []string{"https://www.googleapis.com/auth/drive"}, nil
case ServiceContacts:
return []string{
"https://www.googleapis.com/auth/contacts",
"https://www.googleapis.com/auth/directory.readonly",
}, nil
default:
return nil, errors.New("unknown service")
}
}
func ScopesForServices(services []Service) ([]string, error) {
set := make(map[string]struct{})
for _, svc := range services {
scopes, err := Scopes(svc)
if err != nil {
return nil, err
}
for _, s := range scopes {
set[s] = struct{}{}
}
}
out := make([]string, 0, len(set))
for s := range set {
out = append(out, s)
}
// stable ordering (useful for tests + auth URL diffs)
sort.Strings(out)
return out, nil
}

View File

@ -0,0 +1,35 @@
package googleauth
import "testing"
func TestParseService(t *testing.T) {
tests := []struct {
in string
want Service
}{
{"gmail", ServiceGmail},
{"GMAIL", ServiceGmail},
{"calendar", ServiceCalendar},
{"drive", ServiceDrive},
{"contacts", ServiceContacts},
}
for _, tt := range tests {
got, err := ParseService(tt.in)
if err != nil {
t.Fatalf("ParseService(%q) err: %v", tt.in, err)
}
if got != tt.want {
t.Fatalf("ParseService(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestExtractCodeAndState(t *testing.T) {
code, state, err := extractCodeAndState("http://localhost:1/?code=abc&state=xyz")
if err != nil {
t.Fatalf("err: %v", err)
}
if code != "abc" || state != "xyz" {
t.Fatalf("unexpected: code=%q state=%q", code, state)
}
}

View File

@ -0,0 +1,94 @@
//go:build integration
package integration
import (
"context"
"os"
"testing"
"time"
"github.com/steipete/gogcli/internal/googleapi"
)
func TestDriveSmoke(t *testing.T) {
account := os.Getenv("GOG_IT_ACCOUNT")
if account == "" {
t.Skip("set GOG_IT_ACCOUNT")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
svc, err := googleapi.NewDrive(ctx, account)
if err != nil {
t.Fatalf("NewDrive: %v", err)
}
_, err = svc.Files.List().
Q("trashed = false").
PageSize(1).
SupportsAllDrives(true).
IncludeItemsFromAllDrives(true).
Fields("files(id)").
Do()
if err != nil {
t.Fatalf("Drive list: %v", err)
}
}
func TestCalendarSmoke(t *testing.T) {
account := os.Getenv("GOG_IT_ACCOUNT")
if account == "" {
t.Skip("set GOG_IT_ACCOUNT")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
svc, err := googleapi.NewCalendar(ctx, account)
if err != nil {
t.Fatalf("NewCalendar: %v", err)
}
_, err = svc.CalendarList.List().MaxResults(1).Do()
if err != nil {
t.Fatalf("Calendar list: %v", err)
}
}
func TestGmailSmoke(t *testing.T) {
account := os.Getenv("GOG_IT_ACCOUNT")
if account == "" {
t.Skip("set GOG_IT_ACCOUNT")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
svc, err := googleapi.NewGmail(ctx, account)
if err != nil {
t.Fatalf("NewGmail: %v", err)
}
_, err = svc.Users.Labels.List("me").Do()
if err != nil {
t.Fatalf("Gmail labels: %v", err)
}
}
func TestContactsSmoke(t *testing.T) {
account := os.Getenv("GOG_IT_ACCOUNT")
if account == "" {
t.Skip("set GOG_IT_ACCOUNT")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
svc, err := googleapi.NewPeople(ctx, account)
if err != nil {
t.Fatalf("NewPeople: %v", err)
}
_, err = svc.People.Connections.List("people/me").PersonFields("names").PageSize(1).Do()
if err != nil {
t.Fatalf("People connections: %v", err)
}
}

53
internal/outfmt/outfmt.go Normal file
View File

@ -0,0 +1,53 @@
package outfmt
import (
"context"
"encoding/json"
"errors"
"io"
"strings"
)
type Mode string
const (
ModeText Mode = "text"
ModeJSON Mode = "json"
)
func Parse(s string) (Mode, error) {
switch Mode(strings.ToLower(strings.TrimSpace(s))) {
case ModeText, "":
return ModeText, nil
case ModeJSON:
return ModeJSON, nil
default:
return "", errors.New("invalid --output (expected text|json)")
}
}
type ctxKey struct{}
func WithMode(ctx context.Context, mode Mode) context.Context {
return context.WithValue(ctx, ctxKey{}, mode)
}
func FromContext(ctx context.Context) Mode {
if v := ctx.Value(ctxKey{}); v != nil {
if m, ok := v.(Mode); ok {
return m
}
}
return ModeText
}
func IsJSON(ctx context.Context) bool {
return FromContext(ctx) == ModeJSON
}
func WriteJSON(w io.Writer, v any) error {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
return enc.Encode(v)
}

150
internal/secrets/store.go Normal file
View File

@ -0,0 +1,150 @@
package secrets
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/99designs/keyring"
"github.com/steipete/gogcli/internal/config"
)
type Store interface {
Keys() ([]string, error)
SetToken(email string, tok Token) error
GetToken(email string) (Token, error)
DeleteToken(email string) error
ListTokens() ([]Token, error)
}
type KeyringStore struct {
ring keyring.Keyring
}
type Token struct {
Email string `json:"email"`
Services []string `json:"services,omitempty"`
Scopes []string `json:"scopes,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
RefreshToken string `json:"-"`
}
func OpenDefault() (Store, error) {
ring, err := keyring.Open(keyring.Config{
ServiceName: config.AppName,
})
if err != nil {
return nil, err
}
return &KeyringStore{ring: ring}, nil
}
func (s *KeyringStore) Keys() ([]string, error) {
return s.ring.Keys()
}
type storedToken struct {
RefreshToken string `json:"refresh_token"`
Services []string `json:"services,omitempty"`
Scopes []string `json:"scopes,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
}
func (s *KeyringStore) SetToken(email string, tok Token) error {
email = normalize(email)
if email == "" {
return fmt.Errorf("missing email")
}
if tok.RefreshToken == "" {
return fmt.Errorf("missing refresh token")
}
if tok.CreatedAt.IsZero() {
tok.CreatedAt = time.Now().UTC()
}
payload, err := json.Marshal(storedToken{
RefreshToken: tok.RefreshToken,
Services: tok.Services,
Scopes: tok.Scopes,
CreatedAt: tok.CreatedAt,
})
if err != nil {
return err
}
return s.ring.Set(keyring.Item{
Key: tokenKey(email),
Data: payload,
})
}
func (s *KeyringStore) GetToken(email string) (Token, error) {
email = normalize(email)
if email == "" {
return Token{}, fmt.Errorf("missing email")
}
it, err := s.ring.Get(tokenKey(email))
if err != nil {
return Token{}, err
}
var st storedToken
if err := json.Unmarshal(it.Data, &st); err != nil {
return Token{}, err
}
return Token{
Email: email,
Services: st.Services,
Scopes: st.Scopes,
CreatedAt: st.CreatedAt,
RefreshToken: st.RefreshToken,
}, nil
}
func (s *KeyringStore) DeleteToken(email string) error {
email = normalize(email)
if email == "" {
return fmt.Errorf("missing email")
}
return s.ring.Remove(tokenKey(email))
}
func (s *KeyringStore) ListTokens() ([]Token, error) {
keys, err := s.Keys()
if err != nil {
return nil, err
}
out := make([]Token, 0)
for _, k := range keys {
email, ok := ParseTokenKey(k)
if !ok {
continue
}
tok, err := s.GetToken(email)
if err != nil {
return nil, err
}
out = append(out, tok)
}
return out, nil
}
func ParseTokenKey(k string) (email string, ok bool) {
const prefix = "token:"
if !strings.HasPrefix(k, prefix) {
return "", false
}
rest := strings.TrimPrefix(k, prefix)
if strings.TrimSpace(rest) == "" {
return "", false
}
return rest, true
}
func tokenKey(email string) string {
return fmt.Sprintf("token:%s", email)
}
func normalize(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}

View File

@ -0,0 +1,22 @@
package secrets
import "testing"
func TestTokenKey(t *testing.T) {
if got := tokenKey("a@b.com"); got != "token:a@b.com" {
t.Fatalf("unexpected: %q", got)
}
}
func TestParseTokenKey(t *testing.T) {
email, ok := ParseTokenKey("token:a@b.com")
if !ok {
t.Fatalf("expected ok")
}
if email != "a@b.com" {
t.Fatalf("unexpected: %q", email)
}
if _, ok := ParseTokenKey("nope"); ok {
t.Fatalf("expected not ok")
}
}

121
internal/ui/ui.go Normal file
View File

@ -0,0 +1,121 @@
package ui
import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/muesli/termenv"
)
type Options struct {
Stdout io.Writer
Stderr io.Writer
Color string // auto|always|never
}
type UI struct {
out *Printer
err *Printer
}
func New(opts Options) (*UI, error) {
if opts.Stdout == nil {
opts.Stdout = os.Stdout
}
if opts.Stderr == nil {
opts.Stderr = os.Stderr
}
colorMode := strings.ToLower(strings.TrimSpace(opts.Color))
if colorMode == "" {
colorMode = "auto"
}
if colorMode != "auto" && colorMode != "always" && colorMode != "never" {
return nil, errors.New("invalid --color (expected auto|always|never)")
}
out := termenv.NewOutput(opts.Stdout, termenv.WithProfile(termenv.EnvColorProfile()))
errOut := termenv.NewOutput(opts.Stderr, termenv.WithProfile(termenv.EnvColorProfile()))
outProfile := chooseProfile(out.Profile, colorMode)
errProfile := chooseProfile(errOut.Profile, colorMode)
return &UI{
out: newPrinter(out, outProfile),
err: newPrinter(errOut, errProfile),
}, nil
}
func chooseProfile(detected termenv.Profile, mode string) termenv.Profile {
if termenv.EnvNoColor() {
return termenv.Ascii
}
switch mode {
case "never":
return termenv.Ascii
case "always":
return termenv.TrueColor
default:
return detected
}
}
func (u *UI) Out() *Printer { return u.out }
func (u *UI) Err() *Printer { return u.err }
type Printer struct {
o *termenv.Output
profile termenv.Profile
}
func newPrinter(o *termenv.Output, profile termenv.Profile) *Printer {
return &Printer{o: o, profile: profile}
}
func (p *Printer) ColorEnabled() bool { return p.profile != termenv.Ascii }
func (p *Printer) line(s string) {
_, _ = io.WriteString(p.o, s+"\n")
}
func (p *Printer) printf(format string, args ...any) {
p.line(fmt.Sprintf(format, args...))
}
func (p *Printer) Successf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
if p.ColorEnabled() {
msg = termenv.String(msg).Foreground(p.profile.Color("#22c55e")).String()
}
p.line(msg)
}
func (p *Printer) Error(msg string) {
if p.ColorEnabled() {
msg = termenv.String(msg).Foreground(p.profile.Color("#ef4444")).String()
}
p.line(msg)
}
func (p *Printer) Errorf(format string, args ...any) { p.Error(fmt.Sprintf(format, args...)) }
func (p *Printer) Printf(format string, args ...any) { p.printf(format, args...) }
func (p *Printer) Println(msg string) { p.line(msg) }
type ctxKey struct{}
func WithUI(ctx context.Context, u *UI) context.Context {
return context.WithValue(ctx, ctxKey{}, u)
}
func FromContext(ctx context.Context) *UI {
v := ctx.Value(ctxKey{})
if v == nil {
return nil
}
u, _ := v.(*UI)
return u
}

13
internal/ui/ui_test.go Normal file
View File

@ -0,0 +1,13 @@
package ui
import (
"bytes"
"testing"
)
func TestUIColorFlagValidation(t *testing.T) {
_, err := New(Options{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, Color: "nope"})
if err == nil {
t.Fatalf("expected error")
}
}