Skip to content

Commit 3b2ab32

Browse files
committed
feat(cli): add forms and appscript commands
1 parent b460a42 commit 3b2ab32

9 files changed

Lines changed: 1031 additions & 8 deletions

File tree

README.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
![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)
44
<!-- Created with GitHub Repo Banner by Waren Gonzaga: https://ghrb.waren.build -->
55

6-
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.
6+
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.
77

88
## Features
99

@@ -16,6 +16,8 @@ Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Sli
1616
- **Contacts** - search/create/update contacts, access Workspace directory/other contacts
1717
- **Tasks** - manage tasklists and tasks: get/create/add/update/done/undo/delete/clear, repeat schedules
1818
- **Sheets** - read/write/update spreadsheets, format cells, create new sheets (and export via Drive)
19+
- **Forms** - create/get forms and inspect responses
20+
- **Apps Script** - create/get projects, inspect content, and run functions
1921
- **Docs/Slides** - export to PDF/DOCX/PPTX via Drive (plus create/copy, docs-to-text)
2022
- **People** - access profile information
2123
- **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:
7678
- People API (Contacts): https://console.cloud.google.com/apis/api/people.googleapis.com
7779
- Google Tasks API: https://console.cloud.google.com/apis/api/tasks.googleapis.com
7880
- Google Sheets API: https://console.cloud.google.com/apis/api/sheets.googleapis.com
81+
- Google Forms API: https://console.cloud.google.com/apis/api/forms.googleapis.com
82+
- Apps Script API: https://console.cloud.google.com/apis/api/script.googleapis.com
7983
- Cloud Identity API (Groups): https://console.cloud.google.com/apis/api/cloudidentity.googleapis.com
8084
3. Configure OAuth consent screen: https://console.cloud.google.com/auth/branding
8185
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
337341
| 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` | |
338342
| drive | yes | Drive API | `https://www.googleapis.com/auth/drive` | |
339343
| docs | yes | Docs API, Drive API | `https://www.googleapis.com/auth/drive`<br>`https://www.googleapis.com/auth/documents` | Export/copy/create via Drive |
344+
| slides | yes | Slides API, Drive API | `https://www.googleapis.com/auth/drive`<br>`https://www.googleapis.com/auth/presentations` | Create/edit presentations |
340345
| 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 |
341346
| tasks | yes | Tasks API | `https://www.googleapis.com/auth/tasks` | |
342347
| sheets | yes | Sheets API, Drive API | `https://www.googleapis.com/auth/drive`<br>`https://www.googleapis.com/auth/spreadsheets` | Export via Drive |
343348
| people | yes | People API | `profile` | OIDC profile scope |
349+
| forms | yes | Forms API | `https://www.googleapis.com/auth/forms.body`<br>`https://www.googleapis.com/auth/forms.responses.readonly` | |
350+
| 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` | |
344351
| groups | no | Cloud Identity API | `https://www.googleapis.com/auth/cloud-identity.groups.readonly` | Workspace only |
345352
| keep | no | Keep API | `https://www.googleapis.com/auth/keep.readonly` | Workspace only; service account (domain-wide delegation) |
346353
<!-- auth-services:end -->
@@ -922,6 +929,32 @@ gog sheets format <spreadsheetId> 'Sheet1!A1:B2' --format-json '{"textFormat":{"
922929
gog sheets create "My New Spreadsheet" --sheets "Sheet1,Sheet2"
923930
```
924931

932+
### Forms
933+
934+
```bash
935+
# Forms
936+
gog forms get <formId>
937+
gog forms create --title "Weekly Check-in" --description "Friday async update"
938+
939+
# Responses
940+
gog forms responses list <formId> --max 20
941+
gog forms responses get <formId> <responseId>
942+
```
943+
944+
### Apps Script
945+
946+
```bash
947+
# Projects
948+
gog appscript get <scriptId>
949+
gog appscript content <scriptId>
950+
gog appscript create --title "Automation Helpers"
951+
gog appscript create --title "Bound Script" --parent-id <driveFileId>
952+
953+
# Execute functions
954+
gog appscript run <scriptId> myFunction --params '["arg1", 123, true]'
955+
gog appscript run <scriptId> myFunction --dev-mode
956+
```
957+
925958
### People
926959

927960
```bash

