diff --git a/pkg/cmd/agentstudio/agentstudio.go b/pkg/cmd/agentstudio/agentstudio.go index f3e6617a..8d2fd421 100644 --- a/pkg/cmd/agentstudio/agentstudio.go +++ b/pkg/cmd/agentstudio/agentstudio.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/algolia/cli/pkg/cmd/agents" + "github.com/algolia/cli/pkg/cmd/conversations" "github.com/algolia/cli/pkg/cmdutil" ) @@ -16,5 +17,6 @@ func NewAgentStudioCmd(f *cmdutil.Factory) *cobra.Command { } cmd.AddCommand(agents.NewAgentsCmd(f)) + cmd.AddCommand(conversations.NewConversationsCmd(f)) return cmd } diff --git a/pkg/cmd/conversations/conversations.go b/pkg/cmd/conversations/conversations.go new file mode 100644 index 00000000..bfdd2312 --- /dev/null +++ b/pkg/cmd/conversations/conversations.go @@ -0,0 +1,28 @@ +package conversations + +import ( + "github.com/spf13/cobra" + + "github.com/algolia/cli/pkg/cmd/conversations/delete" + "github.com/algolia/cli/pkg/cmd/conversations/export" + "github.com/algolia/cli/pkg/cmd/conversations/get" + "github.com/algolia/cli/pkg/cmd/conversations/list" + "github.com/algolia/cli/pkg/cmd/conversations/purge" + "github.com/algolia/cli/pkg/cmdutil" +) + +// NewConversationsCmd returns the conversations command group. +func NewConversationsCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "conversations", + Short: "Manage Agent Studio conversations", + Long: "List, get, export, delete, and purge Agent Studio conversations.", + } + + cmd.AddCommand(list.NewListCmd(f)) + cmd.AddCommand(get.NewGetCmd(f)) + cmd.AddCommand(export.NewExportCmd(f)) + cmd.AddCommand(delete.NewDeleteCmd(f)) + cmd.AddCommand(purge.NewPurgeCmd(f)) + return cmd +} diff --git a/pkg/cmd/conversations/delete/delete.go b/pkg/cmd/conversations/delete/delete.go new file mode 100644 index 00000000..a1ec15a0 --- /dev/null +++ b/pkg/cmd/conversations/delete/delete.go @@ -0,0 +1,112 @@ +package delete + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc" + agentStudio "github.com/algolia/algoliasearch-client-go/v4/algolia/agent-studio" + "github.com/spf13/cobra" + + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/pkg/prompt" + "github.com/algolia/cli/pkg/validators" +) + +// DeleteOptions holds the dependencies and flags for the delete command. +type DeleteOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + AgentStudioClient func() (*agentStudio.APIClient, error) + AgentID string + ConversationID string + DoConfirm bool +} + +// NewDeleteCmd returns the `conversations delete` command. +func NewDeleteCmd(f *cmdutil.Factory) *cobra.Command { + opts := &DeleteOptions{ + IO: f.IOStreams, + Config: f.Config, + AgentStudioClient: f.AgentStudioClient, + } + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an Agent Studio conversation", + Args: validators.ExactArgsWithMsg( + 2, + "agentstudio conversations delete requires and arguments.", + ), + Annotations: map[string]string{ + "acls": "logs", + }, + Example: heredoc.Doc(` + # Delete a conversation (with confirmation prompt) + $ algolia agentstudio conversations delete 11111111-1111-4111-8111-111111111111 33333333-3333-4333-8333-333333333333 + + # Delete without confirmation + $ algolia agentstudio conversations delete 11111111-1111-4111-8111-111111111111 33333333-3333-4333-8333-333333333333 --confirm + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.AgentID = args[0] + opts.ConversationID = args[1] + + if !opts.DoConfirm { + if !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf( + "--confirm required when non-interactive shell is detected", + ) + } + var confirmed bool + err := prompt.Confirm( + fmt.Sprintf("Delete conversation %q?", opts.ConversationID), + &confirmed, + ) + if err != nil { + return fmt.Errorf("failed to prompt: %w", err) + } + if !confirmed { + return nil + } + } + + return runDeleteCmd(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.DoConfirm, "confirm", "y", false, "Skip confirmation prompt") + return cmd +} + +func runDeleteCmd(opts *DeleteOptions) error { + client, err := opts.AgentStudioClient() + if err != nil { + return err + } + + opts.IO.StartProgressIndicatorWithLabel("Deleting conversation") + + err = client.DeleteConversation( + client.NewApiDeleteConversationRequest(opts.ConversationID, opts.AgentID), + ) + if err != nil { + opts.IO.StopProgressIndicator() + return err + } + + opts.IO.StopProgressIndicator() + + if opts.IO.IsStdoutTTY() { + cs := opts.IO.ColorScheme() + fmt.Fprintf( + opts.IO.Out, + "%s Deleted conversation %s\n", + cs.SuccessIcon(), + opts.ConversationID, + ) + } + + return nil +} diff --git a/pkg/cmd/conversations/delete/delete_test.go b/pkg/cmd/conversations/delete/delete_test.go new file mode 100644 index 00000000..5afe7f84 --- /dev/null +++ b/pkg/cmd/conversations/delete/delete_test.go @@ -0,0 +1,53 @@ +package delete_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/pkg/cmd/conversations/delete" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/test" +) + +const ( + agentID = "11111111-1111-4111-8111-111111111111" + convID = "33333333-3333-4333-8333-333333333333" + deletePath = "agent-studio/1/agents/" + agentID + "/conversations/" + convID +) + +func TestDeleteConversation(t *testing.T) { + r := &httpmock.Registry{} + r.Register(httpmock.REST("DELETE", deletePath), httpmock.StringResponse("")) + + f, out := test.NewFactory(false, r, nil, "") + cmd := delete.NewDeleteCmd(f) + _, err := test.Execute(cmd, agentID+" "+convID+" --confirm", out) + require.NoError(t, err) + assert.Empty(t, out.String()) + r.Verify(t) +} + +func TestDeleteConversation_RequiresConfirmation(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := delete.NewDeleteCmd(f) + _, err := test.Execute(cmd, agentID+" "+convID, out) + require.Error(t, err) + assert.Contains(t, err.Error(), "--confirm required") + assert.Empty(t, r.Requests) +} + +func TestDeleteConversation_MissingArgs(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := delete.NewDeleteCmd(f) + _, err := test.Execute(cmd, agentID+" --confirm", out) + require.Error(t, err) + assert.Contains( + t, + err.Error(), + "agentstudio conversations delete requires and ", + ) +} diff --git a/pkg/cmd/conversations/export/export.go b/pkg/cmd/conversations/export/export.go new file mode 100644 index 00000000..0632a9a0 --- /dev/null +++ b/pkg/cmd/conversations/export/export.go @@ -0,0 +1,95 @@ +package export + +import ( + "github.com/MakeNowJust/heredoc" + agentStudio "github.com/algolia/algoliasearch-client-go/v4/algolia/agent-studio" + "github.com/spf13/cobra" + + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/pkg/validators" +) + +// ExportOptions holds the dependencies and flags for the export command. +type ExportOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + AgentStudioClient func() (*agentStudio.APIClient, error) + PrintFlags *cmdutil.PrintFlags + + AgentID string + StartDate string + EndDate string +} + +// NewExportCmd returns the `conversations export` command. +func NewExportCmd(f *cmdutil.Factory) *cobra.Command { + opts := &ExportOptions{ + IO: f.IOStreams, + Config: f.Config, + AgentStudioClient: f.AgentStudioClient, + PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), + } + + cmd := &cobra.Command{ + Use: "export ", + Short: "Export Agent Studio conversations for an agent", + Args: validators.ExactArgsWithMsg( + 1, + "agentstudio conversations export requires a argument.", + ), + Annotations: map[string]string{ + "acls": "logs", + }, + Example: heredoc.Doc(` + # Export all conversations for an agent + $ algolia agentstudio conversations export 11111111-1111-4111-8111-111111111111 + + # Export a date range + $ algolia agentstudio conversations export 11111111-1111-4111-8111-111111111111 --start-date 2026-01-01 --end-date 2026-01-31 + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.AgentID = args[0] + return runExportCmd(opts) + }, + } + + cmd.Flags().StringVar(&opts.StartDate, "start-date", "", "Created after this date (YYYY-MM-DD)") + cmd.Flags().StringVar(&opts.EndDate, "end-date", "", "Created before this date (YYYY-MM-DD)") + + opts.PrintFlags.AddFlags(cmd) + return cmd +} + +func runExportCmd(opts *ExportOptions) error { + client, err := opts.AgentStudioClient() + if err != nil { + return err + } + + p, err := opts.PrintFlags.ToPrinter() + if err != nil { + return err + } + + req := client.NewApiExportConversationsRequest(opts.AgentID) + if opts.StartDate != "" { + req = req.WithStartDate(opts.StartDate) + } + if opts.EndDate != "" { + req = req.WithEndDate(opts.EndDate) + } + + opts.IO.StartProgressIndicatorWithLabel("Exporting conversations") + + res, err := client.ExportConversations(req) + if err != nil { + opts.IO.StopProgressIndicator() + return err + } + + opts.IO.StopProgressIndicator() + + return p.Print(opts.IO, res) +} diff --git a/pkg/cmd/conversations/export/export_test.go b/pkg/cmd/conversations/export/export_test.go new file mode 100644 index 00000000..6e4d280a --- /dev/null +++ b/pkg/cmd/conversations/export/export_test.go @@ -0,0 +1,56 @@ +package export_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/pkg/cmd/conversations/export" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/test" +) + +const ( + agentID = "11111111-1111-4111-8111-111111111111" + exportPath = "agent-studio/1/agents/" + agentID + "/conversations/export" + exportBody = `[{ + "id": "33333333-3333-4333-8333-333333333333", + "agentId": "11111111-1111-4111-8111-111111111111", + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "messages": [] +}]` +) + +func TestExportConversations(t *testing.T) { + r := &httpmock.Registry{} + r.Register(httpmock.REST("GET", exportPath), httpmock.StringResponse(exportBody)) + + f, out := test.NewFactory(false, r, nil, "") + cmd := export.NewExportCmd(f) + _, err := test.Execute(cmd, agentID+" --start-date 2026-01-01 --end-date 2026-01-31", out) + require.NoError(t, err) + + assert.JSONEq(t, exportBody, strings.TrimSpace(out.String())) + r.Verify(t) + + require.Len(t, r.Requests, 1) + q := r.Requests[0].URL.Query() + assert.Equal(t, "2026-01-01", q.Get("startDate")) + assert.Equal(t, "2026-01-31", q.Get("endDate")) +} + +func TestExportConversations_MissingArg(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := export.NewExportCmd(f) + _, err := test.Execute(cmd, "", out) + require.Error(t, err) + assert.Contains( + t, + err.Error(), + "agentstudio conversations export requires a argument", + ) +} diff --git a/pkg/cmd/conversations/get/get.go b/pkg/cmd/conversations/get/get.go new file mode 100644 index 00000000..02717a40 --- /dev/null +++ b/pkg/cmd/conversations/get/get.go @@ -0,0 +1,108 @@ +package get + +import ( + "github.com/MakeNowJust/heredoc" + agentStudio "github.com/algolia/algoliasearch-client-go/v4/algolia/agent-studio" + "github.com/spf13/cobra" + + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/pkg/validators" +) + +// GetOptions holds the dependencies and flags for the get command. +type GetOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + AgentStudioClient func() (*agentStudio.APIClient, error) + PrintFlags *cmdutil.PrintFlags + + AgentID string + ConversationID string + IncludeFeedback bool + IncludeMessageEvents bool + IncludeImpactAnalytics bool + SetFeedback bool + SetMessageEvents bool + SetImpactAnalytics bool +} + +// NewGetCmd returns the `conversations get` command. +func NewGetCmd(f *cmdutil.Factory) *cobra.Command { + opts := &GetOptions{ + IO: f.IOStreams, + Config: f.Config, + AgentStudioClient: f.AgentStudioClient, + PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), + } + + cmd := &cobra.Command{ + Use: "get ", + Short: "Get an Agent Studio conversation including its messages", + Args: validators.ExactArgsWithMsg( + 2, + "agentstudio conversations get requires and arguments.", + ), + Annotations: map[string]string{ + "acls": "logs", + }, + Example: heredoc.Doc(` + # Get a conversation + $ algolia agentstudio conversations get 11111111-1111-4111-8111-111111111111 33333333-3333-4333-8333-333333333333 + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.AgentID = args[0] + opts.ConversationID = args[1] + opts.SetFeedback = cmd.Flags().Changed("include-feedback") + opts.SetMessageEvents = cmd.Flags().Changed("include-message-events") + opts.SetImpactAnalytics = cmd.Flags().Changed("include-impact-analytics") + return runGetCmd(opts) + }, + } + + cmd.Flags(). + BoolVar(&opts.IncludeFeedback, "include-feedback", false, "Include feedback for the conversation") + cmd.Flags(). + BoolVar(&opts.IncludeMessageEvents, "include-message-events", false, "Include Insights events on assistant messages") + cmd.Flags(). + BoolVar(&opts.IncludeImpactAnalytics, "include-impact-analytics", false, "Include hasView/hasClick/hasConversion") + + opts.PrintFlags.AddFlags(cmd) + return cmd +} + +func runGetCmd(opts *GetOptions) error { + client, err := opts.AgentStudioClient() + if err != nil { + return err + } + + p, err := opts.PrintFlags.ToPrinter() + if err != nil { + return err + } + + req := client.NewApiGetConversationRequest(opts.ConversationID, opts.AgentID) + if opts.SetFeedback { + req = req.WithIncludeFeedback(opts.IncludeFeedback) + } + if opts.SetMessageEvents { + req = req.WithIncludeMessageEvents(opts.IncludeMessageEvents) + } + if opts.SetImpactAnalytics { + req = req.WithIncludeImpactAnalytics(opts.IncludeImpactAnalytics) + } + + opts.IO.StartProgressIndicatorWithLabel("Fetching conversation") + + res, err := client.GetConversation(req) + if err != nil { + opts.IO.StopProgressIndicator() + return err + } + + opts.IO.StopProgressIndicator() + + return p.Print(opts.IO, res) +} diff --git a/pkg/cmd/conversations/get/get_test.go b/pkg/cmd/conversations/get/get_test.go new file mode 100644 index 00000000..667cca8f --- /dev/null +++ b/pkg/cmd/conversations/get/get_test.go @@ -0,0 +1,61 @@ +package get_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/pkg/cmd/conversations/get" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/test" +) + +const ( + agentID = "11111111-1111-4111-8111-111111111111" + convID = "33333333-3333-4333-8333-333333333333" + getPath = "agent-studio/1/agents/" + agentID + "/conversations/" + convID + getBody = `{ + "id": "33333333-3333-4333-8333-333333333333", + "agentId": "11111111-1111-4111-8111-111111111111", + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "messages": [] +}` +) + +func TestGetConversation(t *testing.T) { + r := &httpmock.Registry{} + r.Register(httpmock.REST("GET", getPath), httpmock.StringResponse(getBody)) + + f, out := test.NewFactory(false, r, nil, "") + cmd := get.NewGetCmd(f) + _, err := test.Execute( + cmd, + agentID+" "+convID+" --include-feedback --include-message-events", + out, + ) + require.NoError(t, err) + + assert.JSONEq(t, getBody, strings.TrimSpace(out.String())) + r.Verify(t) + + require.Len(t, r.Requests, 1) + q := r.Requests[0].URL.Query() + assert.Equal(t, "true", q.Get("includeFeedback")) + assert.Equal(t, "true", q.Get("includeMessageEvents")) +} + +func TestGetConversation_MissingArgs(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := get.NewGetCmd(f) + _, err := test.Execute(cmd, agentID, out) + require.Error(t, err) + assert.Contains( + t, + err.Error(), + "agentstudio conversations get requires and ", + ) +} diff --git a/pkg/cmd/conversations/list/list.go b/pkg/cmd/conversations/list/list.go new file mode 100644 index 00000000..b6cfebc9 --- /dev/null +++ b/pkg/cmd/conversations/list/list.go @@ -0,0 +1,113 @@ +package list + +import ( + "github.com/MakeNowJust/heredoc" + agentStudio "github.com/algolia/algoliasearch-client-go/v4/algolia/agent-studio" + "github.com/spf13/cobra" + + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/pkg/validators" +) + +// ListOptions holds the dependencies and flags for the list command. +type ListOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + AgentStudioClient func() (*agentStudio.APIClient, error) + PrintFlags *cmdutil.PrintFlags + + AgentID string + Page int32 + Limit int32 + StartDate string + EndDate string + IncludeFeedback bool + SetFeedback bool +} + +// NewListCmd returns the `conversations list` command. +func NewListCmd(f *cmdutil.Factory) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + Config: f.Config, + AgentStudioClient: f.AgentStudioClient, + PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), + } + + cmd := &cobra.Command{ + Use: "list ", + Short: "List Agent Studio conversations for an agent", + Args: validators.ExactArgsWithMsg( + 1, + "agentstudio conversations list requires a argument.", + ), + Annotations: map[string]string{ + "acls": "logs", + }, + Example: heredoc.Doc(` + # List conversations for an agent + $ algolia agentstudio conversations list 11111111-1111-4111-8111-111111111111 + + # Filter by date range + $ algolia agentstudio conversations list 11111111-1111-4111-8111-111111111111 --start-date 2026-01-01 --end-date 2026-01-31 + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.AgentID = args[0] + opts.SetFeedback = cmd.Flags().Changed("include-feedback") + return runListCmd(opts) + }, + } + + cmd.Flags().Int32Var(&opts.Page, "page", 0, "Page number") + cmd.Flags().Int32Var(&opts.Limit, "limit", 0, "Items per page") + cmd.Flags().StringVar(&opts.StartDate, "start-date", "", "Created after this date (YYYY-MM-DD)") + cmd.Flags().StringVar(&opts.EndDate, "end-date", "", "Created before this date (YYYY-MM-DD)") + cmd.Flags(). + BoolVar(&opts.IncludeFeedback, "include-feedback", false, "Include feedback per conversation") + + opts.PrintFlags.AddFlags(cmd) + return cmd +} + +func runListCmd(opts *ListOptions) error { + client, err := opts.AgentStudioClient() + if err != nil { + return err + } + + p, err := opts.PrintFlags.ToPrinter() + if err != nil { + return err + } + + req := client.NewApiListAgentConversationsRequest(opts.AgentID) + if opts.Page > 0 { + req = req.WithPage(opts.Page) + } + if opts.Limit > 0 { + req = req.WithLimit(opts.Limit) + } + if opts.StartDate != "" { + req = req.WithStartDate(opts.StartDate) + } + if opts.EndDate != "" { + req = req.WithEndDate(opts.EndDate) + } + if opts.SetFeedback { + req = req.WithIncludeFeedback(opts.IncludeFeedback) + } + + opts.IO.StartProgressIndicatorWithLabel("Fetching conversations") + + res, err := client.ListAgentConversations(req) + if err != nil { + opts.IO.StopProgressIndicator() + return err + } + + opts.IO.StopProgressIndicator() + + return p.Print(opts.IO, res) +} diff --git a/pkg/cmd/conversations/list/list_test.go b/pkg/cmd/conversations/list/list_test.go new file mode 100644 index 00000000..593a46df --- /dev/null +++ b/pkg/cmd/conversations/list/list_test.go @@ -0,0 +1,105 @@ +package list_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/pkg/cmd/conversations/list" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/test" +) + +const ( + agentID = "11111111-1111-4111-8111-111111111111" + listPath = "agent-studio/1/agents/" + agentID + "/conversations" + listBody = `{ + "data": [ + { + "id": "33333333-3333-4333-8333-333333333333", + "agentId": "11111111-1111-4111-8111-111111111111", + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" + } + ], + "pagination": { + "page": 1, + "limit": 10, + "totalCount": 1, + "totalPages": 1 + } +}` + emptyListBody = `{ + "data": [], + "pagination": { + "page": 1, + "limit": 10, + "totalCount": 0, + "totalPages": 0 + } +}` +) + +func TestListConversations(t *testing.T) { + tests := []struct { + name string + cli string + mock string + wantOut string + wantQ map[string]string + }{ + { + name: "single conversation", + cli: agentID, + mock: listBody, + wantOut: listBody, + }, + { + name: "pagination and dates", + cli: agentID + " --page 2 --limit 20 --start-date 2026-01-01 --end-date 2026-01-31 --include-feedback", + mock: emptyListBody, + wantOut: emptyListBody, + wantQ: map[string]string{ + "page": "2", + "limit": "20", + "startDate": "2026-01-01", + "endDate": "2026-01-31", + "includeFeedback": "true", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &httpmock.Registry{} + r.Register(httpmock.REST("GET", listPath), httpmock.StringResponse(tt.mock)) + + f, out := test.NewFactory(false, r, nil, "") + cmd := list.NewListCmd(f) + _, err := test.Execute(cmd, tt.cli, out) + require.NoError(t, err) + + assert.JSONEq(t, tt.wantOut, strings.TrimSpace(out.String())) + r.Verify(t) + + if tt.wantQ != nil { + require.Len(t, r.Requests, 1) + q := r.Requests[0].URL.Query() + for k, v := range tt.wantQ { + assert.Equal(t, v, q.Get(k), "query %s", k) + } + } + }) + } +} + +func TestListConversations_MissingArg(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := list.NewListCmd(f) + _, err := test.Execute(cmd, "", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "agentstudio conversations list requires a argument") +} diff --git a/pkg/cmd/conversations/purge/purge.go b/pkg/cmd/conversations/purge/purge.go new file mode 100644 index 00000000..251dcf26 --- /dev/null +++ b/pkg/cmd/conversations/purge/purge.go @@ -0,0 +1,122 @@ +package purge + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc" + agentStudio "github.com/algolia/algoliasearch-client-go/v4/algolia/agent-studio" + "github.com/spf13/cobra" + + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/pkg/prompt" + "github.com/algolia/cli/pkg/validators" +) + +// PurgeOptions holds the dependencies and flags for the purge command. +type PurgeOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + AgentStudioClient func() (*agentStudio.APIClient, error) + AgentID string + StartDate string + EndDate string + DoConfirm bool +} + +// NewPurgeCmd returns the `conversations purge` command. +func NewPurgeCmd(f *cmdutil.Factory) *cobra.Command { + opts := &PurgeOptions{ + IO: f.IOStreams, + Config: f.Config, + AgentStudioClient: f.AgentStudioClient, + } + + cmd := &cobra.Command{ + Use: "purge ", + Short: "Bulk-delete Agent Studio conversations in a date range", + Long: "Deletes conversations for an agent matching --start-date and/or --end-date. At least one date is required so the delete is bounded.", + Args: validators.ExactArgsWithMsg( + 1, + "agentstudio conversations purge requires a argument.", + ), + Annotations: map[string]string{ + "acls": "logs", + }, + Example: heredoc.Doc(` + # Purge conversations before a date + $ algolia agentstudio conversations purge 11111111-1111-4111-8111-111111111111 --end-date 2026-01-01 --confirm + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.AgentID = args[0] + + if opts.StartDate == "" && opts.EndDate == "" { + return cmdutil.FlagErrorf("`--start-date` or `--end-date` is required") + } + + if !opts.DoConfirm { + if !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf( + "--confirm required when non-interactive shell is detected", + ) + } + var confirmed bool + err := prompt.Confirm( + fmt.Sprintf("Purge conversations for agent %q?", opts.AgentID), + &confirmed, + ) + if err != nil { + return fmt.Errorf("failed to prompt: %w", err) + } + if !confirmed { + return nil + } + } + + return runPurgeCmd(opts) + }, + } + + cmd.Flags().StringVar(&opts.StartDate, "start-date", "", "Created after this date (YYYY-MM-DD)") + cmd.Flags().StringVar(&opts.EndDate, "end-date", "", "Created before this date (YYYY-MM-DD)") + cmd.Flags().BoolVarP(&opts.DoConfirm, "confirm", "y", false, "Skip confirmation prompt") + return cmd +} + +func runPurgeCmd(opts *PurgeOptions) error { + client, err := opts.AgentStudioClient() + if err != nil { + return err + } + + req := client.NewApiDeleteAgentConversationsRequest(opts.AgentID) + if opts.StartDate != "" { + req = req.WithStartDate(opts.StartDate) + } + if opts.EndDate != "" { + req = req.WithEndDate(opts.EndDate) + } + + opts.IO.StartProgressIndicatorWithLabel("Purging conversations") + + err = client.DeleteAgentConversations(req) + if err != nil { + opts.IO.StopProgressIndicator() + return err + } + + opts.IO.StopProgressIndicator() + + if opts.IO.IsStdoutTTY() { + cs := opts.IO.ColorScheme() + fmt.Fprintf( + opts.IO.Out, + "%s Purged conversations for agent %s\n", + cs.SuccessIcon(), + opts.AgentID, + ) + } + + return nil +} diff --git a/pkg/cmd/conversations/purge/purge_test.go b/pkg/cmd/conversations/purge/purge_test.go new file mode 100644 index 00000000..fb84eb1d --- /dev/null +++ b/pkg/cmd/conversations/purge/purge_test.go @@ -0,0 +1,52 @@ +package purge_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/pkg/cmd/conversations/purge" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/test" +) + +const ( + agentID = "11111111-1111-4111-8111-111111111111" + purgePath = "agent-studio/1/agents/" + agentID + "/conversations" +) + +func TestPurgeConversations(t *testing.T) { + r := &httpmock.Registry{} + r.Register(httpmock.REST("DELETE", purgePath), httpmock.StringResponse("")) + + f, out := test.NewFactory(false, r, nil, "") + cmd := purge.NewPurgeCmd(f) + _, err := test.Execute(cmd, agentID+" --end-date 2026-01-01 --confirm", out) + require.NoError(t, err) + assert.Empty(t, out.String()) + r.Verify(t) + + require.Len(t, r.Requests, 1) + assert.Equal(t, "2026-01-01", r.Requests[0].URL.Query().Get("endDate")) +} + +func TestPurgeConversations_RequiresDate(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := purge.NewPurgeCmd(f) + _, err := test.Execute(cmd, agentID+" --confirm", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "`--start-date` or `--end-date` is required") + assert.Empty(t, r.Requests) +} + +func TestPurgeConversations_RequiresConfirmation(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := purge.NewPurgeCmd(f) + _, err := test.Execute(cmd, agentID+" --end-date 2026-01-01", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "--confirm required") + assert.Empty(t, r.Requests) +}