diff --git a/README.md b/README.md
index 241c93c..f8a76c7 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@

-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")