feat(cli): add forms and appscript commands

This commit is contained in:
Peter Steinberger 2026-02-14 06:24:41 +01:00
parent b460a426fb
commit 3b2ab325af
9 changed files with 1031 additions and 8 deletions

View File

@ -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)
<!-- Created with GitHub Repo Banner by Waren Gonzaga: https://ghrb.waren.build -->
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`<br>`https://www.googleapis.com/auth/classroom.rosters`<br>`https://www.googleapis.com/auth/classroom.coursework.students`<br>`https://www.googleapis.com/auth/classroom.coursework.me`<br>`https://www.googleapis.com/auth/classroom.courseworkmaterials`<br>`https://www.googleapis.com/auth/classroom.announcements`<br>`https://www.googleapis.com/auth/classroom.topics`<br>`https://www.googleapis.com/auth/classroom.guardianlinks.students`<br>`https://www.googleapis.com/auth/classroom.profile.emails`<br>`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`<br>`https://www.googleapis.com/auth/documents` | Export/copy/create via Drive |
| slides | yes | Slides API, Drive API | `https://www.googleapis.com/auth/drive`<br>`https://www.googleapis.com/auth/presentations` | Create/edit presentations |
| contacts | yes | People API | `https://www.googleapis.com/auth/contacts`<br>`https://www.googleapis.com/auth/contacts.other.readonly`<br>`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`<br>`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`<br>`https://www.googleapis.com/auth/forms.responses.readonly` | |
| appscript | yes | Apps Script API | `https://www.googleapis.com/auth/script.projects`<br>`https://www.googleapis.com/auth/script.deployments`<br>`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) |
<!-- auth-services:end -->
@ -922,6 +929,32 @@ gog sheets format <spreadsheetId> 'Sheet1!A1:B2' --format-json '{"textFormat":{"
gog sheets create "My New Spreadsheet" --sheets "Sheet1,Sheet2"
```
### Forms
```bash
# Forms
gog forms get <formId>
gog forms create --title "Weekly Check-in" --description "Friday async update"
# Responses
gog forms responses list <formId> --max 20
gog forms responses get <formId> <responseId>
```
### Apps Script
```bash
# Projects
gog appscript get <scriptId>
gog appscript content <scriptId>
gog appscript create --title "Automation Helpers"
gog appscript create --title "Bound Script" --parent-id <driveFileId>
# Execute functions
gog appscript run <scriptId> myFunction --params '["arg1", 123, true]'
gog appscript run <scriptId> myFunction --dev-mode
```
### People
```bash

276
internal/cmd/appscript.go Normal file
View File

@ -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"
}

View File

@ -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)
}
}

262
internal/cmd/forms.go Normal file
View File

@ -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 ""
}

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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:

View File

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