Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,28 @@ gog calendar create <calendarId> \
--attendees "alice@example.com,bob@example.com" \
--location "Zoom"

# Place lookup for locations (requires Places API (New) enabled + API key)
# Google Cloud Console → APIs & Services → Library → enable "Places API (New)".
# Credentials → Create API key (restrict to Places API (New) if possible).
# Store via "gog auth manage" (Places API Key) or:
gog config set places_api_key "..."
# Or use an environment variable (overrides stored keys):
export GOOGLE_PLACES_API_KEY="..."
# Note: config storage is plain text; keychain is recommended.
# Headless/CI: gog auth places-key set --key "..." --store keychain|config
gog calendar create <calendarId> \
--summary "Coffee" \
--from 2025-01-15T14:00:00Z \
--to 2025-01-15T15:00:00Z \
--location-search "Elysian Coffee Vancouver"

gog calendar create <calendarId> \
--summary "Coffee" \
--from 2025-01-15T14:00:00Z \
--to 2025-01-15T15:00:00Z \
--place-id ChIJ...
# Optional locale hints: --place-language en --place-region US

gog calendar update <calendarId> <eventId> \
--summary "Updated Meeting" \
--from 2025-01-15T11:00:00Z \
Expand Down
8 changes: 6 additions & 2 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ Environment:
- `GOG_ENABLE_COMMANDS=calendar,tasks` (optional allowlist of top-level commands)
- `config.json` can also set `keyring_backend` (JSON5; env vars take precedence)
- `config.json` can also set `default_timezone` (IANA name or `UTC`)
- `config.json` can also set `places_api_key` (Google Places API key; env vars take precedence)
- `config.json` can also set `account_aliases` for `gog auth alias` (JSON5)
- `config.json` can also set `account_clients` (email -> client) and `client_domains` (domain -> client)

Expand All @@ -150,6 +151,9 @@ Flag aliases:
- `gog --client <name> auth credentials <credentials.json|->`
- `gog auth add <email> [--services user|all|gmail,calendar,classroom,drive,docs,contacts,tasks,sheets,people,groups] [--readonly] [--drive-scope full|readonly|file] [--manual] [--force-consent]`
- `gog auth services [--markdown]`
- `gog auth places-key set --key <api-key> [--store keychain|config]`
- `gog auth places-key status`
- `gog auth places-key clear [--store keychain|config|all]`
- `gog auth keep <email> --key <service-account.json>` (Google Keep; Workspace only)
- `gog auth list`
- `gog auth alias list`
Expand Down Expand Up @@ -184,8 +188,8 @@ Flag aliases:
- `gog calendar events <calendarId> [--from RFC3339] [--to RFC3339] [--max N] [--page TOKEN] [--query Q] [--weekday]`
- `gog calendar event|get <calendarId> <eventId>`
- `GOG_CALENDAR_WEEKDAY=1` defaults `--weekday` for `gog calendar events`
- `gog calendar create <calendarId> --summary S --from DT --to DT [--description D] [--location L] [--attendees a@b.com,c@d.com] [--all-day] [--event-type TYPE]`
- `gog calendar update <calendarId> <eventId> [--summary S] [--from DT] [--to DT] [--description D] [--location L] [--attendees ...] [--add-attendee ...] [--all-day] [--event-type TYPE]`
- `gog calendar create <calendarId> --summary S --from DT --to DT [--description D] [--location L|--location-search Q|--place-id ID] [--place-language LANG] [--place-region REGION] [--attendees a@b.com,c@d.com] [--all-day] [--event-type TYPE]`
- `gog calendar update <calendarId> <eventId> [--summary S] [--from DT] [--to DT] [--description D] [--location L|--location-search Q|--place-id ID] [--place-language LANG] [--place-region REGION] [--attendees ...] [--add-attendee ...] [--all-day] [--event-type TYPE]`
- `gog calendar delete <calendarId> <eventId>`
- `gog calendar freebusy <calendarIds> --from RFC3339 --to RFC3339`
- `gog calendar respond <calendarId> <eventId> --status accepted|declined|tentative [--send-updates all|none|externalOnly]`
Expand Down
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
module github.com/steipete/gogcli

go 1.25
go 1.25.5

