Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions catalog/tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
3 changes: 2 additions & 1 deletion internal/app/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
79 changes: 73 additions & 6 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand All @@ -201,7 +218,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
Expand Down Expand Up @@ -390,3 +407,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), &params); 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)
}
8 changes: 7 additions & 1 deletion providers/example/echo/e2e_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package echo_test

import (
"os"
"os/exec"
"path/filepath"
"runtime"
Expand Down Expand Up @@ -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)
Expand All @@ -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()
}
16 changes: 16 additions & 0 deletions providers/google-workspace/calendar-check/cli-metadata.yaml
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions providers/google-workspace/calendar-check/e2e_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions providers/google-workspace/calendar-check/generator-prompt.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions providers/google-workspace/calendar-check/input-schema.yaml
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions providers/google-workspace/calendar-check/metadata_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

72 changes: 72 additions & 0 deletions providers/google-workspace/calendar-check/mod.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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 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) Aliases() []string { return append([]string(nil), toolAliases...) }
func (Tool) InputSchema() schema.JSONSchema {
return toolInputSchema
}
func (Tool) OutputSchema() schema.JSONSchema {
return toolOutputSchema
}

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