diff --git a/.golangci.yml b/.golangci.yml index 79ad127c..0ba78525 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,3 +1,6 @@ +run: + timeout: 5m + linters: enable: - gosec diff --git a/go.mod b/go.mod index 70ecf23d..20f43803 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/BurntSushi/toml v1.4.0 github.com/MakeNowJust/heredoc v1.0.0 - github.com/algolia/algoliasearch-client-go/v4 v4.43.1 + github.com/algolia/algoliasearch-client-go/v4 v4.48.0 github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 github.com/briandowns/spinner v1.23.2 github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24 diff --git a/go.sum b/go.sum index 5c33c544..10d7b75f 100644 --- a/go.sum +++ b/go.sum @@ -8,12 +8,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= -github.com/algolia/algoliasearch-client-go/v4 v4.37.2 h1:Iqwb/mx9mVKKWKo8qildTAK36RTLIhFNuufqkhBmiHM= -github.com/algolia/algoliasearch-client-go/v4 v4.37.2/go.mod h1:2bHeze2/5+jvT8IYVq8j2NDLr/4R6erGxgud7ESuXww= -github.com/algolia/algoliasearch-client-go/v4 v4.38.0 h1:ufPpfevxC7DN9vTt9niVc7MvV6inrq8zHpIiTvp2lXI= -github.com/algolia/algoliasearch-client-go/v4 v4.38.0/go.mod h1:2bHeze2/5+jvT8IYVq8j2NDLr/4R6erGxgud7ESuXww= -github.com/algolia/algoliasearch-client-go/v4 v4.43.1 h1:XKS6RIGaGdlBhTdDdg/eZPts3QE+ASVAWNsSaC0Ouko= -github.com/algolia/algoliasearch-client-go/v4 v4.43.1/go.mod h1:2bHeze2/5+jvT8IYVq8j2NDLr/4R6erGxgud7ESuXww= +github.com/algolia/algoliasearch-client-go/v4 v4.48.0 h1:wphSQqb1ezO1s+UesFq0Z54zKDrxd5dYnhajg5DKcNs= +github.com/algolia/algoliasearch-client-go/v4 v4.48.0/go.mod h1:2bHeze2/5+jvT8IYVq8j2NDLr/4R6erGxgud7ESuXww= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= diff --git a/pkg/cmd/agents/agents.go b/pkg/cmd/agents/agents.go new file mode 100644 index 00000000..630cd947 --- /dev/null +++ b/pkg/cmd/agents/agents.go @@ -0,0 +1,32 @@ +package agents + +import ( + "github.com/spf13/cobra" + + "github.com/algolia/cli/pkg/cmd/agents/create" + "github.com/algolia/cli/pkg/cmd/agents/delete" + "github.com/algolia/cli/pkg/cmd/agents/get" + "github.com/algolia/cli/pkg/cmd/agents/list" + "github.com/algolia/cli/pkg/cmd/agents/publish" + "github.com/algolia/cli/pkg/cmd/agents/unpublish" + "github.com/algolia/cli/pkg/cmd/agents/update" + "github.com/algolia/cli/pkg/cmdutil" +) + +// NewAgentsCmd returns the agents command group. +func NewAgentsCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "agents", + Short: "Manage Agent Studio agents", + Long: "List, create, update, delete, publish, and unpublish Agent Studio agents.", + } + + cmd.AddCommand(list.NewListCmd(f)) + cmd.AddCommand(get.NewGetCmd(f)) + cmd.AddCommand(create.NewCreateCmd(f)) + cmd.AddCommand(update.NewUpdateCmd(f)) + cmd.AddCommand(delete.NewDeleteCmd(f)) + cmd.AddCommand(publish.NewPublishCmd(f)) + cmd.AddCommand(unpublish.NewUnpublishCmd(f)) + return cmd +} diff --git a/pkg/cmd/agents/create/create.go b/pkg/cmd/agents/create/create.go new file mode 100644 index 00000000..0f122814 --- /dev/null +++ b/pkg/cmd/agents/create/create.go @@ -0,0 +1,124 @@ +package create + +import ( + "encoding/json" + "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" +) + +// CreateOptions holds the dependencies and flags for the create command. +type CreateOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + AgentStudioClient func() (*agentStudio.APIClient, error) + PrintFlags *cmdutil.PrintFlags + + JSON string + Name string + Instructions string +} + +// NewCreateCmd returns the `agents create` command. +func NewCreateCmd(f *cmdutil.Factory) *cobra.Command { + opts := &CreateOptions{ + IO: f.IOStreams, + Config: f.Config, + AgentStudioClient: f.AgentStudioClient, + PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), + } + + cmd := &cobra.Command{ + Use: "create", + Short: "Create an Agent Studio agent", + Annotations: map[string]string{ + "acls": "editSettings", + }, + Example: heredoc.Doc(` + # Create a draft from flags (not publishable until it has a provider and model) + $ algolia agentstudio agents create --name "support-bot" --instructions "You are a helpful assistant." + + # Create from a JSON object + $ algolia agentstudio agents create --json '{"name":"support-bot","instructions":"You are a helpful assistant."}' + + # Create with a provider and model so the agent can be published + $ algolia agentstudio agents create --json '{"name":"support-bot","instructions":"You are a helpful assistant.","providerId":"22222222-2222-4222-8222-222222222222","model":"gpt-4o"}' + `), + RunE: func(cmd *cobra.Command, args []string) error { + if opts.JSON != "" { + if opts.Name != "" || opts.Instructions != "" { + return cmdutil.FlagErrorf("--json cannot be combined with --name or --instructions") + } + } else if opts.Name == "" || opts.Instructions == "" { + return cmdutil.FlagErrorf("`--json` or both `--name` and `--instructions` are required") + } + + return runCreateCmd(opts) + }, + } + + cmd.Flags().StringVar(&opts.JSON, "json", "", "Agent JSON object (include providerId and model to publish later)") + cmd.Flags().StringVar(&opts.Name, "name", "", "Agent name") + cmd.Flags().StringVar(&opts.Instructions, "instructions", "", "Agent prompt") + + opts.PrintFlags.AddFlags(cmd) + return cmd +} + +func buildAgent(opts *CreateOptions) (*agentStudio.AgentConfigCreate, error) { + if opts.JSON == "" { + return agentStudio.NewEmptyAgentConfigCreate(). + SetName(opts.Name). + SetInstructions(opts.Instructions), nil + } + + var body agentStudio.AgentConfigCreate + if err := json.Unmarshal([]byte(opts.JSON), &body); err != nil { + return nil, fmt.Errorf("parsing agent JSON: %w", err) + } + if body.Name == "" || body.Instructions == "" { + return nil, cmdutil.FlagErrorf("agent JSON requires `name` and `instructions`") + } + + return &body, nil +} + +func runCreateCmd(opts *CreateOptions) error { + body, err := buildAgent(opts) + if err != nil { + return err + } + + client, err := opts.AgentStudioClient() + if err != nil { + return err + } + + p, err := opts.PrintFlags.ToPrinter() + if err != nil { + return err + } + + opts.IO.StartProgressIndicatorWithLabel("Creating agent") + + res, err := client.CreateAgent(client.NewApiCreateAgentRequest(body)) + 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 Created agent %s\n", cs.SuccessIcon(), res.Id) + } + + return p.Print(opts.IO, res) +} diff --git a/pkg/cmd/agents/create/create_test.go b/pkg/cmd/agents/create/create_test.go new file mode 100644 index 00000000..08a9903a --- /dev/null +++ b/pkg/cmd/agents/create/create_test.go @@ -0,0 +1,108 @@ +package create_test + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/pkg/cmd/agents/create" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/test" +) + +const createdAgentBody = `{ + "id": "agt_1", + "name": "cli-test-agent", + "description": null, + "status": "draft", + "providerId": null, + "instructions": "You are a helpful assistant.", + "config": {}, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "lastUsedAt": null +}` + +func TestCreateAgent_FromFlags(t *testing.T) { + r := &httpmock.Registry{} + var gotBody []byte + r.Register(httpmock.REST("POST", "agent-studio/1/agents"), func(req *http.Request) (*http.Response, error) { + gotBody, _ = io.ReadAll(req.Body) + return httpmock.StringResponse(createdAgentBody)(req) + }) + + f, out := test.NewFactory(false, r, nil, "") + cmd := create.NewCreateCmd(f) + _, err := test.Execute(cmd, `--name cli-test-agent --instructions "You are a helpful assistant."`, out) + require.NoError(t, err) + + assert.JSONEq(t, createdAgentBody, strings.TrimSpace(out.String())) + assert.JSONEq(t, `{"name":"cli-test-agent","instructions":"You are a helpful assistant."}`, string(gotBody)) + r.Verify(t) +} + +func TestCreateAgent_FromJSON(t *testing.T) { + r := &httpmock.Registry{} + var gotBody []byte + r.Register(httpmock.REST("POST", "agent-studio/1/agents"), func(req *http.Request) (*http.Response, error) { + gotBody, _ = io.ReadAll(req.Body) + return httpmock.StringResponse(createdAgentBody)(req) + }) + + f, out := test.NewFactory(false, r, nil, "") + cmd := create.NewCreateCmd(f) + _, err := test.Execute( + cmd, + `--json '{"name":"cli-test-agent","instructions":"You are a helpful assistant.","model":"gpt-4o"}'`, + out, + ) + require.NoError(t, err) + + assert.JSONEq(t, createdAgentBody, strings.TrimSpace(out.String())) + assert.JSONEq( + t, + `{"name":"cli-test-agent","instructions":"You are a helpful assistant.","model":"gpt-4o"}`, + string(gotBody), + ) + r.Verify(t) +} + +func TestCreateAgent_MissingInput(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := create.NewCreateCmd(f) + _, err := test.Execute(cmd, "", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "`--json` or both `--name` and `--instructions` are required") +} + +func TestCreateAgent_JSONAndFlagsConflict(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := create.NewCreateCmd(f) + _, err := test.Execute(cmd, `--json '{"name":"x","instructions":"y"}' --name x --instructions y`, out) + require.Error(t, err) + assert.Contains(t, err.Error(), "--json cannot be combined") +} + +func TestCreateAgent_InvalidJSON(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := create.NewCreateCmd(f) + _, err := test.Execute(cmd, "--json not-json", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "parsing agent JSON") +} + +func TestCreateAgent_JSONMissingRequiredFields(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := create.NewCreateCmd(f) + _, err := test.Execute(cmd, `--json '{"name":"only-name"}'`, out) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires `name` and `instructions`") +} diff --git a/pkg/cmd/agents/delete/delete.go b/pkg/cmd/agents/delete/delete.go new file mode 100644 index 00000000..47dc0877 --- /dev/null +++ b/pkg/cmd/agents/delete/delete.go @@ -0,0 +1,98 @@ +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 + DoConfirm bool +} + +// NewDeleteCmd returns the `agents 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 agent", + Args: validators.ExactArgsWithMsg(1, "agentstudio agents delete requires a argument."), + Annotations: map[string]string{ + "acls": "editSettings", + }, + Example: heredoc.Doc(` + # Delete an agent (with confirmation prompt) + $ algolia agentstudio agents delete 11111111-1111-4111-8111-111111111111 + + # Delete without confirmation + $ algolia agentstudio agents delete 11111111-1111-4111-8111-111111111111 --confirm + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.AgentID = args[0] + + 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 agent %q?", opts.AgentID), + &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 agent") + + err = client.DeleteAgent(client.NewApiDeleteAgentRequest(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 agent %s\n", cs.SuccessIcon(), opts.AgentID) + } + + return nil +} diff --git a/pkg/cmd/agents/delete/delete_test.go b/pkg/cmd/agents/delete/delete_test.go new file mode 100644 index 00000000..46c216e5 --- /dev/null +++ b/pkg/cmd/agents/delete/delete_test.go @@ -0,0 +1,46 @@ +package delete_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/pkg/cmd/agents/delete" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/test" +) + +func TestDeleteAgent(t *testing.T) { + r := &httpmock.Registry{} + r.Register( + httpmock.REST("DELETE", "agent-studio/1/agents/agt_1"), + httpmock.StringResponse(""), + ) + + f, out := test.NewFactory(false, r, nil, "") + cmd := delete.NewDeleteCmd(f) + _, err := test.Execute(cmd, "agt_1 --confirm", out) + require.NoError(t, err) + assert.Empty(t, out.String()) + r.Verify(t) +} + +func TestDeleteAgent_RequiresConfirmation(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := delete.NewDeleteCmd(f) + _, err := test.Execute(cmd, "agt_1", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "--confirm required") + assert.Empty(t, r.Requests) +} + +func TestDeleteAgent_MissingArg(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := delete.NewDeleteCmd(f) + _, err := test.Execute(cmd, "--confirm", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "agentstudio agents delete requires a argument") +} diff --git a/pkg/cmd/agents/get/get.go b/pkg/cmd/agents/get/get.go new file mode 100644 index 00000000..1e3f9820 --- /dev/null +++ b/pkg/cmd/agents/get/get.go @@ -0,0 +1,75 @@ +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) + AgentID string + PrintFlags *cmdutil.PrintFlags +} + +// NewGetCmd returns the `agents 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 agent by ID", + Args: validators.ExactArgsWithMsg(1, "agentstudio agents get requires a argument."), + Annotations: map[string]string{ + "acls": "settings", + }, + Example: heredoc.Doc(` + # Get an agent by ID + $ algolia agentstudio agents get 11111111-1111-4111-8111-111111111111 + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.AgentID = args[0] + return runGetCmd(opts) + }, + } + + 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 + } + + opts.IO.StartProgressIndicatorWithLabel("Fetching agent") + + res, err := client.GetAgent(client.NewApiGetAgentRequest(opts.AgentID)) + if err != nil { + opts.IO.StopProgressIndicator() + return err + } + + opts.IO.StopProgressIndicator() + + return p.Print(opts.IO, res) +} diff --git a/pkg/cmd/agents/get/get_test.go b/pkg/cmd/agents/get/get_test.go new file mode 100644 index 00000000..135627f4 --- /dev/null +++ b/pkg/cmd/agents/get/get_test.go @@ -0,0 +1,51 @@ +package get_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/pkg/cmd/agents/get" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/test" +) + +const getAgentBody = `{ + "id": "agt_1", + "name": "test-agent", + "description": "d", + "status": "draft", + "providerId": "p1", + "instructions": "You are helpful.", + "config": {}, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "lastUsedAt": null +}` + +func TestGetAgent(t *testing.T) { + r := &httpmock.Registry{} + r.Register( + httpmock.REST("GET", "agent-studio/1/agents/agt_1"), + httpmock.StringResponse(getAgentBody), + ) + + f, out := test.NewFactory(false, r, nil, "") + cmd := get.NewGetCmd(f) + _, err := test.Execute(cmd, "agt_1", out) + require.NoError(t, err) + + assert.JSONEq(t, getAgentBody, strings.TrimSpace(out.String())) + r.Verify(t) +} + +func TestGetAgent_MissingArg(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := get.NewGetCmd(f) + _, err := test.Execute(cmd, "", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "agentstudio agents get requires a argument") +} diff --git a/pkg/cmd/agents/list/list.go b/pkg/cmd/agents/list/list.go new file mode 100644 index 00000000..16c934ee --- /dev/null +++ b/pkg/cmd/agents/list/list.go @@ -0,0 +1,96 @@ +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" +) + +// 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 + + Page int32 + Limit int32 + ProviderID string +} + +// NewListCmd returns the `agents 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 agents", + Annotations: map[string]string{ + "acls": "settings", + }, + Example: heredoc.Doc(` + # List agents + $ algolia agentstudio agents list + + # List the second page of 20 agents + $ algolia agentstudio agents list --page 2 --limit 20 + + # Filter by provider + $ algolia agentstudio agents list --provider-id 22222222-2222-4222-8222-222222222222 + `), + RunE: func(cmd *cobra.Command, args []string) error { + 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.ProviderID, "provider-id", "", "Filter by provider ID") + + 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.NewApiListAgentsRequest() + if opts.Page > 0 { + req = req.WithPage(opts.Page) + } + if opts.Limit > 0 { + req = req.WithLimit(opts.Limit) + } + if opts.ProviderID != "" { + req = req.WithProviderId(opts.ProviderID) + } + + opts.IO.StartProgressIndicatorWithLabel("Fetching agents") + + res, err := client.ListAgents(req) + if err != nil { + opts.IO.StopProgressIndicator() + return err + } + + opts.IO.StopProgressIndicator() + + return p.Print(opts.IO, res) +} diff --git a/pkg/cmd/agents/list/list_test.go b/pkg/cmd/agents/list/list_test.go new file mode 100644 index 00000000..ae4d4779 --- /dev/null +++ b/pkg/cmd/agents/list/list_test.go @@ -0,0 +1,104 @@ +package list_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/pkg/cmd/agents/list" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/test" +) + +const listAgentsBody = `{ + "data": [ + { + "id": "agt_1", + "name": "test-agent", + "description": "d", + "status": "draft", + "providerId": "p1", + "instructions": "You are helpful.", + "config": {}, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "lastUsedAt": null + } + ], + "pagination": { + "page": 1, + "limit": 10, + "totalCount": 1, + "totalPages": 1 + } +}` + +const emptyListAgentsBody = `{ + "data": [], + "pagination": { + "page": 1, + "limit": 10, + "totalCount": 0, + "totalPages": 0 + } +}` + +func TestListAgents(t *testing.T) { + tests := []struct { + name string + cli string + mock string + wantOut string + wantQ map[string]string + }{ + { + name: "single agent", + mock: listAgentsBody, + wantOut: listAgentsBody, + }, + { + name: "empty list", + mock: emptyListAgentsBody, + wantOut: emptyListAgentsBody, + }, + { + name: "pagination and provider filter", + cli: "--page 2 --limit 20 --provider-id p1", + mock: emptyListAgentsBody, + wantOut: emptyListAgentsBody, + wantQ: map[string]string{ + "page": "2", + "limit": "20", + "providerId": "p1", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &httpmock.Registry{} + r.Register( + httpmock.REST("GET", "agent-studio/1/agents"), + 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) + } + } + }) + } +} diff --git a/pkg/cmd/agents/publish/publish.go b/pkg/cmd/agents/publish/publish.go new file mode 100644 index 00000000..59695f75 --- /dev/null +++ b/pkg/cmd/agents/publish/publish.go @@ -0,0 +1,83 @@ +package publish + +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/validators" +) + +// PublishOptions holds the dependencies and flags for the publish command. +type PublishOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + AgentStudioClient func() (*agentStudio.APIClient, error) + AgentID string + PrintFlags *cmdutil.PrintFlags +} + +// NewPublishCmd returns the `agents publish` command. +func NewPublishCmd(f *cmdutil.Factory) *cobra.Command { + opts := &PublishOptions{ + IO: f.IOStreams, + Config: f.Config, + AgentStudioClient: f.AgentStudioClient, + PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), + } + + cmd := &cobra.Command{ + Use: "publish ", + Short: "Publish an Agent Studio agent", + Long: "Publishes a draft agent. The API returns 409 unless the agent has a provider and a model.", + Args: validators.ExactArgsWithMsg(1, "agentstudio agents publish requires a argument."), + Annotations: map[string]string{ + "acls": "editSettings", + }, + Example: heredoc.Doc(` + # Publish a draft that already has a provider and model + $ algolia agentstudio agents publish 11111111-1111-4111-8111-111111111111 + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.AgentID = args[0] + return runPublishCmd(opts) + }, + } + + opts.PrintFlags.AddFlags(cmd) + return cmd +} + +func runPublishCmd(opts *PublishOptions) error { + client, err := opts.AgentStudioClient() + if err != nil { + return err + } + + p, err := opts.PrintFlags.ToPrinter() + if err != nil { + return err + } + + opts.IO.StartProgressIndicatorWithLabel("Publishing agent") + + res, err := client.PublishAgent(client.NewApiPublishAgentRequest(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 Published agent %s\n", cs.SuccessIcon(), opts.AgentID) + } + + return p.Print(opts.IO, res) +} diff --git a/pkg/cmd/agents/publish/publish_test.go b/pkg/cmd/agents/publish/publish_test.go new file mode 100644 index 00000000..02d31a83 --- /dev/null +++ b/pkg/cmd/agents/publish/publish_test.go @@ -0,0 +1,51 @@ +package publish_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/pkg/cmd/agents/publish" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/test" +) + +const publishedAgentBody = `{ + "id": "agt_1", + "name": "test-agent", + "description": "d", + "status": "published", + "providerId": "p1", + "instructions": "You are helpful.", + "config": {}, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "lastUsedAt": null +}` + +func TestPublishAgent(t *testing.T) { + r := &httpmock.Registry{} + r.Register( + httpmock.REST("POST", "agent-studio/1/agents/agt_1/publish"), + httpmock.StringResponse(publishedAgentBody), + ) + + f, out := test.NewFactory(false, r, nil, "") + cmd := publish.NewPublishCmd(f) + _, err := test.Execute(cmd, "agt_1", out) + require.NoError(t, err) + + assert.JSONEq(t, publishedAgentBody, strings.TrimSpace(out.String())) + r.Verify(t) +} + +func TestPublishAgent_MissingArg(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := publish.NewPublishCmd(f) + _, err := test.Execute(cmd, "", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "agentstudio agents publish requires a argument") +} diff --git a/pkg/cmd/agents/unpublish/unpublish.go b/pkg/cmd/agents/unpublish/unpublish.go new file mode 100644 index 00000000..4bb9de5b --- /dev/null +++ b/pkg/cmd/agents/unpublish/unpublish.go @@ -0,0 +1,82 @@ +package unpublish + +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/validators" +) + +// UnpublishOptions holds the dependencies and flags for the unpublish command. +type UnpublishOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + AgentStudioClient func() (*agentStudio.APIClient, error) + AgentID string + PrintFlags *cmdutil.PrintFlags +} + +// NewUnpublishCmd returns the `agents unpublish` command. +func NewUnpublishCmd(f *cmdutil.Factory) *cobra.Command { + opts := &UnpublishOptions{ + IO: f.IOStreams, + Config: f.Config, + AgentStudioClient: f.AgentStudioClient, + PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), + } + + cmd := &cobra.Command{ + Use: "unpublish ", + Short: "Unpublish an Agent Studio agent", + Args: validators.ExactArgsWithMsg(1, "agentstudio agents unpublish requires a argument."), + Annotations: map[string]string{ + "acls": "editSettings", + }, + Example: heredoc.Doc(` + # Unpublish the agent + $ algolia agentstudio agents unpublish 11111111-1111-4111-8111-111111111111 + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.AgentID = args[0] + return runUnpublishCmd(opts) + }, + } + + opts.PrintFlags.AddFlags(cmd) + return cmd +} + +func runUnpublishCmd(opts *UnpublishOptions) error { + client, err := opts.AgentStudioClient() + if err != nil { + return err + } + + p, err := opts.PrintFlags.ToPrinter() + if err != nil { + return err + } + + opts.IO.StartProgressIndicatorWithLabel("Unpublishing agent") + + res, err := client.UnpublishAgent(client.NewApiUnpublishAgentRequest(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 Unpublished agent %s\n", cs.SuccessIcon(), opts.AgentID) + } + + return p.Print(opts.IO, res) +} diff --git a/pkg/cmd/agents/unpublish/unpublish_test.go b/pkg/cmd/agents/unpublish/unpublish_test.go new file mode 100644 index 00000000..ceeb87a2 --- /dev/null +++ b/pkg/cmd/agents/unpublish/unpublish_test.go @@ -0,0 +1,51 @@ +package unpublish_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/pkg/cmd/agents/unpublish" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/test" +) + +const unpublishedAgentBody = `{ + "id": "agt_1", + "name": "test-agent", + "description": "d", + "status": "draft", + "providerId": "p1", + "instructions": "You are helpful.", + "config": {}, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "lastUsedAt": null +}` + +func TestUnpublishAgent(t *testing.T) { + r := &httpmock.Registry{} + r.Register( + httpmock.REST("POST", "agent-studio/1/agents/agt_1/unpublish"), + httpmock.StringResponse(unpublishedAgentBody), + ) + + f, out := test.NewFactory(false, r, nil, "") + cmd := unpublish.NewUnpublishCmd(f) + _, err := test.Execute(cmd, "agt_1", out) + require.NoError(t, err) + + assert.JSONEq(t, unpublishedAgentBody, strings.TrimSpace(out.String())) + r.Verify(t) +} + +func TestUnpublishAgent_MissingArg(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := unpublish.NewUnpublishCmd(f) + _, err := test.Execute(cmd, "", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "agentstudio agents unpublish requires a argument") +} diff --git a/pkg/cmd/agents/update/update.go b/pkg/cmd/agents/update/update.go new file mode 100644 index 00000000..c5b0f492 --- /dev/null +++ b/pkg/cmd/agents/update/update.go @@ -0,0 +1,128 @@ +package update + +import ( + "encoding/json" + "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/validators" +) + +// UpdateOptions holds the dependencies and flags for the update command. +type UpdateOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + AgentStudioClient func() (*agentStudio.APIClient, error) + PrintFlags *cmdutil.PrintFlags + + AgentID string + JSON string + Name string + Instructions string +} + +// NewUpdateCmd returns the `agents update` command. +func NewUpdateCmd(f *cmdutil.Factory) *cobra.Command { + opts := &UpdateOptions{ + IO: f.IOStreams, + Config: f.Config, + AgentStudioClient: f.AgentStudioClient, + PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), + } + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update an Agent Studio agent", + Args: validators.ExactArgsWithMsg(1, "agentstudio agents update requires a argument."), + Annotations: map[string]string{ + "acls": "editSettings", + }, + Example: heredoc.Doc(` + # Update the agent name + $ algolia agentstudio agents update 11111111-1111-4111-8111-111111111111 --name "renamed-bot" + + # Update from a JSON object + $ algolia agentstudio agents update 11111111-1111-4111-8111-111111111111 --json '{"name":"renamed-bot","instructions":"Be brief."}' + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.AgentID = args[0] + + if opts.JSON != "" { + if opts.Name != "" || opts.Instructions != "" { + return cmdutil.FlagErrorf("--json cannot be combined with --name or --instructions") + } + } else if opts.Name == "" && opts.Instructions == "" { + return cmdutil.FlagErrorf("`--json` or at least one of `--name` or `--instructions` is required") + } + + return runUpdateCmd(opts) + }, + } + + cmd.Flags().StringVar(&opts.JSON, "json", "", "Agent JSON patch object") + cmd.Flags().StringVar(&opts.Name, "name", "", "Agent name") + cmd.Flags().StringVar(&opts.Instructions, "instructions", "", "Agent prompt") + + opts.PrintFlags.AddFlags(cmd) + return cmd +} + +func buildPatch(opts *UpdateOptions) (*agentStudio.AgentConfigUpdate, error) { + if opts.JSON == "" { + body := agentStudio.NewEmptyAgentConfigUpdate() + if opts.Name != "" { + body.SetName(opts.Name) + } + if opts.Instructions != "" { + body.SetInstructions(opts.Instructions) + } + return body, nil + } + + var body agentStudio.AgentConfigUpdate + if err := json.Unmarshal([]byte(opts.JSON), &body); err != nil { + return nil, fmt.Errorf("parsing agent JSON: %w", err) + } + + return &body, nil +} + +func runUpdateCmd(opts *UpdateOptions) error { + body, err := buildPatch(opts) + if err != nil { + return err + } + + client, err := opts.AgentStudioClient() + if err != nil { + return err + } + + p, err := opts.PrintFlags.ToPrinter() + if err != nil { + return err + } + + opts.IO.StartProgressIndicatorWithLabel("Updating agent") + + res, err := client.UpdateAgent(client.NewApiUpdateAgentRequest(opts.AgentID, body)) + 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 Updated agent %s\n", cs.SuccessIcon(), opts.AgentID) + } + + return p.Print(opts.IO, res) +} diff --git a/pkg/cmd/agents/update/update_test.go b/pkg/cmd/agents/update/update_test.go new file mode 100644 index 00000000..c59cf6dd --- /dev/null +++ b/pkg/cmd/agents/update/update_test.go @@ -0,0 +1,100 @@ +package update_test + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/pkg/cmd/agents/update" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/test" +) + +const updatedAgentBody = `{ + "id": "agt_1", + "name": "renamed-bot", + "description": null, + "status": "draft", + "providerId": null, + "instructions": "You are a helpful assistant.", + "config": {}, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "lastUsedAt": null +}` + +func TestUpdateAgent_FromFlags(t *testing.T) { + r := &httpmock.Registry{} + var gotBody []byte + r.Register(httpmock.REST("PATCH", "agent-studio/1/agents/agt_1"), func(req *http.Request) (*http.Response, error) { + gotBody, _ = io.ReadAll(req.Body) + return httpmock.StringResponse(updatedAgentBody)(req) + }) + + f, out := test.NewFactory(false, r, nil, "") + cmd := update.NewUpdateCmd(f) + _, err := test.Execute(cmd, "agt_1 --name renamed-bot", out) + require.NoError(t, err) + + assert.JSONEq(t, updatedAgentBody, strings.TrimSpace(out.String())) + assert.JSONEq(t, `{"name":"renamed-bot"}`, string(gotBody)) + r.Verify(t) +} + +func TestUpdateAgent_FromJSON(t *testing.T) { + r := &httpmock.Registry{} + var gotBody []byte + r.Register(httpmock.REST("PATCH", "agent-studio/1/agents/agt_1"), func(req *http.Request) (*http.Response, error) { + gotBody, _ = io.ReadAll(req.Body) + return httpmock.StringResponse(updatedAgentBody)(req) + }) + + f, out := test.NewFactory(false, r, nil, "") + cmd := update.NewUpdateCmd(f) + _, err := test.Execute(cmd, `agt_1 --json '{"name":"renamed-bot","instructions":"Be brief."}'`, out) + require.NoError(t, err) + + assert.JSONEq(t, updatedAgentBody, strings.TrimSpace(out.String())) + assert.JSONEq(t, `{"name":"renamed-bot","instructions":"Be brief."}`, string(gotBody)) + r.Verify(t) +} + +func TestUpdateAgent_MissingArg(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := update.NewUpdateCmd(f) + _, err := test.Execute(cmd, "--name x", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "agentstudio agents update requires a argument") +} + +func TestUpdateAgent_MissingInput(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := update.NewUpdateCmd(f) + _, err := test.Execute(cmd, "agt_1", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "`--json` or at least one of `--name` or `--instructions` is required") +} + +func TestUpdateAgent_JSONAndFlagsConflict(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := update.NewUpdateCmd(f) + _, err := test.Execute(cmd, `agt_1 --json '{"name":"x"}' --name x`, out) + require.Error(t, err) + assert.Contains(t, err.Error(), "--json cannot be combined") +} + +func TestUpdateAgent_InvalidJSON(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := update.NewUpdateCmd(f) + _, err := test.Execute(cmd, "agt_1 --json not-json", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "parsing agent JSON") +} diff --git a/pkg/cmd/agentstudio/agentstudio.go b/pkg/cmd/agentstudio/agentstudio.go new file mode 100644 index 00000000..8d2fd421 --- /dev/null +++ b/pkg/cmd/agentstudio/agentstudio.go @@ -0,0 +1,22 @@ +package agentstudio + +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" +) + +// NewAgentStudioCmd returns the agentstudio command group. +func NewAgentStudioCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "agentstudio", + Short: "Manage Algolia Agent Studio", + Long: "Manage Algolia Agent Studio resources.", + } + + 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) +} diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 6e27cf3e..1c157e90 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -5,6 +5,7 @@ import ( "net/url" "strings" + agentStudio "github.com/algolia/algoliasearch-client-go/v4/algolia/agent-studio" "github.com/algolia/algoliasearch-client-go/v4/algolia/call" "github.com/algolia/algoliasearch-client-go/v4/algolia/composition" "github.com/algolia/algoliasearch-client-go/v4/algolia/search" @@ -29,6 +30,7 @@ func New(appVersion string, cfg config.IConfig) *cmdutil.Factory { f.SearchClient = searchClient(f, appVersion) f.CrawlerClient = crawlerClient(f) f.CompositionClient = compositionClient(f, appVersion) + f.AgentStudioClient = agentStudioClient(f, appVersion) return f } @@ -143,6 +145,35 @@ func compositionClient(f *cmdutil.Factory, appVersion string) func() (*compositi } } +func agentStudioClient(f *cmdutil.Factory, appVersion string) func() (*agentStudio.APIClient, error) { + return func() (*agentStudio.APIClient, error) { + appID, err := f.Config.Profile().GetApplicationID() + if err != nil { + return nil, err + } + apiKey, err := f.Config.Profile().GetAPIKey() + if err != nil { + return nil, err + } + + userAgent, err := getUserAgentInfo(appID, apiKey, appVersion) + if err != nil { + return nil, err + } + + clientConf := agentStudio.AgentStudioConfiguration{ + Configuration: transport.Configuration{ + AppID: appID, + ApiKey: apiKey, + UserAgent: userAgent, + ExposeIntermediateNetworkErrors: true, + }, + } + + return agentStudio.NewClientWithConfig(clientConf) + } +} + // getUserAgentInfo returns the standard user agent info plus Algolia CLI func getUserAgentInfo(appID string, apiKey string, appVersion string) (string, error) { client, err := search.NewClient(appID, apiKey) diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index a53e242c..8aac09a7 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -22,6 +22,7 @@ import ( "github.com/algolia/cli/internal/update" "github.com/algolia/cli/pkg/auth" + "github.com/algolia/cli/pkg/cmd/agentstudio" "github.com/algolia/cli/pkg/cmd/apikeys" "github.com/algolia/cli/pkg/cmd/application" authcmd "github.com/algolia/cli/pkg/cmd/auth" @@ -124,6 +125,7 @@ func NewRootCmd(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(events.NewEventsCmd(f)) cmd.AddCommand(crawler.NewCrawlersCmd(f)) cmd.AddCommand(compositions.NewCompositionsCmd(f)) + cmd.AddCommand(agentstudio.NewAgentStudioCmd(f)) return cmd } diff --git a/pkg/cmdutil/factory.go b/pkg/cmdutil/factory.go index 3d135893..bdbefc72 100644 --- a/pkg/cmdutil/factory.go +++ b/pkg/cmdutil/factory.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" + agentStudio "github.com/algolia/algoliasearch-client-go/v4/algolia/agent-studio" "github.com/algolia/algoliasearch-client-go/v4/algolia/composition" "github.com/algolia/algoliasearch-client-go/v4/algolia/search" @@ -20,6 +21,7 @@ type Factory struct { SearchClient func() (*search.APIClient, error) CrawlerClient func() (*crawler.Client, error) CompositionClient func() (*composition.APIClient, error) + AgentStudioClient func() (*agentStudio.APIClient, error) // Prompter is the interactive input source used by commands that support // an --interactive mode. Defaulted to a real SurveyPrompter in factory.New; diff --git a/test/helpers.go b/test/helpers.go index 4a627787..7e9d2e34 100644 --- a/test/helpers.go +++ b/test/helpers.go @@ -8,6 +8,7 @@ import ( "github.com/google/shlex" "github.com/spf13/cobra" + agentStudio "github.com/algolia/algoliasearch-client-go/v4/algolia/agent-studio" "github.com/algolia/algoliasearch-client-go/v4/algolia/composition" "github.com/algolia/algoliasearch-client-go/v4/algolia/search" "github.com/algolia/algoliasearch-client-go/v4/algolia/transport" @@ -96,6 +97,16 @@ func NewFactory( } return composition.NewClientWithConfig(cfg) } + f.AgentStudioClient = func() (*agentStudio.APIClient, error) { + cfg := agentStudio.AgentStudioConfiguration{ + Configuration: transport.Configuration{ + AppID: "default", + ApiKey: "default", + Requester: r, + }, + } + return agentStudio.NewClientWithConfig(cfg) + } } if cfg != nil {