diff --git a/pkg/cmd/agents/create/create.go b/pkg/cmd/agents/create/create.go index 0f122814..d2b093c2 100644 --- a/pkg/cmd/agents/create/create.go +++ b/pkg/cmd/agents/create/create.go @@ -21,6 +21,7 @@ type CreateOptions struct { PrintFlags *cmdutil.PrintFlags JSON string + File string Name string Instructions string } @@ -47,23 +48,36 @@ func NewCreateCmd(f *cmdutil.Factory) *cobra.Command { # Create from a JSON object $ algolia agentstudio agents create --json '{"name":"support-bot","instructions":"You are a helpful assistant."}' + # Create from a JSON file + $ algolia agentstudio agents create --file agent.json + + # Create from stdin + $ cat agent.json | algolia agentstudio agents create --file - + # 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") + hasPayload := opts.JSON != "" || opts.File != "" + hasFlags := opts.Name != "" || opts.Instructions != "" + if hasPayload && hasFlags { + return cmdutil.FlagErrorf( + "--json and --file cannot be combined with --name or --instructions", + ) + } + if !hasPayload && (opts.Name == "" || opts.Instructions == "") { + return cmdutil.FlagErrorf( + "`--json`, `--file`, 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.JSON, "json", "", "Agent JSON object (include providerId and model to publish later)") + cmd.Flags().StringVarP(&opts.File, "file", "f", "", "JSON file path (use - for stdin)") cmd.Flags().StringVar(&opts.Name, "name", "", "Agent name") cmd.Flags().StringVar(&opts.Instructions, "instructions", "", "Agent prompt") @@ -72,14 +86,18 @@ func NewCreateCmd(f *cmdutil.Factory) *cobra.Command { } func buildAgent(opts *CreateOptions) (*agentStudio.AgentConfigCreate, error) { - if opts.JSON == "" { + raw, err := cmdutil.JSONPayload(opts.JSON, opts.File, opts.IO.In) + if err != nil { + return nil, err + } + if raw == "" { return agentStudio.NewEmptyAgentConfigCreate(). SetName(opts.Name). SetInstructions(opts.Instructions), nil } var body agentStudio.AgentConfigCreate - if err := json.Unmarshal([]byte(opts.JSON), &body); err != nil { + if err := json.Unmarshal([]byte(raw), &body); err != nil { return nil, fmt.Errorf("parsing agent JSON: %w", err) } if body.Name == "" || body.Instructions == "" { diff --git a/pkg/cmd/agents/create/create_test.go b/pkg/cmd/agents/create/create_test.go index 08a9903a..763bc5e3 100644 --- a/pkg/cmd/agents/create/create_test.go +++ b/pkg/cmd/agents/create/create_test.go @@ -3,6 +3,8 @@ package create_test import ( "io" "net/http" + "os" + "path/filepath" "strings" "testing" @@ -30,28 +32,42 @@ const createdAgentBody = `{ 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) - }) + 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) + _, 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)) + 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) - }) + 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) @@ -71,22 +87,106 @@ func TestCreateAgent_FromJSON(t *testing.T) { r.Verify(t) } +func TestCreateAgent_FromFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "agent.json") + require.NoError( + t, + os.WriteFile( + path, + []byte( + `{"name":"cli-test-agent","instructions":"You are a helpful assistant.","model":"gpt-4o"}`, + ), + 0o600, + ), + ) + + 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, "--file "+path, 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_FromStdin(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, + `{"name":"cli-test-agent","instructions":"You are a helpful assistant."}`, + ) + cmd := create.NewCreateCmd(f) + _, err := test.Execute(cmd, "--file -", 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_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") + assert.Contains( + t, + err.Error(), + "`--json`, `--file`, 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) + _, err := test.Execute( + cmd, + `--json '{"name":"x","instructions":"y"}' --name x --instructions y`, + out, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot be combined with --name or --instructions") +} + +func TestCreateAgent_JSONAndFileConflict(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"}' --file agent.json`, out) require.Error(t, err) - assert.Contains(t, err.Error(), "--json cannot be combined") + assert.Contains(t, err.Error(), "--json cannot be combined with --file") } func TestCreateAgent_InvalidJSON(t *testing.T) { diff --git a/pkg/cmd/agents/update/update.go b/pkg/cmd/agents/update/update.go index c5b0f492..c5c30a61 100644 --- a/pkg/cmd/agents/update/update.go +++ b/pkg/cmd/agents/update/update.go @@ -23,6 +23,7 @@ type UpdateOptions struct { AgentID string JSON string + File string Name string Instructions string } @@ -39,7 +40,10 @@ func NewUpdateCmd(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "update ", Short: "Update an Agent Studio agent", - Args: validators.ExactArgsWithMsg(1, "agentstudio agents update requires a argument."), + Args: validators.ExactArgsWithMsg( + 1, + "agentstudio agents update requires a argument.", + ), Annotations: map[string]string{ "acls": "editSettings", }, @@ -49,16 +53,24 @@ func NewUpdateCmd(f *cmdutil.Factory) *cobra.Command { # Update from a JSON object $ algolia agentstudio agents update 11111111-1111-4111-8111-111111111111 --json '{"name":"renamed-bot","instructions":"Be brief."}' + + # Update from a JSON file + $ algolia agentstudio agents update 11111111-1111-4111-8111-111111111111 --file patch.json `), 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") + hasPayload := opts.JSON != "" || opts.File != "" + hasFlags := opts.Name != "" || opts.Instructions != "" + if hasPayload && hasFlags { + return cmdutil.FlagErrorf( + "--json and --file cannot be combined with --name or --instructions", + ) + } + if !hasPayload && !hasFlags { + return cmdutil.FlagErrorf( + "`--json`, `--file`, or at least one of `--name` or `--instructions` is required", + ) } return runUpdateCmd(opts) @@ -66,6 +78,7 @@ func NewUpdateCmd(f *cmdutil.Factory) *cobra.Command { } cmd.Flags().StringVar(&opts.JSON, "json", "", "Agent JSON patch object") + cmd.Flags().StringVarP(&opts.File, "file", "f", "", "JSON file path (use - for stdin)") cmd.Flags().StringVar(&opts.Name, "name", "", "Agent name") cmd.Flags().StringVar(&opts.Instructions, "instructions", "", "Agent prompt") @@ -74,7 +87,11 @@ func NewUpdateCmd(f *cmdutil.Factory) *cobra.Command { } func buildPatch(opts *UpdateOptions) (*agentStudio.AgentConfigUpdate, error) { - if opts.JSON == "" { + raw, err := cmdutil.JSONPayload(opts.JSON, opts.File, opts.IO.In) + if err != nil { + return nil, err + } + if raw == "" { body := agentStudio.NewEmptyAgentConfigUpdate() if opts.Name != "" { body.SetName(opts.Name) @@ -86,7 +103,7 @@ func buildPatch(opts *UpdateOptions) (*agentStudio.AgentConfigUpdate, error) { } var body agentStudio.AgentConfigUpdate - if err := json.Unmarshal([]byte(opts.JSON), &body); err != nil { + if err := json.Unmarshal([]byte(raw), &body); err != nil { return nil, fmt.Errorf("parsing agent JSON: %w", err) } diff --git a/pkg/cmd/agents/update/update_test.go b/pkg/cmd/agents/update/update_test.go index c59cf6dd..20d9be49 100644 --- a/pkg/cmd/agents/update/update_test.go +++ b/pkg/cmd/agents/update/update_test.go @@ -3,6 +3,8 @@ package update_test import ( "io" "net/http" + "os" + "path/filepath" "strings" "testing" @@ -30,10 +32,13 @@ const updatedAgentBody = `{ 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) - }) + 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) @@ -48,14 +53,21 @@ func TestUpdateAgent_FromFlags(t *testing.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) - }) + 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) + _, 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())) @@ -72,13 +84,44 @@ func TestUpdateAgent_MissingArg(t *testing.T) { assert.Contains(t, err.Error(), "agentstudio agents update requires a argument") } +func TestUpdateAgent_FromFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "patch.json") + require.NoError( + t, + os.WriteFile(path, []byte(`{"name":"renamed-bot","instructions":"Be brief."}`), 0o600), + ) + + 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 --file "+path, 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_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") + assert.Contains( + t, + err.Error(), + "`--json`, `--file`, or at least one of `--name` or `--instructions` is required", + ) } func TestUpdateAgent_JSONAndFlagsConflict(t *testing.T) { @@ -87,7 +130,16 @@ func TestUpdateAgent_JSONAndFlagsConflict(t *testing.T) { 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") + assert.Contains(t, err.Error(), "cannot be combined with --name or --instructions") +} + +func TestUpdateAgent_JSONAndFileConflict(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"}' --file patch.json`, out) + require.Error(t, err) + assert.Contains(t, err.Error(), "--json cannot be combined with --file") } func TestUpdateAgent_InvalidJSON(t *testing.T) { diff --git a/pkg/cmd/providers/create/create.go b/pkg/cmd/providers/create/create.go index 13acdc1a..149e5dcd 100644 --- a/pkg/cmd/providers/create/create.go +++ b/pkg/cmd/providers/create/create.go @@ -20,6 +20,7 @@ type CreateOptions struct { AgentStudioClient func() (*agentStudio.APIClient, error) PrintFlags *cmdutil.PrintFlags JSON string + File string } // NewCreateCmd returns the `providers create` command. @@ -44,10 +45,13 @@ func NewCreateCmd(f *cmdutil.Factory) *cobra.Command { # Create an OpenAI provider $ algolia agentstudio providers create --json '{"name":"openai-prod","providerName":"openai","input":{"apiKey":""}}' + + # Create from a JSON file + $ algolia agentstudio providers create --file provider.json `), RunE: func(cmd *cobra.Command, args []string) error { - if opts.JSON == "" { - return cmdutil.FlagErrorf("`--json` is required") + if opts.JSON == "" && opts.File == "" { + return cmdutil.FlagErrorf("`--json` or `--file` is required") } return runCreateCmd(opts) }, @@ -55,14 +59,20 @@ func NewCreateCmd(f *cmdutil.Factory) *cobra.Command { cmd.Flags(). StringVar(&opts.JSON, "json", "", "Provider JSON object (name, providerName, input)") + cmd.Flags().StringVarP(&opts.File, "file", "f", "", "JSON file path (use - for stdin)") opts.PrintFlags.AddFlags(cmd) return cmd } func buildProvider(opts *CreateOptions) (*agentStudio.ProviderAuthenticationCreate, error) { + raw, err := cmdutil.JSONPayload(opts.JSON, opts.File, opts.IO.In) + if err != nil { + return nil, err + } + var body agentStudio.ProviderAuthenticationCreate - if err := json.Unmarshal([]byte(opts.JSON), &body); err != nil { + if err := json.Unmarshal([]byte(raw), &body); err != nil { return nil, fmt.Errorf("parsing provider JSON: %w", err) } if body.Name == "" || !body.ProviderName.IsValid() { diff --git a/pkg/cmd/providers/create/create_test.go b/pkg/cmd/providers/create/create_test.go index ad9bb4d4..3901626c 100644 --- a/pkg/cmd/providers/create/create_test.go +++ b/pkg/cmd/providers/create/create_test.go @@ -3,6 +3,8 @@ package create_test import ( "io" "net/http" + "os" + "path/filepath" "strings" "testing" @@ -52,13 +54,89 @@ func TestCreateProvider_FromJSON(t *testing.T) { r.Verify(t) } +func TestCreateProvider_FromFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "provider.json") + require.NoError( + t, + os.WriteFile( + path, + []byte( + `{"name":"anthropic-prod","providerName":"anthropic","input":{"apiKey":"sk-test"}}`, + ), + 0o600, + ), + ) + + 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, "--file "+path, 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_FromStdin(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, + `{"name":"anthropic-prod","providerName":"anthropic","input":{"apiKey":"sk-test"}}`, + ) + cmd := create.NewCreateCmd(f) + _, err := test.Execute(cmd, "--file -", 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") + assert.Contains(t, err.Error(), "`--json` or `--file` is required") +} + +func TestCreateProvider_JSONAndFileConflict(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := create.NewCreateCmd(f) + _, err := test.Execute(cmd, `--json '{"name":"x"}' --file provider.json`, out) + require.Error(t, err) + assert.Contains(t, err.Error(), "--json cannot be combined with --file") } func TestCreateProvider_InvalidJSON(t *testing.T) { diff --git a/pkg/cmd/providers/update/update.go b/pkg/cmd/providers/update/update.go index 9aae0d80..0129954c 100644 --- a/pkg/cmd/providers/update/update.go +++ b/pkg/cmd/providers/update/update.go @@ -23,6 +23,7 @@ type UpdateOptions struct { ProviderID string JSON string + File string Name string } @@ -52,16 +53,19 @@ func NewUpdateCmd(f *cmdutil.Factory) *cobra.Command { # Rotate the API key $ algolia agentstudio providers update 22222222-2222-4222-8222-222222222222 --json '{"input":{"apiKey":""}}' + + # Patch from a JSON file + $ algolia agentstudio providers update 22222222-2222-4222-8222-222222222222 --file patch.json `), 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") + hasPayload := opts.JSON != "" || opts.File != "" + if hasPayload && opts.Name != "" { + return cmdutil.FlagErrorf("--json and --file cannot be combined with --name") + } + if !hasPayload && opts.Name == "" { + return cmdutil.FlagErrorf("`--json`, `--file`, or `--name` is required") } return runUpdateCmd(opts) @@ -69,6 +73,7 @@ func NewUpdateCmd(f *cmdutil.Factory) *cobra.Command { } cmd.Flags().StringVar(&opts.JSON, "json", "", "Provider JSON patch object (name and/or input)") + cmd.Flags().StringVarP(&opts.File, "file", "f", "", "JSON file path (use - for stdin)") cmd.Flags().StringVar(&opts.Name, "name", "", "Provider display name") opts.PrintFlags.AddFlags(cmd) @@ -76,12 +81,16 @@ func NewUpdateCmd(f *cmdutil.Factory) *cobra.Command { } func buildPatch(opts *UpdateOptions) (*agentStudio.ProviderAuthenticationPatch, error) { - if opts.JSON == "" { + raw, err := cmdutil.JSONPayload(opts.JSON, opts.File, opts.IO.In) + if err != nil { + return nil, err + } + if raw == "" { return agentStudio.NewEmptyProviderAuthenticationPatch().SetName(opts.Name), nil } var body agentStudio.ProviderAuthenticationPatch - if err := json.Unmarshal([]byte(opts.JSON), &body); err != nil { + if err := json.Unmarshal([]byte(raw), &body); err != nil { return nil, fmt.Errorf("parsing provider JSON: %w", err) } if !body.HasName() && !body.HasInput() { diff --git a/pkg/cmd/providers/update/update_test.go b/pkg/cmd/providers/update/update_test.go index 5476c00c..deacf892 100644 --- a/pkg/cmd/providers/update/update_test.go +++ b/pkg/cmd/providers/update/update_test.go @@ -3,6 +3,8 @@ package update_test import ( "io" "net/http" + "os" + "path/filepath" "strings" "testing" @@ -76,13 +78,34 @@ func TestUpdateProvider_MissingArg(t *testing.T) { ) } +func TestUpdateProvider_FromFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "patch.json") + require.NoError(t, os.WriteFile(path, []byte(`{"input":{"apiKey":"sk-test"}}`), 0o600)) + + 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+" --file "+path, 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_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") + assert.Contains(t, err.Error(), "`--json`, `--file`, or `--name` is required") } func TestUpdateProvider_JSONAndFlagsConflict(t *testing.T) { @@ -91,7 +114,16 @@ func TestUpdateProvider_JSONAndFlagsConflict(t *testing.T) { 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") + assert.Contains(t, err.Error(), "cannot be combined with --name") +} + +func TestUpdateProvider_JSONAndFileConflict(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"}' --file patch.json`, out) + require.Error(t, err) + assert.Contains(t, err.Error(), "--json cannot be combined with --file") } func TestUpdateProvider_EmptyJSON(t *testing.T) { diff --git a/pkg/cmdutil/file_input.go b/pkg/cmdutil/file_input.go index 467010d1..563ad27e 100644 --- a/pkg/cmdutil/file_input.go +++ b/pkg/cmdutil/file_input.go @@ -2,6 +2,8 @@ package cmdutil import ( "bufio" + "bytes" + "fmt" "io" "os" ) @@ -18,6 +20,24 @@ func ReadFile(filename string, stdin io.ReadCloser) ([]byte, error) { return os.ReadFile(filename) } +// JSONPayload returns inline --json or the contents of --file (use "-" for stdin). +// The two sources are mutually exclusive. Both empty returns "", nil. +func JSONPayload(jsonStr, filename string, stdin io.ReadCloser) (string, error) { + if jsonStr != "" && filename != "" { + return "", FlagErrorf("--json cannot be combined with --file") + } + if filename == "" { + return jsonStr, nil + } + + b, err := ReadFile(filename, stdin) + if err != nil { + return "", fmt.Errorf("reading file: %w", err) + } + + return string(bytes.TrimSpace(b)), nil +} + func ScanFile(filename string, stdin io.ReadCloser) (*bufio.Scanner, error) { var scanner *bufio.Scanner diff --git a/pkg/cmdutil/file_input_test.go b/pkg/cmdutil/file_input_test.go new file mode 100644 index 00000000..5437425a --- /dev/null +++ b/pkg/cmdutil/file_input_test.go @@ -0,0 +1,51 @@ +package cmdutil + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJSONPayload_Inline(t *testing.T) { + got, err := JSONPayload(`{"a":1}`, "", nil) + require.NoError(t, err) + assert.Equal(t, `{"a":1}`, got) +} + +func TestJSONPayload_File(t *testing.T) { + path := filepath.Join(t.TempDir(), "payload.json") + require.NoError(t, os.WriteFile(path, []byte(" {\"a\":1}\n"), 0o600)) + + got, err := JSONPayload("", path, nil) + require.NoError(t, err) + assert.Equal(t, `{"a":1}`, got) +} + +func TestJSONPayload_Stdin(t *testing.T) { + got, err := JSONPayload("", "-", io.NopCloser(strings.NewReader("{\"a\":1}\n"))) + require.NoError(t, err) + assert.Equal(t, `{"a":1}`, got) +} + +func TestJSONPayload_Empty(t *testing.T) { + got, err := JSONPayload("", "", nil) + require.NoError(t, err) + assert.Equal(t, "", got) +} + +func TestJSONPayload_Conflict(t *testing.T) { + _, err := JSONPayload(`{"a":1}`, "payload.json", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "--json cannot be combined with --file") +} + +func TestJSONPayload_MissingFile(t *testing.T) { + _, err := JSONPayload("", filepath.Join(t.TempDir(), "missing.json"), nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "reading file") +}