From 8ab50e590c61bfad32a90ba46b89e2c4bc559612 Mon Sep 17 00:00:00 2001 From: drewburchfield Date: Wed, 25 Feb 2026 08:08:42 -0600 Subject: [PATCH 1/4] feat: add compile-time safety profiles for command removal Add a build-time code generation system that produces restricted CLI binaries from a YAML configuration. Disabled commands are removed at compile time via Go build tags, so they cannot be invoked at all. How it works: - Parent struct definitions extracted to *_types.go (build tag !safety_profile) - cmd/gen-safety reads a YAML profile and generates *_cmd_gen.go files with build tag safety_profile, containing only the enabled commands - Building with -tags safety_profile uses the generated structs - Stock "go build" is completely unchanged Key design decisions: - Fail-closed: commands not listed in YAML are excluded by default - Each service section can be toggled with enabled: true/false - Individual subcommands can be selectively included or excluded - Utility commands (version, auth, config, completion) always included Includes: - cmd/gen-safety: code generator with YAML validation and --strict mode - cmd/extract-types: one-time tool for extracting parent structs - build-safe.sh: convenience script (generate + compile) - Preset profiles: full.yaml, readonly.yaml, agent-safe.yaml - Example profile: safety-profile.yaml Also fixes contacts_crud.go parameter type grouping (given/org were bool instead of string), which is an existing upstream bug. refactor(safety): replace manual registry with AST auto-discovery Replace the ~655-line hand-maintained command registry in gen-safety with automatic discovery via Go's AST package. The generator now parses *_types.go source files directly to find all Cmd structs and their hierarchy, eliminating manual sync when upstream adds commands. What changed: - New discover.go: parses *_types.go files, walks from CLI struct down to build serviceSpec list and CLI field categorization automatically - New discover_test.go: 10 tests covering tag parsing, file parsing, multi-struct files, NonCmdPrefix, field categorization, and more - main.go: wired to call AST discovery instead of manual registry; ~655 lines of spec functions deleted - Rename safety-profile.yaml to safety-profile.example.yaml (users should copy and customize, not edit the example directly) - Updated Makefile, build-safe.sh, README.md for the rename The generated output is identical (verified by diffing all *_cmd_gen.go files before and after). Net change: ~400 fewer lines of code and no more manual updates when upstream adds commands. fix(safety): address review findings from quality gate - buildEmptyStruct: preserve non-command fields (e.g. KeepCmd's ServiceAccount/Impersonate) when service is fully disabled - mapHasEnabledLeaf: fatal on unexpected YAML types instead of silently ignoring (matches isEnabled behavior) - isServiceDisabled: warn on unexpected types before fail-closed - Remove misleading `open` key from utility section (it's an alias, not a utility, and is controllable via aliases.open) - Add main_test.go with tests for fail-closed security contract: isEnabled, filterFields, isServiceDisabled, resolveEnabledFields, mapHasEnabledLeaf (15 total tests now) - Fix doc comments, remove dead code, fix build-safe.sh version suffix feat(safety): isolate config directory for safety profile builds Safety profile builds now use ~/Library/Application Support/gogcli-safe/ instead of gogcli/, preventing credential sharing between stock gog and gog-safe binaries. Uses the existing safety_profile build tag. --- .gitignore | 3 + Makefile | 16 +- README.md | 94 ++++- build-safe.sh | 85 ++++ cmd/extract-types/main.go | 248 ++++++++++++ cmd/gen-safety/discover.go | 353 +++++++++++++++++ cmd/gen-safety/discover_test.go | 342 ++++++++++++++++ cmd/gen-safety/main.go | 431 +++++++++++++++++++++ cmd/gen-safety/main_test.go | 139 +++++++ internal/cmd/appscript.go | 7 - internal/cmd/appscript_types.go | 10 + internal/cmd/auth.go | 27 -- internal/cmd/auth_alias.go | 6 - internal/cmd/auth_alias_types.go | 9 + internal/cmd/auth_service_account.go | 6 - internal/cmd/auth_service_account_types.go | 9 + internal/cmd/auth_types.go | 30 ++ internal/cmd/calendar.go | 22 -- internal/cmd/calendar_types.go | 25 ++ internal/cmd/chat.go | 7 - internal/cmd/chat_dm.go | 5 - internal/cmd/chat_dm_types.go | 8 + internal/cmd/chat_messages.go | 5 - internal/cmd/chat_messages_types.go | 8 + internal/cmd/chat_spaces.go | 6 - internal/cmd/chat_spaces_types.go | 9 + internal/cmd/chat_threads.go | 4 - internal/cmd/chat_threads_types.go | 7 + internal/cmd/chat_types.go | 10 + internal/cmd/classroom.go | 16 - internal/cmd/classroom_types.go | 19 + internal/cmd/contacts.go | 11 - internal/cmd/contacts_directory.go | 11 - internal/cmd/contacts_directory_types.go | 14 + internal/cmd/contacts_types.go | 14 + internal/cmd/docs.go | 20 - internal/cmd/docs_comments.go | 8 - internal/cmd/docs_comments_types.go | 12 + internal/cmd/docs_types.go | 22 ++ internal/cmd/drive.go | 19 - internal/cmd/drive_comments.go | 8 - internal/cmd/drive_comments_types.go | 12 + internal/cmd/drive_types.go | 22 ++ internal/cmd/forms.go | 11 - internal/cmd/forms_types.go | 14 + internal/cmd/gmail.go | 45 --- internal/cmd/gmail_batch.go | 5 - internal/cmd/gmail_batch_types.go | 8 + internal/cmd/gmail_drafts.go | 9 - internal/cmd/gmail_drafts_types.go | 12 + internal/cmd/gmail_labels.go | 8 - internal/cmd/gmail_labels_types.go | 11 + internal/cmd/gmail_thread.go | 6 - internal/cmd/gmail_thread_types.go | 9 + internal/cmd/gmail_types.go | 51 +++ internal/cmd/groups.go | 5 - internal/cmd/groups_types.go | 8 + internal/cmd/keep.go | 10 - internal/cmd/keep_types.go | 13 + internal/cmd/people.go | 7 - internal/cmd/people_types.go | 10 + internal/cmd/root.go | 44 --- internal/cmd/root_types.go | 51 +++ internal/cmd/sheets.go | 15 - internal/cmd/sheets_types.go | 18 + internal/cmd/slides.go | 14 - internal/cmd/slides_types.go | 17 + internal/cmd/tasks.go | 12 - internal/cmd/tasks_lists.go | 5 - internal/cmd/tasks_lists_types.go | 8 + internal/cmd/tasks_types.go | 15 + internal/config/appname.go | 5 + internal/config/appname_safe.go | 5 + internal/config/paths.go | 1 - safety-profile.example.yaml | 279 +++++++++++++ safety-profiles/agent-safe.yaml | 250 ++++++++++++ safety-profiles/full.yaml | 248 ++++++++++++ safety-profiles/readonly.yaml | 249 ++++++++++++ 78 files changed, 3230 insertions(+), 387 deletions(-) create mode 100755 build-safe.sh create mode 100644 cmd/extract-types/main.go create mode 100644 cmd/gen-safety/discover.go create mode 100644 cmd/gen-safety/discover_test.go create mode 100644 cmd/gen-safety/main.go create mode 100644 cmd/gen-safety/main_test.go create mode 100644 internal/cmd/appscript_types.go create mode 100644 internal/cmd/auth_alias_types.go create mode 100644 internal/cmd/auth_service_account_types.go create mode 100644 internal/cmd/auth_types.go create mode 100644 internal/cmd/calendar_types.go create mode 100644 internal/cmd/chat_dm_types.go create mode 100644 internal/cmd/chat_messages_types.go create mode 100644 internal/cmd/chat_spaces_types.go create mode 100644 internal/cmd/chat_threads_types.go create mode 100644 internal/cmd/chat_types.go create mode 100644 internal/cmd/classroom_types.go create mode 100644 internal/cmd/contacts_directory_types.go create mode 100644 internal/cmd/contacts_types.go create mode 100644 internal/cmd/docs_comments_types.go create mode 100644 internal/cmd/docs_types.go create mode 100644 internal/cmd/drive_comments_types.go create mode 100644 internal/cmd/drive_types.go create mode 100644 internal/cmd/forms_types.go create mode 100644 internal/cmd/gmail_batch_types.go create mode 100644 internal/cmd/gmail_drafts_types.go create mode 100644 internal/cmd/gmail_labels_types.go create mode 100644 internal/cmd/gmail_thread_types.go create mode 100644 internal/cmd/gmail_types.go create mode 100644 internal/cmd/groups_types.go create mode 100644 internal/cmd/keep_types.go create mode 100644 internal/cmd/people_types.go create mode 100644 internal/cmd/root_types.go create mode 100644 internal/cmd/sheets_types.go create mode 100644 internal/cmd/slides_types.go create mode 100644 internal/cmd/tasks_lists_types.go create mode 100644 internal/cmd/tasks_types.go create mode 100644 internal/config/appname.go create mode 100644 internal/config/appname_safe.go create mode 100644 safety-profile.example.yaml create mode 100644 safety-profiles/agent-safe.yaml create mode 100644 safety-profiles/full.yaml create mode 100644 safety-profiles/readonly.yaml 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..aa6b708a --- /dev/null +++ b/build-safe.sh @@ -0,0 +1,85 @@ +#!/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 + +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 "$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/extract-types/main.go b/cmd/extract-types/main.go new file mode 100644 index 00000000..9918b8a1 --- /dev/null +++ b/cmd/extract-types/main.go @@ -0,0 +1,248 @@ +// extract-types is a one-time setup tool that extracts parent command struct +// definitions from their implementation files into separate _types.go files. +// +// This enables the build-tag-based safety profile system: +// - *_types.go files have //go:build !safety_profile (original full structs) +// - *_cmd_gen.go files have //go:build safety_profile (trimmed structs) +// - Original files keep all helper functions and child command implementations +// +// Usage: +// +// go run ./cmd/extract-types +package main + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +type extraction struct { + File string // source file path + StructNames []string // struct names to extract + OutputFile string // _types.go output file + ExtraCode string // extra code that must move with the struct (e.g., var declarations) +} + +func main() { + baseDir := "internal/cmd" + + extractions := []extraction{ + {File: "gmail.go", StructNames: []string{"GmailCmd", "GmailSettingsCmd"}, OutputFile: "gmail_types.go", + ExtraCode: "var newGmailService = googleapi.NewGmail"}, + {File: "gmail_thread.go", StructNames: []string{"GmailThreadCmd"}, OutputFile: "gmail_thread_types.go"}, + {File: "gmail_drafts.go", StructNames: []string{"GmailDraftsCmd"}, OutputFile: "gmail_drafts_types.go"}, + {File: "gmail_labels.go", StructNames: []string{"GmailLabelsCmd"}, OutputFile: "gmail_labels_types.go"}, + {File: "gmail_batch.go", StructNames: []string{"GmailBatchCmd"}, OutputFile: "gmail_batch_types.go"}, + {File: "calendar.go", StructNames: []string{"CalendarCmd"}, OutputFile: "calendar_types.go"}, + {File: "drive.go", StructNames: []string{"DriveCmd"}, OutputFile: "drive_types.go"}, + {File: "drive_comments.go", StructNames: []string{"DriveCommentsCmd"}, OutputFile: "drive_comments_types.go"}, + {File: "contacts.go", StructNames: []string{"ContactsCmd"}, OutputFile: "contacts_types.go"}, + {File: "contacts_directory.go", StructNames: []string{"ContactsDirectoryCmd", "ContactsOtherCmd"}, OutputFile: "contacts_directory_types.go"}, + {File: "tasks.go", StructNames: []string{"TasksCmd"}, OutputFile: "tasks_types.go"}, + {File: "tasks_lists.go", StructNames: []string{"TasksListsCmd"}, OutputFile: "tasks_lists_types.go"}, + {File: "docs.go", StructNames: []string{"DocsCmd"}, OutputFile: "docs_types.go"}, + {File: "docs_comments.go", StructNames: []string{"DocsCommentsCmd"}, OutputFile: "docs_comments_types.go"}, + {File: "sheets.go", StructNames: []string{"SheetsCmd"}, OutputFile: "sheets_types.go"}, + {File: "slides.go", StructNames: []string{"SlidesCmd"}, OutputFile: "slides_types.go"}, + {File: "chat.go", StructNames: []string{"ChatCmd"}, OutputFile: "chat_types.go"}, + {File: "chat_spaces.go", StructNames: []string{"ChatSpacesCmd"}, OutputFile: "chat_spaces_types.go"}, + {File: "chat_messages.go", StructNames: []string{"ChatMessagesCmd"}, OutputFile: "chat_messages_types.go"}, + {File: "chat_dm.go", StructNames: []string{"ChatDMCmd"}, OutputFile: "chat_dm_types.go"}, + {File: "chat_threads.go", StructNames: []string{"ChatThreadsCmd"}, OutputFile: "chat_threads_types.go"}, + {File: "forms.go", StructNames: []string{"FormsCmd", "FormsResponsesCmd"}, OutputFile: "forms_types.go"}, + {File: "appscript.go", StructNames: []string{"AppScriptCmd"}, OutputFile: "appscript_types.go"}, + {File: "classroom.go", StructNames: []string{"ClassroomCmd"}, OutputFile: "classroom_types.go"}, + {File: "people.go", StructNames: []string{"PeopleCmd"}, OutputFile: "people_types.go"}, + {File: "groups.go", StructNames: []string{"GroupsCmd"}, OutputFile: "groups_types.go"}, + {File: "keep.go", StructNames: []string{"KeepCmd"}, OutputFile: "keep_types.go"}, + {File: "auth.go", StructNames: []string{"AuthCmd", "AuthCredentialsCmd", "AuthTokensCmd"}, OutputFile: "auth_types.go"}, + {File: "auth_alias.go", StructNames: []string{"AuthAliasCmd"}, OutputFile: "auth_alias_types.go"}, + {File: "auth_service_account.go", StructNames: []string{"AuthServiceAccountCmd"}, OutputFile: "auth_service_account_types.go"}, + {File: "root.go", StructNames: []string{"CLI"}, OutputFile: "root_types.go", + ExtraCode: "NEEDS_KONG_IMPORT"}, + } + + for _, ext := range extractions { + srcPath := filepath.Join(baseDir, ext.File) + outPath := filepath.Join(baseDir, ext.OutputFile) + + fmt.Printf("Processing %s -> %s\n", ext.File, ext.OutputFile) + + if err := processExtraction(srcPath, outPath, ext); err != nil { + fmt.Fprintf(os.Stderr, "Error processing %s: %v\n", ext.File, err) + os.Exit(1) + } + } + + fmt.Println("\nDone! Verify with: go build ./...") +} + +func processExtraction(srcPath, outPath string, ext extraction) error { + lines, err := readLines(srcPath) + if err != nil { + return fmt.Errorf("reading %s: %w", srcPath, err) + } + + // Find struct boundaries + type structRange struct { + name string + startLine int // line index of "type X struct {" + endLine int // line index of closing "}" + } + + var ranges []structRange + for _, name := range ext.StructNames { + start, end, found := findStructBounds(lines, name) + if !found { + return fmt.Errorf("struct %s not found in %s", name, srcPath) + } + ranges = append(ranges, structRange{name: name, startLine: start, endLine: end}) + } + + // Extract the structs into the types file + var typesContent strings.Builder + typesContent.WriteString("//go:build !safety_profile\n\n") + typesContent.WriteString("package cmd\n") + + // Check if we need any imports for the types file + if ext.ExtraCode != "" { + // Check if the extra code references any imports + needsImports := detectNeededImports(lines, ext.ExtraCode) + if len(needsImports) > 0 { + typesContent.WriteString("\nimport (\n") + for _, imp := range needsImports { + typesContent.WriteString(fmt.Sprintf("\t%s\n", imp)) + } + typesContent.WriteString(")\n") + } + // Only write the extra code if it's actual Go code (not a marker) + if !strings.HasPrefix(ext.ExtraCode, "NEEDS_") { + typesContent.WriteString("\n") + typesContent.WriteString(ext.ExtraCode) + typesContent.WriteString("\n") + } + } + + for _, r := range ranges { + typesContent.WriteString("\n") + for i := r.startLine; i <= r.endLine; i++ { + typesContent.WriteString(lines[i]) + typesContent.WriteString("\n") + } + } + + if err := os.WriteFile(outPath, []byte(typesContent.String()), 0o644); err != nil { + return fmt.Errorf("writing %s: %w", outPath, err) + } + + // Remove the structs from the original file + // Also remove the extra code line if present + skipLines := make(map[int]bool) + for _, r := range ranges { + for i := r.startLine; i <= r.endLine; i++ { + skipLines[i] = true + } + // Also skip blank lines immediately before the struct + for i := r.startLine - 1; i >= 0; i-- { + if strings.TrimSpace(lines[i]) == "" { + skipLines[i] = true + } else { + break + } + } + } + + // Remove extra code from original if it was moved to types file + if ext.ExtraCode != "" { + for i, line := range lines { + if strings.TrimSpace(line) == strings.TrimSpace(ext.ExtraCode) { + skipLines[i] = true + // Also skip blank lines after + for j := i + 1; j < len(lines); j++ { + if strings.TrimSpace(lines[j]) == "" { + skipLines[j] = true + } else { + break + } + } + break + } + } + } + + var modifiedContent strings.Builder + for i, line := range lines { + if skipLines[i] { + continue + } + modifiedContent.WriteString(line) + modifiedContent.WriteString("\n") + } + + // Clean up multiple consecutive blank lines + cleaned := cleanBlankLines(modifiedContent.String()) + + if err := os.WriteFile(srcPath, []byte(cleaned), 0o644); err != nil { + return fmt.Errorf("writing %s: %w", srcPath, err) + } + + return nil +} + +func findStructBounds(lines []string, name string) (int, int, bool) { + pattern := fmt.Sprintf(`^type\s+%s\s+struct\s*\{`, regexp.QuoteMeta(name)) + re := regexp.MustCompile(pattern) + + for i, line := range lines { + if re.MatchString(line) { + // Find the closing brace + depth := 0 + for j := i; j < len(lines); j++ { + depth += strings.Count(lines[j], "{") - strings.Count(lines[j], "}") + if depth == 0 { + return i, j, true + } + } + return i, len(lines) - 1, true + } + } + return 0, 0, false +} + +func readLines(path string) ([]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var lines []string + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + return lines, scanner.Err() +} + +func detectNeededImports(lines []string, extraCode string) []string { + if strings.Contains(extraCode, "googleapi.") { + return []string{`"github.com/steipete/gogcli/internal/googleapi"`} + } + if extraCode == "NEEDS_KONG_IMPORT" { + return []string{`"github.com/alecthomas/kong"`} + } + return nil +} + +func cleanBlankLines(s string) string { + // Replace 3+ consecutive newlines with 2 + for strings.Contains(s, "\n\n\n") { + s = strings.ReplaceAll(s, "\n\n\n", "\n\n") + } + return s +} diff --git a/cmd/gen-safety/discover.go b/cmd/gen-safety/discover.go new file mode 100644 index 00000000..1fe0c172 --- /dev/null +++ b/cmd/gen-safety/discover.go @@ -0,0 +1,353 @@ +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 +// (no YAML key, no filtering). +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 { + warn("struct %s not found in types files", 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..b59f9707 --- /dev/null +++ b/cmd/gen-safety/discover_test.go @@ -0,0 +1,342 @@ +package main + +import ( + "os" + "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") + } +} diff --git a/cmd/gen-safety/main.go b/cmd/gen-safety/main.go new file mode 100644 index 00000000..5e7f5741 --- /dev/null +++ b/cmd/gen-safety/main.go @@ -0,0 +1,431 @@ +// 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, "-") { + 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) + } + + 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) + 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 != "" && isServiceDisabled(profile, f.YAMLKey) { + 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. +func buildKnownKeys(specs []serviceSpec, aliases []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 ignored but tolerated) + known["config"] = true + known["time"] = true + // "open" is an alias (leaf command), controlled via aliases.open + 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..ef67101a --- /dev/null +++ b/cmd/gen-safety/main_test.go @@ -0,0 +1,139 @@ +package main + +import "testing" + +func TestIsEnabled(t *testing.T) { + 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) { + 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 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/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..bf3d8109 --- /dev/null +++ b/safety-profile.example.yaml @@ -0,0 +1,279 @@ +# 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 + + # 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] + 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] + 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..e5a47378 --- /dev/null +++ b/safety-profiles/agent-safe.yaml @@ -0,0 +1,250 @@ +# 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 + 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 + comments: + list: true + get: true + add: true # Add review comments + reply: true + resolve: false + delete: false + +sheets: + get: true + metadata: true + notes: 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..f92b48fd --- /dev/null +++ b/safety-profiles/full.yaml @@ -0,0 +1,248 @@ +# 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 + 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 + comments: + list: true + get: true + add: true + reply: true + resolve: true + delete: true + +sheets: + get: true + metadata: true + notes: 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..c99320c4 --- /dev/null +++ b/safety-profiles/readonly.yaml @@ -0,0 +1,249 @@ +# 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 + 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 + comments: + list: true + get: true + add: false + reply: false + resolve: false + delete: false + +sheets: + get: true + metadata: true + notes: 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 From ab1d4632b1470cbfca0873cd345c39fbb578d475 Mon Sep 17 00:00:00 2001 From: drewburchfield Date: Mon, 2 Mar 2026 12:54:43 -0600 Subject: [PATCH 2/4] feat(safety): add sedmat and contacts.other to safety profiles fix(safety): enforce --strict in build-safe.sh Ensures YAML typos in safety profiles cause build failures instead of silent warnings. Without --strict, a mistyped key like 'gmal' instead of 'gmail' would silently exclude the service (fail-closed is safe but violates user intent). All three preset profiles already pass --strict with zero warnings. --- build-safe.sh | 2 +- safety-profile.example.yaml | 4 ++++ safety-profiles/agent-safe.yaml | 4 ++++ safety-profiles/full.yaml | 4 ++++ safety-profiles/readonly.yaml | 4 ++++ 5 files changed, 17 insertions(+), 1 deletion(-) diff --git a/build-safe.sh b/build-safe.sh index aa6b708a..61607c23 100755 --- a/build-safe.sh +++ b/build-safe.sh @@ -63,7 +63,7 @@ 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 "$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") diff --git a/safety-profile.example.yaml b/safety-profile.example.yaml index bf3d8109..b0afafa7 100644 --- a/safety-profile.example.yaml +++ b/safety-profile.example.yaml @@ -153,6 +153,10 @@ docs: 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] diff --git a/safety-profiles/agent-safe.yaml b/safety-profiles/agent-safe.yaml index e5a47378..a71946b5 100644 --- a/safety-profiles/agent-safe.yaml +++ b/safety-profiles/agent-safe.yaml @@ -128,6 +128,10 @@ docs: delete: false find-replace: false update: false + edit: false + sed: false + clear: false + structure: true comments: list: true get: true diff --git a/safety-profiles/full.yaml b/safety-profiles/full.yaml index f92b48fd..5775a9ef 100644 --- a/safety-profiles/full.yaml +++ b/safety-profiles/full.yaml @@ -126,6 +126,10 @@ docs: delete: true find-replace: true update: true + edit: true + sed: true + clear: true + structure: true comments: list: true get: true diff --git a/safety-profiles/readonly.yaml b/safety-profiles/readonly.yaml index c99320c4..7a8a3f5a 100644 --- a/safety-profiles/readonly.yaml +++ b/safety-profiles/readonly.yaml @@ -127,6 +127,10 @@ docs: delete: false find-replace: false update: false + edit: false + sed: false + clear: false + structure: true comments: list: true get: true From 49755944b61b3b47986bed3e5a37aacaffc3b94c Mon Sep 17 00:00:00 2001 From: drewburchfield Date: Thu, 5 Mar 2026 21:36:48 -0600 Subject: [PATCH 3/4] feat(safety): add gmail organize commands and sheets links to safety profiles New upstream commands: - gmail: archive, mark-read, unread, trash (convenience organize commands) - sheets: links/hyperlinks (read cell hyperlinks) Profile decisions: - full: all enabled - readonly: mark-read/unread enabled, archive/trash disabled - agent-safe: archive/mark-read/unread enabled, trash disabled - example: archive/mark-read/unread enabled, trash disabled fix(safety): readonly profile - mark-read and unread are write ops Both commands modify message state via Gmail API (add/remove UNREAD label). Setting them true violated the readonly profile's stated contract of no writes. All four gmail organize commands now correctly disabled: archive, mark-read, unread, trash all false in readonly. fix(safety): harden gen-safety and build-safe.sh robustness - build-safe.sh: anchor to repo root via cd $(dirname $0) so relative paths work correctly when script is invoked from outside repo root - discover.go: buildSpecsForStruct now fatal() instead of warn() when a struct is not found; warn() was semantically wrong since missing struct guarantees a compile failure anyway - better to fail loud early - main.go: fatal on empty/null YAML profile instead of silently disabling all services with no warnings (empty profile now gives clear actionable error message) - main.go: generateCLIFile now skips services where all leaf commands are false, matching the stated comment and preventing ghost empty top-level commands in the CLI struct --- build-safe.sh | 4 ++++ cmd/gen-safety/discover.go | 2 +- cmd/gen-safety/main.go | 16 ++++++++++++++-- safety-profile.example.yaml | 6 ++++++ safety-profiles/agent-safe.yaml | 5 +++++ safety-profiles/full.yaml | 5 +++++ safety-profiles/readonly.yaml | 5 +++++ 7 files changed, 40 insertions(+), 3 deletions(-) diff --git a/build-safe.sh b/build-safe.sh index 61607c23..b83c0abc 100755 --- a/build-safe.sh +++ b/build-safe.sh @@ -13,6 +13,10 @@ # 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 diff --git a/cmd/gen-safety/discover.go b/cmd/gen-safety/discover.go index 1fe0c172..9be8d1ef 100644 --- a/cmd/gen-safety/discover.go +++ b/cmd/gen-safety/discover.go @@ -241,7 +241,7 @@ func buildNonCmdPrefix(fields []parsedField) string { func buildSpecsForStruct(structs map[string]*parsedStruct, structName, yamlKey string, specs *[]serviceSpec) { ps, ok := structs[structName] if !ok { - warn("struct %s not found in types files", structName) + fatal("struct %s not found in types files (missing *_types.go entry?)", structName) return } diff --git a/cmd/gen-safety/main.go b/cmd/gen-safety/main.go index 5e7f5741..d1dc8a15 100644 --- a/cmd/gen-safety/main.go +++ b/cmd/gen-safety/main.go @@ -71,6 +71,10 @@ func main() { 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) @@ -193,8 +197,16 @@ func generateCLIFile(dir string, profile map[string]any, cliAliases []field, cli // 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 != "" && isServiceDisabled(profile, f.YAMLKey) { - continue + 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) } diff --git a/safety-profile.example.yaml b/safety-profile.example.yaml index b0afafa7..0d24e860 100644 --- a/safety-profile.example.yaml +++ b/safety-profile.example.yaml @@ -44,6 +44,11 @@ gmail: 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 @@ -169,6 +174,7 @@ sheets: get: true # [safe] metadata: true # [safe] notes: true # [safe] + links: true # [safe] export: true # [safe] update: false # [medium] append: false # [medium] diff --git a/safety-profiles/agent-safe.yaml b/safety-profiles/agent-safe.yaml index a71946b5..6dd89cf0 100644 --- a/safety-profiles/agent-safe.yaml +++ b/safety-profiles/agent-safe.yaml @@ -25,6 +25,10 @@ gmail: batch: modify: true # Bulk label changes for triage delete: false + archive: true + mark-read: true + unread: true + trash: false send: false track: false drafts: @@ -144,6 +148,7 @@ sheets: get: true metadata: true notes: true + links: true export: true update: false append: false diff --git a/safety-profiles/full.yaml b/safety-profiles/full.yaml index 5775a9ef..ba041ab1 100644 --- a/safety-profiles/full.yaml +++ b/safety-profiles/full.yaml @@ -23,6 +23,10 @@ gmail: batch: modify: true delete: true + archive: true + mark-read: true + unread: true + trash: true send: true track: true drafts: @@ -142,6 +146,7 @@ sheets: get: true metadata: true notes: true + links: true export: true update: true append: true diff --git a/safety-profiles/readonly.yaml b/safety-profiles/readonly.yaml index 7a8a3f5a..8cd31deb 100644 --- a/safety-profiles/readonly.yaml +++ b/safety-profiles/readonly.yaml @@ -24,6 +24,10 @@ gmail: batch: modify: false delete: false + archive: false + mark-read: false + unread: false + trash: false send: false track: false drafts: @@ -143,6 +147,7 @@ sheets: get: true metadata: true notes: true + links: true export: true update: false append: false From 6c25871e03c756d3ed1287808ca1e943974de5cc Mon Sep 17 00:00:00 2001 From: drewburchfield Date: Thu, 5 Mar 2026 23:02:43 -0600 Subject: [PATCH 4/4] chore(safety): remove migration tool, add docs and tests - Remove cmd/extract-types/ (one-time migration artifact, not ongoing tooling) - Add docs/safety-profiles.md explaining the *_types.go convention, YAML format, build process, and contributor workflow - Unify utility-command exception lists: buildKnownKeys now derives tolerated YAML keys from utilityTypes via buildCLIFields, removing the manually maintained config/time hardcoded entries - Add TestEndToEndSafeBuild: generates from full.yaml with --strict then compiles with -tags safety_profile - Add TestValidateYAMLKeys, TestBuildEmptyStruct, TestBuildEmptyStructWithNonCmdPrefix --- cmd/extract-types/main.go | 248 -------------------------------- cmd/gen-safety/discover.go | 6 +- cmd/gen-safety/discover_test.go | 42 ++++++ cmd/gen-safety/main.go | 23 ++- cmd/gen-safety/main_test.go | 87 ++++++++++- docs/safety-profiles.md | 66 +++++++++ 6 files changed, 215 insertions(+), 257 deletions(-) delete mode 100644 cmd/extract-types/main.go create mode 100644 docs/safety-profiles.md diff --git a/cmd/extract-types/main.go b/cmd/extract-types/main.go deleted file mode 100644 index 9918b8a1..00000000 --- a/cmd/extract-types/main.go +++ /dev/null @@ -1,248 +0,0 @@ -// extract-types is a one-time setup tool that extracts parent command struct -// definitions from their implementation files into separate _types.go files. -// -// This enables the build-tag-based safety profile system: -// - *_types.go files have //go:build !safety_profile (original full structs) -// - *_cmd_gen.go files have //go:build safety_profile (trimmed structs) -// - Original files keep all helper functions and child command implementations -// -// Usage: -// -// go run ./cmd/extract-types -package main - -import ( - "bufio" - "fmt" - "os" - "path/filepath" - "regexp" - "strings" -) - -type extraction struct { - File string // source file path - StructNames []string // struct names to extract - OutputFile string // _types.go output file - ExtraCode string // extra code that must move with the struct (e.g., var declarations) -} - -func main() { - baseDir := "internal/cmd" - - extractions := []extraction{ - {File: "gmail.go", StructNames: []string{"GmailCmd", "GmailSettingsCmd"}, OutputFile: "gmail_types.go", - ExtraCode: "var newGmailService = googleapi.NewGmail"}, - {File: "gmail_thread.go", StructNames: []string{"GmailThreadCmd"}, OutputFile: "gmail_thread_types.go"}, - {File: "gmail_drafts.go", StructNames: []string{"GmailDraftsCmd"}, OutputFile: "gmail_drafts_types.go"}, - {File: "gmail_labels.go", StructNames: []string{"GmailLabelsCmd"}, OutputFile: "gmail_labels_types.go"}, - {File: "gmail_batch.go", StructNames: []string{"GmailBatchCmd"}, OutputFile: "gmail_batch_types.go"}, - {File: "calendar.go", StructNames: []string{"CalendarCmd"}, OutputFile: "calendar_types.go"}, - {File: "drive.go", StructNames: []string{"DriveCmd"}, OutputFile: "drive_types.go"}, - {File: "drive_comments.go", StructNames: []string{"DriveCommentsCmd"}, OutputFile: "drive_comments_types.go"}, - {File: "contacts.go", StructNames: []string{"ContactsCmd"}, OutputFile: "contacts_types.go"}, - {File: "contacts_directory.go", StructNames: []string{"ContactsDirectoryCmd", "ContactsOtherCmd"}, OutputFile: "contacts_directory_types.go"}, - {File: "tasks.go", StructNames: []string{"TasksCmd"}, OutputFile: "tasks_types.go"}, - {File: "tasks_lists.go", StructNames: []string{"TasksListsCmd"}, OutputFile: "tasks_lists_types.go"}, - {File: "docs.go", StructNames: []string{"DocsCmd"}, OutputFile: "docs_types.go"}, - {File: "docs_comments.go", StructNames: []string{"DocsCommentsCmd"}, OutputFile: "docs_comments_types.go"}, - {File: "sheets.go", StructNames: []string{"SheetsCmd"}, OutputFile: "sheets_types.go"}, - {File: "slides.go", StructNames: []string{"SlidesCmd"}, OutputFile: "slides_types.go"}, - {File: "chat.go", StructNames: []string{"ChatCmd"}, OutputFile: "chat_types.go"}, - {File: "chat_spaces.go", StructNames: []string{"ChatSpacesCmd"}, OutputFile: "chat_spaces_types.go"}, - {File: "chat_messages.go", StructNames: []string{"ChatMessagesCmd"}, OutputFile: "chat_messages_types.go"}, - {File: "chat_dm.go", StructNames: []string{"ChatDMCmd"}, OutputFile: "chat_dm_types.go"}, - {File: "chat_threads.go", StructNames: []string{"ChatThreadsCmd"}, OutputFile: "chat_threads_types.go"}, - {File: "forms.go", StructNames: []string{"FormsCmd", "FormsResponsesCmd"}, OutputFile: "forms_types.go"}, - {File: "appscript.go", StructNames: []string{"AppScriptCmd"}, OutputFile: "appscript_types.go"}, - {File: "classroom.go", StructNames: []string{"ClassroomCmd"}, OutputFile: "classroom_types.go"}, - {File: "people.go", StructNames: []string{"PeopleCmd"}, OutputFile: "people_types.go"}, - {File: "groups.go", StructNames: []string{"GroupsCmd"}, OutputFile: "groups_types.go"}, - {File: "keep.go", StructNames: []string{"KeepCmd"}, OutputFile: "keep_types.go"}, - {File: "auth.go", StructNames: []string{"AuthCmd", "AuthCredentialsCmd", "AuthTokensCmd"}, OutputFile: "auth_types.go"}, - {File: "auth_alias.go", StructNames: []string{"AuthAliasCmd"}, OutputFile: "auth_alias_types.go"}, - {File: "auth_service_account.go", StructNames: []string{"AuthServiceAccountCmd"}, OutputFile: "auth_service_account_types.go"}, - {File: "root.go", StructNames: []string{"CLI"}, OutputFile: "root_types.go", - ExtraCode: "NEEDS_KONG_IMPORT"}, - } - - for _, ext := range extractions { - srcPath := filepath.Join(baseDir, ext.File) - outPath := filepath.Join(baseDir, ext.OutputFile) - - fmt.Printf("Processing %s -> %s\n", ext.File, ext.OutputFile) - - if err := processExtraction(srcPath, outPath, ext); err != nil { - fmt.Fprintf(os.Stderr, "Error processing %s: %v\n", ext.File, err) - os.Exit(1) - } - } - - fmt.Println("\nDone! Verify with: go build ./...") -} - -func processExtraction(srcPath, outPath string, ext extraction) error { - lines, err := readLines(srcPath) - if err != nil { - return fmt.Errorf("reading %s: %w", srcPath, err) - } - - // Find struct boundaries - type structRange struct { - name string - startLine int // line index of "type X struct {" - endLine int // line index of closing "}" - } - - var ranges []structRange - for _, name := range ext.StructNames { - start, end, found := findStructBounds(lines, name) - if !found { - return fmt.Errorf("struct %s not found in %s", name, srcPath) - } - ranges = append(ranges, structRange{name: name, startLine: start, endLine: end}) - } - - // Extract the structs into the types file - var typesContent strings.Builder - typesContent.WriteString("//go:build !safety_profile\n\n") - typesContent.WriteString("package cmd\n") - - // Check if we need any imports for the types file - if ext.ExtraCode != "" { - // Check if the extra code references any imports - needsImports := detectNeededImports(lines, ext.ExtraCode) - if len(needsImports) > 0 { - typesContent.WriteString("\nimport (\n") - for _, imp := range needsImports { - typesContent.WriteString(fmt.Sprintf("\t%s\n", imp)) - } - typesContent.WriteString(")\n") - } - // Only write the extra code if it's actual Go code (not a marker) - if !strings.HasPrefix(ext.ExtraCode, "NEEDS_") { - typesContent.WriteString("\n") - typesContent.WriteString(ext.ExtraCode) - typesContent.WriteString("\n") - } - } - - for _, r := range ranges { - typesContent.WriteString("\n") - for i := r.startLine; i <= r.endLine; i++ { - typesContent.WriteString(lines[i]) - typesContent.WriteString("\n") - } - } - - if err := os.WriteFile(outPath, []byte(typesContent.String()), 0o644); err != nil { - return fmt.Errorf("writing %s: %w", outPath, err) - } - - // Remove the structs from the original file - // Also remove the extra code line if present - skipLines := make(map[int]bool) - for _, r := range ranges { - for i := r.startLine; i <= r.endLine; i++ { - skipLines[i] = true - } - // Also skip blank lines immediately before the struct - for i := r.startLine - 1; i >= 0; i-- { - if strings.TrimSpace(lines[i]) == "" { - skipLines[i] = true - } else { - break - } - } - } - - // Remove extra code from original if it was moved to types file - if ext.ExtraCode != "" { - for i, line := range lines { - if strings.TrimSpace(line) == strings.TrimSpace(ext.ExtraCode) { - skipLines[i] = true - // Also skip blank lines after - for j := i + 1; j < len(lines); j++ { - if strings.TrimSpace(lines[j]) == "" { - skipLines[j] = true - } else { - break - } - } - break - } - } - } - - var modifiedContent strings.Builder - for i, line := range lines { - if skipLines[i] { - continue - } - modifiedContent.WriteString(line) - modifiedContent.WriteString("\n") - } - - // Clean up multiple consecutive blank lines - cleaned := cleanBlankLines(modifiedContent.String()) - - if err := os.WriteFile(srcPath, []byte(cleaned), 0o644); err != nil { - return fmt.Errorf("writing %s: %w", srcPath, err) - } - - return nil -} - -func findStructBounds(lines []string, name string) (int, int, bool) { - pattern := fmt.Sprintf(`^type\s+%s\s+struct\s*\{`, regexp.QuoteMeta(name)) - re := regexp.MustCompile(pattern) - - for i, line := range lines { - if re.MatchString(line) { - // Find the closing brace - depth := 0 - for j := i; j < len(lines); j++ { - depth += strings.Count(lines[j], "{") - strings.Count(lines[j], "}") - if depth == 0 { - return i, j, true - } - } - return i, len(lines) - 1, true - } - } - return 0, 0, false -} - -func readLines(path string) ([]string, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - - var lines []string - scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 1024*1024), 1024*1024) - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - return lines, scanner.Err() -} - -func detectNeededImports(lines []string, extraCode string) []string { - if strings.Contains(extraCode, "googleapi.") { - return []string{`"github.com/steipete/gogcli/internal/googleapi"`} - } - if extraCode == "NEEDS_KONG_IMPORT" { - return []string{`"github.com/alecthomas/kong"`} - } - return nil -} - -func cleanBlankLines(s string) string { - // Replace 3+ consecutive newlines with 2 - for strings.Contains(s, "\n\n\n") { - s = strings.ReplaceAll(s, "\n\n\n", "\n\n") - } - return s -} diff --git a/cmd/gen-safety/discover.go b/cmd/gen-safety/discover.go index 9be8d1ef..82a24ad9 100644 --- a/cmd/gen-safety/discover.go +++ b/cmd/gen-safety/discover.go @@ -29,7 +29,11 @@ type parsedField struct { } // utilityTypes is the set of CLI field types that are always included -// (no YAML key, no filtering). +// 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, diff --git a/cmd/gen-safety/discover_test.go b/cmd/gen-safety/discover_test.go index b59f9707..97b46f9b 100644 --- a/cmd/gen-safety/discover_test.go +++ b/cmd/gen-safety/discover_test.go @@ -2,6 +2,7 @@ package main import ( "os" + "os/exec" "path/filepath" "strings" "testing" @@ -340,3 +341,44 @@ func TestClassroomFieldsNoNameTag(t *testing.T) { 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 index d1dc8a15..7bc46721 100644 --- a/cmd/gen-safety/main.go +++ b/cmd/gen-safety/main.go @@ -56,7 +56,9 @@ func main() { for _, arg := range os.Args[1:] { if arg == "--strict" { strict = true - } else if !strings.HasPrefix(arg, "-") { + } else if strings.HasPrefix(arg, "-") { + fatal("unknown flag: %s", arg) + } else { profilePath = arg } } @@ -90,7 +92,7 @@ func main() { aliases, services := buildCLIFields(structs) // Validate YAML keys against known specs to catch typos. - knownKeys := buildKnownKeys(specs, aliases) + knownKeys := buildKnownKeys(specs, aliases, services) validateYAMLKeys(profile, knownKeys, "") for _, spec := range specs { @@ -328,14 +330,21 @@ func mapHasEnabledLeaf(m map[string]any) bool { } // buildKnownKeys constructs a set of all valid YAML key paths from the specs. -func buildKnownKeys(specs []serviceSpec, aliases []field) map[string]bool { +// 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 ignored but tolerated) - known["config"] = true - known["time"] = true - // "open" is an alias (leaf command), controlled via aliases.open + // 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) } diff --git a/cmd/gen-safety/main_test.go b/cmd/gen-safety/main_test.go index ef67101a..5d4b9c50 100644 --- a/cmd/gen-safety/main_test.go +++ b/cmd/gen-safety/main_test.go @@ -1,8 +1,14 @@ package main -import "testing" +import ( + "strings" + "testing" +) func TestIsEnabled(t *testing.T) { + warnings = nil + defer func() { warnings = nil }() + config := map[string]any{ "send": true, "delete": false, @@ -40,6 +46,9 @@ func TestIsEnabled(t *testing.T) { } func TestFilterFields(t *testing.T) { + warnings = nil + defer func() { warnings = nil }() + fields := []field{ {GoName: "Send", YAMLKey: "send"}, {GoName: "Search", YAMLKey: "search"}, @@ -118,6 +127,82 @@ func TestResolveEnabledFields_NestedBoolShorthand(t *testing.T) { } } +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 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.