internal/cmd/appscript.go

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"os"
7+
"strings"
8+
9+
scriptapi "google.golang.org/api/script/v1"
10+
11+
"github.com/steipete/gogcli/internal/googleapi"
12+
"github.com/steipete/gogcli/internal/outfmt"
13+
"github.com/steipete/gogcli/internal/ui"
14+
)
15+
16+
var newAppScriptService = googleapi.NewAppScript
17+
18+
type AppScriptCmd struct {
19+
Get AppScriptGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get Apps Script project metadata"`
20+
Content AppScriptContentCmd `cmd:"" name:"content" aliases:"cat" help:"Get Apps Script project content"`
21+
Run AppScriptRunCmd `cmd:"" name:"run" help:"Run a deployed Apps Script function"`
22+
Create AppScriptCreateCmd `cmd:"" name:"create" aliases:"new" help:"Create an Apps Script project"`
23+
}
24+
25+
type AppScriptGetCmd struct {
26+
ScriptID string `arg:"" name:"scriptId" help:"Script ID"`
27+
}
28+
29+
func (c *AppScriptGetCmd) Run(ctx context.Context, flags *RootFlags) error {
30+
account, err := requireAccount(flags)
31+
if err != nil {
32+
return err
33+
}
34+
scriptID := strings.TrimSpace(normalizeGoogleID(c.ScriptID))
35+
if scriptID == "" {
36+
return usage("empty scriptId")
37+
}
38+
39+
svc, err := newAppScriptService(ctx, account)
40+
if err != nil {
41+
return err
42+
}
43+
project, err := svc.Projects.Get(scriptID).Context(ctx).Do()
44+
if err != nil {
45+
return err
46+
}
47+
48+
if outfmt.IsJSON(ctx) {
49+
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
50+
"project": project,
51+
"editor_url": appScriptEditURL(scriptID),
52+
})
53+
}
54+
55+
u := ui.FromContext(ctx)
56+
u.Out().Printf("script_id\t%s", project.ScriptId)
57+
if project.Title != "" {
58+
u.Out().Printf("title\t%s", project.Title)
59+
}
60+
if project.ParentId != "" {
61+
u.Out().Printf("parent_id\t%s", project.ParentId)
62+
}
63+
if project.CreateTime != "" {
64+
u.Out().Printf("created\t%s", project.CreateTime)
65+
}
66+
if project.UpdateTime != "" {
67+
u.Out().Printf("updated\t%s", project.UpdateTime)
68+
}
69+
u.Out().Printf("editor_url\t%s", appScriptEditURL(scriptID))
70+
return nil
71+
}
72+
73+
type AppScriptContentCmd struct {
74+
ScriptID string `arg:"" name:"scriptId" help:"Script ID"`
75+
}
76+
77+
func (c *AppScriptContentCmd) Run(ctx context.Context, flags *RootFlags) error {
78+
account, err := requireAccount(flags)
79+
if err != nil {
80+
return err
81+
}
82+
scriptID := strings.TrimSpace(normalizeGoogleID(c.ScriptID))
83+
if scriptID == "" {
84+
return usage("empty scriptId")
85+
}
86+
87+
svc, err := newAppScriptService(ctx, account)
88+
if err != nil {
89+
return err
90+
}
91+
content, err := svc.Projects.GetContent(scriptID).Context(ctx).Do()
92+
if err != nil {
93+
return err
94+
}
95+
96+
if outfmt.IsJSON(ctx) {
97+
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
98+
"content": content,
99+
})
100+
}
101+
102+
u := ui.FromContext(ctx)
103+
u.Out().Printf("script_id\t%s", content.ScriptId)
104+
u.Out().Printf("files\t%d", len(content.Files))
105+
for _, file := range content.Files {
106+
if file == nil {
107+
continue
108+
}
109+
u.Out().Printf("file\t%s\t%s", file.Name, file.Type)
110+
}
111+
return nil
112+
}
113+
114+
type AppScriptRunCmd struct {
115+
ScriptID string `arg:"" name:"scriptId" help:"Script ID"`
116+
Function string `arg:"" name:"function" help:"Function name to run"`
117+
Params string `name:"params" help:"JSON array of function parameters" default:"[]"`
118+
DevMode bool `name:"dev-mode" help:"Run latest saved code if you own the script"`
119+
}
120+
121+
func (c *AppScriptRunCmd) Run(ctx context.Context, flags *RootFlags) error {
122+
account, err := requireAccount(flags)
123+
if err != nil {
124+
return err
125+
}
126+
scriptID := strings.TrimSpace(normalizeGoogleID(c.ScriptID))
127+
if scriptID == "" {
128+
return usage("empty scriptId")
129+
}
130+
function := strings.TrimSpace(c.Function)
131+
if function == "" {
132+
return usage("empty function")
133+
}
134+
135+
params, err := parseJSONArray(c.Params)
136+
if err != nil {
137+
return err
138+
}
139+
140+
svc, err := newAppScriptService(ctx, account)
141+
if err != nil {
142+
return err
143+
}
144+
op, err := svc.Scripts.Run(scriptID, &scriptapi.ExecutionRequest{
145+
Function: function,
146+
Parameters: params,
147+
DevMode: c.DevMode,
148+
}).Context(ctx).Do()
149+
if err != nil {
150+
return err
151+
}
152+
153+
if outfmt.IsJSON(ctx) {
154+
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
155+
"operation": op,
156+
})
157+
}
158+
159+
u := ui.FromContext(ctx)
160+
u.Out().Printf("done\t%t", op.Done)
161+
162+
if op.Error != nil {
163+
if op.Error.Code != 0 {
164+
u.Out().Printf("error_code\t%d", op.Error.Code)
165+
}
166+
if op.Error.Message != "" {
167+
u.Out().Printf("error\t%s", op.Error.Message)
168+
}
169+
if detail := parseExecutionError(op.Error); detail != nil {
170+
if detail.ErrorType != "" {
171+
u.Out().Printf("error_type\t%s", detail.ErrorType)
172+
}
173+
if detail.ErrorMessage != "" {
174+
u.Out().Printf("error_message\t%s", detail.ErrorMessage)
175+
}
176+
}
177+
return nil
178+
}
179+
180+
if len(op.Response) > 0 {
181+
var execResp scriptapi.ExecutionResponse
182+
if err := json.Unmarshal(op.Response, &execResp); err == nil && execResp.Result != nil {
183+
if b, marshalErr := json.Marshal(execResp.Result); marshalErr == nil {
184+
u.Out().Printf("result\t%s", string(b))
185+
}
186+
}
187+
}
188+
return nil
189+
}
190+
191+
type AppScriptCreateCmd struct {
192+
Title string `name:"title" help:"Project title" required:""`
193+
ParentID string `name:"parent-id" help:"Optional Drive file ID to bind to"`
194+
}
195+
196+
func (c *AppScriptCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
197+
account, err := requireAccount(flags)
198+
if err != nil {
199+
return err
200+
}
201+
title := strings.TrimSpace(c.Title)
202+
if title == "" {
203+
return usage("empty --title")
204+
}
205+
parentID := strings.TrimSpace(normalizeGoogleID(c.ParentID))
206+
207+
if dryRunErr := dryRunExit(ctx, flags, "appscript.create", map[string]any{
208+
"title": title,
209+
"parent_id": parentID,
210+
}); dryRunErr != nil {
211+
return dryRunErr
212+
}
213+
214+
svc, err := newAppScriptService(ctx, account)
215+
if err != nil {
216+
return err
217+
}
218+
project, err := svc.Projects.Create(&scriptapi.CreateProjectRequest{
219+
Title: title,
220+
ParentId: parentID,
221+
}).Context(ctx).Do()
222+
if err != nil {
223+
return err
224+
}
225+
226+
if outfmt.IsJSON(ctx) {
227+
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
228+
"created": true,
229+
"project": project,
230+
"editor_url": appScriptEditURL(project.ScriptId),
231+
})
232+
}
233+
234+
u := ui.FromContext(ctx)
235+
u.Out().Printf("created\ttrue")
236+
u.Out().Printf("script_id\t%s", project.ScriptId)
237+
if project.Title != "" {
238+
u.Out().Printf("title\t%s", project.Title)
239+
}
240+
if project.ParentId != "" {
241+
u.Out().Printf("parent_id\t%s", project.ParentId)
242+
}
243+
u.Out().Printf("editor_url\t%s", appScriptEditURL(project.ScriptId))
244+
return nil
245+
}
246+
247+
func parseJSONArray(raw string) ([]interface{}, error) {
248+
val := strings.TrimSpace(raw)
249+
if val == "" {
250+
return nil, nil
251+
}
252+
var out []interface{}
253+
if err := json.Unmarshal([]byte(val), &out); err != nil {
254+
return nil, usagef("invalid --params JSON array: %v", err)
255+
}
256+
return out, nil
257+
}
258+
259+
func parseExecutionError(status *scriptapi.Status) *scriptapi.ExecutionError {
260+
if status == nil || len(status.Details) == 0 {
261+
return nil
262+
}
263+
var detail scriptapi.ExecutionError
264+
if err := json.Unmarshal(status.Details[0], &detail); err != nil {
265+
return nil
266+
}
267+
return &detail
268+
}
269+
270+
func appScriptEditURL(scriptID string) string {
271+
scriptID = strings.TrimSpace(scriptID)
272+
if scriptID == "" {
273+
return ""
274+
}
275+
return "https://script.google.com/d/" + scriptID + "/edit"
276+
}

0 commit comments

Comments
 (0)