From 9e9a3edd8e8085decbf47e7b7e4f5357fdfa63ed Mon Sep 17 00:00:00 2001 From: Catalin Marguta Date: Thu, 15 Oct 2026 18:12:42 +0300 Subject: [PATCH 1/2] feat(providers): add Agent Studio provider list, get, delete, and models Sibling module under agentstudio. Create and update are deferred because they handle provider secrets. Co-authored-by: Cursor --- pkg/cmd/agentstudio/agentstudio.go | 2 + pkg/cmd/providers/delete/delete.go | 103 ++++++++++++++++++++++++ pkg/cmd/providers/delete/delete_test.go | 52 ++++++++++++ pkg/cmd/providers/get/get.go | 78 ++++++++++++++++++ pkg/cmd/providers/get/get_test.go | 48 +++++++++++ pkg/cmd/providers/list/list.go | 88 ++++++++++++++++++++ pkg/cmd/providers/list/list_test.go | 94 +++++++++++++++++++++ pkg/cmd/providers/models/models.go | 97 ++++++++++++++++++++++ pkg/cmd/providers/models/models_test.go | 57 +++++++++++++ pkg/cmd/providers/providers.go | 26 ++++++ 10 files changed, 645 insertions(+) create mode 100644 pkg/cmd/providers/delete/delete.go create mode 100644 pkg/cmd/providers/delete/delete_test.go create mode 100644 pkg/cmd/providers/get/get.go create mode 100644 pkg/cmd/providers/get/get_test.go create mode 100644 pkg/cmd/providers/list/list.go create mode 100644 pkg/cmd/providers/list/list_test.go create mode 100644 pkg/cmd/providers/models/models.go create mode 100644 pkg/cmd/providers/models/models_test.go create mode 100644 pkg/cmd/providers/providers.go diff --git a/pkg/cmd/agentstudio/agentstudio.go b/pkg/cmd/agentstudio/agentstudio.go index 8d2fd421..3de920f3 100644 --- a/pkg/cmd/agentstudio/agentstudio.go +++ b/pkg/cmd/agentstudio/agentstudio.go @@ -5,6 +5,7 @@ import ( "github.com/algolia/cli/pkg/cmd/agents" "github.com/algolia/cli/pkg/cmd/conversations" + "github.com/algolia/cli/pkg/cmd/providers" "github.com/algolia/cli/pkg/cmdutil" ) @@ -18,5 +19,6 @@ func NewAgentStudioCmd(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(agents.NewAgentsCmd(f)) cmd.AddCommand(conversations.NewConversationsCmd(f)) + cmd.AddCommand(providers.NewProvidersCmd(f)) return cmd } diff --git a/pkg/cmd/providers/delete/delete.go b/pkg/cmd/providers/delete/delete.go new file mode 100644 index 00000000..048600fc --- /dev/null +++ b/pkg/cmd/providers/delete/delete.go @@ -0,0 +1,103 @@ +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) + ProviderID string + DoConfirm bool +} + +// NewDeleteCmd returns the `providers 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 provider", + Args: validators.ExactArgsWithMsg( + 1, + "agentstudio providers delete requires a argument.", + ), + Annotations: map[string]string{ + "acls": "editSettings", + }, + Example: heredoc.Doc(` + # Delete a provider (with confirmation prompt) + $ algolia agentstudio providers delete 22222222-2222-4222-8222-222222222222 + + # Delete without confirmation + $ algolia agentstudio providers delete 22222222-2222-4222-8222-222222222222 --confirm + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.ProviderID = 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 provider %q?", opts.ProviderID), + &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 provider") + + err = client.DeleteProvider(client.NewApiDeleteProviderRequest(opts.ProviderID)) + 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 provider %s\n", cs.SuccessIcon(), opts.ProviderID) + } + + return nil +} diff --git a/pkg/cmd/providers/delete/delete_test.go b/pkg/cmd/providers/delete/delete_test.go new file mode 100644 index 00000000..c8a647f4 --- /dev/null +++ b/pkg/cmd/providers/delete/delete_test.go @@ -0,0 +1,52 @@ +package delete_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/pkg/cmd/providers/delete" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/test" +) + +const ( + providerID = "22222222-2222-4222-8222-222222222222" + deletePath = "agent-studio/1/providers/" + providerID +) + +func TestDeleteProvider(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, providerID+" --confirm", out) + require.NoError(t, err) + assert.Empty(t, out.String()) + r.Verify(t) +} + +func TestDeleteProvider_RequiresConfirmation(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := delete.NewDeleteCmd(f) + _, err := test.Execute(cmd, providerID, out) + require.Error(t, err) + assert.Contains(t, err.Error(), "--confirm required") + assert.Empty(t, r.Requests) +} + +func TestDeleteProvider_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 providers delete requires a argument", + ) +} diff --git a/pkg/cmd/providers/get/get.go b/pkg/cmd/providers/get/get.go new file mode 100644 index 00000000..1dc53d40 --- /dev/null +++ b/pkg/cmd/providers/get/get.go @@ -0,0 +1,78 @@ +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) + ProviderID string + PrintFlags *cmdutil.PrintFlags +} + +// NewGetCmd returns the `providers 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 provider by ID", + Args: validators.ExactArgsWithMsg( + 1, + "agentstudio providers get requires a argument.", + ), + Annotations: map[string]string{ + "acls": "settings", + }, + Example: heredoc.Doc(` + # Get a provider by ID + $ algolia agentstudio providers get 22222222-2222-4222-8222-222222222222 + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.ProviderID = 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 provider") + + res, err := client.GetProvider(client.NewApiGetProviderRequest(opts.ProviderID)) + if err != nil { + opts.IO.StopProgressIndicator() + return err + } + + opts.IO.StopProgressIndicator() + + return p.Print(opts.IO, res) +} diff --git a/pkg/cmd/providers/get/get_test.go b/pkg/cmd/providers/get/get_test.go new file mode 100644 index 00000000..c9ea5b8e --- /dev/null +++ b/pkg/cmd/providers/get/get_test.go @@ -0,0 +1,48 @@ +package get_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/pkg/cmd/providers/get" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/test" +) + +const ( + providerID = "22222222-2222-4222-8222-222222222222" + getPath = "agent-studio/1/providers/" + providerID + getBody = `{ + "id": "22222222-2222-4222-8222-222222222222", + "name": "openai", + "providerName": "openai", + "input": {"apiKey": "sk-test"}, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" +}` +) + +func TestGetProvider(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, providerID, out) + require.NoError(t, err) + + assert.JSONEq(t, getBody, strings.TrimSpace(out.String())) + r.Verify(t) +} + +func TestGetProvider_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 providers get requires a argument") +} diff --git a/pkg/cmd/providers/list/list.go b/pkg/cmd/providers/list/list.go new file mode 100644 index 00000000..684d8f27 --- /dev/null +++ b/pkg/cmd/providers/list/list.go @@ -0,0 +1,88 @@ +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 +} + +// NewListCmd returns the `providers 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 providers", + Annotations: map[string]string{ + "acls": "settings", + }, + Example: heredoc.Doc(` + # List providers + $ algolia agentstudio providers list + + # List the second page of 20 providers + $ algolia agentstudio providers list --page 2 --limit 20 + `), + 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") + + 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.NewApiListProvidersRequest() + if opts.Page > 0 { + req = req.WithPage(opts.Page) + } + if opts.Limit > 0 { + req = req.WithLimit(opts.Limit) + } + + opts.IO.StartProgressIndicatorWithLabel("Fetching providers") + + res, err := client.ListProviders(req) + if err != nil { + opts.IO.StopProgressIndicator() + return err + } + + opts.IO.StopProgressIndicator() + + return p.Print(opts.IO, res) +} diff --git a/pkg/cmd/providers/list/list_test.go b/pkg/cmd/providers/list/list_test.go new file mode 100644 index 00000000..06195191 --- /dev/null +++ b/pkg/cmd/providers/list/list_test.go @@ -0,0 +1,94 @@ +package list_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/pkg/cmd/providers/list" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/test" +) + +const listProvidersBody = `{ + "data": [ + { + "id": "22222222-2222-4222-8222-222222222222", + "name": "openai", + "providerName": "openai", + "input": {"apiKey": "sk-test"}, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" + } + ], + "pagination": { + "page": 1, + "limit": 10, + "totalCount": 1, + "totalPages": 1 + } +}` + +const emptyListProvidersBody = `{ + "data": [], + "pagination": { + "page": 1, + "limit": 10, + "totalCount": 0, + "totalPages": 0 + } +}` + +func TestListProviders(t *testing.T) { + tests := []struct { + name string + cli string + mock string + wantOut string + wantQ map[string]string + }{ + { + name: "single provider", + mock: listProvidersBody, + wantOut: listProvidersBody, + }, + { + name: "pagination", + cli: "--page 2 --limit 20", + mock: emptyListProvidersBody, + wantOut: emptyListProvidersBody, + wantQ: map[string]string{ + "page": "2", + "limit": "20", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &httpmock.Registry{} + r.Register( + httpmock.REST("GET", "agent-studio/1/providers"), + 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/providers/models/models.go b/pkg/cmd/providers/models/models.go new file mode 100644 index 00000000..05910223 --- /dev/null +++ b/pkg/cmd/providers/models/models.go @@ -0,0 +1,97 @@ +package models + +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" +) + +// ModelsOptions holds the dependencies and flags for the models command. +type ModelsOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + AgentStudioClient func() (*agentStudio.APIClient, error) + PrintFlags *cmdutil.PrintFlags + ProviderID string +} + +// NewModelsCmd returns the `providers models` command. +func NewModelsCmd(f *cmdutil.Factory) *cobra.Command { + opts := &ModelsOptions{ + IO: f.IOStreams, + Config: f.Config, + AgentStudioClient: f.AgentStudioClient, + PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), + } + + cmd := &cobra.Command{ + Use: "models []", + Short: "List models available per provider type, or for a configured provider", + Long: "Without an ID, lists models by provider type. With a provider ID, lists models for that configured provider.", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) > 1 { + return cmdutil.FlagErrorf( + "agentstudio providers models takes an optional argument.", + ) + } + return nil + }, + Annotations: map[string]string{ + "acls": "settings", + }, + Example: heredoc.Doc(` + # List models by provider type + $ algolia agentstudio providers models + + # List models for a configured provider + $ algolia agentstudio providers models 22222222-2222-4222-8222-222222222222 + `), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 1 { + opts.ProviderID = args[0] + } + return runModelsCmd(opts) + }, + } + + opts.PrintFlags.AddFlags(cmd) + return cmd +} + +func runModelsCmd(opts *ModelsOptions) error { + client, err := opts.AgentStudioClient() + if err != nil { + return err + } + + p, err := opts.PrintFlags.ToPrinter() + if err != nil { + return err + } + + if opts.ProviderID != "" { + opts.IO.StartProgressIndicatorWithLabel("Fetching provider models") + res, err := client.ListProviderModels( + client.NewApiListProviderModelsRequest(opts.ProviderID), + ) + if err != nil { + opts.IO.StopProgressIndicator() + return err + } + opts.IO.StopProgressIndicator() + return p.Print(opts.IO, res) + } + + opts.IO.StartProgressIndicatorWithLabel("Fetching models") + res, err := client.ListModels() + if err != nil { + opts.IO.StopProgressIndicator() + return err + } + opts.IO.StopProgressIndicator() + return p.Print(opts.IO, res) +} diff --git a/pkg/cmd/providers/models/models_test.go b/pkg/cmd/providers/models/models_test.go new file mode 100644 index 00000000..8e5532b1 --- /dev/null +++ b/pkg/cmd/providers/models/models_test.go @@ -0,0 +1,57 @@ +package models_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/pkg/cmd/providers/models" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/test" +) + +const ( + providerID = "22222222-2222-4222-8222-222222222222" + catalogPath = "agent-studio/1/providers/models" + providerPath = "agent-studio/1/providers/" + providerID + "/models" + catalogBody = `{"openai":["gpt-4o","gpt-4o-mini"],"anthropic":["claude-3-5-sonnet"]}` + providerBody = `["gpt-4o","gpt-4o-mini"]` +) + +func TestModels_Catalog(t *testing.T) { + r := &httpmock.Registry{} + r.Register(httpmock.REST("GET", catalogPath), httpmock.StringResponse(catalogBody)) + + f, out := test.NewFactory(false, r, nil, "") + cmd := models.NewModelsCmd(f) + _, err := test.Execute(cmd, "", out) + require.NoError(t, err) + + assert.JSONEq(t, catalogBody, strings.TrimSpace(out.String())) + r.Verify(t) +} + +func TestModels_ForProvider(t *testing.T) { + r := &httpmock.Registry{} + r.Register(httpmock.REST("GET", providerPath), httpmock.StringResponse(providerBody)) + + f, out := test.NewFactory(false, r, nil, "") + cmd := models.NewModelsCmd(f) + _, err := test.Execute(cmd, providerID, out) + require.NoError(t, err) + + assert.JSONEq(t, providerBody, strings.TrimSpace(out.String())) + r.Verify(t) +} + +func TestModels_TooManyArgs(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := models.NewModelsCmd(f) + _, err := test.Execute(cmd, providerID+" extra", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "optional ") + assert.Empty(t, r.Requests) +} diff --git a/pkg/cmd/providers/providers.go b/pkg/cmd/providers/providers.go new file mode 100644 index 00000000..28f081b9 --- /dev/null +++ b/pkg/cmd/providers/providers.go @@ -0,0 +1,26 @@ +package providers + +import ( + "github.com/spf13/cobra" + + "github.com/algolia/cli/pkg/cmd/providers/delete" + "github.com/algolia/cli/pkg/cmd/providers/get" + "github.com/algolia/cli/pkg/cmd/providers/list" + "github.com/algolia/cli/pkg/cmd/providers/models" + "github.com/algolia/cli/pkg/cmdutil" +) + +// NewProvidersCmd returns the providers command group. +func NewProvidersCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "providers", + Short: "Manage Agent Studio LLM providers", + Long: "List, get, delete, and inspect models for Agent Studio providers.", + } + + cmd.AddCommand(list.NewListCmd(f)) + cmd.AddCommand(get.NewGetCmd(f)) + cmd.AddCommand(models.NewModelsCmd(f)) + cmd.AddCommand(delete.NewDeleteCmd(f)) + return cmd +} From b98801506502eddff2752311ab767bf42f4cbd8b Mon Sep 17 00:00:00 2001 From: Catalin Marguta Date: Thu, 15 Oct 2026 19:01:28 +0300 Subject: [PATCH 2/2] feat(providers): add create and update via inline --json Create takes the full provider object (name, providerName, input). Update patches name and/or input; provider type cannot change. Co-authored-by: Cursor --- pkg/cmd/providers/create/create.go | 110 +++++++++++++++++++++ pkg/cmd/providers/create/create_test.go | 97 ++++++++++++++++++ pkg/cmd/providers/providers.go | 6 +- pkg/cmd/providers/update/update.go | 126 ++++++++++++++++++++++++ pkg/cmd/providers/update/update_test.go | 113 +++++++++++++++++++++ 5 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 pkg/cmd/providers/create/create.go create mode 100644 pkg/cmd/providers/create/create_test.go create mode 100644 pkg/cmd/providers/update/update.go create mode 100644 pkg/cmd/providers/update/update_test.go diff --git a/pkg/cmd/providers/create/create.go b/pkg/cmd/providers/create/create.go new file mode 100644 index 00000000..13acdc1a --- /dev/null +++ b/pkg/cmd/providers/create/create.go @@ -0,0 +1,110 @@ +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 +} + +// NewCreateCmd returns the `providers 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 LLM provider", + Long: "Creates a provider from a JSON object. Anthropic keys must be scoped to a single workspace; Agent Studio does not send anthropic-workspace-id.", + Annotations: map[string]string{ + "acls": "editSettings", + }, + Example: heredoc.Doc(` + # Create an Anthropic provider + $ algolia agentstudio providers create --json '{"name":"anthropic-prod","providerName":"anthropic","input":{"apiKey":""}}' + + # Create an OpenAI provider + $ algolia agentstudio providers create --json '{"name":"openai-prod","providerName":"openai","input":{"apiKey":""}}' + `), + RunE: func(cmd *cobra.Command, args []string) error { + if opts.JSON == "" { + return cmdutil.FlagErrorf("`--json` is required") + } + return runCreateCmd(opts) + }, + } + + cmd.Flags(). + StringVar(&opts.JSON, "json", "", "Provider JSON object (name, providerName, input)") + + opts.PrintFlags.AddFlags(cmd) + return cmd +} + +func buildProvider(opts *CreateOptions) (*agentStudio.ProviderAuthenticationCreate, error) { + var body agentStudio.ProviderAuthenticationCreate + if err := json.Unmarshal([]byte(opts.JSON), &body); err != nil { + return nil, fmt.Errorf("parsing provider JSON: %w", err) + } + if body.Name == "" || !body.ProviderName.IsValid() { + return nil, cmdutil.FlagErrorf("provider JSON requires `name` and a valid `providerName`") + } + if body.Input.GetActualInstance() == nil { + return nil, cmdutil.FlagErrorf("provider JSON requires `input`") + } + + return &body, nil +} + +func runCreateCmd(opts *CreateOptions) error { + body, err := buildProvider(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 provider") + + res, err := client.CreateProvider(client.NewApiCreateProviderRequest(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 provider %s\n", cs.SuccessIcon(), res.Id) + } + + return p.Print(opts.IO, res) +} diff --git a/pkg/cmd/providers/create/create_test.go b/pkg/cmd/providers/create/create_test.go new file mode 100644 index 00000000..ad9bb4d4 --- /dev/null +++ b/pkg/cmd/providers/create/create_test.go @@ -0,0 +1,97 @@ +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/providers/create" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/test" +) + +const createdProviderBody = `{ + "id": "22222222-2222-4222-8222-222222222222", + "name": "anthropic-prod", + "providerName": "anthropic", + "input": {"apiKey": "sk-test"}, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" +}` + +func TestCreateProvider_FromJSON(t *testing.T) { + r := &httpmock.Registry{} + var gotBody []byte + r.Register( + httpmock.REST("POST", "agent-studio/1/providers"), + func(req *http.Request) (*http.Response, error) { + gotBody, _ = io.ReadAll(req.Body) + return httpmock.StringResponse(createdProviderBody)(req) + }, + ) + + f, out := test.NewFactory(false, r, nil, "") + cmd := create.NewCreateCmd(f) + _, err := test.Execute( + cmd, + `--json '{"name":"anthropic-prod","providerName":"anthropic","input":{"apiKey":"sk-test"}}'`, + out, + ) + require.NoError(t, err) + + assert.JSONEq(t, createdProviderBody, strings.TrimSpace(out.String())) + assert.JSONEq( + t, + `{"name":"anthropic-prod","providerName":"anthropic","input":{"apiKey":"sk-test"}}`, + string(gotBody), + ) + r.Verify(t) +} + +func TestCreateProvider_MissingJSON(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` is required") +} + +func TestCreateProvider_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 provider JSON") +} + +func TestCreateProvider_MissingName(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := create.NewCreateCmd(f) + _, err := test.Execute( + cmd, + `--json '{"providerName":"anthropic","input":{"apiKey":"sk-test"}}'`, + out, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires `name` and a valid `providerName`") +} + +func TestCreateProvider_MissingInput(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := create.NewCreateCmd(f) + _, err := test.Execute( + cmd, + `--json '{"name":"anthropic-prod","providerName":"anthropic"}'`, + out, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires `input`") +} diff --git a/pkg/cmd/providers/providers.go b/pkg/cmd/providers/providers.go index 28f081b9..2dd5e383 100644 --- a/pkg/cmd/providers/providers.go +++ b/pkg/cmd/providers/providers.go @@ -3,10 +3,12 @@ package providers import ( "github.com/spf13/cobra" + "github.com/algolia/cli/pkg/cmd/providers/create" "github.com/algolia/cli/pkg/cmd/providers/delete" "github.com/algolia/cli/pkg/cmd/providers/get" "github.com/algolia/cli/pkg/cmd/providers/list" "github.com/algolia/cli/pkg/cmd/providers/models" + "github.com/algolia/cli/pkg/cmd/providers/update" "github.com/algolia/cli/pkg/cmdutil" ) @@ -15,11 +17,13 @@ func NewProvidersCmd(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "providers", Short: "Manage Agent Studio LLM providers", - Long: "List, get, delete, and inspect models for Agent Studio providers.", + Long: "List, get, create, update, delete, and inspect models for Agent Studio providers.", } cmd.AddCommand(list.NewListCmd(f)) cmd.AddCommand(get.NewGetCmd(f)) + cmd.AddCommand(create.NewCreateCmd(f)) + cmd.AddCommand(update.NewUpdateCmd(f)) cmd.AddCommand(models.NewModelsCmd(f)) cmd.AddCommand(delete.NewDeleteCmd(f)) return cmd diff --git a/pkg/cmd/providers/update/update.go b/pkg/cmd/providers/update/update.go new file mode 100644 index 00000000..9aae0d80 --- /dev/null +++ b/pkg/cmd/providers/update/update.go @@ -0,0 +1,126 @@ +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 + + ProviderID string + JSON string + Name string +} + +// NewUpdateCmd returns the `providers 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 LLM provider", + Long: "Patches name and/or input. providerName cannot be changed.", + Args: validators.ExactArgsWithMsg( + 1, + "agentstudio providers update requires a argument.", + ), + Annotations: map[string]string{ + "acls": "editSettings", + }, + Example: heredoc.Doc(` + # Rename a provider + $ algolia agentstudio providers update 22222222-2222-4222-8222-222222222222 --name "anthropic-prod" + + # Rotate the API key + $ algolia agentstudio providers update 22222222-2222-4222-8222-222222222222 --json '{"input":{"apiKey":""}}' + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.ProviderID = args[0] + + if opts.JSON != "" { + if opts.Name != "" { + return cmdutil.FlagErrorf("--json cannot be combined with --name") + } + } else if opts.Name == "" { + return cmdutil.FlagErrorf("`--json` or `--name` is required") + } + + return runUpdateCmd(opts) + }, + } + + cmd.Flags().StringVar(&opts.JSON, "json", "", "Provider JSON patch object (name and/or input)") + cmd.Flags().StringVar(&opts.Name, "name", "", "Provider display name") + + opts.PrintFlags.AddFlags(cmd) + return cmd +} + +func buildPatch(opts *UpdateOptions) (*agentStudio.ProviderAuthenticationPatch, error) { + if opts.JSON == "" { + return agentStudio.NewEmptyProviderAuthenticationPatch().SetName(opts.Name), nil + } + + var body agentStudio.ProviderAuthenticationPatch + if err := json.Unmarshal([]byte(opts.JSON), &body); err != nil { + return nil, fmt.Errorf("parsing provider JSON: %w", err) + } + if !body.HasName() && !body.HasInput() { + return nil, cmdutil.FlagErrorf("provider JSON requires `name` and/or `input`") + } + + 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 provider") + + res, err := client.UpdateProvider(client.NewApiUpdateProviderRequest(opts.ProviderID, 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 provider %s\n", cs.SuccessIcon(), opts.ProviderID) + } + + return p.Print(opts.IO, res) +} diff --git a/pkg/cmd/providers/update/update_test.go b/pkg/cmd/providers/update/update_test.go new file mode 100644 index 00000000..5476c00c --- /dev/null +++ b/pkg/cmd/providers/update/update_test.go @@ -0,0 +1,113 @@ +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/providers/update" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/test" +) + +const ( + providerID = "22222222-2222-4222-8222-222222222222" + updatePath = "agent-studio/1/providers/" + providerID + updatedProviderBody = `{ + "id": "22222222-2222-4222-8222-222222222222", + "name": "anthropic-prod", + "providerName": "anthropic", + "input": {"apiKey": "sk-test"}, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" +}` +) + +func TestUpdateProvider_FromFlags(t *testing.T) { + r := &httpmock.Registry{} + var gotBody []byte + r.Register(httpmock.REST("PATCH", updatePath), func(req *http.Request) (*http.Response, error) { + gotBody, _ = io.ReadAll(req.Body) + return httpmock.StringResponse(updatedProviderBody)(req) + }) + + f, out := test.NewFactory(false, r, nil, "") + cmd := update.NewUpdateCmd(f) + _, err := test.Execute(cmd, providerID+` --name anthropic-prod`, out) + require.NoError(t, err) + + assert.JSONEq(t, updatedProviderBody, strings.TrimSpace(out.String())) + assert.JSONEq(t, `{"name":"anthropic-prod"}`, string(gotBody)) + r.Verify(t) +} + +func TestUpdateProvider_FromJSON(t *testing.T) { + r := &httpmock.Registry{} + var gotBody []byte + r.Register(httpmock.REST("PATCH", updatePath), func(req *http.Request) (*http.Response, error) { + gotBody, _ = io.ReadAll(req.Body) + return httpmock.StringResponse(updatedProviderBody)(req) + }) + + f, out := test.NewFactory(false, r, nil, "") + cmd := update.NewUpdateCmd(f) + _, err := test.Execute(cmd, providerID+` --json '{"input":{"apiKey":"sk-test"}}'`, out) + require.NoError(t, err) + + assert.JSONEq(t, updatedProviderBody, strings.TrimSpace(out.String())) + assert.Contains(t, string(gotBody), `"apiKey":"sk-test"`) + r.Verify(t) +} + +func TestUpdateProvider_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 providers update requires a argument", + ) +} + +func TestUpdateProvider_MissingInput(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := update.NewUpdateCmd(f) + _, err := test.Execute(cmd, providerID, out) + require.Error(t, err) + assert.Contains(t, err.Error(), "`--json` or `--name` is required") +} + +func TestUpdateProvider_JSONAndFlagsConflict(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := update.NewUpdateCmd(f) + _, err := test.Execute(cmd, providerID+` --json '{"name":"x"}' --name x`, out) + require.Error(t, err) + assert.Contains(t, err.Error(), "--json cannot be combined") +} + +func TestUpdateProvider_EmptyJSON(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := update.NewUpdateCmd(f) + _, err := test.Execute(cmd, providerID+` --json '{}'`, out) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires `name` and/or `input`") +} + +func TestUpdateProvider_InvalidJSON(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := update.NewUpdateCmd(f) + _, err := test.Execute(cmd, providerID+" --json not-json", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "parsing provider JSON") +}