From 66d0d64fa4a28e747a3830a0e15e8a30d7d18e70 Mon Sep 17 00:00:00 2001 From: Daniel Blignaut Date: Fri, 15 May 2026 16:25:30 +0200 Subject: [PATCH 1/5] provider harness base for exp_4b83a548-18c --- internal/app/registry.go | 3 +- internal/cli/cli.go | 52 +- .../calendar-check/cli-metadata.yaml | 16 + .../calendar-check/e2e_test.go | 37 ++ .../calendar-check/generator-prompt.md | 4 + .../calendar-check/input-schema.yaml | 21 + .../google-workspace/calendar-check/mod.go | 89 ++++ .../calendar-check/output-schema.yaml | 25 + .../calendar-create/cli-metadata.yaml | 15 + .../calendar-create/e2e_test.go | 45 ++ .../calendar-create/generator-prompt.md | 5 + .../calendar-create/input-schema.yaml | 35 ++ .../google-workspace/calendar-create/mod.go | 125 +++++ .../calendar-create/output-schema.yaml | 23 + providers/google-workspace/cli-metadata.yaml | 25 + .../google-workspace/generator-metadata.yaml | 389 +++++++++++++++ .../google-workspace/generator-prompt.md | 6 + .../gmail-check/cli-metadata.yaml | 16 + .../google-workspace/gmail-check/e2e_test.go | 33 ++ .../gmail-check/generator-prompt.md | 5 + .../gmail-check/input-schema.yaml | 13 + providers/google-workspace/gmail-check/mod.go | 61 +++ .../gmail-check/output-schema.yaml | 19 + .../gmail-send/cli-metadata.yaml | 15 + .../google-workspace/gmail-send/e2e_test.go | 36 ++ .../gmail-send/generator-prompt.md | 5 + .../gmail-send/input-schema.yaml | 28 ++ providers/google-workspace/gmail-send/mod.go | 62 +++ .../gmail-send/output-schema.yaml | 10 + .../google-workspace/internal/e2e/helpers.go | 155 ++++++ .../internal/googleapi/client.go | 458 ++++++++++++++++++ providers/google-workspace/mod.go | 41 ++ .../test_secrets.example.yaml | 7 + 33 files changed, 1877 insertions(+), 2 deletions(-) create mode 100644 providers/google-workspace/calendar-check/cli-metadata.yaml create mode 100644 providers/google-workspace/calendar-check/e2e_test.go create mode 100644 providers/google-workspace/calendar-check/generator-prompt.md create mode 100644 providers/google-workspace/calendar-check/input-schema.yaml create mode 100644 providers/google-workspace/calendar-check/mod.go create mode 100644 providers/google-workspace/calendar-check/output-schema.yaml create mode 100644 providers/google-workspace/calendar-create/cli-metadata.yaml create mode 100644 providers/google-workspace/calendar-create/e2e_test.go create mode 100644 providers/google-workspace/calendar-create/generator-prompt.md create mode 100644 providers/google-workspace/calendar-create/input-schema.yaml create mode 100644 providers/google-workspace/calendar-create/mod.go create mode 100644 providers/google-workspace/calendar-create/output-schema.yaml create mode 100644 providers/google-workspace/cli-metadata.yaml create mode 100644 providers/google-workspace/generator-metadata.yaml create mode 100644 providers/google-workspace/generator-prompt.md create mode 100644 providers/google-workspace/gmail-check/cli-metadata.yaml create mode 100644 providers/google-workspace/gmail-check/e2e_test.go create mode 100644 providers/google-workspace/gmail-check/generator-prompt.md create mode 100644 providers/google-workspace/gmail-check/input-schema.yaml create mode 100644 providers/google-workspace/gmail-check/mod.go create mode 100644 providers/google-workspace/gmail-check/output-schema.yaml create mode 100644 providers/google-workspace/gmail-send/cli-metadata.yaml create mode 100644 providers/google-workspace/gmail-send/e2e_test.go create mode 100644 providers/google-workspace/gmail-send/generator-prompt.md create mode 100644 providers/google-workspace/gmail-send/input-schema.yaml create mode 100644 providers/google-workspace/gmail-send/mod.go create mode 100644 providers/google-workspace/gmail-send/output-schema.yaml create mode 100644 providers/google-workspace/internal/e2e/helpers.go create mode 100644 providers/google-workspace/internal/googleapi/client.go create mode 100644 providers/google-workspace/mod.go create mode 100644 providers/google-workspace/test_secrets.example.yaml diff --git a/internal/app/registry.go b/internal/app/registry.go index 45cfb16..0b516a3 100644 --- a/internal/app/registry.go +++ b/internal/app/registry.go @@ -3,8 +3,9 @@ package app import ( "cli-factory/internal/provider" "cli-factory/providers/example" + googleworkspace "cli-factory/providers/google-workspace" ) func Registry() (*provider.Registry, error) { - return provider.NewRegistry(example.New()) + return provider.NewRegistry(example.New(), googleworkspace.New()) } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 7236aa4..7f8aae9 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -201,7 +201,7 @@ func (a App) invokeTool(ctx context.Context, flags globalFlags, original []strin if err := validateRequired(rt.Tool.InputSchema(), req.Params); err != nil { return a.finish(ctx, flags, original, providerToolIDs(providerID, toolID), nil, errObj("validation_failed", err.Error(), false), 2) } - rec, err := invocationlog.New(flags.LogDir, original) + rec, err := invocationlog.New(flags.LogDir, redactProviderSecrets(rt.Provider, original)) if err != nil { fmt.Fprintln(a.Stderr, err) return 1 @@ -390,3 +390,53 @@ func asProviderError(err error) *provider.Error { func providerToolIDs(providerID, toolID string) []string { return []string{providerID, toolID} } + +func redactProviderSecrets(p provider.Provider, args []string) []string { + if p == nil { + return append([]string(nil), args...) + } + secrets := map[string]bool{} + for _, param := range p.Parameters() { + if param.Secret { + secrets[param.Name] = true + secrets[strings.ReplaceAll(param.Name, "_", "-")] = true + } + } + if len(secrets) == 0 { + return append([]string(nil), args...) + } + out := append([]string(nil), args...) + for i := 0; i < len(out); i++ { + name := strings.TrimPrefix(out[i], "--") + switch name { + case "provider-params-json": + if i+1 < len(out) { + out[i+1] = redactProviderParamsJSON(out[i+1], secrets) + i++ + } + default: + if strings.HasPrefix(out[i], "--") && secrets[name] && i+1 < len(out) { + out[i+1] = "[REDACTED]" + i++ + } + } + } + return out +} + +func redactProviderParamsJSON(value string, secrets map[string]bool) string { + params := map[string]any{} + if err := json.Unmarshal([]byte(value), ¶ms); err != nil { + return "[REDACTED_PROVIDER_PARAMS]" + } + for name := range params { + if secrets[name] || secrets[strings.ReplaceAll(name, "_", "-")] { + params[name] = "[REDACTED]" + } + } + data, err := json.Marshal(params) + if err != nil { + return "[REDACTED_PROVIDER_PARAMS]" + } + return string(data) +} diff --git a/providers/google-workspace/calendar-check/cli-metadata.yaml b/providers/google-workspace/calendar-check/cli-metadata.yaml new file mode 100644 index 0000000..fa112f1 --- /dev/null +++ b/providers/google-workspace/calendar-check/cli-metadata.yaml @@ -0,0 +1,16 @@ +id: calendar-check +name: Calendar Check +short_description: Search Google Calendar events. +long_description: | + List or search Google Calendar events in a time window on the configured + calendar. +categories: + - calendar + - scheduling + - search +aliases: + - check-events + - list-events +command_path: + - google-workspace + - calendar-check diff --git a/providers/google-workspace/calendar-check/e2e_test.go b/providers/google-workspace/calendar-check/e2e_test.go new file mode 100644 index 0000000..a8e6e71 --- /dev/null +++ b/providers/google-workspace/calendar-check/e2e_test.go @@ -0,0 +1,37 @@ +package calendarcheck_test + +import ( + "context" + "testing" + "time" + + "cli-factory/providers/google-workspace/internal/e2e" + "cli-factory/providers/google-workspace/internal/googleapi" +) + +func TestE2ECalendarCheckCommand(t *testing.T) { + secrets := e2e.LoadSecrets(t) + client := secrets.Client(t) + summary := e2e.Unique("cli-factory-calendar-check") + start := time.Now().UTC().Add(3 * time.Hour).Truncate(time.Second) + end := start.Add(30 * time.Minute) + event, err := client.CreateEvent(context.Background(), googleapi.CalendarEventInput{ + Summary: summary, + Start: start.Format(time.RFC3339), + End: end.Format(time.RFC3339), + Description: "Google Workspace calendar-check e2e.", + }) + if err != nil { + t.Fatalf("seed Calendar event: %v", err) + } + t.Cleanup(func() { _ = client.DeleteEvent(context.Background(), event.ID) }) + log := e2e.RunFactory(t, + "google-workspace", "calendar-check", + "--provider-params-json", secrets.ProviderParamsJSON(t), + "--params-json", `{"time_min":"`+start.Add(-time.Hour).Format(time.RFC3339)+`","time_max":"`+end.Add(time.Hour).Format(time.RFC3339)+`","query":"`+summary+`","max_results":10,"single_events":true}`, + ) + data := e2e.Data(t, log) + if count, _ := data["result_count"].(float64); count < 1 { + t.Fatalf("result_count = %v, want at least 1: %#v", data["result_count"], data) + } +} diff --git a/providers/google-workspace/calendar-check/generator-prompt.md b/providers/google-workspace/calendar-check/generator-prompt.md new file mode 100644 index 0000000..b2fa18c --- /dev/null +++ b/providers/google-workspace/calendar-check/generator-prompt.md @@ -0,0 +1,4 @@ +# Calendar Check Tool Guidance + +List or search Calendar events by time window and optional query. Keep output +compact and include pagination metadata when Google returns it. diff --git a/providers/google-workspace/calendar-check/input-schema.yaml b/providers/google-workspace/calendar-check/input-schema.yaml new file mode 100644 index 0000000..8925328 --- /dev/null +++ b/providers/google-workspace/calendar-check/input-schema.yaml @@ -0,0 +1,21 @@ +type: object +required: + - time_min + - time_max +properties: + time_min: + type: string + format: date-time + time_max: + type: string + format: date-time + query: + type: string + max_results: + type: integer + minimum: 1 + maximum: 50 + default: 10 + single_events: + type: boolean + default: true diff --git a/providers/google-workspace/calendar-check/mod.go b/providers/google-workspace/calendar-check/mod.go new file mode 100644 index 0000000..b189fbe --- /dev/null +++ b/providers/google-workspace/calendar-check/mod.go @@ -0,0 +1,89 @@ +package calendarcheck + +import ( + "context" + + "cli-factory/internal/provider" + "cli-factory/internal/schema" + "cli-factory/providers/google-workspace/internal/googleapi" +) + +type Tool struct{} + +func (Tool) ID() string { return "calendar-check" } +func (Tool) Name() string { return "Calendar Check" } +func (Tool) ShortDescription() string { return "Search Google Calendar events." } +func (Tool) LongDescription() string { + return "List or search Google Calendar events in a time window on the configured calendar." +} +func (Tool) Categories() []string { return []string{"calendar", "scheduling", "search"} } +func (Tool) Aliases() []string { return []string{"check-events", "list-events"} } +func (Tool) InputSchema() schema.JSONSchema { + return schema.JSONSchema{ + "type": "object", + "required": []any{"time_min", "time_max"}, + "properties": map[string]any{ + "time_min": map[string]any{"type": "string", "format": "date-time"}, + "time_max": map[string]any{"type": "string", "format": "date-time"}, + "query": map[string]any{"type": "string"}, + "max_results": map[string]any{"type": "integer", "minimum": 1, "maximum": 50, "default": 10}, + "single_events": map[string]any{"type": "boolean", "default": true}, + }, + } +} +func (Tool) OutputSchema() schema.JSONSchema { + return schema.JSONSchema{ + "type": "object", + "properties": map[string]any{ + "result_count": map[string]any{"type": "integer"}, + "next_page_token": map[string]any{"type": "string"}, + "events": map[string]any{"type": "array", "items": map[string]any{"type": "object", "properties": map[string]any{"event_id": map[string]any{"type": "string"}, "summary": map[string]any{"type": "string"}, "status": map[string]any{"type": "string"}, "html_link": map[string]any{"type": "string"}, "start": map[string]any{"type": "string"}, "end": map[string]any{"type": "string"}, "location": map[string]any{"type": "string"}}}}, + }, + } +} + +func (Tool) Invoke(ctx context.Context, req provider.InvokeRequest, events provider.EventSink) (provider.InvokeResult, error) { + if err := googleapi.RFC3339(googleapi.StringValue(req.Params, "time_min"), "time_min"); err != nil { + return provider.InvokeResult{}, err + } + if err := googleapi.RFC3339(googleapi.StringValue(req.Params, "time_max"), "time_max"); err != nil { + return provider.InvokeResult{}, err + } + client, err := googleapi.New(req.ProviderParams) + if err != nil { + return provider.InvokeResult{}, err + } + events.Emit(provider.Event{Type: "status", Message: "checking Calendar events"}) + eventsOut, next, err := client.ListEvents(ctx, googleapi.CalendarListInput{ + TimeMin: googleapi.StringValue(req.Params, "time_min"), + TimeMax: googleapi.StringValue(req.Params, "time_max"), + Query: googleapi.StringValue(req.Params, "query"), + MaxResults: googleapi.IntValue(req.Params, "max_results", 10), + SingleEvents: googleapi.BoolValue(req.Params, "single_events", true), + }) + if err != nil { + return provider.InvokeResult{}, err + } + items := make([]map[string]any, 0, len(eventsOut)) + for _, event := range eventsOut { + items = append(items, map[string]any{ + "event_id": event.ID, + "summary": event.Summary, + "status": event.Status, + "html_link": event.HTMLLink, + "start": firstNonEmpty(event.Start.DateTime, event.Start.Date), + "end": firstNonEmpty(event.End.DateTime, event.End.Date), + "location": event.Location, + }) + } + return provider.InvokeResult{Data: map[string]any{"result_count": len(items), "next_page_token": next, "events": items}}, nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/providers/google-workspace/calendar-check/output-schema.yaml b/providers/google-workspace/calendar-check/output-schema.yaml new file mode 100644 index 0000000..ca06b68 --- /dev/null +++ b/providers/google-workspace/calendar-check/output-schema.yaml @@ -0,0 +1,25 @@ +type: object +properties: + result_count: + type: integer + next_page_token: + type: string + events: + type: array + items: + type: object + properties: + event_id: + type: string + summary: + type: string + status: + type: string + html_link: + type: string + start: + type: string + end: + type: string + location: + type: string diff --git a/providers/google-workspace/calendar-create/cli-metadata.yaml b/providers/google-workspace/calendar-create/cli-metadata.yaml new file mode 100644 index 0000000..490d47c --- /dev/null +++ b/providers/google-workspace/calendar-create/cli-metadata.yaml @@ -0,0 +1,15 @@ +id: calendar-create +name: Calendar Create +short_description: Create a Google Calendar event. +long_description: | + Create a Google Calendar event on the configured calendar and return compact + event details. +categories: + - calendar + - scheduling +aliases: + - create-event + - schedule +command_path: + - google-workspace + - calendar-create diff --git a/providers/google-workspace/calendar-create/e2e_test.go b/providers/google-workspace/calendar-create/e2e_test.go new file mode 100644 index 0000000..b768b38 --- /dev/null +++ b/providers/google-workspace/calendar-create/e2e_test.go @@ -0,0 +1,45 @@ +package calendarcreate_test + +import ( + "context" + "testing" + "time" + + "cli-factory/providers/google-workspace/internal/e2e" + "cli-factory/providers/google-workspace/internal/googleapi" +) + +func TestE2ECalendarCreateCommand(t *testing.T) { + secrets := e2e.LoadSecrets(t) + summary := e2e.Unique("cli-factory-calendar-create") + start := time.Now().UTC().Add(2 * time.Hour).Truncate(time.Second) + end := start.Add(30 * time.Minute) + log := e2e.RunFactory(t, + "google-workspace", "calendar-create", + "--provider-params-json", secrets.ProviderParamsJSON(t), + "--params-json", `{"summary":"`+summary+`","start":"`+start.Format(time.RFC3339)+`","end":"`+end.Format(time.RFC3339)+`","description":"Google Workspace calendar-create e2e."}`, + ) + data := e2e.Data(t, log) + id, _ := data["event_id"].(string) + if id == "" { + t.Fatalf("event_id missing from result: %#v", data) + } + client := secrets.Client(t) + t.Cleanup(func() { _ = client.DeleteEvent(context.Background(), id) }) + events, _, err := client.ListEvents(context.Background(), googleapi.CalendarListInput{ + TimeMin: start.Add(-time.Hour).Format(time.RFC3339), + TimeMax: end.Add(time.Hour).Format(time.RFC3339), + Query: summary, + MaxResults: 10, + SingleEvents: true, + }) + if err != nil { + t.Fatalf("verify created event: %v", err) + } + for _, event := range events { + if event.ID == id { + return + } + } + t.Fatalf("created event %q was not found", id) +} diff --git a/providers/google-workspace/calendar-create/generator-prompt.md b/providers/google-workspace/calendar-create/generator-prompt.md new file mode 100644 index 0000000..22ae416 --- /dev/null +++ b/providers/google-workspace/calendar-create/generator-prompt.md @@ -0,0 +1,5 @@ +# Calendar Create Tool Guidance + +Create focused Calendar events with summary, start/end, and optional attendee +or location details. Return the event ID so e2e tests can clean up created +resources. diff --git a/providers/google-workspace/calendar-create/input-schema.yaml b/providers/google-workspace/calendar-create/input-schema.yaml new file mode 100644 index 0000000..cf1898b --- /dev/null +++ b/providers/google-workspace/calendar-create/input-schema.yaml @@ -0,0 +1,35 @@ +type: object +required: + - summary + - start + - end +properties: + summary: + type: string + minLength: 1 + start: + type: string + format: date-time + description: RFC3339 start timestamp. + end: + type: string + format: date-time + description: RFC3339 end timestamp. + time_zone: + type: string + description: Optional IANA time zone. + description: + type: string + location: + type: string + attendees: + type: array + items: + type: object + required: + - email + properties: + email: + type: string + display_name: + type: string diff --git a/providers/google-workspace/calendar-create/mod.go b/providers/google-workspace/calendar-create/mod.go new file mode 100644 index 0000000..a07af0d --- /dev/null +++ b/providers/google-workspace/calendar-create/mod.go @@ -0,0 +1,125 @@ +package calendarcreate + +import ( + "context" + + "cli-factory/internal/provider" + "cli-factory/internal/schema" + "cli-factory/providers/google-workspace/internal/googleapi" +) + +type Tool struct{} + +func (Tool) ID() string { return "calendar-create" } +func (Tool) Name() string { return "Calendar Create" } +func (Tool) ShortDescription() string { return "Create a Google Calendar event." } +func (Tool) LongDescription() string { + return "Create a Google Calendar event on the configured calendar and return compact event details." +} +func (Tool) Categories() []string { return []string{"calendar", "scheduling"} } +func (Tool) Aliases() []string { return []string{"create-event", "schedule"} } +func (Tool) InputSchema() schema.JSONSchema { + return schema.JSONSchema{ + "type": "object", + "required": []any{"summary", "start", "end"}, + "properties": map[string]any{ + "summary": map[string]any{"type": "string", "minLength": 1}, + "start": map[string]any{"type": "string", "format": "date-time", "description": "RFC3339 start timestamp."}, + "end": map[string]any{"type": "string", "format": "date-time", "description": "RFC3339 end timestamp."}, + "time_zone": map[string]any{"type": "string", "description": "Optional IANA time zone."}, + "description": map[string]any{"type": "string"}, + "location": map[string]any{"type": "string"}, + "attendees": map[string]any{"type": "array", "items": map[string]any{"type": "object", "required": []any{"email"}, "properties": map[string]any{"email": map[string]any{"type": "string"}, "display_name": map[string]any{"type": "string"}}}}, + }, + } +} +func (Tool) OutputSchema() schema.JSONSchema { + return schema.JSONSchema{ + "type": "object", + "properties": map[string]any{ + "event_id": map[string]any{"type": "string"}, + "html_link": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string"}, + "summary": map[string]any{"type": "string"}, + "start": map[string]any{"type": "string"}, + "end": map[string]any{"type": "string"}, + "attendees": map[string]any{"type": "array", "items": map[string]any{"type": "object", "properties": map[string]any{"email": map[string]any{"type": "string"}, "response_status": map[string]any{"type": "string"}}}}, + }, + } +} + +func (Tool) Invoke(ctx context.Context, req provider.InvokeRequest, events provider.EventSink) (provider.InvokeResult, error) { + if err := googleapi.RFC3339(googleapi.StringValue(req.Params, "start"), "start"); err != nil { + return provider.InvokeResult{}, err + } + if err := googleapi.RFC3339(googleapi.StringValue(req.Params, "end"), "end"); err != nil { + return provider.InvokeResult{}, err + } + client, err := googleapi.New(req.ProviderParams) + if err != nil { + return provider.InvokeResult{}, err + } + events.Emit(provider.Event{Type: "status", Message: "creating Calendar event"}) + event, err := client.CreateEvent(ctx, googleapi.CalendarEventInput{ + Summary: googleapi.StringValue(req.Params, "summary"), + Start: googleapi.StringValue(req.Params, "start"), + End: googleapi.StringValue(req.Params, "end"), + TimeZone: googleapi.StringValue(req.Params, "time_zone"), + Description: googleapi.StringValue(req.Params, "description"), + Location: googleapi.StringValue(req.Params, "location"), + Attendees: attendees(req.Params["attendees"]), + }) + if err != nil { + return provider.InvokeResult{}, err + } + return provider.InvokeResult{Data: eventData(event)}, nil +} + +func attendees(value any) []map[string]string { + items, ok := value.([]any) + if !ok { + return nil + } + out := make([]map[string]string, 0, len(items)) + for _, item := range items { + m, ok := item.(map[string]any) + if !ok { + continue + } + email, _ := m["email"].(string) + if email == "" { + continue + } + next := map[string]string{"email": email} + if display, _ := m["display_name"].(string); display != "" { + next["displayName"] = display + } + out = append(out, next) + } + return out +} + +func eventData(event googleapi.CalendarEvent) map[string]any { + attendees := make([]map[string]any, 0, len(event.Attendees)) + for _, attendee := range event.Attendees { + attendees = append(attendees, map[string]any{"email": attendee.Email, "response_status": attendee.ResponseStatus}) + } + return map[string]any{ + "event_id": event.ID, + "html_link": event.HTMLLink, + "status": event.Status, + "summary": event.Summary, + "start": firstNonEmpty(event.Start.DateTime, event.Start.Date), + "end": firstNonEmpty(event.End.DateTime, event.End.Date), + "attendees": attendees, + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/providers/google-workspace/calendar-create/output-schema.yaml b/providers/google-workspace/calendar-create/output-schema.yaml new file mode 100644 index 0000000..7bb2e6c --- /dev/null +++ b/providers/google-workspace/calendar-create/output-schema.yaml @@ -0,0 +1,23 @@ +type: object +properties: + event_id: + type: string + html_link: + type: string + status: + type: string + summary: + type: string + start: + type: string + end: + type: string + attendees: + type: array + items: + type: object + properties: + email: + type: string + response_status: + type: string diff --git a/providers/google-workspace/cli-metadata.yaml b/providers/google-workspace/cli-metadata.yaml new file mode 100644 index 0000000..d150e7e --- /dev/null +++ b/providers/google-workspace/cli-metadata.yaml @@ -0,0 +1,25 @@ +id: google-workspace +name: Google Workspace +short_description: Use Gmail and Google Calendar from a Google Workspace account. +long_description: | + Google Workspace exposes curated Gmail and Google Calendar workflows for agents: + send and check email, and create and check calendar events with OAuth-backed API access. +categories: + - email + - calendar + - productivity +aliases: + - google + - gmail + - calendar +provider_parameters: + - name: client_id + description: Google OAuth 2.0 client ID. + - name: client_secret + description: Google OAuth 2.0 client secret. + - name: refresh_token + description: OAuth refresh token for the Google Workspace user. + - name: user_id + description: Gmail user identifier. Defaults to me. + - name: calendar_id + description: Google Calendar ID. Defaults to primary. diff --git a/providers/google-workspace/generator-metadata.yaml b/providers/google-workspace/generator-metadata.yaml new file mode 100644 index 0000000..2fa3e8c --- /dev/null +++ b/providers/google-workspace/generator-metadata.yaml @@ -0,0 +1,389 @@ +discovery: + provider_goal: "Add a Google Workspace provider exposing curated agent-facing + Gmail and Google Calendar workflows for the bot@trytilde.ai Workspace test + user: send/check Gmail messages and create/check Calendar events using real + Google Workspace API behavior." + tool_goals: + gmail-send: Send a Gmail message from bot@trytilde.ai to itself in e2e tests, + returning Gmail message identifiers and delivery metadata where available. + gmail-check: Search or list Gmail messages for bot@trytilde.ai using high-level + filters such as query, sender, subject, label, and max results; e2e should + find the test message created by gmail-send. + calendar-create: Create a Google Calendar event on bot@trytilde.ai's primary + calendar, returning event identifiers, links, times, attendees, and + status; e2e cleanup may delete created events. + calendar-check: Search or list Google Calendar events on bot@trytilde.ai's + primary calendar within a time window, with query/max-results + pagination-aware behavior; e2e should find the test event created by + calendar-create. + references: + - url: https://developers.google.com/workspace/guides/auth-overview + title: Google Workspace authentication and authorization overview + kind: auth + source: external + summary: Official Google Workspace overview for choosing OAuth/user + authorization or service-account/delegated access patterns. + confirmed: true + - url: https://developers.google.com/workspace/guides/create-credentials + title: Create access credentials for Google Workspace APIs + kind: auth + source: external + summary: Official guidance for creating OAuth clients, service accounts, and API + credentials in Google Cloud. + confirmed: true + - url: https://developers.google.com/identity/protocols/oauth2/web-server + title: OAuth 2.0 for Web Server Applications + kind: auth + source: external + summary: Official OAuth flow reference relevant to obtaining durable refresh + tokens for e2e tests. + confirmed: true + - url: https://support.google.com/a/answer/162106?hl=en-EN + title: Domain-wide delegation control + kind: auth + source: external + summary: Admin-side domain-wide delegation reference for service accounts + impersonating Workspace users when available. + confirmed: true + - url: https://developers.google.com/workspace/gmail/api/guides/sending + title: Gmail API create and send email messages + kind: api_usage + source: external + summary: Explains direct message sending via messages.send and MIME/base64url + message construction. + confirmed: true + - url: https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/send + title: Gmail users.messages.send reference + kind: api_usage + source: external + summary: REST reference for sending a message through Gmail API. + confirmed: true + - url: https://developers.google.com/workspace/gmail/api/guides/list-messages + title: Gmail list messages guide + kind: api_usage + source: external + summary: Guide for listing and filtering Gmail messages. + confirmed: true + - url: https://developers.google.cn/calendar/api/guides/create-events?hl=en + title: Google Calendar create events guide + kind: api_usage + source: external + summary: Official guide for creating Calendar events and representing event + time/attendee fields. + confirmed: true + - url: https://developers.google.com/workspace/calendar/api/v3/reference/events/insert + title: Calendar events.insert reference + kind: api_usage + source: external + summary: REST reference for creating an event in a calendar. + confirmed: true + - url: https://developers.google.com/workspace/calendar/api/v3/reference/events/list + title: Calendar events.list reference + kind: api_usage + source: external + summary: REST reference for listing events, including time bounds, query, + maxResults, pageToken, nextPageToken, and scopes. + confirmed: true + - url: https://piumal1999.medium.com/displaying-a-list-of-events-using-google-calendar-api-c785a713749a + title: "Medium example: displaying Calendar events" + kind: general + source: user + summary: User-provided secondary example showing Calendar events listing for a + public calendar/API-key style scenario. + confirmed: true + - url: repo://skills/add-provider/SKILL.md + title: CLI Factory add-provider workflow + kind: general + source: external + summary: Local workflow defining provider layout, metadata, docs/catalog + generation, e2e safety, and secret handling. + confirmed: true + - url: repo://providers/example/echo + title: CLI Factory example provider + kind: general + source: external + summary: Local provider/tool implementation and CLI e2e pattern. + confirmed: true + discovery_notes: + - Provider id confirmed as google-workspace. + - "Curated tools confirmed: gmail-send, gmail-check, calendar-create, + calendar-check." + - "Target e2e Workspace user confirmed: bot@trytilde.ai." + - "Calendar e2e target confirmed: primary calendar for bot@trytilde.ai." + - "E2E writes allowed against bot@trytilde.ai: Gmail may email itself; + Calendar may modify this account's primary calendar." + - "Preferred auth model: OAuth client + durable refresh token for + bot@trytilde.ai." + - Gmail send and Calendar create are write operations; Gmail check and + Calendar check are read operations. + - "Calendar cleanup confirmed: e2e tests may delete calendar events they + create." + - "Gmail cleanup confirmed: e2e tests should trash/delete test messages + after assertions." + - "Spend/rate-limit constraint confirmed: no paid Google API usage expected; + keep full e2e run under 10 Gmail sends and under 10 Calendar writes." + - "Credentials expected through secure secret collection: OAuth client ID, + OAuth client secret, refresh token, test user email, and calendar ID." +plan: + provider_plan: "Create providers/google-workspace with provider metadata, + generator metadata/prompt, test secret examples, and four curated tools: + gmail-send, gmail-check, calendar-create, calendar-check. Register + google-workspace in internal/app/registry.go alongside the example provider. + Implement a shared Google Workspace client in the provider package using + net/http and encoding/json rather than adding a Google SDK dependency: read + provider params client_id, client_secret, refresh_token, optional user_id + defaulting to me, and optional calendar_id defaulting to primary; exchange + refresh_token at Google's OAuth token endpoint for an access token; attach + Authorization: Bearer to Gmail and Calendar API calls; normalize Google API + errors into provider.Error with retryable flags for 429/5xx. Keep tool + implementations high-level and workflow-oriented rather than raw CRUD + wrappers. Generate docs/catalog from metadata and schemas after + implementation. E2e tests use bot@trytilde.ai, primary calendar, real OAuth + refresh-token credentials, and stay under 10 Gmail sends and 10 Calendar + writes per full run. Gmail tests send to the same account, verify via + search, then trash/delete test messages. Calendar tests create a unique + event, verify via list/search, then delete the created event for cleanup." + tool_plans: + - toolId: gmail-send + goal: Send a Gmail message from the authenticated Workspace user, used by e2e to + email bot@trytilde.ai to itself with a unique subject. + implementation: Build an RFC 2822/MIME text message from + to/cc/bcc/subject/body_text, base64url encode it without padding, POST + to /gmail/v1/users/{user_id}/messages/send, and return Gmail identifiers + and labels. Validate at least one recipient, subject, and body_text. Use + Gmail API sending guide and users.messages.send reference. + inputSchema: |- + type: object + required: [to, subject, body_text] + properties: + to: + type: array + items: { type: string } + minItems: 1 + description: Recipient email addresses. + cc: + type: array + items: { type: string } + description: Optional CC recipients. + bcc: + type: array + items: { type: string } + description: Optional BCC recipients. + subject: + type: string + minLength: 1 + body_text: + type: string + minLength: 1 + outputSchema: |- + type: object + properties: + message_id: { type: string } + thread_id: { type: string } + label_ids: + type: array + items: { type: string } + - toolId: gmail-check + goal: Search or list Gmail messages for the authenticated Workspace user, used + by e2e to verify the message sent by gmail-send. + implementation: GET /gmail/v1/users/{user_id}/messages with q and maxResults, + then optionally fetch message details for snippet/labels via + /messages/{id}. Return compact message results and count. Use Gmail list + messages guide. This remains a high-level check/search tool rather than + exposing arbitrary Gmail CRUD. + inputSchema: >- + type: object + + properties: + query: + type: string + description: Gmail search query, for example subject:"cli-factory-e2e". + max_results: + type: integer + minimum: 1 + maximum: 25 + default: 10 + include_snippet: + type: boolean + default: true + outputSchema: |- + type: object + properties: + result_count: { type: integer } + messages: + type: array + items: + type: object + properties: + id: { type: string } + thread_id: { type: string } + snippet: { type: string } + label_ids: + type: array + items: { type: string } + - toolId: calendar-create + goal: Create a Google Calendar event on bot@trytilde.ai's primary calendar, + returning event details and enabling e2e cleanup by event id. + implementation: POST /calendar/v3/calendars/{calendar_id}/events with summary, + start, end, optional description/location/attendees/time_zone. Represent + date-time values as RFC3339 dateTime fields with optional timeZone. + Return the created event id, htmlLink, status, start/end, and attendees. + Use Calendar create events guide and events.insert reference. + inputSchema: |- + type: object + required: [summary, start, end] + properties: + summary: + type: string + minLength: 1 + start: + type: string + format: date-time + description: RFC3339 start timestamp. + end: + type: string + format: date-time + description: RFC3339 end timestamp. + time_zone: + type: string + description: Optional IANA time zone, for example Europe/Berlin. + description: + type: string + location: + type: string + attendees: + type: array + items: + type: object + required: [email] + properties: + email: { type: string } + display_name: { type: string } + outputSchema: |- + type: object + properties: + event_id: { type: string } + html_link: { type: string } + status: { type: string } + summary: { type: string } + start: { type: string } + end: { type: string } + attendees: + type: array + items: + type: object + properties: + email: { type: string } + response_status: { type: string } + - toolId: calendar-check + goal: Search/list Calendar events on bot@trytilde.ai's primary calendar within a + time window, used by e2e to verify calendar-create. + implementation: GET /calendar/v3/calendars/{calendar_id}/events with timeMin, + timeMax, q, maxResults, singleEvents, and orderBy=startTime when + singleEvents is true. Return compact event results, count, and + nextPageToken when present. Use Calendar events.list reference. + inputSchema: |- + type: object + required: [time_min, time_max] + properties: + time_min: + type: string + format: date-time + time_max: + type: string + format: date-time + query: + type: string + max_results: + type: integer + minimum: 1 + maximum: 50 + default: 10 + single_events: + type: boolean + default: true + outputSchema: |- + type: object + properties: + result_count: { type: integer } + next_page_token: { type: string } + events: + type: array + items: + type: object + properties: + event_id: { type: string } + summary: { type: string } + status: { type: string } + html_link: { type: string } + start: { type: string } + end: { type: string } + location: { type: string } +testing: + testing_plan: "Use real Google Workspace e2e tests against bot@trytilde.ai. + Secrets are supplied via + providers/google-workspace/override_test_secrets.yaml during local harness + work, with OAuth client credentials and a durable refresh token authorized + by bot@trytilde.ai. Required scopes: + https://www.googleapis.com/auth/gmail.modify, + https://www.googleapis.com/auth/gmail.send, and + https://www.googleapis.com/auth/calendar.events. Tests must stay under the + confirmed cap of 10 Gmail sends and 10 Calendar writes per full e2e run. No + mock-backed e2e tests are acceptable. Write tests first in the provider/tool + directories, then implement until targeted e2e tests pass or an external + credential/account blocker is hit." + e2e_tests: + - id: gmail_send_check_cleanup + description: Send a unique email from bot@trytilde.ai to itself using + gmail-send, verify it is discoverable with gmail-check by unique + subject, then trash/delete matching test messages for cleanup. + command: make test-provider-tool PROVIDER=google-workspace TOOL=gmail-send && + make test-provider-tool PROVIDER=google-workspace TOOL=gmail-check + mode: real_account + assertions: + - gmail-send returns SUCCESS and a non-empty message_id/thread_id. + - gmail-check returns SUCCESS and finds at least one message matching + the unique subject. + - Created Gmail test messages are trashed/deleted after assertions. + - The flow sends at most one email per test invocation. + cleanup: Trash/delete Gmail test messages matching the generated e2e subject + after assertions. + destructive_risk: low + - id: calendar_create_check_cleanup + description: Create a unique event on bot@trytilde.ai's primary calendar using + calendar-create, verify it is discoverable with calendar-check by + query/time window, then delete the created event for cleanup. + command: make test-provider-tool PROVIDER=google-workspace TOOL=calendar-create + && make test-provider-tool PROVIDER=google-workspace TOOL=calendar-check + mode: real_account + assertions: + - calendar-create returns SUCCESS and a non-empty + event_id/html_link/status. + - calendar-check returns SUCCESS and finds the unique event in the + expected time window. + - Created Calendar test events are deleted after assertions. + - The flow creates at most one calendar event per test invocation. + cleanup: Delete Calendar events created by the e2e test using their returned + event IDs. + destructive_risk: low + - id: google_workspace_provider_e2e + description: Run all Google Workspace provider e2e tests together after + individual tool flows pass, preserving the write cap and cleanup + behavior. + command: make test-provider PROVIDER=google-workspace + mode: real_account + assertions: + - All provider e2e tests pass against real Google Workspace APIs. + - Full provider e2e run stays under 10 Gmail sends and 10 Calendar + writes. + - No secrets are printed in command output or invocation logs. + cleanup: All Gmail messages and Calendar events created by tests are cleaned up + by test code. + destructive_risk: low + override_test_secrets_shape: + client_id: Google OAuth 2.0 client ID authorized by bot@trytilde.ai + client_secret: Google OAuth 2.0 client secret for the same OAuth client + refresh_token: Durable refresh token granted by bot@trytilde.ai with Gmail + modify/send and Calendar events scopes + test_user_email: bot@trytilde.ai + calendar_id: primary diff --git a/providers/google-workspace/generator-prompt.md b/providers/google-workspace/generator-prompt.md new file mode 100644 index 0000000..48adf6a --- /dev/null +++ b/providers/google-workspace/generator-prompt.md @@ -0,0 +1,6 @@ +# Google Workspace Provider Generation Guidance + +Expose high-level Gmail and Google Calendar workflows, not raw Google API CRUD. +Use durable OAuth refresh-token credentials for real e2e tests. Keep test writes +limited to bot@trytilde.ai, send Gmail only to itself, create Calendar events on +the configured test calendar, and clean up test-created resources. diff --git a/providers/google-workspace/gmail-check/cli-metadata.yaml b/providers/google-workspace/gmail-check/cli-metadata.yaml new file mode 100644 index 0000000..f25e5b6 --- /dev/null +++ b/providers/google-workspace/gmail-check/cli-metadata.yaml @@ -0,0 +1,16 @@ +id: gmail-check +name: Gmail Check +short_description: Search recent Gmail messages. +long_description: | + Search or list Gmail messages for the authenticated Google Workspace user and + return compact message details. +categories: + - email + - gmail + - search +aliases: + - check-email + - search-email +command_path: + - google-workspace + - gmail-check diff --git a/providers/google-workspace/gmail-check/e2e_test.go b/providers/google-workspace/gmail-check/e2e_test.go new file mode 100644 index 0000000..c7af872 --- /dev/null +++ b/providers/google-workspace/gmail-check/e2e_test.go @@ -0,0 +1,33 @@ +package gmailcheck_test + +import ( + "context" + "testing" + + "cli-factory/providers/google-workspace/internal/e2e" + "cli-factory/providers/google-workspace/internal/googleapi" +) + +func TestE2EGmailCheckCommand(t *testing.T) { + secrets := e2e.LoadSecrets(t) + client := secrets.Client(t) + subject := e2e.Unique("cli-factory-gmail-check") + msg, err := client.SendGmail(context.Background(), googleapi.EmailMessage{ + To: []string{secrets.TestUserEmail}, + Subject: subject, + BodyText: "Google Workspace gmail-check e2e.", + }) + if err != nil { + t.Fatalf("seed Gmail message: %v", err) + } + t.Cleanup(func() { _ = client.TrashGmail(context.Background(), msg.ID) }) + log := e2e.RunFactory(t, + "google-workspace", "gmail-check", + "--provider-params-json", secrets.ProviderParamsJSON(t), + "--params-json", `{"query":"subject:\"`+subject+`\"","max_results":10,"include_snippet":true}`, + ) + data := e2e.Data(t, log) + if count, _ := data["result_count"].(float64); count < 1 { + t.Fatalf("result_count = %v, want at least 1: %#v", data["result_count"], data) + } +} diff --git a/providers/google-workspace/gmail-check/generator-prompt.md b/providers/google-workspace/gmail-check/generator-prompt.md new file mode 100644 index 0000000..009fb27 --- /dev/null +++ b/providers/google-workspace/gmail-check/generator-prompt.md @@ -0,0 +1,5 @@ +# Gmail Check Tool Guidance + +Search Gmail with the q query parameter and return compact message details. +Keep the tool focused on checking/searching messages, not arbitrary mailbox +mutation. diff --git a/providers/google-workspace/gmail-check/input-schema.yaml b/providers/google-workspace/gmail-check/input-schema.yaml new file mode 100644 index 0000000..d89aaca --- /dev/null +++ b/providers/google-workspace/gmail-check/input-schema.yaml @@ -0,0 +1,13 @@ +type: object +properties: + query: + type: string + description: Gmail search query. + max_results: + type: integer + minimum: 1 + maximum: 25 + default: 10 + include_snippet: + type: boolean + default: true diff --git a/providers/google-workspace/gmail-check/mod.go b/providers/google-workspace/gmail-check/mod.go new file mode 100644 index 0000000..a2adf38 --- /dev/null +++ b/providers/google-workspace/gmail-check/mod.go @@ -0,0 +1,61 @@ +package gmailcheck + +import ( + "context" + + "cli-factory/internal/provider" + "cli-factory/internal/schema" + "cli-factory/providers/google-workspace/internal/googleapi" +) + +type Tool struct{} + +func (Tool) ID() string { return "gmail-check" } +func (Tool) Name() string { return "Gmail Check" } +func (Tool) ShortDescription() string { return "Search recent Gmail messages." } +func (Tool) LongDescription() string { + return "Search or list Gmail messages for the authenticated Google Workspace user and return compact message details." +} +func (Tool) Categories() []string { return []string{"email", "gmail", "search"} } +func (Tool) Aliases() []string { return []string{"check-email", "search-email"} } +func (Tool) InputSchema() schema.JSONSchema { + return schema.JSONSchema{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{"type": "string", "description": "Gmail search query."}, + "max_results": map[string]any{"type": "integer", "minimum": 1, "maximum": 25, "default": 10}, + "include_snippet": map[string]any{"type": "boolean", "default": true}, + }, + } +} +func (Tool) OutputSchema() schema.JSONSchema { + return schema.JSONSchema{ + "type": "object", + "properties": map[string]any{ + "result_count": map[string]any{"type": "integer"}, + "messages": map[string]any{"type": "array", "items": map[string]any{"type": "object", "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "thread_id": map[string]any{"type": "string"}, + "snippet": map[string]any{"type": "string"}, + "label_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }}}, + }, + } +} + +func (Tool) Invoke(ctx context.Context, req provider.InvokeRequest, events provider.EventSink) (provider.InvokeResult, error) { + client, err := googleapi.New(req.ProviderParams) + if err != nil { + return provider.InvokeResult{}, err + } + events.Emit(provider.Event{Type: "status", Message: "checking Gmail messages"}) + messages, err := client.ListGmail(ctx, googleapi.StringValue(req.Params, "query"), googleapi.IntValue(req.Params, "max_results", 10), googleapi.BoolValue(req.Params, "include_snippet", true)) + if err != nil { + return provider.InvokeResult{}, err + } + out := make([]map[string]any, 0, len(messages)) + for _, msg := range messages { + out = append(out, map[string]any{"id": msg.ID, "thread_id": msg.ThreadID, "snippet": msg.Snippet, "label_ids": msg.LabelIDs}) + } + return provider.InvokeResult{Data: map[string]any{"result_count": len(out), "messages": out}}, nil +} diff --git a/providers/google-workspace/gmail-check/output-schema.yaml b/providers/google-workspace/gmail-check/output-schema.yaml new file mode 100644 index 0000000..70787f4 --- /dev/null +++ b/providers/google-workspace/gmail-check/output-schema.yaml @@ -0,0 +1,19 @@ +type: object +properties: + result_count: + type: integer + messages: + type: array + items: + type: object + properties: + id: + type: string + thread_id: + type: string + snippet: + type: string + label_ids: + type: array + items: + type: string diff --git a/providers/google-workspace/gmail-send/cli-metadata.yaml b/providers/google-workspace/gmail-send/cli-metadata.yaml new file mode 100644 index 0000000..771b25c --- /dev/null +++ b/providers/google-workspace/gmail-send/cli-metadata.yaml @@ -0,0 +1,15 @@ +id: gmail-send +name: Gmail Send +short_description: Send a Gmail message from a Google Workspace account. +long_description: | + Send a plain-text Gmail message through the Gmail API using OAuth-backed + Google Workspace credentials. +categories: + - email + - gmail +aliases: + - send-email + - email +command_path: + - google-workspace + - gmail-send diff --git a/providers/google-workspace/gmail-send/e2e_test.go b/providers/google-workspace/gmail-send/e2e_test.go new file mode 100644 index 0000000..9b878ef --- /dev/null +++ b/providers/google-workspace/gmail-send/e2e_test.go @@ -0,0 +1,36 @@ +package gmailsend_test + +import ( + "context" + "strings" + "testing" + + "cli-factory/providers/google-workspace/internal/e2e" +) + +func TestE2EGmailSendCommand(t *testing.T) { + secrets := e2e.LoadSecrets(t) + subject := e2e.Unique("cli-factory-gmail-send") + log := e2e.RunFactory(t, + "google-workspace", "gmail-send", + "--provider-params-json", secrets.ProviderParamsJSON(t), + "--params-json", `{"to":["`+secrets.TestUserEmail+`"],"subject":"`+subject+`","body_text":"Google Workspace gmail-send e2e."}`, + ) + data := e2e.Data(t, log) + id, _ := data["message_id"].(string) + if id == "" { + t.Fatalf("message_id missing from result: %#v", data) + } + client := secrets.Client(t) + t.Cleanup(func() { _ = client.TrashGmail(context.Background(), id) }) + found, err := client.ListGmail(context.Background(), `subject:"`+subject+`"`, 10, true) + if err != nil { + t.Fatalf("verify sent message: %v", err) + } + for _, msg := range found { + if msg.ID == id || strings.Contains(msg.Snippet, "gmail-send e2e") { + return + } + } + t.Fatalf("sent message %q was not found by subject %q", id, subject) +} diff --git a/providers/google-workspace/gmail-send/generator-prompt.md b/providers/google-workspace/gmail-send/generator-prompt.md new file mode 100644 index 0000000..1b05af1 --- /dev/null +++ b/providers/google-workspace/gmail-send/generator-prompt.md @@ -0,0 +1,5 @@ +# Gmail Send Tool Guidance + +Send a plain-text Gmail message using users.messages.send. Build a MIME message, +base64url encode it, and return Gmail identifiers. Do not expose arbitrary raw +Gmail API request fields. diff --git a/providers/google-workspace/gmail-send/input-schema.yaml b/providers/google-workspace/gmail-send/input-schema.yaml new file mode 100644 index 0000000..a093bd1 --- /dev/null +++ b/providers/google-workspace/gmail-send/input-schema.yaml @@ -0,0 +1,28 @@ +type: object +required: + - to + - subject + - body_text +properties: + to: + type: array + items: + type: string + minItems: 1 + description: Recipient email addresses. + cc: + type: array + items: + type: string + description: Optional CC recipients. + bcc: + type: array + items: + type: string + description: Optional BCC recipients. + subject: + type: string + minLength: 1 + body_text: + type: string + minLength: 1 diff --git a/providers/google-workspace/gmail-send/mod.go b/providers/google-workspace/gmail-send/mod.go new file mode 100644 index 0000000..0447311 --- /dev/null +++ b/providers/google-workspace/gmail-send/mod.go @@ -0,0 +1,62 @@ +package gmailsend + +import ( + "context" + + "cli-factory/internal/provider" + "cli-factory/internal/schema" + "cli-factory/providers/google-workspace/internal/googleapi" +) + +type Tool struct{} + +func (Tool) ID() string { return "gmail-send" } +func (Tool) Name() string { return "Gmail Send" } +func (Tool) ShortDescription() string { return "Send a Gmail message from a Google Workspace account." } +func (Tool) LongDescription() string { + return "Send a plain-text Gmail message through the Gmail API using OAuth-backed Google Workspace credentials." +} +func (Tool) Categories() []string { return []string{"email", "gmail"} } +func (Tool) Aliases() []string { return []string{"send-email", "email"} } +func (Tool) InputSchema() schema.JSONSchema { + return schema.JSONSchema{ + "type": "object", + "required": []any{"to", "subject", "body_text"}, + "properties": map[string]any{ + "to": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "minItems": 1, "description": "Recipient email addresses."}, + "cc": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "Optional CC recipients."}, + "bcc": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "Optional BCC recipients."}, + "subject": map[string]any{"type": "string", "minLength": 1}, + "body_text": map[string]any{"type": "string", "minLength": 1}, + }, + } +} +func (Tool) OutputSchema() schema.JSONSchema { + return schema.JSONSchema{ + "type": "object", + "properties": map[string]any{ + "message_id": map[string]any{"type": "string"}, + "thread_id": map[string]any{"type": "string"}, + "label_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }, + } +} + +func (Tool) Invoke(ctx context.Context, req provider.InvokeRequest, events provider.EventSink) (provider.InvokeResult, error) { + client, err := googleapi.New(req.ProviderParams) + if err != nil { + return provider.InvokeResult{}, err + } + events.Emit(provider.Event{Type: "status", Message: "sending Gmail message"}) + msg, err := client.SendGmail(ctx, googleapi.EmailMessage{ + To: googleapi.StringSlice(req.Params["to"]), + CC: googleapi.StringSlice(req.Params["cc"]), + BCC: googleapi.StringSlice(req.Params["bcc"]), + Subject: googleapi.StringValue(req.Params, "subject"), + BodyText: googleapi.StringValue(req.Params, "body_text"), + }) + if err != nil { + return provider.InvokeResult{}, err + } + return provider.InvokeResult{Data: map[string]any{"message_id": msg.ID, "thread_id": msg.ThreadID, "label_ids": msg.LabelIDs}}, nil +} diff --git a/providers/google-workspace/gmail-send/output-schema.yaml b/providers/google-workspace/gmail-send/output-schema.yaml new file mode 100644 index 0000000..194dc19 --- /dev/null +++ b/providers/google-workspace/gmail-send/output-schema.yaml @@ -0,0 +1,10 @@ +type: object +properties: + message_id: + type: string + thread_id: + type: string + label_ids: + type: array + items: + type: string diff --git a/providers/google-workspace/internal/e2e/helpers.go b/providers/google-workspace/internal/e2e/helpers.go new file mode 100644 index 0000000..e7e50b1 --- /dev/null +++ b/providers/google-workspace/internal/e2e/helpers.go @@ -0,0 +1,155 @@ +package e2e + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "cli-factory/internal/invocationlog" + "cli-factory/internal/testharness" + "cli-factory/providers/google-workspace/internal/googleapi" + "gopkg.in/yaml.v3" +) + +type Secrets struct { + ClientID string `yaml:"client_id"` + ClientSecret string `yaml:"client_secret"` + RefreshToken string `yaml:"refresh_token"` + TestUserEmail string `yaml:"test_user_email"` + CalendarID string `yaml:"calendar_id"` +} + +func LoadSecrets(t *testing.T) Secrets { + t.Helper() + root := RepoRoot(t) + loaded, err := testharness.LoadProviderTestSecrets(context.Background(), testharness.SecretOptionsFromEnv(filepath.Join(root, "providers", "google-workspace"))) + if err != nil { + t.Fatalf("load Google Workspace test secrets: %v", err) + } + var secrets Secrets + if err := yaml.Unmarshal(loaded.Data, &secrets); err != nil { + t.Fatalf("parse %s: %v", loaded.Path, err) + } + if secrets.ClientID == "" || secrets.ClientSecret == "" || secrets.RefreshToken == "" { + t.Fatalf("%s must define client_id, client_secret, and refresh_token", loaded.Path) + } + if secrets.TestUserEmail == "" { + secrets.TestUserEmail = "bot@trytilde.ai" + } + if secrets.CalendarID == "" { + secrets.CalendarID = "primary" + } + return secrets +} + +func (s Secrets) ProviderParamsJSON(t *testing.T) string { + t.Helper() + data, err := json.Marshal(s.ProviderParams()) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func (s Secrets) ProviderParams() map[string]any { + return map[string]any{ + "client_id": s.ClientID, + "client_secret": s.ClientSecret, + "refresh_token": s.RefreshToken, + "user_id": "me", + "calendar_id": s.CalendarID, + } +} + +func (s Secrets) Client(t *testing.T) *googleapi.Client { + t.Helper() + client, err := googleapi.New(s.ProviderParams()) + if err != nil { + t.Fatalf("create Google API client: %v", err) + } + return client +} + +func RunFactory(t *testing.T, args ...string) invocationlog.Log { + t.Helper() + logDir := t.TempDir() + fullArgs := append([]string{"run", "./cmd/factory", "--log-dir", logDir}, args...) + cmd := exec.Command("go", fullArgs...) + cmd.Dir = RepoRoot(t) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("factory command failed: %v\n%s", err, string(out)) + } + text := string(out) + if !strings.Contains(text, "SUCCESS") { + t.Fatalf("missing SUCCESS output:\n%s", text) + } + logPath := logPathFromOutput(t, text) + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read invocation log %s: %v", logPath, err) + } + var log invocationlog.Log + if err := json.Unmarshal(data, &log); err != nil { + t.Fatalf("parse invocation log %s: %v", logPath, err) + } + if log.Status != "SUCCESS" { + t.Fatalf("log status = %s, want SUCCESS: %+v", log.Status, log.Error) + } + return log +} + +func Data(t *testing.T, log invocationlog.Log) map[string]any { + t.Helper() + raw, err := json.Marshal(log.Result) + if err != nil { + t.Fatal(err) + } + var parsed struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatalf("parse result data: %v", err) + } + return parsed.Data +} + +func Unique(prefix string) string { + return prefix + "-" + time.Now().UTC().Format("20060102T150405.000000000Z") +} + +func RepoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime caller failed") + } + dir := filepath.Dir(file) + for { + if filepath.Base(dir) == "cli-factory" { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("repo root not found") + } + dir = parent + } +} + +func logPathFromOutput(t *testing.T, output string) string { + t.Helper() + for _, line := range strings.Split(output, "\n") { + if path, ok := strings.CutPrefix(line, "full logs at "); ok { + return strings.TrimSpace(path) + } + } + t.Fatalf("missing log path in output:\n%s", output) + return "" +} diff --git a/providers/google-workspace/internal/googleapi/client.go b/providers/google-workspace/internal/googleapi/client.go new file mode 100644 index 0000000..7a79590 --- /dev/null +++ b/providers/google-workspace/internal/googleapi/client.go @@ -0,0 +1,458 @@ +package googleapi + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "mime" + "mime/quotedprintable" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "cli-factory/internal/provider" +) + +const ( + gmailBaseURL = "https://gmail.googleapis.com/gmail/v1" + calendarBaseURL = "https://www.googleapis.com/calendar/v3" + tokenURL = "https://oauth2.googleapis.com/token" +) + +type Config struct { + ClientID string + ClientSecret string + RefreshToken string + UserID string + CalendarID string +} + +type Client struct { + Config Config + HTTP *http.Client + token string +} + +func New(params map[string]any) (*Client, error) { + cfg := Config{ + ClientID: stringParam(params, "client_id"), + ClientSecret: stringParam(params, "client_secret"), + RefreshToken: stringParam(params, "refresh_token"), + UserID: defaultString(stringParam(params, "user_id"), "me"), + CalendarID: defaultString(stringParam(params, "calendar_id"), "primary"), + } + var missing []string + if cfg.ClientID == "" { + missing = append(missing, "client_id") + } + if cfg.ClientSecret == "" { + missing = append(missing, "client_secret") + } + if cfg.RefreshToken == "" { + missing = append(missing, "refresh_token") + } + if len(missing) > 0 { + return nil, &provider.Error{Code: "validation_failed", Message: "missing provider params: " + strings.Join(missing, ", "), Retryable: false} + } + return &Client{Config: cfg, HTTP: http.DefaultClient}, nil +} + +func (c *Client) SendGmail(ctx context.Context, msg EmailMessage) (GmailMessage, error) { + raw, err := BuildRawEmail(msg) + if err != nil { + return GmailMessage{}, err + } + var out GmailMessage + err = c.do(ctx, http.MethodPost, gmailBaseURL+"/users/"+url.PathEscape(c.Config.UserID)+"/messages/send", nil, map[string]any{"raw": raw}, &out) + return out, err +} + +func (c *Client) ListGmail(ctx context.Context, query string, maxResults int, includeSnippet bool) ([]GmailMessage, error) { + if maxResults <= 0 { + maxResults = 10 + } + if maxResults > 25 { + maxResults = 25 + } + values := url.Values{} + values.Set("maxResults", strconv.Itoa(maxResults)) + if query != "" { + values.Set("q", query) + } + var listed struct { + Messages []GmailMessage `json:"messages"` + } + err := c.do(ctx, http.MethodGet, gmailBaseURL+"/users/"+url.PathEscape(c.Config.UserID)+"/messages?"+values.Encode(), nil, nil, &listed) + if err != nil { + return nil, err + } + if !includeSnippet { + return listed.Messages, nil + } + out := make([]GmailMessage, 0, len(listed.Messages)) + for _, msg := range listed.Messages { + detail, err := c.GetGmail(ctx, msg.ID) + if err != nil { + return nil, err + } + out = append(out, detail) + } + return out, nil +} + +func (c *Client) GetGmail(ctx context.Context, id string) (GmailMessage, error) { + var out GmailMessage + values := url.Values{} + values.Set("format", "metadata") + err := c.do(ctx, http.MethodGet, gmailBaseURL+"/users/"+url.PathEscape(c.Config.UserID)+"/messages/"+url.PathEscape(id)+"?"+values.Encode(), nil, nil, &out) + return out, err +} + +func (c *Client) TrashGmail(ctx context.Context, id string) error { + var out map[string]any + return c.do(ctx, http.MethodPost, gmailBaseURL+"/users/"+url.PathEscape(c.Config.UserID)+"/messages/"+url.PathEscape(id)+"/trash", nil, map[string]any{}, &out) +} + +func (c *Client) CreateEvent(ctx context.Context, input CalendarEventInput) (CalendarEvent, error) { + payload := map[string]any{ + "summary": input.Summary, + "start": eventTime(input.Start, input.TimeZone), + "end": eventTime(input.End, input.TimeZone), + } + if input.Description != "" { + payload["description"] = input.Description + } + if input.Location != "" { + payload["location"] = input.Location + } + if len(input.Attendees) > 0 { + payload["attendees"] = input.Attendees + } + var out CalendarEvent + err := c.do(ctx, http.MethodPost, calendarBaseURL+"/calendars/"+url.PathEscape(c.Config.CalendarID)+"/events", nil, payload, &out) + return out, err +} + +func (c *Client) ListEvents(ctx context.Context, input CalendarListInput) ([]CalendarEvent, string, error) { + maxResults := input.MaxResults + if maxResults <= 0 { + maxResults = 10 + } + if maxResults > 50 { + maxResults = 50 + } + values := url.Values{} + values.Set("timeMin", input.TimeMin) + values.Set("timeMax", input.TimeMax) + values.Set("maxResults", strconv.Itoa(maxResults)) + if input.Query != "" { + values.Set("q", input.Query) + } + if input.SingleEvents { + values.Set("singleEvents", "true") + values.Set("orderBy", "startTime") + } + var out struct { + Items []CalendarEvent `json:"items"` + NextPageToken string `json:"nextPageToken"` + } + err := c.do(ctx, http.MethodGet, calendarBaseURL+"/calendars/"+url.PathEscape(c.Config.CalendarID)+"/events?"+values.Encode(), nil, nil, &out) + return out.Items, out.NextPageToken, err +} + +func (c *Client) DeleteEvent(ctx context.Context, id string) error { + return c.do(ctx, http.MethodDelete, calendarBaseURL+"/calendars/"+url.PathEscape(c.Config.CalendarID)+"/events/"+url.PathEscape(id), nil, nil, nil) +} + +func (c *Client) do(ctx context.Context, method, endpoint string, headers map[string]string, payload any, out any) error { + token, err := c.accessToken(ctx) + if err != nil { + return err + } + var body io.Reader + if payload != nil { + data, err := json.Marshal(payload) + if err != nil { + return err + } + body = bytes.NewReader(data) + } + req, err := http.NewRequestWithContext(ctx, method, endpoint, body) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := c.httpClient().Do(req) + if err != nil { + return &provider.Error{Code: "provider_request_failed", Message: err.Error(), Retryable: true} + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return parseGoogleError(resp) + } + if out == nil || resp.StatusCode == http.StatusNoContent { + return nil + } + return json.NewDecoder(resp.Body).Decode(out) +} + +func (c *Client) accessToken(ctx context.Context) (string, error) { + if c.token != "" { + return c.token, nil + } + form := url.Values{} + form.Set("client_id", c.Config.ClientID) + form.Set("client_secret", c.Config.ClientSecret) + form.Set("refresh_token", c.Config.RefreshToken) + form.Set("grant_type", "refresh_token") + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := c.httpClient().Do(req) + if err != nil { + return "", &provider.Error{Code: "auth_failed", Message: err.Error(), Retryable: true} + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", parseGoogleError(resp) + } + var parsed struct { + AccessToken string `json:"access_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return "", err + } + if parsed.AccessToken == "" { + return "", &provider.Error{Code: "auth_failed", Message: "Google token response did not include access_token", Retryable: false} + } + c.token = parsed.AccessToken + return c.token, nil +} + +func (c *Client) httpClient() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + return http.DefaultClient +} + +type EmailMessage struct { + To []string + CC []string + BCC []string + Subject string + BodyText string +} + +type GmailMessage struct { + ID string `json:"id"` + ThreadID string `json:"threadId"` + Snippet string `json:"snippet"` + LabelIDs []string `json:"labelIds"` +} + +func BuildRawEmail(msg EmailMessage) (string, error) { + if len(msg.To) == 0 { + return "", &provider.Error{Code: "validation_failed", Message: "to is required", Retryable: false} + } + if strings.TrimSpace(msg.Subject) == "" { + return "", &provider.Error{Code: "validation_failed", Message: "subject is required", Retryable: false} + } + if strings.TrimSpace(msg.BodyText) == "" { + return "", &provider.Error{Code: "validation_failed", Message: "body_text is required", Retryable: false} + } + headers := map[string]string{ + "To": strings.Join(msg.To, ", "), + "Subject": mimeHeader(msg.Subject), + "MIME-Version": "1.0", + "Content-Type": `text/plain; charset="UTF-8"`, + } + if len(msg.CC) > 0 { + headers["Cc"] = strings.Join(msg.CC, ", ") + } + if len(msg.BCC) > 0 { + headers["Bcc"] = strings.Join(msg.BCC, ", ") + } + keys := make([]string, 0, len(headers)) + for k := range headers { + keys = append(keys, k) + } + sort.Strings(keys) + var buf bytes.Buffer + for _, k := range keys { + fmt.Fprintf(&buf, "%s: %s\r\n", k, headers[k]) + } + buf.WriteString("\r\n") + qp := quotedprintable.NewWriter(&buf) + _, _ = qp.Write([]byte(msg.BodyText)) + _ = qp.Close() + return base64.RawURLEncoding.EncodeToString(buf.Bytes()), nil +} + +func mimeHeader(value string) string { + return mime.QEncoding.Encode("UTF-8", value) +} + +type CalendarEventInput struct { + Summary string + Start string + End string + TimeZone string + Description string + Location string + Attendees []map[string]string +} + +type CalendarListInput struct { + TimeMin string + TimeMax string + Query string + MaxResults int + SingleEvents bool +} + +type CalendarEvent struct { + ID string `json:"id"` + Summary string `json:"summary"` + Status string `json:"status"` + HTMLLink string `json:"htmlLink"` + Start CalendarEventTime `json:"start"` + End CalendarEventTime `json:"end"` + Location string `json:"location"` + Attendees []CalendarAttendee `json:"attendees"` + Raw map[string]any `json:"-"` +} + +type CalendarEventTime struct { + DateTime string `json:"dateTime"` + Date string `json:"date"` + TimeZone string `json:"timeZone"` +} + +type CalendarAttendee struct { + Email string `json:"email"` + ResponseStatus string `json:"responseStatus"` +} + +func eventTime(value, tz string) map[string]string { + out := map[string]string{"dateTime": value} + if tz != "" { + out["timeZone"] = tz + } + return out +} + +func parseGoogleError(resp *http.Response) error { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + var parsed struct { + Error struct { + Code int `json:"code"` + Message string `json:"message"` + Status string `json:"status"` + } `json:"error"` + } + message := strings.TrimSpace(string(data)) + code := strings.ToLower(strings.ReplaceAll(resp.Status, " ", "_")) + if json.Unmarshal(data, &parsed) == nil && parsed.Error.Message != "" { + message = parsed.Error.Message + if parsed.Error.Status != "" { + code = strings.ToLower(parsed.Error.Status) + } + } + if message == "" { + message = resp.Status + } + return &provider.Error{Code: code, Message: message, ProviderStatus: resp.Status, Retryable: resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500} +} + +func stringParam(params map[string]any, name string) string { + value, _ := params[name].(string) + return strings.TrimSpace(value) +} + +func defaultString(value, fallback string) string { + if value == "" { + return fallback + } + return value +} + +func StringSlice(value any) []string { + switch v := value.(type) { + case []string: + return v + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok && s != "" { + out = append(out, s) + } + } + return out + case string: + if v == "" { + return nil + } + return []string{v} + default: + return nil + } +} + +func StringValue(params map[string]any, name string) string { + value, _ := params[name].(string) + return strings.TrimSpace(value) +} + +func IntValue(params map[string]any, name string, fallback int) int { + switch v := params[name].(type) { + case float64: + return int(v) + case int: + return v + case string: + if parsed, err := strconv.Atoi(v); err == nil { + return parsed + } + } + return fallback +} + +func BoolValue(params map[string]any, name string, fallback bool) bool { + switch v := params[name].(type) { + case bool: + return v + case string: + switch strings.ToLower(v) { + case "true", "1", "yes", "on": + return true + case "false", "0", "no", "off": + return false + } + } + return fallback +} + +func RFC3339(value, name string) error { + if value == "" { + return &provider.Error{Code: "validation_failed", Message: name + " is required", Retryable: false} + } + if _, err := time.Parse(time.RFC3339, value); err != nil { + return &provider.Error{Code: "validation_failed", Message: name + " must be RFC3339 date-time", Retryable: false} + } + return nil +} diff --git a/providers/google-workspace/mod.go b/providers/google-workspace/mod.go new file mode 100644 index 0000000..c1db788 --- /dev/null +++ b/providers/google-workspace/mod.go @@ -0,0 +1,41 @@ +package googleworkspace + +import ( + "cli-factory/internal/provider" + "cli-factory/providers/google-workspace/calendarcheck" + "cli-factory/providers/google-workspace/calendarcreate" + "cli-factory/providers/google-workspace/gmailcheck" + "cli-factory/providers/google-workspace/gmailsend" +) + +type Provider struct{} + +func New() Provider { return Provider{} } + +func (Provider) ID() string { return "google-workspace" } +func (Provider) Name() string { return "Google Workspace" } +func (Provider) ShortDescription() string { + return "Use Gmail and Google Calendar from a Google Workspace account." +} +func (Provider) LongDescription() string { + return "Google Workspace exposes curated Gmail and Google Calendar workflows for agents: send and check email, and create and check calendar events with OAuth-backed API access." +} +func (Provider) Categories() []string { return []string{"email", "calendar", "productivity"} } +func (Provider) Aliases() []string { return []string{"google", "gmail", "calendar"} } +func (Provider) Parameters() []provider.Parameter { + return []provider.Parameter{ + {Name: "client_id", Description: "Google OAuth 2.0 client ID.", Required: true, Secret: true}, + {Name: "client_secret", Description: "Google OAuth 2.0 client secret.", Required: true, Secret: true}, + {Name: "refresh_token", Description: "OAuth refresh token for the Google Workspace user.", Required: true, Secret: true}, + {Name: "user_id", Description: "Gmail user identifier. Defaults to me.", Required: false}, + {Name: "calendar_id", Description: "Google Calendar ID. Defaults to primary.", Required: false}, + } +} +func (Provider) Tools() []provider.Tool { + return []provider.Tool{ + gmailsend.Tool{}, + gmailcheck.Tool{}, + calendarcreate.Tool{}, + calendarcheck.Tool{}, + } +} diff --git a/providers/google-workspace/test_secrets.example.yaml b/providers/google-workspace/test_secrets.example.yaml new file mode 100644 index 0000000..916071b --- /dev/null +++ b/providers/google-workspace/test_secrets.example.yaml @@ -0,0 +1,7 @@ +# Copy to override_test_secrets.yaml for local e2e runs, or to test_secrets.yaml +# before encrypting shared provider credentials. +client_id: replace-me.apps.googleusercontent.com +client_secret: replace-me +refresh_token: replace-me +test_user_email: bot@trytilde.ai +calendar_id: primary From 98e2ce28585d7a0261827d20fbdae195850a0e80 Mon Sep 17 00:00:00 2001 From: Daniel Blignaut Date: Fri, 15 May 2026 16:28:36 +0200 Subject: [PATCH 2/5] run 1: fix google workspace e2e wiring --- internal/cli/cli.go | 27 +++++++++++++++---- .../google-workspace/generator-metadata.yaml | 17 ++++++++++++ .../google-workspace/internal/e2e/helpers.go | 13 +++++++-- providers/google-workspace/mod.go | 8 +++--- 4 files changed, 54 insertions(+), 11 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 7f8aae9..56642b9 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -157,13 +157,24 @@ func (a App) runInvoke(ctx context.Context, flags globalFlags, original, args [] func (a App) runProviderTool(ctx context.Context, flags globalFlags, original []string, providerID, toolID string, args []string) int { params := map[string]any{} providerParams := map[string]any{} + var paramsJSON, providerParamsJSON string for i := 0; i < len(args); i++ { if !strings.HasPrefix(args[i], "--") { continue } name := strings.TrimPrefix(args[i], "--") - if name == "provider-params-json" || name == "params-json" { - i++ + if name == "provider-params-json" { + if i+1 < len(args) { + i++ + providerParamsJSON = args[i] + } + continue + } + if name == "params-json" { + if i+1 < len(args) { + i++ + paramsJSON = args[i] + } continue } value := "true" @@ -177,9 +188,15 @@ func (a App) runProviderTool(ctx context.Context, flags globalFlags, original [] params[strings.ReplaceAll(name, "-", "_")] = value } } - pp, _ := json.Marshal(providerParams) - p, _ := json.Marshal(params) - return a.invokeTool(ctx, flags, original, providerID, toolID, string(pp), string(p)) + if providerParamsJSON == "" { + pp, _ := json.Marshal(providerParams) + providerParamsJSON = string(pp) + } + if paramsJSON == "" { + p, _ := json.Marshal(params) + paramsJSON = string(p) + } + return a.invokeTool(ctx, flags, original, providerID, toolID, providerParamsJSON, paramsJSON) } func (a App) invokeTool(ctx context.Context, flags globalFlags, original []string, providerID, toolID, providerParamsJSON, paramsJSON string) int { diff --git a/providers/google-workspace/generator-metadata.yaml b/providers/google-workspace/generator-metadata.yaml index 2fa3e8c..c0bc222 100644 --- a/providers/google-workspace/generator-metadata.yaml +++ b/providers/google-workspace/generator-metadata.yaml @@ -387,3 +387,20 @@ testing: modify/send and Calendar events scopes test_user_email: bot@trytilde.ai calendar_id: primary +implementation: + notes: + - "Run 1 fixed e2e repo-root detection to locate go.mod instead of the + parent cli-factory directory." + - "Run 1 fixed direct provider-tool CLI handling so --params-json and + --provider-params-json are passed through to invokeTool instead of being + skipped." + - "Run 1 fixed google-workspace provider imports for hyphenated tool + directories." + - "Targeted real e2e reached the Gmail API. Invocation logs redacted + client_id, client_secret, and refresh_token in provider params." + last_failure: "Targeted gmail-send e2e is blocked by external Google Cloud + project state: Gmail API is disabled for project 68290622560, returning 403 + permission_denied." + next_action: "Enable Gmail API for Google Cloud project 68290622560, wait for + propagation, then rerun GOCACHE=/tmp/go-cache make test-provider-tool + PROVIDER=google-workspace TOOL=gmail-send." diff --git a/providers/google-workspace/internal/e2e/helpers.go b/providers/google-workspace/internal/e2e/helpers.go index e7e50b1..7b4b069 100644 --- a/providers/google-workspace/internal/e2e/helpers.go +++ b/providers/google-workspace/internal/e2e/helpers.go @@ -78,7 +78,11 @@ func (s Secrets) Client(t *testing.T) *googleapi.Client { func RunFactory(t *testing.T, args ...string) invocationlog.Log { t.Helper() - logDir := t.TempDir() + logDir, err := os.MkdirTemp("", "cli-factory-google-workspace-e2e-*") + if err != nil { + t.Fatalf("create e2e log dir: %v", err) + } + t.Logf("factory invocation logs: %s", logDir) fullArgs := append([]string{"run", "./cmd/factory", "--log-dir", logDir}, args...) cmd := exec.Command("go", fullArgs...) cmd.Dir = RepoRoot(t) @@ -132,7 +136,7 @@ func RepoRoot(t *testing.T) string { } dir := filepath.Dir(file) for { - if filepath.Base(dir) == "cli-factory" { + if fileExists(filepath.Join(dir, "go.mod")) { return dir } parent := filepath.Dir(dir) @@ -143,6 +147,11 @@ func RepoRoot(t *testing.T) string { } } +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + func logPathFromOutput(t *testing.T, output string) string { t.Helper() for _, line := range strings.Split(output, "\n") { diff --git a/providers/google-workspace/mod.go b/providers/google-workspace/mod.go index c1db788..3955db3 100644 --- a/providers/google-workspace/mod.go +++ b/providers/google-workspace/mod.go @@ -2,10 +2,10 @@ package googleworkspace import ( "cli-factory/internal/provider" - "cli-factory/providers/google-workspace/calendarcheck" - "cli-factory/providers/google-workspace/calendarcreate" - "cli-factory/providers/google-workspace/gmailcheck" - "cli-factory/providers/google-workspace/gmailsend" + calendarcheck "cli-factory/providers/google-workspace/calendar-check" + calendarcreate "cli-factory/providers/google-workspace/calendar-create" + gmailcheck "cli-factory/providers/google-workspace/gmail-check" + gmailsend "cli-factory/providers/google-workspace/gmail-send" ) type Provider struct{} From 663b0f5308fcb17ebd68b9b0551918ca85effd97 Mon Sep 17 00:00:00 2001 From: Daniel Blignaut Date: Fri, 15 May 2026 16:33:48 +0200 Subject: [PATCH 3/5] run 2: record google workspace scope blocker --- .../google-workspace/generator-metadata.yaml | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/providers/google-workspace/generator-metadata.yaml b/providers/google-workspace/generator-metadata.yaml index c0bc222..608e724 100644 --- a/providers/google-workspace/generator-metadata.yaml +++ b/providers/google-workspace/generator-metadata.yaml @@ -389,6 +389,12 @@ testing: calendar_id: primary implementation: notes: + - "Run 2 retried gmail-send after Gmail API enablement. The CLI/provider + send path succeeded and returned Gmail message_id 19e2c0d9a2196308 with + redacted provider params in the invocation log." + - "Run 2 verification failed when listing Gmail by query: Google returned + 'Metadata scope does not support q parameter', which indicates the saved + refresh token does not include gmail.modify/read-capable access." - "Run 1 fixed e2e repo-root detection to locate go.mod instead of the parent cli-factory directory." - "Run 1 fixed direct provider-tool CLI handling so --params-json and @@ -398,9 +404,11 @@ implementation: directories." - "Targeted real e2e reached the Gmail API. Invocation logs redacted client_id, client_secret, and refresh_token in provider params." - last_failure: "Targeted gmail-send e2e is blocked by external Google Cloud - project state: Gmail API is disabled for project 68290622560, returning 403 - permission_denied." - next_action: "Enable Gmail API for Google Cloud project 68290622560, wait for - propagation, then rerun GOCACHE=/tmp/go-cache make test-provider-tool - PROVIDER=google-workspace TOOL=gmail-send." + last_failure: "Targeted gmail-send e2e now sends successfully, but Gmail + search/check and cleanup are blocked by insufficient refresh-token scopes: + Google reports 'Metadata scope does not support q parameter'." + next_action: "Regenerate and save providers/google-workspace/override_test_secrets.yaml + with a refresh token authorized for https://www.googleapis.com/auth/gmail.modify, + https://www.googleapis.com/auth/gmail.send, and + https://www.googleapis.com/auth/calendar.events; then rerun GOCACHE=/tmp/go-cache + make test-provider-tool PROVIDER=google-workspace TOOL=gmail-send." From 0077c62591085e0cc8411fce9e8d17f955692e9a Mon Sep 17 00:00:00 2001 From: Daniel Blignaut Date: Fri, 15 May 2026 16:45:10 +0200 Subject: [PATCH 4/5] run 3: pass google workspace provider checks --- catalog/tools.json | 62 +++++++++++++++++++ docs | 2 +- providers/example/echo/e2e_test.go | 8 ++- .../google-workspace/generator-metadata.yaml | 38 +++++------- .../internal/googleapi/client.go | 1 + 5 files changed, 86 insertions(+), 25 deletions(-) diff --git a/catalog/tools.json b/catalog/tools.json index d5ae28a..4c08ae7 100644 --- a/catalog/tools.json +++ b/catalog/tools.json @@ -14,6 +14,68 @@ "debug" ], "search_document": "example Example echo Echo Echo a message for CLI and e2e validation. Echo returns the provided message and is intentionally local, deterministic, and safe for CI e2e tests. example debug repeat say" + }, + { + "id": "google-workspace.calendar-check", + "provider_id": "google-workspace", + "command_path": [ + "google-workspace", + "calendar-check" + ], + "name": "Calendar Check", + "description": "Search Google Calendar events.", + "categories": [ + "calendar", + "scheduling", + "search" + ], + "search_document": "google-workspace Google Workspace calendar-check Calendar Check Search Google Calendar events. List or search Google Calendar events in a time window on the configured calendar. calendar scheduling search check-events list-events" + }, + { + "id": "google-workspace.calendar-create", + "provider_id": "google-workspace", + "command_path": [ + "google-workspace", + "calendar-create" + ], + "name": "Calendar Create", + "description": "Create a Google Calendar event.", + "categories": [ + "calendar", + "scheduling" + ], + "search_document": "google-workspace Google Workspace calendar-create Calendar Create Create a Google Calendar event. Create a Google Calendar event on the configured calendar and return compact event details. calendar scheduling create-event schedule" + }, + { + "id": "google-workspace.gmail-check", + "provider_id": "google-workspace", + "command_path": [ + "google-workspace", + "gmail-check" + ], + "name": "Gmail Check", + "description": "Search recent Gmail messages.", + "categories": [ + "email", + "gmail", + "search" + ], + "search_document": "google-workspace Google Workspace gmail-check Gmail Check Search recent Gmail messages. Search or list Gmail messages for the authenticated Google Workspace user and return compact message details. email gmail search check-email search-email" + }, + { + "id": "google-workspace.gmail-send", + "provider_id": "google-workspace", + "command_path": [ + "google-workspace", + "gmail-send" + ], + "name": "Gmail Send", + "description": "Send a Gmail message from a Google Workspace account.", + "categories": [ + "email", + "gmail" + ], + "search_document": "google-workspace Google Workspace gmail-send Gmail Send Send a Gmail message from a Google Workspace account. Send a plain-text Gmail message through the Gmail API using OAuth-backed Google Workspace credentials. email gmail send-email email" } ] } diff --git a/docs b/docs index 73bda2f..ac3c667 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 73bda2f443edf7468ae135950f0e3198b7cab3ea +Subproject commit ac3c667967d2bdbe22f0982beeb671344071b1ab diff --git a/providers/example/echo/e2e_test.go b/providers/example/echo/e2e_test.go index 6101f7e..a4575d2 100644 --- a/providers/example/echo/e2e_test.go +++ b/providers/example/echo/e2e_test.go @@ -1,6 +1,7 @@ package echo_test import ( + "os" "os/exec" "path/filepath" "runtime" @@ -33,7 +34,7 @@ func repoRoot(t *testing.T) string { } dir := filepath.Dir(file) for { - if filepath.Base(dir) == "cli-factory" { + if fileExists(filepath.Join(dir, "go.mod")) { return dir } parent := filepath.Dir(dir) @@ -43,3 +44,8 @@ func repoRoot(t *testing.T) string { dir = parent } } + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/providers/google-workspace/generator-metadata.yaml b/providers/google-workspace/generator-metadata.yaml index 608e724..3e340b9 100644 --- a/providers/google-workspace/generator-metadata.yaml +++ b/providers/google-workspace/generator-metadata.yaml @@ -389,26 +389,18 @@ testing: calendar_id: primary implementation: notes: - - "Run 2 retried gmail-send after Gmail API enablement. The CLI/provider - send path succeeded and returned Gmail message_id 19e2c0d9a2196308 with - redacted provider params in the invocation log." - - "Run 2 verification failed when listing Gmail by query: Google returned - 'Metadata scope does not support q parameter', which indicates the saved - refresh token does not include gmail.modify/read-capable access." - - "Run 1 fixed e2e repo-root detection to locate go.mod instead of the - parent cli-factory directory." - - "Run 1 fixed direct provider-tool CLI handling so --params-json and - --provider-params-json are passed through to invokeTool instead of being - skipped." - - "Run 1 fixed google-workspace provider imports for hyphenated tool - directories." - - "Targeted real e2e reached the Gmail API. Invocation logs redacted - client_id, client_secret, and refresh_token in provider params." - last_failure: "Targeted gmail-send e2e now sends successfully, but Gmail - search/check and cleanup are blocked by insufficient refresh-token scopes: - Google reports 'Metadata scope does not support q parameter'." - next_action: "Regenerate and save providers/google-workspace/override_test_secrets.yaml - with a refresh token authorized for https://www.googleapis.com/auth/gmail.modify, - https://www.googleapis.com/auth/gmail.send, and - https://www.googleapis.com/auth/calendar.events; then rerun GOCACHE=/tmp/go-cache - make test-provider-tool PROVIDER=google-workspace TOOL=gmail-send." + - Run 3 saved the regenerated refresh token locally, patched OAuth refresh + to request the narrower access-token subset gmail.modify, gmail.send, and + calendar.events so Gmail query search is not blocked by the token also + having gmail.metadata. + - Run 3 targeted e2e passed for gmail-send, gmail-check, calendar-create, + calendar-check, and make test-provider PROVIDER=google-workspace. + - "Run 3 broad checks passed: make test-unit, make test-e2e, make + generate-docs, make generate-catalog, and make build." + - Run 3 fixed the pre-existing example provider e2e repo-root helper to + locate go.mod, allowing make test-e2e to pass in this workspace layout. + last_failure: No provider/test failure remains after run 3. The only remaining + blocker is Git push from this clone, which still fails because the local + repository has missing blobs unrelated to the provider changes. + next_action: Repair or reclone the Git repository before pushing the local + branch/tags, or apply the local commits from this branch in a healthy clone. diff --git a/providers/google-workspace/internal/googleapi/client.go b/providers/google-workspace/internal/googleapi/client.go index 7a79590..039c8a7 100644 --- a/providers/google-workspace/internal/googleapi/client.go +++ b/providers/google-workspace/internal/googleapi/client.go @@ -217,6 +217,7 @@ func (c *Client) accessToken(ctx context.Context) (string, error) { form.Set("client_secret", c.Config.ClientSecret) form.Set("refresh_token", c.Config.RefreshToken) form.Set("grant_type", "refresh_token") + form.Set("scope", "https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/calendar.events") req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode())) if err != nil { return "", err From 380c529eb8fe8c4c8635a7536c11c8d935ae3c71 Mon Sep 17 00:00:00 2001 From: Daniel Blignaut Date: Fri, 15 May 2026 17:17:01 +0200 Subject: [PATCH 5/5] run 4: use generated google workspace metadata --- docs | 2 +- .../calendar-check/metadata_gen.go | 25 +++++++ .../google-workspace/calendar-check/mod.go | 35 +++------- .../calendar-create/metadata_gen.go | 25 +++++++ .../google-workspace/calendar-create/mod.go | 41 +++-------- providers/google-workspace/cli-metadata.yaml | 10 ++- .../gmail-check/metadata_gen.go | 25 +++++++ providers/google-workspace/gmail-check/mod.go | 36 +++------- .../gmail-send/metadata_gen.go | 25 +++++++ providers/google-workspace/gmail-send/mod.go | 35 +++------- .../google-workspace/internal/e2e/helpers.go | 56 ++++++++++++--- .../internal/googleapi/client.go | 70 +++---------------- providers/google-workspace/metadata_gen.go | 25 +++++++ providers/google-workspace/mod.go | 24 +++---- 14 files changed, 232 insertions(+), 202 deletions(-) create mode 100644 providers/google-workspace/calendar-check/metadata_gen.go create mode 100644 providers/google-workspace/calendar-create/metadata_gen.go create mode 100644 providers/google-workspace/gmail-check/metadata_gen.go create mode 100644 providers/google-workspace/gmail-send/metadata_gen.go create mode 100644 providers/google-workspace/metadata_gen.go diff --git a/docs b/docs index ac3c667..0f9697b 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit ac3c667967d2bdbe22f0982beeb671344071b1ab +Subproject commit 0f9697bc304d17f202a4bca06e050751d238dc77 diff --git a/providers/google-workspace/calendar-check/metadata_gen.go b/providers/google-workspace/calendar-check/metadata_gen.go new file mode 100644 index 0000000..9c9df3d --- /dev/null +++ b/providers/google-workspace/calendar-check/metadata_gen.go @@ -0,0 +1,25 @@ +// Code generated by make generate-metadata; DO NOT EDIT. +// Source of truth: cli-metadata.yaml plus input-schema.yaml/output-schema.yaml. +// The generator compiles provider/tool metadata and JSON schemas into static Go values for the factory binary. + +package calendarcheck + +import "cli-factory/internal/schema" + +const toolID = "calendar-check" + +const toolName = "Calendar Check" + +const toolShortDescription = "Search Google Calendar events." + +const toolLongDescription = "List or search Google Calendar events in a time window on the configured calendar." + +var toolCategories = []string{"calendar", "scheduling", "search"} + +var toolAliases = []string{"check-events", "list-events"} + +var toolCommandPath = []string{"google-workspace", "calendar-check"} + +var toolInputSchema = schema.JSONSchema(map[string]any{"properties": map[string]any{"max_results": map[string]any{"default": 10, "maximum": 50, "minimum": 1, "type": "integer"}, "query": map[string]any{"type": "string"}, "single_events": map[string]any{"default": true, "type": "boolean"}, "time_max": map[string]any{"format": "date-time", "type": "string"}, "time_min": map[string]any{"format": "date-time", "type": "string"}}, "required": []any{"time_min", "time_max"}, "type": "object"}) + +var toolOutputSchema = schema.JSONSchema(map[string]any{"properties": map[string]any{"events": map[string]any{"items": map[string]any{"properties": map[string]any{"end": map[string]any{"type": "string"}, "event_id": map[string]any{"type": "string"}, "html_link": map[string]any{"type": "string"}, "location": map[string]any{"type": "string"}, "start": map[string]any{"type": "string"}, "status": map[string]any{"type": "string"}, "summary": map[string]any{"type": "string"}}, "type": "object"}, "type": "array"}, "next_page_token": map[string]any{"type": "string"}, "result_count": map[string]any{"type": "integer"}}, "type": "object"}) diff --git a/providers/google-workspace/calendar-check/mod.go b/providers/google-workspace/calendar-check/mod.go index b189fbe..0ed8f98 100644 --- a/providers/google-workspace/calendar-check/mod.go +++ b/providers/google-workspace/calendar-check/mod.go @@ -10,36 +10,19 @@ import ( type Tool struct{} -func (Tool) ID() string { return "calendar-check" } -func (Tool) Name() string { return "Calendar Check" } -func (Tool) ShortDescription() string { return "Search Google Calendar events." } -func (Tool) LongDescription() string { - return "List or search Google Calendar events in a time window on the configured calendar." +func (Tool) ID() string { return toolID } +func (Tool) Name() string { return toolName } +func (Tool) ShortDescription() string { return toolShortDescription } +func (Tool) LongDescription() string { return toolLongDescription } +func (Tool) Categories() []string { + return append([]string(nil), toolCategories...) } -func (Tool) Categories() []string { return []string{"calendar", "scheduling", "search"} } -func (Tool) Aliases() []string { return []string{"check-events", "list-events"} } +func (Tool) Aliases() []string { return append([]string(nil), toolAliases...) } func (Tool) InputSchema() schema.JSONSchema { - return schema.JSONSchema{ - "type": "object", - "required": []any{"time_min", "time_max"}, - "properties": map[string]any{ - "time_min": map[string]any{"type": "string", "format": "date-time"}, - "time_max": map[string]any{"type": "string", "format": "date-time"}, - "query": map[string]any{"type": "string"}, - "max_results": map[string]any{"type": "integer", "minimum": 1, "maximum": 50, "default": 10}, - "single_events": map[string]any{"type": "boolean", "default": true}, - }, - } + return toolInputSchema } func (Tool) OutputSchema() schema.JSONSchema { - return schema.JSONSchema{ - "type": "object", - "properties": map[string]any{ - "result_count": map[string]any{"type": "integer"}, - "next_page_token": map[string]any{"type": "string"}, - "events": map[string]any{"type": "array", "items": map[string]any{"type": "object", "properties": map[string]any{"event_id": map[string]any{"type": "string"}, "summary": map[string]any{"type": "string"}, "status": map[string]any{"type": "string"}, "html_link": map[string]any{"type": "string"}, "start": map[string]any{"type": "string"}, "end": map[string]any{"type": "string"}, "location": map[string]any{"type": "string"}}}}, - }, - } + return toolOutputSchema } func (Tool) Invoke(ctx context.Context, req provider.InvokeRequest, events provider.EventSink) (provider.InvokeResult, error) { diff --git a/providers/google-workspace/calendar-create/metadata_gen.go b/providers/google-workspace/calendar-create/metadata_gen.go new file mode 100644 index 0000000..027d826 --- /dev/null +++ b/providers/google-workspace/calendar-create/metadata_gen.go @@ -0,0 +1,25 @@ +// Code generated by make generate-metadata; DO NOT EDIT. +// Source of truth: cli-metadata.yaml plus input-schema.yaml/output-schema.yaml. +// The generator compiles provider/tool metadata and JSON schemas into static Go values for the factory binary. + +package calendarcreate + +import "cli-factory/internal/schema" + +const toolID = "calendar-create" + +const toolName = "Calendar Create" + +const toolShortDescription = "Create a Google Calendar event." + +const toolLongDescription = "Create a Google Calendar event on the configured calendar and return compact event details." + +var toolCategories = []string{"calendar", "scheduling"} + +var toolAliases = []string{"create-event", "schedule"} + +var toolCommandPath = []string{"google-workspace", "calendar-create"} + +var toolInputSchema = schema.JSONSchema(map[string]any{"properties": map[string]any{"attendees": map[string]any{"items": map[string]any{"properties": map[string]any{"display_name": map[string]any{"type": "string"}, "email": map[string]any{"type": "string"}}, "required": []any{"email"}, "type": "object"}, "type": "array"}, "description": map[string]any{"type": "string"}, "end": map[string]any{"description": "RFC3339 end timestamp.", "format": "date-time", "type": "string"}, "location": map[string]any{"type": "string"}, "start": map[string]any{"description": "RFC3339 start timestamp.", "format": "date-time", "type": "string"}, "summary": map[string]any{"minLength": 1, "type": "string"}, "time_zone": map[string]any{"description": "Optional IANA time zone.", "type": "string"}}, "required": []any{"summary", "start", "end"}, "type": "object"}) + +var toolOutputSchema = schema.JSONSchema(map[string]any{"properties": map[string]any{"attendees": map[string]any{"items": map[string]any{"properties": map[string]any{"email": map[string]any{"type": "string"}, "response_status": map[string]any{"type": "string"}}, "type": "object"}, "type": "array"}, "end": map[string]any{"type": "string"}, "event_id": map[string]any{"type": "string"}, "html_link": map[string]any{"type": "string"}, "start": map[string]any{"type": "string"}, "status": map[string]any{"type": "string"}, "summary": map[string]any{"type": "string"}}, "type": "object"}) diff --git a/providers/google-workspace/calendar-create/mod.go b/providers/google-workspace/calendar-create/mod.go index a07af0d..da221f7 100644 --- a/providers/google-workspace/calendar-create/mod.go +++ b/providers/google-workspace/calendar-create/mod.go @@ -10,42 +10,19 @@ import ( type Tool struct{} -func (Tool) ID() string { return "calendar-create" } -func (Tool) Name() string { return "Calendar Create" } -func (Tool) ShortDescription() string { return "Create a Google Calendar event." } -func (Tool) LongDescription() string { - return "Create a Google Calendar event on the configured calendar and return compact event details." +func (Tool) ID() string { return toolID } +func (Tool) Name() string { return toolName } +func (Tool) ShortDescription() string { return toolShortDescription } +func (Tool) LongDescription() string { return toolLongDescription } +func (Tool) Categories() []string { + return append([]string(nil), toolCategories...) } -func (Tool) Categories() []string { return []string{"calendar", "scheduling"} } -func (Tool) Aliases() []string { return []string{"create-event", "schedule"} } +func (Tool) Aliases() []string { return append([]string(nil), toolAliases...) } func (Tool) InputSchema() schema.JSONSchema { - return schema.JSONSchema{ - "type": "object", - "required": []any{"summary", "start", "end"}, - "properties": map[string]any{ - "summary": map[string]any{"type": "string", "minLength": 1}, - "start": map[string]any{"type": "string", "format": "date-time", "description": "RFC3339 start timestamp."}, - "end": map[string]any{"type": "string", "format": "date-time", "description": "RFC3339 end timestamp."}, - "time_zone": map[string]any{"type": "string", "description": "Optional IANA time zone."}, - "description": map[string]any{"type": "string"}, - "location": map[string]any{"type": "string"}, - "attendees": map[string]any{"type": "array", "items": map[string]any{"type": "object", "required": []any{"email"}, "properties": map[string]any{"email": map[string]any{"type": "string"}, "display_name": map[string]any{"type": "string"}}}}, - }, - } + return toolInputSchema } func (Tool) OutputSchema() schema.JSONSchema { - return schema.JSONSchema{ - "type": "object", - "properties": map[string]any{ - "event_id": map[string]any{"type": "string"}, - "html_link": map[string]any{"type": "string"}, - "status": map[string]any{"type": "string"}, - "summary": map[string]any{"type": "string"}, - "start": map[string]any{"type": "string"}, - "end": map[string]any{"type": "string"}, - "attendees": map[string]any{"type": "array", "items": map[string]any{"type": "object", "properties": map[string]any{"email": map[string]any{"type": "string"}, "response_status": map[string]any{"type": "string"}}}}, - }, - } + return toolOutputSchema } func (Tool) Invoke(ctx context.Context, req provider.InvokeRequest, events provider.EventSink) (provider.InvokeResult, error) { diff --git a/providers/google-workspace/cli-metadata.yaml b/providers/google-workspace/cli-metadata.yaml index d150e7e..b85cffd 100644 --- a/providers/google-workspace/cli-metadata.yaml +++ b/providers/google-workspace/cli-metadata.yaml @@ -13,12 +13,10 @@ aliases: - gmail - calendar provider_parameters: - - name: client_id - description: Google OAuth 2.0 client ID. - - name: client_secret - description: Google OAuth 2.0 client secret. - - name: refresh_token - description: OAuth refresh token for the Google Workspace user. + - name: access_token + description: Google OAuth 2.0 access token with Gmail and Calendar scopes. + required: true + secret: true - name: user_id description: Gmail user identifier. Defaults to me. - name: calendar_id diff --git a/providers/google-workspace/gmail-check/metadata_gen.go b/providers/google-workspace/gmail-check/metadata_gen.go new file mode 100644 index 0000000..b8a0790 --- /dev/null +++ b/providers/google-workspace/gmail-check/metadata_gen.go @@ -0,0 +1,25 @@ +// Code generated by make generate-metadata; DO NOT EDIT. +// Source of truth: cli-metadata.yaml plus input-schema.yaml/output-schema.yaml. +// The generator compiles provider/tool metadata and JSON schemas into static Go values for the factory binary. + +package gmailcheck + +import "cli-factory/internal/schema" + +const toolID = "gmail-check" + +const toolName = "Gmail Check" + +const toolShortDescription = "Search recent Gmail messages." + +const toolLongDescription = "Search or list Gmail messages for the authenticated Google Workspace user and return compact message details." + +var toolCategories = []string{"email", "gmail", "search"} + +var toolAliases = []string{"check-email", "search-email"} + +var toolCommandPath = []string{"google-workspace", "gmail-check"} + +var toolInputSchema = schema.JSONSchema(map[string]any{"properties": map[string]any{"include_snippet": map[string]any{"default": true, "type": "boolean"}, "max_results": map[string]any{"default": 10, "maximum": 25, "minimum": 1, "type": "integer"}, "query": map[string]any{"description": "Gmail search query.", "type": "string"}}, "type": "object"}) + +var toolOutputSchema = schema.JSONSchema(map[string]any{"properties": map[string]any{"messages": map[string]any{"items": map[string]any{"properties": map[string]any{"id": map[string]any{"type": "string"}, "label_ids": map[string]any{"items": map[string]any{"type": "string"}, "type": "array"}, "snippet": map[string]any{"type": "string"}, "thread_id": map[string]any{"type": "string"}}, "type": "object"}, "type": "array"}, "result_count": map[string]any{"type": "integer"}}, "type": "object"}) diff --git a/providers/google-workspace/gmail-check/mod.go b/providers/google-workspace/gmail-check/mod.go index a2adf38..9d1d290 100644 --- a/providers/google-workspace/gmail-check/mod.go +++ b/providers/google-workspace/gmail-check/mod.go @@ -10,37 +10,19 @@ import ( type Tool struct{} -func (Tool) ID() string { return "gmail-check" } -func (Tool) Name() string { return "Gmail Check" } -func (Tool) ShortDescription() string { return "Search recent Gmail messages." } -func (Tool) LongDescription() string { - return "Search or list Gmail messages for the authenticated Google Workspace user and return compact message details." +func (Tool) ID() string { return toolID } +func (Tool) Name() string { return toolName } +func (Tool) ShortDescription() string { return toolShortDescription } +func (Tool) LongDescription() string { return toolLongDescription } +func (Tool) Categories() []string { + return append([]string(nil), toolCategories...) } -func (Tool) Categories() []string { return []string{"email", "gmail", "search"} } -func (Tool) Aliases() []string { return []string{"check-email", "search-email"} } +func (Tool) Aliases() []string { return append([]string(nil), toolAliases...) } func (Tool) InputSchema() schema.JSONSchema { - return schema.JSONSchema{ - "type": "object", - "properties": map[string]any{ - "query": map[string]any{"type": "string", "description": "Gmail search query."}, - "max_results": map[string]any{"type": "integer", "minimum": 1, "maximum": 25, "default": 10}, - "include_snippet": map[string]any{"type": "boolean", "default": true}, - }, - } + return toolInputSchema } func (Tool) OutputSchema() schema.JSONSchema { - return schema.JSONSchema{ - "type": "object", - "properties": map[string]any{ - "result_count": map[string]any{"type": "integer"}, - "messages": map[string]any{"type": "array", "items": map[string]any{"type": "object", "properties": map[string]any{ - "id": map[string]any{"type": "string"}, - "thread_id": map[string]any{"type": "string"}, - "snippet": map[string]any{"type": "string"}, - "label_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, - }}}, - }, - } + return toolOutputSchema } func (Tool) Invoke(ctx context.Context, req provider.InvokeRequest, events provider.EventSink) (provider.InvokeResult, error) { diff --git a/providers/google-workspace/gmail-send/metadata_gen.go b/providers/google-workspace/gmail-send/metadata_gen.go new file mode 100644 index 0000000..4d3164d --- /dev/null +++ b/providers/google-workspace/gmail-send/metadata_gen.go @@ -0,0 +1,25 @@ +// Code generated by make generate-metadata; DO NOT EDIT. +// Source of truth: cli-metadata.yaml plus input-schema.yaml/output-schema.yaml. +// The generator compiles provider/tool metadata and JSON schemas into static Go values for the factory binary. + +package gmailsend + +import "cli-factory/internal/schema" + +const toolID = "gmail-send" + +const toolName = "Gmail Send" + +const toolShortDescription = "Send a Gmail message from a Google Workspace account." + +const toolLongDescription = "Send a plain-text Gmail message through the Gmail API using OAuth-backed Google Workspace credentials." + +var toolCategories = []string{"email", "gmail"} + +var toolAliases = []string{"send-email", "email"} + +var toolCommandPath = []string{"google-workspace", "gmail-send"} + +var toolInputSchema = schema.JSONSchema(map[string]any{"properties": map[string]any{"bcc": map[string]any{"description": "Optional BCC recipients.", "items": map[string]any{"type": "string"}, "type": "array"}, "body_text": map[string]any{"minLength": 1, "type": "string"}, "cc": map[string]any{"description": "Optional CC recipients.", "items": map[string]any{"type": "string"}, "type": "array"}, "subject": map[string]any{"minLength": 1, "type": "string"}, "to": map[string]any{"description": "Recipient email addresses.", "items": map[string]any{"type": "string"}, "minItems": 1, "type": "array"}}, "required": []any{"to", "subject", "body_text"}, "type": "object"}) + +var toolOutputSchema = schema.JSONSchema(map[string]any{"properties": map[string]any{"label_ids": map[string]any{"items": map[string]any{"type": "string"}, "type": "array"}, "message_id": map[string]any{"type": "string"}, "thread_id": map[string]any{"type": "string"}}, "type": "object"}) diff --git a/providers/google-workspace/gmail-send/mod.go b/providers/google-workspace/gmail-send/mod.go index 0447311..1519f4d 100644 --- a/providers/google-workspace/gmail-send/mod.go +++ b/providers/google-workspace/gmail-send/mod.go @@ -10,36 +10,19 @@ import ( type Tool struct{} -func (Tool) ID() string { return "gmail-send" } -func (Tool) Name() string { return "Gmail Send" } -func (Tool) ShortDescription() string { return "Send a Gmail message from a Google Workspace account." } -func (Tool) LongDescription() string { - return "Send a plain-text Gmail message through the Gmail API using OAuth-backed Google Workspace credentials." +func (Tool) ID() string { return toolID } +func (Tool) Name() string { return toolName } +func (Tool) ShortDescription() string { return toolShortDescription } +func (Tool) LongDescription() string { return toolLongDescription } +func (Tool) Categories() []string { + return append([]string(nil), toolCategories...) } -func (Tool) Categories() []string { return []string{"email", "gmail"} } -func (Tool) Aliases() []string { return []string{"send-email", "email"} } +func (Tool) Aliases() []string { return append([]string(nil), toolAliases...) } func (Tool) InputSchema() schema.JSONSchema { - return schema.JSONSchema{ - "type": "object", - "required": []any{"to", "subject", "body_text"}, - "properties": map[string]any{ - "to": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "minItems": 1, "description": "Recipient email addresses."}, - "cc": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "Optional CC recipients."}, - "bcc": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "Optional BCC recipients."}, - "subject": map[string]any{"type": "string", "minLength": 1}, - "body_text": map[string]any{"type": "string", "minLength": 1}, - }, - } + return toolInputSchema } func (Tool) OutputSchema() schema.JSONSchema { - return schema.JSONSchema{ - "type": "object", - "properties": map[string]any{ - "message_id": map[string]any{"type": "string"}, - "thread_id": map[string]any{"type": "string"}, - "label_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, - }, - } + return toolOutputSchema } func (Tool) Invoke(ctx context.Context, req provider.InvokeRequest, events provider.EventSink) (provider.InvokeResult, error) { diff --git a/providers/google-workspace/internal/e2e/helpers.go b/providers/google-workspace/internal/e2e/helpers.go index 7b4b069..365bc89 100644 --- a/providers/google-workspace/internal/e2e/helpers.go +++ b/providers/google-workspace/internal/e2e/helpers.go @@ -3,6 +3,9 @@ package e2e import ( "context" "encoding/json" + "errors" + "net/http" + "net/url" "os" "os/exec" "path/filepath" @@ -17,6 +20,8 @@ import ( "gopkg.in/yaml.v3" ) +const tokenURL = "https://oauth2.googleapis.com/token" + type Secrets struct { ClientID string `yaml:"client_id"` ClientSecret string `yaml:"client_secret"` @@ -30,6 +35,9 @@ func LoadSecrets(t *testing.T) Secrets { root := RepoRoot(t) loaded, err := testharness.LoadProviderTestSecrets(context.Background(), testharness.SecretOptionsFromEnv(filepath.Join(root, "providers", "google-workspace"))) if err != nil { + if errors.Is(err, testharness.ErrNoSecrets) { + t.Skip("Google Workspace e2e secrets not configured") + } t.Fatalf("load Google Workspace test secrets: %v", err) } var secrets Secrets @@ -50,32 +58,64 @@ func LoadSecrets(t *testing.T) Secrets { func (s Secrets) ProviderParamsJSON(t *testing.T) string { t.Helper() - data, err := json.Marshal(s.ProviderParams()) + data, err := json.Marshal(s.ProviderParams(t)) if err != nil { t.Fatal(err) } return string(data) } -func (s Secrets) ProviderParams() map[string]any { +func (s Secrets) ProviderParams(t *testing.T) map[string]any { + t.Helper() return map[string]any{ - "client_id": s.ClientID, - "client_secret": s.ClientSecret, - "refresh_token": s.RefreshToken, - "user_id": "me", - "calendar_id": s.CalendarID, + "access_token": s.AccessToken(t, context.Background()), + "user_id": "me", + "calendar_id": s.CalendarID, } } func (s Secrets) Client(t *testing.T) *googleapi.Client { t.Helper() - client, err := googleapi.New(s.ProviderParams()) + client, err := googleapi.New(s.ProviderParams(t)) if err != nil { t.Fatalf("create Google API client: %v", err) } return client } +func (s Secrets) AccessToken(t *testing.T, ctx context.Context) string { + t.Helper() + form := url.Values{} + form.Set("client_id", s.ClientID) + form.Set("client_secret", s.ClientSecret) + form.Set("refresh_token", s.RefreshToken) + form.Set("grant_type", "refresh_token") + form.Set("scope", "https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/calendar.events") + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode())) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + t.Fatalf("Google token refresh failed: %s", resp.Status) + } + var parsed struct { + AccessToken string `json:"access_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + t.Fatal(err) + } + if parsed.AccessToken == "" { + t.Fatal("Google token refresh response did not include access_token") + } + return parsed.AccessToken +} + func RunFactory(t *testing.T, args ...string) invocationlog.Log { t.Helper() logDir, err := os.MkdirTemp("", "cli-factory-google-workspace-e2e-*") diff --git a/providers/google-workspace/internal/googleapi/client.go b/providers/google-workspace/internal/googleapi/client.go index 039c8a7..b78595c 100644 --- a/providers/google-workspace/internal/googleapi/client.go +++ b/providers/google-workspace/internal/googleapi/client.go @@ -22,40 +22,28 @@ import ( const ( gmailBaseURL = "https://gmail.googleapis.com/gmail/v1" calendarBaseURL = "https://www.googleapis.com/calendar/v3" - tokenURL = "https://oauth2.googleapis.com/token" ) type Config struct { - ClientID string - ClientSecret string - RefreshToken string - UserID string - CalendarID string + AccessToken string + UserID string + CalendarID string } type Client struct { Config Config HTTP *http.Client - token string } func New(params map[string]any) (*Client, error) { cfg := Config{ - ClientID: stringParam(params, "client_id"), - ClientSecret: stringParam(params, "client_secret"), - RefreshToken: stringParam(params, "refresh_token"), - UserID: defaultString(stringParam(params, "user_id"), "me"), - CalendarID: defaultString(stringParam(params, "calendar_id"), "primary"), + AccessToken: stringParam(params, "access_token"), + UserID: defaultString(stringParam(params, "user_id"), "me"), + CalendarID: defaultString(stringParam(params, "calendar_id"), "primary"), } var missing []string - if cfg.ClientID == "" { - missing = append(missing, "client_id") - } - if cfg.ClientSecret == "" { - missing = append(missing, "client_secret") - } - if cfg.RefreshToken == "" { - missing = append(missing, "refresh_token") + if cfg.AccessToken == "" { + missing = append(missing, "access_token") } if len(missing) > 0 { return nil, &provider.Error{Code: "validation_failed", Message: "missing provider params: " + strings.Join(missing, ", "), Retryable: false} @@ -171,10 +159,6 @@ func (c *Client) DeleteEvent(ctx context.Context, id string) error { } func (c *Client) do(ctx context.Context, method, endpoint string, headers map[string]string, payload any, out any) error { - token, err := c.accessToken(ctx) - if err != nil { - return err - } var body io.Reader if payload != nil { data, err := json.Marshal(payload) @@ -187,7 +171,7 @@ func (c *Client) do(ctx context.Context, method, endpoint string, headers map[st if err != nil { return err } - req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Authorization", "Bearer "+c.Config.AccessToken) if payload != nil { req.Header.Set("Content-Type", "application/json") } @@ -208,42 +192,6 @@ func (c *Client) do(ctx context.Context, method, endpoint string, headers map[st return json.NewDecoder(resp.Body).Decode(out) } -func (c *Client) accessToken(ctx context.Context) (string, error) { - if c.token != "" { - return c.token, nil - } - form := url.Values{} - form.Set("client_id", c.Config.ClientID) - form.Set("client_secret", c.Config.ClientSecret) - form.Set("refresh_token", c.Config.RefreshToken) - form.Set("grant_type", "refresh_token") - form.Set("scope", "https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/calendar.events") - req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode())) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := c.httpClient().Do(req) - if err != nil { - return "", &provider.Error{Code: "auth_failed", Message: err.Error(), Retryable: true} - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", parseGoogleError(resp) - } - var parsed struct { - AccessToken string `json:"access_token"` - } - if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { - return "", err - } - if parsed.AccessToken == "" { - return "", &provider.Error{Code: "auth_failed", Message: "Google token response did not include access_token", Retryable: false} - } - c.token = parsed.AccessToken - return c.token, nil -} - func (c *Client) httpClient() *http.Client { if c.HTTP != nil { return c.HTTP diff --git a/providers/google-workspace/metadata_gen.go b/providers/google-workspace/metadata_gen.go new file mode 100644 index 0000000..60ac96a --- /dev/null +++ b/providers/google-workspace/metadata_gen.go @@ -0,0 +1,25 @@ +// Code generated by make generate-metadata; DO NOT EDIT. +// Source of truth: cli-metadata.yaml plus input-schema.yaml/output-schema.yaml. +// The generator compiles provider/tool metadata and JSON schemas into static Go values for the factory binary. + +package googleworkspace + +import "cli-factory/internal/provider" + +const providerID = "google-workspace" + +const providerName = "Google Workspace" + +const providerShortDescription = "Use Gmail and Google Calendar from a Google Workspace account." + +const providerLongDescription = "Google Workspace exposes curated Gmail and Google Calendar workflows for agents: send and check email, and create and check calendar events with OAuth-backed API access." + +var providerCategories = []string{"email", "calendar", "productivity"} + +var providerAliases = []string{"google", "gmail", "calendar"} + +var providerParameters = []provider.Parameter{ + {Name: "access_token", Description: "Google OAuth 2.0 access token with Gmail and Calendar scopes.", Required: true, Secret: true}, + {Name: "user_id", Description: "Gmail user identifier. Defaults to me.", Required: false, Secret: false}, + {Name: "calendar_id", Description: "Google Calendar ID. Defaults to primary.", Required: false, Secret: false}, +} diff --git a/providers/google-workspace/mod.go b/providers/google-workspace/mod.go index 3955db3..7f610f4 100644 --- a/providers/google-workspace/mod.go +++ b/providers/google-workspace/mod.go @@ -12,24 +12,18 @@ type Provider struct{} func New() Provider { return Provider{} } -func (Provider) ID() string { return "google-workspace" } -func (Provider) Name() string { return "Google Workspace" } -func (Provider) ShortDescription() string { - return "Use Gmail and Google Calendar from a Google Workspace account." +func (Provider) ID() string { return providerID } +func (Provider) Name() string { return providerName } +func (Provider) ShortDescription() string { return providerShortDescription } +func (Provider) LongDescription() string { return providerLongDescription } +func (Provider) Categories() []string { + return append([]string(nil), providerCategories...) } -func (Provider) LongDescription() string { - return "Google Workspace exposes curated Gmail and Google Calendar workflows for agents: send and check email, and create and check calendar events with OAuth-backed API access." +func (Provider) Aliases() []string { + return append([]string(nil), providerAliases...) } -func (Provider) Categories() []string { return []string{"email", "calendar", "productivity"} } -func (Provider) Aliases() []string { return []string{"google", "gmail", "calendar"} } func (Provider) Parameters() []provider.Parameter { - return []provider.Parameter{ - {Name: "client_id", Description: "Google OAuth 2.0 client ID.", Required: true, Secret: true}, - {Name: "client_secret", Description: "Google OAuth 2.0 client secret.", Required: true, Secret: true}, - {Name: "refresh_token", Description: "OAuth refresh token for the Google Workspace user.", Required: true, Secret: true}, - {Name: "user_id", Description: "Gmail user identifier. Defaults to me.", Required: false}, - {Name: "calendar_id", Description: "Google Calendar ID. Defaults to primary.", Required: false}, - } + return append([]provider.Parameter(nil), providerParameters...) } func (Provider) Tools() []provider.Tool { return []provider.Tool{