Skip to content
Merged
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
This repository builds two Google command-line tools from one Go module:

- `gro` reads and organizes Gmail, Calendar, Contacts, and Drive data without exposing destructive operations.
- `grw` is the read-write Google tool. Its mail, calendar, and contacts command trees extend `gro` with delete and restore, folder management, filters, and calendar/contact creation, updates, and deletion.
- `grw` is the read-write Google tool. Its mail, calendar, contacts, and Drive command trees extend `gro` with delete and restore, folder management, filters, uploads, and resource creation, updates, moves, and deletion.

Both tools use the same configuration and keyring machinery, but register separate identities. Their configuration, tokens, environment variables, and keyring entries do not collide. `gro` requests only the scopes needed for its non-destructive surface; `grw` adds Gmail settings, permanent-delete access, Calendar event writes, and Contacts writes.
Both tools use the same configuration and keyring machinery, but register separate identities. Their configuration, tokens, environment variables, and keyring entries do not collide. `gro` requests only the scopes needed for its non-destructive surface; `grw` adds Gmail settings, permanent-delete access, Calendar event writes, Contacts writes, and full Drive access.

## Safety model

Expand Down Expand Up @@ -60,9 +60,11 @@ gro mail list
gro calendar today

grw init
grw mail send <draft-id> --dry-run
grw mail delete --query "older_than:1y" --dry-run
grw calendar create --summary "Planning" --start 2026-10-01 --dry-run
grw contacts create --given-name Test --email t@example.com --dry-run
grw drive trash --query "name contains 'old'" --dry-run
```

One desktop OAuth client can be used by both tools, but each tool asks for consent and stores its token under its own identity. Google Workspace administrators should start with [`WORKSPACE_ADMINS.md`](WORKSPACE_ADMINS.md).
Expand Down
5 changes: 4 additions & 1 deletion WORKSPACE_ADMINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,12 @@ https://www.googleapis.com/auth/calendar.readonly
https://www.googleapis.com/auth/calendar.events
https://www.googleapis.com/auth/contacts
https://www.googleapis.com/auth/userinfo.profile
https://www.googleapis.com/auth/drive
https://www.googleapis.com/auth/drive.readonly
https://www.googleapis.com/auth/drive.metadata
```

Gmail settings access supports filters. The broad mail scope is required for permanent deletion; the command defaults to recoverable Trash and gates permanent deletion behind `--permanent --yes`. Calendar scopes support reading and mutating events, the Contacts scope supports reading and mutating contacts and groups, and the profile scope supports `grw me`.
Gmail settings access supports filters. The broad mail scope is required for permanent deletion; the command defaults to recoverable Trash and gates permanent deletion behind `--permanent --yes`. Calendar scopes support reading and mutating events, the Contacts scope supports reading and mutating contacts and groups, the Drive scopes support reading, uploading, organizing, trashing, restoring, and permanently deleting files, and the profile scope supports `grw me`.

## Distribute and verify

Expand Down
8 changes: 6 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ cmd/grw
-> internal/cmd/contacts
-> internal/rw/contacts
-> internal/api/contacts
-> internal/rwcmd/drive
-> internal/cmd/drive
-> internal/rw/drive
-> internal/api/drive
-> internal/{auth,config,keychain,...}
```

Expand Down Expand Up @@ -67,9 +71,9 @@ The write `NewCommand()` starts with `internal/cmd/<domain>.NewCommand()` and at

`cmd/gro/main.go` registers `internal/app/gro.Identity()` before running the `gro` root. That root composes setup/configuration commands and all five read domains.

`cmd/grw/main.go` registers `internal/app/grw.Identity()` before running the `grw` root. That root composes setup/configuration and profile commands with the extended Gmail, Calendar, and Contacts commands.
`cmd/grw/main.go` registers `internal/app/grw.Identity()` before running the `grw` root. That root composes setup/configuration and profile commands with the extended Gmail, Calendar, Contacts, and Drive commands.

Both identities drive the same config, cache, credential-reference, and keyring code. They use different directory names, default credential references, environment-variable prefixes, and keyring namespaces. Their OAuth client JSON may be reused across identities, but tokens and consent remain separate. `gro` requests its non-destructive multi-service scopes; `grw` requests Gmail write scopes, Calendar and Contacts read/write scopes, and basic profile access.
Both identities drive the same config, cache, credential-reference, and keyring code. They use different directory names, default credential references, environment-variable prefixes, and keyring namespaces. Their OAuth client JSON may be reused across identities, but tokens and consent remain separate. `gro` requests its non-destructive multi-service scopes; `grw` requests Gmail write scopes, Calendar, Contacts, and Drive read/write scopes, and basic profile access.

## Structural enforcement

Expand Down
4 changes: 4 additions & 0 deletions docs/golden-principles.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ These rules keep the two binaries mechanically distinct. Structural tests named

`gro` must have no dependency path into `internal/rw` or `internal/rwcmd`, its scopes must stay on the non-destructive allowlist, and its production graph must contain no forbidden destructive Google API calls.

The only send path is `grw mail send` in `internal/rw/gmail`; it is unreachable from `gro` by the link-graph test.

Enforced by `TestGroNeverLinksWriteCode`, `TestAllScopesAreNonDestructive`, and `TestNoDestructiveAPIMethodsInProductionCode`.

## 2. `grw` covers the read scope for every service it touches
Expand All @@ -24,6 +26,8 @@ Enforced by `TestWriteClientsEmbedReadClients`, `TestWriteCommandClientsEmbedRea

