diff --git a/.gitignore b/.gitignore index b2b0327e..a204d6a2 100644 --- a/.gitignore +++ b/.gitignore @@ -38,5 +38,8 @@ go.work.sum bin/ /gog +# Safety profile generated files +internal/cmd/*_cmd_gen.go + # Node (optional dev scripts) node_modules/ diff --git a/Makefile b/Makefile index 43b784db..af84379b 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ SHELL := /bin/bash .DEFAULT_GOAL := build .PHONY: build gog gogcli gog-help gogcli-help help fmt fmt-check lint test ci tools -.PHONY: worker-ci +.PHONY: worker-ci build-safe build-profile clean-gen BIN_DIR := $(CURDIR)/bin BIN := $(BIN_DIR)/gog @@ -86,6 +86,20 @@ pnpm-gate: test: @go test ./... +# Safety profile builds +SAFE_BIN := $(BIN_DIR)/gog-safe +PROFILE ?= safety-profile.example.yaml + +build-safe: + @./build-safe.sh $(PROFILE) + +build-profile: + @./build-safe.sh $(PROFILE) -o $(SAFE_BIN) + +clean-gen: + @rm -f internal/cmd/*_cmd_gen.go + @echo "Cleaned generated files" + ci: pnpm-gate fmt-check lint test worker-ci: diff --git a/README.md b/README.md index 8d071122..078f4590 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Sli - **Local time** - quick local/UTC time display for scripts and agents - **Multiple accounts** - manage multiple Google accounts simultaneously (with aliases) - **Command allowlist** - restrict top-level commands for sandboxed/agent runs +- **Safety profiles** - compile-time command removal via YAML config (non-bypassable, ideal for AI agents) - **Secure credential storage** using OS keyring or encrypted on-disk keyring (configurable) - **Auto-refreshing tokens** - authenticate once, use indefinitely - **Least-privilege auth** - `--readonly`, `--drive-scope`, and `--gmail-scope` to request fewer scopes @@ -52,6 +53,13 @@ cd gogcli make ``` +Build a safety-profiled binary (see [Safety Profiles](#safety-profiles-compile-time)): + +```bash +make build-safe # uses ./safety-profile.example.yaml +make build-safe PROFILE=safety-profiles/readonly.yaml +``` + Run: ```bash @@ -494,7 +502,91 @@ gog --enable-commands calendar,tasks calendar events --today export GOG_ENABLE_COMMANDS=calendar,tasks gog tasks list ``` - + +### Safety Profiles (Compile-Time) + +`--enable-commands` is a runtime flag, which means an AI agent can bypass it by passing `--enable-commands=""`. Safety profiles solve this by removing commands from the binary at compile time. If `gmail send` is not in the binary, no prompt injection or flag override can invoke it. + +A safety profile is a YAML file that maps every command to `true` (include) or `false` (omit): + +```yaml +gmail: + search: true + get: true + send: false # removed from the binary + drafts: + list: true + create: true + send: false # can draft but not send + settings: false # all settings subcommands removed + +calendar: + events: true + create: true + delete: false + +drive: + ls: true + search: true + upload: false + delete: false + +classroom: false # entire service removed +``` + +Build a profiled binary with `make build-safe`: + +```bash +# Use the example profile +make build-safe + +# Use a preset profile +make build-safe PROFILE=safety-profiles/readonly.yaml + +# Custom output path +./build-safe.sh safety-profiles/agent-safe.yaml -o /usr/local/bin/gog-safe +``` + +The generator parses the `*_types.go` source files via Go's AST package to auto-discover all commands and their hierarchy. It then reads the YAML profile and generates Go source files containing only the enabled command fields, compiled with `-tags safety_profile` so the generated structs replace the full ones. New upstream commands are picked up automatically with no manual registry to maintain. The resulting binary is identical to stock `gog` for the commands it includes; disabled commands simply do not exist. + +**Preset profiles** are provided in `safety-profiles/`: + +| Profile | Description | +|---------|-------------| +| `full.yaml` | Everything enabled (equivalent to stock `gog`) | +| `readonly.yaml` | Read/list/search/get only, no creates, updates, sends, or deletes | +| `agent-safe.yaml` | Read + draft + archive + label, no send/delete/settings | + +To create a custom profile, copy `safety-profiles/full.yaml` and set commands to `false` as needed. Setting a service key to `false` (e.g., `classroom: false`) disables all of its subcommands. + +#### Why compile-time instead of runtime? + +Runtime restrictions (`--enable-commands`, environment variables, config flags) are useful for preventing accidental misuse, but an AI agent with shell access can bypass them: + +```bash +# Agent can clear the runtime allowlist +gog --enable-commands="" gmail send ... + +# Agent can unset the env var +unset GOG_ENABLE_COMMANDS && gog gmail send ... +``` + +Most agent frameworks (Claude Code, OpenClaw, Cursor, etc.) do not inspect the content of individual shell commands at the policy level, and those that support it (like Claude Code's PreToolUse hooks) rely on pattern matching that can be circumvented with creative quoting or indirection. They can allow or deny the shell/exec tool entirely, but reliably blocking specific subcommands within an allowed tool is brittle. This means that if an agent has shell access at all, runtime flags are the only thing standing between it and destructive commands, and those flags are bypassable. + +Compile-time removal closes this gap. The binary physically lacks the command. There is no sequence of arguments, environment variables, or config changes that can invoke it. + +This is especially relevant for Gmail's draft-without-send use case: Google's OAuth scopes have no `gmail.drafts` scope. `gmail.readonly` blocks all writes including drafts; `gmail.compose` allows drafts AND sending. The only way to allow `gog gmail drafts create` while blocking `gog gmail send` is to remove `send` from the binary. + +#### Config isolation + +Safety profile builds automatically use a separate config directory (`gogcli-safe/` instead of `gogcli/`), so credentials are not shared between stock `gog` and a profiled binary. This means an agent cannot bypass command removal by calling the stock `gog` binary with the same account's tokens. + +- macOS: `~/Library/Application Support/gogcli-safe/` +- Linux: `~/.config/gogcli-safe/` (or `$XDG_CONFIG_HOME/gogcli-safe/`) +- Windows: `%AppData%\\gogcli-safe\\` + +Authenticate each binary independently. The two binaries can run side-by-side on the same machine with different accounts and different permission levels. + ## Security ### Credential Storage diff --git a/build-safe.sh b/build-safe.sh new file mode 100755 index 00000000..b83c0abc --- /dev/null +++ b/build-safe.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# +# build-safe.sh - Build a safety-profiled gog binary. +# +# Reads a safety-profile.yaml, generates Go source files with only +# the enabled commands, and compiles with -tags safety_profile. +# The resulting binary version is tagged with "-safe" suffix. +# +# Usage: +# ./build-safe.sh safety-profile.example.yaml # Uses the example profile +# ./build-safe.sh safety-profiles/readonly.yaml # Uses a preset +# ./build-safe.sh safety-profiles/agent-safe.yaml -o /usr/local/bin/gog-safe +# +set -euo pipefail + +# Anchor to repo root so relative paths (internal/cmd, go.mod) always work +# regardless of where the user invokes the script from. +cd "$(dirname "$0")" + +if [[ -z "${1:-}" ]] || [[ "$1" == -* ]]; then + echo "Usage: $0 [-o output]" >&2 + echo "" >&2 + echo "Examples:" >&2 + echo " $0 safety-profile.example.yaml" >&2 + echo " $0 safety-profiles/readonly.yaml" >&2 + echo " $0 safety-profiles/agent-safe.yaml -o /usr/local/bin/gog-safe" >&2 + exit 1 +fi + +PROFILE="$1" +shift + +# Parse optional flags +OUTPUT="" +while [[ $# -gt 0 ]]; do + case "$1" in + -o|--output) + if [[ -z "${2:-}" ]]; then + echo "Error: -o requires an output path" >&2 + exit 1 + fi + OUTPUT="$2" + shift 2 + ;; + *) + echo "Unknown flag: $1" >&2 + exit 1 + ;; + esac +done + +if [[ -z "$OUTPUT" ]]; then + OUTPUT="bin/gog-safe" +fi + +if [[ ! -f "$PROFILE" ]]; then + echo "Error: profile not found: $PROFILE" >&2 + exit 1 +fi + +echo "Safety profile: $PROFILE" +echo "Output binary: $OUTPUT" +echo "" + +# Step 1: Clean previous generated files to avoid stale leftovers +rm -f internal/cmd/*_cmd_gen.go + +# Step 2: Generate Go files from the safety profile +echo "Generating command structs from profile..." +go run ./cmd/gen-safety --strict "$PROFILE" + +# Step 3: Build with the safety_profile tag +VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev") +COMMIT=$(git rev-parse --short=12 HEAD 2>/dev/null || echo "") +DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) +LDFLAGS="-X github.com/steipete/gogcli/internal/cmd.version=${VERSION}-safe -X github.com/steipete/gogcli/internal/cmd.commit=${COMMIT} -X github.com/steipete/gogcli/internal/cmd.date=${DATE}" + +mkdir -p "$(dirname "$OUTPUT")" + +echo "Building with -tags safety_profile..." +go build -tags safety_profile -ldflags "$LDFLAGS" -o "$OUTPUT" ./cmd/gog/ + +echo "" +echo "Built: $OUTPUT" +echo "Profile: $PROFILE" +if ! "$OUTPUT" --version; then + echo "WARNING: built binary failed to run --version" >&2 + exit 1 +fi diff --git a/cmd/gen-safety/discover.go b/cmd/gen-safety/discover.go new file mode 100644 index 00000000..82a24ad9 --- /dev/null +++ b/cmd/gen-safety/discover.go @@ -0,0 +1,357 @@ +package main + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "reflect" + "strings" +) + +// parsedStruct holds the AST-extracted information for one struct type. +type parsedStruct struct { + Name string + Fields []parsedField + SourceFile string + Imports []string + ExtraCode string +} + +// parsedField is a single field extracted from a Go struct via AST. +type parsedField struct { + GoName string + GoType string + Tag string // full tag including backticks + IsCmd bool // has cmd:"" in the tag +} + +// utilityTypes is the set of CLI field types that are always included +// in safety-profiled builds (no YAML key, no filtering). +// +// Keep in sync with utility commands in internal/cmd/root_types.go. +// If a new utility command type is added to CLI, add it here so it +// is always included regardless of the safety profile. +var utilityTypes = map[string]bool{ + "TimeCmd": true, + "ConfigCmd": true, + "AgentExitCodesCmd": true, + "AgentCmd": true, + "SchemaCmd": true, + "VersionCmd": true, + "CompletionCmd": true, + "CompletionInternalCmd": true, +} + +// yamlKeyFromTag extracts the name:"" value from a struct tag. +// Falls back to strings.ToLower(goName) if no name tag is present. +func yamlKeyFromTag(rawTag, goName string) string { + tag := reflect.StructTag(strings.Trim(rawTag, "`")) + if v, ok := tag.Lookup("name"); ok && v != "" { + return v + } + return strings.ToLower(goName) +} + +// hasCmdTag checks whether a struct tag contains cmd:"". +func hasCmdTag(rawTag string) bool { + tag := reflect.StructTag(strings.Trim(rawTag, "`")) + _, ok := tag.Lookup("cmd") + return ok +} + +// extractStructFields walks an AST struct type and returns its fields, +// skipping embedded (anonymous) fields. +func extractStructFields(st *ast.StructType) []parsedField { + var out []parsedField + for _, f := range st.Fields.List { + if len(f.Names) == 0 { + continue // embedded/anonymous + } + name := f.Names[0].Name + typeName := exprToString(f.Type) + tag := "" + if f.Tag != nil { + tag = f.Tag.Value + } + out = append(out, parsedField{ + GoName: name, + GoType: typeName, + Tag: tag, + IsCmd: tag != "" && hasCmdTag(tag), + }) + } + return out +} + +// exprToString converts an ast.Expr to its string representation. +func exprToString(expr ast.Expr) string { + switch e := expr.(type) { + case *ast.Ident: + return e.Name + case *ast.SelectorExpr: + return exprToString(e.X) + "." + e.Sel.Name + case *ast.StarExpr: + return "*" + exprToString(e.X) + default: + fatal("unexpected AST node type %T in struct field type", expr) + return "" // unreachable + } +} + +// extractImports returns import paths from a file, skipping kong. +func extractImports(file *ast.File) []string { + var out []string + for _, imp := range file.Imports { + path := strings.Trim(imp.Path.Value, `"`) + if path == "github.com/alecthomas/kong" { + continue + } + out = append(out, path) + } + return out +} + +// extractExtraCode pulls var/const/func declarations from the source bytes, +// excluding import blocks and type declarations. +func extractExtraCode(file *ast.File, fset *token.FileSet, src []byte) string { + var parts []string + for _, decl := range file.Decls { + switch d := decl.(type) { + case *ast.GenDecl: + if d.Tok == token.IMPORT || d.Tok == token.TYPE { + continue + } + // var or const + start := fset.Position(d.Pos()).Offset + end := fset.Position(d.End()).Offset + parts = append(parts, string(src[start:end])) + case *ast.FuncDecl: + start := fset.Position(d.Pos()).Offset + end := fset.Position(d.End()).Offset + parts = append(parts, string(src[start:end])) + } + } + return strings.Join(parts, "\n\n") +} + +// parseTypesFiles parses all *_types.go files in dir and returns a map +// from struct name to parsed struct info. Only structs named "CLI" or +// ending in "Cmd" are included. +func parseTypesFiles(dir string) (map[string]*parsedStruct, error) { + pattern := filepath.Join(dir, "*_types.go") + files, err := filepath.Glob(pattern) + if err != nil { + return nil, fmt.Errorf("globbing types files: %w", err) + } + if len(files) == 0 { + return nil, fmt.Errorf("no *_types.go files found in %s", dir) + } + + structs := make(map[string]*parsedStruct) + fset := token.NewFileSet() + + for _, path := range files { + src, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + + f, err := parser.ParseFile(fset, path, src, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + + base := filepath.Base(path) + imports := extractImports(f) + extraCode := extractExtraCode(f, fset, src) + + // Track whether we've assigned imports/extraCode to the first Cmd struct in this file. + firstCmdAssigned := false + + for _, decl := range f.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok || gd.Tok != token.TYPE { + continue + } + for _, spec := range gd.Specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + st, ok := ts.Type.(*ast.StructType) + if !ok { + continue + } + name := ts.Name.Name + if name != "CLI" && !strings.HasSuffix(name, "Cmd") { + continue + } + fields := extractStructFields(st) + ps := &parsedStruct{ + Name: name, + Fields: fields, + SourceFile: base, + } + // Attach imports and extraCode only to first Cmd struct in the file. + if !firstCmdAssigned { + ps.Imports = imports + ps.ExtraCode = extraCode + firstCmdAssigned = true + } + structs[name] = ps + } + } + } + + return structs, nil +} + +// isParentCmd checks whether a struct type has any fields with cmd:"" tags, +// making it a parent command that contains subcommands. +func isParentCmd(structs map[string]*parsedStruct, typeName string) bool { + ps, ok := structs[typeName] + if !ok { + return false + } + for _, f := range ps.Fields { + if f.IsCmd { + return true + } + } + return false +} + +// buildNonCmdPrefix renders non-cmd fields as literal Go source lines +// (e.g., for KeepCmd's ServiceAccount and Impersonate fields). +func buildNonCmdPrefix(fields []parsedField) string { + var lines []string + for _, f := range fields { + if f.IsCmd { + continue + } + lines = append(lines, fmt.Sprintf("\t%s %s %s", f.GoName, f.GoType, f.Tag)) + } + if len(lines) == 0 { + return "" + } + return strings.Join(lines, "\n") + "\n" +} + +// buildSpecsForStruct recursively builds serviceSpecs for a parent Cmd struct +// and any child parent Cmds it contains. +func buildSpecsForStruct(structs map[string]*parsedStruct, structName, yamlKey string, specs *[]serviceSpec) { + ps, ok := structs[structName] + if !ok { + fatal("struct %s not found in types files (missing *_types.go entry?)", structName) + return + } + + spec := serviceSpec{ + YAMLKey: yamlKey, + StructName: structName, + File: outputFileName(yamlKey), + Imports: ps.Imports, + ExtraCode: ps.ExtraCode, + NonCmdPrefix: buildNonCmdPrefix(ps.Fields), + } + + for _, f := range ps.Fields { + if !f.IsCmd { + continue + } + childKey := yamlKeyFromTag(f.Tag, f.GoName) + if isParentCmd(structs, f.GoType) { + // Recurse: child parent Cmd gets its own spec with dotted YAML key. + buildSpecsForStruct(structs, f.GoType, yamlKey+"."+childKey, specs) + } + spec.Fields = append(spec.Fields, field{ + GoName: f.GoName, + GoType: f.GoType, + Tag: f.Tag, + YAMLKey: childKey, + }) + } + + *specs = append(*specs, spec) +} + +// buildServiceSpecs walks the CLI struct and builds all serviceSpecs by +// discovering parent Cmd types and recursing into their subcommands. +func buildServiceSpecs(structs map[string]*parsedStruct) ([]serviceSpec, error) { + cli, ok := structs["CLI"] + if !ok { + return nil, fmt.Errorf("CLI struct not found in types files") + } + + var specs []serviceSpec + for _, f := range cli.Fields { + if !f.IsCmd { + continue + } + if utilityTypes[f.GoType] { + continue // utilities have no YAML key, always included + } + if !isParentCmd(structs, f.GoType) { + continue // aliases (leaf commands on CLI) handled by buildCLIFields + } + yamlKey := yamlKeyFromTag(f.Tag, f.GoName) + buildSpecsForStruct(structs, f.GoType, yamlKey, &specs) + } + + return specs, nil +} + +// buildCLIFields categorizes CLI struct fields into aliases (leaf commands) +// and services/utilities for the CLI generation file. +func buildCLIFields(structs map[string]*parsedStruct) (aliases []field, services []field) { + cli, ok := structs["CLI"] + if !ok { + fatal("CLI struct not found in types files") + return + } + + for _, f := range cli.Fields { + if !f.IsCmd { + continue + } + yamlKey := yamlKeyFromTag(f.Tag, f.GoName) + + if utilityTypes[f.GoType] { + // Utility: no YAMLKey, always included. + services = append(services, field{ + GoName: f.GoName, + GoType: f.GoType, + Tag: f.Tag, + }) + } else if isParentCmd(structs, f.GoType) { + // Service: parent Cmd with subcommands. + services = append(services, field{ + GoName: f.GoName, + GoType: f.GoType, + Tag: f.Tag, + YAMLKey: yamlKey, + }) + } else { + // Alias: leaf command on CLI (e.g., Send, Ls, Download). + aliases = append(aliases, field{ + GoName: f.GoName, + GoType: f.GoType, + Tag: f.Tag, + YAMLKey: yamlKey, + }) + } + } + + return aliases, services +} + +// outputFileName converts a dotted YAML key to an output filename. +// "auth.service-account" -> "auth_service_account_cmd_gen.go" +func outputFileName(yamlKey string) string { + name := strings.ReplaceAll(yamlKey, ".", "_") + name = strings.ReplaceAll(name, "-", "_") + return name + "_cmd_gen.go" +} diff --git a/cmd/gen-safety/discover_test.go b/cmd/gen-safety/discover_test.go new file mode 100644 index 00000000..97b46f9b --- /dev/null +++ b/cmd/gen-safety/discover_test.go @@ -0,0 +1,384 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestYamlKeyFromTag(t *testing.T) { + tests := []struct { + tag string + goName string + want string + }{ + {"`cmd:\"\" name:\"send\" help:\"Send an email\"`", "Send", "send"}, + {"`cmd:\"\" name:\"service-account\" help:\"Configure SA\"`", "ServiceAcct", "service-account"}, + {"`cmd:\"\" aliases:\"course\" help:\"Courses\"`", "Courses", "courses"}, + {"`cmd:\"\" name:\"out-of-office\" aliases:\"ooo\" help:\"OOO\"`", "OOO", "out-of-office"}, + {"", "FooBar", "foobar"}, + } + for _, tt := range tests { + got := yamlKeyFromTag(tt.tag, tt.goName) + if got != tt.want { + t.Errorf("yamlKeyFromTag(%q, %q) = %q, want %q", tt.tag, tt.goName, got, tt.want) + } + } +} + +func TestHasCmdTag(t *testing.T) { + if !hasCmdTag("`cmd:\"\" name:\"send\" help:\"Send\"`") { + t.Error("expected true for cmd tag") + } + if hasCmdTag("`name:\"service-account\" help:\"Path\"`") { + t.Error("expected false for non-cmd tag") + } + if hasCmdTag("") { + t.Error("expected false for empty tag") + } +} + +func TestOutputFileName(t *testing.T) { + tests := []struct { + key string + want string + }{ + {"gmail", "gmail_cmd_gen.go"}, + {"gmail.settings", "gmail_settings_cmd_gen.go"}, + {"auth.service-account", "auth_service_account_cmd_gen.go"}, + {"contacts.directory", "contacts_directory_cmd_gen.go"}, + } + for _, tt := range tests { + got := outputFileName(tt.key) + if got != tt.want { + t.Errorf("outputFileName(%q) = %q, want %q", tt.key, got, tt.want) + } + } +} + +// TestParseTypesFiles verifies that the AST parser can read the actual +// upstream *_types.go files and produce sensible results. +func TestParseTypesFiles(t *testing.T) { + dir := filepath.Join("..", "..", "internal", "cmd") + if _, err := os.Stat(filepath.Join(dir, "root_types.go")); err != nil { + t.Skipf("types files not found at %s: %v", dir, err) + } + + structs, err := parseTypesFiles(dir) + if err != nil { + t.Fatalf("parseTypesFiles: %v", err) + } + + // CLI struct must be found. + cli, ok := structs["CLI"] + if !ok { + t.Fatal("CLI struct not found") + } + if len(cli.Fields) == 0 { + t.Fatal("CLI struct has no fields") + } + + // GmailCmd must exist with expected fields. + gmail, ok := structs["GmailCmd"] + if !ok { + t.Fatal("GmailCmd not found") + } + var hasSearch, hasSend bool + for _, f := range gmail.Fields { + if f.GoName == "Search" { + hasSearch = true + } + if f.GoName == "Send" { + hasSend = true + } + } + if !hasSearch || !hasSend { + t.Errorf("GmailCmd missing expected fields: hasSearch=%v, hasSend=%v", hasSearch, hasSend) + } + + // GmailCmd should have imports (googleapi). + if len(gmail.Imports) == 0 { + t.Error("GmailCmd should have imports") + } + + // GmailCmd should have ExtraCode (var newGmailService). + if !strings.Contains(gmail.ExtraCode, "newGmailService") { + t.Errorf("GmailCmd ExtraCode should contain newGmailService, got: %q", gmail.ExtraCode) + } +} + +// TestMultiStructFile verifies that files with multiple Cmd structs +// (e.g., auth_types.go) are all discovered. +func TestMultiStructFile(t *testing.T) { + dir := filepath.Join("..", "..", "internal", "cmd") + if _, err := os.Stat(filepath.Join(dir, "auth_types.go")); err != nil { + t.Skipf("types files not found: %v", err) + } + + structs, err := parseTypesFiles(dir) + if err != nil { + t.Fatalf("parseTypesFiles: %v", err) + } + + // auth_types.go has AuthCmd, AuthCredentialsCmd, AuthTokensCmd. + for _, name := range []string{"AuthCmd", "AuthCredentialsCmd", "AuthTokensCmd"} { + if _, ok := structs[name]; !ok { + t.Errorf("struct %s not found (expected from auth_types.go)", name) + } + } + + // forms_types.go has FormsCmd, FormsResponsesCmd. + for _, name := range []string{"FormsCmd", "FormsResponsesCmd"} { + if _, ok := structs[name]; !ok { + t.Errorf("struct %s not found (expected from forms_types.go)", name) + } + } +} + +// TestKeepNonCmdPrefix verifies that KeepCmd's non-cmd fields +// (ServiceAccount, Impersonate) are captured as NonCmdPrefix. +func TestKeepNonCmdPrefix(t *testing.T) { + dir := filepath.Join("..", "..", "internal", "cmd") + if _, err := os.Stat(filepath.Join(dir, "keep_types.go")); err != nil { + t.Skipf("types files not found: %v", err) + } + + structs, err := parseTypesFiles(dir) + if err != nil { + t.Fatalf("parseTypesFiles: %v", err) + } + + keep, ok := structs["KeepCmd"] + if !ok { + t.Fatal("KeepCmd not found") + } + + prefix := buildNonCmdPrefix(keep.Fields) + if !strings.Contains(prefix, "ServiceAccount") { + t.Errorf("NonCmdPrefix should contain ServiceAccount, got: %q", prefix) + } + if !strings.Contains(prefix, "Impersonate") { + t.Errorf("NonCmdPrefix should contain Impersonate, got: %q", prefix) + } +} + +// TestBuildServiceSpecs verifies the full pipeline produces specs +// for known services. +func TestBuildServiceSpecs(t *testing.T) { + dir := filepath.Join("..", "..", "internal", "cmd") + if _, err := os.Stat(filepath.Join(dir, "root_types.go")); err != nil { + t.Skipf("types files not found: %v", err) + } + + structs, err := parseTypesFiles(dir) + if err != nil { + t.Fatalf("parseTypesFiles: %v", err) + } + + specs, err := buildServiceSpecs(structs) + if err != nil { + t.Fatalf("buildServiceSpecs: %v", err) + } + + if len(specs) == 0 { + t.Fatal("no specs generated") + } + + // Build a map for easier lookup. + specMap := make(map[string]serviceSpec) + for _, s := range specs { + specMap[s.YAMLKey] = s + } + + // Verify a selection of expected specs. + expected := []struct { + key string + structName string + minFields int + }{ + {"gmail", "GmailCmd", 10}, + {"gmail.settings", "GmailSettingsCmd", 5}, + {"gmail.thread", "GmailThreadCmd", 2}, + {"calendar", "CalendarCmd", 15}, + {"drive", "DriveCmd", 10}, + {"drive.comments", "DriveCommentsCmd", 4}, + {"auth", "AuthCmd", 10}, + {"auth.service-account", "AuthServiceAccountCmd", 2}, + {"keep", "KeepCmd", 3}, + {"classroom", "ClassroomCmd", 10}, + } + + for _, e := range expected { + spec, ok := specMap[e.key] + if !ok { + t.Errorf("missing spec for key %q", e.key) + continue + } + if spec.StructName != e.structName { + t.Errorf("spec %q: struct name = %q, want %q", e.key, spec.StructName, e.structName) + } + if len(spec.Fields) < e.minFields { + t.Errorf("spec %q: got %d fields, want at least %d", e.key, len(spec.Fields), e.minFields) + } + } +} + +// TestBuildCLIFields verifies that CLI fields are categorized correctly. +func TestBuildCLIFields(t *testing.T) { + dir := filepath.Join("..", "..", "internal", "cmd") + if _, err := os.Stat(filepath.Join(dir, "root_types.go")); err != nil { + t.Skipf("types files not found: %v", err) + } + + structs, err := parseTypesFiles(dir) + if err != nil { + t.Fatalf("parseTypesFiles: %v", err) + } + + aliases, services := buildCLIFields(structs) + + if len(aliases) == 0 { + t.Error("expected at least one alias") + } + if len(services) == 0 { + t.Error("expected at least one service") + } + + // Aliases should include Send (GmailSendCmd is a leaf, not a parent). + var hasSendAlias bool + for _, a := range aliases { + if a.GoName == "Send" { + hasSendAlias = true + } + } + if !hasSendAlias { + t.Error("Send should be classified as an alias") + } + + // Services should include Gmail (parent Cmd) and Time (utility). + var hasGmail, hasTime bool + for _, s := range services { + if s.GoName == "Gmail" { + hasGmail = true + if s.YAMLKey == "" { + t.Error("Gmail service should have a YAMLKey") + } + } + if s.GoName == "Time" { + hasTime = true + if s.YAMLKey != "" { + t.Error("Time utility should have empty YAMLKey") + } + } + } + if !hasGmail { + t.Error("Gmail should be classified as a service") + } + if !hasTime { + t.Error("Time should be classified as a utility (in services list)") + } +} + +// TestGmailWatchSkipped verifies that gmail_watch_types.go (no Cmd structs) +// does not contribute any structs to the map. +func TestGmailWatchSkipped(t *testing.T) { + dir := filepath.Join("..", "..", "internal", "cmd") + if _, err := os.Stat(filepath.Join(dir, "gmail_watch_types.go")); err != nil { + t.Skipf("gmail_watch_types.go not found: %v", err) + } + + structs, err := parseTypesFiles(dir) + if err != nil { + t.Fatalf("parseTypesFiles: %v", err) + } + + // None of the types in gmail_watch_types.go end in "Cmd". + for name, ps := range structs { + if ps.SourceFile == "gmail_watch_types.go" { + t.Errorf("unexpected struct %q from gmail_watch_types.go", name) + } + } +} + +// TestClassroomFieldsNoNameTag verifies that fields without a name:"" tag +// (like Classroom's Courses, Students, etc.) get lowercase GoName as YAML key. +func TestClassroomFieldsNoNameTag(t *testing.T) { + dir := filepath.Join("..", "..", "internal", "cmd") + structs, err := parseTypesFiles(dir) + if err != nil { + t.Fatalf("parseTypesFiles: %v", err) + } + + specs, err := buildServiceSpecs(structs) + if err != nil { + t.Fatalf("buildServiceSpecs: %v", err) + } + + var classroomSpec *serviceSpec + for i := range specs { + if specs[i].YAMLKey == "classroom" { + classroomSpec = &specs[i] + break + } + } + if classroomSpec == nil { + t.Fatal("classroom spec not found") + } + + // Courses has no name:"" tag, so YAML key should be "courses" (lowercased). + var found bool + for _, f := range classroomSpec.Fields { + if f.GoName == "Courses" { + found = true + if f.YAMLKey != "courses" { + t.Errorf("Courses YAML key = %q, want %q", f.YAMLKey, "courses") + } + } + } + if !found { + t.Error("Courses field not found in classroom spec") + } +} + +// TestEndToEndSafeBuild runs the full pipeline: generate from a profile +// and compile with -tags safety_profile. This catches regressions where +// generated code does not compile. Tests both full.yaml (all enabled) and +// readonly.yaml (most disabled) to exercise both code paths. +func TestEndToEndSafeBuild(t *testing.T) { + if testing.Short() { + t.Skip("skipping end-to-end build test in short mode") + } + + repoRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolving repo root: %v", err) + } + + profiles := []string{"full.yaml", "readonly.yaml"} + for _, profile := range profiles { + t.Run(profile, func(t *testing.T) { + profilePath := filepath.Join(repoRoot, "safety-profiles", profile) + if _, err := os.Stat(profilePath); err != nil { + t.Skipf("%s not found: %v", profile, err) + } + + // Step 1: Run the generator with --strict. + gen := exec.Command("go", "run", "./cmd/gen-safety", "--strict", profilePath) + gen.Dir = repoRoot + out, err := gen.CombinedOutput() + if err != nil { + t.Fatalf("gen-safety --strict %s failed:\n%s\n%v", profile, out, err) + } + + // Step 2: Build with -tags safety_profile. + build := exec.Command("go", "build", "-tags", "safety_profile", "./cmd/gog/") + build.Dir = repoRoot + out, err = build.CombinedOutput() + if err != nil { + t.Fatalf("go build -tags safety_profile (%s) failed:\n%s\n%v", profile, out, err) + } + }) + } +} diff --git a/cmd/gen-safety/main.go b/cmd/gen-safety/main.go new file mode 100644 index 00000000..7bc46721 --- /dev/null +++ b/cmd/gen-safety/main.go @@ -0,0 +1,452 @@ +// gen-safety reads a safety-profile.yaml and generates Go source files +// that define parent command structs with only the enabled subcommands. +// +// Generated files get a //go:build safety_profile constraint so they +// replace the original structs when built with -tags safety_profile. +// +// Usage: +// +// go run ./cmd/gen-safety [profile.yaml] +// go run ./cmd/gen-safety safety-profiles/readonly.yaml +// go run ./cmd/gen-safety --strict safety-profiles/agent-safe.yaml +package main + +import ( + "bytes" + "fmt" + "go/format" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// field represents a Kong command struct field. +type field struct { + GoName string // e.g. "Send" + GoType string // e.g. "GmailSendCmd" + Tag string // full struct tag string + YAMLKey string // key in safety-profile.yaml +} + +// serviceSpec defines a service or nested parent command and how to generate its struct. +type serviceSpec struct { + YAMLKey string // key in safety-profile.yaml + StructName string // Go struct name (e.g. "GmailCmd") + File string // output file (e.g. "gmail_cmd_gen.go") + Fields []field // all possible subcommand fields + Imports []string + ExtraCode string // extra code to include (e.g. var declarations) + NonCmdPrefix string // literal field lines for non-command fields (e.g. KeepCmd flags) +} + +// warnings accumulates non-fatal issues found during generation. +// All warnings are printed at the end; with --strict they become fatal. +var warnings []string + +func warn(msg string, args ...any) { + w := fmt.Sprintf(msg, args...) + warnings = append(warnings, w) +} + +func main() { + profilePath := "safety-profile.example.yaml" + strict := false + for _, arg := range os.Args[1:] { + if arg == "--strict" { + strict = true + } else if strings.HasPrefix(arg, "-") { + fatal("unknown flag: %s", arg) + } else { + profilePath = arg + } + } + + data, err := os.ReadFile(profilePath) + if err != nil { + fatal("reading profile: %v", err) + } + + var profile map[string]any + if err := yaml.Unmarshal(data, &profile); err != nil { + fatal("parsing YAML: %v", err) + } + + if len(profile) == 0 { + fatal("profile is empty or null - all services would be silently disabled. Check your YAML file.") + } + + outputDir := "internal/cmd" + + structs, err := parseTypesFiles(outputDir) + if err != nil { + fatal("parsing types: %v", err) + } + + specs, err := buildServiceSpecs(structs) + if err != nil { + fatal("building specs: %v", err) + } + + aliases, services := buildCLIFields(structs) + + // Validate YAML keys against known specs to catch typos. + knownKeys := buildKnownKeys(specs, aliases, services) + validateYAMLKeys(profile, knownKeys, "") + + for _, spec := range specs { + if err := generateServiceFile(outputDir, spec, profile); err != nil { + fatal("generating %s: %v", spec.File, err) + } + } + + if err := generateCLIFile(outputDir, profile, aliases, services); err != nil { + fatal("generating cli_cmd_gen.go: %v", err) + } + + fmt.Printf("Generated %d files in %s/\n", len(specs)+1, outputDir) + + // Print build summary so users can verify their profile + fmt.Printf("\nSafety profile summary:\n") + for _, spec := range specs { + if isServiceDisabled(profile, spec.YAMLKey) { + fmt.Printf(" %-20s DISABLED (entire service)\n", spec.YAMLKey) + continue + } + svcConfig := resolveDottedSection(profile, spec.YAMLKey) + enabled := resolveEnabledFields(spec.Fields, svcConfig, profile, spec.YAMLKey) + disabled := len(spec.Fields) - len(enabled) + fmt.Printf(" %-20s %d enabled, %d disabled\n", spec.YAMLKey, len(enabled), disabled) + } + + // Print consolidated warnings + if len(warnings) > 0 { + fmt.Fprintf(os.Stderr, "\ngen-safety: %d warning(s):\n", len(warnings)) + for _, w := range warnings { + fmt.Fprintf(os.Stderr, " - %s\n", w) + } + if strict { + fatal("aborting due to warnings (remove --strict to allow)") + } + } +} + +func generateServiceFile(dir string, spec serviceSpec, profile map[string]any) error { + svcConfig := resolveDottedSection(profile, spec.YAMLKey) + + // If the entire service is disabled (set to false at the top level), generate an empty struct + if isServiceDisabled(profile, spec.YAMLKey) { + return writeGenFile(dir, spec.File, buildEmptyStruct(spec)) + } + + // If service is set to `true` (bool shorthand), include ALL fields. + // resolveDottedSection returns nil for bools, so we check explicitly. + enabledFields := resolveEnabledFields(spec.Fields, svcConfig, profile, spec.YAMLKey) + + var buf bytes.Buffer + buf.WriteString(genHeader) + + if len(spec.Imports) > 0 { + buf.WriteString("import (\n") + for _, imp := range spec.Imports { + fmt.Fprintf(&buf, "\t%q\n", imp) + } + buf.WriteString(")\n\n") + } + + if spec.ExtraCode != "" { + buf.WriteString(spec.ExtraCode) + buf.WriteString("\n\n") + } + + fmt.Fprintf(&buf, "type %s struct {\n", spec.StructName) + + if spec.NonCmdPrefix != "" { + buf.WriteString(spec.NonCmdPrefix) + buf.WriteString("\n") + } + + for _, f := range enabledFields { + writeStructField(&buf, f) + } + buf.WriteString("}\n") + + return writeGenFile(dir, spec.File, buf.String()) +} + +func generateCLIFile(dir string, profile map[string]any, cliAliases []field, cliServices []field) error { + aliasConfig := resolveDottedSection(profile, "aliases") + + var buf bytes.Buffer + buf.WriteString(genHeader) + + buf.WriteString("import \"github.com/alecthomas/kong\"\n\n") + + buf.WriteString("type CLI struct {\n") + buf.WriteString("\tRootFlags `embed:\"\"`\n\n") + buf.WriteString("\tVersion kong.VersionFlag `help:\"Print version and exit\"`\n\n") + + // Action-first aliases + buf.WriteString("\t// Action-first desire paths (agent-friendly shortcuts).\n") + for _, f := range cliAliases { + if isEnabled(aliasConfig, f.YAMLKey) { + writeStructField(&buf, f) + } + } + buf.WriteString("\n") + + // Service commands: include services that have at least one enabled command. + // Fields without a YAMLKey (utility commands) are always included. + for _, f := range cliServices { + if f.YAMLKey != "" { + if isServiceDisabled(profile, f.YAMLKey) { + continue + } + // Also skip if service map is present but all leaves are false + // (e.g., gmail: { send: false, drafts: { create: false } }). + svcConfig := resolveDottedSection(profile, f.YAMLKey) + if svcConfig != nil && !mapHasEnabledLeaf(svcConfig) { + continue + } + } + writeStructField(&buf, f) + } + + buf.WriteString("}\n") + + return writeGenFile(dir, "cli_cmd_gen.go", buf.String()) +} + +func buildEmptyStruct(spec serviceSpec) string { + var buf bytes.Buffer + buf.WriteString(genHeader) + if len(spec.Imports) > 0 { + buf.WriteString("import (\n") + for _, imp := range spec.Imports { + fmt.Fprintf(&buf, "\t%q\n", imp) + } + buf.WriteString(")\n\n") + } + if spec.ExtraCode != "" { + buf.WriteString(spec.ExtraCode) + buf.WriteString("\n\n") + } + if spec.NonCmdPrefix != "" { + fmt.Fprintf(&buf, "type %s struct {\n%s}\n", spec.StructName, spec.NonCmdPrefix) + } else { + fmt.Fprintf(&buf, "type %s struct{}\n", spec.StructName) + } + return buf.String() +} + +// resolveEnabledFields returns the enabled fields for a spec, handling the +// `service: true` bool shorthand (include all fields) and normal map config. +func resolveEnabledFields(fields []field, svcConfig map[string]any, profile map[string]any, yamlKey string) []field { + if svcConfig == nil && isServiceEnabledBool(profile, yamlKey) { + // Bool shorthand: `service: true` means include all fields. + return fields + } + return filterFields(fields, svcConfig) +} + +// isServiceEnabledBool checks if a dotted key resolves to a `true` boolean. +func isServiceEnabledBool(config map[string]any, key string) bool { + parts := strings.Split(key, ".") + current := config + for _, part := range parts { + if current == nil { + return false + } + v, ok := current[part] + if !ok { + return false + } + if b, ok := v.(bool); ok { + return b + } + if m, ok := v.(map[string]any); ok { + current = m + } else { + return false + } + } + return false +} + +func filterFields(fields []field, config map[string]any) []field { + var out []field + for _, f := range fields { + if isEnabled(config, f.YAMLKey) { + out = append(out, f) + } + } + return out +} + +func isEnabled(config map[string]any, key string) bool { + if config == nil { + // Fail-closed: if no config section exists, disable commands. + // This prevents new upstream commands from silently appearing in + // safety-profiled builds when the YAML doesn't mention them. + warn("no config section for key %q, EXCLUDING (fail-closed)", key) + return false + } + v, ok := config[key] + if !ok { + warn("key %q not in profile, EXCLUDING (fail-closed)", key) + return false + } + switch val := v.(type) { + case bool: + return val + case map[string]any: + // Recursively check: enabled if at least one leaf bool is true. + return mapHasEnabledLeaf(val) + default: + fatal("invalid value for key %q: got %T (%v), expected bool or map", key, v, v) + return false // unreachable + } +} + +// mapHasEnabledLeaf recursively checks whether a nested map contains +// at least one boolean leaf set to true. +func mapHasEnabledLeaf(m map[string]any) bool { + for k, v := range m { + switch val := v.(type) { + case bool: + if val { + return true + } + case map[string]any: + if mapHasEnabledLeaf(val) { + return true + } + default: + fatal("invalid value for key %q: got %T (%v), expected bool or map", k, v, v) + } + } + return false +} + +// buildKnownKeys constructs a set of all valid YAML key paths from the specs. +// The services list is used to derive tolerated YAML keys for utility commands +// (those with empty YAMLKey), keeping this in sync with the utilityTypes map +// in discover.go automatically. +func buildKnownKeys(specs []serviceSpec, aliases []field, services []field) map[string]bool { + known := make(map[string]bool) + // Top-level sections recognized by the generator + known["aliases"] = true + // Utility commands: always included, YAML keys tolerated but ignored. + // Derived from utilityTypes via buildCLIFields (empty YAMLKey = utility). + for _, f := range services { + if f.YAMLKey == "" { + key := yamlKeyFromTag(f.Tag, f.GoName) + known[key] = true + } + } + for _, spec := range specs { + addSpecKeys(known, spec.YAMLKey, spec.Fields) + } + // Alias sub-keys + for _, f := range aliases { + known["aliases."+f.YAMLKey] = true + } + return known +} + +func addSpecKeys(known map[string]bool, prefix string, fields []field) { + known[prefix] = true + for _, f := range fields { + known[prefix+"."+f.YAMLKey] = true + } +} + +// validateYAMLKeys walks the YAML tree and warns about any keys not in the known set. +func validateYAMLKeys(config map[string]any, known map[string]bool, prefix string) { + for key, val := range config { + fullKey := key + if prefix != "" { + fullKey = prefix + "." + key + } + if !known[fullKey] { + warn("unrecognized key %q in profile (typo?)", fullKey) + } + if m, ok := val.(map[string]any); ok { + validateYAMLKeys(m, known, fullKey) + } + } +} + +// resolveDottedSection resolves a dotted key path like "gmail.settings" +// by walking the YAML tree one level at a time. +func resolveDottedSection(config map[string]any, key string) map[string]any { + parts := strings.Split(key, ".") + current := config + for _, part := range parts { + if current == nil { + return nil + } + v, ok := current[part] + if !ok { + return nil + } + if m, ok := v.(map[string]any); ok { + current = m + } else { + return nil + } + } + return current +} + +// isServiceDisabled checks if a dotted key path resolves to `false` at any level. +// Returns true (disabled) when the key is missing, matching fail-closed semantics. +func isServiceDisabled(config map[string]any, key string) bool { + parts := strings.Split(key, ".") + current := config + for _, part := range parts { + if current == nil { + return true // missing section = disabled (fail-closed) + } + v, ok := current[part] + if !ok { + return true // missing key = disabled (fail-closed) + } + if b, ok := v.(bool); ok { + return !b + } + if m, ok := v.(map[string]any); ok { + current = m + } else { + warn("unexpected type %T at key %q in profile, treating as disabled", v, part) + return true // unexpected type = disabled (fail-closed) + } + } + return false +} + +const genHeader = "//go:build safety_profile\n\npackage cmd\n\n" + +func writeStructField(buf *bytes.Buffer, f field) { + fmt.Fprintf(buf, "\t%s %s %s\n", f.GoName, f.GoType, f.Tag) +} + +func writeGenFile(dir, filename, content string) error { + formatted, err := format.Source([]byte(content)) + if err != nil { + // Write unformatted so we can debug + path := filepath.Join(dir, filename) + if writeErr := os.WriteFile(path, []byte(content), 0o644); writeErr != nil { + return fmt.Errorf("formatting %s: %w (also failed to write debug file: %v)", filename, err, writeErr) + } + return fmt.Errorf("formatting %s: %w\n\nUnformatted content written to %s for debugging.", filename, err, path) + } + path := filepath.Join(dir, filename) + return os.WriteFile(path, formatted, 0o644) +} + +func fatal(msg string, args ...any) { + fmt.Fprintf(os.Stderr, "gen-safety: "+msg+"\n", args...) + os.Exit(1) +} diff --git a/cmd/gen-safety/main_test.go b/cmd/gen-safety/main_test.go new file mode 100644 index 00000000..5d4b9c50 --- /dev/null +++ b/cmd/gen-safety/main_test.go @@ -0,0 +1,224 @@ +package main + +import ( + "strings" + "testing" +) + +func TestIsEnabled(t *testing.T) { + warnings = nil + defer func() { warnings = nil }() + + config := map[string]any{ + "send": true, + "delete": false, + "drafts": map[string]any{ + "create": true, + "send": false, + }, + "settings": map[string]any{ + "filters": false, + "forwarding": false, + }, + } + + tests := []struct { + key string + want bool + }{ + {"send", true}, + {"delete", false}, + {"drafts", true}, // map with at least one true leaf + {"settings", false}, // map with all false leaves + {"missing", false}, // fail-closed: not in config + } + for _, tt := range tests { + got := isEnabled(config, tt.key) + if got != tt.want { + t.Errorf("isEnabled(config, %q) = %v, want %v", tt.key, got, tt.want) + } + } + + // nil config = fail-closed + if isEnabled(nil, "anything") { + t.Error("isEnabled(nil, ...) should return false") + } +} + +func TestFilterFields(t *testing.T) { + warnings = nil + defer func() { warnings = nil }() + + fields := []field{ + {GoName: "Send", YAMLKey: "send"}, + {GoName: "Search", YAMLKey: "search"}, + {GoName: "Delete", YAMLKey: "delete"}, + } + config := map[string]any{ + "send": false, + "search": true, + // "delete" absent = fail-closed (excluded) + } + + got := filterFields(fields, config) + if len(got) != 1 || got[0].GoName != "Search" { + t.Errorf("filterFields: got %v, want [Search]", got) + } + + // nil config = fail-closed (all excluded) + got = filterFields(fields, nil) + if len(got) != 0 { + t.Errorf("filterFields(nil): got %d fields, want 0", len(got)) + } +} + +func TestIsServiceDisabled(t *testing.T) { + profile := map[string]any{ + "classroom": false, + "calendar": true, + "gmail": map[string]any{ + "send": true, + "thread": map[string]any{"get": true}, + }, + } + + tests := []struct { + key string + want bool // true = disabled + }{ + {"classroom", true}, // explicitly false + {"calendar", false}, // explicitly true + {"gmail", false}, // map (not disabled) + {"gmail.thread", false}, // nested map + {"nonexistent", true}, // missing = disabled (fail-closed) + } + for _, tt := range tests { + got := isServiceDisabled(profile, tt.key) + if got != tt.want { + t.Errorf("isServiceDisabled(profile, %q) = %v, want %v", tt.key, got, tt.want) + } + } +} + +func TestResolveEnabledFields_BoolShorthand(t *testing.T) { + profile := map[string]any{"calendar": true} + fields := []field{ + {GoName: "Events", YAMLKey: "events"}, + {GoName: "Create", YAMLKey: "create"}, + } + + got := resolveEnabledFields(fields, nil, profile, "calendar") + if len(got) != len(fields) { + t.Errorf("service: true should include all %d fields, got %d", len(fields), len(got)) + } +} + +func TestResolveEnabledFields_NestedBoolShorthand(t *testing.T) { + // gmail: true should enable all fields in nested parent commands like gmail.settings + profile := map[string]any{"gmail": true} + fields := []field{ + {GoName: "Filters", YAMLKey: "filters"}, + {GoName: "Forwarding", YAMLKey: "forwarding"}, + } + + got := resolveEnabledFields(fields, nil, profile, "gmail.settings") + if len(got) != len(fields) { + t.Errorf("gmail: true should enable all gmail.settings fields, got %d of %d", len(got), len(fields)) + } +} + +func TestValidateYAMLKeys(t *testing.T) { + warnings = nil + defer func() { warnings = nil }() + + known := map[string]bool{ + "gmail": true, + "gmail.send": true, + "gmail.search": true, + "aliases": true, + "aliases.ls": true, + } + + profile := map[string]any{ + "gmail": map[string]any{ + "send": true, + "search": true, + "typo": true, // unrecognized + }, + "bogus": true, // unrecognized top-level + } + + validateYAMLKeys(profile, known, "") + + if len(warnings) != 2 { + t.Errorf("expected 2 warnings, got %d: %v", len(warnings), warnings) + } + + var foundTypo, foundBogus bool + for _, w := range warnings { + if strings.Contains(w, "gmail.typo") { + foundTypo = true + } + if strings.Contains(w, "bogus") { + foundBogus = true + } + } + if !foundTypo { + t.Error("expected warning about gmail.typo") + } + if !foundBogus { + t.Error("expected warning about bogus") + } + +} + +func TestBuildEmptyStruct(t *testing.T) { + spec := serviceSpec{ + StructName: "ClassroomCmd", + File: "classroom_cmd_gen.go", + } + + got := buildEmptyStruct(spec) + if !strings.Contains(got, "type ClassroomCmd struct{}") { + t.Errorf("expected empty struct, got:\n%s", got) + } + if !strings.Contains(got, "//go:build safety_profile") { + t.Error("expected safety_profile build tag") + } +} + +func TestBuildEmptyStructWithNonCmdPrefix(t *testing.T) { + spec := serviceSpec{ + StructName: "KeepCmd", + File: "keep_cmd_gen.go", + NonCmdPrefix: "\tServiceAccount string `help:\"SA email\"`\n", + } + + got := buildEmptyStruct(spec) + if !strings.Contains(got, "type KeepCmd struct") { + t.Errorf("expected KeepCmd struct, got:\n%s", got) + } + if !strings.Contains(got, "ServiceAccount") { + t.Error("expected NonCmdPrefix fields preserved in empty struct") + } +} + +func TestMapHasEnabledLeaf(t *testing.T) { + tests := []struct { + name string + m map[string]any + want bool + }{ + {"all false", map[string]any{"a": false, "b": false}, false}, + {"one true", map[string]any{"a": false, "b": true}, true}, + {"nested true", map[string]any{"a": map[string]any{"b": true}}, true}, + {"nested all false", map[string]any{"a": map[string]any{"b": false}}, false}, + {"empty map", map[string]any{}, false}, + } + for _, tt := range tests { + got := mapHasEnabledLeaf(tt.m) + if got != tt.want { + t.Errorf("mapHasEnabledLeaf(%s) = %v, want %v", tt.name, got, tt.want) + } + } +} diff --git a/docs/safety-profiles.md b/docs/safety-profiles.md new file mode 100644 index 00000000..3984d41c --- /dev/null +++ b/docs/safety-profiles.md @@ -0,0 +1,66 @@ +# Safety Profiles + +Safety profiles let you build a `gog` binary with specific commands removed at compile time. This is designed for AI agent use cases where you want to guarantee certain operations (send, delete, etc.) are physically impossible. + +## How It Works + +1. Parent command structs (e.g., `GmailCmd`, `DriveCmd`) live in `*_types.go` files with a `//go:build !safety_profile` tag. +2. A code generator (`cmd/gen-safety`) reads these structs via AST parsing, consults a YAML profile, and generates replacement structs containing only the enabled commands. Generated files get `//go:build safety_profile`. +3. `build-safe.sh` runs the generator and compiles with `-tags safety_profile`, producing a binary where disabled commands do not exist. + +A standard `go build` (without the tag) ignores all generated files and produces the normal binary. The safety profile system has zero impact on stock builds. + +## Building + +```bash +./build-safe.sh safety-profiles/agent-safe.yaml +./build-safe.sh safety-profiles/readonly.yaml -o /usr/local/bin/gog-safe +``` + +## Preset Profiles + +- **`full.yaml`** - All commands enabled (useful as a template) +- **`agent-safe.yaml`** - Read, draft, organize. Blocks send, delete, admin. +- **`readonly.yaml`** - Only read/list/search/get. Nothing can be created or modified. + +## YAML Format + +Each service maps its subcommands to `true` (enabled) or `false` (disabled): + +```yaml +gmail: + search: true + send: false + drafts: + create: true + send: false +``` + +Use `service: true` as shorthand to enable all subcommands, or `service: false` to disable the entire service. + +## Fail-Closed Semantics + +Commands not listed in the YAML are excluded by default. The `--strict` flag (used by `build-safe.sh`) makes this fatal, so new upstream commands never silently appear in safety-profiled builds. + +## Utility Commands (Always Included) + +A small set of infrastructure commands (`config`, `time`, `version`, `schema`, `agent`, `agent-exit-codes`, `completion`) are always included in every safety-profiled build. These commands cannot access or modify user data, so they bypass YAML filtering. Their keys are tolerated in YAML profiles to avoid "unrecognized key" warnings, but their values have no effect. + +The list is defined in the `utilityTypes` map in `cmd/gen-safety/discover.go`. If a new utility command type is added upstream, it must be added there. + +## For Contributors: The `*_types.go` Convention + +When adding or modifying a parent command struct (one that contains `cmd:""` subcommand fields), edit the corresponding `*_types.go` file, not the original `.go` file. + +For example, to add a new Gmail subcommand: + +1. Add the field to `internal/cmd/gmail_types.go` (not `gmail.go`) +2. The YAML key is derived from the `name:""` struct tag (not the Go field name) +3. Add the new key to each profile in `safety-profiles/` +4. Run `./build-safe.sh safety-profiles/full.yaml` to verify + +The `_types.go` files are the source of truth for command struct definitions. The original `.go` files contain the `Run()` implementations and helper functions. + +### Why the Split? + +Go build tags require mutual exclusion at the file level. The stock build uses the `_types.go` definitions (with `!safety_profile` tag), and the safety build uses the generated `_cmd_gen.go` replacements (with `safety_profile` tag). Both define the same struct name, so only one can be active per build. diff --git a/internal/cmd/appscript.go b/internal/cmd/appscript.go index d5047eef..83280fe2 100644 --- a/internal/cmd/appscript.go +++ b/internal/cmd/appscript.go @@ -15,13 +15,6 @@ import ( var newAppScriptService = googleapi.NewAppScript -type AppScriptCmd struct { - Get AppScriptGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get Apps Script project metadata"` - Content AppScriptContentCmd `cmd:"" name:"content" aliases:"cat" help:"Get Apps Script project content"` - Run AppScriptRunCmd `cmd:"" name:"run" help:"Run a deployed Apps Script function"` - Create AppScriptCreateCmd `cmd:"" name:"create" aliases:"new" help:"Create an Apps Script project"` -} - type AppScriptGetCmd struct { ScriptID string `arg:"" name:"scriptId" help:"Script ID"` } diff --git a/internal/cmd/appscript_types.go b/internal/cmd/appscript_types.go new file mode 100644 index 00000000..6368f257 --- /dev/null +++ b/internal/cmd/appscript_types.go @@ -0,0 +1,10 @@ +//go:build !safety_profile + +package cmd + +type AppScriptCmd struct { + Get AppScriptGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get Apps Script project metadata"` + Content AppScriptContentCmd `cmd:"" name:"content" aliases:"cat" help:"Get Apps Script project content"` + Run AppScriptRunCmd `cmd:"" name:"run" help:"Run a deployed Apps Script function"` + Create AppScriptCreateCmd `cmd:"" name:"create" aliases:"new" help:"Create an Apps Script project"` +} diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index edf8f5ef..4c6ddcc7 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -50,26 +50,6 @@ const ( authTypeOAuthServiceAccount = "oauth+service_account" ) -type AuthCmd struct { - Credentials AuthCredentialsCmd `cmd:"" name:"credentials" help:"Manage OAuth client credentials"` - Add AuthAddCmd `cmd:"" name:"add" help:"Authorize and store a refresh token"` - Services AuthServicesCmd `cmd:"" name:"services" help:"List supported auth services and scopes"` - List AuthListCmd `cmd:"" name:"list" help:"List stored accounts"` - Aliases AuthAliasCmd `cmd:"" name:"alias" help:"Manage account aliases"` - Status AuthStatusCmd `cmd:"" name:"status" help:"Show auth configuration and keyring backend"` - Keyring AuthKeyringCmd `cmd:"" name:"keyring" help:"Configure keyring backend"` - 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"` - 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)"` -} - -type AuthCredentialsCmd struct { - Set AuthCredentialsSetCmd `cmd:"" default:"withargs" help:"Store OAuth client credentials"` - List AuthCredentialsListCmd `cmd:"" name:"list" help:"List stored OAuth client credentials"` -} - type AuthCredentialsSetCmd struct { Path string `arg:"" name:"credentials" help:"Path to credentials.json or '-' for stdin"` Domains string `name:"domain" help:"Comma-separated domains to map to this client (e.g. example.com)"` @@ -212,13 +192,6 @@ func (c *AuthCredentialsListCmd) Run(ctx context.Context, _ *RootFlags) error { return nil } -type AuthTokensCmd struct { - List AuthTokensListCmd `cmd:"" name:"list" help:"List stored tokens (by key only)"` - Delete AuthTokensDeleteCmd `cmd:"" name:"delete" help:"Delete a stored refresh token"` - Export AuthTokensExportCmd `cmd:"" name:"export" help:"Export a refresh token to a file (contains secrets)"` - Import AuthTokensImportCmd `cmd:"" name:"import" help:"Import a refresh token file into keyring (contains secrets)"` -} - type AuthTokensListCmd struct{} func (c *AuthTokensListCmd) Run(ctx context.Context, _ *RootFlags) error { diff --git a/internal/cmd/auth_alias.go b/internal/cmd/auth_alias.go index e85adc9c..0e65c75f 100644 --- a/internal/cmd/auth_alias.go +++ b/internal/cmd/auth_alias.go @@ -12,12 +12,6 @@ import ( "github.com/steipete/gogcli/internal/ui" ) -type AuthAliasCmd struct { - List AuthAliasListCmd `cmd:"" name:"list" help:"List account aliases"` - Set AuthAliasSetCmd `cmd:"" name:"set" help:"Set an account alias"` - Unset AuthAliasUnsetCmd `cmd:"" name:"unset" help:"Remove an account alias"` -} - type AuthAliasListCmd struct{} func (c *AuthAliasListCmd) Run(ctx context.Context) error { diff --git a/internal/cmd/auth_alias_types.go b/internal/cmd/auth_alias_types.go new file mode 100644 index 00000000..1bc2908c --- /dev/null +++ b/internal/cmd/auth_alias_types.go @@ -0,0 +1,9 @@ +//go:build !safety_profile + +package cmd + +type AuthAliasCmd struct { + List AuthAliasListCmd `cmd:"" name:"list" help:"List account aliases"` + Set AuthAliasSetCmd `cmd:"" name:"set" help:"Set an account alias"` + Unset AuthAliasUnsetCmd `cmd:"" name:"unset" help:"Remove an account alias"` +} diff --git a/internal/cmd/auth_service_account.go b/internal/cmd/auth_service_account.go index 2ebef398..14efd8f5 100644 --- a/internal/cmd/auth_service_account.go +++ b/internal/cmd/auth_service_account.go @@ -12,12 +12,6 @@ import ( "github.com/steipete/gogcli/internal/ui" ) -type AuthServiceAccountCmd struct { - Set AuthServiceAccountSetCmd `cmd:"" name:"set" help:"Store a service account key for impersonation"` - Unset AuthServiceAccountUnsetCmd `cmd:"" name:"unset" help:"Remove stored service account key"` - Status AuthServiceAccountStatusCmd `cmd:"" name:"status" help:"Show stored service account key status"` -} - type serviceAccountJSONInfo struct { ClientEmail string ClientID string diff --git a/internal/cmd/auth_service_account_types.go b/internal/cmd/auth_service_account_types.go new file mode 100644 index 00000000..511a7f60 --- /dev/null +++ b/internal/cmd/auth_service_account_types.go @@ -0,0 +1,9 @@ +//go:build !safety_profile + +package cmd + +type AuthServiceAccountCmd struct { + Set AuthServiceAccountSetCmd `cmd:"" name:"set" help:"Store a service account key for impersonation"` + Unset AuthServiceAccountUnsetCmd `cmd:"" name:"unset" help:"Remove stored service account key"` + Status AuthServiceAccountStatusCmd `cmd:"" name:"status" help:"Show stored service account key status"` +} diff --git a/internal/cmd/auth_types.go b/internal/cmd/auth_types.go new file mode 100644 index 00000000..ed549f98 --- /dev/null +++ b/internal/cmd/auth_types.go @@ -0,0 +1,30 @@ +//go:build !safety_profile + +package cmd + +type AuthCmd struct { + Credentials AuthCredentialsCmd `cmd:"" name:"credentials" help:"Manage OAuth client credentials"` + Add AuthAddCmd `cmd:"" name:"add" help:"Authorize and store a refresh token"` + Services AuthServicesCmd `cmd:"" name:"services" help:"List supported auth services and scopes"` + List AuthListCmd `cmd:"" name:"list" help:"List stored accounts"` + Aliases AuthAliasCmd `cmd:"" name:"alias" help:"Manage account aliases"` + Status AuthStatusCmd `cmd:"" name:"status" help:"Show auth configuration and keyring backend"` + Keyring AuthKeyringCmd `cmd:"" name:"keyring" help:"Configure keyring backend"` + 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"` + 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)"` +} + +type AuthCredentialsCmd struct { + Set AuthCredentialsSetCmd `cmd:"" default:"withargs" help:"Store OAuth client credentials"` + List AuthCredentialsListCmd `cmd:"" name:"list" help:"List stored OAuth client credentials"` +} + +type AuthTokensCmd struct { + List AuthTokensListCmd `cmd:"" name:"list" help:"List stored tokens (by key only)"` + Delete AuthTokensDeleteCmd `cmd:"" name:"delete" help:"Delete a stored refresh token"` + Export AuthTokensExportCmd `cmd:"" name:"export" help:"Export a refresh token to a file (contains secrets)"` + Import AuthTokensImportCmd `cmd:"" name:"import" help:"Import a refresh token file into keyring (contains secrets)"` +} diff --git a/internal/cmd/calendar.go b/internal/cmd/calendar.go index ac41aac6..58871a20 100644 --- a/internal/cmd/calendar.go +++ b/internal/cmd/calendar.go @@ -12,28 +12,6 @@ import ( "github.com/steipete/gogcli/internal/ui" ) -type CalendarCmd struct { - Calendars CalendarCalendarsCmd `cmd:"" name:"calendars" help:"List calendars"` - ACL CalendarAclCmd `cmd:"" name:"acl" aliases:"permissions,perms" help:"List calendar ACL"` - Events CalendarEventsCmd `cmd:"" name:"events" aliases:"list,ls" help:"List events from a calendar or all calendars"` - Event CalendarEventCmd `cmd:"" name:"event" aliases:"get,info,show" help:"Get event"` - Create CalendarCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create an event"` - Update CalendarUpdateCmd `cmd:"" name:"update" aliases:"edit,set" help:"Update an event"` - Delete CalendarDeleteCmd `cmd:"" name:"delete" aliases:"rm,del,remove" help:"Delete an event"` - FreeBusy CalendarFreeBusyCmd `cmd:"" name:"freebusy" help:"Get free/busy"` - Respond CalendarRespondCmd `cmd:"" name:"respond" aliases:"rsvp,reply" help:"Respond to an event invitation"` - ProposeTime CalendarProposeTimeCmd `cmd:"" name:"propose-time" help:"Generate URL to propose a new meeting time (browser-only feature)"` - Colors CalendarColorsCmd `cmd:"" name:"colors" help:"Show calendar colors"` - Conflicts CalendarConflictsCmd `cmd:"" name:"conflicts" help:"Find conflicts"` - Search CalendarSearchCmd `cmd:"" name:"search" aliases:"find,query" help:"Search events"` - Time CalendarTimeCmd `cmd:"" name:"time" help:"Show server time"` - Users CalendarUsersCmd `cmd:"" name:"users" help:"List workspace users (use their email as calendar ID)"` - Team CalendarTeamCmd `cmd:"" name:"team" help:"Show events for all members of a Google Group"` - FocusTime CalendarFocusTimeCmd `cmd:"" name:"focus-time" aliases:"focus" help:"Create a Focus Time block"` - OOO CalendarOOOCmd `cmd:"" name:"out-of-office" aliases:"ooo" help:"Create an Out of Office event"` - WorkingLocation CalendarWorkingLocationCmd `cmd:"" name:"working-location" aliases:"wl" help:"Set working location (home/office/custom)"` -} - type CalendarCalendarsCmd struct { Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"` Page string `name:"page" aliases:"cursor" help:"Page token"` diff --git a/internal/cmd/calendar_types.go b/internal/cmd/calendar_types.go new file mode 100644 index 00000000..c5ecc92a --- /dev/null +++ b/internal/cmd/calendar_types.go @@ -0,0 +1,25 @@ +//go:build !safety_profile + +package cmd + +type CalendarCmd struct { + Calendars CalendarCalendarsCmd `cmd:"" name:"calendars" help:"List calendars"` + ACL CalendarAclCmd `cmd:"" name:"acl" aliases:"permissions,perms" help:"List calendar ACL"` + Events CalendarEventsCmd `cmd:"" name:"events" aliases:"list,ls" help:"List events from a calendar or all calendars"` + Event CalendarEventCmd `cmd:"" name:"event" aliases:"get,info,show" help:"Get event"` + Create CalendarCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create an event"` + Update CalendarUpdateCmd `cmd:"" name:"update" aliases:"edit,set" help:"Update an event"` + Delete CalendarDeleteCmd `cmd:"" name:"delete" aliases:"rm,del,remove" help:"Delete an event"` + FreeBusy CalendarFreeBusyCmd `cmd:"" name:"freebusy" help:"Get free/busy"` + Respond CalendarRespondCmd `cmd:"" name:"respond" aliases:"rsvp,reply" help:"Respond to an event invitation"` + ProposeTime CalendarProposeTimeCmd `cmd:"" name:"propose-time" help:"Generate URL to propose a new meeting time (browser-only feature)"` + Colors CalendarColorsCmd `cmd:"" name:"colors" help:"Show calendar colors"` + Conflicts CalendarConflictsCmd `cmd:"" name:"conflicts" help:"Find conflicts"` + Search CalendarSearchCmd `cmd:"" name:"search" aliases:"find,query" help:"Search events"` + Time CalendarTimeCmd `cmd:"" name:"time" help:"Show server time"` + Users CalendarUsersCmd `cmd:"" name:"users" help:"List workspace users (use their email as calendar ID)"` + Team CalendarTeamCmd `cmd:"" name:"team" help:"Show events for all members of a Google Group"` + FocusTime CalendarFocusTimeCmd `cmd:"" name:"focus-time" aliases:"focus" help:"Create a Focus Time block"` + OOO CalendarOOOCmd `cmd:"" name:"out-of-office" aliases:"ooo" help:"Create an Out of Office event"` + WorkingLocation CalendarWorkingLocationCmd `cmd:"" name:"working-location" aliases:"wl" help:"Set working location (home/office/custom)"` +} diff --git a/internal/cmd/chat.go b/internal/cmd/chat.go index 2ea35deb..1d619dd0 100644 --- a/internal/cmd/chat.go +++ b/internal/cmd/chat.go @@ -1,8 +1 @@ package cmd - -type ChatCmd struct { - Spaces ChatSpacesCmd `cmd:"" name:"spaces" help:"Chat spaces"` - Messages ChatMessagesCmd `cmd:"" name:"messages" help:"Chat messages"` - Threads ChatThreadsCmd `cmd:"" name:"threads" help:"Chat threads"` - DM ChatDMCmd `cmd:"" name:"dm" help:"Direct messages"` -} diff --git a/internal/cmd/chat_dm.go b/internal/cmd/chat_dm.go index 5bb2ffe7..aaa2516d 100644 --- a/internal/cmd/chat_dm.go +++ b/internal/cmd/chat_dm.go @@ -12,11 +12,6 @@ import ( "github.com/steipete/gogcli/internal/ui" ) -type ChatDMCmd struct { - Send ChatDMSendCmd `cmd:"" name:"send" aliases:"create,post" help:"Send a direct message"` - Space ChatDMSpaceCmd `cmd:"" name:"space" aliases:"find,setup" help:"Find or create a DM space"` -} - type ChatDMSendCmd struct { Email string `arg:"" name:"email" help:"Recipient email"` Text string `name:"text" help:"Message text (required)"` diff --git a/internal/cmd/chat_dm_types.go b/internal/cmd/chat_dm_types.go new file mode 100644 index 00000000..426e5c32 --- /dev/null +++ b/internal/cmd/chat_dm_types.go @@ -0,0 +1,8 @@ +//go:build !safety_profile + +package cmd + +type ChatDMCmd struct { + Send ChatDMSendCmd `cmd:"" name:"send" aliases:"create,post" help:"Send a direct message"` + Space ChatDMSpaceCmd `cmd:"" name:"space" aliases:"find,setup" help:"Find or create a DM space"` +} diff --git a/internal/cmd/chat_messages.go b/internal/cmd/chat_messages.go index 1023bf60..23912045 100644 --- a/internal/cmd/chat_messages.go +++ b/internal/cmd/chat_messages.go @@ -12,11 +12,6 @@ import ( "github.com/steipete/gogcli/internal/ui" ) -type ChatMessagesCmd struct { - List ChatMessagesListCmd `cmd:"" name:"list" aliases:"ls" help:"List messages"` - Send ChatMessagesSendCmd `cmd:"" name:"send" aliases:"create,post" help:"Send a message"` -} - type ChatMessagesListCmd struct { Space string `arg:"" name:"space" help:"Space name (spaces/...)"` Max int64 `name:"max" aliases:"limit" help:"Max results" default:"50"` diff --git a/internal/cmd/chat_messages_types.go b/internal/cmd/chat_messages_types.go new file mode 100644 index 00000000..ddccb75a --- /dev/null +++ b/internal/cmd/chat_messages_types.go @@ -0,0 +1,8 @@ +//go:build !safety_profile + +package cmd + +type ChatMessagesCmd struct { + List ChatMessagesListCmd `cmd:"" name:"list" aliases:"ls" help:"List messages"` + Send ChatMessagesSendCmd `cmd:"" name:"send" aliases:"create,post" help:"Send a message"` +} diff --git a/internal/cmd/chat_spaces.go b/internal/cmd/chat_spaces.go index ddb8af0e..30ba5ad4 100644 --- a/internal/cmd/chat_spaces.go +++ b/internal/cmd/chat_spaces.go @@ -12,12 +12,6 @@ import ( "github.com/steipete/gogcli/internal/ui" ) -type ChatSpacesCmd struct { - List ChatSpacesListCmd `cmd:"" name:"list" aliases:"ls" help:"List spaces"` - Find ChatSpacesFindCmd `cmd:"" name:"find" aliases:"search,query" help:"Find spaces by display name"` - Create ChatSpacesCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a space"` -} - type ChatSpacesListCmd struct { Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"` Page string `name:"page" aliases:"cursor" help:"Page token"` diff --git a/internal/cmd/chat_spaces_types.go b/internal/cmd/chat_spaces_types.go new file mode 100644 index 00000000..5bd909bd --- /dev/null +++ b/internal/cmd/chat_spaces_types.go @@ -0,0 +1,9 @@ +//go:build !safety_profile + +package cmd + +type ChatSpacesCmd struct { + List ChatSpacesListCmd `cmd:"" name:"list" aliases:"ls" help:"List spaces"` + Find ChatSpacesFindCmd `cmd:"" name:"find" aliases:"search,query" help:"Find spaces by display name"` + Create ChatSpacesCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a space"` +} diff --git a/internal/cmd/chat_threads.go b/internal/cmd/chat_threads.go index 9278f95a..70f21b00 100644 --- a/internal/cmd/chat_threads.go +++ b/internal/cmd/chat_threads.go @@ -12,10 +12,6 @@ import ( "github.com/steipete/gogcli/internal/ui" ) -type ChatThreadsCmd struct { - List ChatThreadsListCmd `cmd:"" name:"list" help:"List threads in a space"` -} - type ChatThreadsListCmd struct { Space string `arg:"" name:"space" help:"Space name (spaces/...)"` Max int64 `name:"max" aliases:"limit" help:"Max results" default:"50"` diff --git a/internal/cmd/chat_threads_types.go b/internal/cmd/chat_threads_types.go new file mode 100644 index 00000000..527ab6cc --- /dev/null +++ b/internal/cmd/chat_threads_types.go @@ -0,0 +1,7 @@ +//go:build !safety_profile + +package cmd + +type ChatThreadsCmd struct { + List ChatThreadsListCmd `cmd:"" name:"list" help:"List threads in a space"` +} diff --git a/internal/cmd/chat_types.go b/internal/cmd/chat_types.go new file mode 100644 index 00000000..c2582cea --- /dev/null +++ b/internal/cmd/chat_types.go @@ -0,0 +1,10 @@ +//go:build !safety_profile + +package cmd + +type ChatCmd struct { + Spaces ChatSpacesCmd `cmd:"" name:"spaces" help:"Chat spaces"` + Messages ChatMessagesCmd `cmd:"" name:"messages" help:"Chat messages"` + Threads ChatThreadsCmd `cmd:"" name:"threads" help:"Chat threads"` + DM ChatDMCmd `cmd:"" name:"dm" help:"Direct messages"` +} diff --git a/internal/cmd/classroom.go b/internal/cmd/classroom.go index 9866db2d..4b70f83f 100644 --- a/internal/cmd/classroom.go +++ b/internal/cmd/classroom.go @@ -3,19 +3,3 @@ package cmd import "github.com/steipete/gogcli/internal/googleapi" var newClassroomService = googleapi.NewClassroom - -type ClassroomCmd struct { - Courses ClassroomCoursesCmd `cmd:"" aliases:"course" help:"Courses"` - Students ClassroomStudentsCmd `cmd:"" aliases:"student" help:"Course students"` - Teachers ClassroomTeachersCmd `cmd:"" aliases:"teacher" help:"Course teachers"` - Roster ClassroomRosterCmd `cmd:"" aliases:"members" help:"Course roster (students + teachers)"` - Coursework ClassroomCourseworkCmd `cmd:"" name:"coursework" aliases:"work" help:"Coursework"` - Materials ClassroomMaterialsCmd `cmd:"" name:"materials" aliases:"material" help:"Coursework materials"` - Submissions ClassroomSubmissionsCmd `cmd:"" aliases:"submission" help:"Student submissions"` - Announcements ClassroomAnnouncementsCmd `cmd:"" aliases:"announcement,ann" help:"Announcements"` - Topics ClassroomTopicsCmd `cmd:"" aliases:"topic" help:"Topics"` - Invitations ClassroomInvitationsCmd `cmd:"" aliases:"invitation,invites" help:"Invitations"` - Guardians ClassroomGuardiansCmd `cmd:"" aliases:"guardian" help:"Guardians"` - GuardianInvites ClassroomGuardianInvitesCmd `cmd:"" name:"guardian-invitations" aliases:"guardian-invites" help:"Guardian invitations"` - Profile ClassroomProfileCmd `cmd:"" aliases:"me" help:"User profiles"` -} diff --git a/internal/cmd/classroom_types.go b/internal/cmd/classroom_types.go new file mode 100644 index 00000000..0bad4127 --- /dev/null +++ b/internal/cmd/classroom_types.go @@ -0,0 +1,19 @@ +//go:build !safety_profile + +package cmd + +type ClassroomCmd struct { + Courses ClassroomCoursesCmd `cmd:"" aliases:"course" help:"Courses"` + Students ClassroomStudentsCmd `cmd:"" aliases:"student" help:"Course students"` + Teachers ClassroomTeachersCmd `cmd:"" aliases:"teacher" help:"Course teachers"` + Roster ClassroomRosterCmd `cmd:"" aliases:"members" help:"Course roster (students + teachers)"` + Coursework ClassroomCourseworkCmd `cmd:"" name:"coursework" aliases:"work" help:"Coursework"` + Materials ClassroomMaterialsCmd `cmd:"" name:"materials" aliases:"material" help:"Coursework materials"` + Submissions ClassroomSubmissionsCmd `cmd:"" aliases:"submission" help:"Student submissions"` + Announcements ClassroomAnnouncementsCmd `cmd:"" aliases:"announcement,ann" help:"Announcements"` + Topics ClassroomTopicsCmd `cmd:"" aliases:"topic" help:"Topics"` + Invitations ClassroomInvitationsCmd `cmd:"" aliases:"invitation,invites" help:"Invitations"` + Guardians ClassroomGuardiansCmd `cmd:"" aliases:"guardian" help:"Guardians"` + GuardianInvites ClassroomGuardianInvitesCmd `cmd:"" name:"guardian-invitations" aliases:"guardian-invites" help:"Guardian invitations"` + Profile ClassroomProfileCmd `cmd:"" aliases:"me" help:"User profiles"` +} diff --git a/internal/cmd/contacts.go b/internal/cmd/contacts.go index 991e8e0a..9f9fe226 100644 --- a/internal/cmd/contacts.go +++ b/internal/cmd/contacts.go @@ -12,17 +12,6 @@ import ( "github.com/steipete/gogcli/internal/ui" ) -type ContactsCmd struct { - Search ContactsSearchCmd `cmd:"" name:"search" help:"Search contacts by name/email/phone"` - List ContactsListCmd `cmd:"" name:"list" aliases:"ls" help:"List contacts"` - Get ContactsGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a contact"` - Create ContactsCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a contact"` - Update ContactsUpdateCmd `cmd:"" name:"update" aliases:"edit,set" help:"Update a contact"` - Delete ContactsDeleteCmd `cmd:"" name:"delete" aliases:"rm,del,remove" help:"Delete a contact"` - Directory ContactsDirectoryCmd `cmd:"" name:"directory" help:"Directory contacts"` - Other ContactsOtherCmd `cmd:"" name:"other" help:"Other contacts"` -} - type ContactsSearchCmd struct { Query []string `arg:"" name:"query" help:"Search query"` Max int64 `name:"max" aliases:"limit" help:"Max results" default:"50"` diff --git a/internal/cmd/contacts_directory.go b/internal/cmd/contacts_directory.go index 493185a4..3fff5d78 100644 --- a/internal/cmd/contacts_directory.go +++ b/internal/cmd/contacts_directory.go @@ -18,11 +18,6 @@ const ( directoryRequestTimeout = 20 * time.Second ) -type ContactsDirectoryCmd struct { - List ContactsDirectoryListCmd `cmd:"" name:"list" help:"List people from the Workspace directory"` - Search ContactsDirectorySearchCmd `cmd:"" name:"search" help:"Search people in the Workspace directory"` -} - type ContactsDirectoryListCmd struct { Max int64 `name:"max" aliases:"limit" help:"Max results" default:"50"` Page string `name:"page" aliases:"cursor" help:"Page token"` @@ -235,12 +230,6 @@ func (c *ContactsDirectorySearchCmd) Run(ctx context.Context, flags *RootFlags) return nil } -type ContactsOtherCmd struct { - List ContactsOtherListCmd `cmd:"" name:"list" help:"List other contacts"` - Search ContactsOtherSearchCmd `cmd:"" name:"search" help:"Search other contacts"` - Delete ContactsOtherDeleteCmd `cmd:"" name:"delete" help:"Delete an other contact"` -} - type ContactsOtherListCmd struct { Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"` Page string `name:"page" aliases:"cursor" help:"Page token"` diff --git a/internal/cmd/contacts_directory_types.go b/internal/cmd/contacts_directory_types.go new file mode 100644 index 00000000..945eb157 --- /dev/null +++ b/internal/cmd/contacts_directory_types.go @@ -0,0 +1,14 @@ +//go:build !safety_profile + +package cmd + +type ContactsDirectoryCmd struct { + List ContactsDirectoryListCmd `cmd:"" name:"list" help:"List people from the Workspace directory"` + Search ContactsDirectorySearchCmd `cmd:"" name:"search" help:"Search people in the Workspace directory"` +} + +type ContactsOtherCmd struct { + List ContactsOtherListCmd `cmd:"" name:"list" help:"List other contacts"` + Search ContactsOtherSearchCmd `cmd:"" name:"search" help:"Search other contacts"` + Delete ContactsOtherDeleteCmd `cmd:"" name:"delete" help:"Delete an other contact"` +} diff --git a/internal/cmd/contacts_types.go b/internal/cmd/contacts_types.go new file mode 100644 index 00000000..33cc4b39 --- /dev/null +++ b/internal/cmd/contacts_types.go @@ -0,0 +1,14 @@ +//go:build !safety_profile + +package cmd + +type ContactsCmd struct { + Search ContactsSearchCmd `cmd:"" name:"search" help:"Search contacts by name/email/phone"` + List ContactsListCmd `cmd:"" name:"list" aliases:"ls" help:"List contacts"` + Get ContactsGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a contact"` + Create ContactsCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a contact"` + Update ContactsUpdateCmd `cmd:"" name:"update" aliases:"edit,set" help:"Update a contact"` + Delete ContactsDeleteCmd `cmd:"" name:"delete" aliases:"rm,del,remove" help:"Delete a contact"` + Directory ContactsDirectoryCmd `cmd:"" name:"directory" help:"Directory contacts"` + Other ContactsOtherCmd `cmd:"" name:"other" help:"Other contacts"` +} diff --git a/internal/cmd/docs.go b/internal/cmd/docs.go index 91727cd3..529a5536 100644 --- a/internal/cmd/docs.go +++ b/internal/cmd/docs.go @@ -23,26 +23,6 @@ import ( ) var newDocsService = googleapi.NewDocs - -type DocsCmd struct { - Export DocsExportCmd `cmd:"" name:"export" aliases:"download,dl" help:"Export a Google Doc (pdf|docx|txt)"` - Info DocsInfoCmd `cmd:"" name:"info" aliases:"get,show" help:"Get Google Doc metadata"` - Create DocsCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a Google Doc"` - Copy DocsCopyCmd `cmd:"" name:"copy" aliases:"cp,duplicate" help:"Copy a Google Doc"` - Cat DocsCatCmd `cmd:"" name:"cat" aliases:"text,read" help:"Print a Google Doc as plain text"` - Comments DocsCommentsCmd `cmd:"" name:"comments" help:"Manage comments on files"` - ListTabs DocsListTabsCmd `cmd:"" name:"list-tabs" help:"List all tabs in a Google Doc"` - Write DocsWriteCmd `cmd:"" name:"write" help:"Write content to a Google Doc"` - Insert DocsInsertCmd `cmd:"" name:"insert" help:"Insert text at a specific position"` - Delete DocsDeleteCmd `cmd:"" name:"delete" help:"Delete text range from document"` - FindReplace DocsFindReplaceCmd `cmd:"" name:"find-replace" help:"Find and replace text in document"` - Update DocsUpdateCmd `cmd:"" name:"update" help:"Insert text at a specific index in a Google Doc"` - Edit DocsEditCmd `cmd:"" name:"edit" help:"Find and replace text in a Google Doc"` - Sed DocsSedCmd `cmd:"" name:"sed" help:"Regex find/replace (sed-style: s/pattern/replacement/g)"` - Clear DocsClearCmd `cmd:"" name:"clear" help:"Clear all content from a Google Doc"` - Structure DocsStructureCmd `cmd:"" name:"structure" aliases:"struct" help:"Show document structure with numbered paragraphs"` -} - type DocsExportCmd struct { DocID string `arg:"" name:"docId" help:"Doc ID"` Output OutputPathFlag `embed:""` diff --git a/internal/cmd/docs_comments.go b/internal/cmd/docs_comments.go index 1251c743..0f07af91 100644 --- a/internal/cmd/docs_comments.go +++ b/internal/cmd/docs_comments.go @@ -13,14 +13,6 @@ import ( ) // DocsCommentsCmd is the parent command for comment operations on a Google Doc. -type DocsCommentsCmd struct { - List DocsCommentsListCmd `cmd:"" name:"list" aliases:"ls" help:"List comments on a Google Doc"` - Get DocsCommentsGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a comment by ID"` - Add DocsCommentsAddCmd `cmd:"" name:"add" aliases:"create,new" help:"Add a comment to a Google Doc"` - Reply DocsCommentsReplyCmd `cmd:"" name:"reply" aliases:"respond" help:"Reply to a comment"` - Resolve DocsCommentsResolveCmd `cmd:"" name:"resolve" help:"Resolve a comment (mark as done)"` - Delete DocsCommentsDeleteCmd `cmd:"" name:"delete" aliases:"rm,del,remove" help:"Delete a comment"` -} // DocsCommentsListCmd lists comments on a Google Doc. type DocsCommentsListCmd struct { diff --git a/internal/cmd/docs_comments_types.go b/internal/cmd/docs_comments_types.go new file mode 100644 index 00000000..3e0e7097 --- /dev/null +++ b/internal/cmd/docs_comments_types.go @@ -0,0 +1,12 @@ +//go:build !safety_profile + +package cmd + +type DocsCommentsCmd struct { + List DocsCommentsListCmd `cmd:"" name:"list" aliases:"ls" help:"List comments on a Google Doc"` + Get DocsCommentsGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a comment by ID"` + Add DocsCommentsAddCmd `cmd:"" name:"add" aliases:"create,new" help:"Add a comment to a Google Doc"` + Reply DocsCommentsReplyCmd `cmd:"" name:"reply" aliases:"respond" help:"Reply to a comment"` + Resolve DocsCommentsResolveCmd `cmd:"" name:"resolve" help:"Resolve a comment (mark as done)"` + Delete DocsCommentsDeleteCmd `cmd:"" name:"delete" aliases:"rm,del,remove" help:"Delete a comment"` +} diff --git a/internal/cmd/docs_types.go b/internal/cmd/docs_types.go new file mode 100644 index 00000000..cf4dfd97 --- /dev/null +++ b/internal/cmd/docs_types.go @@ -0,0 +1,22 @@ +//go:build !safety_profile + +package cmd + +type DocsCmd struct { + Export DocsExportCmd `cmd:"" name:"export" aliases:"download,dl" help:"Export a Google Doc (pdf|docx|txt)"` + Info DocsInfoCmd `cmd:"" name:"info" aliases:"get,show" help:"Get Google Doc metadata"` + Create DocsCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a Google Doc"` + Copy DocsCopyCmd `cmd:"" name:"copy" aliases:"cp,duplicate" help:"Copy a Google Doc"` + Cat DocsCatCmd `cmd:"" name:"cat" aliases:"text,read" help:"Print a Google Doc as plain text"` + Comments DocsCommentsCmd `cmd:"" name:"comments" help:"Manage comments on a Google Doc"` + ListTabs DocsListTabsCmd `cmd:"" name:"list-tabs" help:"List all tabs in a Google Doc"` + Write DocsWriteCmd `cmd:"" name:"write" help:"Write content to a Google Doc"` + Insert DocsInsertCmd `cmd:"" name:"insert" help:"Insert text at a specific position"` + Delete DocsDeleteCmd `cmd:"" name:"delete" help:"Delete text range from document"` + FindReplace DocsFindReplaceCmd `cmd:"" name:"find-replace" help:"Find and replace text in document"` + Update DocsUpdateCmd `cmd:"" name:"update" help:"Update content in a Google Doc"` + Edit DocsEditCmd `cmd:"" name:"edit" help:"Find and replace text in a Google Doc"` + Sed DocsSedCmd `cmd:"" name:"sed" help:"Regex find/replace (sed-style: s/pattern/replacement/g)"` + Clear DocsClearCmd `cmd:"" name:"clear" help:"Clear all content from a Google Doc"` + Structure DocsStructureCmd `cmd:"" name:"structure" aliases:"struct" help:"Show document structure with numbered paragraphs"` +} diff --git a/internal/cmd/drive.go b/internal/cmd/drive.go index ee062ab6..6e172d6e 100644 --- a/internal/cmd/drive.go +++ b/internal/cmd/drive.go @@ -60,25 +60,6 @@ const ( drivePermRoleWriter = "writer" ) -type DriveCmd struct { - Ls DriveLsCmd `cmd:"" name:"ls" help:"List files in a folder (default: root)"` - Search DriveSearchCmd `cmd:"" name:"search" help:"Full-text search across Drive"` - Get DriveGetCmd `cmd:"" name:"get" help:"Get file metadata"` - Download DriveDownloadCmd `cmd:"" name:"download" help:"Download a file (exports Google Docs formats)"` - Copy DriveCopyCmd `cmd:"" name:"copy" help:"Copy a file"` - Upload DriveUploadCmd `cmd:"" name:"upload" help:"Upload a file"` - Mkdir DriveMkdirCmd `cmd:"" name:"mkdir" help:"Create a folder"` - Delete DriveDeleteCmd `cmd:"" name:"delete" help:"Move a file to trash (use --permanent to delete forever)" aliases:"rm,del"` - Move DriveMoveCmd `cmd:"" name:"move" help:"Move a file to a different folder"` - Rename DriveRenameCmd `cmd:"" name:"rename" help:"Rename a file or folder"` - Share DriveShareCmd `cmd:"" name:"share" help:"Share a file or folder"` - Unshare DriveUnshareCmd `cmd:"" name:"unshare" help:"Remove a permission from a file"` - Permissions DrivePermissionsCmd `cmd:"" name:"permissions" help:"List permissions on a file"` - URL DriveURLCmd `cmd:"" name:"url" help:"Print web URLs for files"` - Comments DriveCommentsCmd `cmd:"" name:"comments" help:"Manage comments on files"` - Drives DriveDrivesCmd `cmd:"" name:"drives" help:"List shared drives (Team Drives)"` -} - type DriveLsCmd struct { Max int64 `name:"max" aliases:"limit" help:"Max results" default:"20"` Page string `name:"page" aliases:"cursor" help:"Page token"` diff --git a/internal/cmd/drive_comments.go b/internal/cmd/drive_comments.go index 030c84fa..91b3f33b 100644 --- a/internal/cmd/drive_comments.go +++ b/internal/cmd/drive_comments.go @@ -13,14 +13,6 @@ import ( ) // DriveCommentsCmd is the parent command for comments subcommands -type DriveCommentsCmd struct { - List DriveCommentsListCmd `cmd:"" name:"list" aliases:"ls" help:"List comments on a file"` - Get DriveCommentsGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a comment by ID"` - Create DriveCommentsCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a comment on a file"` - Update DriveCommentsUpdateCmd `cmd:"" name:"update" aliases:"edit,set" help:"Update a comment"` - Delete DriveCommentsDeleteCmd `cmd:"" name:"delete" aliases:"rm,del,remove" help:"Delete a comment"` - Reply DriveCommentReplyCmd `cmd:"" name:"reply" aliases:"respond" help:"Reply to a comment"` -} type DriveCommentsListCmd struct { FileID string `arg:"" name:"fileId" help:"File ID"` diff --git a/internal/cmd/drive_comments_types.go b/internal/cmd/drive_comments_types.go new file mode 100644 index 00000000..d8de8011 --- /dev/null +++ b/internal/cmd/drive_comments_types.go @@ -0,0 +1,12 @@ +//go:build !safety_profile + +package cmd + +type DriveCommentsCmd struct { + List DriveCommentsListCmd `cmd:"" name:"list" aliases:"ls" help:"List comments on a file"` + Get DriveCommentsGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a comment by ID"` + Create DriveCommentsCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a comment on a file"` + Update DriveCommentsUpdateCmd `cmd:"" name:"update" aliases:"edit,set" help:"Update a comment"` + Delete DriveCommentsDeleteCmd `cmd:"" name:"delete" aliases:"rm,del,remove" help:"Delete a comment"` + Reply DriveCommentReplyCmd `cmd:"" name:"reply" aliases:"respond" help:"Reply to a comment"` +} diff --git a/internal/cmd/drive_types.go b/internal/cmd/drive_types.go new file mode 100644 index 00000000..acb954ba --- /dev/null +++ b/internal/cmd/drive_types.go @@ -0,0 +1,22 @@ +//go:build !safety_profile + +package cmd + +type DriveCmd struct { + Ls DriveLsCmd `cmd:"" name:"ls" help:"List files in a folder (default: root)"` + Search DriveSearchCmd `cmd:"" name:"search" help:"Full-text search across Drive"` + Get DriveGetCmd `cmd:"" name:"get" help:"Get file metadata"` + Download DriveDownloadCmd `cmd:"" name:"download" help:"Download a file (exports Google Docs formats)"` + Copy DriveCopyCmd `cmd:"" name:"copy" help:"Copy a file"` + Upload DriveUploadCmd `cmd:"" name:"upload" help:"Upload a file"` + Mkdir DriveMkdirCmd `cmd:"" name:"mkdir" help:"Create a folder"` + Delete DriveDeleteCmd `cmd:"" name:"delete" help:"Move a file to trash (use --permanent to delete forever)" aliases:"rm,del"` + Move DriveMoveCmd `cmd:"" name:"move" help:"Move a file to a different folder"` + Rename DriveRenameCmd `cmd:"" name:"rename" help:"Rename a file or folder"` + Share DriveShareCmd `cmd:"" name:"share" help:"Share a file or folder"` + Unshare DriveUnshareCmd `cmd:"" name:"unshare" help:"Remove a permission from a file"` + Permissions DrivePermissionsCmd `cmd:"" name:"permissions" help:"List permissions on a file"` + URL DriveURLCmd `cmd:"" name:"url" help:"Print web URLs for files"` + Comments DriveCommentsCmd `cmd:"" name:"comments" help:"Manage comments on files"` + Drives DriveDrivesCmd `cmd:"" name:"drives" help:"List shared drives (Team Drives)"` +} diff --git a/internal/cmd/forms.go b/internal/cmd/forms.go index c1d89843..9d0d701c 100644 --- a/internal/cmd/forms.go +++ b/internal/cmd/forms.go @@ -15,17 +15,6 @@ import ( var newFormsService = googleapi.NewForms -type FormsCmd struct { - Get FormsGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a form"` - Create FormsCreateCmd `cmd:"" name:"create" aliases:"new" help:"Create a form"` - Responses FormsResponsesCmd `cmd:"" name:"responses" help:"Form responses"` -} - -type FormsResponsesCmd struct { - List FormsResponsesListCmd `cmd:"" name:"list" aliases:"ls" help:"List form responses"` - Get FormsResponseGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a form response"` -} - type FormsGetCmd struct { FormID string `arg:"" name:"formId" help:"Form ID"` } diff --git a/internal/cmd/forms_types.go b/internal/cmd/forms_types.go new file mode 100644 index 00000000..9db727d6 --- /dev/null +++ b/internal/cmd/forms_types.go @@ -0,0 +1,14 @@ +//go:build !safety_profile + +package cmd + +type FormsCmd struct { + Get FormsGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a form"` + Create FormsCreateCmd `cmd:"" name:"create" aliases:"new" help:"Create a form"` + Responses FormsResponsesCmd `cmd:"" name:"responses" help:"Form responses"` +} + +type FormsResponsesCmd struct { + List FormsResponsesListCmd `cmd:"" name:"list" aliases:"ls" help:"List form responses"` + Get FormsResponseGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a form response"` +} diff --git a/internal/cmd/gmail.go b/internal/cmd/gmail.go index b8fb1070..e01fd812 100644 --- a/internal/cmd/gmail.go +++ b/internal/cmd/gmail.go @@ -11,55 +11,10 @@ import ( "google.golang.org/api/gmail/v1" - "github.com/steipete/gogcli/internal/googleapi" "github.com/steipete/gogcli/internal/outfmt" "github.com/steipete/gogcli/internal/ui" ) -var newGmailService = googleapi.NewGmail - -type GmailCmd struct { - Search GmailSearchCmd `cmd:"" name:"search" aliases:"find,query,ls,list" group:"Read" help:"Search threads using Gmail query syntax"` - Messages GmailMessagesCmd `cmd:"" name:"messages" aliases:"message,msg,msgs" group:"Read" help:"Message operations"` - Thread GmailThreadCmd `cmd:"" name:"thread" aliases:"threads,read" group:"Organize" help:"Thread operations (get, modify)"` - Get GmailGetCmd `cmd:"" name:"get" aliases:"info,show" group:"Read" help:"Get a message (full|metadata|raw)"` - Attachment GmailAttachmentCmd `cmd:"" name:"attachment" group:"Read" help:"Download a single attachment"` - URL GmailURLCmd `cmd:"" name:"url" group:"Read" help:"Print Gmail web URLs for threads"` - History GmailHistoryCmd `cmd:"" name:"history" group:"Read" help:"Gmail history"` - - Labels GmailLabelsCmd `cmd:"" name:"labels" aliases:"label" group:"Organize" help:"Label operations"` - Batch GmailBatchCmd `cmd:"" name:"batch" group:"Organize" help:"Batch operations"` - Archive GmailArchiveCmd `cmd:"" name:"archive" group:"Organize" help:"Archive messages (remove from inbox)"` - Read GmailReadCmd `cmd:"" name:"mark-read" aliases:"read-messages" group:"Organize" help:"Mark messages as read"` - Unread GmailUnreadCmd `cmd:"" name:"unread" aliases:"mark-unread" group:"Organize" help:"Mark messages as unread"` - Trash GmailTrashMsgCmd `cmd:"" name:"trash" group:"Organize" help:"Move messages to trash"` - - Send GmailSendCmd `cmd:"" name:"send" group:"Write" help:"Send an email"` - Track GmailTrackCmd `cmd:"" name:"track" group:"Write" help:"Email open tracking"` - Drafts GmailDraftsCmd `cmd:"" name:"drafts" aliases:"draft" group:"Write" help:"Draft operations"` - - Settings GmailSettingsCmd `cmd:"" name:"settings" group:"Admin" help:"Settings and admin"` - - // Kept for backwards-compatibility; hidden from default help. - Watch GmailWatchCmd `cmd:"" name:"watch" hidden:"" help:"Manage Gmail watch"` - AutoForward GmailAutoForwardCmd `cmd:"" name:"autoforward" hidden:"" help:"Auto-forwarding settings"` - Delegates GmailDelegatesCmd `cmd:"" name:"delegates" hidden:"" help:"Delegate operations"` - Filters GmailFiltersCmd `cmd:"" name:"filters" hidden:"" help:"Filter operations"` - Forwarding GmailForwardingCmd `cmd:"" name:"forwarding" hidden:"" help:"Forwarding addresses"` - SendAs GmailSendAsCmd `cmd:"" name:"sendas" hidden:"" help:"Send-as settings"` - Vacation GmailVacationCmd `cmd:"" name:"vacation" hidden:"" help:"Vacation responder"` -} - -type GmailSettingsCmd struct { - Filters GmailFiltersCmd `cmd:"" name:"filters" group:"Organize" help:"Filter operations"` - Delegates GmailDelegatesCmd `cmd:"" name:"delegates" group:"Admin" help:"Delegate operations"` - Forwarding GmailForwardingCmd `cmd:"" name:"forwarding" group:"Admin" help:"Forwarding addresses"` - AutoForward GmailAutoForwardCmd `cmd:"" name:"autoforward" group:"Admin" help:"Auto-forwarding settings"` - SendAs GmailSendAsCmd `cmd:"" name:"sendas" group:"Admin" help:"Send-as settings"` - Vacation GmailVacationCmd `cmd:"" name:"vacation" group:"Admin" help:"Vacation responder"` - Watch GmailWatchCmd `cmd:"" name:"watch" group:"Admin" help:"Manage Gmail watch"` -} - type GmailSearchCmd struct { Query []string `arg:"" name:"query" help:"Search query"` Max int64 `name:"max" aliases:"limit" help:"Max results" default:"10"` diff --git a/internal/cmd/gmail_batch.go b/internal/cmd/gmail_batch.go index 2d46c604..64767dd6 100644 --- a/internal/cmd/gmail_batch.go +++ b/internal/cmd/gmail_batch.go @@ -11,11 +11,6 @@ import ( "github.com/steipete/gogcli/internal/ui" ) -type GmailBatchCmd struct { - Delete GmailBatchDeleteCmd `cmd:"" name:"delete" aliases:"rm,del,remove" help:"Permanently delete multiple messages"` - Modify GmailBatchModifyCmd `cmd:"" name:"modify" aliases:"update,edit,set" help:"Modify labels on multiple messages"` -} - type GmailBatchDeleteCmd struct { MessageIDs []string `arg:"" name:"messageId" help:"Message IDs"` } diff --git a/internal/cmd/gmail_batch_types.go b/internal/cmd/gmail_batch_types.go new file mode 100644 index 00000000..570b7933 --- /dev/null +++ b/internal/cmd/gmail_batch_types.go @@ -0,0 +1,8 @@ +//go:build !safety_profile + +package cmd + +type GmailBatchCmd struct { + Delete GmailBatchDeleteCmd `cmd:"" name:"delete" aliases:"rm,del,remove" help:"Permanently delete multiple messages"` + Modify GmailBatchModifyCmd `cmd:"" name:"modify" aliases:"update,edit,set" help:"Modify labels on multiple messages"` +} diff --git a/internal/cmd/gmail_drafts.go b/internal/cmd/gmail_drafts.go index f601c58a..ef3c01a7 100644 --- a/internal/cmd/gmail_drafts.go +++ b/internal/cmd/gmail_drafts.go @@ -14,15 +14,6 @@ import ( "github.com/steipete/gogcli/internal/ui" ) -type GmailDraftsCmd struct { - List GmailDraftsListCmd `cmd:"" name:"list" aliases:"ls" help:"List drafts"` - Get GmailDraftsGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get draft details"` - Delete GmailDraftsDeleteCmd `cmd:"" name:"delete" aliases:"rm,del,remove" help:"Delete a draft"` - Send GmailDraftsSendCmd `cmd:"" name:"send" aliases:"post" help:"Send a draft"` - Create GmailDraftsCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a draft"` - Update GmailDraftsUpdateCmd `cmd:"" name:"update" aliases:"edit,set" help:"Update a draft"` -} - type GmailDraftsListCmd struct { Max int64 `name:"max" aliases:"limit" help:"Max results" default:"20"` Page string `name:"page" aliases:"cursor" help:"Page token"` diff --git a/internal/cmd/gmail_drafts_types.go b/internal/cmd/gmail_drafts_types.go new file mode 100644 index 00000000..1d1dbba2 --- /dev/null +++ b/internal/cmd/gmail_drafts_types.go @@ -0,0 +1,12 @@ +//go:build !safety_profile + +package cmd + +type GmailDraftsCmd struct { + List GmailDraftsListCmd `cmd:"" name:"list" aliases:"ls" help:"List drafts"` + Get GmailDraftsGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get draft details"` + Delete GmailDraftsDeleteCmd `cmd:"" name:"delete" aliases:"rm,del,remove" help:"Delete a draft"` + Send GmailDraftsSendCmd `cmd:"" name:"send" aliases:"post" help:"Send a draft"` + Create GmailDraftsCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a draft"` + Update GmailDraftsUpdateCmd `cmd:"" name:"update" aliases:"edit,set" help:"Update a draft"` +} diff --git a/internal/cmd/gmail_labels.go b/internal/cmd/gmail_labels.go index bb54835a..7a9a8287 100644 --- a/internal/cmd/gmail_labels.go +++ b/internal/cmd/gmail_labels.go @@ -12,14 +12,6 @@ import ( "github.com/steipete/gogcli/internal/ui" ) -type GmailLabelsCmd struct { - List GmailLabelsListCmd `cmd:"" name:"list" aliases:"ls" help:"List labels"` - Get GmailLabelsGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get label details (including counts)"` - Create GmailLabelsCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a new label"` - Modify GmailLabelsModifyCmd `cmd:"" name:"modify" aliases:"update,edit,set" help:"Modify labels on threads"` - Delete GmailLabelsDeleteCmd `cmd:"" name:"delete" aliases:"rm,del" help:"Delete a label"` -} - type GmailLabelsGetCmd struct { Label string `arg:"" name:"labelIdOrName" help:"Label ID or name"` } diff --git a/internal/cmd/gmail_labels_types.go b/internal/cmd/gmail_labels_types.go new file mode 100644 index 00000000..3a825363 --- /dev/null +++ b/internal/cmd/gmail_labels_types.go @@ -0,0 +1,11 @@ +//go:build !safety_profile + +package cmd + +type GmailLabelsCmd struct { + List GmailLabelsListCmd `cmd:"" name:"list" aliases:"ls" help:"List labels"` + Get GmailLabelsGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get label details (including counts)"` + Create GmailLabelsCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a new label"` + Modify GmailLabelsModifyCmd `cmd:"" name:"modify" aliases:"update,edit,set" help:"Modify labels on threads"` + Delete GmailLabelsDeleteCmd `cmd:"" name:"delete" aliases:"rm,del" help:"Delete a label"` +} diff --git a/internal/cmd/gmail_thread.go b/internal/cmd/gmail_thread.go index 37f18d5f..8e7ed1fa 100644 --- a/internal/cmd/gmail_thread.go +++ b/internal/cmd/gmail_thread.go @@ -47,12 +47,6 @@ func stripHTMLTags(s string) string { return strings.TrimSpace(s) } -type GmailThreadCmd struct { - Get GmailThreadGetCmd `cmd:"" name:"get" aliases:"info,show" default:"withargs" help:"Get a thread with all messages (optionally download attachments)"` - Modify GmailThreadModifyCmd `cmd:"" name:"modify" aliases:"update,edit,set" help:"Modify labels on all messages in a thread"` - Attachments GmailThreadAttachmentsCmd `cmd:"" name:"attachments" aliases:"files" help:"List all attachments in a thread"` -} - type GmailThreadGetCmd struct { ThreadID string `arg:"" name:"threadId" help:"Thread ID"` Download bool `name:"download" help:"Download attachments"` diff --git a/internal/cmd/gmail_thread_types.go b/internal/cmd/gmail_thread_types.go new file mode 100644 index 00000000..ee37aa33 --- /dev/null +++ b/internal/cmd/gmail_thread_types.go @@ -0,0 +1,9 @@ +//go:build !safety_profile + +package cmd + +type GmailThreadCmd struct { + Get GmailThreadGetCmd `cmd:"" name:"get" aliases:"info,show" default:"withargs" help:"Get a thread with all messages (optionally download attachments)"` + Modify GmailThreadModifyCmd `cmd:"" name:"modify" aliases:"update,edit,set" help:"Modify labels on all messages in a thread"` + Attachments GmailThreadAttachmentsCmd `cmd:"" name:"attachments" aliases:"files" help:"List all attachments in a thread"` +} diff --git a/internal/cmd/gmail_types.go b/internal/cmd/gmail_types.go new file mode 100644 index 00000000..1e4062e4 --- /dev/null +++ b/internal/cmd/gmail_types.go @@ -0,0 +1,51 @@ +//go:build !safety_profile + +package cmd + +import ( + "github.com/steipete/gogcli/internal/googleapi" +) + +var newGmailService = googleapi.NewGmail + +type GmailCmd struct { + Search GmailSearchCmd `cmd:"" name:"search" aliases:"find,query,ls,list" group:"Read" help:"Search threads using Gmail query syntax"` + Messages GmailMessagesCmd `cmd:"" name:"messages" aliases:"message,msg,msgs" group:"Read" help:"Message operations"` + Thread GmailThreadCmd `cmd:"" name:"thread" aliases:"threads,read" group:"Organize" help:"Thread operations (get, modify)"` + Get GmailGetCmd `cmd:"" name:"get" aliases:"info,show" group:"Read" help:"Get a message (full|metadata|raw)"` + Attachment GmailAttachmentCmd `cmd:"" name:"attachment" group:"Read" help:"Download a single attachment"` + URL GmailURLCmd `cmd:"" name:"url" group:"Read" help:"Print Gmail web URLs for threads"` + History GmailHistoryCmd `cmd:"" name:"history" group:"Read" help:"Gmail history"` + + Labels GmailLabelsCmd `cmd:"" name:"labels" aliases:"label" group:"Organize" help:"Label operations"` + Batch GmailBatchCmd `cmd:"" name:"batch" group:"Organize" help:"Batch operations"` + Archive GmailArchiveCmd `cmd:"" name:"archive" group:"Organize" help:"Archive messages (remove from inbox)"` + Read GmailReadCmd `cmd:"" name:"mark-read" aliases:"read-messages" group:"Organize" help:"Mark messages as read"` + Unread GmailUnreadCmd `cmd:"" name:"unread" aliases:"mark-unread" group:"Organize" help:"Mark messages as unread"` + Trash GmailTrashMsgCmd `cmd:"" name:"trash" group:"Organize" help:"Move messages to trash"` + + Send GmailSendCmd `cmd:"" name:"send" group:"Write" help:"Send an email"` + Track GmailTrackCmd `cmd:"" name:"track" group:"Write" help:"Email open tracking"` + Drafts GmailDraftsCmd `cmd:"" name:"drafts" aliases:"draft" group:"Write" help:"Draft operations"` + + Settings GmailSettingsCmd `cmd:"" name:"settings" group:"Admin" help:"Settings and admin"` + + // Kept for backwards-compatibility; hidden from default help. + Watch GmailWatchCmd `cmd:"" name:"watch" hidden:"" help:"Manage Gmail watch"` + AutoForward GmailAutoForwardCmd `cmd:"" name:"autoforward" hidden:"" help:"Auto-forwarding settings"` + Delegates GmailDelegatesCmd `cmd:"" name:"delegates" hidden:"" help:"Delegate operations"` + Filters GmailFiltersCmd `cmd:"" name:"filters" hidden:"" help:"Filter operations"` + Forwarding GmailForwardingCmd `cmd:"" name:"forwarding" hidden:"" help:"Forwarding addresses"` + SendAs GmailSendAsCmd `cmd:"" name:"sendas" hidden:"" help:"Send-as settings"` + Vacation GmailVacationCmd `cmd:"" name:"vacation" hidden:"" help:"Vacation responder"` +} + +type GmailSettingsCmd struct { + Filters GmailFiltersCmd `cmd:"" name:"filters" group:"Organize" help:"Filter operations"` + Delegates GmailDelegatesCmd `cmd:"" name:"delegates" group:"Admin" help:"Delegate operations"` + Forwarding GmailForwardingCmd `cmd:"" name:"forwarding" group:"Admin" help:"Forwarding addresses"` + AutoForward GmailAutoForwardCmd `cmd:"" name:"autoforward" group:"Admin" help:"Auto-forwarding settings"` + SendAs GmailSendAsCmd `cmd:"" name:"sendas" group:"Admin" help:"Send-as settings"` + Vacation GmailVacationCmd `cmd:"" name:"vacation" group:"Admin" help:"Vacation responder"` + Watch GmailWatchCmd `cmd:"" name:"watch" group:"Admin" help:"Manage Gmail watch"` +} diff --git a/internal/cmd/groups.go b/internal/cmd/groups.go index e6b2648e..e3b308b0 100644 --- a/internal/cmd/groups.go +++ b/internal/cmd/groups.go @@ -23,11 +23,6 @@ const ( groupRoleMember = "MEMBER" ) -type GroupsCmd struct { - List GroupsListCmd `cmd:"" name:"list" aliases:"ls" help:"List groups you belong to"` - Members GroupsMembersCmd `cmd:"" name:"members" help:"List members of a group"` -} - type GroupsListCmd struct { Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"` Page string `name:"page" aliases:"cursor" help:"Page token"` diff --git a/internal/cmd/groups_types.go b/internal/cmd/groups_types.go new file mode 100644 index 00000000..fb012a51 --- /dev/null +++ b/internal/cmd/groups_types.go @@ -0,0 +1,8 @@ +//go:build !safety_profile + +package cmd + +type GroupsCmd struct { + List GroupsListCmd `cmd:"" name:"list" aliases:"ls" help:"List groups you belong to"` + Members GroupsMembersCmd `cmd:"" name:"members" help:"List members of a group"` +} diff --git a/internal/cmd/keep.go b/internal/cmd/keep.go index ec67360d..6b6c68c7 100644 --- a/internal/cmd/keep.go +++ b/internal/cmd/keep.go @@ -18,16 +18,6 @@ import ( var newKeepServiceWithSA = googleapi.NewKeepWithServiceAccount -type KeepCmd struct { - ServiceAccount string `name:"service-account" help:"Path to service account JSON file"` - Impersonate string `name:"impersonate" help:"Email to impersonate (required with service-account)"` - - List KeepListCmd `cmd:"" default:"withargs" help:"List notes"` - Get KeepGetCmd `cmd:"" name:"get" help:"Get a note"` - Search KeepSearchCmd `cmd:"" name:"search" help:"Search notes by text (client-side)"` - Attachment KeepAttachmentCmd `cmd:"" name:"attachment" help:"Download an attachment"` -} - type KeepListCmd struct { Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"` Page string `name:"page" aliases:"cursor" help:"Page token"` diff --git a/internal/cmd/keep_types.go b/internal/cmd/keep_types.go new file mode 100644 index 00000000..3bc933b0 --- /dev/null +++ b/internal/cmd/keep_types.go @@ -0,0 +1,13 @@ +//go:build !safety_profile + +package cmd + +type KeepCmd struct { + ServiceAccount string `name:"service-account" help:"Path to service account JSON file"` + Impersonate string `name:"impersonate" help:"Email to impersonate (required with service-account)"` + + List KeepListCmd `cmd:"" default:"withargs" help:"List notes"` + Get KeepGetCmd `cmd:"" name:"get" help:"Get a note"` + Search KeepSearchCmd `cmd:"" name:"search" help:"Search notes by text (client-side)"` + Attachment KeepAttachmentCmd `cmd:"" name:"attachment" help:"Download an attachment"` +} diff --git a/internal/cmd/people.go b/internal/cmd/people.go index 3d7274db..613b7ea6 100644 --- a/internal/cmd/people.go +++ b/internal/cmd/people.go @@ -8,13 +8,6 @@ import ( "github.com/steipete/gogcli/internal/ui" ) -type PeopleCmd struct { - Me PeopleMeCmd `cmd:"" name:"me" help:"Show your profile (people/me)"` - Get PeopleGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a user profile by ID"` - Search PeopleSearchCmd `cmd:"" name:"search" aliases:"find,query" help:"Search the Workspace directory"` - Relations PeopleRelationsCmd `cmd:"" name:"relations" help:"Get user relations"` -} - type PeopleMeCmd struct{} func (c *PeopleMeCmd) Run(ctx context.Context, flags *RootFlags) error { diff --git a/internal/cmd/people_types.go b/internal/cmd/people_types.go new file mode 100644 index 00000000..ae18adbc --- /dev/null +++ b/internal/cmd/people_types.go @@ -0,0 +1,10 @@ +//go:build !safety_profile + +package cmd + +type PeopleCmd struct { + Me PeopleMeCmd `cmd:"" name:"me" help:"Show your profile (people/me)"` + Get PeopleGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a user profile by ID"` + Search PeopleSearchCmd `cmd:"" name:"search" aliases:"find,query" help:"Search the Workspace directory"` + Relations PeopleRelationsCmd `cmd:"" name:"relations" help:"Get user relations"` +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 80547ede..593e5939 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -42,50 +42,6 @@ type RootFlags struct { Verbose bool `help:"Enable verbose logging" short:"v"` } -type CLI struct { - RootFlags `embed:""` - - Version kong.VersionFlag `help:"Print version and exit"` - - // Action-first desire paths (agent-friendly shortcuts). - Send GmailSendCmd `cmd:"" name:"send" help:"Send an email (alias for 'gmail send')"` - Ls DriveLsCmd `cmd:"" name:"ls" aliases:"list" help:"List Drive files (alias for 'drive ls')"` - Search DriveSearchCmd `cmd:"" name:"search" aliases:"find" help:"Search Drive files (alias for 'drive search')"` - Open OpenCmd `cmd:"" name:"open" aliases:"browse" help:"Print a best-effort web URL for a Google URL/ID (offline)"` - Download DriveDownloadCmd `cmd:"" name:"download" aliases:"dl" help:"Download a Drive file (alias for 'drive download')"` - Upload DriveUploadCmd `cmd:"" name:"upload" aliases:"up,put" help:"Upload a file to Drive (alias for 'drive upload')"` - Login AuthAddCmd `cmd:"" name:"login" help:"Authorize and store a refresh token (alias for 'auth add')"` - Logout AuthRemoveCmd `cmd:"" name:"logout" help:"Remove a stored refresh token (alias for 'auth remove')"` - Status AuthStatusCmd `cmd:"" name:"status" aliases:"st" help:"Show auth/config status (alias for 'auth status')"` - Me PeopleMeCmd `cmd:"" name:"me" help:"Show your profile (alias for 'people me')"` - Whoami PeopleMeCmd `cmd:"" name:"whoami" aliases:"who-am-i" help:"Show your profile (alias for 'people me')"` - - Auth AuthCmd `cmd:"" help:"Auth and credentials"` - Groups GroupsCmd `cmd:"" aliases:"group" help:"Google Groups"` - Drive DriveCmd `cmd:"" aliases:"drv" help:"Google Drive"` - Docs DocsCmd `cmd:"" aliases:"doc" help:"Google Docs (export via Drive)"` - Slides SlidesCmd `cmd:"" aliases:"slide" help:"Google Slides"` - Calendar CalendarCmd `cmd:"" aliases:"cal" help:"Google Calendar"` - Classroom ClassroomCmd `cmd:"" aliases:"class" help:"Google Classroom"` - Time TimeCmd `cmd:"" help:"Local time utilities"` - Gmail GmailCmd `cmd:"" aliases:"mail,email" help:"Gmail"` - Chat ChatCmd `cmd:"" help:"Google Chat"` - Contacts ContactsCmd `cmd:"" aliases:"contact" help:"Google Contacts"` - Tasks TasksCmd `cmd:"" aliases:"task" help:"Google Tasks"` - People PeopleCmd `cmd:"" aliases:"person" help:"Google People"` - Keep KeepCmd `cmd:"" help:"Google Keep (Workspace only)"` - Sheets SheetsCmd `cmd:"" aliases:"sheet" help:"Google Sheets"` - Forms FormsCmd `cmd:"" aliases:"form" help:"Google Forms"` - AppScript AppScriptCmd `cmd:"" name:"appscript" aliases:"script,apps-script" help:"Google Apps Script"` - Config ConfigCmd `cmd:"" help:"Manage configuration"` - ExitCodes AgentExitCodesCmd `cmd:"" name:"exit-codes" aliases:"exitcodes" help:"Print stable exit codes (alias for 'agent exit-codes')"` - Agent AgentCmd `cmd:"" help:"Agent-friendly helpers"` - Schema SchemaCmd `cmd:"" help:"Machine-readable command/flag schema" aliases:"help-json,helpjson"` - VersionCmd VersionCmd `cmd:"" name:"version" help:"Print version"` - Completion CompletionCmd `cmd:"" help:"Generate shell completion scripts"` - Complete CompletionInternalCmd `cmd:"" name:"__complete" hidden:"" help:"Internal completion helper"` -} - type exitPanic struct{ code int } func Execute(args []string) (err error) { diff --git a/internal/cmd/root_types.go b/internal/cmd/root_types.go new file mode 100644 index 00000000..20b3e2a3 --- /dev/null +++ b/internal/cmd/root_types.go @@ -0,0 +1,51 @@ +//go:build !safety_profile + +package cmd + +import ( + "github.com/alecthomas/kong" +) + +type CLI struct { + RootFlags `embed:""` + + Version kong.VersionFlag `help:"Print version and exit"` + + // Action-first desire paths (agent-friendly shortcuts). + Send GmailSendCmd `cmd:"" name:"send" help:"Send an email (alias for 'gmail send')"` + Ls DriveLsCmd `cmd:"" name:"ls" aliases:"list" help:"List Drive files (alias for 'drive ls')"` + Search DriveSearchCmd `cmd:"" name:"search" aliases:"find" help:"Search Drive files (alias for 'drive search')"` + Open OpenCmd `cmd:"" name:"open" aliases:"browse" help:"Print a best-effort web URL for a Google URL/ID (offline)"` + Download DriveDownloadCmd `cmd:"" name:"download" aliases:"dl" help:"Download a Drive file (alias for 'drive download')"` + Upload DriveUploadCmd `cmd:"" name:"upload" aliases:"up,put" help:"Upload a file to Drive (alias for 'drive upload')"` + Login AuthAddCmd `cmd:"" name:"login" help:"Authorize and store a refresh token (alias for 'auth add')"` + Logout AuthRemoveCmd `cmd:"" name:"logout" help:"Remove a stored refresh token (alias for 'auth remove')"` + Status AuthStatusCmd `cmd:"" name:"status" aliases:"st" help:"Show auth/config status (alias for 'auth status')"` + Me PeopleMeCmd `cmd:"" name:"me" help:"Show your profile (alias for 'people me')"` + Whoami PeopleMeCmd `cmd:"" name:"whoami" aliases:"who-am-i" help:"Show your profile (alias for 'people me')"` + + Auth AuthCmd `cmd:"" help:"Auth and credentials"` + Groups GroupsCmd `cmd:"" aliases:"group" help:"Google Groups"` + Drive DriveCmd `cmd:"" aliases:"drv" help:"Google Drive"` + Docs DocsCmd `cmd:"" aliases:"doc" help:"Google Docs (export via Drive)"` + Slides SlidesCmd `cmd:"" aliases:"slide" help:"Google Slides"` + Calendar CalendarCmd `cmd:"" aliases:"cal" help:"Google Calendar"` + Classroom ClassroomCmd `cmd:"" aliases:"class" help:"Google Classroom"` + Time TimeCmd `cmd:"" help:"Local time utilities"` + Gmail GmailCmd `cmd:"" aliases:"mail,email" help:"Gmail"` + Chat ChatCmd `cmd:"" help:"Google Chat"` + Contacts ContactsCmd `cmd:"" aliases:"contact" help:"Google Contacts"` + Tasks TasksCmd `cmd:"" aliases:"task" help:"Google Tasks"` + People PeopleCmd `cmd:"" aliases:"person" help:"Google People"` + Keep KeepCmd `cmd:"" help:"Google Keep (Workspace only)"` + Sheets SheetsCmd `cmd:"" aliases:"sheet" help:"Google Sheets"` + Forms FormsCmd `cmd:"" aliases:"form" help:"Google Forms"` + AppScript AppScriptCmd `cmd:"" name:"appscript" aliases:"script,apps-script" help:"Google Apps Script"` + Config ConfigCmd `cmd:"" help:"Manage configuration"` + ExitCodes AgentExitCodesCmd `cmd:"" name:"exit-codes" aliases:"exitcodes" help:"Print stable exit codes (alias for 'agent exit-codes')"` + Agent AgentCmd `cmd:"" help:"Agent-friendly helpers"` + Schema SchemaCmd `cmd:"" help:"Machine-readable command/flag schema" aliases:"help-json,helpjson"` + VersionCmd VersionCmd `cmd:"" name:"version" help:"Print version"` + Completion CompletionCmd `cmd:"" help:"Generate shell completion scripts"` + Complete CompletionInternalCmd `cmd:"" name:"__complete" hidden:"" help:"Internal completion helper"` +} diff --git a/internal/cmd/sheets.go b/internal/cmd/sheets.go index 2d54e829..36792b8d 100644 --- a/internal/cmd/sheets.go +++ b/internal/cmd/sheets.go @@ -23,21 +23,6 @@ func cleanRange(r string) string { return strings.ReplaceAll(r, `\!`, "!") } -type SheetsCmd struct { - Get SheetsGetCmd `cmd:"" name:"get" aliases:"read,show" help:"Get values from a range"` - Update SheetsUpdateCmd `cmd:"" name:"update" aliases:"edit,set" help:"Update values in a range"` - Append SheetsAppendCmd `cmd:"" name:"append" aliases:"add" help:"Append values to a range"` - Insert SheetsInsertCmd `cmd:"" name:"insert" help:"Insert empty rows or columns into a sheet"` - Clear SheetsClearCmd `cmd:"" name:"clear" help:"Clear values in a range"` - Format SheetsFormatCmd `cmd:"" name:"format" help:"Apply cell formatting to a range"` - Notes SheetsNotesCmd `cmd:"" name:"notes" help:"Get cell notes from a range"` - Links SheetsLinksCmd `cmd:"" name:"links" aliases:"hyperlinks" help:"Get cell hyperlinks from a range"` - Metadata SheetsMetadataCmd `cmd:"" name:"metadata" aliases:"info" help:"Get spreadsheet metadata"` - Create SheetsCreateCmd `cmd:"" name:"create" aliases:"new" help:"Create a new spreadsheet"` - Copy SheetsCopyCmd `cmd:"" name:"copy" aliases:"cp,duplicate" help:"Copy a Google Sheet"` - Export SheetsExportCmd `cmd:"" name:"export" aliases:"download,dl" help:"Export a Google Sheet (pdf|xlsx|csv) via Drive"` -} - type SheetsExportCmd struct { SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` Output OutputPathFlag `embed:""` diff --git a/internal/cmd/sheets_types.go b/internal/cmd/sheets_types.go new file mode 100644 index 00000000..98e33342 --- /dev/null +++ b/internal/cmd/sheets_types.go @@ -0,0 +1,18 @@ +//go:build !safety_profile + +package cmd + +type SheetsCmd struct { + Get SheetsGetCmd `cmd:"" name:"get" aliases:"read,show" help:"Get values from a range"` + Update SheetsUpdateCmd `cmd:"" name:"update" aliases:"edit,set" help:"Update values in a range"` + Append SheetsAppendCmd `cmd:"" name:"append" aliases:"add" help:"Append values to a range"` + Insert SheetsInsertCmd `cmd:"" name:"insert" help:"Insert empty rows or columns into a sheet"` + Clear SheetsClearCmd `cmd:"" name:"clear" help:"Clear values in a range"` + Format SheetsFormatCmd `cmd:"" name:"format" help:"Apply cell formatting to a range"` + Notes SheetsNotesCmd `cmd:"" name:"notes" help:"Get cell notes from a range"` + Links SheetsLinksCmd `cmd:"" name:"links" aliases:"hyperlinks" help:"Get cell hyperlinks from a range"` + Metadata SheetsMetadataCmd `cmd:"" name:"metadata" aliases:"info" help:"Get spreadsheet metadata"` + Create SheetsCreateCmd `cmd:"" name:"create" aliases:"new" help:"Create a new spreadsheet"` + Copy SheetsCopyCmd `cmd:"" name:"copy" aliases:"cp,duplicate" help:"Copy a Google Sheet"` + Export SheetsExportCmd `cmd:"" name:"export" aliases:"download,dl" help:"Export a Google Sheet (pdf|xlsx|csv) via Drive"` +} diff --git a/internal/cmd/slides.go b/internal/cmd/slides.go index 9b6a9b1e..3f54705f 100644 --- a/internal/cmd/slides.go +++ b/internal/cmd/slides.go @@ -19,20 +19,6 @@ var debugSlides = false var newSlidesService = googleapi.NewSlides -type SlidesCmd struct { - Export SlidesExportCmd `cmd:"" name:"export" aliases:"download,dl" help:"Export a Google Slides deck (pdf|pptx)"` - Info SlidesInfoCmd `cmd:"" name:"info" aliases:"get,show" help:"Get Google Slides presentation metadata"` - Create SlidesCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a Google Slides presentation"` - CreateFromMarkdown SlidesCreateFromMarkdownCmd `cmd:"" name:"create-from-markdown" help:"Create a Google Slides presentation from markdown"` - Copy SlidesCopyCmd `cmd:"" name:"copy" aliases:"cp,duplicate" help:"Copy a Google Slides presentation"` - AddSlide SlidesAddSlideCmd `cmd:"" name:"add-slide" help:"Add a slide with a full-bleed image and optional speaker notes"` - ListSlides SlidesListSlidesCmd `cmd:"" name:"list-slides" help:"List all slides with their object IDs"` - DeleteSlide SlidesDeleteSlideCmd `cmd:"" name:"delete-slide" help:"Delete a slide by object ID"` - ReadSlide SlidesReadSlideCmd `cmd:"" name:"read-slide" help:"Read slide content: speaker notes, text elements, and images"` - UpdateNotes SlidesUpdateNotesCmd `cmd:"" name:"update-notes" help:"Update speaker notes on an existing slide"` - ReplaceSlide SlidesReplaceSlideCmd `cmd:"" name:"replace-slide" help:"Replace the image on an existing slide in-place"` -} - type SlidesExportCmd struct { PresentationID string `arg:"" name:"presentationId" help:"Presentation ID"` Output OutputPathFlag `embed:""` diff --git a/internal/cmd/slides_types.go b/internal/cmd/slides_types.go new file mode 100644 index 00000000..421f93fb --- /dev/null +++ b/internal/cmd/slides_types.go @@ -0,0 +1,17 @@ +//go:build !safety_profile + +package cmd + +type SlidesCmd struct { + Export SlidesExportCmd `cmd:"" name:"export" aliases:"download,dl" help:"Export a Google Slides deck (pdf|pptx)"` + Info SlidesInfoCmd `cmd:"" name:"info" aliases:"get,show" help:"Get Google Slides presentation metadata"` + Create SlidesCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a Google Slides presentation"` + CreateFromMarkdown SlidesCreateFromMarkdownCmd `cmd:"" name:"create-from-markdown" help:"Create a Google Slides presentation from markdown"` + Copy SlidesCopyCmd `cmd:"" name:"copy" aliases:"cp,duplicate" help:"Copy a Google Slides presentation"` + AddSlide SlidesAddSlideCmd `cmd:"" name:"add-slide" help:"Add a slide with a full-bleed image and optional speaker notes"` + ListSlides SlidesListSlidesCmd `cmd:"" name:"list-slides" help:"List all slides with their object IDs"` + DeleteSlide SlidesDeleteSlideCmd `cmd:"" name:"delete-slide" help:"Delete a slide by object ID"` + ReadSlide SlidesReadSlideCmd `cmd:"" name:"read-slide" help:"Read slide content: speaker notes, text elements, and images"` + UpdateNotes SlidesUpdateNotesCmd `cmd:"" name:"update-notes" help:"Update speaker notes on an existing slide"` + ReplaceSlide SlidesReplaceSlideCmd `cmd:"" name:"replace-slide" help:"Replace the image on an existing slide in-place"` +} diff --git a/internal/cmd/tasks.go b/internal/cmd/tasks.go index 2c479a34..da3af41b 100644 --- a/internal/cmd/tasks.go +++ b/internal/cmd/tasks.go @@ -5,15 +5,3 @@ import ( ) var newTasksService = googleapi.NewTasks - -type TasksCmd struct { - Lists TasksListsCmd `cmd:"" name:"lists" help:"List task lists"` - List TasksListCmd `cmd:"" name:"list" aliases:"ls" help:"List tasks"` - Get TasksGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a task"` - Add TasksAddCmd `cmd:"" name:"add" help:"Add a task" aliases:"create"` - Update TasksUpdateCmd `cmd:"" name:"update" aliases:"edit,set" help:"Update a task"` - Done TasksDoneCmd `cmd:"" name:"done" help:"Mark task completed" aliases:"complete"` - Undo TasksUndoCmd `cmd:"" name:"undo" help:"Mark task needs action" aliases:"uncomplete,undone"` - Delete TasksDeleteCmd `cmd:"" name:"delete" aliases:"rm,del,remove" help:"Delete a task"` - Clear TasksClearCmd `cmd:"" name:"clear" help:"Clear completed tasks"` -} diff --git a/internal/cmd/tasks_lists.go b/internal/cmd/tasks_lists.go index ed23fcd3..c220b903 100644 --- a/internal/cmd/tasks_lists.go +++ b/internal/cmd/tasks_lists.go @@ -12,11 +12,6 @@ import ( "github.com/steipete/gogcli/internal/ui" ) -type TasksListsCmd struct { - List TasksListsListCmd `cmd:"" default:"withargs" help:"List task lists"` - Create TasksListsCreateCmd `cmd:"" name:"create" help:"Create a task list" aliases:"add,new"` -} - type TasksListsListCmd struct { Max int64 `name:"max" aliases:"limit" help:"Max results (max allowed: 1000)" default:"100"` Page string `name:"page" aliases:"cursor" help:"Page token"` diff --git a/internal/cmd/tasks_lists_types.go b/internal/cmd/tasks_lists_types.go new file mode 100644 index 00000000..1cc0c155 --- /dev/null +++ b/internal/cmd/tasks_lists_types.go @@ -0,0 +1,8 @@ +//go:build !safety_profile + +package cmd + +type TasksListsCmd struct { + List TasksListsListCmd `cmd:"" default:"withargs" help:"List task lists"` + Create TasksListsCreateCmd `cmd:"" name:"create" help:"Create a task list" aliases:"add,new"` +} diff --git a/internal/cmd/tasks_types.go b/internal/cmd/tasks_types.go new file mode 100644 index 00000000..b2d2c2ce --- /dev/null +++ b/internal/cmd/tasks_types.go @@ -0,0 +1,15 @@ +//go:build !safety_profile + +package cmd + +type TasksCmd struct { + Lists TasksListsCmd `cmd:"" name:"lists" help:"List task lists"` + List TasksListCmd `cmd:"" name:"list" aliases:"ls" help:"List tasks"` + Get TasksGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a task"` + Add TasksAddCmd `cmd:"" name:"add" help:"Add a task" aliases:"create"` + Update TasksUpdateCmd `cmd:"" name:"update" aliases:"edit,set" help:"Update a task"` + Done TasksDoneCmd `cmd:"" name:"done" help:"Mark task completed" aliases:"complete"` + Undo TasksUndoCmd `cmd:"" name:"undo" help:"Mark task needs action" aliases:"uncomplete,undone"` + Delete TasksDeleteCmd `cmd:"" name:"delete" aliases:"rm,del,remove" help:"Delete a task"` + Clear TasksClearCmd `cmd:"" name:"clear" help:"Clear completed tasks"` +} diff --git a/internal/config/appname.go b/internal/config/appname.go new file mode 100644 index 00000000..d8206f28 --- /dev/null +++ b/internal/config/appname.go @@ -0,0 +1,5 @@ +//go:build !safety_profile + +package config + +const AppName = "gogcli" diff --git a/internal/config/appname_safe.go b/internal/config/appname_safe.go new file mode 100644 index 00000000..9042a334 --- /dev/null +++ b/internal/config/appname_safe.go @@ -0,0 +1,5 @@ +//go:build safety_profile + +package config + +const AppName = "gogcli-safe" diff --git a/internal/config/paths.go b/internal/config/paths.go index c1399f44..8b79fe3f 100644 --- a/internal/config/paths.go +++ b/internal/config/paths.go @@ -9,7 +9,6 @@ import ( "strings" ) -const AppName = "gogcli" func Dir() (string, error) { base, err := os.UserConfigDir() diff --git a/safety-profile.example.yaml b/safety-profile.example.yaml new file mode 100644 index 00000000..0d24e860 --- /dev/null +++ b/safety-profile.example.yaml @@ -0,0 +1,289 @@ +# Safety Profile for gogcli-safe +# +# This file controls which commands are compiled into the binary. +# Set a command to `false` to exclude it entirely from the build. +# Commands set to `false` won't appear in --help or be callable. +# +# IMPORTANT: Commands not listed in this file are DISABLED by default +# (fail-closed). To ensure all intended commands are included, copy +# safety-profiles/full.yaml and set unwanted commands to false. +# +# Usage: +# ./build-safe.sh # Uses this file +# ./build-safe.sh safety-profiles/readonly.yaml # Uses a preset +# +# Risk levels in comments: +# [safe] - Read-only, no side effects +# [low] - Creates content but recoverable (drafts, labels, tasks) +# [medium] - Modifies state (move, rename, update) +# [high] - Sends externally visible content (email, chat) +# [critical] - Permanent deletion or grants access to others + +gmail: + # Read commands [safe] + search: true + get: true + messages: true + attachment: true + url: true + history: true + + thread: + get: true # [safe] Read thread with messages + modify: true # [low] Add/remove labels on thread messages + attachments: true # [safe] List attachments in thread + + labels: + list: true # [safe] + get: true # [safe] + create: true # [low] Create new labels + modify: true # [low] Apply/remove labels on messages + delete: false # [critical] Delete a label + + batch: + modify: true # [low] Bulk label changes for triage + delete: false # [critical] Permanently delete multiple messages + + archive: true # [low] Archive messages (remove from inbox) + mark-read: true # [low] Mark messages as read + unread: true # [low] Mark messages as unread + trash: false # [critical] Move messages to trash + + # Write commands + send: false # [high] Send email + track: false # [medium] Email open tracking + + drafts: + list: true # [safe] + get: true # [safe] + create: true # [low] Create draft replies + update: true # [low] Update existing drafts + delete: false # [medium] Delete a draft + send: false # [high] Send a draft as email + + # Admin commands + settings: false # [critical] Entire settings tree (delegates, forwarding, filters, etc.) + + # Hidden legacy aliases (mirror settings subcommands) + watch: false # [critical] + autoforward: false # [critical] Auto-forwarding settings + delegates: false # [critical] Delegate access + filters: false # [critical] Mail filters + forwarding: false # [critical] Forwarding addresses + sendas: false # [critical] Send-as settings + vacation: false # [critical] Vacation responder + +calendar: + calendars: true # [safe] + acl: true # [safe] + events: true # [safe] + event: true # [safe] + create: true # [low] Create events + update: true # [medium] Update events + delete: true # [medium] Delete events (recoverable) + freebusy: true # [safe] + respond: true # [medium] RSVP to invitations + propose-time: true # [safe] Generate URL only + colors: true # [safe] + conflicts: true # [safe] + search: true # [safe] + time: true # [safe] + users: true # [safe] + team: true # [safe] + focus-time: true # [low] Create focus time block + out-of-office: true # [low] Create OOO event + working-location: true # [low] Set working location + +drive: + ls: true # [safe] + search: true # [safe] + get: true # [safe] + download: true # [safe] + upload: true # [low] Upload files + mkdir: true # [low] Create folders + copy: true # [low] Copy files + delete: false # [critical] Move to trash / permanent delete + move: true # [medium] Move files between folders + rename: true # [medium] Rename files + share: false # [critical] Could expose files publicly + unshare: false # [critical] Remove sharing permissions + permissions: true # [safe] List permissions + url: true # [safe] + drives: true # [safe] + comments: + list: true # [safe] + get: true # [safe] + create: true # [low] Add comments + update: true # [low] Edit comments + delete: false # [medium] Delete comments + reply: true # [low] Reply to comments + +contacts: + search: true # [safe] + list: true # [safe] + get: true # [safe] + create: true # [low] Add contacts + update: true # [medium] Update contacts + delete: false # [critical] Delete contacts + directory: + list: true # [safe] + search: true # [safe] + other: + list: true # [safe] + search: true # [safe] + delete: false # [critical] Delete other contacts + +tasks: + lists: + list: true # [safe] + create: true # [low] + list: true # [safe] + get: true # [safe] + add: true # [low] + update: true # [low] + done: true # [low] + undo: true # [low] + delete: true # [low] Tasks are ephemeral + clear: true # [low] Clear completed + +docs: + export: true # [safe] + info: true # [safe] + cat: true # [safe] + list-tabs: true # [safe] + create: false # [low] + copy: false # [low] + write: false # [medium] + insert: false # [medium] + delete: false # [medium] + find-replace: false # [medium] + update: false # [medium] + edit: false # [medium] + sed: false # [medium] + clear: false # [medium] + structure: true # [safe] + comments: + list: true # [safe] + get: true # [safe] + add: false # [low] + reply: false # [low] + resolve: false # [low] + delete: false # [medium] + +sheets: + get: true # [safe] + metadata: true # [safe] + notes: true # [safe] + links: true # [safe] + export: true # [safe] + update: false # [medium] + append: false # [medium] + insert: false # [medium] + clear: false # [medium] + format: false # [medium] + create: false # [low] + copy: false # [low] + +slides: + export: true # [safe] + info: true # [safe] + list-slides: true # [safe] + read-slide: true # [safe] + create: false # [low] + create-from-markdown: false # [low] + copy: false # [low] + add-slide: false # [medium] + delete-slide: false # [medium] + update-notes: false # [medium] + replace-slide: false # [medium] + +chat: + spaces: + list: true # [safe] + find: true # [safe] + create: false # [low] + messages: + list: true # [safe] + send: false # [high] Send chat message + threads: + list: true # [safe] + dm: + send: false # [high] Send DM + space: false # [low] Create DM space + +forms: + get: true # [safe] + create: false # [low] + responses: + list: true # [safe] + get: true # [safe] + +appscript: + get: true # [safe] + content: true # [safe] + run: false # [critical] Execute arbitrary scripts + create: false # [low] + +classroom: false # Entire service disabled + +people: + me: true # [safe] + get: true # [safe] + search: true # [safe] + relations: true # [safe] + +groups: + list: true # [safe] + members: true # [safe] + +keep: + list: true # [safe] + get: true # [safe] + search: true # [safe] + attachment: true # [safe] + +# Top-level CLI aliases (shortcuts that mirror service commands) +aliases: + send: false # [high] Mirrors gmail send + ls: true # [safe] + search: true # [safe] + open: true # [safe] + download: true # [safe] + upload: true # [low] Mirrors drive upload + login: true # [safe] + logout: false # [medium] Remove stored token + status: true # [safe] + me: true # [safe] + whoami: true # [safe] + +# Auth commands +auth: + credentials: + set: true # [medium] + list: true # [safe] + add: true # [medium] + services: true # [safe] + list: true # [safe] + alias: + list: true # [safe] + set: true # [low] + unset: true # [low] + status: true # [safe] + keyring: true # [medium] + remove: false # [critical] Remove stored token + tokens: + list: true # [safe] + delete: false # [critical] + export: false # [critical] Exports secrets + import: true # [medium] + manage: true # [safe] Opens browser + service-account: + set: true # [medium] + unset: false # [medium] + status: true # [safe] + keep: true # [medium] + +# Utility commands (these keys are recognized but these commands are +# always included in the binary regardless of their value here). +config: true # [safe] +time: true # [safe] diff --git a/safety-profiles/agent-safe.yaml b/safety-profiles/agent-safe.yaml new file mode 100644 index 00000000..6dd89cf0 --- /dev/null +++ b/safety-profiles/agent-safe.yaml @@ -0,0 +1,259 @@ +# Safety Profile: Agent-Safe +# +# Designed for AI agents that triage email and organize files. +# Allows reading, drafting, archiving, and labeling. Blocks sending, +# deleting, and admin operations. Agents can prepare work for human review +# without causing irreversible side effects. + +gmail: + search: true + get: true + messages: true + attachment: true + url: true + history: true + thread: + get: true + modify: true # Label/archive threads + attachments: true + labels: + list: true + get: true + create: true # Create labels for triage + modify: true # Apply/remove labels + delete: false + batch: + modify: true # Bulk label changes for triage + delete: false + archive: true + mark-read: true + unread: true + trash: false + send: false + track: false + drafts: + list: true + get: true + create: true # Create draft replies + update: true # Edit drafts + delete: false + send: false + settings: false + watch: false + autoforward: false + delegates: false + filters: false + forwarding: false + sendas: false + vacation: false + +calendar: + calendars: true + acl: true + events: true + event: true + create: true # Create events (drafts equivalent) + update: true # Update events + delete: false + freebusy: true + respond: false # Don't RSVP on behalf of user + propose-time: true # URL generation only + colors: true + conflicts: true + search: true + time: true + users: true + team: true + focus-time: true + out-of-office: false + working-location: false + +drive: + ls: true + search: true + get: true + download: true + upload: true # Upload files for draft attachments + mkdir: true # Organize into folders + copy: true + delete: false + move: true # Move files for organization + rename: true + share: false + unshare: false + permissions: true + url: true + drives: true + comments: + list: true + get: true + create: true # Add comments for context + update: true + delete: false + reply: true + +contacts: + search: true + list: true + get: true + create: false + update: false + delete: false + directory: + list: true + search: true + other: + list: true + search: true + delete: false + +tasks: + lists: + list: true + create: true + list: true + get: true + add: true # Create tasks from triaged items + update: true + done: true + undo: true + delete: false + clear: false + +docs: + export: true + info: true + cat: true + list-tabs: true + create: false + copy: false + write: false + insert: false + delete: false + find-replace: false + update: false + edit: false + sed: false + clear: false + structure: true + comments: + list: true + get: true + add: true # Add review comments + reply: true + resolve: false + delete: false + +sheets: + get: true + metadata: true + notes: true + links: true + export: true + update: false + append: false + insert: false + clear: false + format: false + create: false + copy: false + +slides: + export: true + info: true + list-slides: true + read-slide: true + create: false + create-from-markdown: false + copy: false + add-slide: false + delete-slide: false + update-notes: false + replace-slide: false + +chat: + spaces: + list: true + find: true + create: false + messages: + list: true + send: false + threads: + list: true + dm: + send: false + space: false + +forms: + get: true + create: false + responses: + list: true + get: true + +appscript: + get: true + content: true + run: false + create: false + +classroom: false + +people: + me: true + get: true + search: true + relations: true + +groups: + list: true + members: true + +keep: + list: true + get: true + search: true + attachment: true + +aliases: + send: false + ls: true + search: true + open: true + download: true + upload: true + login: true + logout: false + status: true + me: true + whoami: true + +auth: + credentials: + set: false + list: true + add: false + services: true + list: true + alias: + list: true + set: false + unset: false + status: true + keyring: false + remove: false + tokens: + list: true + delete: false + export: false + import: false + manage: false + service-account: + set: false + unset: false + status: true + keep: false + +config: true +time: true diff --git a/safety-profiles/full.yaml b/safety-profiles/full.yaml new file mode 100644 index 00000000..ba041ab1 --- /dev/null +++ b/safety-profiles/full.yaml @@ -0,0 +1,257 @@ +# Safety Profile: Full Access +# +# Stock GOG behavior with every command enabled. +# Use this when you trust the caller completely (e.g., interactive CLI use). + +gmail: + search: true + get: true + messages: true + attachment: true + url: true + history: true + thread: + get: true + modify: true + attachments: true + labels: + list: true + get: true + create: true + modify: true + delete: true + batch: + modify: true + delete: true + archive: true + mark-read: true + unread: true + trash: true + send: true + track: true + drafts: + list: true + get: true + create: true + update: true + delete: true + send: true + settings: true + watch: true + autoforward: true + delegates: true + filters: true + forwarding: true + sendas: true + vacation: true + +calendar: + calendars: true + acl: true + events: true + event: true + create: true + update: true + delete: true + freebusy: true + respond: true + propose-time: true + colors: true + conflicts: true + search: true + time: true + users: true + team: true + focus-time: true + out-of-office: true + working-location: true + +drive: + ls: true + search: true + get: true + download: true + upload: true + mkdir: true + copy: true + delete: true + move: true + rename: true + share: true + unshare: true + permissions: true + url: true + drives: true + comments: + list: true + get: true + create: true + update: true + delete: true + reply: true + +contacts: + search: true + list: true + get: true + create: true + update: true + delete: true + directory: + list: true + search: true + other: + list: true + search: true + delete: true + +tasks: + lists: + list: true + create: true + list: true + get: true + add: true + update: true + done: true + undo: true + delete: true + clear: true + +docs: + export: true + info: true + cat: true + list-tabs: true + create: true + copy: true + write: true + insert: true + delete: true + find-replace: true + update: true + edit: true + sed: true + clear: true + structure: true + comments: + list: true + get: true + add: true + reply: true + resolve: true + delete: true + +sheets: + get: true + metadata: true + notes: true + links: true + export: true + update: true + append: true + insert: true + clear: true + format: true + create: true + copy: true + +slides: + export: true + info: true + list-slides: true + read-slide: true + create: true + create-from-markdown: true + copy: true + add-slide: true + delete-slide: true + update-notes: true + replace-slide: true + +chat: + spaces: + list: true + find: true + create: true + messages: + list: true + send: true + threads: + list: true + dm: + send: true + space: true + +forms: + get: true + create: true + responses: + list: true + get: true + +appscript: + get: true + content: true + run: true + create: true + +classroom: true + +people: + me: true + get: true + search: true + relations: true + +groups: + list: true + members: true + +keep: + list: true + get: true + search: true + attachment: true + +aliases: + send: true + ls: true + search: true + open: true + download: true + upload: true + login: true + logout: true + status: true + me: true + whoami: true + +auth: + credentials: + set: true + list: true + add: true + services: true + list: true + alias: + list: true + set: true + unset: true + status: true + keyring: true + remove: true + tokens: + list: true + delete: true + export: true + import: true + manage: true + service-account: + set: true + unset: true + status: true + keep: true + +config: true +time: true diff --git a/safety-profiles/readonly.yaml b/safety-profiles/readonly.yaml new file mode 100644 index 00000000..8cd31deb --- /dev/null +++ b/safety-profiles/readonly.yaml @@ -0,0 +1,258 @@ +# Safety Profile: Read-Only +# +# The most restrictive useful profile. Only read/list/search/get commands +# are enabled. Nothing can be created, modified, sent, or deleted. +# Good for monitoring, auditing, and reporting workflows. + +gmail: + search: true + get: true + messages: true + attachment: true + url: true + history: true + thread: + get: true + modify: false + attachments: true + labels: + list: true + get: true + create: false + modify: false + delete: false + batch: + modify: false + delete: false + archive: false + mark-read: false + unread: false + trash: false + send: false + track: false + drafts: + list: true + get: true + create: false + update: false + delete: false + send: false + settings: false + watch: false + autoforward: false + delegates: false + filters: false + forwarding: false + sendas: false + vacation: false + +calendar: + calendars: true + acl: true + events: true + event: true + create: false + update: false + delete: false + freebusy: true + respond: false + propose-time: false + colors: true + conflicts: true + search: true + time: true + users: true + team: true + focus-time: false + out-of-office: false + working-location: false + +drive: + ls: true + search: true + get: true + download: true + upload: false + mkdir: false + copy: false + delete: false + move: false + rename: false + share: false + unshare: false + permissions: true + url: true + drives: true + comments: + list: true + get: true + create: false + update: false + delete: false + reply: false + +contacts: + search: true + list: true + get: true + create: false + update: false + delete: false + directory: + list: true + search: true + other: + list: true + search: true + delete: false + +tasks: + lists: + list: true + create: false + list: true + get: true + add: false + update: false + done: false + undo: false + delete: false + clear: false + +docs: + export: true + info: true + cat: true + list-tabs: true + create: false + copy: false + write: false + insert: false + delete: false + find-replace: false + update: false + edit: false + sed: false + clear: false + structure: true + comments: + list: true + get: true + add: false + reply: false + resolve: false + delete: false + +sheets: + get: true + metadata: true + notes: true + links: true + export: true + update: false + append: false + insert: false + clear: false + format: false + create: false + copy: false + +slides: + export: true + info: true + list-slides: true + read-slide: true + create: false + create-from-markdown: false + copy: false + add-slide: false + delete-slide: false + update-notes: false + replace-slide: false + +chat: + spaces: + list: true + find: true + create: false + messages: + list: true + send: false + threads: + list: true + dm: + send: false + space: false + +forms: + get: true + create: false + responses: + list: true + get: true + +appscript: + get: true + content: true + run: false + create: false + +classroom: false + +people: + me: true + get: true + search: true + relations: true + +groups: + list: true + members: true + +keep: + list: true + get: true + search: true + attachment: true + +aliases: + send: false + ls: true + search: true + open: true + download: true + upload: false + login: true + logout: false + status: true + me: true + whoami: true + +auth: + credentials: + set: false + list: true + add: false + services: true + list: true + alias: + list: true + set: false + unset: false + status: true + keyring: false + remove: false + tokens: + list: true + delete: false + export: false + import: false + manage: false + service-account: + set: false + unset: false + status: true + keep: false + +config: true +time: true