diff --git a/README.md b/README.md index 241c93c..f8a76c7 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ![GitHub Repo Banner](https://ghrb.waren.build/banner?header=gogcli%F0%9F%A7%AD&subheader=Google+in+your+terminal&bg=f3f4f6&color=1f2937&support=true) -Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Slides, Sheets, Contacts, Tasks, People, Groups (Workspace), and Keep (Workspace-only). JSON-first output, multiple accounts, and least-privilege auth built in. +Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Slides, Sheets, Forms, Apps Script, Contacts, Tasks, People, Groups (Workspace), and Keep (Workspace-only). JSON-first output, multiple accounts, and least-privilege auth built in. ## Features @@ -16,6 +16,8 @@ Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Sli - **Contacts** - search/create/update contacts, access Workspace directory/other contacts - **Tasks** - manage tasklists and tasks: get/create/add/update/done/undo/delete/clear, repeat schedules - **Sheets** - read/write/update spreadsheets, format cells, create new sheets (and export via Drive) +- **Forms** - create/get forms and inspect responses +- **Apps Script** - create/get projects, inspect content, and run functions - **Docs/Slides** - export to PDF/DOCX/PPTX via Drive (plus create/copy, docs-to-text) - **People** - access profile information - **Keep (Workspace only)** - list/get/search notes and download attachments (service account + domain-wide delegation) @@ -76,6 +78,8 @@ Before adding an account, create OAuth2 credentials from Google Cloud Console: - People API (Contacts): https://console.cloud.google.com/apis/api/people.googleapis.com - Google Tasks API: https://console.cloud.google.com/apis/api/tasks.googleapis.com - Google Sheets API: https://console.cloud.google.com/apis/api/sheets.googleapis.com + - Google Forms API: https://console.cloud.google.com/apis/api/forms.googleapis.com + - Apps Script API: https://console.cloud.google.com/apis/api/script.googleapis.com - Cloud Identity API (Groups): https://console.cloud.google.com/apis/api/cloudidentity.googleapis.com 3. Configure OAuth consent screen: https://console.cloud.google.com/auth/branding 4. If your app is in "Testing", add test users: https://console.cloud.google.com/auth/audience @@ -337,10 +341,13 @@ Service scope matrix (auto-generated; run `go run scripts/gen-auth-services-md.g | classroom | yes | Classroom API | `https://www.googleapis.com/auth/classroom.courses`
`https://www.googleapis.com/auth/classroom.rosters`
`https://www.googleapis.com/auth/classroom.coursework.students`
`https://www.googleapis.com/auth/classroom.coursework.me`
`https://www.googleapis.com/auth/classroom.courseworkmaterials`
`https://www.googleapis.com/auth/classroom.announcements`
`https://www.googleapis.com/auth/classroom.topics`
`https://www.googleapis.com/auth/classroom.guardianlinks.students`
`https://www.googleapis.com/auth/classroom.profile.emails`
`https://www.googleapis.com/auth/classroom.profile.photos` | | | drive | yes | Drive API | `https://www.googleapis.com/auth/drive` | | | docs | yes | Docs API, Drive API | `https://www.googleapis.com/auth/drive`
`https://www.googleapis.com/auth/documents` | Export/copy/create via Drive | +| slides | yes | Slides API, Drive API | `https://www.googleapis.com/auth/drive`
`https://www.googleapis.com/auth/presentations` | Create/edit presentations | | contacts | yes | People API | `https://www.googleapis.com/auth/contacts`
`https://www.googleapis.com/auth/contacts.other.readonly`
`https://www.googleapis.com/auth/directory.readonly` | Contacts + other contacts + directory | | tasks | yes | Tasks API | `https://www.googleapis.com/auth/tasks` | | | sheets | yes | Sheets API, Drive API | `https://www.googleapis.com/auth/drive`
`https://www.googleapis.com/auth/spreadsheets` | Export via Drive | | people | yes | People API | `profile` | OIDC profile scope | +| forms | yes | Forms API | `https://www.googleapis.com/auth/forms.body`
`https://www.googleapis.com/auth/forms.responses.readonly` | | +| appscript | yes | Apps Script API | `https://www.googleapis.com/auth/script.projects`
`https://www.googleapis.com/auth/script.deployments`
`https://www.googleapis.com/auth/script.processes` | | | groups | no | Cloud Identity API | `https://www.googleapis.com/auth/cloud-identity.groups.readonly` | Workspace only | | keep | no | Keep API | `https://www.googleapis.com/auth/keep.readonly` | Workspace only; service account (domain-wide delegation) | @@ -922,6 +929,32 @@ gog sheets format 'Sheet1!A1:B2' --format-json '{"textFormat":{" gog sheets create "My New Spreadsheet" --sheets "Sheet1,Sheet2" ``` +### Forms + +```bash +# Forms +gog forms get +gog forms create --title "Weekly Check-in" --description "Friday async update" + +# Responses +gog forms responses list --max 20 +gog forms responses get +``` + +### Apps Script + +```bash +# Projects +gog appscript get +gog appscript content +gog appscript create --title "Automation Helpers" +gog appscript create --title "Bound Script" --parent-id + +# Execute functions +gog appscript run myFunction --params '["arg1", 123, true]' +gog appscript run myFunction --dev-mode +``` + ### People ```bash diff --git a/internal/cmd/appscript.go b/internal/cmd/appscript.go new file mode 100644 index 0000000..d5047ee --- /dev/null +++ b/internal/cmd/appscript.go @@ -0,0 +1,276 @@ +package cmd + +import ( + "context" + "encoding/json" + "os" + "strings" + + scriptapi "google.golang.org/api/script/v1" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +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"` +} + +func (c *AppScriptGetCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + scriptID := strings.TrimSpace(normalizeGoogleID(c.ScriptID)) + if scriptID == "" { + return usage("empty scriptId") + } + + svc, err := newAppScriptService(ctx, account) + if err != nil { + return err + } + project, err := svc.Projects.Get(scriptID).Context(ctx).Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "project": project, + "editor_url": appScriptEditURL(scriptID), + }) + } + + u := ui.FromContext(ctx) + u.Out().Printf("script_id\t%s", project.ScriptId) + if project.Title != "" { + u.Out().Printf("title\t%s", project.Title) + } + if project.ParentId != "" { + u.Out().Printf("parent_id\t%s", project.ParentId) + } + if project.CreateTime != "" { + u.Out().Printf("created\t%s", project.CreateTime) + } + if project.UpdateTime != "" { + u.Out().Printf("updated\t%s", project.UpdateTime) + } + u.Out().Printf("editor_url\t%s", appScriptEditURL(scriptID)) + return nil +} + +type AppScriptContentCmd struct { + ScriptID string `arg:"" name:"scriptId" help:"Script ID"` +} + +func (c *AppScriptContentCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + scriptID := strings.TrimSpace(normalizeGoogleID(c.ScriptID)) + if scriptID == "" { + return usage("empty scriptId") + } + + svc, err := newAppScriptService(ctx, account) + if err != nil { + return err + } + content, err := svc.Projects.GetContent(scriptID).Context(ctx).Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "content": content, + }) + } + + u := ui.FromContext(ctx) + u.Out().Printf("script_id\t%s", content.ScriptId) + u.Out().Printf("files\t%d", len(content.Files)) + for _, file := range content.Files { + if file == nil { + continue + } + u.Out().Printf("file\t%s\t%s", file.Name, file.Type) + } + return nil +} + +type AppScriptRunCmd struct { + ScriptID string `arg:"" name:"scriptId" help:"Script ID"` + Function string `arg:"" name:"function" help:"Function name to run"` + Params string `name:"params" help:"JSON array of function parameters" default:"[]"` + DevMode bool `name:"dev-mode" help:"Run latest saved code if you own the script"` +} + +func (c *AppScriptRunCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + scriptID := strings.TrimSpace(normalizeGoogleID(c.ScriptID)) + if scriptID == "" { + return usage("empty scriptId") + } + function := strings.TrimSpace(c.Function) + if function == "" { + return usage("empty function") + } + + params, err := parseJSONArray(c.Params) + if err != nil { + return err + } + + svc, err := newAppScriptService(ctx, account) + if err != nil { + return err + } + op, err := svc.Scripts.Run(scriptID, &scriptapi.ExecutionRequest{ + Function: function, + Parameters: params, + DevMode: c.DevMode, + }).Context(ctx).Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "operation": op, + }) + } + + u := ui.FromContext(ctx) + u.Out().Printf("done\t%t", op.Done) + + if op.Error != nil { + if op.Error.Code != 0 { + u.Out().Printf("error_code\t%d", op.Error.Code) + } + if op.Error.Message != "" { + u.Out().Printf("error\t%s", op.Error.Message) + } + if detail := parseExecutionError(op.Error); detail != nil { + if detail.ErrorType != "" { + u.Out().Printf("error_type\t%s", detail.ErrorType) + } + if detail.ErrorMessage != "" { + u.Out().Printf("error_message\t%s", detail.ErrorMessage) + } + } + return nil + } + + if len(op.Response) > 0 { + var execResp scriptapi.ExecutionResponse + if err := json.Unmarshal(op.Response, &execResp); err == nil && execResp.Result != nil { + if b, marshalErr := json.Marshal(execResp.Result); marshalErr == nil { + u.Out().Printf("result\t%s", string(b)) + } + } + } + return nil +} + +type AppScriptCreateCmd struct { + Title string `name:"title" help:"Project title" required:""` + ParentID string `name:"parent-id" help:"Optional Drive file ID to bind to"` +} + +func (c *AppScriptCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + title := strings.TrimSpace(c.Title) + if title == "" { + return usage("empty --title") + } + parentID := strings.TrimSpace(normalizeGoogleID(c.ParentID)) + + if dryRunErr := dryRunExit(ctx, flags, "appscript.create", map[string]any{ + "title": title, + "parent_id": parentID, + }); dryRunErr != nil { + return dryRunErr + } + + svc, err := newAppScriptService(ctx, account) + if err != nil { + return err + } + project, err := svc.Projects.Create(&scriptapi.CreateProjectRequest{ + Title: title, + ParentId: parentID, + }).Context(ctx).Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "created": true, + "project": project, + "editor_url": appScriptEditURL(project.ScriptId), + }) + } + + u := ui.FromContext(ctx) + u.Out().Printf("created\ttrue") + u.Out().Printf("script_id\t%s", project.ScriptId) + if project.Title != "" { + u.Out().Printf("title\t%s", project.Title) + } + if project.ParentId != "" { + u.Out().Printf("parent_id\t%s", project.ParentId) + } + u.Out().Printf("editor_url\t%s", appScriptEditURL(project.ScriptId)) + return nil +} + +func parseJSONArray(raw string) ([]interface{}, error) { + val := strings.TrimSpace(raw) + if val == "" { + return nil, nil + } + var out []interface{} + if err := json.Unmarshal([]byte(val), &out); err != nil { + return nil, usagef("invalid --params JSON array: %v", err) + } + return out, nil +} + +func parseExecutionError(status *scriptapi.Status) *scriptapi.ExecutionError { + if status == nil || len(status.Details) == 0 { + return nil + } + var detail scriptapi.ExecutionError + if err := json.Unmarshal(status.Details[0], &detail); err != nil { + return nil + } + return &detail +} + +func appScriptEditURL(scriptID string) string { + scriptID = strings.TrimSpace(scriptID) + if scriptID == "" { + return "" + } + return "https://script.google.com/d/" + scriptID + "/edit" +} diff --git a/internal/cmd/execute_forms_appscript_test.go b/internal/cmd/execute_forms_appscript_test.go new file mode 100644 index 0000000..613220e --- /dev/null +++ b/internal/cmd/execute_forms_appscript_test.go @@ -0,0 +1,328 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + formsapi "google.golang.org/api/forms/v1" + "google.golang.org/api/option" + scriptapi "google.golang.org/api/script/v1" +) + +func TestExecute_FormsGet_Text(t *testing.T) { + origNew := newFormsService + t.Cleanup(func() { newFormsService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(strings.Contains(r.URL.Path, "/forms/form123") && r.Method == http.MethodGet) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "formId": "form123", + "responderUri": "https://docs.google.com/forms/d/e/resp", + "info": map[string]any{ + "title": "Survey", + "description": "Weekly check-in", + }, + }) + })) + defer srv.Close() + + svc, err := formsapi.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newFormsService = func(context.Context, string) (*formsapi.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--account", "a@b.com", "forms", "get", "form123"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + if !strings.Contains(out, "id\tform123") || !strings.Contains(out, "title\tSurvey") || !strings.Contains(out, "edit_url\thttps://docs.google.com/forms/d/form123/edit") { + t.Fatalf("unexpected out=%q", out) + } +} + +func TestExecute_FormsResponsesList_JSON(t *testing.T) { + origNew := newFormsService + t.Cleanup(func() { newFormsService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(strings.Contains(r.URL.Path, "/forms/form123/responses") && r.Method == http.MethodGet) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "responses": []map[string]any{ + { + "responseId": "r1", + "lastSubmittedTime": "2026-02-14T00:00:00Z", + "respondentEmail": "user@example.com", + }, + }, + "nextPageToken": "next123", + }) + })) + defer srv.Close() + + svc, err := formsapi.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newFormsService = func(context.Context, string) (*formsapi.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "--account", "a@b.com", "forms", "responses", "list", "form123", "--max", "1"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var parsed struct { + FormID string `json:"form_id"` + Responses []struct { + ResponseID string `json:"responseId"` + } `json:"responses"` + NextPageToken string `json:"nextPageToken"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if parsed.FormID != "form123" || len(parsed.Responses) != 1 || parsed.Responses[0].ResponseID != "r1" || parsed.NextPageToken != "next123" { + t.Fatalf("unexpected payload: %#v", parsed) + } +} + +func TestExecute_FormsResponsesList_RejectsNonPositiveMax(t *testing.T) { + origNew := newFormsService + t.Cleanup(func() { newFormsService = origNew }) + newFormsService = func(context.Context, string) (*formsapi.Service, error) { + t.Fatalf("expected validation to fail before creating forms service") + return nil, errors.New("unexpected forms service call") + } + + _ = captureStderr(t, func() { + err := Execute([]string{"--account", "a@b.com", "forms", "responses", "list", "form123", "--max", "0"}) + if err == nil || !strings.Contains(err.Error(), "--max must be > 0") { + t.Fatalf("unexpected err: %v", err) + } + }) +} + +func TestExecute_FormsCreate_DryRun_JSON(t *testing.T) { + origNew := newFormsService + t.Cleanup(func() { newFormsService = origNew }) + newFormsService = func(context.Context, string) (*formsapi.Service, error) { + t.Fatalf("dry-run should not create forms service") + return nil, errors.New("unexpected forms service call") + } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{ + "--json", + "--dry-run", + "--account", "a@b.com", + "forms", "create", + "--title", "Weekly Check-in", + "--description", "Friday async update", + }); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var parsed struct { + DryRun bool `json:"dry_run"` + Op string `json:"op"` + Request struct { + Title string `json:"title"` + Description string `json:"description"` + } `json:"request"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !parsed.DryRun || parsed.Op != "forms.create" { + t.Fatalf("unexpected dry-run payload: %#v", parsed) + } + if parsed.Request.Title != "Weekly Check-in" || parsed.Request.Description != "Friday async update" { + t.Fatalf("unexpected request payload: %#v", parsed.Request) + } +} + +func TestExecute_AppScriptRun_JSON(t *testing.T) { + origNew := newAppScriptService + t.Cleanup(func() { newAppScriptService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(strings.Contains(r.URL.Path, "/scripts/script123:run") && r.Method == http.MethodPost) { + http.NotFound(w, r) + return + } + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req["function"] != "myFunc" { + t.Fatalf("unexpected function: %#v", req["function"]) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "done": true, + "response": map[string]any{ + "result": map[string]any{ + "ok": true, + }, + }, + }) + })) + defer srv.Close() + + svc, err := scriptapi.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newAppScriptService = func(context.Context, string) (*scriptapi.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "--account", "a@b.com", "appscript", "run", "script123", "myFunc", "--params", "[\"x\"]"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var parsed map[string]any + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + op, ok := parsed["operation"].(map[string]any) + if !ok { + t.Fatalf("missing operation: %#v", parsed) + } + if done, _ := op["done"].(bool); !done { + t.Fatalf("expected done=true: %#v", op) + } +} + +func TestExecute_AppScriptCreate_DryRun_Text(t *testing.T) { + origNew := newAppScriptService + t.Cleanup(func() { newAppScriptService = origNew }) + newAppScriptService = func(context.Context, string) (*scriptapi.Service, error) { + t.Fatalf("dry-run should not create appscript service") + return nil, errors.New("unexpected appscript service call") + } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{ + "--dry-run", + "--account", "a@b.com", + "appscript", "create", + "--title", "Automation Helpers", + "--parent-id", "drive123", + }); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + if !strings.Contains(out, "Dry run: would appscript.create") || + !strings.Contains(out, `"title": "Automation Helpers"`) || + !strings.Contains(out, `"parent_id": "drive123"`) { + t.Fatalf("unexpected dry-run out=%q", out) + } +} + +func TestExecute_AppScriptRun_RejectsNonArrayParams(t *testing.T) { + origNew := newAppScriptService + t.Cleanup(func() { newAppScriptService = origNew }) + newAppScriptService = func(context.Context, string) (*scriptapi.Service, error) { + t.Fatalf("expected params validation to fail before creating appscript service") + return nil, errors.New("unexpected appscript service call") + } + + _ = captureStderr(t, func() { + err := Execute([]string{"--account", "a@b.com", "appscript", "run", "script123", "myFunc", "--params", `{"x":1}`}) + if err == nil || !strings.Contains(err.Error(), "invalid --params JSON array") { + t.Fatalf("unexpected err: %v", err) + } + }) +} + +func TestExecute_AppScriptRun_TextErrorDetails(t *testing.T) { + origNew := newAppScriptService + t.Cleanup(func() { newAppScriptService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(strings.Contains(r.URL.Path, "/scripts/script123:run") && r.Method == http.MethodPost) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "done": true, + "error": map[string]any{ + "code": 3, + "message": "Script execution failed", + "details": []map[string]any{ + { + "@type": "type.googleapis.com/google.apps.script.type.ExecutionError", + "errorType": "TypeError", + "errorMessage": "boom", + }, + }, + }, + }) + })) + defer srv.Close() + + svc, err := scriptapi.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newAppScriptService = func(context.Context, string) (*scriptapi.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--account", "a@b.com", "appscript", "run", "script123", "myFunc"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + if !strings.Contains(out, "done\ttrue") || + !strings.Contains(out, "error_code\t3") || + !strings.Contains(out, "error\tScript execution failed") || + !strings.Contains(out, "error_type\tTypeError") || + !strings.Contains(out, "error_message\tboom") { + t.Fatalf("unexpected out=%q", out) + } +} diff --git a/internal/cmd/forms.go b/internal/cmd/forms.go new file mode 100644 index 0000000..c1d8984 --- /dev/null +++ b/internal/cmd/forms.go @@ -0,0 +1,262 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + formsapi "google.golang.org/api/forms/v1" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +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"` +} + +func (c *FormsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + formID := strings.TrimSpace(normalizeGoogleID(c.FormID)) + if formID == "" { + return usage("empty formId") + } + + svc, err := newFormsService(ctx, account) + if err != nil { + return err + } + + form, err := svc.Forms.Get(formID).Context(ctx).Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "form": form, + "edit_url": formEditURL(formID), + }) + } + + u := ui.FromContext(ctx) + printFormSummary(u, form, formID) + return nil +} + +type FormsCreateCmd struct { + Title string `name:"title" help:"Form title" required:""` + Description string `name:"description" help:"Form description"` +} + +func (c *FormsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + title := strings.TrimSpace(c.Title) + if title == "" { + return usage("empty --title") + } + description := strings.TrimSpace(c.Description) + + if dryRunErr := dryRunExit(ctx, flags, "forms.create", map[string]any{ + "title": title, + "description": description, + }); dryRunErr != nil { + return dryRunErr + } + + svc, err := newFormsService(ctx, account) + if err != nil { + return err + } + + req := &formsapi.Form{Info: &formsapi.Info{ + Title: title, + Description: description, + }} + form, err := svc.Forms.Create(req).Context(ctx).Do() + if err != nil { + return err + } + + formID := strings.TrimSpace(form.FormId) + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "created": true, + "form": form, + "edit_url": formEditURL(formID), + }) + } + + u := ui.FromContext(ctx) + u.Out().Printf("created\ttrue") + printFormSummary(u, form, formID) + return nil +} + +type FormsResponsesListCmd struct { + FormID string `arg:"" name:"formId" help:"Form ID"` + Max int `name:"max" help:"Maximum responses" default:"20"` + Page string `name:"page" help:"Page token"` + Filter string `name:"filter" help:"Filter expression"` +} + +func (c *FormsResponsesListCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + formID := strings.TrimSpace(normalizeGoogleID(c.FormID)) + if formID == "" { + return usage("empty formId") + } + if c.Max <= 0 { + return usage("--max must be > 0") + } + + svc, err := newFormsService(ctx, account) + if err != nil { + return err + } + + call := svc.Forms.Responses.List(formID).PageSize(int64(c.Max)).Context(ctx) + if page := strings.TrimSpace(c.Page); page != "" { + call = call.PageToken(page) + } + if filter := strings.TrimSpace(c.Filter); filter != "" { + call = call.Filter(filter) + } + resp, err := call.Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "form_id": formID, + "responses": resp.Responses, + "nextPageToken": resp.NextPageToken, + }) + } + + u := ui.FromContext(ctx) + u.Out().Println("RESPONSE_ID\tSUBMITTED\tEMAIL") + for _, item := range resp.Responses { + if item == nil { + continue + } + submitted := firstFormTime(item.LastSubmittedTime, item.CreateTime) + u.Out().Printf("%s\t%s\t%s", item.ResponseId, submitted, item.RespondentEmail) + } + if next := strings.TrimSpace(resp.NextPageToken); next != "" { + u.Err().Println("# Next page: --page " + next) + } + return nil +} + +type FormsResponseGetCmd struct { + FormID string `arg:"" name:"formId" help:"Form ID"` + ResponseID string `arg:"" name:"responseId" help:"Response ID"` +} + +func (c *FormsResponseGetCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + formID := strings.TrimSpace(normalizeGoogleID(c.FormID)) + if formID == "" { + return usage("empty formId") + } + responseID := strings.TrimSpace(c.ResponseID) + if responseID == "" { + return usage("empty responseId") + } + + svc, err := newFormsService(ctx, account) + if err != nil { + return err + } + resp, err := svc.Forms.Responses.Get(formID, responseID).Context(ctx).Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "response": resp, + }) + } + + u := ui.FromContext(ctx) + u.Out().Printf("response_id\t%s", resp.ResponseId) + u.Out().Printf("submitted\t%s", firstFormTime(resp.LastSubmittedTime, resp.CreateTime)) + if resp.RespondentEmail != "" { + u.Out().Printf("email\t%s", resp.RespondentEmail) + } + u.Out().Printf("answers\t%d", len(resp.Answers)) + if resp.TotalScore != 0 { + u.Out().Printf("total_score\t%s", strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", resp.TotalScore), "0"), ".")) + } + return nil +} + +func printFormSummary(u *ui.UI, form *formsapi.Form, fallbackID string) { + if u == nil || form == nil { + return + } + formID := strings.TrimSpace(form.FormId) + if formID == "" { + formID = strings.TrimSpace(fallbackID) + } + u.Out().Printf("id\t%s", formID) + if form.Info != nil { + if form.Info.Title != "" { + u.Out().Printf("title\t%s", form.Info.Title) + } + if form.Info.Description != "" { + u.Out().Printf("description\t%s", form.Info.Description) + } + } + if form.ResponderUri != "" { + u.Out().Printf("responder_uri\t%s", form.ResponderUri) + } + u.Out().Printf("edit_url\t%s", formEditURL(formID)) +} + +func formEditURL(formID string) string { + formID = strings.TrimSpace(formID) + if formID == "" { + return "" + } + return "https://docs.google.com/forms/d/" + formID + "/edit" +} + +func firstFormTime(values ...string) string { + for _, v := range values { + if s := strings.TrimSpace(v); s != "" { + return s + } + } + return "" +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 91df3be..e1a7da9 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -29,7 +29,7 @@ const ( type RootFlags struct { Color string `help:"Color output: auto|always|never" default:"${color}"` - Account string `help:"Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets)" aliases:"acct" short:"a"` + Account string `help:"Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript)" aliases:"acct" short:"a"` Client string `help:"OAuth client name (selects stored credentials + token bucket)" default:"${client}"` EnableCommands string `help:"Comma-separated list of enabled top-level commands (restricts CLI)" default:"${enabled_commands}"` JSON bool `help:"Output JSON to stdout (best for scripting)" default:"${json}" aliases:"machine" short:"j"` @@ -75,6 +75,8 @@ type CLI struct { 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"` @@ -325,7 +327,7 @@ func newParser(description string) (*kong.Kong, *CLI, error) { } func baseDescription() string { - return "Google CLI for Gmail/Calendar/Chat/Classroom/Drive/Contacts/Tasks/Sheets/Docs/Slides/People" + return "Google CLI for Gmail/Calendar/Chat/Classroom/Drive/Contacts/Tasks/Sheets/Docs/Slides/People/Forms/App Script" } func helpDescription() string { diff --git a/internal/googleapi/appscript.go b/internal/googleapi/appscript.go new file mode 100644 index 0000000..65c9921 --- /dev/null +++ b/internal/googleapi/appscript.go @@ -0,0 +1,20 @@ +package googleapi + +import ( + "context" + "fmt" + + "google.golang.org/api/script/v1" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewAppScript(ctx context.Context, email string) (*script.Service, error) { + if opts, err := optionsForAccount(ctx, googleauth.ServiceAppScript, email); err != nil { + return nil, fmt.Errorf("appscript options: %w", err) + } else if svc, err := script.NewService(ctx, opts...); err != nil { + return nil, fmt.Errorf("create appscript service: %w", err) + } else { + return svc, nil + } +} diff --git a/internal/googleapi/forms.go b/internal/googleapi/forms.go new file mode 100644 index 0000000..05a6af8 --- /dev/null +++ b/internal/googleapi/forms.go @@ -0,0 +1,20 @@ +package googleapi + +import ( + "context" + "fmt" + + "google.golang.org/api/forms/v1" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewForms(ctx context.Context, email string) (*forms.Service, error) { + if opts, err := optionsForAccount(ctx, googleauth.ServiceForms, email); err != nil { + return nil, fmt.Errorf("forms options: %w", err) + } else if svc, err := forms.NewService(ctx, opts...); err != nil { + return nil, fmt.Errorf("create forms service: %w", err) + } else { + return svc, nil + } +} diff --git a/internal/googleauth/service.go b/internal/googleauth/service.go index 74933b0..f464af9 100644 --- a/internal/googleauth/service.go +++ b/internal/googleauth/service.go @@ -21,6 +21,8 @@ const ( ServiceTasks Service = "tasks" ServicePeople Service = "people" ServiceSheets Service = "sheets" + ServiceForms Service = "forms" + ServiceAppScript Service = "appscript" ServiceGroups Service = "groups" ServiceKeep Service = "keep" ) @@ -68,6 +70,8 @@ var serviceOrder = []Service{ ServiceTasks, ServiceSheets, ServicePeople, + ServiceForms, + ServiceAppScript, ServiceGroups, ServiceKeep, } @@ -170,6 +174,23 @@ var serviceInfoByService = map[Service]serviceInfo{ apis: []string{"Sheets API", "Drive API"}, note: "Export via Drive", }, + ServiceForms: { + scopes: []string{ + "https://www.googleapis.com/auth/forms.body", + "https://www.googleapis.com/auth/forms.responses.readonly", + }, + user: true, + apis: []string{"Forms API"}, + }, + ServiceAppScript: { + scopes: []string{ + "https://www.googleapis.com/auth/script.projects", + "https://www.googleapis.com/auth/script.deployments", + "https://www.googleapis.com/auth/script.processes", + }, + user: true, + apis: []string{"Apps Script API"}, + }, ServiceGroups: { scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"}, user: false, @@ -478,6 +499,25 @@ func scopesForServiceWithOptions(service Service, opts ScopeOptions) ([]string, } return []string{driveScopeValue(), sheetsScope}, nil + case ServiceForms: + formBodyScope := "https://www.googleapis.com/auth/forms.body" + if opts.Readonly { + formBodyScope = "https://www.googleapis.com/auth/forms.body.readonly" + } + + return []string{ + formBodyScope, + "https://www.googleapis.com/auth/forms.responses.readonly", + }, nil + case ServiceAppScript: + if opts.Readonly { + return []string{ + "https://www.googleapis.com/auth/script.projects.readonly", + "https://www.googleapis.com/auth/script.deployments.readonly", + }, nil + } + + return Scopes(service) case ServiceGroups: return Scopes(service) case ServiceKeep: diff --git a/internal/googleauth/service_test.go b/internal/googleauth/service_test.go index 6baebfe..79804f8 100644 --- a/internal/googleauth/service_test.go +++ b/internal/googleauth/service_test.go @@ -19,6 +19,8 @@ func TestParseService(t *testing.T) { {"tasks", ServiceTasks}, {"people", ServicePeople}, {"sheets", ServiceSheets}, + {"forms", ServiceForms}, + {"appscript", ServiceAppScript}, {"groups", ServiceGroups}, {"keep", ServiceKeep}, } @@ -63,7 +65,7 @@ func TestExtractCodeAndState_Errors(t *testing.T) { func TestAllServices(t *testing.T) { svcs := AllServices() - if len(svcs) != 13 { + if len(svcs) != 15 { t.Fatalf("unexpected: %v", svcs) } seen := make(map[Service]bool) @@ -72,7 +74,7 @@ func TestAllServices(t *testing.T) { seen[s] = true } - for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceChat, ServiceClassroom, ServiceDrive, ServiceDocs, ServiceSlides, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets, ServiceGroups, ServiceKeep} { + for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceChat, ServiceClassroom, ServiceDrive, ServiceDocs, ServiceSlides, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets, ServiceForms, ServiceAppScript, ServiceGroups, ServiceKeep} { if !seen[want] { t.Fatalf("missing %q", want) } @@ -81,7 +83,7 @@ func TestAllServices(t *testing.T) { func TestUserServices(t *testing.T) { svcs := UserServices() - if len(svcs) != 11 { + if len(svcs) != 13 { t.Fatalf("unexpected: %v", svcs) } @@ -94,6 +96,8 @@ func TestUserServices(t *testing.T) { seenDocs = true case ServiceSlides: seenSlides = true + case ServiceForms, ServiceAppScript: + // expected user services case ServiceKeep: t.Fatalf("unexpected keep in user services") } @@ -109,7 +113,7 @@ func TestUserServices(t *testing.T) { } func TestUserServiceCSV(t *testing.T) { - want := "gmail,calendar,chat,classroom,drive,docs,slides,contacts,tasks,sheets,people" + want := "gmail,calendar,chat,classroom,drive,docs,slides,contacts,tasks,sheets,people,forms,appscript" if got := UserServiceCSV(); got != want { t.Fatalf("unexpected user services csv: %q", got) } @@ -225,7 +229,7 @@ func TestScopesForServices_UnionSorted(t *testing.T) { } func TestScopesForManageWithOptions_Readonly(t *testing.T) { - scopes, err := ScopesForManageWithOptions([]Service{ServiceGmail, ServiceDrive, ServiceCalendar, ServiceContacts, ServiceTasks, ServiceSheets, ServiceDocs, ServicePeople}, ScopeOptions{ + scopes, err := ScopesForManageWithOptions([]Service{ServiceGmail, ServiceDrive, ServiceCalendar, ServiceContacts, ServiceTasks, ServiceSheets, ServiceDocs, ServicePeople, ServiceForms, ServiceAppScript}, ScopeOptions{ Readonly: true, DriveScope: DriveScopeFull, }) @@ -244,6 +248,10 @@ func TestScopesForManageWithOptions_Readonly(t *testing.T) { "https://www.googleapis.com/auth/tasks.readonly", "https://www.googleapis.com/auth/spreadsheets.readonly", "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/forms.body.readonly", + "https://www.googleapis.com/auth/forms.responses.readonly", + "https://www.googleapis.com/auth/script.projects.readonly", + "https://www.googleapis.com/auth/script.deployments.readonly", "profile", } for _, w := range want { @@ -428,6 +436,40 @@ func TestScopes_GmailIncludesSettingsSharing(t *testing.T) { } } +func TestScopes_FormsIncludesBodyAndResponses(t *testing.T) { + scopes, err := Scopes(ServiceForms) + if err != nil { + t.Fatalf("err: %v", err) + } + + if !containsScope(scopes, "https://www.googleapis.com/auth/forms.body") { + t.Fatalf("missing forms.body in %v", scopes) + } + + if !containsScope(scopes, "https://www.googleapis.com/auth/forms.responses.readonly") { + t.Fatalf("missing forms.responses.readonly in %v", scopes) + } +} + +func TestScopesForServiceWithOptions_AppScriptReadonly(t *testing.T) { + scopes, err := scopesForServiceWithOptions(ServiceAppScript, ScopeOptions{Readonly: true}) + if err != nil { + t.Fatalf("err: %v", err) + } + + if !containsScope(scopes, "https://www.googleapis.com/auth/script.projects.readonly") { + t.Fatalf("missing script.projects.readonly in %v", scopes) + } + + if !containsScope(scopes, "https://www.googleapis.com/auth/script.deployments.readonly") { + t.Fatalf("missing script.deployments.readonly in %v", scopes) + } + + if containsScope(scopes, "https://www.googleapis.com/auth/script.projects") { + t.Fatalf("unexpected script.projects in %v", scopes) + } +} + func TestScopes_UnknownService(t *testing.T) { if _, err := Scopes(Service("nope")); err == nil { t.Fatalf("expected error")