require (
github.com/99designs/keyring v1.2.2
github.com/alecthomas/kong v1.13.0
github.com/muesli/termenv v0.16.0
github.com/steipete/goplaces v0.2.1
github.com/yosuke-furukawa/json5 v0.1.1
golang.org/x/net v0.49.0
golang.org/x/oauth2 v0.34.0
golang.org/x/term v0.39.0
google.golang.org/api v0.260.0
Expand Down Expand Up @@ -40,7 +42,6 @@ require (
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ 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/steipete/goplaces v0.2.1 h1:d1yg+K84d7VlRPFYW0vGpfFNjNJYW/qTUIHaIaKQ6cs=
github.com/steipete/goplaces v0.2.1/go.mod h1:TwmCIa+afuSL2OdCOJuSf/L8afmYOa5zrl4GQ1jTT1M=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
Expand Down
141 changes: 141 additions & 0 deletions internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/googleauth"
"github.com/steipete/gogcli/internal/outfmt"
placescfg "github.com/steipete/gogcli/internal/places"
"github.com/steipete/gogcli/internal/secrets"
"github.com/steipete/gogcli/internal/ui"
)
Expand Down Expand Up @@ -60,6 +61,7 @@ type AuthCmd struct {
Remove AuthRemoveCmd `cmd:"" name:"remove" help:"Remove a stored refresh token"`
Tokens AuthTokensCmd `cmd:"" name:"tokens" help:"Manage stored refresh tokens"`
Manage AuthManageCmd `cmd:"" name:"manage" help:"Open accounts manager in browser" aliases:"login"`
PlacesKey AuthPlacesKeyCmd `cmd:"" name:"places-key" help:"Manage Places API key for calendar lookups"`
ServiceAcct AuthServiceAccountCmd `cmd:"" name:"service-account" help:"Configure service account (Workspace only; domain-wide delegation)"`
Keep AuthKeepCmd `cmd:"" name:"keep" help:"Configure service account for Google Keep (Workspace only)"`
}
Expand Down Expand Up @@ -982,6 +984,145 @@ func (c *AuthManageCmd) Run(ctx context.Context) error {
})
}

type AuthPlacesKeyCmd struct {
Status AuthPlacesKeyStatusCmd `cmd:"" name:"status" help:"Show Places API key status"`
Set AuthPlacesKeySetCmd `cmd:"" name:"set" help:"Store Places API key"`
Clear AuthPlacesKeyClearCmd `cmd:"" name:"clear" help:"Remove stored Places API key"`
}

type AuthPlacesKeyStatusCmd struct{}

func (c *AuthPlacesKeyStatusCmd) Run(ctx context.Context) error {
u := ui.FromContext(ctx)

state, err := placescfg.LoadAPIKey()
if err != nil {
return err
}
configured := strings.TrimSpace(state.Key) != ""
hint := placescfg.MaskAPIKey(state.Key)

if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"configured": configured,
"source": state.Source,
"hint": hint,
})
}

u.Out().Printf("configured\t%t", configured)
u.Out().Printf("source\t%s", state.Source)
if hint != "" {
u.Out().Printf("hint\t%s", hint)
}
return nil
}

type AuthPlacesKeySetCmd struct {
Key string `name:"key" help:"Places API key or '-' to read from stdin"`
Store string `name:"store" help:"Storage: keychain or config" default:"keychain"`
}

func (c *AuthPlacesKeySetCmd) Run(ctx context.Context) error {
u := ui.FromContext(ctx)

key := strings.TrimSpace(c.Key)
if key == "" {
return usage("missing --key (use --key - to read from stdin)")
}
if key == "-" {
b, err := io.ReadAll(os.Stdin)
if err != nil {
return err
}
key = strings.TrimSpace(string(b))
if key == "" {
return usage("empty key from stdin")
}
}

store := strings.ToLower(strings.TrimSpace(c.Store))
if store == "" {
store = "keychain"
}

switch store {
case "keychain":
if err := ensureKeychainAccessIfNeeded(); err != nil {
return err
}
if err := placescfg.SaveAPIKeyKeychain(key); err != nil {
return err
}
case "config":
if err := placescfg.SaveAPIKeyConfig(key); err != nil {
return err
}
default:
return usage("invalid --store (use keychain or config)")
}

if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"saved": true,
"store": store,
"source": store,
})
}
u.Out().Printf("saved\ttrue")
u.Out().Printf("store\t%s", store)
return nil
}

type AuthPlacesKeyClearCmd struct {
Store string `name:"store" help:"Storage to clear: keychain, config, all" default:"all"`
}

func (c *AuthPlacesKeyClearCmd) Run(ctx context.Context) error {
u := ui.FromContext(ctx)

store := strings.ToLower(strings.TrimSpace(c.Store))
if store == "" {
store = "all"
}

switch store {
case "keychain":
if err := ensureKeychainAccessIfNeeded(); err != nil {
return err
}
if err := placescfg.ClearAPIKeyKeychain(); err != nil {
return err
}
case "config":
if err := placescfg.ClearAPIKeyConfig(); err != nil {
return err
}
case "all":
if err := ensureKeychainAccessIfNeeded(); err != nil {
return err
}
if err := placescfg.ClearAPIKeyKeychain(); err != nil {
return err
}
if err := placescfg.ClearAPIKeyConfig(); err != nil {
return err
}
default:
return usage("invalid --store (use keychain, config, or all)")
}

if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"cleared": true,
"store": store,
})
}
u.Out().Printf("cleared\ttrue")
u.Out().Printf("store\t%s", store)
return nil
}