Every leaf added by a write command declares boolean `--dry-run` with shorthand `-n`. The read verbs `list`, `get`, `show`, and `search` are exempt. A command exposing `--permanent` must also expose `--yes`; recoverable behavior remains the default.

Confirmation is reserved for unrecoverable data loss. Actions that are irreversible but not destructive, such as `grw mail send`, take an explicit ID, print what will happen, and proceed; `--dry-run` is the review step, and a prompt would only break the piping and agent flows grw exists for.

Enforced by `TestWriteLeavesHaveDryRun` and `TestPermanentWriteLeavesRequireYes`. `TestDelete_DryRunDoesNotMutate` and `TestDelete_PermanentWithYes` verify behavior.

## 5. Resource leaves are text-only
Expand Down
3 changes: 3 additions & 0 deletions internal/api/drive/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ type Client struct {
service *drive.Service
}

// Service returns the underlying Drive API service.
func (c *Client) Service() *drive.Service { return c.service }

// NewClient creates a new Drive client with OAuth2 authentication
func NewClient(ctx context.Context) (*Client, error) {
client, err := auth.GetHTTPClient(ctx)
Expand Down
24 changes: 22 additions & 2 deletions internal/api/gmail/drafts.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,29 @@ type DraftResult struct {
ThreadID string `json:"threadId,omitempty"`
}

// DraftSummary is the metadata shown before sending a draft.
type DraftSummary struct {
ID string
MessageID string
ThreadID string
To string
Cc string
Bcc string
Subject string
From string
AttachmentCount int
}

// SentResult identifies the message created by sending a draft.
type SentResult struct {
ID string
ThreadID string
LabelIDs []string
}

// CreateDraft assembles a MIME message and POSTs to users.drafts.create.
// The CLI never calls drafts.send — drafts sit in the user's Drafts folder
// for explicit human review.
// This shared read layer never calls drafts.send; sending stays isolated in
// internal/rw/gmail for grw.
func (c *Client) CreateDraft(ctx context.Context, msg DraftMessage) (*DraftResult, error) {
raw, err := buildMIME(msg)
if err != nil {
Expand Down
7 changes: 7 additions & 0 deletions internal/app/grw/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ package grw

import (
"google.golang.org/api/calendar/v3"
"google.golang.org/api/drive/v3"
"google.golang.org/api/gmail/v1"
"google.golang.org/api/people/v1"

Expand All @@ -31,6 +32,9 @@ var Scopes = []string{
gmail.MailGoogleComScope,
calendar.CalendarReadonlyScope,
calendar.CalendarEventsScope,
drive.DriveScope,
drive.DriveReadonlyScope,
drive.DriveMetadataScope,
people.ContactsScope,
people.UserinfoProfileScope,
}
Expand All @@ -43,6 +47,9 @@ var ScopeDescriptions = map[string]string{
gmail.MailGoogleComScope: "Gmail Full Access — required for permanent deletion (gated behind --permanent and a typed confirmation).",
calendar.CalendarReadonlyScope: "Calendar Read-Only — read calendars and events.",
calendar.CalendarEventsScope: "Calendar Events — create, update, and delete events.",
drive.DriveScope: "Drive Full Access — upload files, create folders, rename, move, trash, restore, and permanently delete files.",
drive.DriveReadonlyScope: "Drive Read-Only — read files and metadata.",
drive.DriveMetadataScope: "Drive Metadata — read and update file metadata (star/unstar).",
people.ContactsScope: "Contacts — read, create, update, and delete contacts and contact groups.",
people.UserinfoProfileScope: "Basic Profile — read the authenticated user's name and email for `me`.",
}
Expand Down
7 changes: 7 additions & 0 deletions internal/app/grw/identity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"testing"

"google.golang.org/api/calendar/v3"
"google.golang.org/api/drive/v3"
"google.golang.org/api/gmail/v1"
"google.golang.org/api/people/v1"
)
Expand All @@ -19,6 +20,9 @@ func TestScopes(t *testing.T) {
gmail.MailGoogleComScope,
calendar.CalendarReadonlyScope,
calendar.CalendarEventsScope,
drive.DriveScope,
drive.DriveReadonlyScope,
drive.DriveMetadataScope,
people.ContactsScope,
people.UserinfoProfileScope,
}
Expand Down Expand Up @@ -50,6 +54,9 @@ func TestReadWriteScopesPresent(t *testing.T) {
if !strings.Contains(joined, "mail.google.com") {
t.Error("grw must request mail.google.com (for permanent delete)")
}
if !strings.Contains(joined, "/auth/drive ") {
t.Error("grw must request full Drive access")
}
}

func TestIdentity(t *testing.T) {
Expand Down
4 changes: 3 additions & 1 deletion internal/app/grw/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/open-cli-collective/google-cli/internal/rootutil"
rwcalendar "github.com/open-cli-collective/google-cli/internal/rwcmd/calendar"
rwcontacts "github.com/open-cli-collective/google-cli/internal/rwcmd/contacts"
rwdrive "github.com/open-cli-collective/google-cli/internal/rwcmd/drive"
"github.com/open-cli-collective/google-cli/internal/rwcmd/mail"
"github.com/open-cli-collective/google-cli/internal/version"
)
Expand All @@ -35,7 +36,7 @@ It reads and organizes mail like its read-only sibling gro, and adds the
operations gro deliberately cannot perform: deleting messages (Trash by
default, permanent behind a guard), managing labels as folders/subfolders, and
creating Gmail filters. It also reads profile information and creates, updates,
and deletes Google Calendar events, contacts, and contact groups.
and deletes Google Calendar events, contacts, contact groups, and Drive files.

grw stores its credentials separately from gro (keyring namespace
"google-readwrite"), so the two can be used in isolation — e.g. give an agent
Expand Down Expand Up @@ -79,5 +80,6 @@ func init() {
rootCmd.AddCommand(mail.NewCommand())
rootCmd.AddCommand(rwcalendar.NewCommand())
rootCmd.AddCommand(rwcontacts.NewCommand())
rootCmd.AddCommand(rwdrive.NewCommand())
rootCmd.AddCommand(refreshcmd.NewCommand())
}
2 changes: 1 addition & 1 deletion internal/app/grw/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package grw
import "testing"

func TestRootCommands(t *testing.T) {
want := map[string]bool{"contacts": false, "me": false, "profiles": false}
want := map[string]bool{"contacts": false, "drive": false, "me": false, "profiles": false}
for _, command := range rootCmd.Commands() {
if _, ok := want[command.Name()]; ok {
want[command.Name()] = true
Expand Down
5 changes: 5 additions & 0 deletions internal/architecture/architecture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
mecmd "github.com/open-cli-collective/google-cli/internal/cmd/me"
rwcalendarcmd "github.com/open-cli-collective/google-cli/internal/rwcmd/calendar"
rwcontactscmd "github.com/open-cli-collective/google-cli/internal/rwcmd/contacts"
rwdrivecmd "github.com/open-cli-collective/google-cli/internal/rwcmd/drive"
rwmailcmd "github.com/open-cli-collective/google-cli/internal/rwcmd/mail"
)

Expand All @@ -50,6 +51,7 @@ type commandPair struct {
var writeCommandPairs = map[string]commandPair{
"calendar": {read: calcmd.NewCommand, write: rwcalendarcmd.NewCommand},
"contacts": {read: contactscmd.NewCommand, write: rwcontactscmd.NewCommand},
"drive": {read: drivecmd.NewCommand, write: rwdrivecmd.NewCommand},
"mail": {read: mailcmd.NewCommand, write: rwmailcmd.NewCommand},
}

Expand Down Expand Up @@ -423,6 +425,7 @@ var knownGrwScopes = map[string]bool{
"https://www.googleapis.com/auth/userinfo.profile": true,
"https://www.googleapis.com/auth/drive.readonly": true,
"https://www.googleapis.com/auth/drive.metadata": true,
"https://www.googleapis.com/auth/drive": true,
"https://www.googleapis.com/auth/gmail.settings.basic": true,
"https://mail.google.com/": true,
}
Expand Down Expand Up @@ -530,8 +533,10 @@ func TestGrwLinksWriteCode(t *testing.T) {
modulePath + "/internal/rw/calendar": false,
modulePath + "/internal/rw/contacts": false,
modulePath + "/internal/rw/gmail": false,
modulePath + "/internal/rw/drive": false,
modulePath + "/internal/rwcmd/calendar": false,
modulePath + "/internal/rwcmd/contacts": false,
modulePath + "/internal/rwcmd/drive": false,
modulePath + "/internal/rwcmd/mail": false,
}
for _, pkg := range goListDeps(t, "./cmd/grw") {
Expand Down
Loading
Loading