diff --git a/README.md b/README.md index a8a0670..4b81da6 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,9 @@ copera boards list copera boards list --query "roadmap" copera boards get copera boards create --name "Q3 Roadmap" +copera boards create --name "CRM" \ + --column '{"label":"Contact","type":"CONTACT"}' \ + --column '{"label":"Follow up","type":"DATE","format":"DATE_ISO"}' copera boards update --name "Q4 Roadmap" copera boards delete --force copera boards participants add --board --participant @@ -192,6 +195,11 @@ for the exact flags each write command accepts. ```bash copera rows list --board --table copera rows list --board --table --query "oauth" +copera rows list --board --table --offset 0 --limit 100 +copera rows query --data @query.json +copera rows aggregate --data @aggregation.json +copera rows bulk --data @operations.json +copera rows views copera rows get --board --table copera rows get --row 42 --board --table # look up by visible Row# copera rows create --board --table --data '{"columns":[{"columnId":"","value":"Hello"}]}' @@ -252,6 +260,7 @@ copera docs update --content "Replacement content" copera docs update --operation append --content "More content" copera docs metadata --title "New title" copera docs delete --force +copera docs generate --data @document.json ``` ### Drive @@ -304,13 +313,37 @@ copera workspace teams copera workspace team create --name "Design" --participant ``` -### Bookings and Meeting Notes +### Tempo, Agenda, Inbox, Automations, and Bookings ```bash +copera tempo calendars list # `time` is an alias +copera tempo calendars create --field name="Focus" +copera tempo shelf create --data '{"title":"Prepare review"}' +copera tempo shelf schedule --data @schedule.json + +copera agenda events list --query-param from=2026-09-01T00:00:00Z +copera agenda events create --data @event.json +copera agenda events respond --field status=ACCEPTED + +copera inbox list +copera inbox threads list --query-param page=1 +copera inbox send --data @message.json +copera inbox attachments upload --file ./contract.pdf +copera inbox delete --data @email-ids.json --force + +copera automations list +copera automations run --data @trigger.json +copera automations retry + copera bookings list copera bookings list --status CONFIRMED --from 2026-01-01T00:00:00Z copera bookings get +copera bookings cancel --field reason="Customer request" --force copera booking-types list +copera booking-types create --data @booking-type.json +copera booking-host profile get +copera booking-host schedules create --data @schedule.json +copera booking-host links create --field maxUses=1 copera meeting-notes list copera meeting-notes list --query "standup" @@ -318,6 +351,21 @@ copera meeting-notes get copera meeting-notes get --transcript ``` +Newer structured commands accept `--data '{...}'`, `--data @file.json`, or +`--data -` for stdin. Repeatable `--field key=value` flags override top-level +JSON fields, and `--query-param key=value` adds URL query parameters. Destructive +commands require interactive confirmation or `--force`. + +Inbox requires a Personal Access Token with `access_inbox`; it is deliberately +available through REST and the CLI, not through hosted MCP. + +### Exports + +```bash +copera exports job +copera exports result-set --data '{"resultSetId":"","format":"CSV"}' +``` + ### Search and Notifications ```bash diff --git a/VERSION b/VERSION index a551051..04a373e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.15.0 +0.16.0 diff --git a/commands/boards.go b/commands/boards.go index fcc4187..5eb0e6f 100644 --- a/commands/boards.go +++ b/commands/boards.go @@ -2,6 +2,7 @@ package commands import ( "context" + "encoding/json" "fmt" "os" "strings" @@ -415,6 +416,8 @@ func parseColumnOptions(raw []string) []api.ColumnOptionInput { func newBoardsCreateCmd(cli *CLI) *cobra.Command { var flagName, flagDescription, flagTableName string + var flagData string + var flagColumns []string cmd := &cobra.Command{ Use: "create", @@ -430,23 +433,48 @@ Example: return err } - if flagName == "" { + payload, err := readObjectInput(cli, flagData, false) + if err != nil { + return usageError(cli, err) + } + if payload == nil { + payload = map[string]any{} + } + if cmd.Flags().Changed("name") { + payload["name"] = flagName + } + if cmd.Flags().Changed("description") { + payload["description"] = flagDescription + } + if cmd.Flags().Changed("table-name") { + payload["tableName"] = flagTableName + } + if len(flagColumns) > 0 { + columns := make([]any, 0, len(flagColumns)) + for _, raw := range flagColumns { + var column map[string]any + if err := json.Unmarshal([]byte(raw), &column); err != nil { + return usageError(cli, fmt.Errorf("invalid --column JSON: %w", err)) + } + columns = append(columns, column) + } + payload["columns"] = columns + } + name, _ := payload["name"].(string) + if name == "" { cli.Printer.PrintError("missing_input", "board name is required", - "Use --name ", false) + "Use --name or provide name in --data", false) return exitcodes.Newf(exitcodes.Usage, "board name is required") } - board, err := client.BoardCreate(context.Background(), &api.CreateBoardInput{ - Name: flagName, - Description: flagDescription, - TableName: flagTableName, - }) + var board api.CreatedBoard + err = client.DoJSON(context.Background(), "POST", "/board", payload, &board) if err != nil { return apiError(cli, err) } if cli.Printer.IsJSON() { - return cli.Printer.PrintJSON(board) + return cli.Printer.PrintJSON(&board) } cli.Printer.PrintLine(fmt.Sprintf("ID: %s", board.ID)) @@ -463,6 +491,8 @@ Example: cmd.Flags().StringVar(&flagName, "name", "", "Board name (required)") cmd.Flags().StringVar(&flagDescription, "description", "", "Board description") cmd.Flags().StringVar(&flagTableName, "table-name", "", "Name for the initial table (default: Table)") + cmd.Flags().StringVar(&flagData, "data", "", "Full board JSON inline, @file, or - for stdin") + cmd.Flags().StringArrayVar(&flagColumns, "column", nil, `Initial column JSON, e.g. {"label":"Due","type":"DATE","format":"DATE_ISO"} (repeatable)`) return cmd } diff --git a/commands/bookings.go b/commands/bookings.go index 15b0606..cfcc364 100644 --- a/commands/bookings.go +++ b/commands/bookings.go @@ -19,6 +19,7 @@ func newBookingsCmd(cli *CLI) *cobra.Command { newBookingsListCmd(cli), newBookingsGetCmd(cli), ) + cmd.AddCommand(bookingLifecycleCommands(cli)...) return cmd } @@ -146,6 +147,7 @@ func newBookingTypesCmd(cli *CLI) *cobra.Command { cmd.AddCommand( newBookingTypesListCmd(cli), ) + cmd.AddCommand(bookingTypeManagementCommands(cli)...) return cmd } diff --git a/commands/docs.go b/commands/docs.go index d89d31d..eb26b7a 100644 --- a/commands/docs.go +++ b/commands/docs.go @@ -25,6 +25,7 @@ func newDocsCmd(cli *CLI) *cobra.Command { newDocsMetadataCmd(cli), newDocsCreateCmd(cli), newDocsDeleteCmd(cli), + newEndpointCmd(cli, endpointSpec{Use: "generate", Short: "Generate a PDF, spreadsheet, CSV, Markdown, or HTML document", Method: "POST", Path: "/documents/generate", Body: true, BodyRequired: true}), ) return cmd } @@ -502,4 +503,3 @@ func readStdinContent(cli *CLI) (string, error) { } return content, nil } - diff --git a/commands/public_api.go b/commands/public_api.go new file mode 100644 index 0000000..802dcde --- /dev/null +++ b/commands/public_api.go @@ -0,0 +1,363 @@ +package commands + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + + "github.com/copera/copera-cli/internal/exitcodes" + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" +) + +type endpointSpec struct { + Use string + Short string + Method string + Path string + Args int + Body bool + BodyRequired bool + Destructive bool +} + +// newEndpointCmd is the common escape hatch for structured public API +// operations. --data accepts inline JSON, @file, or - (stdin); --field values +// override top-level JSON properties and are decoded as JSON scalars when +// possible. --query is repeatable and URL-encodes query parameters. +func newEndpointCmd(cli *CLI, spec endpointSpec) *cobra.Command { + var rawData string + var fields, queries []string + var force bool + cmd := &cobra.Command{ + Use: spec.Use, + Short: spec.Short, + Args: cobra.ExactArgs(spec.Args), + RunE: func(cmd *cobra.Command, args []string) error { + client, _, err := requireAPIClient(cli) + if err != nil { + return err + } + if spec.Destructive { + ok, err := confirmDestructive(cli, force, fmt.Sprintf("Proceed with %s? [y/N]: ", cmd.CommandPath())) + if err != nil || !ok { + return err + } + } + + path := spec.Path + for i, arg := range args { + path = strings.ReplaceAll(path, fmt.Sprintf("{%d}", i), url.PathEscape(arg)) + } + query, err := parsePairs(queries, false) + if err != nil { + return usageError(cli, err) + } + if len(query) > 0 { + values := url.Values{} + for key, value := range query { + values.Set(key, fmt.Sprint(value)) + } + path += "?" + values.Encode() + } + + var body any + if spec.Body { + payload, err := readObjectInput(cli, rawData, spec.BodyRequired && len(fields) == 0) + if err != nil { + return usageError(cli, err) + } + overrides, err := parsePairs(fields, true) + if err != nil { + return usageError(cli, err) + } + if payload == nil { + payload = map[string]any{} + } + for key, value := range overrides { + payload[key] = value + } + body = payload + } + + var result any + if err := client.DoJSON(context.Background(), spec.Method, path, body, &result); err != nil { + return apiError(cli, err) + } + if result == nil { + result = map[string]any{"success": true} + } + return cli.Printer.PrintJSON(result) + }, + } + cmd.Flags().StringVar(&rawData, "data", "", "JSON body inline, @file, or - for stdin") + cmd.Flags().StringArrayVar(&fields, "field", nil, "Set/override a top-level body field (key=value; repeatable)") + cmd.Flags().StringArrayVar(&queries, "query-param", nil, "Add a URL query parameter (key=value; repeatable)") + if !spec.Body { + _ = cmd.Flags().MarkHidden("data") + _ = cmd.Flags().MarkHidden("field") + } + if spec.Destructive { + cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt") + } + return cmd +} + +func usageError(cli *CLI, err error) error { + cli.Printer.PrintError("invalid_input", err.Error(), "Use --data '{...}', --data @file.json, or --data -", false) + return exitcodes.New(exitcodes.Usage, err) +} + +func readObjectInput(cli *CLI, source string, required bool) (map[string]any, error) { + var data []byte + var err error + switch { + case source == "-": + data, err = io.ReadAll(cli.Stdin) + case strings.HasPrefix(source, "@"): + if len(source) == 1 { + return nil, fmt.Errorf("missing file path after @") + } + data, err = os.ReadFile(source[1:]) + case source != "": + data = []byte(source) + case required: + if f, ok := cli.Stdin.(*os.File); ok && (isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())) { + return nil, fmt.Errorf("request body required") + } + data, err = io.ReadAll(cli.Stdin) + default: + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read request body: %w", err) + } + if len(strings.TrimSpace(string(data))) == 0 { + if required { + return nil, fmt.Errorf("request body required") + } + return nil, nil + } + var object map[string]any + if err := json.Unmarshal(data, &object); err != nil { + return nil, fmt.Errorf("invalid JSON object: %w", err) + } + if object == nil { + return nil, fmt.Errorf("request body must be a JSON object") + } + return object, nil +} + +func parsePairs(pairs []string, decodeJSON bool) (map[string]any, error) { + result := make(map[string]any, len(pairs)) + for _, pair := range pairs { + key, raw, ok := strings.Cut(pair, "=") + if !ok || strings.TrimSpace(key) == "" { + return nil, fmt.Errorf("expected key=value, got %q", pair) + } + var value any = raw + if decodeJSON { + var decoded any + if json.Unmarshal([]byte(raw), &decoded) == nil { + value = decoded + } + } + result[strings.TrimSpace(key)] = value + } + return result, nil +} + +func addEndpoints(parent *cobra.Command, cli *CLI, specs ...endpointSpec) { + for _, spec := range specs { + parent.AddCommand(newEndpointCmd(cli, spec)) + } +} + +func newTimeCmd(cli *CLI) *cobra.Command { + cmd := &cobra.Command{Use: "tempo", Aliases: []string{"time"}, Short: "Manage Copera Tempo calendars, shelf items, and activity blocks"} + calendars := &cobra.Command{Use: "calendars", Short: "Manage Tempo calendars"} + addEndpoints(calendars, cli, + endpointSpec{Use: "list", Short: "List calendars", Method: http.MethodGet, Path: "/time/calendars"}, + endpointSpec{Use: "create", Short: "Create a calendar", Method: http.MethodPost, Path: "/time/calendars", Body: true, BodyRequired: true}, + endpointSpec{Use: "update ", Short: "Update a calendar", Method: http.MethodPatch, Path: "/time/calendars/{0}", Args: 1, Body: true, BodyRequired: true}, + endpointSpec{Use: "archive ", Short: "Archive a calendar", Method: http.MethodPost, Path: "/time/calendars/{0}/archive", Args: 1, Destructive: true}, + endpointSpec{Use: "leave ", Short: "Leave a shared calendar", Method: http.MethodPost, Path: "/time/calendars/{0}/leave", Args: 1, Destructive: true}, + ) + shelf := &cobra.Command{Use: "shelf", Short: "Manage unscheduled Tempo items"} + addEndpoints(shelf, cli, + endpointSpec{Use: "list", Short: "List shelf items", Method: http.MethodGet, Path: "/time/shelf-items"}, + endpointSpec{Use: "create", Short: "Create a shelf item", Method: http.MethodPost, Path: "/time/shelf-items", Body: true, BodyRequired: true}, + endpointSpec{Use: "update ", Short: "Update a shelf item", Method: http.MethodPatch, Path: "/time/shelf-items/{0}", Args: 1, Body: true, BodyRequired: true}, + endpointSpec{Use: "delete ", Short: "Delete a shelf item", Method: http.MethodDelete, Path: "/time/shelf-items/{0}", Args: 1, Destructive: true}, + endpointSpec{Use: "schedule ", Short: "Schedule a shelf item", Method: http.MethodPost, Path: "/time/shelf-items/{0}/schedule", Args: 1, Body: true, BodyRequired: true}, + ) + activities := &cobra.Command{Use: "activities", Short: "Inspect Tempo activity blocks"} + addEndpoints(activities, cli, endpointSpec{Use: "list", Short: "List activity blocks", Method: http.MethodGet, Path: "/time/activity-blocks"}) + cmd.AddCommand(calendars, shelf, activities) + return cmd +} + +func newAgendaCmd(cli *CLI) *cobra.Command { + cmd := &cobra.Command{Use: "agenda", Short: "Manage agenda events"} + events := &cobra.Command{Use: "events", Short: "Manage events"} + addEndpoints(events, cli, + endpointSpec{Use: "list", Short: "List events", Method: http.MethodGet, Path: "/agenda/events"}, + endpointSpec{Use: "get ", Short: "Get an event", Method: http.MethodGet, Path: "/agenda/events/{0}", Args: 1}, + endpointSpec{Use: "upcoming ", Short: "List upcoming channel events", Method: http.MethodGet, Path: "/agenda/channels/{0}/upcoming", Args: 1}, + endpointSpec{Use: "create", Short: "Create an event", Method: http.MethodPost, Path: "/agenda/events", Body: true, BodyRequired: true}, + endpointSpec{Use: "update ", Short: "Update an event", Method: http.MethodPatch, Path: "/agenda/events/{0}", Args: 1, Body: true, BodyRequired: true}, + endpointSpec{Use: "delete ", Short: "Delete an event", Method: http.MethodDelete, Path: "/agenda/events/{0}", Args: 1, Destructive: true}, + endpointSpec{Use: "respond ", Short: "Respond to an event", Method: http.MethodPost, Path: "/agenda/events/{0}/respond", Args: 1, Body: true, BodyRequired: true}, + endpointSpec{Use: "ring ", Short: "Set ring-at-start preference", Method: http.MethodPost, Path: "/agenda/events/{0}/ring-preference", Args: 1, Body: true, BodyRequired: true}, + ) + cmd.AddCommand(events) + return cmd +} + +func newInboxCmd(cli *CLI) *cobra.Command { + cmd := &cobra.Command{Use: "inbox", Aliases: []string{"inboxes"}, Short: "Read and manage inbox mail"} + addEndpoints(cmd, cli, + endpointSpec{Use: "list", Short: "List inboxes", Method: http.MethodGet, Path: "/inboxes"}, + endpointSpec{Use: "get ", Short: "Get an inbox", Method: http.MethodGet, Path: "/inboxes/{0}", Args: 1}, + endpointSpec{Use: "folders ", Short: "List inbox folders", Method: http.MethodGet, Path: "/inboxes/{0}/folders", Args: 1}, + endpointSpec{Use: "send", Short: "Send a message", Method: http.MethodPost, Path: "/inboxes/messages", Body: true, BodyRequired: true}, + endpointSpec{Use: "flag ", Short: "Update email flags", Method: http.MethodPost, Path: "/inboxes/{0}/emails/flags", Args: 1, Body: true, BodyRequired: true}, + endpointSpec{Use: "move ", Short: "Move emails between folders", Method: http.MethodPost, Path: "/inboxes/{0}/emails/move", Args: 1, Body: true, BodyRequired: true}, + endpointSpec{Use: "delete ", Short: "Permanently delete emails", Method: http.MethodDelete, Path: "/inboxes/{0}/emails", Args: 1, Body: true, BodyRequired: true, Destructive: true}, + endpointSpec{Use: "purge-junk ", Short: "Permanently purge junk", Method: http.MethodPost, Path: "/inboxes/{0}/junk/purge", Args: 1, Body: true, Destructive: true}, + endpointSpec{Use: "rsvp ", Short: "Respond to a calendar invitation", Method: http.MethodPost, Path: "/inboxes/{0}/rsvp", Args: 1, Body: true, BodyRequired: true}, + ) + threads := &cobra.Command{Use: "threads", Short: "Read inbox threads"} + addEndpoints(threads, cli, + endpointSpec{Use: "list ", Short: "List threads", Method: http.MethodGet, Path: "/inboxes/{0}/threads", Args: 1}, + endpointSpec{Use: "get ", Short: "Get a thread", Method: http.MethodGet, Path: "/inboxes/{0}/threads/{1}", Args: 2}, + ) + drafts := &cobra.Command{Use: "drafts", Short: "Manage drafts"} + addEndpoints(drafts, cli, + endpointSpec{Use: "create", Short: "Create a draft", Method: http.MethodPost, Path: "/inboxes/drafts", Body: true, BodyRequired: true}, + endpointSpec{Use: "update ", Short: "Update a draft", Method: http.MethodPatch, Path: "/inboxes/drafts/{0}", Args: 1, Body: true, BodyRequired: true}, + endpointSpec{Use: "send ", Short: "Send a draft", Method: http.MethodPost, Path: "/inboxes/{0}/drafts/{1}/send", Args: 2}, + ) + attachments := &cobra.Command{Use: "attachments", Short: "Upload message attachments"} + attachments.AddCommand(newInboxAttachmentUploadCmd(cli)) + cmd.AddCommand(threads, drafts, attachments) + return cmd +} + +func newInboxAttachmentUploadCmd(cli *CLI) *cobra.Command { + var file string + cmd := &cobra.Command{Use: "upload ", Short: "Upload an attachment (maximum 25 MiB)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + info, err := os.Stat(file) + if err != nil { + return usageError(cli, fmt.Errorf("inspect attachment: %w", err)) + } + if info.Size() > 25*1024*1024 { + return usageError(cli, fmt.Errorf("attachment exceeds 25 MiB")) + } + client, _, err := requireAPIClient(cli) + if err != nil { + return err + } + var result any + if err := client.UploadFile(context.Background(), "/inboxes/"+url.PathEscape(args[0])+"/attachments", file, &result); err != nil { + return apiError(cli, err) + } + return cli.Printer.PrintJSON(result) + }} + cmd.Flags().StringVar(&file, "file", "", "File to upload (required)") + _ = cmd.MarkFlagRequired("file") + return cmd +} + +func newAutomationsCmd(cli *CLI) *cobra.Command { + cmd := &cobra.Command{Use: "automations", Short: "Inspect and operate automations"} + addEndpoints(cmd, cli, + endpointSpec{Use: "list", Short: "List automations", Method: http.MethodGet, Path: "/automations/"}, + endpointSpec{Use: "get ", Short: "Get an automation", Method: http.MethodGet, Path: "/automations/{0}", Args: 1}, + endpointSpec{Use: "runs ", Short: "List automation runs", Method: http.MethodGet, Path: "/automations/{0}/runs", Args: 1}, + endpointSpec{Use: "run-get ", Short: "Get an automation run", Method: http.MethodGet, Path: "/automations/runs/{0}", Args: 1}, + endpointSpec{Use: "run ", Short: "Run an automation", Method: http.MethodPost, Path: "/automations/{0}/run", Args: 1, Body: true}, + endpointSpec{Use: "retry ", Short: "Retry a failed run", Method: http.MethodPost, Path: "/automations/runs/{0}/retry", Args: 1}, + endpointSpec{Use: "enable ", Short: "Enable an automation", Method: http.MethodPost, Path: "/automations/{0}/enable", Args: 1}, + endpointSpec{Use: "disable ", Short: "Disable an automation", Method: http.MethodPost, Path: "/automations/{0}/disable", Args: 1, Destructive: true}, + ) + return cmd +} + +func newExportsCmd(cli *CLI) *cobra.Command { + cmd := &cobra.Command{Use: "exports", Short: "Inspect and create asynchronous exports"} + addEndpoints(cmd, cli, + endpointSpec{Use: "job ", Short: "Get an export job", Method: http.MethodGet, Path: "/export/job/{0}", Args: 1}, + endpointSpec{Use: "result-set", Short: "Export a materialized row result set", Method: http.MethodPost, Path: "/export/row-set", Body: true, BodyRequired: true}, + ) + return cmd +} + +func bookingLifecycleCommands(cli *CLI) []*cobra.Command { + return []*cobra.Command{ + newEndpointCmd(cli, endpointSpec{Use: "confirm ", Short: "Confirm a booking", Method: http.MethodPost, Path: "/bookings/{0}/confirm", Args: 1, Body: true}), + newEndpointCmd(cli, endpointSpec{Use: "decline ", Short: "Decline a booking", Method: http.MethodPost, Path: "/bookings/{0}/decline", Args: 1, Body: true, Destructive: true}), + newEndpointCmd(cli, endpointSpec{Use: "cancel ", Short: "Cancel a booking", Method: http.MethodPost, Path: "/bookings/{0}/cancel", Args: 1, Body: true, Destructive: true}), + newEndpointCmd(cli, endpointSpec{Use: "reschedule ", Short: "Reschedule a booking", Method: http.MethodPost, Path: "/bookings/{0}/reschedule", Args: 1, Body: true}), + newEndpointCmd(cli, endpointSpec{Use: "no-show ", Short: "Mark a booking as no-show", Method: http.MethodPost, Path: "/bookings/{0}/no-show", Args: 1, Body: true, Destructive: true}), + newEndpointCmd(cli, endpointSpec{Use: "reassign ", Short: "Reassign a booking", Method: http.MethodPost, Path: "/bookings/{0}/reassign", Args: 1, Body: true, BodyRequired: true}), + } +} + +func bookingTypeManagementCommands(cli *CLI) []*cobra.Command { + return []*cobra.Command{ + newEndpointCmd(cli, endpointSpec{Use: "host", Short: "List booking types managed by the current host", Method: http.MethodGet, Path: "/booking-types/host"}), + newEndpointCmd(cli, endpointSpec{Use: "get ", Short: "Get a booking type", Method: http.MethodGet, Path: "/booking-types/{0}", Args: 1}), + newEndpointCmd(cli, endpointSpec{Use: "create", Short: "Create a booking type", Method: http.MethodPost, Path: "/booking-types/", Body: true, BodyRequired: true}), + newEndpointCmd(cli, endpointSpec{Use: "update ", Short: "Update a booking type", Method: http.MethodPatch, Path: "/booking-types/{0}", Args: 1, Body: true, BodyRequired: true}), + newEndpointCmd(cli, endpointSpec{Use: "delete ", Short: "Delete a booking type", Method: http.MethodDelete, Path: "/booking-types/{0}", Args: 1, Destructive: true}), + newEndpointCmd(cli, endpointSpec{Use: "duplicate ", Short: "Duplicate a booking type", Method: http.MethodPost, Path: "/booking-types/{0}/duplicate", Args: 1}), + newEndpointCmd(cli, endpointSpec{Use: "reorder", Short: "Reorder booking types", Method: http.MethodPost, Path: "/booking-types/reorder", Body: true, BodyRequired: true}), + newEndpointCmd(cli, endpointSpec{Use: "set-flag ", Short: "Set hidden or active on a booking type", Method: http.MethodPatch, Path: "/booking-types/{0}/flags/{1}", Args: 2, Body: true, BodyRequired: true}), + } +} + +func newBookingHostCmd(cli *CLI) *cobra.Command { + cmd := &cobra.Command{Use: "booking-host", Short: "Manage booking host profiles, schedules, and private links"} + profile := &cobra.Command{Use: "profile", Short: "Manage the current host profile"} + addEndpoints(profile, cli, + endpointSpec{Use: "get", Short: "Get the host profile", Method: http.MethodGet, Path: "/booking-host/profile"}, + endpointSpec{Use: "set-handle", Short: "Set the host booking handle", Method: http.MethodPatch, Path: "/booking-host/profile/handle", Body: true, BodyRequired: true}, + ) + teams := &cobra.Command{Use: "teams", Short: "Manage team booking profiles"} + addEndpoints(teams, cli, + endpointSpec{Use: "list", Short: "List team booking profiles", Method: http.MethodGet, Path: "/booking-host/teams"}, + endpointSpec{Use: "set-slug ", Short: "Set a team booking slug", Method: http.MethodPatch, Path: "/booking-host/teams/{0}/slug", Args: 1, Body: true, BodyRequired: true}, + ) + schedules := &cobra.Command{Use: "schedules", Short: "Manage host availability schedules"} + addEndpoints(schedules, cli, + endpointSpec{Use: "list", Short: "List schedules", Method: http.MethodGet, Path: "/booking-host/schedules"}, + endpointSpec{Use: "get ", Short: "Get a schedule", Method: http.MethodGet, Path: "/booking-host/schedules/{0}", Args: 1}, + endpointSpec{Use: "create", Short: "Create a schedule", Method: http.MethodPost, Path: "/booking-host/schedules", Body: true, BodyRequired: true}, + endpointSpec{Use: "update ", Short: "Update a schedule", Method: http.MethodPatch, Path: "/booking-host/schedules/{0}", Args: 1, Body: true, BodyRequired: true}, + endpointSpec{Use: "delete ", Short: "Delete a schedule", Method: http.MethodDelete, Path: "/booking-host/schedules/{0}", Args: 1, Destructive: true}, + endpointSpec{Use: "set-default ", Short: "Set the default schedule", Method: http.MethodPost, Path: "/booking-host/schedules/{0}/default", Args: 1}, + ) + links := &cobra.Command{Use: "links", Short: "Manage private booking links"} + addEndpoints(links, cli, + endpointSpec{Use: "list ", Short: "List private links", Method: http.MethodGet, Path: "/booking-types/{0}/links", Args: 1}, + endpointSpec{Use: "create ", Short: "Create a private link", Method: http.MethodPost, Path: "/booking-types/{0}/links", Args: 1, Body: true}, + endpointSpec{Use: "revoke ", Short: "Revoke a private link", Method: http.MethodDelete, Path: "/booking-types/links/{0}", Args: 1, Destructive: true}, + ) + cmd.AddCommand(profile, teams, schedules, links) + return cmd +} + +func rowParityCommands(cli *CLI) []*cobra.Command { + return []*cobra.Command{ + newEndpointCmd(cli, endpointSpec{Use: "query", Short: "Query rows across tables", Method: http.MethodPost, Path: "/rows/query", Body: true, BodyRequired: true}), + newEndpointCmd(cli, endpointSpec{Use: "aggregate ", Short: "Aggregate table rows", Method: http.MethodPost, Path: "/board/{0}/table/{1}/rows/aggregate", Args: 2, Body: true, BodyRequired: true}), + newEndpointCmd(cli, endpointSpec{Use: "bulk ", Short: "Create, update, or delete rows in bulk", Method: http.MethodPost, Path: "/board/{0}/table/{1}/rows/bulk", Args: 2, Body: true, BodyRequired: true}), + newEndpointCmd(cli, endpointSpec{Use: "views ", Short: "List table views", Method: http.MethodGet, Path: "/board/{0}/table/{1}/views", Args: 2}), + } +} diff --git a/commands/public_api_test.go b/commands/public_api_test.go new file mode 100644 index 0000000..d032c81 --- /dev/null +++ b/commands/public_api_test.go @@ -0,0 +1,126 @@ +package commands_test + +import ( + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/copera/copera-cli/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTempoCreateMergesDataAndFields(t *testing.T) { + var body map[string]any + srv := testutil.NewMockServer(t, testutil.MockRoutes{ + "POST /time/calendars": func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + testutil.RespondJSON(w, http.StatusOK, map[string]any{"id": "calendar1"}) + }, + }.Handler()) + setupHome(t, srv.URL) + + res := testutil.RunCommand(t, []string{ + "time", "calendars", "create", "--data", `{"name":"Old","timezone":"UTC"}`, + "--field", "name=Focus", "--field", "roles={\"owner\":true}", "--json", + }, "") + require.Equal(t, 0, res.ExitCode, res.Stderr) + assert.Equal(t, "Focus", body["name"]) + assert.Equal(t, "UTC", body["timezone"]) + assert.Equal(t, map[string]any{"owner": true}, body["roles"]) +} + +func TestStructuredCommandReadsDataFromFile(t *testing.T) { + var body map[string]any + srv := testutil.NewMockServer(t, testutil.MockRoutes{ + "POST /time/shelf-items": func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + testutil.RespondJSON(w, http.StatusOK, map[string]any{"id": "item1"}) + }, + }.Handler()) + setupHome(t, srv.URL) + path := filepath.Join(t.TempDir(), "item.json") + require.NoError(t, os.WriteFile(path, []byte(`{"title":"From file","color":"blue"}`), 0o600)) + + res := testutil.RunCommand(t, []string{ + "tempo", "shelf", "create", "--data", "@" + path, + "--field", "title=Overridden", "--json", + }, "") + require.Equal(t, 0, res.ExitCode, res.Stderr) + assert.Equal(t, "Overridden", body["title"]) + assert.Equal(t, "blue", body["color"]) +} + +func TestAgendaCreateReadsBodyFromStdin(t *testing.T) { + srv := testutil.NewMockServer(t, testutil.MockRoutes{ + "POST /agenda/events": func(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"title":"Planning","startDate":"2026-09-01T10:00:00Z","participants":[]}`, string(data)) + testutil.RespondJSON(w, http.StatusCreated, map[string]any{"id": "event1"}) + }, + }.Handler()) + setupHome(t, srv.URL) + + res := testutil.RunCommand(t, []string{"agenda", "events", "create", "--data", "-", "--json"}, + `{"title":"Planning","startDate":"2026-09-01T10:00:00Z","participants":[]}`) + require.Equal(t, 0, res.ExitCode, res.Stderr) +} + +func TestDestructiveEndpointRequiresForceWhenNonInteractive(t *testing.T) { + called := false + srv := testutil.NewMockServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + testutil.RespondJSON(w, http.StatusOK, map[string]any{"success": true}) + })) + setupHome(t, srv.URL) + + res := testutil.RunCommand(t, []string{"agenda", "events", "delete", "event1", "--no-input", "--json"}, "") + assert.Equal(t, 2, res.ExitCode) + assert.Contains(t, res.Stderr, "confirmation_required") + assert.False(t, called) +} + +func TestBookingCancelWithForceCallsLifecycleEndpoint(t *testing.T) { + srv := testutil.NewMockServer(t, testutil.MockRoutes{ + "POST /bookings/booking1/cancel": func(w http.ResponseWriter, r *http.Request) { + testutil.RespondJSON(w, http.StatusOK, map[string]any{"status": "CANCELLED"}) + }, + }.Handler()) + setupHome(t, srv.URL) + + res := testutil.RunCommand(t, []string{"bookings", "cancel", "booking1", "--force", "--json"}, "") + require.Equal(t, 0, res.ExitCode, res.Stderr) + assert.Contains(t, res.Stdout, "CANCELLED") +} + +func TestInboxAttachmentRejectsFilesOver25MiB(t *testing.T) { + path := filepath.Join(t.TempDir(), "large.bin") + require.NoError(t, os.WriteFile(path, nil, 0o600)) + require.NoError(t, os.Truncate(path, 25*1024*1024+1)) + setupHome(t, "http://127.0.0.1:1") + + res := testutil.RunCommand(t, []string{"inbox", "attachments", "upload", "inbox1", "--file", path, "--json"}, "") + assert.Equal(t, 2, res.ExitCode) + assert.Contains(t, res.Stderr, "exceeds 25 MiB") +} + +func TestRowsListPaginationIsOptIn(t *testing.T) { + srv := testutil.NewMockServer(t, testutil.MockRoutes{ + "GET /board/b1/table/t1/rows": func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "5", r.URL.Query().Get("offset")) + assert.Equal(t, "10", r.URL.Query().Get("limit")) + testutil.RespondJSON(w, http.StatusOK, map[string]any{ + "rows": []any{}, "total": 12, "offset": 5, "limit": 10, "hasMore": false, + }) + }, + }.Handler()) + setupHome(t, srv.URL) + + res := testutil.RunCommand(t, []string{"rows", "list", "--board", "b1", "--table", "t1", "--offset", "5", "--limit", "10", "--json"}, "") + require.Equal(t, 0, res.ExitCode, res.Stderr) + assert.Contains(t, res.Stdout, `"total": 12`) +} diff --git a/commands/root.go b/commands/root.go index 0bbada4..7861837 100644 --- a/commands/root.go +++ b/commands/root.go @@ -102,8 +102,14 @@ Documentation: https://developers.copera.ai/`, newChannelsCmd(cli), newDriveCmd(cli), newWorkspaceCmd(cli), + newTimeCmd(cli), + newAgendaCmd(cli), + newInboxCmd(cli), + newAutomationsCmd(cli), newBookingsCmd(cli), newBookingTypesCmd(cli), + newBookingHostCmd(cli), + newExportsCmd(cli), newMeetingNotesCmd(cli), newSearchCmd(cli), newNotificationsCmd(cli), diff --git a/commands/rows.go b/commands/rows.go index a7bc28e..b6aa969 100644 --- a/commands/rows.go +++ b/commands/rows.go @@ -47,6 +47,7 @@ func newRowsCmd(cli *CLI) *cobra.Command { newRowsCommentCmd(cli), newRowsCommentsCmd(cli), ) + cmd.AddCommand(rowParityCommands(cli)...) return cmd } @@ -54,6 +55,7 @@ func newRowsCmd(cli *CLI) *cobra.Command { func newRowsListCmd(cli *CLI) *cobra.Command { var flagBoard, flagTable, flagQuery, flagFilter, flagSort string + var flagOffset, flagLimit int cmd := &cobra.Command{ Use: "list", @@ -117,13 +119,31 @@ Examples: } opts := &api.RowListOptions{Query: flagQuery, Filter: filterJSON, Sort: flagSort} - - rows, err := client.RowList(context.Background(), boardID, tableID, opts) + paginated := cmd.Flags().Changed("offset") || cmd.Flags().Changed("limit") + if cmd.Flags().Changed("offset") { + opts.Offset = &flagOffset + } + if cmd.Flags().Changed("limit") { + opts.Limit = &flagLimit + } + var rows []api.Row + var page *api.RowsPage + if paginated { + page, err = client.RowListPage(context.Background(), boardID, tableID, opts) + if err == nil { + rows = page.Rows + } + } else { + rows, err = client.RowList(context.Background(), boardID, tableID, opts) + } if err != nil { return apiError(cli, err) } if cli.Printer.IsJSON() { + if paginated { + return cli.Printer.PrintJSON(page) + } return cli.Printer.PrintJSON(rows) } @@ -146,6 +166,8 @@ Examples: cmd.Flags().StringVar(&flagQuery, "query", "", "Search visible row columns") cmd.Flags().StringVar(&flagFilter, "filter", "", "Filter JSON (inline or @file)") cmd.Flags().StringVar(&flagSort, "sort", "", "Sort spec, e.g. col_a:asc,col_b:desc") + cmd.Flags().IntVar(&flagOffset, "offset", 0, "Skip matching rows and opt into paginated output") + cmd.Flags().IntVar(&flagLimit, "limit", 0, "Page size (1-1000) and opt into paginated output") return cmd } diff --git a/internal/api/boards.go b/internal/api/boards.go index e4c4659..8fd6032 100644 --- a/internal/api/boards.go +++ b/internal/api/boards.go @@ -219,6 +219,18 @@ type RowListOptions struct { Query string Filter string Sort string + Offset *int + Limit *int +} + +// RowsPage is returned only when offset or limit is explicitly provided. +// Omitting both retains the public API's legacy bare-array response. +type RowsPage struct { + Rows []Row `json:"rows"` + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + HasMore bool `json:"hasMore"` } func (c *Client) RowList(ctx context.Context, boardID, tableID string, opts *RowListOptions) ([]Row, error) { @@ -234,6 +246,12 @@ func (c *Client) RowList(ctx context.Context, boardID, tableID string, opts *Row if opts.Sort != "" { q.Set("sort", opts.Sort) } + if opts.Offset != nil { + q.Set("offset", fmt.Sprintf("%d", *opts.Offset)) + } + if opts.Limit != nil { + q.Set("limit", fmt.Sprintf("%d", *opts.Limit)) + } if encoded := q.Encode(); encoded != "" { path = path + "?" + encoded } @@ -246,6 +264,37 @@ func (c *Client) RowList(ctx context.Context, boardID, tableID string, opts *Row return rows, nil } +// RowListPage opts into the paginated envelope response. At least one of +// Offset or Limit must be non-nil. +func (c *Client) RowListPage(ctx context.Context, boardID, tableID string, opts *RowListOptions) (*RowsPage, error) { + if opts == nil || (opts.Offset == nil && opts.Limit == nil) { + return nil, fmt.Errorf("row pagination requires offset or limit") + } + path := fmt.Sprintf("/board/%s/table/%s/rows", boardID, tableID) + q := url.Values{} + if opts.Query != "" { + q.Set("q", opts.Query) + } + if opts.Filter != "" { + q.Set("filter", opts.Filter) + } + if opts.Sort != "" { + q.Set("sort", opts.Sort) + } + if opts.Offset != nil { + q.Set("offset", fmt.Sprintf("%d", *opts.Offset)) + } + if opts.Limit != nil { + q.Set("limit", fmt.Sprintf("%d", *opts.Limit)) + } + path += "?" + q.Encode() + var page RowsPage + if err := c.do(ctx, http.MethodGet, path, nil, &page); err != nil { + return nil, err + } + return &page, nil +} + func (c *Client) RowGet(ctx context.Context, boardID, tableID, rowID string) (*Row, error) { var r Row path := fmt.Sprintf("/board/%s/table/%s/row/%s", boardID, tableID, rowID) @@ -383,9 +432,24 @@ func (c *Client) CommentAttachmentDownload(ctx context.Context, boardID, tableID // CreateBoardInput is the body for POST /board. type CreateBoardInput struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - TableName string `json:"tableName,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + TableName string `json:"tableName,omitempty"` + Columns []BoardBlueprintColumn `json:"columns,omitempty"` +} + +// BoardBlueprintColumn describes a custom column created with a board's +// initial table. The API validates which formats/options apply to each type. +type BoardBlueprintColumn struct { + Label string `json:"label"` + Type string `json:"type"` + Format string `json:"format,omitempty"` + Options []BoardColumnOption `json:"options,omitempty"` +} + +type BoardColumnOption struct { + Label string `json:"label"` + Color string `json:"color,omitempty"` } // CreatedBoard is the response for POST /board. @@ -394,6 +458,7 @@ type CreatedBoard struct { Name string `json:"name"` Description string `json:"description,omitempty"` DefaultTableID string `json:"defaultTableId,omitempty"` + Columns any `json:"columns,omitempty"` } // UpdateBoardInput is the body for PATCH /board/{boardId}. Fields are optional. diff --git a/internal/api/client.go b/internal/api/client.go index 373ac5f..c046604 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -7,7 +7,10 @@ import ( "encoding/json" "fmt" "io" + "mime/multipart" "net/http" + "os" + "path/filepath" "strings" "time" @@ -85,6 +88,80 @@ func (c *Client) HTTPClient() *http.Client { return c.httpClient } +// DoJSON executes an authenticated JSON request against a public API path. +// It is intended for newer API resources whose response shapes are deliberately +// open-ended while the server contracts are still evolving. +func (c *Client) DoJSON(ctx context.Context, method, path string, body, out any) error { + return c.do(ctx, method, path, body, out) +} + +// UploadFile executes an authenticated multipart upload using the form field +// name "file". The caller is responsible for enforcing resource-specific size +// limits before calling this method. +func (c *Client) UploadFile(ctx context.Context, path, filePath string, out any) error { + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("api: open upload: %w", err) + } + defer file.Close() + + var payload bytes.Buffer + writer := multipart.NewWriter(&payload) + part, err := writer.CreateFormFile("file", filepath.Base(filePath)) + if err != nil { + return fmt.Errorf("api: create multipart field: %w", err) + } + if _, err := io.Copy(part, file); err != nil { + return fmt.Errorf("api: read upload: %w", err) + } + if err := writer.Close(); err != nil { + return fmt.Errorf("api: close multipart body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, &payload) + if err != nil { + return fmt.Errorf("api: build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("api: %w", err) + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("api: read response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + apiErr := &APIError{StatusCode: resp.StatusCode} + var errBody struct { + Code string `json:"code"` + Error string `json:"error"` + Message string `json:"message"` + } + if json.Unmarshal(respBody, &errBody) == nil { + apiErr.Code = errBody.Code + apiErr.Message = errBody.Error + if apiErr.Message == "" { + apiErr.Message = errBody.Message + } + } + if apiErr.Message == "" { + apiErr.Message = http.StatusText(resp.StatusCode) + } + return apiErr + } + if out != nil && len(respBody) > 0 { + if err := json.Unmarshal(respBody, out); err != nil { + return fmt.Errorf("api: decode response: %w", err) + } + } + return nil +} + // Download executes an authenticated GET request for a binary response. // Unlike do(), successful responses leave the body open for the caller to // stream. Non-2xx responses are decoded into APIError using the JSON error