type AuthKeepCmd struct {
Email string `arg:"" name:"email" help:"Email to impersonate when using Keep"`
Key string `name:"key" required:"" help:"Path to service account JSON key file"`
Expand Down
88 changes: 88 additions & 0 deletions internal/cmd/auth_places_key_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package cmd

import (
"encoding/json"
"testing"

"github.com/steipete/gogcli/internal/config"
)

func TestAuthPlacesKeyConfigFlow(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("GOOGLE_PLACES_API_KEY", "")

setOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "auth", "places-key", "set", "--key", "places-123", "--store", "config"}); err != nil {
t.Fatalf("Execute set: %v", err)
}
})
})

var setResp struct {
Saved bool `json:"saved"`
}
if err := json.Unmarshal([]byte(setOut), &setResp); err != nil {
t.Fatalf("set json parse: %v\nout=%q", err, setOut)
}
if !setResp.Saved {
t.Fatalf("expected saved true")
}

cfg, err := config.ReadConfig()
if err != nil {
t.Fatalf("read config: %v", err)
}
if cfg.PlacesAPIKey != "places-123" {
t.Fatalf("expected places api key stored, got %q", cfg.PlacesAPIKey)
}

statusOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if execErr := Execute([]string{"--json", "auth", "places-key", "status"}); execErr != nil {
t.Fatalf("Execute status: %v", execErr)
}
})
})

var status struct {
Configured bool `json:"configured"`
Source string `json:"source"`
}
if unmarshalErr := json.Unmarshal([]byte(statusOut), &status); unmarshalErr != nil {
t.Fatalf("status json parse: %v\nout=%q", unmarshalErr, statusOut)
}
if !status.Configured {
t.Fatalf("expected configured true")
}
if status.Source != "config" {
t.Fatalf("expected source config, got %q", status.Source)
}

clearOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if execErr := Execute([]string{"--json", "auth", "places-key", "clear", "--store", "config"}); execErr != nil {
t.Fatalf("Execute clear: %v", execErr)
}
})
})

var clearResp struct {
Cleared bool `json:"cleared"`
}
if unmarshalErr := json.Unmarshal([]byte(clearOut), &clearResp); unmarshalErr != nil {
t.Fatalf("clear json parse: %v\nout=%q", unmarshalErr, clearOut)
}
if !clearResp.Cleared {
t.Fatalf("expected cleared true")
}

cfg, err = config.ReadConfig()
if err != nil {
t.Fatalf("read config: %v", err)
}
if cfg.PlacesAPIKey != "" {
t.Fatalf("expected places api key cleared, got %q", cfg.PlacesAPIKey)
}
}
52 changes: 52 additions & 0 deletions internal/cmd/calendar_build.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,3 +232,55 @@ func buildExtendedProperties(privateProps, sharedProps []string) *calendar.Event

return props
}

func mergeExtendedProperties(base *calendar.EventExtendedProperties, addPrivate, addShared map[string]string) *calendar.EventExtendedProperties {
if base == nil && len(addPrivate) == 0 && len(addShared) == 0 {
return nil
}
if base == nil {
base = &calendar.EventExtendedProperties{}
}
if len(addPrivate) > 0 {
if base.Private == nil {
base.Private = make(map[string]string)
}
for k, v := range addPrivate {
if strings.TrimSpace(k) == "" {
continue
}
base.Private[k] = v
}
}
if len(addShared) > 0 {
if base.Shared == nil {
base.Shared = make(map[string]string)
}
for k, v := range addShared {
if strings.TrimSpace(k) == "" {
continue
}
base.Shared[k] = v
}
}
return base
}

func cloneExtendedProperties(props *calendar.EventExtendedProperties) *calendar.EventExtendedProperties {
if props == nil {
return nil
}
clone := &calendar.EventExtendedProperties{}
if len(props.Private) > 0 {
clone.Private = make(map[string]string, len(props.Private))
for k, v := range props.Private {
clone.Private[k] = v
}
}
if len(props.Shared) > 0 {
clone.Shared = make(map[string]string, len(props.Shared))
for k, v := range props.Shared {
clone.Shared[k] = v
}
}
return clone
}
Loading