From 8fcd5fbbb47b32468d0ee4da7b109529b8baef62 Mon Sep 17 00:00:00 2001 From: Cody Hartsook Date: Mon, 24 Aug 2026 15:41:26 -0700 Subject: [PATCH 01/10] feat(cli): add v2 agent instance clients Signed-off-by: Cody Hartsook --- go/api/a2a/routing.go | 9 + go/api/client/a2a.go | 85 +++++++ go/api/client/agentinstance.go | 89 +++++++ go/api/client/agentinstancegrpc_test.go | 145 +++++++++++ go/api/client/clientset.go | 4 + go/api/client/grpc.go | 5 +- go/core/cli/cmd/kagent/main.go | 72 +++--- go/core/cli/cmd/kagent/main_test.go | 67 +++++ go/core/cli/internal/cli/agent/install.go | 9 +- go/core/cli/internal/cli/agent/invoke.go | 19 +- go/core/cli/internal/cli/agent/invoke_test.go | 2 +- go/core/cli/internal/cli/agent/utils.go | 69 ----- .../cli/internal/cli/connection/connection.go | 212 ++++++++++++++++ .../cli/connection/connection_test.go | 238 ++++++++++++++++++ go/core/cli/internal/config/config.go | 53 ++-- go/core/v2/a2agateway/gateway.go | 26 +- go/core/v2/a2agateway/gateway_test.go | 24 +- 17 files changed, 964 insertions(+), 164 deletions(-) create mode 100644 go/api/a2a/routing.go create mode 100644 go/api/client/a2a.go create mode 100644 go/api/client/agentinstance.go create mode 100644 go/api/client/agentinstancegrpc_test.go create mode 100644 go/core/cli/internal/cli/connection/connection.go create mode 100644 go/core/cli/internal/cli/connection/connection_test.go diff --git a/go/api/a2a/routing.go b/go/api/a2a/routing.go new file mode 100644 index 000000000..14b408f62 --- /dev/null +++ b/go/api/a2a/routing.go @@ -0,0 +1,9 @@ +// Package a2a defines kagent's public A2A routing contract. +package a2a + +const ( + // AgentInstanceNamespaceHeader selects the Kubernetes namespace containing the AgentInstance. + AgentInstanceNamespaceHeader = "x-kagent-agent-instance-namespace" + // AgentInstanceIDHeader selects the AgentInstance within that namespace. + AgentInstanceIDHeader = "x-kagent-agent-instance-id" +) diff --git a/go/api/client/a2a.go b/go/api/client/a2a.go new file mode 100644 index 000000000..569bafc39 --- /dev/null +++ b/go/api/client/a2a.go @@ -0,0 +1,85 @@ +package client + +import ( + "context" + "time" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" + a2agrpc "github.com/a2aproject/a2a-go/v2/a2agrpc/v1" + a2apb "github.com/a2aproject/a2a-go/v2/a2apb/v1" + kagenta2a "github.com/kagent-dev/kagent/go/api/a2a" +) + +const userIDHeader = "x-user-id" + +// A2AClient creates upstream A2A clients routed to an AgentInstance. +type A2AClient struct { + client *BaseClient +} + +// NewA2AClient creates an A2A client factory over the shared gRPC connection. +func NewA2AClient(client *BaseClient) *A2AClient { + return &A2AClient{client: client} +} + +// ForAgentInstance creates an upstream A2A client routed to one AgentInstance. +func (c *A2AClient) ForAgentInstance(ctx context.Context, namespace, id string) (*a2aclient.Client, error) { + connection, err := c.client.grpcConnection() + if err != nil { + return nil, err + } + transport := a2agrpc.NewGRPCTransportFromClient(a2apb.NewA2AServiceClient(connection)) + return a2aclient.NewFromEndpoints(ctx, []*a2atype.AgentInterface{{ + URL: c.client.grpc.target, + ProtocolBinding: a2atype.TransportProtocolGRPC, + ProtocolVersion: a2atype.Version, + }}, + a2aclient.WithDefaultsDisabled(), + a2aclient.WithTransport(a2atype.TransportProtocolGRPC, a2aclient.TransportFactoryFn( + func(context.Context, *a2atype.AgentCard, *a2atype.AgentInterface) (a2aclient.Transport, error) { + return transport, nil + }, + )), + a2aclient.WithCallInterceptors(&agentInstanceRoutingInterceptor{ + namespace: namespace, + id: id, + userID: c.client.UserID, + timeout: c.client.grpc.timeout, + }), + ) +} + +type cancelCallContextKey struct{} + +type agentInstanceRoutingInterceptor struct { + a2aclient.PassthroughInterceptor + namespace string + id string + userID string + timeout time.Duration +} + +func (i *agentInstanceRoutingInterceptor) Before(ctx context.Context, request *a2aclient.Request) (context.Context, any, error) { + request.ServiceParams.Append(kagenta2a.AgentInstanceNamespaceHeader, i.namespace) + request.ServiceParams.Append(kagenta2a.AgentInstanceIDHeader, i.id) + if i.userID != "" { + request.ServiceParams.Append(userIDHeader, i.userID) + } + if i.timeout <= 0 || isStreamingA2AMethod(request.Method) { + return ctx, nil, nil + } + callContext, cancel := context.WithTimeout(ctx, i.timeout) + return context.WithValue(callContext, cancelCallContextKey{}, cancel), nil, nil +} + +func (i *agentInstanceRoutingInterceptor) After(ctx context.Context, _ *a2aclient.Response) error { + if cancel, ok := ctx.Value(cancelCallContextKey{}).(context.CancelFunc); ok { + cancel() + } + return nil +} + +func isStreamingA2AMethod(method string) bool { + return method == "SendStreamingMessage" || method == "SubscribeToTask" +} diff --git a/go/api/client/agentinstance.go b/go/api/client/agentinstance.go new file mode 100644 index 000000000..47465a1f6 --- /dev/null +++ b/go/api/client/agentinstance.go @@ -0,0 +1,89 @@ +package client + +import ( + "context" + + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" +) + +// AgentInstance provides AgentInstance lifecycle operations. +type AgentInstance interface { + CreateAgentInstance(context.Context, *apiv1alpha1.CreateAgentInstanceRequest) (*apiv1alpha1.CreateAgentInstanceResponse, error) + GetAgentInstance(context.Context, *apiv1alpha1.GetAgentInstanceRequest) (*apiv1alpha1.GetAgentInstanceResponse, error) + ListAgentInstances(context.Context, *apiv1alpha1.ListAgentInstancesRequest) (*apiv1alpha1.ListAgentInstancesResponse, error) + SuspendAgentInstance(context.Context, *apiv1alpha1.SuspendAgentInstanceRequest) (*apiv1alpha1.SuspendAgentInstanceResponse, error) + ResumeAgentInstance(context.Context, *apiv1alpha1.ResumeAgentInstanceRequest) (*apiv1alpha1.ResumeAgentInstanceResponse, error) + DeleteAgentInstance(context.Context, *apiv1alpha1.DeleteAgentInstanceRequest) (*apiv1alpha1.DeleteAgentInstanceResponse, error) +} + +type agentInstanceClient struct { + client *BaseClient +} + +// NewAgentInstanceClient creates an AgentInstance client over the shared gRPC connection. +func NewAgentInstanceClient(client *BaseClient) AgentInstance { + return &agentInstanceClient{client: client} +} + +func (c *agentInstanceClient) CreateAgentInstance(ctx context.Context, request *apiv1alpha1.CreateAgentInstanceRequest) (*apiv1alpha1.CreateAgentInstanceResponse, error) { + client, callContext, cancel, err := c.client.agentInstanceCall(ctx) + if err != nil { + return nil, err + } + defer cancel() + return client.CreateAgentInstance(callContext, request) +} + +func (c *agentInstanceClient) GetAgentInstance(ctx context.Context, request *apiv1alpha1.GetAgentInstanceRequest) (*apiv1alpha1.GetAgentInstanceResponse, error) { + client, callContext, cancel, err := c.client.agentInstanceCall(ctx) + if err != nil { + return nil, err + } + defer cancel() + return client.GetAgentInstance(callContext, request) +} + +func (c *agentInstanceClient) ListAgentInstances(ctx context.Context, request *apiv1alpha1.ListAgentInstancesRequest) (*apiv1alpha1.ListAgentInstancesResponse, error) { + client, callContext, cancel, err := c.client.agentInstanceCall(ctx) + if err != nil { + return nil, err + } + defer cancel() + return client.ListAgentInstances(callContext, request) +} + +func (c *agentInstanceClient) SuspendAgentInstance(ctx context.Context, request *apiv1alpha1.SuspendAgentInstanceRequest) (*apiv1alpha1.SuspendAgentInstanceResponse, error) { + client, callContext, cancel, err := c.client.agentInstanceCall(ctx) + if err != nil { + return nil, err + } + defer cancel() + return client.SuspendAgentInstance(callContext, request) +} + +func (c *agentInstanceClient) ResumeAgentInstance(ctx context.Context, request *apiv1alpha1.ResumeAgentInstanceRequest) (*apiv1alpha1.ResumeAgentInstanceResponse, error) { + client, callContext, cancel, err := c.client.agentInstanceCall(ctx) + if err != nil { + return nil, err + } + defer cancel() + return client.ResumeAgentInstance(callContext, request) +} + +func (c *agentInstanceClient) DeleteAgentInstance(ctx context.Context, request *apiv1alpha1.DeleteAgentInstanceRequest) (*apiv1alpha1.DeleteAgentInstanceResponse, error) { + client, callContext, cancel, err := c.client.agentInstanceCall(ctx) + if err != nil { + return nil, err + } + defer cancel() + return client.DeleteAgentInstance(callContext, request) +} + +func (c *BaseClient) agentInstanceCall(ctx context.Context) (apiv1alpha1.AgentInstanceServiceClient, context.Context, context.CancelFunc, error) { + connection, err := c.grpcConnection() + if err != nil { + return nil, nil, nil, err + } + callContext, cancel := c.grpcCallContext(ctx) + return apiv1alpha1.NewAgentInstanceServiceClient(connection), callContext, cancel, nil +} diff --git a/go/api/client/agentinstancegrpc_test.go b/go/api/client/agentinstancegrpc_test.go new file mode 100644 index 000000000..6c30e9115 --- /dev/null +++ b/go/api/client/agentinstancegrpc_test.go @@ -0,0 +1,145 @@ +package client + +import ( + "context" + "net" + "sync" + "sync/atomic" + "testing" + "time" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + a2apb "github.com/a2aproject/a2a-go/v2/a2apb/v1" + "github.com/a2aproject/a2a-go/v2/a2apb/v1/pbconv" + kagenta2a "github.com/kagent-dev/kagent/go/api/a2a" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/test/bufconn" +) + +const agentInstanceClientTestID = "8bd650a8-9775-488f-8bc1-0d52bf7bdcab" + +type recordingAgentInstanceService struct { + apiv1alpha1.UnimplementedAgentInstanceServiceServer + observation callObservation +} + +func (s *recordingAgentInstanceService) CreateAgentInstance(ctx context.Context, _ *apiv1alpha1.CreateAgentInstanceRequest) (*apiv1alpha1.CreateAgentInstanceResponse, error) { + values, _ := metadata.FromIncomingContext(ctx) + _, hasDeadline := ctx.Deadline() + s.observation = callObservation{userID: first(values.Get(userIDHeader)), hasDeadline: hasDeadline} + return &apiv1alpha1.CreateAgentInstanceResponse{}, nil +} + +type a2aCallObservation struct { + namespace string + id string + userID string + hasDeadline bool +} + +type recordingA2AService struct { + a2apb.UnimplementedA2AServiceServer + mu sync.Mutex + observations []a2aCallObservation +} + +func (s *recordingA2AService) SendMessage(ctx context.Context, _ *a2apb.SendMessageRequest) (*a2apb.SendMessageResponse, error) { + s.observe(ctx) + return pbconv.ToProtoSendMessageResponse(a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.NewTextPart("hello"))) +} + +func (s *recordingA2AService) SendStreamingMessage(_ *a2apb.SendMessageRequest, stream grpc.ServerStreamingServer[a2apb.StreamResponse]) error { + s.observe(stream.Context()) + response, err := pbconv.ToProtoStreamResponse(a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.NewTextPart("hello"))) + if err != nil { + return err + } + return stream.Send(response) +} + +func (s *recordingA2AService) SubscribeToTask(_ *a2apb.SubscribeToTaskRequest, stream grpc.ServerStreamingServer[a2apb.StreamResponse]) error { + s.observe(stream.Context()) + response, err := pbconv.ToProtoStreamResponse(a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.NewTextPart("hello"))) + if err != nil { + return err + } + return stream.Send(response) +} + +func (s *recordingA2AService) observe(ctx context.Context) { + values, _ := metadata.FromIncomingContext(ctx) + _, hasDeadline := ctx.Deadline() + s.mu.Lock() + defer s.mu.Unlock() + s.observations = append(s.observations, a2aCallObservation{ + namespace: first(values.Get(kagenta2a.AgentInstanceNamespaceHeader)), + id: first(values.Get(kagenta2a.AgentInstanceIDHeader)), + userID: first(values.Get(userIDHeader)), + hasDeadline: hasDeadline, + }) +} + +func TestAgentInstanceAndA2AClientsShareGRPCConnection(t *testing.T) { + listener := bufconn.Listen(1024 * 1024) + agentInstanceService := &recordingAgentInstanceService{} + a2aService := &recordingA2AService{} + server := grpc.NewServer() + apiv1alpha1.RegisterAgentInstanceServiceServer(server, agentInstanceService) + a2apb.RegisterA2AServiceServer(server, a2aService) + go func() { _ = server.Serve(listener) }() + t.Cleanup(func() { + server.Stop() + _ = listener.Close() + }) + + var dialCount atomic.Int32 + clientSet := New( + "http://rest-must-not-be-used.invalid", + WithUserID("caller"), + WithGRPCTarget("passthrough:///bufnet"), + WithGRPCTimeout(5*time.Second), + WithGRPCDialOptions(grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + dialCount.Add(1) + return listener.Dial() + })), + ) + t.Cleanup(func() { require.NoError(t, clientSet.Close()) }) + + _, err := clientSet.AgentInstance.CreateAgentInstance(context.Background(), &apiv1alpha1.CreateAgentInstanceRequest{}) + require.NoError(t, err) + assert.Equal(t, callObservation{userID: "caller", hasDeadline: true}, agentInstanceService.observation) + + a2aClient, err := clientSet.A2A.ForAgentInstance(context.Background(), "kagent", agentInstanceClientTestID) + require.NoError(t, err) + request := &a2atype.SendMessageRequest{Message: a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart("hi"))} + _, err = a2aClient.SendMessage(context.Background(), request) + require.NoError(t, err) + for _, streamErr := range a2aClient.SendStreamingMessage(context.Background(), request) { + require.NoError(t, streamErr) + } + for _, streamErr := range a2aClient.SubscribeToTask(context.Background(), &a2atype.SubscribeToTaskRequest{ID: "task-id"}) { + require.NoError(t, streamErr) + } + + a2aService.mu.Lock() + require.Equal(t, []a2aCallObservation{ + {namespace: "kagent", id: agentInstanceClientTestID, userID: "caller", hasDeadline: true}, + {namespace: "kagent", id: agentInstanceClientTestID, userID: "caller", hasDeadline: false}, + {namespace: "kagent", id: agentInstanceClientTestID, userID: "caller", hasDeadline: false}, + }, a2aService.observations) + a2aService.mu.Unlock() + assert.Equal(t, int32(1), dialCount.Load()) +} + +func TestStreamingA2AMethodsMatchUpstreamService(t *testing.T) { + methods := make([]string, 0, len(a2apb.A2AService_ServiceDesc.Streams)) + for _, stream := range a2apb.A2AService_ServiceDesc.Streams { + methods = append(methods, stream.StreamName) + assert.True(t, isStreamingA2AMethod(stream.StreamName), "streaming method %q must not receive a unary timeout", stream.StreamName) + } + assert.Equal(t, []string{"SendStreamingMessage", "SubscribeToTask"}, methods) +} diff --git a/go/api/client/clientset.go b/go/api/client/clientset.go index 9272c1cb0..922bcaacb 100644 --- a/go/api/client/clientset.go +++ b/go/api/client/clientset.go @@ -15,6 +15,8 @@ type ClientSet struct { Model Model Namespace Namespace Feedback Feedback + AgentInstance AgentInstance + A2A *A2AClient } // New creates a new KAgent client set @@ -34,6 +36,8 @@ func New(baseURL string, options ...ClientOption) *ClientSet { Model: NewModelClient(baseClient), Namespace: NewNamespaceClient(baseClient), Feedback: NewFeedbackClient(baseClient), + AgentInstance: NewAgentInstanceClient(baseClient), + A2A: NewA2AClient(baseClient), } } diff --git a/go/api/client/grpc.go b/go/api/client/grpc.go index 883c990c5..0d67893c7 100644 --- a/go/api/client/grpc.go +++ b/go/api/client/grpc.go @@ -16,7 +16,8 @@ import ( ) const ( - defaultGRPCTarget = "localhost:8084" + // DefaultGRPCTarget is the local gRPC endpoint used when no target is configured. + DefaultGRPCTarget = "localhost:8084" defaultGRPCTimeout = 30 * time.Second defaultGRPCMaxMessageSize = 16 << 20 ) @@ -42,7 +43,7 @@ type grpcTransport struct { func newGRPCTransport() grpcTransport { return grpcTransport{ - target: defaultGRPCTarget, + target: DefaultGRPCTarget, timeout: defaultGRPCTimeout, maxMessageBytes: defaultGRPCMaxMessageSize, } diff --git a/go/core/cli/cmd/kagent/main.go b/go/core/cli/cmd/kagent/main.go index be46d5779..b4bbe41bf 100644 --- a/go/core/cli/cmd/kagent/main.go +++ b/go/core/cli/cmd/kagent/main.go @@ -10,6 +10,7 @@ import ( "time" cli "github.com/kagent-dev/kagent/go/core/cli/internal/cli/agent" + "github.com/kagent-dev/kagent/go/core/cli/internal/cli/connection" "github.com/kagent-dev/kagent/go/core/cli/internal/cli/envdoc" "github.com/kagent-dev/kagent/go/core/cli/internal/cli/mcp" "github.com/kagent-dev/kagent/go/core/cli/internal/config" @@ -66,6 +67,9 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { Use: "kagent", Short: "kagent is a CLI and TUI for kagent", Long: "kagent is a CLI and TUI for kagent", + PersistentPreRunE: func(_ *cobra.Command, _ []string) error { + return cfg.Validate() + }, Run: func(cmd *cobra.Command, args []string) { runInteractive(cmd, args, cfg) }, @@ -81,6 +85,7 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { rootCmd.PersistentFlags().StringVarP(&cfg.OutputFormat, "output-format", "o", cfg.OutputFormat, "Output format") rootCmd.PersistentFlags().BoolVarP(&cfg.Verbose, "verbose", "v", cfg.Verbose, "Verbose output") rootCmd.PersistentFlags().DurationVar(&cfg.Timeout, "timeout", cfg.Timeout, "Timeout") + rootCmd.PersistentFlags().StringVar(&cfg.UserID, "user-id", cfg.UserID, "Caller identity used to select the server-side data partition") installCfg := &cli.InstallCfg{ Config: cfg, } @@ -135,12 +140,12 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { Short: "Generate a bug report", Long: `Generate a bug report`, Run: func(cmd *cobra.Command, args []string) { - if err := cli.CheckServerConnection(cmd.Context(), cfg.Client()); err != nil { - pf, err := cli.NewPortForward(cmd.Context(), cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "Error starting port-forward: %v\n", err) - return - } + pf, err := connection.Connect(cmd.Context(), cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "Error connecting to server: %v\n", err) + return + } + if pf != nil { defer pf.Stop() } cli.BugReportCmd(cfg) @@ -156,10 +161,8 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { // versions unable to obtain from the remote kagent will be reported as "unknown" defer cli.VersionCmd(cfg) - if err := cli.CheckServerConnection(cmd.Context(), cfg.Client()); err != nil { - if pf, e := cli.NewPortForward(cmd.Context(), cfg); e == nil { - defer pf.Stop() - } + if pf, _ := connection.Connect(cmd.Context(), cfg); pf != nil { + defer pf.Stop() } }, } @@ -189,12 +192,12 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { Short: "Get a session or list all sessions", Long: `Get a session by ID or list all sessions`, Run: func(cmd *cobra.Command, args []string) { - if err := cli.CheckServerConnection(cmd.Context(), cfg.Client()); err != nil { - pf, err := cli.NewPortForward(cmd.Context(), cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "Error starting port-forward: %v\n", err) - return - } + pf, err := connection.Connect(cmd.Context(), cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "Error connecting to server: %v\n", err) + return + } + if pf != nil { defer pf.Stop() } resourceName := "" @@ -210,11 +213,12 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { Short: "Get an agent or list all agents", Long: `Get an agent by name or list all agents`, Run: func(cmd *cobra.Command, args []string) { - if err := cli.CheckServerConnection(cmd.Context(), cfg.Client()); err != nil { - pf, err := cli.NewPortForward(cmd.Context(), cfg) - if err != nil { - return - } + pf, err := connection.Connect(cmd.Context(), cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "Error connecting to server: %v\n", err) + return + } + if pf != nil { defer pf.Stop() } resourceName := "" @@ -230,12 +234,12 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { Short: "Get tools", Long: `List all available tools`, Run: func(cmd *cobra.Command, args []string) { - if err := cli.CheckServerConnection(cmd.Context(), cfg.Client()); err != nil { - pf, err := cli.NewPortForward(cmd.Context(), cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "Error starting port-forward: %v\n", err) - return - } + pf, err := connection.Connect(cmd.Context(), cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "Error connecting to server: %v\n", err) + return + } + if pf != nil { defer pf.Stop() } cli.GetToolCmd(cfg) @@ -540,14 +544,12 @@ func runInteractive(cmd *cobra.Command, args []string, cfg *config.Config) { client := cfg.Client() defer client.Close() //nolint:errcheck - // Start port forward and ensure it is healthy. - var pf *cli.PortForward - if err := cli.CheckServerConnection(cmd.Context(), client); err != nil { - pf, err = cli.NewPortForward(cmd.Context(), cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "Error starting port-forward: %v\n", err) - return - } + pf, err := connection.Connect(cmd.Context(), cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "Error connecting to server: %v\n", err) + return + } + if pf != nil { defer pf.Stop() } diff --git a/go/core/cli/cmd/kagent/main_test.go b/go/core/cli/cmd/kagent/main_test.go index 4fd1d5293..62d3d6fbd 100644 --- a/go/core/cli/cmd/kagent/main_test.go +++ b/go/core/cli/cmd/kagent/main_test.go @@ -32,6 +32,7 @@ namespace: configured-ns output_format: json verbose: true timeout: 45s +user_id: configured-user `), 0600)) cfg, err := loadConfig() @@ -46,6 +47,42 @@ timeout: 45s assert.Equal(t, "json", cfg.OutputFormat) assert.True(t, cfg.Verbose) assert.Equal(t, 45*time.Second, cfg.Timeout) + assert.Equal(t, "configured-user", cfg.UserID) +} + +func TestLoadConfigDoesNotWriteDefaults(t *testing.T) { + resetConfigState(t) + + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + cfg, err := loadConfig() + require.NoError(t, err) + assert.Equal(t, config.DefaultUserID, cfg.UserID) + _, err = os.Stat(filepath.Join(homeDir, ".kagent")) + assert.ErrorIs(t, err, os.ErrNotExist) +} + +func TestLoadConfigReadsUserIDFromEnvironment(t *testing.T) { + resetConfigState(t) + + t.Setenv("HOME", t.TempDir()) + t.Setenv("KAGENT_USER_ID", "environment-user") + + cfg, err := loadConfig() + require.NoError(t, err) + assert.Equal(t, "environment-user", cfg.UserID) +} + +func TestLoadConfigRejectsInvalidUserID(t *testing.T) { + resetConfigState(t) + + t.Setenv("HOME", t.TempDir()) + t.Setenv("KAGENT_USER_ID", "invalid user") + + _, err := loadConfig() + require.Error(t, err) + assert.Contains(t, err.Error(), "caller identity must not contain whitespace") } func TestRootCommandUsesConfigValuesAsFlagDefaults(t *testing.T) { @@ -59,6 +96,7 @@ func TestRootCommandUsesConfigValuesAsFlagDefaults(t *testing.T) { OutputFormat: "json", Verbose: true, Timeout: 45 * time.Second, + UserID: "configured-user", } rootCmd := newRootCommand(context.Background(), cfg) @@ -72,6 +110,7 @@ func TestRootCommandUsesConfigValuesAsFlagDefaults(t *testing.T) { assert.Equal(t, "json", rootCmd.PersistentFlags().Lookup("output-format").DefValue) assert.Equal(t, "true", rootCmd.PersistentFlags().Lookup("verbose").DefValue) assert.Equal(t, "45s", rootCmd.PersistentFlags().Lookup("timeout").DefValue) + assert.Equal(t, "configured-user", rootCmd.PersistentFlags().Lookup("user-id").DefValue) deployCmd, _, err := rootCmd.Find([]string{"deploy"}) require.NoError(t, err) @@ -102,6 +141,7 @@ func TestRootCommandFlagsOverrideConfigValues(t *testing.T) { "--output-format", "yaml", "--verbose", "--timeout", "10s", + "--user-id", "flag-user", })) assert.Equal(t, "http://flag.example.test", cfg.KAgentURL) @@ -113,6 +153,33 @@ func TestRootCommandFlagsOverrideConfigValues(t *testing.T) { assert.Equal(t, "yaml", cfg.OutputFormat) assert.True(t, cfg.Verbose) assert.Equal(t, 10*time.Second, cfg.Timeout) + assert.Equal(t, "flag-user", cfg.UserID) +} + +func TestRootCommandRejectsInvalidUserIDFlag(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {name: "empty", args: []string{"--user-id=", "version"}}, + {name: "whitespace", args: []string{"--user-id", "invalid user", "version"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{ + KAgentURL: config.DefaultKAgentURL, + KAgentGRPCURL: config.DefaultKAgentGRPCURL, + UserID: config.DefaultUserID, + } + rootCmd := newRootCommand(t.Context(), cfg) + rootCmd.SetArgs(tt.args) + + err := rootCmd.ExecuteContext(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), "caller identity") + }) + } } func resetConfigState(t *testing.T) { diff --git a/go/core/cli/internal/cli/agent/install.go b/go/core/cli/internal/cli/agent/install.go index c12d3d304..47a184d73 100644 --- a/go/core/cli/internal/cli/agent/install.go +++ b/go/core/cli/internal/cli/agent/install.go @@ -15,6 +15,7 @@ import ( "github.com/abiosoft/ishell/v2" "github.com/briandowns/spinner" + "github.com/kagent-dev/kagent/go/core/cli/internal/cli/connection" "github.com/kagent-dev/kagent/go/core/cli/internal/config" "github.com/kagent-dev/kagent/go/core/cli/internal/profiles" ) @@ -65,7 +66,7 @@ func installChart(ctx context.Context, chartName string, namespace string, regis return "", nil } -func InstallCmd(ctx context.Context, cfg *InstallCfg) *PortForward { +func InstallCmd(ctx context.Context, cfg *InstallCfg) *connection.PortForward { if version.Version == "dev" { fmt.Fprintln(os.Stderr, "Installation requires released version of kagent") return nil @@ -105,7 +106,7 @@ func InstallCmd(ctx context.Context, cfg *InstallCfg) *PortForward { return install(ctx, cfg.Config, helmConfig, modelProvider) } -func InteractiveInstallCmd(ctx context.Context, c *ishell.Context) *PortForward { +func InteractiveInstallCmd(ctx context.Context, c *ishell.Context) *connection.PortForward { if version.Version == "dev" { fmt.Fprintln(os.Stderr, "Installation requires released version of kagent") return nil @@ -180,7 +181,7 @@ func setupHelmConfig(modelProvider v1alpha3.ModelProvider, apiKeyValue string) h } // install installs kagent and kagent-crds using the helm config -func install(ctx context.Context, cfg *config.Config, helmConfig helmConfig, modelProvider v1alpha3.ModelProvider) *PortForward { +func install(ctx context.Context, cfg *config.Config, helmConfig helmConfig, modelProvider v1alpha3.ModelProvider) *connection.PortForward { // spinner for installation progress s := spinner.New(spinner.CharSets[35], 100*time.Millisecond) @@ -232,7 +233,7 @@ func install(ctx context.Context, cfg *config.Config, helmConfig helmConfig, mod s.Stop() fmt.Fprintln(os.Stdout, "kagent installed successfully") - pf, err := NewPortForward(ctx, cfg) + pf, err := connection.NewPortForward(ctx, cfg) if err != nil { fmt.Fprintf(os.Stderr, "Error starting port-forward: %v\n", err) return nil diff --git a/go/core/cli/internal/cli/agent/invoke.go b/go/core/cli/internal/cli/agent/invoke.go index e46bcfd16..2f6826a24 100644 --- a/go/core/cli/internal/cli/agent/invoke.go +++ b/go/core/cli/internal/cli/agent/invoke.go @@ -13,6 +13,7 @@ import ( a2atype "github.com/a2aproject/a2a-go/v2/a2a" api "github.com/kagent-dev/kagent/go/api/httpapi" clia2a "github.com/kagent-dev/kagent/go/core/cli/internal/a2a" + "github.com/kagent-dev/kagent/go/core/cli/internal/cli/connection" "github.com/kagent-dev/kagent/go/core/cli/internal/config" ) @@ -40,18 +41,18 @@ func (t *bearerTokenTransport) RoundTrip(req *http.Request) (*http.Response, err } func InvokeCmd(ctx context.Context, cfg *InvokeCfg) { - clientSet := cfg.Config.Client() - - if err := CheckServerConnection(ctx, clientSet); err != nil { - // If a connection does not exist, start a short-lived port-forward. - pf, err := NewPortForward(ctx, cfg.Config) - if err != nil { - fmt.Fprintf(os.Stderr, "Error starting port-forward: %v\n", err) - return - } + pf, err := connection.Connect(ctx, cfg.Config) + if err != nil { + fmt.Fprintf(os.Stderr, "Error connecting to server: %v\n", err) + return + } + if pf != nil { defer pf.Stop() } + clientSet := cfg.Config.Client() + defer clientSet.Close() //nolint:errcheck + var task string // If task is set, use it. Otherwise, read from file or stdin. if cfg.Task != "" { diff --git a/go/core/cli/internal/cli/agent/invoke_test.go b/go/core/cli/internal/cli/agent/invoke_test.go index a281164ca..f2b2350c8 100644 --- a/go/core/cli/internal/cli/agent/invoke_test.go +++ b/go/core/cli/internal/cli/agent/invoke_test.go @@ -11,7 +11,7 @@ import ( func TestInvokeCmd_ServerError(t *testing.T) { // This behavior is exercised in integration tests, which can safely depend on // Kubernetes port-forwarding and external tooling like kubectl. - // Invoking InvokeCmd here would trigger CheckServerConnection and may start a + // Invoking InvokeCmd here would check the server connection and may start a // real kubectl port-forward, which is not appropriate for unit tests. t.Skip("Skipping InvokeCmd server error test in unit suite; covered by integration tests without requiring kubectl/port-forwarding") } diff --git a/go/core/cli/internal/cli/agent/utils.go b/go/core/cli/internal/cli/agent/utils.go index 75b6dc0da..a3864d5aa 100644 --- a/go/core/cli/internal/cli/agent/utils.go +++ b/go/core/cli/internal/cli/agent/utils.go @@ -1,18 +1,14 @@ package cli import ( - "context" "encoding/json" "fmt" "io/fs" "os" - "os/exec" "path/filepath" "regexp" "slices" - "time" - "github.com/kagent-dev/kagent/go/api/client" "github.com/kagent-dev/kagent/go/api/v1alpha3" clia2a "github.com/kagent-dev/kagent/go/core/cli/internal/a2a" pygen "github.com/kagent-dev/kagent/go/core/cli/internal/agent/frameworks/adk/python" @@ -20,76 +16,11 @@ import ( "github.com/kagent-dev/kagent/go/core/cli/internal/config" ) -var ( - ErrServerConnection = fmt.Errorf("error connecting to server. Please run 'install' command first") -) - const ( DockerComposeFilename = "docker-compose.yaml" DockerComposeTemplate = "templates/docker-compose.yaml.tmpl" ) -func CheckServerConnection(ctx context.Context, client *client.ClientSet) error { - // Only check if we have a valid client - if client == nil { - return ErrServerConnection - } - - ctx, cancel := context.WithTimeout(ctx, time.Second*5) - defer cancel() - _, err := client.Version.GetVersion(ctx) - if err != nil { - return ErrServerConnection - } - return nil -} - -type PortForward struct { - cmd *exec.Cmd - cancel context.CancelFunc -} - -func NewPortForward(ctx context.Context, cfg *config.Config) (*PortForward, error) { - ctx, cancel := context.WithCancel(ctx) - cmd := exec.CommandContext(ctx, "kubectl", "-n", cfg.Namespace, "port-forward", "service/kagent-controller", "8083:8083", "8084:8084") - - go func() { - if err := cmd.Start(); err != nil { - fmt.Fprintf(os.Stderr, "Error starting port-forward: %v\n", err) - os.Exit(1) - } - }() - - client := cfg.Client() - var err error - for range 10 { - err = CheckServerConnection(ctx, client) - if err == nil { - // Connection successful, port-forward is working - return &PortForward{ - cmd: cmd, - cancel: cancel, - }, nil - } - - time.Sleep(100 * time.Millisecond) - } - - cancel() - return nil, fmt.Errorf("failed to establish connection to kagent-controller. %w", err) -} - -func (p *PortForward) Stop() { - p.cancel() - // This will terminate the kubectl process in case the cancel does not work. - if p.cmd.Process != nil { - p.cmd.Process.Kill() //nolint:errcheck - } - - // Don't wait for the process - just cancel the context and let it die - // The kubectl process will terminate when the context is canceled -} - func StreamA2AEvents(ch <-chan clia2a.StreamResult, verbose bool) error { _ = verbose defer fmt.Fprintln(os.Stdout) diff --git a/go/core/cli/internal/cli/connection/connection.go b/go/core/cli/internal/cli/connection/connection.go new file mode 100644 index 000000000..ff177368b --- /dev/null +++ b/go/core/cli/internal/cli/connection/connection.go @@ -0,0 +1,212 @@ +// Package connection owns CLI server connectivity and Kubernetes port-forward fallback. +package connection + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync" + "time" + + "github.com/kagent-dev/kagent/go/api/client" + "github.com/kagent-dev/kagent/go/core/cli/internal/config" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var ErrServerConnection = errors.New("error connecting to server") + +const ( + portForwardReadyTimeout = 15 * time.Second + portForwardRetryDelay = 100 * time.Millisecond + kubectlErrorLimit = 8 << 10 +) + +type connectionRuntime struct { + checkServer func(context.Context, *client.ClientSet) error + commandContext func(context.Context, string, ...string) *exec.Cmd + stderr io.Writer + readyTimeout time.Duration + retryDelay time.Duration +} + +var defaultConnectionRuntime = connectionRuntime{ + checkServer: CheckServer, + commandContext: exec.CommandContext, + stderr: os.Stderr, + readyTimeout: portForwardReadyTimeout, + retryDelay: portForwardRetryDelay, +} + +// CheckServer checks whether the configured server is reachable. +func CheckServer(ctx context.Context, clientSet *client.ClientSet) error { + if clientSet == nil { + return ErrServerConnection + } + + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if _, err := clientSet.Version.GetVersion(ctx); err != nil { + return fmt.Errorf("%w: %w", ErrServerConnection, err) + } + return nil +} + +// Connect checks the configured server and starts a port-forward only for an +// unreachable default local endpoint. +func Connect(ctx context.Context, cfg *config.Config) (*PortForward, error) { + return defaultConnectionRuntime.connect(ctx, cfg) +} + +func (r connectionRuntime) connect(ctx context.Context, cfg *config.Config) (*PortForward, error) { + if cfg.Verbose { + fmt.Fprintf(r.stderr, "Using caller identity %q\n", cfg.UserID) + } + + err := r.checkConfiguredServer(ctx, cfg) + if err == nil { + return nil, nil + } + if !shouldPortForward(cfg, err) { + return nil, err + } + return r.newPortForward(ctx, cfg) +} + +func shouldPortForward(cfg *config.Config, err error) bool { + grpcURL := cfg.KAgentGRPCURL + if grpcURL == "" { + grpcURL = client.DefaultGRPCTarget + } + if cfg.KAgentGRPCTLS || grpcURL != client.DefaultGRPCTarget || strings.TrimRight(cfg.KAgentURL, "/") != config.DefaultKAgentURL { + return false + } + code := status.Code(err) + return code == codes.Unavailable || code == codes.DeadlineExceeded || errors.Is(err, context.DeadlineExceeded) +} + +func (r connectionRuntime) checkConfiguredServer(ctx context.Context, cfg *config.Config) (err error) { + clientSet := cfg.Client() + defer func() { + err = errors.Join(err, clientSet.Close()) + }() + return r.checkServer(ctx, clientSet) +} + +// PortForward is a running kubectl port-forward process. +type PortForward struct { + cmd *exec.Cmd + cancel context.CancelFunc + wait <-chan error + stop sync.Once +} + +// NewPortForward starts a port-forward and waits for the server to become reachable. +func NewPortForward(ctx context.Context, cfg *config.Config) (*PortForward, error) { + return defaultConnectionRuntime.newPortForward(ctx, cfg) +} + +func (r connectionRuntime) newPortForward(ctx context.Context, cfg *config.Config) (*PortForward, error) { + ctx, cancel := context.WithCancel(ctx) + cmd := r.commandContext(ctx, "kubectl", "-n", cfg.Namespace, "port-forward", "service/kagent-controller", "8083:8083", "8084:8084") + stderr := newBoundedBuffer(kubectlErrorLimit) + cmd.Stderr = stderr + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("start kubectl port-forward: %w", err) + } + + wait := make(chan error, 1) + go func() { + wait <- cmd.Wait() + close(wait) + }() + portForward := &PortForward{cmd: cmd, cancel: cancel, wait: wait} + + readyCtx, cancelReady := context.WithTimeout(ctx, r.readyTimeout) + defer cancelReady() + ticker := time.NewTicker(r.retryDelay) + defer ticker.Stop() + + var lastErr error + for { + lastErr = r.checkConfiguredServer(readyCtx, cfg) + if lastErr == nil { + return portForward, nil + } + + select { + case processErr := <-wait: + cancel() + return nil, portForwardExitedError(processErr, lastErr, stderr.String()) + case <-readyCtx.Done(): + portForward.Stop() + return nil, portForwardReadinessError(readyCtx.Err(), lastErr, stderr.String()) + case <-ticker.C: + } + } +} + +func portForwardExitedError(processErr, serverErr error, stderr string) error { + cause := errors.Join(processErr, serverErr) + if cause == nil { + cause = ErrServerConnection + } + return fmt.Errorf("kubectl port-forward exited before the server became ready%s: %w", kubectlDetails(stderr), cause) +} + +func portForwardReadinessError(deadlineErr, serverErr error, stderr string) error { + return fmt.Errorf("failed to establish connection to kagent-controller%s: %w", kubectlDetails(stderr), errors.Join(deadlineErr, serverErr)) +} + +func kubectlDetails(stderr string) string { + stderr = strings.TrimSpace(stderr) + if stderr == "" { + return "" + } + return fmt.Sprintf(" (kubectl: %s)", stderr) +} + +type boundedBuffer struct { + buffer bytes.Buffer + remaining int +} + +func newBoundedBuffer(limit int) *boundedBuffer { + return &boundedBuffer{remaining: limit} +} + +func (b *boundedBuffer) Write(data []byte) (int, error) { + written := len(data) + if len(data) > b.remaining { + data = data[:b.remaining] + } + if _, err := b.buffer.Write(data); err != nil { + return 0, err + } + b.remaining -= len(data) + return written, nil +} + +func (b *boundedBuffer) String() string { + return b.buffer.String() +} + +// Stop terminates the port-forward process and waits for it to be reaped. +func (p *PortForward) Stop() { + if p == nil { + return + } + p.stop.Do(func() { + p.cancel() + if p.cmd.Process != nil { + _ = p.cmd.Process.Kill() + } + <-p.wait + }) +} diff --git a/go/core/cli/internal/cli/connection/connection_test.go b/go/core/cli/internal/cli/connection/connection_test.go new file mode 100644 index 000000000..9f42d50c4 --- /dev/null +++ b/go/core/cli/internal/cli/connection/connection_test.go @@ -0,0 +1,238 @@ +package connection + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "testing" + "time" + + "github.com/kagent-dev/kagent/go/api/client" + api "github.com/kagent-dev/kagent/go/api/httpapi" + "github.com/kagent-dev/kagent/go/core/cli/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type failingVersionClient struct { + err error +} + +func (c failingVersionClient) GetVersion(context.Context) (*api.VersionResponse, error) { + return nil, c.err +} + +func TestCheckServerPreservesCause(t *testing.T) { + permissionErr := status.Error(codes.PermissionDenied, "denied") + err := CheckServer(t.Context(), &client.ClientSet{Version: failingVersionClient{err: permissionErr}}) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrServerConnection) + assert.Equal(t, codes.PermissionDenied, status.Code(err)) +} + +func TestShouldPortForward(t *testing.T) { + defaultConfig := config.Config{KAgentURL: config.DefaultKAgentURL, KAgentGRPCURL: config.DefaultKAgentGRPCURL} + tests := []struct { + name string + config config.Config + err error + want bool + }{ + {name: "default endpoint unavailable", config: defaultConfig, err: status.Error(codes.Unavailable, "offline"), want: true}, + {name: "default endpoint gRPC deadline", config: defaultConfig, err: status.Error(codes.DeadlineExceeded, "deadline"), want: true}, + {name: "default endpoint context deadline", config: defaultConfig, err: context.DeadlineExceeded, want: true}, + {name: "empty gRPC endpoint uses client default", config: config.Config{KAgentURL: config.DefaultKAgentURL}, err: status.Error(codes.Unavailable, "offline"), want: true}, + {name: "authentication failure", config: defaultConfig, err: status.Error(codes.Unauthenticated, "unauthenticated")}, + {name: "authorization failure", config: defaultConfig, err: status.Error(codes.PermissionDenied, "denied")}, + {name: "explicit TLS", config: config.Config{KAgentURL: config.DefaultKAgentURL, KAgentGRPCURL: config.DefaultKAgentGRPCURL, KAgentGRPCTLS: true}, err: status.Error(codes.Unavailable, "TLS failed")}, + {name: "explicit gRPC endpoint", config: config.Config{KAgentURL: config.DefaultKAgentURL, KAgentGRPCURL: "api.example.test:443"}, err: status.Error(codes.Unavailable, "offline")}, + {name: "explicit HTTP endpoint", config: config.Config{KAgentURL: "https://api.example.test", KAgentGRPCURL: config.DefaultKAgentGRPCURL}, err: status.Error(codes.Unavailable, "offline")}, + {name: "other error", config: defaultConfig, err: errors.New("invalid CA")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, shouldPortForward(&tt.config, tt.err)) + }) + } +} + +func TestConnectionRuntimeConnectStartsPortForwardAndRedials(t *testing.T) { + var output bytes.Buffer + var clients []*client.ClientSet + attempts := 0 + runtime := testConnectionRuntime(t, "wait", func(_ context.Context, clientSet *client.ClientSet) error { + clients = append(clients, clientSet) + attempts++ + if attempts < 3 { + return status.Error(codes.Unavailable, "not ready") + } + return nil + }) + runtime.stderr = &output + + portForward, err := runtime.connect(t.Context(), defaultTestConfig(true)) + require.NoError(t, err) + require.NotNil(t, portForward) + t.Cleanup(portForward.Stop) + + assert.Len(t, clients, 3) + assert.NotSame(t, clients[0], clients[1]) + assert.NotSame(t, clients[1], clients[2]) + assert.Contains(t, output.String(), `Using caller identity "test-user"`) + portForward.Stop() + assert.NotNil(t, portForward.cmd.ProcessState) +} + +func TestConnectionRuntimeConnectDoesNotPortForwardExplicitFailures(t *testing.T) { + tests := []struct { + name string + config *config.Config + err error + }{ + {name: "authentication failure", config: defaultTestConfig(false), err: status.Error(codes.Unauthenticated, "unauthenticated")}, + {name: "TLS endpoint", config: func() *config.Config { + cfg := defaultTestConfig(false) + cfg.KAgentGRPCTLS = true + return cfg + }(), err: status.Error(codes.Unavailable, "TLS failed")}, + {name: "remote gRPC endpoint", config: func() *config.Config { + cfg := defaultTestConfig(false) + cfg.KAgentGRPCURL = "api.example.test:443" + return cfg + }(), err: status.Error(codes.Unavailable, "offline")}, + {name: "remote HTTP endpoint", config: func() *config.Config { + cfg := defaultTestConfig(false) + cfg.KAgentURL = "https://api.example.test" + return cfg + }(), err: status.Error(codes.Unavailable, "offline")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runtime := testConnectionRuntime(t, "wait", func(context.Context, *client.ClientSet) error { + return tt.err + }) + runtime.commandContext = func(context.Context, string, ...string) *exec.Cmd { + t.Fatal("kubectl must not start for this failure") + return nil + } + + portForward, err := runtime.connect(t.Context(), tt.config) + require.Error(t, err) + assert.Nil(t, portForward) + assert.Equal(t, status.Code(tt.err), status.Code(err)) + }) + } +} + +func TestConnectionRuntimeNewPortForwardReportsStartFailure(t *testing.T) { + runtime := testConnectionRuntime(t, "wait", func(context.Context, *client.ClientSet) error { + t.Fatal("health probe must not run when kubectl cannot start") + return nil + }) + runtime.commandContext = func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, t.TempDir()+"/missing-kubectl") + } + + portForward, err := runtime.newPortForward(t.Context(), defaultTestConfig(false)) + require.Error(t, err) + assert.Nil(t, portForward) + assert.Contains(t, err.Error(), "start kubectl port-forward") +} + +func TestConnectionRuntimeNewPortForwardReportsKubectlStderr(t *testing.T) { + runtime := testConnectionRuntime(t, "fail", func(context.Context, *client.ClientSet) error { + return status.Error(codes.Unavailable, "not ready") + }) + + portForward, err := runtime.newPortForward(t.Context(), defaultTestConfig(false)) + require.Error(t, err) + assert.Nil(t, portForward) + assert.Contains(t, err.Error(), "kubectl denied port-forward") +} + +func TestConnectionRuntimeNewPortForwardStopsProcessOnTimeout(t *testing.T) { + var command *exec.Cmd + runtime := testConnectionRuntime(t, "wait", func(context.Context, *client.ClientSet) error { + return status.Error(codes.Unavailable, "not ready") + }) + runtime.readyTimeout = 30 * time.Millisecond + commandFactory := runtime.commandContext + runtime.commandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd { + command = commandFactory(ctx, name, args...) + return command + } + + portForward, err := runtime.newPortForward(t.Context(), defaultTestConfig(false)) + require.Error(t, err) + assert.Nil(t, portForward) + assert.ErrorIs(t, err, context.DeadlineExceeded) + require.NotNil(t, command) + assert.NotNil(t, command.ProcessState) +} + +func TestBoundedBuffer(t *testing.T) { + buffer := newBoundedBuffer(4) + written, err := buffer.Write([]byte("abcdef")) + require.NoError(t, err) + assert.Equal(t, 6, written) + assert.Equal(t, "abcd", buffer.String()) +} + +func TestPortForwardHelperProcess(t *testing.T) { + if os.Getenv("KAGENT_PORT_FORWARD_HELPER") != "1" { + return + } + + switch os.Getenv("KAGENT_PORT_FORWARD_BEHAVIOR") { + case "fail": + _, _ = fmt.Fprintln(os.Stderr, "kubectl denied port-forward") + os.Exit(2) + case "wait": + for { + time.Sleep(time.Hour) + } + default: + os.Exit(3) + } +} + +func testConnectionRuntime( + t *testing.T, + behavior string, + checkServer func(context.Context, *client.ClientSet) error, +) connectionRuntime { + t.Helper() + return connectionRuntime{ + checkServer: checkServer, + commandContext: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestPortForwardHelperProcess$") + command.Env = append(os.Environ(), + "KAGENT_PORT_FORWARD_HELPER=1", + "KAGENT_PORT_FORWARD_BEHAVIOR="+behavior, + ) + return command + }, + stderr: io.Discard, + readyTimeout: time.Second, + retryDelay: time.Millisecond, + } +} + +func defaultTestConfig(verbose bool) *config.Config { + return &config.Config{ + KAgentURL: config.DefaultKAgentURL, + KAgentGRPCURL: config.DefaultKAgentGRPCURL, + Namespace: "kagent", + UserID: "test-user", + Verbose: verbose, + } +} diff --git a/go/core/cli/internal/config/config.go b/go/core/cli/internal/config/config.go index d3ff23528..5caff51d0 100644 --- a/go/core/cli/internal/config/config.go +++ b/go/core/cli/internal/config/config.go @@ -1,10 +1,13 @@ package config import ( + "errors" "fmt" "os" "path/filepath" + "strings" "time" + "unicode" kagentclient "github.com/kagent-dev/kagent/go/api/client" @@ -12,6 +15,15 @@ import ( "github.com/spf13/viper" ) +const ( + // DefaultKAgentURL is the local HTTP endpoint created by the CLI port-forward. + DefaultKAgentURL = "http://localhost:8083" + // DefaultKAgentGRPCURL is the local endpoint eligible for automatic port-forward fallback. + DefaultKAgentGRPCURL = kagentclient.DefaultGRPCTarget + // DefaultUserID preserves the caller identity used before authentication is configured. + DefaultUserID = "admin@kagent.dev" +) + type Config struct { KAgentURL string `mapstructure:"kagent_url"` KAgentGRPCURL string `mapstructure:"kagent_grpc_url"` @@ -22,11 +34,12 @@ type Config struct { OutputFormat string `mapstructure:"output_format"` Verbose bool `mapstructure:"verbose"` Timeout time.Duration `mapstructure:"timeout"` + UserID string `mapstructure:"user_id"` } func (c *Config) Client() *kagentclient.ClientSet { options := []kagentclient.ClientOption{ - kagentclient.WithUserID("admin@kagent.dev"), + kagentclient.WithUserID(c.UserID), } if c.KAgentGRPCURL != "" { options = append(options, kagentclient.WithGRPCTarget(c.KAgentGRPCURL)) @@ -43,18 +56,24 @@ func (c *Config) Client() *kagentclient.ClientSet { return kagentclient.New(c.KAgentURL, options...) } +// Validate rejects configuration that cannot be transported safely. +func (c *Config) Validate() error { + if c.UserID == "" { + return errors.New("caller identity is required") + } + if strings.IndexFunc(c.UserID, unicode.IsSpace) >= 0 { + return errors.New("caller identity must not contain whitespace") + } + return nil +} + func Init() error { home, err := os.UserHomeDir() if err != nil { return fmt.Errorf("error getting user home directory: %w", err) } - configDir := filepath.Join(home, ".kagent") - if err := os.MkdirAll(configDir, 0755); err != nil { - return fmt.Errorf("error creating config directory: %w", err) - } - - configFile := filepath.Join(configDir, "config.yaml") + configFile := filepath.Join(home, ".kagent", "config.yaml") viper.SetConfigFile(configFile) viper.SetConfigType("yaml") @@ -62,26 +81,22 @@ func Init() error { pflag.StringVar(&configFile, "config", configFile, "config file (default is $HOME/.kagent/config.yaml)") // Set default values - viper.SetDefault("kagent_url", "http://localhost:8083") - viper.SetDefault("kagent_grpc_url", "localhost:8084") + viper.SetDefault("kagent_url", DefaultKAgentURL) + viper.SetDefault("kagent_grpc_url", DefaultKAgentGRPCURL) viper.SetDefault("kagent_grpc_tls", false) viper.SetDefault("output_format", "table") viper.SetDefault("namespace", "kagent") viper.SetDefault("timeout", 300*time.Second) + viper.SetDefault("user_id", DefaultUserID) viper.MustBindEnv("kagent_url", "KAGENT_URL") viper.MustBindEnv("kagent_grpc_url", "KAGENT_GRPC_URL") viper.MustBindEnv("kagent_grpc_tls", "KAGENT_GRPC_TLS") viper.MustBindEnv("kagent_grpc_ca_file", "KAGENT_GRPC_CA_FILE") viper.MustBindEnv("kagent_grpc_server_name", "KAGENT_GRPC_SERVER_NAME") - viper.MustBindEnv("USER_ID") + viper.MustBindEnv("user_id", "KAGENT_USER_ID") if err := viper.ReadInConfig(); err != nil { - // If config file doesn't exist, create it with defaults - if _, ok := err.(viper.ConfigFileNotFoundError); ok || os.IsNotExist(err) { - if err := viper.WriteConfigAs(configFile); err != nil { - return fmt.Errorf("error creating default config file: %w", err) - } - } else { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("error reading config file: %w", err) } } @@ -93,5 +108,11 @@ func Get() (*Config, error) { if err := viper.Unmarshal(&config); err != nil { return nil, fmt.Errorf("error unmarshaling config: %w", err) } + if config.UserID == "" { + config.UserID = DefaultUserID + } + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } return &config, nil } diff --git a/go/core/v2/a2agateway/gateway.go b/go/core/v2/a2agateway/gateway.go index 1ff615a64..63fcb854c 100644 --- a/go/core/v2/a2agateway/gateway.go +++ b/go/core/v2/a2agateway/gateway.go @@ -25,7 +25,7 @@ import ( "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/a2aproject/a2a-go/v2/a2asrv/eventqueue" "github.com/google/uuid" - apia2a "github.com/kagent-dev/kagent/go/api/a2a" + kagenta2a "github.com/kagent-dev/kagent/go/api/a2a" dbpkg "github.com/kagent-dev/kagent/go/api/database" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/core/pkg/auth" @@ -35,14 +35,8 @@ import ( ctrllog "sigs.k8s.io/controller-runtime/pkg/log" ) -const ( - // AgentInstanceNamespaceHeader selects the Kubernetes namespace containing the AgentInstance. - AgentInstanceNamespaceHeader = "x-kagent-agent-instance-namespace" - // AgentInstanceIDHeader selects the AgentInstance within that namespace. - AgentInstanceIDHeader = "x-kagent-agent-instance-id" - // TaskCreatedAtMetadataKey preserves the gateway's durable task creation time. - TaskCreatedAtMetadataKey = "kagent.dev/task-created-at" -) +// TaskCreatedAtMetadataKey preserves the gateway's durable task creation time. +const TaskCreatedAtMetadataKey = "kagent.dev/task-created-at" type instanceStore interface { GetAgentInstance(context.Context, string, string, string) (*apiv1alpha1.AgentInstance, error) @@ -195,17 +189,17 @@ func (g *Gateway) storedInstance(ctx context.Context, verb auth.Verb) (*apiv1alp } func route(ctx context.Context) (namespace, id string, err error) { - namespaces := metadata.ValueFromIncomingContext(ctx, AgentInstanceNamespaceHeader) - ids := metadata.ValueFromIncomingContext(ctx, AgentInstanceIDHeader) + namespaces := metadata.ValueFromIncomingContext(ctx, kagenta2a.AgentInstanceNamespaceHeader) + ids := metadata.ValueFromIncomingContext(ctx, kagenta2a.AgentInstanceIDHeader) if len(namespaces) != 1 || len(ids) != 1 { - return "", "", fmt.Errorf("exactly one %s and %s header is required", AgentInstanceNamespaceHeader, AgentInstanceIDHeader) + return "", "", fmt.Errorf("exactly one %s and %s header is required", kagenta2a.AgentInstanceNamespaceHeader, kagenta2a.AgentInstanceIDHeader) } if problems := utilvalidation.IsDNS1123Label(namespaces[0]); len(problems) > 0 { - return "", "", fmt.Errorf("invalid %s header: %s", AgentInstanceNamespaceHeader, strings.Join(problems, "; ")) + return "", "", fmt.Errorf("invalid %s header: %s", kagenta2a.AgentInstanceNamespaceHeader, strings.Join(problems, "; ")) } parsedID, err := uuid.Parse(ids[0]) if err != nil { - return "", "", fmt.Errorf("invalid %s header: %w", AgentInstanceIDHeader, err) + return "", "", fmt.Errorf("invalid %s header: %w", kagenta2a.AgentInstanceIDHeader, err) } return namespaces[0], parsedID.String(), nil } @@ -462,7 +456,7 @@ func (g *Gateway) prepareSend(ctx context.Context, req *a2atype.SendMessageReque if req == nil || req.Message == nil { return nil, a2atype.NewError(a2atype.ErrInvalidRequest, "message is required") } - apia2a.ClearStoredTask(req.Message) + kagenta2a.ClearStoredTask(req.Message) if req.Message.ID == "" { return nil, a2atype.NewError(a2atype.ErrInvalidRequest, "message ID is required") } @@ -525,7 +519,7 @@ func (g *Gateway) prepareReply(ctx context.Context, instance *apiv1alpha1.AgentI } runtimeMessage := *message runtimeMessage.Metadata = maps.Clone(message.Metadata) - if err := apia2a.AttachStoredTask(&runtimeMessage, stored); err != nil { + if err := kagenta2a.AttachStoredTask(&runtimeMessage, stored); err != nil { return nil, a2atype.NewError(a2atype.ErrInternalError, "failed to prepare task continuation") } req.Message = &runtimeMessage diff --git a/go/core/v2/a2agateway/gateway_test.go b/go/core/v2/a2agateway/gateway_test.go index 991a13a71..af3f24d35 100644 --- a/go/core/v2/a2agateway/gateway_test.go +++ b/go/core/v2/a2agateway/gateway_test.go @@ -15,7 +15,7 @@ import ( a2agrpc "github.com/a2aproject/a2a-go/v2/a2agrpc/v1" a2apb "github.com/a2aproject/a2a-go/v2/a2apb/v1" "github.com/a2aproject/a2a-go/v2/a2apb/v1/pbconv" - apia2a "github.com/kagent-dev/kagent/go/api/a2a" + kagenta2a "github.com/kagent-dev/kagent/go/api/a2a" dbpkg "github.com/kagent-dev/kagent/go/api/database" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/core/pkg/auth" @@ -208,7 +208,7 @@ func (r *gatewayTestRuntime) SubscribeToTask(context.Context, a2aclient.ServiceP func (r *gatewayTestRuntime) SendMessage(_ context.Context, _ a2aclient.ServiceParams, req *a2atype.SendMessageRequest) (a2atype.SendMessageResult, error) { r.sent = true - r.privateTask, _ = apia2a.TakeStoredTask(req.Message) + r.privateTask, _ = kagenta2a.TakeStoredTask(req.Message) r.sendCalls++ r.sentTaskID = req.Message.TaskID return &a2atype.Task{ID: req.Message.TaskID, ContextID: req.Message.ContextID, Status: a2atype.TaskStatus{State: a2atype.TaskStateCompleted}}, nil @@ -295,8 +295,8 @@ func gatewayTestContext() context.Context { func gatewayTestContextWithRoute(namespace, id string) context.Context { ctx := auth.AuthSessionTo(context.Background(), gatewayTestSession{}) return metadata.NewIncomingContext(ctx, metadata.Pairs( - AgentInstanceNamespaceHeader, namespace, - AgentInstanceIDHeader, id, + kagenta2a.AgentInstanceNamespaceHeader, namespace, + kagenta2a.AgentInstanceIDHeader, id, )) } @@ -513,8 +513,8 @@ func TestGatewayReadsRoutingHeadersFromGRPC(t *testing.T) { t.Fatal(err) } ctx := metadata.NewOutgoingContext(t.Context(), metadata.Pairs( - AgentInstanceNamespaceHeader, instance.GetNamespace(), - AgentInstanceIDHeader, instance.GetId(), + kagenta2a.AgentInstanceNamespaceHeader, instance.GetNamespace(), + kagenta2a.AgentInstanceIDHeader, instance.GetId(), )) if _, err := a2apb.NewA2AServiceClient(connection).SendMessage(ctx, request); err != nil { t.Fatal(err) @@ -1002,8 +1002,8 @@ func TestGatewayHonoursAgentInstanceShare(t *testing.T) { ReadOnly: true, }) ctx = metadata.NewIncomingContext(ctx, metadata.Pairs( - AgentInstanceNamespaceHeader, instance.GetNamespace(), - AgentInstanceIDHeader, instance.GetId(), + kagenta2a.AgentInstanceNamespaceHeader, instance.GetNamespace(), + kagenta2a.AgentInstanceIDHeader, instance.GetId(), )) if _, err := gateway.ListTasks(ctx, &a2atype.ListTasksRequest{}); err != nil { @@ -1033,8 +1033,8 @@ func TestGatewayRefusesAShareForADifferentInstance(t *testing.T) { AgentInstanceID: "00000000-0000-0000-0000-000000000000", }) ctx = metadata.NewIncomingContext(ctx, metadata.Pairs( - AgentInstanceNamespaceHeader, instance.GetNamespace(), - AgentInstanceIDHeader, instance.GetId(), + kagenta2a.AgentInstanceNamespaceHeader, instance.GetNamespace(), + kagenta2a.AgentInstanceIDHeader, instance.GetId(), )) if _, err := gateway.ListTasks(ctx, &a2atype.ListTasksRequest{}); err == nil { @@ -1059,8 +1059,8 @@ func TestGatewayIgnoresASessionShare(t *testing.T) { SessionID: instance.GetId(), }) ctx = metadata.NewIncomingContext(ctx, metadata.Pairs( - AgentInstanceNamespaceHeader, instance.GetNamespace(), - AgentInstanceIDHeader, instance.GetId(), + kagenta2a.AgentInstanceNamespaceHeader, instance.GetNamespace(), + kagenta2a.AgentInstanceIDHeader, instance.GetId(), )) if _, err := gateway.ListTasks(ctx, &a2atype.ListTasksRequest{}); err == nil { From e8300337cf02d15c886e108e6c466d46565d8a9e Mon Sep 17 00:00:00 2001 From: Cody Hartsook Date: Mon, 24 Aug 2026 16:53:31 -0700 Subject: [PATCH 02/10] feat(cli): discover and invoke agent instances Signed-off-by: Cody Hartsook --- go/core/cli/cmd/kagent/main.go | 67 ++-- go/core/cli/cmd/kagent/main_test.go | 45 +++ go/core/cli/internal/a2a/assembler.go | 73 ++++ go/core/cli/internal/a2a/assembler_test.go | 58 +++ .../internal/cli/agent/error_handling_test.go | 27 -- go/core/cli/internal/cli/agent/invoke.go | 163 --------- go/core/cli/internal/cli/agent/invoke_test.go | 17 - go/core/cli/internal/cli/agent/stream_test.go | 20 - go/core/cli/internal/cli/agent/utils.go | 21 -- go/core/cli/internal/cli/agentinstance/get.go | 151 ++++++++ .../internal/cli/agentinstance/get_test.go | 123 +++++++ .../cli/internal/cli/agentinstance/invoke.go | 338 +++++++++++++++++ .../internal/cli/agentinstance/invoke_test.go | 346 ++++++++++++++++++ go/core/cli/internal/cli/output/output.go | 43 +++ .../cli/internal/cli/output/output_test.go | 19 + go/core/cli/internal/config/config.go | 1 + go/core/test/e2e/interaction_test.go | 105 +++++- 17 files changed, 1343 insertions(+), 274 deletions(-) create mode 100644 go/core/cli/internal/a2a/assembler.go create mode 100644 go/core/cli/internal/a2a/assembler_test.go delete mode 100644 go/core/cli/internal/cli/agent/invoke.go delete mode 100644 go/core/cli/internal/cli/agent/invoke_test.go delete mode 100644 go/core/cli/internal/cli/agent/stream_test.go create mode 100644 go/core/cli/internal/cli/agentinstance/get.go create mode 100644 go/core/cli/internal/cli/agentinstance/get_test.go create mode 100644 go/core/cli/internal/cli/agentinstance/invoke.go create mode 100644 go/core/cli/internal/cli/agentinstance/invoke_test.go create mode 100644 go/core/cli/internal/cli/output/output.go create mode 100644 go/core/cli/internal/cli/output/output_test.go diff --git a/go/core/cli/cmd/kagent/main.go b/go/core/cli/cmd/kagent/main.go index b4bbe41bf..41f3667ed 100644 --- a/go/core/cli/cmd/kagent/main.go +++ b/go/core/cli/cmd/kagent/main.go @@ -10,9 +10,11 @@ import ( "time" cli "github.com/kagent-dev/kagent/go/core/cli/internal/cli/agent" + agentinstancecli "github.com/kagent-dev/kagent/go/core/cli/internal/cli/agentinstance" "github.com/kagent-dev/kagent/go/core/cli/internal/cli/connection" "github.com/kagent-dev/kagent/go/core/cli/internal/cli/envdoc" "github.com/kagent-dev/kagent/go/core/cli/internal/cli/mcp" + clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" "github.com/kagent-dev/kagent/go/core/cli/internal/config" "github.com/kagent-dev/kagent/go/core/cli/internal/profiles" "github.com/kagent-dev/kagent/go/core/cli/internal/tui" @@ -64,11 +66,17 @@ func loadConfig() (*config.Config, error) { func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { rootCmd := &cobra.Command{ - Use: "kagent", - Short: "kagent is a CLI and TUI for kagent", - Long: "kagent is a CLI and TUI for kagent", + Use: "kagent", + Short: "kagent is a CLI and TUI for kagent", + Long: "kagent is a CLI and TUI for kagent", + SilenceErrors: true, + SilenceUsage: true, PersistentPreRunE: func(_ *cobra.Command, _ []string) error { - return cfg.Validate() + if err := cfg.Validate(); err != nil { + return err + } + _, err := clioutput.Parse(cfg.OutputFormat) + return err }, Run: func(cmd *cobra.Command, args []string) { runInteractive(cmd, args, cfg) @@ -112,28 +120,29 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { }, } - invokeCfg := &cli.InvokeCfg{ + invokeCfg := &agentinstancecli.InvokeCfg{ Config: cfg, } invokeCmd := &cobra.Command{ Use: "invoke", - Short: "Invoke a kagent agent", - Long: `Invoke a kagent agent`, - Run: func(cmd *cobra.Command, args []string) { - cli.InvokeCmd(cmd.Context(), invokeCfg) + Short: "Invoke an AgentInstance", + Long: `Invoke an existing AgentInstance through the A2A API.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return agentinstancecli.InvokeCmd(cmd.Context(), invokeCfg, cmd.InOrStdin(), cmd.OutOrStdout()) }, - Example: `kagent invoke --agent "k8s-agent" --task "Get all the pods in the kagent namespace"`, + Example: `kagent invoke --agent-instance 8bd650a8-9775-488f-8bc1-0d52bf7bdcab --task "Get all the pods"`, } - invokeCmd.Flags().StringVarP(&invokeCfg.Task, "task", "t", "", "Task") - invokeCmd.Flags().StringVarP(&invokeCfg.Session, "session", "s", "", "Session") - invokeCmd.Flags().StringVarP(&invokeCfg.Agent, "agent", "a", "", "Agent") + invokeCmd.Flags().StringVar(&invokeCfg.AgentInstance, "agent-instance", "", "AgentInstance ID") + invokeCmd.Flags().StringVarP(&invokeCfg.Task, "task", "t", "", "Task text") + invokeCmd.Flags().StringVarP(&invokeCfg.File, "file", "f", "", "Read task text from a file or - for stdin") invokeCmd.Flags().BoolVarP(&invokeCfg.Stream, "stream", "S", false, "Stream the response") - invokeCmd.Flags().StringVarP(&invokeCfg.File, "file", "f", "", "File to read the task from") - invokeCmd.Flags().StringVarP(&invokeCfg.URLOverride, "url-override", "u", "", "URL override") - invokeCmd.Flags().MarkHidden("url-override") //nolint:errcheck - invokeCmd.Flags().StringVar(&invokeCfg.Token, "token", "", "Bearer token to include in A2A requests (for API key passthrough)") + invokeCmd.Flags().StringVar(&invokeCfg.Token, "token", "", "Model API key passed through as an A2A Bearer token") + _ = invokeCmd.MarkFlagRequired("agent-instance") + invokeCmd.MarkFlagsOneRequired("task", "file") + invokeCmd.MarkFlagsMutuallyExclusive("task", "file") bugReportCmd := &cobra.Command{ Use: "bug-report", @@ -180,12 +189,26 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { Use: "get", Short: "Get a kagent resource", Long: `Get a kagent resource`, - Run: func(cmd *cobra.Command, args []string) { - fmt.Fprintf(os.Stderr, "No resource type provided\n\n") - cmd.Help() //nolint:errcheck - os.Exit(1) + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return fmt.Errorf("resource type is required") + }, + } + agentInstanceGetCfg := &agentinstancecli.GetCfg{Config: cfg} + getAgentInstanceCmd := &cobra.Command{ + Use: "agent-instance [ID]", + Short: "Get an AgentInstance or list your AgentInstances", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + agentInstanceGetCfg.InstanceID = "" + if len(args) == 1 { + agentInstanceGetCfg.InstanceID = args[0] + } + return agentinstancecli.GetCmd(cmd.Context(), agentInstanceGetCfg, cmd.OutOrStdout()) }, } + getAgentInstanceCmd.Flags().Int32Var(&agentInstanceGetCfg.PageSize, "page-size", 0, "Number of AgentInstances to return (default 50, maximum 100)") + getAgentInstanceCmd.Flags().StringVar(&agentInstanceGetCfg.PageToken, "page-token", "", "Token returned by the previous page") getSessionCmd := &cobra.Command{ Use: "session [session_id]", @@ -246,7 +269,7 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { }, } - getCmd.AddCommand(getSessionCmd, getAgentCmd, getToolCmd) + getCmd.AddCommand(getAgentInstanceCmd, getSessionCmd, getAgentCmd, getToolCmd) initCfg := &cli.InitCfg{ Config: cfg, diff --git a/go/core/cli/cmd/kagent/main_test.go b/go/core/cli/cmd/kagent/main_test.go index 62d3d6fbd..912b2beee 100644 --- a/go/core/cli/cmd/kagent/main_test.go +++ b/go/core/cli/cmd/kagent/main_test.go @@ -68,10 +68,12 @@ func TestLoadConfigReadsUserIDFromEnvironment(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("KAGENT_USER_ID", "environment-user") + t.Setenv("KAGENT_OUTPUT_FORMAT", "json") cfg, err := loadConfig() require.NoError(t, err) assert.Equal(t, "environment-user", cfg.UserID) + assert.Equal(t, "json", cfg.OutputFormat) } func TestLoadConfigRejectsInvalidUserID(t *testing.T) { @@ -182,6 +184,49 @@ func TestRootCommandRejectsInvalidUserIDFlag(t *testing.T) { } } +func TestRootCommandRejectsInvalidOutputBeforeRunningCommand(t *testing.T) { + cfg := &config.Config{ + KAgentURL: config.DefaultKAgentURL, + KAgentGRPCURL: config.DefaultKAgentGRPCURL, + OutputFormat: "yaml", + UserID: config.DefaultUserID, + } + rootCmd := newRootCommand(t.Context(), cfg) + rootCmd.SetArgs([]string{"version"}) + + err := rootCmd.ExecuteContext(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), `unsupported output format "yaml"`) +} + +func TestRootCommandInvokeContract(t *testing.T) { + cfg := &config.Config{ + KAgentURL: config.DefaultKAgentURL, + KAgentGRPCURL: config.DefaultKAgentGRPCURL, + OutputFormat: "table", + UserID: config.DefaultUserID, + } + rootCmd := newRootCommand(t.Context(), cfg) + assert.True(t, rootCmd.SilenceErrors) + assert.True(t, rootCmd.SilenceUsage) + + invokeCmd, _, err := rootCmd.Find([]string{"invoke"}) + require.NoError(t, err) + for _, flag := range []string{"agent-instance", "task", "file", "stream", "token"} { + assert.NotNil(t, invokeCmd.Flags().Lookup(flag), "missing --%s", flag) + } + for _, legacyFlag := range []string{"agent", "session", "url-override"} { + assert.Nil(t, invokeCmd.Flags().Lookup(legacyFlag), "legacy --%s must be removed", legacyFlag) + } + + getInstanceCmd, _, err := rootCmd.Find([]string{"get", "agent-instance"}) + require.NoError(t, err) + assert.Equal(t, "agent-instance [ID]", getInstanceCmd.Use) + for _, flag := range []string{"page-size", "page-token"} { + assert.NotNil(t, getInstanceCmd.Flags().Lookup(flag), "missing --%s", flag) + } +} + func resetConfigState(t *testing.T) { t.Helper() diff --git a/go/core/cli/internal/a2a/assembler.go b/go/core/cli/internal/a2a/assembler.go new file mode 100644 index 000000000..6fad1aa60 --- /dev/null +++ b/go/core/cli/internal/a2a/assembler.go @@ -0,0 +1,73 @@ +package a2a + +import ( + "errors" + "fmt" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aevent" +) + +// Assembler projects an A2A event stream into its current Message or Task. +type Assembler struct { + message *a2atype.Message + task *a2atype.Task +} + +// Apply incorporates one A2A event into the assembled result. +func (a *Assembler) Apply(event a2atype.Event) error { + if event == nil { + return errors.New("a2a stream returned a nil event") + } + if message, ok := event.(*a2atype.Message); ok { + if a.message != nil || a.task != nil { + return errors.New("a2a stream returned a Message after another result") + } + a.message = message + return nil + } + if a.message != nil { + return errors.New("a2a stream returned a task event after a Message") + } + if a.task == nil { + info := event.TaskInfo() + if info.TaskID == "" || info.ContextID == "" { + return errors.New("a2a task event is missing task or context identity") + } + a.task = &a2atype.Task{ + ID: info.TaskID, + ContextID: info.ContextID, + Status: a2atype.TaskStatus{State: a2atype.TaskStateSubmitted}, + } + } + + updated, err := a2aevent.ApplyUpdate(a.task, event) + if err != nil { + return fmt.Errorf("apply a2a event: %w", err) + } + a.task = updated + return nil +} + +// Result returns the assembled result, if the stream has produced one. +func (a *Assembler) Result() a2atype.SendMessageResult { + if a.message != nil { + return a.message + } + if a.task != nil { + return a.task + } + return nil +} + +// Complete reports whether the assembled result is ready to return to the caller. +func (a *Assembler) Complete() bool { + if a.message != nil { + return true + } + if a.task == nil { + return false + } + state := a.task.Status.State + return state.Terminal() || state == a2atype.TaskStateInputRequired || state == a2atype.TaskStateAuthRequired +} diff --git a/go/core/cli/internal/a2a/assembler_test.go b/go/core/cli/internal/a2a/assembler_test.go new file mode 100644 index 000000000..a4bcdb359 --- /dev/null +++ b/go/core/cli/internal/a2a/assembler_test.go @@ -0,0 +1,58 @@ +package a2a + +import ( + "testing" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAssemblerAppliesArtifactReplacement(t *testing.T) { + const ( + taskID = a2atype.TaskID("task-1") + contextID = "instance-1" + artifactID = a2atype.ArtifactID("answer") + ) + events := []a2atype.Event{ + &a2atype.Task{ID: taskID, ContextID: contextID, Status: a2atype.TaskStatus{State: a2atype.TaskStateWorking}}, + &a2atype.TaskArtifactUpdateEvent{ + TaskID: taskID, ContextID: contextID, + Artifact: &a2atype.Artifact{ID: artifactID, Parts: a2atype.ContentParts{a2atype.NewTextPart("Hel")}}, + }, + &a2atype.TaskArtifactUpdateEvent{ + TaskID: taskID, ContextID: contextID, Append: true, + Artifact: &a2atype.Artifact{ID: artifactID, Parts: a2atype.ContentParts{a2atype.NewTextPart("lo")}}, + }, + &a2atype.TaskArtifactUpdateEvent{ + TaskID: taskID, ContextID: contextID, LastChunk: true, + Artifact: &a2atype.Artifact{ID: artifactID, Parts: a2atype.ContentParts{a2atype.NewTextPart("Hello")}}, + }, + &a2atype.TaskStatusUpdateEvent{ + TaskID: taskID, ContextID: contextID, + Status: a2atype.TaskStatus{State: a2atype.TaskStateCompleted}, + }, + } + + assembler := &Assembler{} + for _, event := range events { + require.NoError(t, assembler.Apply(event)) + } + + result, ok := assembler.Result().(*a2atype.Task) + require.True(t, ok) + require.Len(t, result.Artifacts, 1) + require.Len(t, result.Artifacts[0].Parts, 1) + assert.Equal(t, "Hello", result.Artifacts[0].Parts[0].Text()) + assert.Equal(t, a2atype.TaskStateCompleted, result.Status.State) + assert.True(t, assembler.Complete()) +} + +func TestAssemblerAcceptsMessageResult(t *testing.T) { + message := a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.NewTextPart("hello")) + assembler := &Assembler{} + + require.NoError(t, assembler.Apply(message)) + assert.Same(t, message, assembler.Result()) + assert.True(t, assembler.Complete()) +} diff --git a/go/core/cli/internal/cli/agent/error_handling_test.go b/go/core/cli/internal/cli/agent/error_handling_test.go index d89b13dd0..7687749c1 100644 --- a/go/core/cli/internal/cli/agent/error_handling_test.go +++ b/go/core/cli/internal/cli/agent/error_handling_test.go @@ -462,33 +462,6 @@ func TestEdgeCaseInputs(t *testing.T) { } } -// TestInvokeWithInvalidAgent tests agent name validation logic -func TestInvokeWithInvalidAgent(t *testing.T) { - // Note: These tests are limited because InvokeCmd attempts connection - // before validation, so we can only test the validation logic indirectly - - t.Run("agent name validation", func(t *testing.T) { - // Test that agent names with "/" are invalid - invalidName := "namespace/agent" - assert.Contains(t, invalidName, "/", "Agent name with / should be caught") - - // Test that empty agent names should fail - emptyName := "" - assert.Empty(t, emptyName, "Empty agent name should be invalid") - }) - - t.Run("task validation", func(t *testing.T) { - // Both task and file empty should fail - cfg := &InvokeCfg{ - Task: "", - File: "", - } - - assert.Empty(t, cfg.Task, "Empty task should be invalid") - assert.Empty(t, cfg.File, "Empty file should be invalid") - }) -} - // TestDeployWithMissingResources tests deploy when required resources are missing func TestDeployWithMissingResources(t *testing.T) { tests := []struct { diff --git a/go/core/cli/internal/cli/agent/invoke.go b/go/core/cli/internal/cli/agent/invoke.go deleted file mode 100644 index 2f6826a24..000000000 --- a/go/core/cli/internal/cli/agent/invoke.go +++ /dev/null @@ -1,163 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "strings" - "time" - - a2atype "github.com/a2aproject/a2a-go/v2/a2a" - api "github.com/kagent-dev/kagent/go/api/httpapi" - clia2a "github.com/kagent-dev/kagent/go/core/cli/internal/a2a" - "github.com/kagent-dev/kagent/go/core/cli/internal/cli/connection" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" -) - -type InvokeCfg struct { - Config *config.Config - Task string - File string - Session string - Agent string - Stream bool - URLOverride string - Token string -} - -// bearerTokenTransport is an http.RoundTripper that injects an Authorization: Bearer header. -type bearerTokenTransport struct { - base http.RoundTripper - token string -} - -func (t *bearerTokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req = req.Clone(req.Context()) - req.Header.Set("Authorization", "Bearer "+t.token) - return t.base.RoundTrip(req) -} - -func InvokeCmd(ctx context.Context, cfg *InvokeCfg) { - pf, err := connection.Connect(ctx, cfg.Config) - if err != nil { - fmt.Fprintf(os.Stderr, "Error connecting to server: %v\n", err) - return - } - if pf != nil { - defer pf.Stop() - } - - clientSet := cfg.Config.Client() - defer clientSet.Close() //nolint:errcheck - - var task string - // If task is set, use it. Otherwise, read from file or stdin. - if cfg.Task != "" { - task = cfg.Task - } else if cfg.File != "" { - switch cfg.File { - case "-": - // Read from stdin - content, err := io.ReadAll(os.Stdin) - if err != nil { - fmt.Fprintf(os.Stderr, "Error reading from stdin: %v\n", err) - return - } - task = string(content) - default: - // Read from file - content, err := os.ReadFile(cfg.File) - if err != nil { - fmt.Fprintf(os.Stderr, "Error reading from file: %v\n", err) - return - } - task = string(content) - } - } else { - fmt.Fprintln(os.Stderr, "Task or file is required") - return - } - - clientOpts := clia2a.ClientOptions{Timeout: cfg.Config.Timeout} - if cfg.Token != "" { - clientOpts.HTTPClient = &http.Client{ - Timeout: cfg.Config.Timeout, - Transport: &bearerTokenTransport{ - base: http.DefaultTransport, - token: cfg.Token, - }, - } - } - - var a2aURL string - if cfg.URLOverride != "" { - a2aURL = cfg.URLOverride - } else { - if cfg.Agent == "" { - fmt.Fprintln(os.Stderr, "Agent is required") - return - } - - // Error out if the agent is provided with the namespace (e.g., namespace/agent-name) - if strings.Contains(cfg.Agent, "/") { - fmt.Fprintf(os.Stderr, "Invalid agent format: use --namespace to specify the namespace. Got'%s'\n", cfg.Agent) - return - } - - agentResponse, err := clientSet.Agent.GetAgent(ctx, fmt.Sprintf("%s/%s", cfg.Config.Namespace, cfg.Agent)) - if err != nil { - fmt.Fprintf(os.Stderr, "Error getting agent metadata: %v\n", err) - return - } - - a2aURL = buildA2AURL(cfg.Config.KAgentURL, cfg.Config.Namespace, cfg.Agent, agentResponse.Data) - } - - a2aClient, err := clia2a.NewClient(ctx, a2aURL, clientOpts) - if err != nil { - fmt.Fprintf(os.Stderr, "Error creating A2A client: %v\n", err) - return - } - - msg := a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart(task)) - if cfg.Session != "" { - msg.ContextID = cfg.Session - } - req := &a2atype.SendMessageRequest{Message: msg} - - // Use A2A client to send message - if cfg.Stream { - ctx, cancel := context.WithTimeout(ctx, 300*time.Second) - defer cancel() - - ch := clia2a.StreamToChannel(ctx, a2aClient, req) - if err := StreamA2AEvents(ch, cfg.Config.Verbose); err != nil { - fmt.Fprintf(os.Stderr, "Error invoking session: %v\n", err) - return - } - } else { - ctx, cancel := context.WithTimeout(ctx, 300*time.Second) - defer cancel() - - result, err := a2aClient.SendMessage(ctx, req) - if err != nil { - fmt.Fprintf(os.Stderr, "Error invoking session: %v\n", err) - return - } - - jsn, err := json.Marshal(result) - if err != nil { - fmt.Fprintf(os.Stderr, "Error marshaling result: %v\n", err) - return - } - - fmt.Fprintf(os.Stdout, "%+v\n", string(jsn)) - } -} - -func buildA2AURL(baseURL, namespace, agent string, agentResponse *api.AgentResponse) string { - return fmt.Sprintf("%s/api/a2a-sandboxes/%s/%s", baseURL, namespace, agent) -} diff --git a/go/core/cli/internal/cli/agent/invoke_test.go b/go/core/cli/internal/cli/agent/invoke_test.go deleted file mode 100644 index f2b2350c8..000000000 --- a/go/core/cli/internal/cli/agent/invoke_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package cli - -import ( - "testing" -) - -// Note: Most InvokeCmd tests require K8s port-forwarding mock which is complex. -// Testing InvokeCmd with URLOverride still attempts port-forward first. -// Integration tests cover the full invoke workflow. - -func TestInvokeCmd_ServerError(t *testing.T) { - // This behavior is exercised in integration tests, which can safely depend on - // Kubernetes port-forwarding and external tooling like kubectl. - // Invoking InvokeCmd here would check the server connection and may start a - // real kubectl port-forward, which is not appropriate for unit tests. - t.Skip("Skipping InvokeCmd server error test in unit suite; covered by integration tests without requiring kubectl/port-forwarding") -} diff --git a/go/core/cli/internal/cli/agent/stream_test.go b/go/core/cli/internal/cli/agent/stream_test.go deleted file mode 100644 index e0c88e6b4..000000000 --- a/go/core/cli/internal/cli/agent/stream_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package cli - -import ( - "errors" - "testing" - - clia2a "github.com/kagent-dev/kagent/go/core/cli/internal/a2a" - "github.com/stretchr/testify/require" -) - -func TestStreamA2AEventsReturnsTerminalError(t *testing.T) { - wantErr := errors.New("stream disconnected") - ch := make(chan clia2a.StreamResult, 1) - ch <- clia2a.StreamResult{Err: wantErr} - close(ch) - - err := StreamA2AEvents(ch, false) - - require.ErrorIs(t, err, wantErr) -} diff --git a/go/core/cli/internal/cli/agent/utils.go b/go/core/cli/internal/cli/agent/utils.go index a3864d5aa..1760460b0 100644 --- a/go/core/cli/internal/cli/agent/utils.go +++ b/go/core/cli/internal/cli/agent/utils.go @@ -1,7 +1,6 @@ package cli import ( - "encoding/json" "fmt" "io/fs" "os" @@ -10,7 +9,6 @@ import ( "slices" "github.com/kagent-dev/kagent/go/api/v1alpha3" - clia2a "github.com/kagent-dev/kagent/go/core/cli/internal/a2a" pygen "github.com/kagent-dev/kagent/go/core/cli/internal/agent/frameworks/adk/python" "github.com/kagent-dev/kagent/go/core/cli/internal/agent/frameworks/common" "github.com/kagent-dev/kagent/go/core/cli/internal/config" @@ -21,25 +19,6 @@ const ( DockerComposeTemplate = "templates/docker-compose.yaml.tmpl" ) -func StreamA2AEvents(ch <-chan clia2a.StreamResult, verbose bool) error { - _ = verbose - defer fmt.Fprintln(os.Stdout) - - for result := range ch { - if result.Err != nil { - return result.Err - } - - json, err := json.Marshal(result.Event) - if err != nil { - fmt.Fprintf(os.Stderr, "Error marshaling A2A event: %v\n", err) - continue - } - fmt.Fprintf(os.Stdout, "%+v\n", string(json)) - } - return nil -} - // ResolveProjectDir resolves the project directory to an absolute path func ResolveProjectDir(projectDir string) (string, error) { if projectDir == "" { diff --git a/go/core/cli/internal/cli/agentinstance/get.go b/go/core/cli/internal/cli/agentinstance/get.go new file mode 100644 index 000000000..70ed34be1 --- /dev/null +++ b/go/core/cli/internal/cli/agentinstance/get.go @@ -0,0 +1,151 @@ +// Package agentinstance implements AgentInstance CLI commands. +package agentinstance + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jedib0t/go-pretty/v6/table" + kagentclient "github.com/kagent-dev/kagent/go/api/client" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/kagent-dev/kagent/go/core/cli/internal/cli/connection" + clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" + "github.com/kagent-dev/kagent/go/core/cli/internal/config" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const maxPageSize = 100 + +// GetCfg configures AgentInstance get and list operations. +type GetCfg struct { + Config *config.Config + InstanceID string + PageSize int32 + PageToken string +} + +// GetCmd gets one AgentInstance or lists the caller's AgentInstances. +func GetCmd(ctx context.Context, cfg *GetCfg, out io.Writer) (err error) { + format, err := clioutput.Parse(cfg.Config.OutputFormat) + if err != nil { + return err + } + if err := validateGetCfg(cfg); err != nil { + return err + } + + portForward, err := connection.Connect(ctx, cfg.Config) + if err != nil { + return fmt.Errorf("connect to kagent: %w", err) + } + if portForward != nil { + defer portForward.Stop() + } + + clientSet := cfg.Config.Client() + defer func() { + err = errors.Join(err, clientSet.Close()) + }() + return get(ctx, clientSet.AgentInstance, cfg, format, out) +} + +func validateGetCfg(cfg *GetCfg) error { + if cfg.PageSize < 0 || cfg.PageSize > maxPageSize { + return fmt.Errorf("page size must be between 1 and %d, or 0 for the server default", maxPageSize) + } + if cfg.InstanceID == "" { + return nil + } + instanceID, err := uuid.Parse(cfg.InstanceID) + if err != nil { + return fmt.Errorf("invalid AgentInstance ID %q: %w", cfg.InstanceID, err) + } + cfg.InstanceID = instanceID.String() + if cfg.PageSize != 0 || cfg.PageToken != "" { + return errors.New("pagination flags cannot be used when getting one AgentInstance") + } + return nil +} + +func get( + ctx context.Context, + client kagentclient.AgentInstance, + cfg *GetCfg, + format clioutput.Format, + out io.Writer, +) error { + if cfg.InstanceID != "" { + response, err := client.GetAgentInstance(ctx, &apiv1alpha1.GetAgentInstanceRequest{ + Namespace: cfg.Config.Namespace, AgentInstanceId: cfg.InstanceID, + }) + if err != nil { + return fmt.Errorf("get AgentInstance: %w", err) + } + if response.GetAgentInstance() == nil { + return errors.New("get AgentInstance returned no AgentInstance") + } + if format == clioutput.FormatJSON { + return clioutput.WriteProto(out, response) + } + return writeInstancesTable(out, []*apiv1alpha1.AgentInstance{response.GetAgentInstance()}, "") + } + + response, err := client.ListAgentInstances(ctx, &apiv1alpha1.ListAgentInstancesRequest{ + Namespace: cfg.Config.Namespace, + Page: &apiv1alpha1.PageRequest{Limit: cfg.PageSize, PageToken: cfg.PageToken}, + }) + if err != nil { + return fmt.Errorf("list AgentInstances: %w", err) + } + if response == nil { + return errors.New("list AgentInstances returned no response") + } + if format == clioutput.FormatJSON { + return clioutput.WriteProto(out, response) + } + return writeInstancesTable(out, response.GetAgentInstances(), response.GetPage().GetNextPageToken()) +} + +func writeInstancesTable(w io.Writer, instances []*apiv1alpha1.AgentInstance, nextPageToken string) error { + tw := table.NewWriter() + tw.AppendHeader(table.Row{"ID", "AGENT TEMPLATE", "HARNESS", "STATE", "CREATED"}) + for _, instance := range instances { + if instance == nil { + continue + } + tw.AppendRow(table.Row{ + instance.GetId(), + resourceName(instance.GetAgentTemplate()), + resourceName(instance.GetHarness()), + strings.TrimPrefix(instance.GetState().String(), "AGENT_INSTANCE_STATE_"), + formatTimestamp(instance.GetCreatedAt()), + }) + } + output := tw.Render() + if nextPageToken != "" { + output += "\nNext page token: " + nextPageToken + } + if _, err := fmt.Fprintln(w, output); err != nil { + return fmt.Errorf("write AgentInstance output: %w", err) + } + return nil +} + +func resourceName(reference *apiv1alpha1.ResourceReference) string { + if reference == nil { + return "" + } + return reference.GetName() +} + +func formatTimestamp(timestamp *timestamppb.Timestamp) string { + if timestamp == nil { + return "" + } + return timestamp.AsTime().UTC().Format(time.RFC3339) +} diff --git a/go/core/cli/internal/cli/agentinstance/get_test.go b/go/core/cli/internal/cli/agentinstance/get_test.go new file mode 100644 index 000000000..86d70c086 --- /dev/null +++ b/go/core/cli/internal/cli/agentinstance/get_test.go @@ -0,0 +1,123 @@ +package agentinstance + +import ( + "bytes" + "context" + "encoding/json" + "testing" + "time" + + kagentclient "github.com/kagent-dev/kagent/go/api/client" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" + "github.com/kagent-dev/kagent/go/core/cli/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const testInstanceID = "707f3f49-fdc4-40c5-93c7-472d37c8d355" + +func TestValidateGetCfg(t *testing.T) { + tests := []struct { + name string + config GetCfg + wantErr string + }{ + {name: "list"}, + {name: "list page", config: GetCfg{PageSize: 100, PageToken: "next"}}, + {name: "get", config: GetCfg{InstanceID: testInstanceID}}, + {name: "invalid ID", config: GetCfg{InstanceID: "not-an-id"}, wantErr: "invalid AgentInstance ID"}, + {name: "negative page size", config: GetCfg{PageSize: -1}, wantErr: "page size"}, + {name: "large page size", config: GetCfg{PageSize: 101}, wantErr: "page size"}, + {name: "pagination with get", config: GetCfg{InstanceID: testInstanceID, PageSize: 1}, wantErr: "pagination flags"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateGetCfg(&tt.config) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestGetAgentInstanceTableUsesFullID(t *testing.T) { + client := &fakeAgentInstanceClient{instance: testInstance(), nextPageToken: "next-page"} + cfg := &GetCfg{Config: &config.Config{Namespace: "kagent"}} + var output bytes.Buffer + + require.NoError(t, get(t.Context(), client, cfg, clioutput.FormatTable, &output)) + assert.Equal(t, &apiv1alpha1.ListAgentInstancesRequest{ + Namespace: "kagent", Page: &apiv1alpha1.PageRequest{}, + }, client.listRequest) + assert.Contains(t, output.String(), testInstanceID) + assert.Contains(t, output.String(), "smoke") + assert.Contains(t, output.String(), "READY") + assert.Contains(t, output.String(), "Next page token: next-page") +} + +func TestGetOneAgentInstanceJSON(t *testing.T) { + client := &fakeAgentInstanceClient{instance: testInstance()} + cfg := &GetCfg{Config: &config.Config{Namespace: "kagent"}, InstanceID: testInstanceID} + var output bytes.Buffer + + require.NoError(t, get(t.Context(), client, cfg, clioutput.FormatJSON, &output)) + assert.Equal(t, testInstanceID, client.getRequest.GetAgentInstanceId()) + assert.True(t, json.Valid(output.Bytes())) + assert.Contains(t, output.String(), testInstanceID) +} + +func TestListAgentInstancesJSONPreservesNextPageToken(t *testing.T) { + client := &fakeAgentInstanceClient{instance: testInstance(), nextPageToken: "next-page"} + cfg := &GetCfg{ + Config: &config.Config{Namespace: "kagent"}, PageSize: 1, PageToken: "current-page", + } + var output bytes.Buffer + + require.NoError(t, get(t.Context(), client, cfg, clioutput.FormatJSON, &output)) + assert.Equal(t, int32(1), client.listRequest.GetPage().GetLimit()) + assert.Equal(t, "current-page", client.listRequest.GetPage().GetPageToken()) + assert.True(t, json.Valid(output.Bytes())) + assert.Contains(t, output.String(), `"nextPageToken":"next-page"`) +} + +type fakeAgentInstanceClient struct { + kagentclient.AgentInstance + instance *apiv1alpha1.AgentInstance + nextPageToken string + getRequest *apiv1alpha1.GetAgentInstanceRequest + listRequest *apiv1alpha1.ListAgentInstancesRequest +} + +func (c *fakeAgentInstanceClient) GetAgentInstance( + _ context.Context, + request *apiv1alpha1.GetAgentInstanceRequest, +) (*apiv1alpha1.GetAgentInstanceResponse, error) { + c.getRequest = request + return &apiv1alpha1.GetAgentInstanceResponse{AgentInstance: c.instance}, nil +} + +func (c *fakeAgentInstanceClient) ListAgentInstances( + _ context.Context, + request *apiv1alpha1.ListAgentInstancesRequest, +) (*apiv1alpha1.ListAgentInstancesResponse, error) { + c.listRequest = request + return &apiv1alpha1.ListAgentInstancesResponse{ + AgentInstances: []*apiv1alpha1.AgentInstance{c.instance}, + Page: &apiv1alpha1.PageResponse{NextPageToken: c.nextPageToken}, + }, nil +} + +func testInstance() *apiv1alpha1.AgentInstance { + return &apiv1alpha1.AgentInstance{ + Id: testInstanceID, Namespace: "kagent", Creator: "e2e", + Harness: &apiv1alpha1.ResourceReference{Namespace: "kagent", Name: "kagent"}, + AgentTemplate: &apiv1alpha1.ResourceReference{Namespace: "kagent", Name: "smoke"}, + State: apiv1alpha1.AgentInstanceState_AGENT_INSTANCE_STATE_READY, + CreatedAt: timestamppb.New(time.Date(2026, time.August, 24, 12, 0, 0, 0, time.UTC)), + } +} diff --git a/go/core/cli/internal/cli/agentinstance/invoke.go b/go/core/cli/internal/cli/agentinstance/invoke.go new file mode 100644 index 000000000..f35d6b71b --- /dev/null +++ b/go/core/cli/internal/cli/agentinstance/invoke.go @@ -0,0 +1,338 @@ +package agentinstance + +import ( + "context" + "errors" + "fmt" + "io" + "iter" + "os" + "strings" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" + "github.com/a2aproject/a2a-go/v2/a2apb/v1/pbconv" + "github.com/google/uuid" + clia2a "github.com/kagent-dev/kagent/go/core/cli/internal/a2a" + "github.com/kagent-dev/kagent/go/core/cli/internal/cli/connection" + clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" + "github.com/kagent-dev/kagent/go/core/cli/internal/config" + "google.golang.org/grpc/metadata" +) + +var errTruncatedA2AStream = errors.New("a2a stream ended before returning a final result") + +type InvokeCfg struct { + Config *config.Config + Task string + File string + AgentInstance string + Stream bool + Token string +} + +func InvokeCmd(ctx context.Context, cfg *InvokeCfg, in io.Reader, out io.Writer) (err error) { + format, err := clioutput.Parse(cfg.Config.OutputFormat) + if err != nil { + return err + } + task, err := readInvokeTask(cfg, in) + if err != nil { + return err + } + instanceID, err := uuid.Parse(cfg.AgentInstance) + if err != nil { + return fmt.Errorf("invalid AgentInstance ID %q: %w", cfg.AgentInstance, err) + } + if strings.ContainsAny(cfg.Token, " \t\r\n") { + return errors.New("model API key must not contain whitespace") + } + + portForward, err := connection.Connect(ctx, cfg.Config) + if err != nil { + return fmt.Errorf("connect to kagent: %w", err) + } + if portForward != nil { + defer portForward.Stop() + } + + clientSet := cfg.Config.Client() + defer func() { + err = errors.Join(err, clientSet.Close()) + }() + a2aClient, err := clientSet.A2A.ForAgentInstance(ctx, cfg.Config.Namespace, instanceID.String()) + if err != nil { + return fmt.Errorf("create AgentInstance A2A client: %w", err) + } + + request := newInvokeRequest(task, instanceID.String()) + ctx = withModelToken(ctx, cfg.Token) + + if cfg.Stream { + return invokeStreaming(ctx, a2aClient, request, format, out) + } + return invokeNonStreaming(ctx, a2aClient, request, format, out) +} + +func newInvokeRequest(task, instanceID string) *a2atype.SendMessageRequest { + message := a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart(task)) + message.ContextID = instanceID + return &a2atype.SendMessageRequest{Message: message} +} + +func withModelToken(ctx context.Context, token string) context.Context { + if token == "" { + return ctx + } + return metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token) +} + +func readInvokeTask(cfg *InvokeCfg, in io.Reader) (string, error) { + if (cfg.Task == "") == (cfg.File == "") { + return "", errors.New("exactly one of --task or --file is required") + } + if cfg.Task != "" { + if strings.TrimSpace(cfg.Task) == "" { + return "", errors.New("task is empty") + } + return cfg.Task, nil + } + + var ( + content []byte + err error + ) + if cfg.File == "-" { + content, err = io.ReadAll(in) + } else { + content, err = os.ReadFile(cfg.File) + } + if err != nil { + return "", fmt.Errorf("read task from %q: %w", cfg.File, err) + } + if strings.TrimSpace(string(content)) == "" { + return "", errors.New("task is empty") + } + return string(content), nil +} + +func invokeNonStreaming( + ctx context.Context, + client *a2aclient.Client, + request *a2atype.SendMessageRequest, + format clioutput.Format, + out io.Writer, +) error { + result, err := client.SendMessage(ctx, request) + if err != nil { + return fmt.Errorf("invoke AgentInstance: %w", err) + } + if err := writeSendResult(out, format, result); err != nil { + return err + } + return sendResultError(result) +} + +func invokeStreaming( + ctx context.Context, + client *a2aclient.Client, + request *a2atype.SendMessageRequest, + format clioutput.Format, + out io.Writer, +) error { + var onEvent func(a2atype.Event, a2atype.SendMessageResult) error + var tableWriter *tableStreamWriter + if format == clioutput.FormatJSON { + onEvent = func(event a2atype.Event, _ a2atype.SendMessageResult) error { + return writeStreamEvent(out, event) + } + } else { + tableWriter = &tableStreamWriter{w: out} + onEvent = tableWriter.Write + } + + result, streamErr := consumeA2AStream(client.SendStreamingMessage(ctx, request), onEvent) + if tableWriter != nil { + if err := tableWriter.Finish(result); err != nil { + return err + } + } + if streamErr != nil { + return fmt.Errorf("invoke AgentInstance stream: %w", streamErr) + } + return sendResultError(result) +} + +func writeStreamEvent(w io.Writer, event a2atype.Event) error { + response, err := pbconv.ToProtoStreamResponse(event) + if err != nil { + return fmt.Errorf("convert A2A event to protobuf: %w", err) + } + return clioutput.WriteProto(w, response) +} + +func consumeA2AStream( + stream iter.Seq2[a2atype.Event, error], + onEvent func(a2atype.Event, a2atype.SendMessageResult) error, +) (a2atype.SendMessageResult, error) { + assembler := &clia2a.Assembler{} + for event, streamErr := range stream { + if event != nil { + if err := assembler.Apply(event); err != nil { + return assembler.Result(), err + } + if err := onEvent(event, assembler.Result()); err != nil { + return assembler.Result(), err + } + } + if streamErr != nil { + return assembler.Result(), streamErr + } + if event == nil { + return assembler.Result(), errors.New("a2a stream returned an empty event") + } + } + if !assembler.Complete() { + return assembler.Result(), errTruncatedA2AStream + } + return assembler.Result(), nil +} + +type tableStreamWriter struct { + w io.Writer + text string +} + +func (w *tableStreamWriter) Write(_ a2atype.Event, result a2atype.SendMessageResult) error { + text := sendResultText(result) + if text == w.text { + return nil + } + + delta, extends := strings.CutPrefix(text, w.text) + if !extends { + delta = text + if w.text != "" { + delta = "\n" + text + } + } + if _, err := io.WriteString(w.w, delta); err != nil { + return fmt.Errorf("write invoke stream: %w", err) + } + w.text = text + return nil +} + +func (w *tableStreamWriter) Finish(result a2atype.SendMessageResult) error { + if w.text != "" && !strings.HasSuffix(w.text, "\n") { + if _, err := io.WriteString(w.w, "\n"); err != nil { + return fmt.Errorf("write invoke stream: %w", err) + } + } + return writeTableContinuation(w.w, result) +} + +func writeSendResult(w io.Writer, format clioutput.Format, result a2atype.SendMessageResult) error { + if format == clioutput.FormatTable { + return writeTableResult(w, result) + } + response, err := pbconv.ToProtoSendMessageResponse(result) + if err != nil { + return fmt.Errorf("convert A2A result to protobuf: %w", err) + } + return clioutput.WriteProto(w, response) +} + +func writeTableResult(w io.Writer, result a2atype.SendMessageResult) error { + text := sendResultText(result) + if text != "" { + if _, err := io.WriteString(w, text); err != nil { + return fmt.Errorf("write invoke output: %w", err) + } + if !strings.HasSuffix(text, "\n") { + if _, err := io.WriteString(w, "\n"); err != nil { + return fmt.Errorf("write invoke output: %w", err) + } + } + } + + return writeTableContinuation(w, result) +} + +func writeTableContinuation(w io.Writer, result a2atype.SendMessageResult) error { + task, ok := result.(*a2atype.Task) + if !ok { + return nil + } + var continuation string + switch task.Status.State { + case a2atype.TaskStateInputRequired: + continuation = "Input required to continue this AgentInstance.\n" + case a2atype.TaskStateAuthRequired: + continuation = "Authentication required to continue this AgentInstance.\n" + } + if continuation != "" { + if _, err := io.WriteString(w, continuation); err != nil { + return fmt.Errorf("write invoke continuation: %w", err) + } + } + return nil +} + +func sendResultText(result a2atype.SendMessageResult) string { + switch result := result.(type) { + case *a2atype.Message: + return messageText(result) + case *a2atype.Task: + groups := make([]string, 0, len(result.Artifacts)+1) + if text := messageText(result.Status.Message); text != "" { + groups = append(groups, text) + } + for _, artifact := range result.Artifacts { + if artifact == nil { + continue + } + if text := partsText(artifact.Parts); text != "" { + groups = append(groups, text) + } + } + return strings.Join(groups, "\n") + default: + return "" + } +} + +func messageText(message *a2atype.Message) string { + if message == nil { + return "" + } + return partsText(message.Parts) +} + +func partsText(parts a2atype.ContentParts) string { + var text strings.Builder + for _, part := range parts { + if part != nil { + text.WriteString(part.Text()) + } + } + return text.String() +} + +func sendResultError(result a2atype.SendMessageResult) error { + task, ok := result.(*a2atype.Task) + if !ok { + if result == nil { + return errors.New("a2a invocation returned no result") + } + return nil + } + switch task.Status.State { + case a2atype.TaskStateCompleted, a2atype.TaskStateInputRequired, a2atype.TaskStateAuthRequired: + return nil + case a2atype.TaskStateFailed, a2atype.TaskStateRejected, a2atype.TaskStateCanceled: + return fmt.Errorf("AgentInstance task %s ended in %s", task.ID, task.Status.State) + default: + return fmt.Errorf("AgentInstance task %s returned before reaching a final state: %s", task.ID, task.Status.State) + } +} diff --git a/go/core/cli/internal/cli/agentinstance/invoke_test.go b/go/core/cli/internal/cli/agentinstance/invoke_test.go new file mode 100644 index 000000000..2dc43e88c --- /dev/null +++ b/go/core/cli/internal/cli/agentinstance/invoke_test.go @@ -0,0 +1,346 @@ +package agentinstance + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "iter" + "os" + "strings" + "testing" + "time" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/metadata" +) + +func TestReadInvokeTask(t *testing.T) { + tests := []struct { + name string + config InvokeCfg + stdin string + want string + wantErr string + }{ + {name: "task flag", config: InvokeCfg{Task: "hello"}, want: "hello"}, + {name: "empty task flag", config: InvokeCfg{Task: " \n"}, wantErr: "task is empty"}, + {name: "stdin", config: InvokeCfg{File: "-"}, stdin: "hello from stdin\n", want: "hello from stdin\n"}, + {name: "neither", wantErr: "exactly one"}, + {name: "both", config: InvokeCfg{Task: "hello", File: "-"}, wantErr: "exactly one"}, + {name: "empty stdin", config: InvokeCfg{File: "-"}, stdin: " \n", wantErr: "task is empty"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := readInvokeTask(&tt.config, strings.NewReader(tt.stdin)) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestReadInvokeTaskFile(t *testing.T) { + path := t.TempDir() + "/task.txt" + require.NoError(t, os.WriteFile(path, []byte("hello from file"), 0o600)) + + got, err := readInvokeTask(&InvokeCfg{File: path}, strings.NewReader("")) + require.NoError(t, err) + assert.Equal(t, "hello from file", got) +} + +func TestNewInvokeRequestUsesAgentInstanceAsContext(t *testing.T) { + request := newInvokeRequest("hello", "instance-id") + + require.NotNil(t, request.Message) + assert.Equal(t, "instance-id", request.Message.ContextID) + require.Len(t, request.Message.Parts, 1) + assert.Equal(t, "hello", request.Message.Parts[0].Text()) +} + +func TestWithModelToken(t *testing.T) { + ctx := withModelToken(context.Background(), "model-key") + values, ok := metadata.FromOutgoingContext(ctx) + require.True(t, ok) + assert.Equal(t, []string{"Bearer model-key"}, values.Get("authorization")) +} + +func TestWriteSendResultTable(t *testing.T) { + tests := []struct { + name string + result a2atype.SendMessageResult + want string + }{ + { + name: "Message", + result: a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.NewTextPart("hello")), + want: "hello\n", + }, + { + name: "completed Task with multiple artifacts", + result: &a2atype.Task{ + ID: "task-1", ContextID: "instance-1", Status: a2atype.TaskStatus{State: a2atype.TaskStateCompleted}, + Artifacts: []*a2atype.Artifact{ + {ID: "first", Parts: a2atype.ContentParts{a2atype.NewTextPart("hello")}}, + {ID: "second", Parts: a2atype.ContentParts{a2atype.NewTextPart("world")}}, + }, + }, + want: "hello\nworld\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var output bytes.Buffer + require.NoError(t, writeSendResult(&output, clioutput.FormatTable, tt.result)) + assert.Equal(t, tt.want, output.String()) + }) + } +} + +func TestWriteSendResultJSONPreservesArtifactsAndNonTextParts(t *testing.T) { + result := &a2atype.Task{ + ID: "task-1", ContextID: "instance-1", Status: a2atype.TaskStatus{State: a2atype.TaskStateCompleted}, + Artifacts: []*a2atype.Artifact{ + { + ID: "structured", Name: "report", + Parts: a2atype.ContentParts{ + a2atype.NewTextPart("answer"), + a2atype.NewDataPart(map[string]any{"value": "structured"}), + }, + }, + {ID: "binary", Parts: a2atype.ContentParts{a2atype.NewRawPart([]byte{1, 2, 3})}}, + }, + } + + var output bytes.Buffer + require.NoError(t, writeSendResult(&output, clioutput.FormatJSON, result)) + assert.True(t, json.Valid(output.Bytes())) + assert.Contains(t, output.String(), `"artifactId":"structured"`) + assert.Contains(t, output.String(), `"artifactId":"binary"`) + assert.Contains(t, output.String(), `"data"`) + assert.Contains(t, output.String(), `"raw"`) +} + +func TestWriteTableResultContinuation(t *testing.T) { + tests := []struct { + name string + state a2atype.TaskState + want string + }{ + {name: "input required", state: a2atype.TaskStateInputRequired, want: "Need a value\nInput required to continue this AgentInstance.\n"}, + {name: "auth required", state: a2atype.TaskStateAuthRequired, want: "Sign in\nAuthentication required to continue this AgentInstance.\n"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := &a2atype.Task{ + ID: "task-1", ContextID: "instance-1", + Status: a2atype.TaskStatus{ + State: tt.state, + Message: a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.NewTextPart(map[a2atype.TaskState]string{ + a2atype.TaskStateInputRequired: "Need a value", + a2atype.TaskStateAuthRequired: "Sign in", + }[tt.state])), + }, + } + var output bytes.Buffer + require.NoError(t, writeTableResult(&output, result)) + assert.Equal(t, tt.want, output.String()) + require.NoError(t, sendResultError(result)) + }) + } +} + +func TestWriteJSONKeepsPausedStateExplicit(t *testing.T) { + result := &a2atype.Task{ + ID: "task-1", ContextID: "instance-1", + Status: a2atype.TaskStatus{State: a2atype.TaskStateInputRequired}, + } + var output bytes.Buffer + + require.NoError(t, writeSendResult(&output, clioutput.FormatJSON, result)) + assert.True(t, json.Valid(output.Bytes())) + assert.Contains(t, output.String(), `"state":"TASK_STATE_INPUT_REQUIRED"`) +} + +func TestSendResultError(t *testing.T) { + tests := []struct { + state a2atype.TaskState + wantErr bool + }{ + {state: a2atype.TaskStateCompleted}, + {state: a2atype.TaskStateInputRequired}, + {state: a2atype.TaskStateAuthRequired}, + {state: a2atype.TaskStateFailed, wantErr: true}, + {state: a2atype.TaskStateRejected, wantErr: true}, + {state: a2atype.TaskStateCanceled, wantErr: true}, + {state: a2atype.TaskStateWorking, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.state.String(), func(t *testing.T) { + err := sendResultError(&a2atype.Task{ + ID: "task-1", ContextID: "instance-1", Status: a2atype.TaskStatus{State: tt.state}, + }) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + +func TestConsumeA2AStreamReturnsPartialResultOnTruncation(t *testing.T) { + result, err := consumeA2AStream(eventStream( + streamItem{event: &a2atype.Task{ + ID: "task-1", ContextID: "instance-1", Status: a2atype.TaskStatus{State: a2atype.TaskStateWorking}, + }}, + streamItem{event: &a2atype.TaskArtifactUpdateEvent{ + TaskID: "task-1", ContextID: "instance-1", + Artifact: &a2atype.Artifact{ID: "answer", Parts: a2atype.ContentParts{a2atype.NewTextPart("partial")}}, + }}, + ), func(a2atype.Event, a2atype.SendMessageResult) error { return nil }) + + require.ErrorIs(t, err, errTruncatedA2AStream) + assert.Equal(t, "partial", sendResultText(result)) +} + +func TestConsumeA2AStreamWritesJSONL(t *testing.T) { + events := []streamItem{ + {event: &a2atype.Task{ + ID: "task-1", ContextID: "instance-1", Status: a2atype.TaskStatus{State: a2atype.TaskStateWorking}, + }}, + {event: &a2atype.TaskArtifactUpdateEvent{ + TaskID: "task-1", ContextID: "instance-1", + Artifact: &a2atype.Artifact{ID: "answer", Parts: a2atype.ContentParts{a2atype.NewTextPart("done")}}, + }}, + {event: &a2atype.TaskStatusUpdateEvent{ + TaskID: "task-1", ContextID: "instance-1", Status: a2atype.TaskStatus{State: a2atype.TaskStateCompleted}, + }}, + } + var output bytes.Buffer + + result, err := consumeA2AStream(eventStream(events...), func(event a2atype.Event, _ a2atype.SendMessageResult) error { + return writeStreamEvent(&output, event) + }) + require.NoError(t, err) + assert.Equal(t, "done", sendResultText(result)) + lines := strings.Split(strings.TrimSpace(output.String()), "\n") + require.Len(t, lines, len(events)) + for _, line := range lines { + assert.True(t, json.Valid([]byte(line)), "invalid JSONL line: %s", line) + } +} + +func TestConsumeA2AStreamWritesTableBeforeCompletion(t *testing.T) { + release := make(chan struct{}) + t.Cleanup(func() { close(release) }) + stream := func(yield func(a2atype.Event, error) bool) { + if !yield(&a2atype.Task{ + ID: "task-1", ContextID: "instance-1", Status: a2atype.TaskStatus{State: a2atype.TaskStateWorking}, + }, nil) { + return + } + if !yield(&a2atype.TaskArtifactUpdateEvent{ + TaskID: "task-1", ContextID: "instance-1", + Artifact: &a2atype.Artifact{ID: "answer", Parts: a2atype.ContentParts{a2atype.NewTextPart("Hel")}}, + }, nil) { + return + } + <-release + if !yield(&a2atype.TaskArtifactUpdateEvent{ + TaskID: "task-1", ContextID: "instance-1", LastChunk: true, + Artifact: &a2atype.Artifact{ID: "answer", Parts: a2atype.ContentParts{a2atype.NewTextPart("Hello")}}, + }, nil) { + return + } + yield(&a2atype.TaskStatusUpdateEvent{ + TaskID: "task-1", ContextID: "instance-1", Status: a2atype.TaskStatus{State: a2atype.TaskStateCompleted}, + }, nil) + } + out := &signalWriter{wrote: make(chan struct{}, 1)} + done := make(chan error, 1) + + go func() { + writer := tableStreamWriter{w: out} + result, err := consumeA2AStream(stream, writer.Write) + if finishErr := writer.Finish(result); err == nil { + err = finishErr + } + done <- err + }() + + select { + case <-out.wrote: + assert.Equal(t, "Hel", out.String()) + case err := <-done: + t.Fatalf("table stream returned before completion: %v", err) + case <-time.After(time.Second): + t.Fatalf("table stream did not write the partial response before completion: %q", out.String()) + } + release <- struct{}{} + require.NoError(t, <-done) + assert.Equal(t, "Hello\n", out.String()) +} + +func TestConsumeA2AStreamPreservesTerminalError(t *testing.T) { + wantErr := errors.New("stream disconnected") + result, err := consumeA2AStream(eventStream( + streamItem{event: &a2atype.Task{ + ID: "task-1", ContextID: "instance-1", Status: a2atype.TaskStatus{State: a2atype.TaskStateWorking}, + }}, + streamItem{err: wantErr}, + ), func(a2atype.Event, a2atype.SendMessageResult) error { return nil }) + + require.ErrorIs(t, err, wantErr) + require.NotNil(t, result) +} + +type streamItem struct { + event a2atype.Event + err error +} + +func eventStream(items ...streamItem) iter.Seq2[a2atype.Event, error] { + return func(yield func(a2atype.Event, error) bool) { + for _, item := range items { + if !yield(item.event, item.err) { + return + } + } + } +} + +type signalWriter struct { + bytes.Buffer + wrote chan struct{} +} + +func (w *signalWriter) Write(p []byte) (int, error) { + n, err := w.Buffer.Write(p) + w.signal() + return n, err +} + +func (w *signalWriter) WriteString(s string) (int, error) { + n, err := w.Buffer.WriteString(s) + w.signal() + return n, err +} + +func (w *signalWriter) signal() { + select { + case w.wrote <- struct{}{}: + default: + } +} diff --git a/go/core/cli/internal/cli/output/output.go b/go/core/cli/internal/cli/output/output.go new file mode 100644 index 000000000..f110c3991 --- /dev/null +++ b/go/core/cli/internal/cli/output/output.go @@ -0,0 +1,43 @@ +// Package output owns the CLI's shared machine-output contract. +package output + +import ( + "fmt" + "io" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +// Format selects the CLI payload encoding. +type Format string + +const ( + // FormatTable emits human-readable text. + FormatTable Format = "table" + // FormatJSON emits protobuf JSON. + FormatJSON Format = "json" +) + +// Parse validates a CLI output format. +func Parse(value string) (Format, error) { + format := Format(value) + switch format { + case FormatTable, FormatJSON: + return format, nil + default: + return "", fmt.Errorf("unsupported output format %q: must be table or json", value) + } +} + +// WriteProto writes one protobuf message as a JSON line. +func WriteProto(w io.Writer, message proto.Message) error { + data, err := protojson.Marshal(message) + if err != nil { + return fmt.Errorf("marshal JSON output: %w", err) + } + if _, err := fmt.Fprintln(w, string(data)); err != nil { + return fmt.Errorf("write JSON output: %w", err) + } + return nil +} diff --git a/go/core/cli/internal/cli/output/output_test.go b/go/core/cli/internal/cli/output/output_test.go new file mode 100644 index 000000000..c7accca30 --- /dev/null +++ b/go/core/cli/internal/cli/output/output_test.go @@ -0,0 +1,19 @@ +package output + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + for _, value := range []string{"table", "json"} { + format, err := Parse(value) + require.NoError(t, err) + assert.Equal(t, Format(value), format) + } + + _, err := Parse("yaml") + require.Error(t, err) +} diff --git a/go/core/cli/internal/config/config.go b/go/core/cli/internal/config/config.go index 5caff51d0..635565193 100644 --- a/go/core/cli/internal/config/config.go +++ b/go/core/cli/internal/config/config.go @@ -93,6 +93,7 @@ func Init() error { viper.MustBindEnv("kagent_grpc_tls", "KAGENT_GRPC_TLS") viper.MustBindEnv("kagent_grpc_ca_file", "KAGENT_GRPC_CA_FILE") viper.MustBindEnv("kagent_grpc_server_name", "KAGENT_GRPC_SERVER_NAME") + viper.MustBindEnv("output_format", "KAGENT_OUTPUT_FORMAT") viper.MustBindEnv("user_id", "KAGENT_USER_ID") if err := viper.ReadInConfig(); err != nil { diff --git a/go/core/test/e2e/interaction_test.go b/go/core/test/e2e/interaction_test.go index 4c739bb18..988142a95 100644 --- a/go/core/test/e2e/interaction_test.go +++ b/go/core/test/e2e/interaction_test.go @@ -10,6 +10,8 @@ import ( "net/http/httptest" "net/url" "os" + "os/exec" + "path/filepath" goruntime "runtime" "strings" "sync" @@ -85,6 +87,101 @@ func TestAgentInstanceAskUserSurvivesSuspension(t *testing.T) { } } +func TestCLIAgentInstanceDiscoveryAndInvoke(t *testing.T) { + target := interactionTarget(t) + fixture := newInteractionFixture(t, target, startInteractionMock(t)) + binary := buildKagentCLI(t) + baseArgs := []string{ + "--kagent-grpc-url", target, + "--kagent-grpc-tls=false", + "--namespace", "kagent", + "--user-id", "e2e", + } + + listOutput := runKagentCLI(t, fixture.ctx, binary, append(baseArgs, "get", "agent-instance")...) + if !strings.Contains(listOutput, fixture.instanceID) { + t.Fatalf("list AgentInstances stdout = %q, want instance %s", listOutput, fixture.instanceID) + } + + getArgs := append(append([]string{}, baseArgs...), "--output-format", "json", "get", "agent-instance", fixture.instanceID) + getOutput := runKagentCLI(t, fixture.ctx, binary, getArgs...) + if !json.Valid([]byte(getOutput)) || !strings.Contains(getOutput, fixture.instanceID) { + t.Fatalf("get AgentInstance stdout = %q, want JSON for instance %s", getOutput, fixture.instanceID) + } + + tests := []struct { + name string + format string + stream bool + }{ + {name: "table", format: "table"}, + {name: "table stream", format: "table", stream: true}, + {name: "json", format: "json"}, + {name: "json stream", format: "json", stream: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args := append(append([]string{}, baseArgs...), + "--output-format", tt.format, + "invoke", + "--agent-instance", fixture.instanceID, + "--task", "What is 2+2?", + ) + if tt.stream { + args = append(args, "--stream") + } + stdout := runKagentCLI(t, fixture.ctx, binary, args...) + if tt.format == "table" { + if got := strings.TrimSpace(stdout); got != "The answer is 4." { + t.Fatalf("CLI stdout = %q, want final response once", got) + } + return + } + lines := strings.Split(strings.TrimSpace(stdout), "\n") + if !tt.stream && len(lines) != 1 { + t.Fatalf("non-streaming JSON stdout has %d lines, want 1", len(lines)) + } + for _, line := range lines { + if !json.Valid([]byte(line)) { + t.Fatalf("CLI stdout line is not JSON: %q", line) + } + } + }) + } +} + +func runKagentCLI(t *testing.T, ctx context.Context, binary string, args ...string) string { + t.Helper() + command := exec.CommandContext(ctx, binary, args...) + command.Env = append(os.Environ(), "HOME="+t.TempDir()) + var stdout, stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + if err := command.Run(); err != nil { + t.Fatalf("run CLI: %v\nstderr: %s", err, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("CLI stderr = %q, want empty", stderr.String()) + } + return stdout.String() +} + +func buildKagentCLI(t *testing.T) string { + t.Helper() + _, source, _, ok := goruntime.Caller(0) + if !ok { + t.Fatal("locate interaction test source") + } + moduleRoot := filepath.Clean(filepath.Join(filepath.Dir(source), "../../..")) + binary := filepath.Join(t.TempDir(), "kagent") + command := exec.CommandContext(t.Context(), "go", "build", "-o", binary, "./core/cli/cmd/kagent") + command.Dir = moduleRoot + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("build CLI: %v\n%s", err, output) + } + return binary +} + func TestAgentInstanceCheckpoint(t *testing.T) { fixture := newInteractionFixture(t, interactionTarget(t), startInteractionMock(t)) _, _, task := fixture.send(t, "What is 2+2?") @@ -635,7 +732,7 @@ func createInteractionTemplate(t *testing.T, modelURL string) string { template := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "interaction-", Namespace: "kagent", - Labels: map[string]string{"kagent.dev/e2e-runtime": "kagent"}, + Labels: map[string]string{"kagent.dev/e2e-runtime": "kagent", "kagent.dev/harness": "kagent"}, }, Spec: v1alpha3.AgentTemplateSpec{ ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: model.Name}, @@ -670,7 +767,7 @@ func createMCPInteractionTemplate(t *testing.T, modelURL, mcpURL string) string template := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "mcp-interaction-", Namespace: "kagent", - Labels: map[string]string{"kagent.dev/e2e-runtime": "kagent"}, + Labels: map[string]string{"kagent.dev/e2e-runtime": "kagent", "kagent.dev/harness": "kagent"}, }, Spec: v1alpha3.AgentTemplateSpec{ ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: model.Name}, @@ -694,7 +791,7 @@ func createSharedInteractionTemplates(t *testing.T, modelURL string) (string, st child := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "shared-child-", Namespace: "kagent", - Labels: map[string]string{"kagent.dev/e2e-runtime": "kagent"}, + Labels: map[string]string{"kagent.dev/e2e-runtime": "kagent", "kagent.dev/harness": "kagent"}, }, Spec: v1alpha3.AgentTemplateSpec{ ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: childModel.Name}, @@ -706,7 +803,7 @@ func createSharedInteractionTemplates(t *testing.T, modelURL string) (string, st root := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "shared-root-", Namespace: "kagent", - Labels: map[string]string{"kagent.dev/e2e-runtime": "kagent"}, + Labels: map[string]string{"kagent.dev/e2e-runtime": "kagent", "kagent.dev/harness": "kagent"}, }, Spec: v1alpha3.AgentTemplateSpec{ ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: rootModel.Name}, From af6d7829010aaf63adb02bf3dad86fc2b1a338d5 Mon Sep 17 00:00:00 2001 From: Cody Hartsook Date: Thu, 27 Aug 2026 11:38:20 -0700 Subject: [PATCH 03/10] feat(cli): manage agent templates and instances Signed-off-by: Cody Hartsook --- go/core/cli/cmd/kagent/main.go | 65 ++++++- go/core/cli/cmd/kagent/main_test.go | 33 ++++ .../internal/cli/agentinstance/lifecycle.go | 163 ++++++++++++++++++ .../cli/agentinstance/lifecycle_test.go | 152 ++++++++++++++++ go/core/cli/internal/cli/agenttemplate/get.go | 128 ++++++++++++++ .../internal/cli/agenttemplate/get_test.go | 116 +++++++++++++ go/core/cli/internal/cli/output/output.go | 9 + .../cli/internal/cli/output/output_test.go | 11 ++ .../test/e2e/cli_catalog_lifecycle_test.go | 91 ++++++++++ 9 files changed, 766 insertions(+), 2 deletions(-) create mode 100644 go/core/cli/internal/cli/agentinstance/lifecycle.go create mode 100644 go/core/cli/internal/cli/agentinstance/lifecycle_test.go create mode 100644 go/core/cli/internal/cli/agenttemplate/get.go create mode 100644 go/core/cli/internal/cli/agenttemplate/get_test.go create mode 100644 go/core/test/e2e/cli_catalog_lifecycle_test.go diff --git a/go/core/cli/cmd/kagent/main.go b/go/core/cli/cmd/kagent/main.go index 41f3667ed..8b5fbf93b 100644 --- a/go/core/cli/cmd/kagent/main.go +++ b/go/core/cli/cmd/kagent/main.go @@ -11,6 +11,7 @@ import ( cli "github.com/kagent-dev/kagent/go/core/cli/internal/cli/agent" agentinstancecli "github.com/kagent-dev/kagent/go/core/cli/internal/cli/agentinstance" + agenttemplatecli "github.com/kagent-dev/kagent/go/core/cli/internal/cli/agenttemplate" "github.com/kagent-dev/kagent/go/core/cli/internal/cli/connection" "github.com/kagent-dev/kagent/go/core/cli/internal/cli/envdoc" "github.com/kagent-dev/kagent/go/core/cli/internal/cli/mcp" @@ -210,6 +211,22 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { getAgentInstanceCmd.Flags().Int32Var(&agentInstanceGetCfg.PageSize, "page-size", 0, "Number of AgentInstances to return (default 50, maximum 100)") getAgentInstanceCmd.Flags().StringVar(&agentInstanceGetCfg.PageToken, "page-token", "", "Token returned by the previous page") + agentTemplateGetCfg := &agenttemplatecli.GetCfg{Config: cfg} + getAgentTemplateCmd := &cobra.Command{ + Use: "agent-template [NAME]", + Short: "Get an AgentTemplate or list AgentTemplates", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + agentTemplateGetCfg.Name = "" + if len(args) == 1 { + agentTemplateGetCfg.Name = args[0] + } + return agenttemplatecli.GetCmd(cmd.Context(), agentTemplateGetCfg, cmd.OutOrStdout()) + }, + } + getAgentTemplateCmd.Flags().Int64Var(&agentTemplateGetCfg.PageSize, "page-size", 0, "Number of AgentTemplates to return (maximum 100)") + getAgentTemplateCmd.Flags().StringVar(&agentTemplateGetCfg.PageToken, "page-token", "", "Token returned by the previous page") + getSessionCmd := &cobra.Command{ Use: "session [session_id]", Short: "Get a session or list all sessions", @@ -269,7 +286,51 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { }, } - getCmd.AddCommand(getAgentInstanceCmd, getSessionCmd, getAgentCmd, getToolCmd) + getCmd.AddCommand(getAgentInstanceCmd, getAgentTemplateCmd, getSessionCmd, getAgentCmd, getToolCmd) + + createCmd := &cobra.Command{ + Use: "create", + Short: "Create a kagent resource", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return fmt.Errorf("resource type is required") + }, + } + createAgentInstanceCfg := &agentinstancecli.CreateCfg{Config: cfg} + createAgentInstanceCmd := &cobra.Command{ + Use: "agent-instance", + Short: "Create an AgentInstance", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return agentinstancecli.CreateCmd(cmd.Context(), createAgentInstanceCfg, cmd.OutOrStdout()) + }, + } + createAgentInstanceCmd.Flags().StringVar(&createAgentInstanceCfg.Harness, "harness", "", "Harness name") + createAgentInstanceCmd.Flags().StringVar(&createAgentInstanceCfg.AgentTemplate, "agent-template", "", "AgentTemplate name") + createAgentInstanceCmd.Flags().StringVar(&createAgentInstanceCfg.RequestID, "request-id", "", "Idempotency key (generated when omitted)") + _ = createAgentInstanceCmd.MarkFlagRequired("harness") + _ = createAgentInstanceCmd.MarkFlagRequired("agent-template") + createCmd.AddCommand(createAgentInstanceCmd) + + deleteCmd := &cobra.Command{ + Use: "delete", + Short: "Delete a kagent resource", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return fmt.Errorf("resource type is required") + }, + } + deleteAgentInstanceCfg := &agentinstancecli.DeleteCfg{Config: cfg} + deleteAgentInstanceCmd := &cobra.Command{ + Use: "agent-instance ID", + Short: "Delete an AgentInstance", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + deleteAgentInstanceCfg.InstanceID = args[0] + return agentinstancecli.DeleteCmd(cmd.Context(), deleteAgentInstanceCfg, cmd.OutOrStdout()) + }, + } + deleteCmd.AddCommand(deleteAgentInstanceCmd) initCfg := &cli.InitCfg{ Config: cfg, @@ -487,7 +548,7 @@ Examples: runCmd.Flags().StringVar(&runCfg.ProjectDir, "project-dir", "", "Project directory (default: current directory)") runCmd.Flags().BoolVar(&runCfg.Build, "build", false, "Rebuild the Docker image before running") - rootCmd.AddCommand(installCmd, uninstallCmd, invokeCmd, bugReportCmd, versionCmd, dashboardCmd, getCmd, initCmd, buildCmd, deployCmd, addMcpCmd, runCmd, mcp.NewMCPCmd(), envdoc.NewEnvCmd(), dbcli.NewCommandFromFunc(migrationSources(cfg))) + rootCmd.AddCommand(installCmd, uninstallCmd, invokeCmd, bugReportCmd, versionCmd, dashboardCmd, getCmd, createCmd, deleteCmd, initCmd, buildCmd, deployCmd, addMcpCmd, runCmd, mcp.NewMCPCmd(), envdoc.NewEnvCmd(), dbcli.NewCommandFromFunc(migrationSources(cfg))) return rootCmd } diff --git a/go/core/cli/cmd/kagent/main_test.go b/go/core/cli/cmd/kagent/main_test.go index 912b2beee..5bee52bc2 100644 --- a/go/core/cli/cmd/kagent/main_test.go +++ b/go/core/cli/cmd/kagent/main_test.go @@ -227,6 +227,39 @@ func TestRootCommandInvokeContract(t *testing.T) { } } +func TestRootCommandV2CatalogAndLifecycleContract(t *testing.T) { + cfg := &config.Config{ + KAgentURL: config.DefaultKAgentURL, + KAgentGRPCURL: config.DefaultKAgentGRPCURL, + OutputFormat: "table", + UserID: config.DefaultUserID, + } + rootCmd := newRootCommand(t.Context(), cfg) + + getTemplateCmd, _, err := rootCmd.Find([]string{"get", "agent-template"}) + require.NoError(t, err) + assert.Equal(t, "agent-template [NAME]", getTemplateCmd.Use) + for _, flag := range []string{"page-size", "page-token"} { + assert.NotNil(t, getTemplateCmd.Flags().Lookup(flag), "missing --%s", flag) + } + + createInstanceCmd, _, err := rootCmd.Find([]string{"create", "agent-instance"}) + require.NoError(t, err) + assert.Equal(t, "agent-instance", createInstanceCmd.Use) + for _, flag := range []string{"harness", "agent-template", "request-id"} { + assert.NotNil(t, createInstanceCmd.Flags().Lookup(flag), "missing --%s", flag) + } + + deleteInstanceCmd, _, err := rootCmd.Find([]string{"delete", "agent-instance"}) + require.NoError(t, err) + assert.Equal(t, "agent-instance ID", deleteInstanceCmd.Use) + + for _, command := range []string{"suspend", "resume"} { + _, _, err := rootCmd.Find([]string{command, "agent-instance"}) + assert.Error(t, err, "%s must not be exposed by the CLI", command) + } +} + func resetConfigState(t *testing.T) { t.Helper() diff --git a/go/core/cli/internal/cli/agentinstance/lifecycle.go b/go/core/cli/internal/cli/agentinstance/lifecycle.go new file mode 100644 index 000000000..f02caa5f6 --- /dev/null +++ b/go/core/cli/internal/cli/agentinstance/lifecycle.go @@ -0,0 +1,163 @@ +package agentinstance + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "github.com/google/uuid" + kagentclient "github.com/kagent-dev/kagent/go/api/client" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/kagent-dev/kagent/go/core/cli/internal/cli/connection" + clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" + "github.com/kagent-dev/kagent/go/core/cli/internal/config" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +// CreateCfg configures AgentInstance creation. +type CreateCfg struct { + Config *config.Config + Harness string + AgentTemplate string + RequestID string +} + +// DeleteCfg configures AgentInstance deletion. +type DeleteCfg struct { + Config *config.Config + InstanceID string +} + +// CreateCmd creates an AgentInstance. +func CreateCmd(ctx context.Context, cfg *CreateCfg, out io.Writer) (err error) { + format, err := clioutput.Parse(cfg.Config.OutputFormat) + if err != nil { + return err + } + if err := prepareCreateCfg(cfg); err != nil { + return err + } + + portForward, err := connection.Connect(ctx, cfg.Config) + if err != nil { + return fmt.Errorf("connect to kagent: %w", err) + } + if portForward != nil { + defer portForward.Stop() + } + + clientSet := cfg.Config.Client() + defer func() { + err = errors.Join(err, clientSet.Close()) + }() + return create(ctx, clientSet.AgentInstance, cfg, format, out) +} + +// DeleteCmd deletes an AgentInstance. +func DeleteCmd(ctx context.Context, cfg *DeleteCfg, out io.Writer) (err error) { + format, err := clioutput.Parse(cfg.Config.OutputFormat) + if err != nil { + return err + } + if err := validateDeleteCfg(cfg); err != nil { + return err + } + + portForward, err := connection.Connect(ctx, cfg.Config) + if err != nil { + return fmt.Errorf("connect to kagent: %w", err) + } + if portForward != nil { + defer portForward.Stop() + } + + clientSet := cfg.Config.Client() + defer func() { + err = errors.Join(err, clientSet.Close()) + }() + return deleteAgentInstance(ctx, clientSet.AgentInstance, cfg, format, out) +} + +func prepareCreateCfg(cfg *CreateCfg) error { + if cfg.Harness == "" { + return errors.New("harness is required") + } + if cfg.AgentTemplate == "" { + return errors.New("agent template is required") + } + if cfg.RequestID == "" { + cfg.RequestID = uuid.NewString() + return nil + } + if strings.TrimSpace(cfg.RequestID) != cfg.RequestID || len(cfg.RequestID) > 128 { + return errors.New("request ID must be 1-128 characters without surrounding whitespace") + } + return nil +} + +func validateDeleteCfg(cfg *DeleteCfg) error { + instanceID, err := uuid.Parse(cfg.InstanceID) + if err != nil { + return fmt.Errorf("invalid AgentInstance ID %q: %w", cfg.InstanceID, err) + } + cfg.InstanceID = instanceID.String() + return nil +} + +func create( + ctx context.Context, + client kagentclient.AgentInstance, + cfg *CreateCfg, + format clioutput.Format, + out io.Writer, +) error { + response, err := client.CreateAgentInstance(ctx, &apiv1alpha1.CreateAgentInstanceRequest{ + Namespace: cfg.Config.Namespace, Harness: cfg.Harness, + AgentTemplate: cfg.AgentTemplate, RequestId: cfg.RequestID, + }) + if err != nil { + return fmt.Errorf("create AgentInstance: %w", err) + } + if response.GetAgentInstance() == nil { + return errors.New("create AgentInstance returned no AgentInstance") + } + return writeLifecycleResult(out, format, response, response.GetAgentInstance()) +} + +func deleteAgentInstance( + ctx context.Context, + client kagentclient.AgentInstance, + cfg *DeleteCfg, + format clioutput.Format, + out io.Writer, +) error { + response, err := client.DeleteAgentInstance(ctx, &apiv1alpha1.DeleteAgentInstanceRequest{ + Namespace: cfg.Config.Namespace, AgentInstanceId: cfg.InstanceID, + }) + if status.Code(err) == codes.Aborted { + return fmt.Errorf("delete AgentInstance: another lifecycle operation is in progress; retry after it completes: %w", err) + } + if err != nil { + return fmt.Errorf("delete AgentInstance: %w", err) + } + if response.GetAgentInstance() == nil { + return errors.New("delete AgentInstance returned no AgentInstance") + } + return writeLifecycleResult(out, format, response, response.GetAgentInstance()) +} + +func writeLifecycleResult( + w io.Writer, + format clioutput.Format, + response proto.Message, + instance *apiv1alpha1.AgentInstance, +) error { + if format == clioutput.FormatJSON { + return clioutput.WriteProto(w, response) + } + return writeInstancesTable(w, []*apiv1alpha1.AgentInstance{instance}, "") +} diff --git a/go/core/cli/internal/cli/agentinstance/lifecycle_test.go b/go/core/cli/internal/cli/agentinstance/lifecycle_test.go new file mode 100644 index 000000000..9ff7319b3 --- /dev/null +++ b/go/core/cli/internal/cli/agentinstance/lifecycle_test.go @@ -0,0 +1,152 @@ +package agentinstance + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/google/uuid" + kagentclient "github.com/kagent-dev/kagent/go/api/client" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" + "github.com/kagent-dev/kagent/go/core/cli/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestPrepareCreateCfgValidation(t *testing.T) { + tests := []struct { + name string + config CreateCfg + wantErr string + }{ + {name: "valid", config: CreateCfg{Harness: "kagent", AgentTemplate: "smoke", RequestID: "replay-1"}}, + {name: "missing harness", config: CreateCfg{AgentTemplate: "smoke"}, wantErr: "harness is required"}, + {name: "missing template", config: CreateCfg{Harness: "kagent"}, wantErr: "agent template is required"}, + {name: "request ID whitespace", config: CreateCfg{Harness: "kagent", AgentTemplate: "smoke", RequestID: " replay-1"}, wantErr: "request ID"}, + {name: "request ID too long", config: CreateCfg{Harness: "kagent", AgentTemplate: "smoke", RequestID: strings.Repeat("a", 129)}, wantErr: "request ID"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := prepareCreateCfg(&tt.config) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestCreateAgentInstanceGeneratedRequestIDIsStable(t *testing.T) { + cfg := &CreateCfg{ + Config: &config.Config{Namespace: "kagent"}, Harness: "kagent", AgentTemplate: "smoke", + } + require.NoError(t, prepareCreateCfg(cfg)) + requestID := cfg.RequestID + require.NoError(t, uuid.Validate(requestID)) + require.NoError(t, prepareCreateCfg(cfg)) + assert.Equal(t, requestID, cfg.RequestID) + + client := &lifecycleAgentInstanceClient{createInstance: testInstance()} + require.NoError(t, create(t.Context(), client, cfg, clioutput.FormatTable, &bytes.Buffer{})) + assert.Equal(t, requestID, client.createRequest.GetRequestId()) +} + +func TestCreateAgentInstanceExplicitReplayIDAndOutput(t *testing.T) { + tests := []struct { + name string + format clioutput.Format + }{ + {name: "table", format: clioutput.FormatTable}, + {name: "json", format: clioutput.FormatJSON}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := &lifecycleAgentInstanceClient{createInstance: testInstance()} + cfg := &CreateCfg{ + Config: &config.Config{Namespace: "kagent"}, Harness: "kagent", + AgentTemplate: "smoke", RequestID: "replay-1", + } + var output bytes.Buffer + + require.NoError(t, prepareCreateCfg(cfg)) + require.NoError(t, create(t.Context(), client, cfg, tt.format, &output)) + assert.Equal(t, &apiv1alpha1.CreateAgentInstanceRequest{ + Namespace: "kagent", Harness: "kagent", AgentTemplate: "smoke", RequestId: "replay-1", + }, client.createRequest) + assert.Contains(t, output.String(), testInstanceID) + if tt.format == clioutput.FormatJSON { + assert.True(t, json.Valid(output.Bytes())) + } else { + assert.Contains(t, output.String(), "READY") + } + }) + } +} + +func TestDeleteAgentInstance(t *testing.T) { + client := &lifecycleAgentInstanceClient{deleteInstance: &apiv1alpha1.AgentInstance{ + Id: testInstanceID, State: apiv1alpha1.AgentInstanceState_AGENT_INSTANCE_STATE_DELETED, + }} + cfg := &DeleteCfg{Config: &config.Config{Namespace: "kagent"}, InstanceID: testInstanceID} + var output bytes.Buffer + + require.NoError(t, validateDeleteCfg(cfg)) + require.NoError(t, deleteAgentInstance(t.Context(), client, cfg, clioutput.FormatTable, &output)) + assert.Equal(t, &apiv1alpha1.DeleteAgentInstanceRequest{ + Namespace: "kagent", AgentInstanceId: testInstanceID, + }, client.deleteRequest) + assert.Contains(t, output.String(), testInstanceID) + assert.Contains(t, output.String(), "DELETED") +} + +func TestValidateDeleteCfg(t *testing.T) { + cfg := &DeleteCfg{InstanceID: "not-an-id"} + require.ErrorContains(t, validateDeleteCfg(cfg), "invalid AgentInstance ID") +} + +func TestDeleteAgentInstanceAborted(t *testing.T) { + client := &lifecycleAgentInstanceClient{deleteErr: status.Error(codes.Aborted, "conflict")} + cfg := &DeleteCfg{Config: &config.Config{Namespace: "kagent"}, InstanceID: testInstanceID} + + err := deleteAgentInstance(t.Context(), client, cfg, clioutput.FormatTable, &bytes.Buffer{}) + require.ErrorContains(t, err, "another lifecycle operation is in progress; retry after it completes") + assert.Equal(t, codes.Aborted, status.Code(err)) +} + +type lifecycleAgentInstanceClient struct { + kagentclient.AgentInstance + createInstance *apiv1alpha1.AgentInstance + deleteInstance *apiv1alpha1.AgentInstance + createRequest *apiv1alpha1.CreateAgentInstanceRequest + deleteRequest *apiv1alpha1.DeleteAgentInstanceRequest + createErr error + deleteErr error +} + +func (c *lifecycleAgentInstanceClient) CreateAgentInstance( + _ context.Context, + request *apiv1alpha1.CreateAgentInstanceRequest, +) (*apiv1alpha1.CreateAgentInstanceResponse, error) { + c.createRequest = request + if c.createErr != nil { + return nil, c.createErr + } + return &apiv1alpha1.CreateAgentInstanceResponse{AgentInstance: c.createInstance}, nil +} + +func (c *lifecycleAgentInstanceClient) DeleteAgentInstance( + _ context.Context, + request *apiv1alpha1.DeleteAgentInstanceRequest, +) (*apiv1alpha1.DeleteAgentInstanceResponse, error) { + c.deleteRequest = request + if c.deleteErr != nil { + return nil, c.deleteErr + } + return &apiv1alpha1.DeleteAgentInstanceResponse{AgentInstance: c.deleteInstance}, nil +} diff --git a/go/core/cli/internal/cli/agenttemplate/get.go b/go/core/cli/internal/cli/agenttemplate/get.go new file mode 100644 index 000000000..40a0527b1 --- /dev/null +++ b/go/core/cli/internal/cli/agenttemplate/get.go @@ -0,0 +1,128 @@ +// Package agenttemplate implements AgentTemplate CLI commands. +package agenttemplate + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/jedib0t/go-pretty/v6/table" + clientset "github.com/kagent-dev/kagent/go/api/clientset/versioned" + typedapiv1alpha3 "github.com/kagent-dev/kagent/go/api/clientset/versioned/typed/api/v1alpha3" + apiv1alpha3 "github.com/kagent-dev/kagent/go/api/v1alpha3" + clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" + "github.com/kagent-dev/kagent/go/core/cli/internal/config" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/clientcmd" +) + +const maxPageSize = 100 + +// GetCfg configures AgentTemplate get and list operations. +type GetCfg struct { + Config *config.Config + Name string + PageSize int64 + PageToken string +} + +// GetCmd gets one AgentTemplate or lists AgentTemplates through Kubernetes. +func GetCmd(ctx context.Context, cfg *GetCfg, out io.Writer) error { + format, err := clioutput.Parse(cfg.Config.OutputFormat) + if err != nil { + return err + } + if err := validateGetCfg(cfg); err != nil { + return err + } + + restConfig, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + clientcmd.NewDefaultClientConfigLoadingRules(), + &clientcmd.ConfigOverrides{}, + ).ClientConfig() + if err != nil { + return fmt.Errorf("load Kubernetes client config: %w", err) + } + clients, err := clientset.NewForConfig(restConfig) + if err != nil { + return fmt.Errorf("create Kubernetes client: %w", err) + } + return get(ctx, clients.ApiV1alpha3().AgentTemplates(cfg.Config.Namespace), cfg, format, out) +} + +func validateGetCfg(cfg *GetCfg) error { + if cfg.PageSize < 0 || cfg.PageSize > maxPageSize { + return fmt.Errorf("page size must be between 1 and %d, or 0 for the server default", maxPageSize) + } + if cfg.Name != "" && (cfg.PageSize != 0 || cfg.PageToken != "") { + return errors.New("pagination flags cannot be used when getting one AgentTemplate") + } + return nil +} + +func get( + ctx context.Context, + client typedapiv1alpha3.AgentTemplateInterface, + cfg *GetCfg, + format clioutput.Format, + out io.Writer, +) error { + if cfg.Name != "" { + template, err := client.Get(ctx, cfg.Name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("get AgentTemplate %q: %w", cfg.Name, err) + } + if format == clioutput.FormatJSON { + return clioutput.WriteJSON(out, template) + } + return writeTemplatesTable(out, []apiv1alpha3.AgentTemplate{*template}, false, "") + } + + templates, err := client.List(ctx, metav1.ListOptions{Limit: cfg.PageSize, Continue: cfg.PageToken}) + if err != nil { + return fmt.Errorf("list AgentTemplates: %w", err) + } + if format == clioutput.FormatJSON { + return clioutput.WriteJSON(out, templates) + } + return writeTemplatesTable(out, templates.Items, true, templates.Continue) +} + +func writeTemplatesTable(w io.Writer, templates []apiv1alpha3.AgentTemplate, list bool, nextPageToken string) error { + tw := table.NewWriter() + tw.AppendHeader(table.Row{"NAME", "HARNESS", "READY", "CREATED"}) + for i := range templates { + template := &templates[i] + created := "" + if !template.CreationTimestamp.IsZero() { + created = template.CreationTimestamp.Time.UTC().Format(time.RFC3339) + } + if len(template.Status.Harnesses) == 0 { + tw.AppendRow(table.Row{template.Name, "", "UNKNOWN", created}) + continue + } + for j := range template.Status.Harnesses { + harness := &template.Status.Harnesses[j] + ready := "UNKNOWN" + if condition := meta.FindStatusCondition(harness.Conditions, apiv1alpha3.AgentTemplateConditionReady); condition != nil { + ready = strings.ToUpper(string(condition.Status)) + } + tw.AppendRow(table.Row{template.Name, harness.Harness, ready, created}) + } + } + + output := tw.Render() + if list { + if nextPageToken != "" { + output += "\nNext page token: " + nextPageToken + } + } + if _, err := fmt.Fprintln(w, output); err != nil { + return fmt.Errorf("write AgentTemplate output: %w", err) + } + return nil +} diff --git a/go/core/cli/internal/cli/agenttemplate/get_test.go b/go/core/cli/internal/cli/agenttemplate/get_test.go new file mode 100644 index 000000000..20076fdde --- /dev/null +++ b/go/core/cli/internal/cli/agenttemplate/get_test.go @@ -0,0 +1,116 @@ +package agenttemplate + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + clientfake "github.com/kagent-dev/kagent/go/api/clientset/versioned/fake" + apiv1alpha3 "github.com/kagent-dev/kagent/go/api/v1alpha3" + clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" + "github.com/kagent-dev/kagent/go/core/cli/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + k8stesting "k8s.io/client-go/testing" +) + +func TestValidateGetCfg(t *testing.T) { + tests := []struct { + name string + cfg GetCfg + wantErr string + }{ + {name: "list"}, + {name: "list page", cfg: GetCfg{PageSize: 10, PageToken: "next"}}, + {name: "get", cfg: GetCfg{Name: "template"}}, + {name: "negative page size", cfg: GetCfg{PageSize: -1}, wantErr: "page size"}, + {name: "large page size", cfg: GetCfg{PageSize: 101}, wantErr: "page size"}, + {name: "get with pagination", cfg: GetCfg{Name: "template", PageSize: 10}, wantErr: "pagination"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateGetCfg(&tt.cfg) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestGetAgentTemplatesTableReportsHarnessReadiness(t *testing.T) { + clientSet := clientfake.NewSimpleClientset() + clientSet.PrependReactor("list", "agenttemplates", func(action k8stesting.Action) (bool, runtime.Object, error) { + options := action.(interface{ GetListOptions() metav1.ListOptions }).GetListOptions() + assert.Equal(t, int64(3), options.Limit) + assert.Equal(t, "previous-page", options.Continue) + return true, &apiv1alpha3.AgentTemplateList{ + ListMeta: metav1.ListMeta{Continue: "next-page"}, + Items: []apiv1alpha3.AgentTemplate{ + templateWithReadyCondition("ready-template", "kagent", metav1.ConditionTrue), + templateWithReadyCondition("not-ready-template", "codex", metav1.ConditionFalse), + {ObjectMeta: metav1.ObjectMeta{Name: "unknown-template"}}, + }, + }, nil + }) + var output bytes.Buffer + + err := get(context.Background(), clientSet.ApiV1alpha3().AgentTemplates("kagent"), &GetCfg{ + Config: &config.Config{Namespace: "kagent"}, PageSize: 3, PageToken: "previous-page", + }, clioutput.FormatTable, &output) + require.NoError(t, err) + assert.Contains(t, output.String(), "ready-template") + assert.Contains(t, output.String(), "kagent") + assert.Contains(t, output.String(), "TRUE") + assert.Contains(t, output.String(), "not-ready-template") + assert.Contains(t, output.String(), "FALSE") + assert.Contains(t, output.String(), "unknown-template") + assert.Contains(t, output.String(), "UNKNOWN") + assert.NotContains(t, output.String(), "Items:") + assert.Contains(t, output.String(), "Next page token: next-page") +} + +func TestGetAgentTemplatesJSONPreservesListMetadata(t *testing.T) { + clientSet := clientfake.NewSimpleClientset() + clientSet.PrependReactor("list", "agenttemplates", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, &apiv1alpha3.AgentTemplateList{ + ListMeta: metav1.ListMeta{Continue: "next-page"}, + Items: []apiv1alpha3.AgentTemplate{ + templateWithReadyCondition("ready-template", "kagent", metav1.ConditionTrue), + templateWithReadyCondition("not-ready-template", "codex", metav1.ConditionFalse), + {ObjectMeta: metav1.ObjectMeta{Name: "unknown-template"}}, + }, + }, nil + }) + var output bytes.Buffer + + err := get(context.Background(), clientSet.ApiV1alpha3().AgentTemplates("kagent"), &GetCfg{ + Config: &config.Config{Namespace: "kagent"}, + }, clioutput.FormatJSON, &output) + require.NoError(t, err) + assert.True(t, json.Valid(output.Bytes())) + assert.Contains(t, output.String(), `"continue":"next-page"`) + assert.Contains(t, output.String(), `"name":"ready-template"`) + assert.Contains(t, output.String(), `"status":"True"`) + assert.Contains(t, output.String(), `"name":"not-ready-template"`) + assert.Contains(t, output.String(), `"status":"False"`) + assert.Contains(t, output.String(), `"name":"unknown-template"`) +} + +func templateWithReadyCondition(name, harness string, status metav1.ConditionStatus) apiv1alpha3.AgentTemplate { + return apiv1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Status: apiv1alpha3.AgentTemplateStatus{Harnesses: []apiv1alpha3.AgentTemplateHarnessStatus{{ + Harness: harness, + Conditions: []metav1.Condition{{ + Type: apiv1alpha3.AgentTemplateConditionReady, Status: status, Reason: "Test", Message: "test", + }}, + }}}, + } +} diff --git a/go/core/cli/internal/cli/output/output.go b/go/core/cli/internal/cli/output/output.go index f110c3991..3b327168a 100644 --- a/go/core/cli/internal/cli/output/output.go +++ b/go/core/cli/internal/cli/output/output.go @@ -2,6 +2,7 @@ package output import ( + "encoding/json" "fmt" "io" @@ -41,3 +42,11 @@ func WriteProto(w io.Writer, message proto.Message) error { } return nil } + +// WriteJSON writes one JSON value as a line. +func WriteJSON(w io.Writer, value any) error { + if err := json.NewEncoder(w).Encode(value); err != nil { + return fmt.Errorf("write JSON output: %w", err) + } + return nil +} diff --git a/go/core/cli/internal/cli/output/output_test.go b/go/core/cli/internal/cli/output/output_test.go index c7accca30..707c86f98 100644 --- a/go/core/cli/internal/cli/output/output_test.go +++ b/go/core/cli/internal/cli/output/output_test.go @@ -1,6 +1,8 @@ package output import ( + "bytes" + "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -17,3 +19,12 @@ func TestParse(t *testing.T) { _, err := Parse("yaml") require.Error(t, err) } + +func TestWriteJSON(t *testing.T) { + var output bytes.Buffer + require.NoError(t, WriteJSON(&output, struct { + Name string `json:"name"` + }{Name: "template"})) + assert.True(t, json.Valid(output.Bytes())) + assert.JSONEq(t, `{"name":"template"}`, output.String()) +} diff --git a/go/core/test/e2e/cli_catalog_lifecycle_test.go b/go/core/test/e2e/cli_catalog_lifecycle_test.go new file mode 100644 index 000000000..fe5cc2f54 --- /dev/null +++ b/go/core/test/e2e/cli_catalog_lifecycle_test.go @@ -0,0 +1,91 @@ +package e2e_test + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/google/uuid" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "google.golang.org/protobuf/encoding/protojson" + "k8s.io/client-go/tools/clientcmd" +) + +func TestCLIAgentTemplateCatalogAndInstanceLifecycle(t *testing.T) { + if os.Getenv("KUBECONFIG") == "" { + t.Setenv("KUBECONFIG", clientcmd.RecommendedHomeFile) + } + target := interactionTarget(t) + templateName := createInteractionTemplate(t, startInteractionMock(t)) + binary := buildKagentCLI(t) + baseArgs := []string{ + "--kagent-grpc-url", target, + "--kagent-grpc-tls=false", + "--namespace", "kagent", + "--user-id", "e2e", + } + run := func(ctx context.Context, args ...string) string { + return runKagentCLI(t, ctx, binary, append(append([]string{}, baseArgs...), args...)...) + } + + listedTemplates := run(t.Context(), "get", "agent-template") + if !strings.Contains(listedTemplates, templateName) || !strings.Contains(listedTemplates, "TRUE") { + t.Fatalf("list AgentTemplates stdout = %q, want ready template %s", listedTemplates, templateName) + } + templateJSON := run(t.Context(), "--output-format", "json", "get", "agent-template", templateName) + if !json.Valid([]byte(templateJSON)) || !strings.Contains(templateJSON, `"name":"`+templateName+`"`) || + !strings.Contains(templateJSON, `"status":"True"`) { + t.Fatalf("get AgentTemplate stdout = %q, want ready template %s as JSON", templateJSON, templateName) + } + + requestID := uuid.NewString() + createArgs := []string{ + "--output-format", "json", "create", "agent-instance", + "--harness", "kagent", "--agent-template", templateName, "--request-id", requestID, + } + createdJSON := run(t.Context(), createArgs...) + var created apiv1alpha1.CreateAgentInstanceResponse + if err := protojson.Unmarshal([]byte(createdJSON), &created); err != nil { + t.Fatalf("decode create AgentInstance stdout %q: %v", createdJSON, err) + } + instance := created.GetAgentInstance() + if instance.GetId() == "" || instance.GetState() != apiv1alpha1.AgentInstanceState_AGENT_INSTANCE_STATE_READY { + t.Fatalf("created AgentInstance = %#v, want ID and READY state", instance) + } + deleted := false + t.Cleanup(func() { + if !deleted { + run(context.Background(), "delete", "agent-instance", instance.GetId()) + } + }) + + replayedJSON := run(t.Context(), createArgs...) + var replayed apiv1alpha1.CreateAgentInstanceResponse + if err := protojson.Unmarshal([]byte(replayedJSON), &replayed); err != nil { + t.Fatalf("decode replayed create stdout %q: %v", replayedJSON, err) + } + if replayed.GetAgentInstance().GetId() != instance.GetId() { + t.Fatalf("replayed create ID = %q, want %q", replayed.GetAgentInstance().GetId(), instance.GetId()) + } + + listedInstances := run(t.Context(), "get", "agent-instance") + if !strings.Contains(listedInstances, instance.GetId()) { + t.Fatalf("list AgentInstances stdout = %q, want instance %s", listedInstances, instance.GetId()) + } + gotInstance := run(t.Context(), "--output-format", "json", "get", "agent-instance", instance.GetId()) + if !json.Valid([]byte(gotInstance)) || !strings.Contains(gotInstance, instance.GetId()) { + t.Fatalf("get AgentInstance stdout = %q, want instance %s as JSON", gotInstance, instance.GetId()) + } + + deletedJSON := run(t.Context(), "--output-format", "json", "delete", "agent-instance", instance.GetId()) + deleted = true + var deletedResponse apiv1alpha1.DeleteAgentInstanceResponse + if err := protojson.Unmarshal([]byte(deletedJSON), &deletedResponse); err != nil { + t.Fatalf("decode delete AgentInstance stdout %q: %v", deletedJSON, err) + } + if deletedResponse.GetAgentInstance().GetState() != apiv1alpha1.AgentInstanceState_AGENT_INSTANCE_STATE_DELETED { + t.Fatalf("deleted AgentInstance state = %s, want DELETED", deletedResponse.GetAgentInstance().GetState()) + } +} From ac51cca32ad6ca945aee0255babea39d486cb6c9 Mon Sep 17 00:00:00 2001 From: Cody Hartsook Date: Thu, 27 Aug 2026 11:38:28 -0700 Subject: [PATCH 04/10] refactor(cli): remove legacy agent and BYO commands Signed-off-by: Cody Hartsook --- go/core/cli/cmd/kagent/main.go | 315 +---- go/core/cli/cmd/kagent/main_test.go | 40 +- .../adk/python/dice-agent-instruction.md | 15 - .../agent/frameworks/adk/python/generator.go | 134 --- .../adk/python/templates/.python-version | 1 - .../adk/python/templates/Dockerfile.tmpl | 20 - .../adk/python/templates/README.md.tmpl | 33 - .../python/templates/agent/__init__.py.tmpl | 2 - .../templates/agent/agent-card.json.tmpl | 19 - .../adk/python/templates/agent/agent.py.tmpl | 81 -- .../python/templates/agent/mcp_tools.py.tmpl | 105 -- .../python/templates/docker-compose.yaml.tmpl | 41 - .../python/templates/mcp_server/Dockerfile | 25 - .../templates/mcp_server/config.yaml.tmpl | 39 - .../adk/python/templates/pyproject.toml.tmpl | 10 - .../agent/frameworks/common/base_generator.go | 65 -- .../frameworks/common/manifest_manager.go | 165 --- .../internal/agent/frameworks/frameworks.go | 27 - go/core/cli/internal/cli/agent/add_mcp.go | 209 ---- .../cli/internal/cli/agent/add_mcp_test.go | 164 --- go/core/cli/internal/cli/agent/build.go | 206 ---- go/core/cli/internal/cli/agent/build_test.go | 187 --- go/core/cli/internal/cli/agent/deploy.go | 1032 ----------------- go/core/cli/internal/cli/agent/deploy_test.go | 201 ---- .../internal/cli/agent/error_handling_test.go | 526 --------- go/core/cli/internal/cli/agent/format.go | 53 - go/core/cli/internal/cli/agent/get.go | 144 --- go/core/cli/internal/cli/agent/init.go | 128 -- .../cli/agent/init_validation_test.go | 120 -- .../internal/cli/agent/integration_test.go | 225 ---- go/core/cli/internal/cli/agent/run.go | 184 --- go/core/cli/internal/cli/agent/run_test.go | 108 -- go/core/cli/internal/cli/agent/utils.go | 238 ---- go/core/cli/internal/common/image/image.go | 26 - .../internal/tui/dialogs/mcp_server_wizard.go | 1019 ---------------- 35 files changed, 49 insertions(+), 5858 deletions(-) delete mode 100644 go/core/cli/internal/agent/frameworks/adk/python/dice-agent-instruction.md delete mode 100644 go/core/cli/internal/agent/frameworks/adk/python/generator.go delete mode 100644 go/core/cli/internal/agent/frameworks/adk/python/templates/.python-version delete mode 100644 go/core/cli/internal/agent/frameworks/adk/python/templates/Dockerfile.tmpl delete mode 100644 go/core/cli/internal/agent/frameworks/adk/python/templates/README.md.tmpl delete mode 100644 go/core/cli/internal/agent/frameworks/adk/python/templates/agent/__init__.py.tmpl delete mode 100644 go/core/cli/internal/agent/frameworks/adk/python/templates/agent/agent-card.json.tmpl delete mode 100644 go/core/cli/internal/agent/frameworks/adk/python/templates/agent/agent.py.tmpl delete mode 100644 go/core/cli/internal/agent/frameworks/adk/python/templates/agent/mcp_tools.py.tmpl delete mode 100644 go/core/cli/internal/agent/frameworks/adk/python/templates/docker-compose.yaml.tmpl delete mode 100644 go/core/cli/internal/agent/frameworks/adk/python/templates/mcp_server/Dockerfile delete mode 100644 go/core/cli/internal/agent/frameworks/adk/python/templates/mcp_server/config.yaml.tmpl delete mode 100644 go/core/cli/internal/agent/frameworks/adk/python/templates/pyproject.toml.tmpl delete mode 100644 go/core/cli/internal/agent/frameworks/common/base_generator.go delete mode 100644 go/core/cli/internal/agent/frameworks/common/manifest_manager.go delete mode 100644 go/core/cli/internal/agent/frameworks/frameworks.go delete mode 100644 go/core/cli/internal/cli/agent/add_mcp.go delete mode 100644 go/core/cli/internal/cli/agent/add_mcp_test.go delete mode 100644 go/core/cli/internal/cli/agent/build.go delete mode 100644 go/core/cli/internal/cli/agent/build_test.go delete mode 100644 go/core/cli/internal/cli/agent/deploy.go delete mode 100644 go/core/cli/internal/cli/agent/deploy_test.go delete mode 100644 go/core/cli/internal/cli/agent/error_handling_test.go delete mode 100644 go/core/cli/internal/cli/agent/format.go delete mode 100644 go/core/cli/internal/cli/agent/get.go delete mode 100644 go/core/cli/internal/cli/agent/init.go delete mode 100644 go/core/cli/internal/cli/agent/init_validation_test.go delete mode 100644 go/core/cli/internal/cli/agent/integration_test.go delete mode 100644 go/core/cli/internal/cli/agent/run.go delete mode 100644 go/core/cli/internal/cli/agent/run_test.go delete mode 100644 go/core/cli/internal/cli/agent/utils.go delete mode 100644 go/core/cli/internal/common/image/image.go delete mode 100644 go/core/cli/internal/tui/dialogs/mcp_server_wizard.go diff --git a/go/core/cli/cmd/kagent/main.go b/go/core/cli/cmd/kagent/main.go index 8b5fbf93b..ffa36021c 100644 --- a/go/core/cli/cmd/kagent/main.go +++ b/go/core/cli/cmd/kagent/main.go @@ -18,7 +18,6 @@ import ( clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" "github.com/kagent-dev/kagent/go/core/cli/internal/config" "github.com/kagent-dev/kagent/go/core/cli/internal/profiles" - "github.com/kagent-dev/kagent/go/core/cli/internal/tui" dbcli "github.com/kagent-dev/kagent/go/core/pkg/cli/db" dbmigrate "github.com/kagent-dev/kagent/go/core/pkg/cli/db/migrate" "github.com/kagent-dev/kagent/go/core/pkg/migrations" @@ -68,8 +67,8 @@ func loadConfig() (*config.Config, error) { func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { rootCmd := &cobra.Command{ Use: "kagent", - Short: "kagent is a CLI and TUI for kagent", - Long: "kagent is a CLI and TUI for kagent", + Short: "kagent is a CLI for kagent", + Long: "kagent is a CLI for kagent", SilenceErrors: true, SilenceUsage: true, PersistentPreRunE: func(_ *cobra.Command, _ []string) error { @@ -79,8 +78,8 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { _, err := clioutput.Parse(cfg.OutputFormat) return err }, - Run: func(cmd *cobra.Command, args []string) { - runInteractive(cmd, args, cfg) + RunE: func(_ *cobra.Command, _ []string) error { + return fmt.Errorf("interactive mode is not available in this release; use `kagent get agent-instance` and `kagent invoke`") }, } rootCmd.SetContext(ctx) @@ -227,66 +226,7 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { getAgentTemplateCmd.Flags().Int64Var(&agentTemplateGetCfg.PageSize, "page-size", 0, "Number of AgentTemplates to return (maximum 100)") getAgentTemplateCmd.Flags().StringVar(&agentTemplateGetCfg.PageToken, "page-token", "", "Token returned by the previous page") - getSessionCmd := &cobra.Command{ - Use: "session [session_id]", - Short: "Get a session or list all sessions", - Long: `Get a session by ID or list all sessions`, - Run: func(cmd *cobra.Command, args []string) { - pf, err := connection.Connect(cmd.Context(), cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "Error connecting to server: %v\n", err) - return - } - if pf != nil { - defer pf.Stop() - } - resourceName := "" - if len(args) > 0 { - resourceName = args[0] - } - cli.GetSessionCmd(cfg, resourceName) - }, - } - - getAgentCmd := &cobra.Command{ - Use: "agent [agent_name]", - Short: "Get an agent or list all agents", - Long: `Get an agent by name or list all agents`, - Run: func(cmd *cobra.Command, args []string) { - pf, err := connection.Connect(cmd.Context(), cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "Error connecting to server: %v\n", err) - return - } - if pf != nil { - defer pf.Stop() - } - resourceName := "" - if len(args) > 0 { - resourceName = args[0] - } - cli.GetAgentCmd(cfg, resourceName) - }, - } - - getToolCmd := &cobra.Command{ - Use: "tool", - Short: "Get tools", - Long: `List all available tools`, - Run: func(cmd *cobra.Command, args []string) { - pf, err := connection.Connect(cmd.Context(), cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "Error connecting to server: %v\n", err) - return - } - if pf != nil { - defer pf.Stop() - } - cli.GetToolCmd(cfg) - }, - } - - getCmd.AddCommand(getAgentInstanceCmd, getAgentTemplateCmd, getSessionCmd, getAgentCmd, getToolCmd) + getCmd.AddCommand(getAgentInstanceCmd, getAgentTemplateCmd) createCmd := &cobra.Command{ Use: "create", @@ -332,223 +272,7 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { } deleteCmd.AddCommand(deleteAgentInstanceCmd) - initCfg := &cli.InitCfg{ - Config: cfg, - } - - initCmd := &cobra.Command{ - Use: "init [framework] [language] [agent-name]", - Short: "Initialize a new agent project", - Long: `Initialize a new agent project using the specified framework and language. - -You can customize the root agent instructions using the --instruction-file flag. -You can select a specific model using --model-provider and --model-name flags. -If no custom instruction file is provided, a default dice-rolling instruction will be used. -If no model is specified, the agent will need to be configured later. - -Examples: - kagent init adk python dice - kagent init adk python dice --instruction-file instructions.md - kagent init adk python dice --model-provider Gemini --model-name gemini-2.5-flash`, - Args: cobra.ExactArgs(3), - Run: func(cmd *cobra.Command, args []string) { - initCfg.Framework = args[0] - initCfg.Language = args[1] - initCfg.AgentName = args[2] - - if err := cli.InitCmd(initCfg); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - }, - Example: `kagent init adk python dice`, - } - - // Add flags for custom instructions and model selection - initCmd.Flags().StringVar(&initCfg.InstructionFile, "instruction-file", "", "Path to file containing custom instructions for the root agent") - initCmd.Flags().StringVar(&initCfg.ModelProvider, "model-provider", "Gemini", "Model provider (OpenAI, Anthropic, Gemini)") - initCmd.Flags().StringVar(&initCfg.ModelName, "model-name", "gemini-2.5-flash", "Model name (e.g., gpt-4, claude-3-5-sonnet, gemini-2.5-flash)") - initCmd.Flags().StringVar(&initCfg.Description, "description", "", "Description for the agent") - - buildCfg := &cli.BuildCfg{ - Config: cfg, - } - - buildCmd := &cobra.Command{ - Use: "build [project-directory]", - Short: "Build a Docker images for an agent project", - Long: `Build Docker images for an agent project created with the init command. - -This command will look for a kagent.yaml file in the specified project directory and build Docker images using docker build. The images can optionally be pushed to a registry. - -Image naming: -- If --image is provided, it will be used as the full image specification (e.g., ghcr.io/myorg/my-agent:v1.0.0) -- Otherwise, defaults to localhost:5001/{agentName}:latest where agentName is loaded from kagent.yaml - -Examples: - kagent build ./my-agent - kagent build ./my-agent --image ghcr.io/myorg/my-agent:v1.0.0 - kagent build ./my-agent --image ghcr.io/myorg/my-agent:v1.0.0 --push`, - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - buildCfg.ProjectDir = args[0] - - if err := cli.BuildCmd(buildCfg); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - }, - Example: `kagent build ./my-agent`, - } - - // Add flags for build command - buildCmd.Flags().StringVar(&buildCfg.Image, "image", "", "Full image specification (e.g., ghcr.io/myorg/my-agent:v1.0.0)") - buildCmd.Flags().BoolVar(&buildCfg.Push, "push", false, "Push the image to the registry") - buildCmd.Flags().StringVar(&buildCfg.Platform, "platform", "", "Target platform for Docker build (e.g., linux/amd64, linux/arm64)") - - deployCfg := &cli.DeployCfg{ - Config: cfg, - } - - deployCmd := &cobra.Command{ - Use: "deploy [project-directory]", - Short: "Deploy an agent to Kubernetes", - Long: `Deploy an agent to Kubernetes. - -This command will read the kagent.yaml file from the specified project directory, -load environment variables from a .env file, and create an Agent CRD with necessary secrets. - -The command will: -1. Load the agent configuration from kagent.yaml -2. Load environment variables from a .env file (including the model provider API key) -3. Create Kubernetes secrets for environment variables and API keys -4. Create an Agent CRD with the appropriate configuration - -API Key Requirements: - The .env file MUST contain the API key for your model provider: - - Anthropic: ANTHROPIC_API_KEY=your-key-here - - OpenAI: OPENAI_API_KEY=your-key-here - - Gemini: GOOGLE_API_KEY=your-key-here - -Environment Variables: - --env-file: REQUIRED. Path to a .env file containing environment variables (including API keys). - Variables will be stored in a Kubernetes secret and mounted as environment variables. - -Dry-Run Mode: - --dry-run: Output YAML manifests without applying them to the cluster. This is useful - for previewing changes or for use with GitOps workflows. - -Examples: - kagent deploy ./my-agent --env-file .env - kagent deploy ./my-agent --env-file .env --image "myregistry/myagent:v1.0" - kagent deploy ./my-agent --env-file .env --namespace "my-namespace" - kagent deploy ./my-agent --env-file .env --dry-run > manifests.yaml`, - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - deployCfg.ProjectDir = args[0] - - // Create Kubernetes client (skip in dry-run mode) - var k8sClient client.Client - var err error - if !deployCfg.DryRun { - k8sClient, err = cli.CreateKubernetesClient() - if err != nil { - fmt.Fprintf(os.Stderr, "Error creating Kubernetes client: %v\n", err) - os.Exit(1) - } - } - - if err := cli.DeployCmd(cmd.Context(), k8sClient, deployCfg); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - }, - Example: `kagent deploy ./my-agent --env-file .env`, - } - - // Add flags for deploy command - deployCmd.Flags().StringVarP(&deployCfg.Image, "image", "i", "", "Image to use (defaults to localhost:5001/{agentName}:latest)") - deployCmd.Flags().StringVar(&deployCfg.EnvFile, "env-file", "", "Path to .env file containing environment variables (including API keys)") - deployCmd.Flags().StringVar(&deployCfg.Config.Namespace, "namespace", cfg.Namespace, "Kubernetes namespace to deploy to") - deployCmd.Flags().BoolVar(&deployCfg.DryRun, "dry-run", false, "Output YAML manifests without applying them to the cluster") - deployCmd.Flags().StringVar(&deployCfg.Platform, "platform", "", "Target platform for Docker build (e.g., linux/amd64, linux/arm64)") - - // add-mcp command - addMcpCfg := &cli.AddMcpCfg{Config: cfg} - addMcpCmd := &cobra.Command{ - Use: "add-mcp [name] [args...]", - Short: "Add an MCP server entry to kagent.yaml", - Long: `Add an MCP server entry to kagent.yaml. Use flags for non-interactive setup or run without flags to open the wizard.`, - Args: cobra.ArbitraryArgs, - Run: func(cmd *cobra.Command, args []string) { - if len(args) > 0 { - addMcpCfg.Name = args[0] - if len(args) > 1 && addMcpCfg.Command != "" { - addMcpCfg.Args = append(addMcpCfg.Args, args[1:]...) - } - } - if err := cli.AddMcpCmd(addMcpCfg); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - }, - } - - // Flags for non-interactive usage - addMcpCmd.Flags().StringVar(&addMcpCfg.ProjectDir, "project-dir", "", "Project directory (default: current directory)") - addMcpCmd.Flags().StringVar(&addMcpCfg.RemoteURL, "remote", "", "Remote MCP server URL (http/https)") - addMcpCmd.Flags().StringSliceVar(&addMcpCfg.Headers, "header", nil, "HTTP header for remote MCP in KEY=VALUE format (repeatable, supports ${VAR} for env vars)") - addMcpCmd.Flags().StringVar(&addMcpCfg.Command, "command", "", "Command to run MCP server (e.g., npx, uvx, kmcp, or a binary)") - addMcpCmd.Flags().StringSliceVar(&addMcpCfg.Args, "arg", nil, "Command argument (repeatable)") - addMcpCmd.Flags().StringSliceVar(&addMcpCfg.Env, "env", nil, "Environment variable in KEY=VALUE format (repeatable)") - addMcpCmd.Flags().StringVar(&addMcpCfg.Image, "image", "", "Container image (optional; mutually exclusive with --build)") - addMcpCmd.Flags().StringVar(&addMcpCfg.Build, "build", "", "Container build (optional; mutually exclusive with --image)") - - runCfg := &cli.RunCfg{ - Config: cfg, - } - - runCmd := &cobra.Command{ - Use: "run [project-directory]", - Short: "Run agent project locally with docker-compose and launch chat interface", - Long: `Run an agent project locally using docker-compose and launch an interactive chat session. - -Examples: - kagent run ./my-agent - kagent run .`, - Args: cobra.MaximumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { - if len(args) > 0 { - runCfg.ProjectDir = args[0] - } else { - runCfg.ProjectDir = "." - } - - if runCfg.Build { - fmt.Fprintf(os.Stderr, "Building image before running...\n") - - buildCfg := &cli.BuildCfg{ - Config: runCfg.Config, - ProjectDir: runCfg.ProjectDir, - } - - if err := cli.BuildCmd(buildCfg); err != nil { - fmt.Fprintf(os.Stderr, "Build failed: %v\n", err) - os.Exit(1) - } - } - if err := cli.RunCmd(cmd.Context(), runCfg); err != nil { - fmt.Fprintf(os.Stderr, "Error running agent: %v\n", err) - os.Exit(1) - } - }, - Example: `kagent run ./my-agent`, - } - - runCmd.Flags().StringVar(&runCfg.ProjectDir, "project-dir", "", "Project directory (default: current directory)") - runCmd.Flags().BoolVar(&runCfg.Build, "build", false, "Rebuild the Docker image before running") - - rootCmd.AddCommand(installCmd, uninstallCmd, invokeCmd, bugReportCmd, versionCmd, dashboardCmd, getCmd, createCmd, deleteCmd, initCmd, buildCmd, deployCmd, addMcpCmd, runCmd, mcp.NewMCPCmd(), envdoc.NewEnvCmd(), dbcli.NewCommandFromFunc(migrationSources(cfg))) + rootCmd.AddCommand(installCmd, uninstallCmd, invokeCmd, bugReportCmd, versionCmd, dashboardCmd, getCmd, createCmd, deleteCmd, mcp.NewMCPCmd(), envdoc.NewEnvCmd(), dbcli.NewCommandFromFunc(migrationSources(cfg))) return rootCmd } @@ -593,7 +317,14 @@ func migrationSources(cfg *config.Config) dbmigrate.SourcesFunc { // ok=false when no cluster is reachable, the configmap is absent, or the // value doesn't parse — callers fall back to the default. func clusterVectorEnabled(ctx context.Context, namespace string) (enabled, ok bool) { - k8sClient, err := cli.CreateKubernetesClient() + restConfig, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + clientcmd.NewDefaultClientConfigLoadingRules(), + &clientcmd.ConfigOverrides{}, + ).ClientConfig() + if err != nil { + return false, false + } + k8sClient, err := client.New(restConfig, client.Options{}) if err != nil { return false, false } @@ -623,21 +354,3 @@ func currentKubeContext() string { } return raw.CurrentContext } - -func runInteractive(cmd *cobra.Command, args []string, cfg *config.Config) { - client := cfg.Client() - defer client.Close() //nolint:errcheck - - pf, err := connection.Connect(cmd.Context(), cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "Error connecting to server: %v\n", err) - return - } - if pf != nil { - defer pf.Stop() - } - - if err := tui.RunWorkspace(cfg, client, cfg.Verbose); err != nil { - fmt.Fprintf(os.Stderr, "TUI error: %v\n", err) - } -} diff --git a/go/core/cli/cmd/kagent/main_test.go b/go/core/cli/cmd/kagent/main_test.go index 5bee52bc2..651de67dd 100644 --- a/go/core/cli/cmd/kagent/main_test.go +++ b/go/core/cli/cmd/kagent/main_test.go @@ -114,11 +114,6 @@ func TestRootCommandUsesConfigValuesAsFlagDefaults(t *testing.T) { assert.Equal(t, "45s", rootCmd.PersistentFlags().Lookup("timeout").DefValue) assert.Equal(t, "configured-user", rootCmd.PersistentFlags().Lookup("user-id").DefValue) - deployCmd, _, err := rootCmd.Find([]string{"deploy"}) - require.NoError(t, err) - require.NotNil(t, deployCmd) - - assert.Equal(t, "configured-ns", deployCmd.Flags().Lookup("namespace").DefValue) assert.Equal(t, "configured-ns", cfg.Namespace) } @@ -260,6 +255,41 @@ func TestRootCommandV2CatalogAndLifecycleContract(t *testing.T) { } } +func TestRootCommandRemovesLegacyPaths(t *testing.T) { + cfg := &config.Config{ + KAgentURL: config.DefaultKAgentURL, + KAgentGRPCURL: config.DefaultKAgentGRPCURL, + OutputFormat: "table", + UserID: config.DefaultUserID, + } + rootCmd := newRootCommand(t.Context(), cfg) + + rootCommands := make([]string, 0, len(rootCmd.Commands())) + for _, command := range rootCmd.Commands() { + rootCommands = append(rootCommands, command.Name()) + } + for _, command := range []string{"deploy", "init", "build", "run", "add-mcp"} { + assert.NotContains(t, rootCommands, command) + } + assert.Contains(t, rootCommands, "mcp") + + getCmd, _, err := rootCmd.Find([]string{"get"}) + require.NoError(t, err) + getCommands := make([]string, 0, len(getCmd.Commands())) + for _, command := range getCmd.Commands() { + getCommands = append(getCommands, command.Name()) + } + for _, command := range []string{"agent", "session", "tool"} { + assert.NotContains(t, getCommands, command) + } + + rootCmd.SetArgs(nil) + err = rootCmd.ExecuteContext(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), "interactive mode is not available") + assert.Contains(t, err.Error(), "kagent invoke") +} + func resetConfigState(t *testing.T) { t.Helper() diff --git a/go/core/cli/internal/agent/frameworks/adk/python/dice-agent-instruction.md b/go/core/cli/internal/agent/frameworks/adk/python/dice-agent-instruction.md deleted file mode 100644 index 047a10d72..000000000 --- a/go/core/cli/internal/agent/frameworks/adk/python/dice-agent-instruction.md +++ /dev/null @@ -1,15 +0,0 @@ -You roll dice and answer questions about the outcome of the dice rolls. -You can roll dice of different sizes. -You can use multiple tools in parallel by calling functions in parallel(in one request and in one round). -It is ok to discuss previous dice roles, and comment on the dice rolls. -When you are asked to roll a die, you must call the roll_die tool with the number of sides. Be sure to pass in an integer. Do not pass in a string. -You should never roll a die on your own. -When checking prime numbers, call the check_prime tool with a list of integers. Be sure to pass in a list of integers. You should never pass in a string. -You should not check prime numbers before calling the tool. -When you are asked to roll a die and check prime numbers, you should always make the following two function calls: -1. You should first call the roll_die tool to get a roll. Wait for the function response before calling the check_prime tool. -2. After you get the function response from roll_die tool, you should call the check_prime tool with the roll_die result. -2.1 If user asks you to check primes based on previous rolls, make sure you include the previous rolls in the list. -3. When you respond, you must include the roll_die result from step 1. -You should always perform the previous 3 steps when asking for a roll and checking prime numbers. -You should not rely on the previous history on prime results. \ No newline at end of file diff --git a/go/core/cli/internal/agent/frameworks/adk/python/generator.go b/go/core/cli/internal/agent/frameworks/adk/python/generator.go deleted file mode 100644 index a7be6c7c0..000000000 --- a/go/core/cli/internal/agent/frameworks/adk/python/generator.go +++ /dev/null @@ -1,134 +0,0 @@ -package python - -import ( - "embed" - "fmt" - "os" - "path/filepath" - - "github.com/kagent-dev/kagent/go/core/cli/internal/agent/frameworks/common" -) - -//go:embed templates/* templates/agent/* templates/mcp_server/* dice-agent-instruction.md -var templatesFS embed.FS - -// PythonGenerator generates Python ADK projects -type PythonGenerator struct { - *common.BaseGenerator -} - -// NewPythonGenerator creates a new ADK Python generator -func NewPythonGenerator() *PythonGenerator { - return &PythonGenerator{ - BaseGenerator: common.NewBaseGenerator(templatesFS), - } -} - -// Generate creates a new Python ADK project -func (g *PythonGenerator) Generate(projectDir, agentName, instruction, modelProvider, modelName, description string, verbose bool, kagentVersion string) error { - // Create the main project directory structure - subDir := filepath.Join(projectDir, agentName) - if err := os.MkdirAll(subDir, 0755); err != nil { - return fmt.Errorf("failed to create subdirectory: %v", err) - } - // Load default instructions if none provided - if instruction == "" { - if verbose { - fmt.Println("🎲 No instruction provided, using default dice-roller instructions") - } - defaultInstructions, _ := templatesFS.ReadFile("dice-agent-instruction.md") - instruction = string(defaultInstructions) - } - - // agent project configuration - agentConfig := common.AgentConfig{ - Name: agentName, - Directory: projectDir, - Framework: "adk", - Language: "python", - Verbose: verbose, - Instruction: instruction, - ModelProvider: modelProvider, - ModelName: modelName, - KagentVersion: kagentVersion, - // Empty MCP servers on init - McpServers: nil, - } - - // Use the base generator to create the project - if err := g.GenerateProject(agentConfig); err != nil { - return fmt.Errorf("failed to generate project: %v", err) - } - - // Generate project manifest file - projectManifest := common.NewProjectManifest( - agentConfig.Name, - agentConfig.Language, - agentConfig.Framework, - agentConfig.ModelProvider, - agentConfig.ModelName, - description, - agentConfig.McpServers, - ) - - // Save the manifest using the Manager - manager := common.NewManifestManager(projectDir) - if err := manager.Save(projectManifest); err != nil { - return fmt.Errorf("failed to write project manifest: %v", err) - } - - // Move agent files from agent/ subdirectory to {agentName} subdirectory - agentDir := filepath.Join(projectDir, "agent") - if _, err := os.Stat(agentDir); err == nil { - // Move all files from agent/ to project subdirectory - entries, err := os.ReadDir(agentDir) - if err != nil { - return fmt.Errorf("failed to read agent directory: %v", err) - } - - for _, entry := range entries { - if !entry.IsDir() { - srcPath := filepath.Join(agentDir, entry.Name()) - dstPath := filepath.Join(subDir, entry.Name()) - - if err := os.Rename(srcPath, dstPath); err != nil { - return fmt.Errorf("failed to move %s to %s: %v", srcPath, dstPath, err) - } - } - } - - // Remove the now-empty agent directory - if err := os.Remove(agentDir); err != nil { - return fmt.Errorf("failed to remove agent directory: %v", err) - } - } - - fmt.Printf("✅ Successfully created %s project in %s\n", agentConfig.Framework, projectDir) - fmt.Printf("🤖 Model configuration for project: %s (%s)\n", agentConfig.ModelProvider, agentConfig.ModelName) - fmt.Printf("📁 Project structure:\n") - fmt.Printf(" %s/\n", agentConfig.Name) - fmt.Printf(" ├── %s/\n", agentConfig.Name) - fmt.Printf(" │ ├── __init__.py\n") - fmt.Printf(" │ ├── agent.py\n") - fmt.Printf(" │ ├── mcp_tools.py\n") - fmt.Printf(" │ └── agent-card.json\n") - fmt.Printf(" ├── %s\n", common.ManifestFileName) - fmt.Printf(" ├── pyproject.toml\n") - fmt.Printf(" ├── Dockerfile\n") - fmt.Printf(" ├── docker-compose.yaml\n") - fmt.Printf(" └── README.md\n") - fmt.Printf(" Note: MCP server directories are created when you run 'kagent add-mcp'\n") - fmt.Printf("\n🚀 Next steps:\n") - fmt.Printf(" 1. cd %s\n", agentConfig.Name) - fmt.Printf(" 2. Customize the agent in %s/agent.py\n", agentConfig.Name) - fmt.Printf(" 3. Build the agent and MCP servers and push it to the local registry\n") - fmt.Printf(" kagent build %s --push\n", agentConfig.Name) - fmt.Printf(" 4. Run the agent locally\n") - fmt.Printf(" kagent run\n") - fmt.Printf(" 5. Deploy the agent to your local cluster\n") - fmt.Printf(" kagent deploy %s --api-key-secret \n", agentConfig.Name) - fmt.Printf(" Or use --api-key for convenience: kagent deploy %s --api-key \n", agentConfig.Name) - fmt.Printf(" Support for using a credential file is coming soon\n") - - return nil -} diff --git a/go/core/cli/internal/agent/frameworks/adk/python/templates/.python-version b/go/core/cli/internal/agent/frameworks/adk/python/templates/.python-version deleted file mode 100644 index 976544ccb..000000000 --- a/go/core/cli/internal/agent/frameworks/adk/python/templates/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.13.7 diff --git a/go/core/cli/internal/agent/frameworks/adk/python/templates/Dockerfile.tmpl b/go/core/cli/internal/agent/frameworks/adk/python/templates/Dockerfile.tmpl deleted file mode 100644 index 5f8943dc4..000000000 --- a/go/core/cli/internal/agent/frameworks/adk/python/templates/Dockerfile.tmpl +++ /dev/null @@ -1,20 +0,0 @@ -# AUTOGENERATED FILE: DO NOT EDIT -# This file is automatically generated by the kagent-adk CLI tool. -# Any changes to this file will be overwritten. - -ARG DOCKER_REGISTRY=ghcr.io -ARG VERSION={{.KagentVersion}} -FROM $DOCKER_REGISTRY/kagent-dev/kagent/kagent-adk:$VERSION - -WORKDIR /app - -COPY pyproject.toml pyproject.toml -COPY .python-version .python-version - -RUN uv sync - -COPY {{.Name}}/ {{.Name}}/ -COPY README.md README.md - - -CMD ["{{.Name}}"] diff --git a/go/core/cli/internal/agent/frameworks/adk/python/templates/README.md.tmpl b/go/core/cli/internal/agent/frameworks/adk/python/templates/README.md.tmpl deleted file mode 100644 index 191d2dccd..000000000 --- a/go/core/cli/internal/agent/frameworks/adk/python/templates/README.md.tmpl +++ /dev/null @@ -1,33 +0,0 @@ -# {{.Name}} Agent - -This is a {{.Name}} agent that can be used to test KAgent BYO agent with ADK. - -## Model Configuration - -This agent is configured to use the **{{.ModelProvider}}** provider with model **{{.ModelName}}**. - -## Usage - -1. Build the agent image and push it to the local registry using the KAgent CLI - -```bash -kagent build {{.Name}} -``` - -2. Deploy the agent - -```bash -kagent deploy {{.Name}} --api-key -``` - -Or create a secret with the api key - -```bash -kubectl create secret generic my-secret -n --from-literal=_API_KEY=$API_KEY --dry-run=client -oyaml | k apply -f - -``` - -And then deploy the agent - -```bash -kagent deploy {{.Name}} --api-key-secret "my-secret" -``` \ No newline at end of file diff --git a/go/core/cli/internal/agent/frameworks/adk/python/templates/agent/__init__.py.tmpl b/go/core/cli/internal/agent/frameworks/adk/python/templates/agent/__init__.py.tmpl deleted file mode 100644 index 6844c43f9..000000000 --- a/go/core/cli/internal/agent/frameworks/adk/python/templates/agent/__init__.py.tmpl +++ /dev/null @@ -1,2 +0,0 @@ -from . import agent - diff --git a/go/core/cli/internal/agent/frameworks/adk/python/templates/agent/agent-card.json.tmpl b/go/core/cli/internal/agent/frameworks/adk/python/templates/agent/agent-card.json.tmpl deleted file mode 100644 index 0f5b59470..000000000 --- a/go/core/cli/internal/agent/frameworks/adk/python/templates/agent/agent-card.json.tmpl +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "{{.Name}}", - "description": "A {{.Name}} agent", - "url": "localhost:8080", - "version": "0.0.1", - "capabilities": { - "streaming": true - }, - "defaultInputModes": ["text"], - "defaultOutputModes": ["text"], - "skills": [ - { - "id": "{{.Name}}", - "name": "{{.Name}}", - "description": "A {{.Name}} agent", - "tags": ["{{.Name}}"] - } - ] -} diff --git a/go/core/cli/internal/agent/frameworks/adk/python/templates/agent/agent.py.tmpl b/go/core/cli/internal/agent/frameworks/adk/python/templates/agent/agent.py.tmpl deleted file mode 100644 index b3b8b7407..000000000 --- a/go/core/cli/internal/agent/frameworks/adk/python/templates/agent/agent.py.tmpl +++ /dev/null @@ -1,81 +0,0 @@ -import random - -from google.adk import Agent -from google.adk.tools.tool_context import ToolContext -from google.adk.models.lite_llm import LiteLlm -from .mcp_tools import get_mcp_tools - - -def roll_die(sides: int, tool_context: ToolContext) -> int: - """Roll a die and return the rolled result. - Args: - sides: The integer number of sides the die has. - Returns: - An integer of the result of rolling the die. - """ - result = random.randint(1, sides) - if "rolls" not in tool_context.state: - tool_context.state["rolls"] = [] - - tool_context.state["rolls"] = tool_context.state["rolls"] + [result] - return result - - -async def check_prime(nums: list[int]) -> str: - """Check if a given list of numbers are prime. - Args: - nums: The list of numbers to check. - Returns: - A str indicating which number is prime. - """ - primes = set() - for number in nums: - number = int(number) - if number <= 1: - continue - is_prime = True - for i in range(2, int(number**0.5) + 1): - if number % i == 0: - is_prime = False - break - if is_prime: - primes.add(number) - return "No prime numbers found." if not primes else f"{', '.join(str(num) for num in primes)} are prime numbers." - - -# Create model based on provider and model name -{{if eq .ModelProvider "gemini"}} -def create_model(): - """Create a Gemini model instance.""" - return "{{.ModelName}}" -{{else if eq .ModelProvider "openai"}} -def create_model(): - """Create an OpenAI model instance using LiteLLM.""" - return LiteLlm(model="openai/{{.ModelName}}") -{{else if eq .ModelProvider "anthropic"}} -def create_model(): - """Create an Anthropic model instance using LiteLLM.""" - return LiteLlm(model="anthropic/{{.ModelName}}") -{{else if eq .ModelProvider "azureopenai"}} -def create_model(): - """Create an Azure OpenAI model instance using LiteLLM.""" - return LiteLlm(model="azure/{{.ModelName}}") -{{else}} -def create_model(): - """Create a custom model instance.""" - return "{{.ModelName}}" -{{end}} - -mcp_tools = get_mcp_tools() -root_agent = Agent( - model=create_model(), - name="{{.Name}}_agent", - description=("{{.Name}} agent."), - instruction=""" -{{.Instruction}} - """, - tools=[ - roll_die, - check_prime, - ] + (mcp_tools if mcp_tools else []), -) \ No newline at end of file diff --git a/go/core/cli/internal/agent/frameworks/adk/python/templates/agent/mcp_tools.py.tmpl b/go/core/cli/internal/agent/frameworks/adk/python/templates/agent/mcp_tools.py.tmpl deleted file mode 100644 index 988492933..000000000 --- a/go/core/cli/internal/agent/frameworks/adk/python/templates/agent/mcp_tools.py.tmpl +++ /dev/null @@ -1,105 +0,0 @@ -# AUTOGENERATED FILE: DO NOT EDIT -# This file is automatically generated by the kagent-adk CLI tool. -# Any changes to this file will be overwritten. - -import os -import re -from typing import List, Optional, Union -from google.adk.tools.base_toolset import ToolPredicate -from google.adk.tools.mcp_tool.mcp_toolset import MCPToolset, StreamableHTTPConnectionParams - - -# MCP Server configuration generated from kagent.yaml -_MCP_SERVERS = [ -{{- range .McpServers }} - { - "name": "{{ .Name }}", - "type": "{{ .Type }}", - {{- if eq .Type "remote" }} - "url": "{{ .URL }}", - {{- if .Headers }} - "headers": { - {{- range $key, $value := .Headers }} - "{{ $key }}": "{{ $value }}", - {{- end }} - }, - {{- end }} - {{- end }} - }, -{{- end }} -] - - -def _resolve_env_vars(value: str) -> str: - """Resolve environment variables in a string value. - - Supports ${VAR_NAME} syntax for environment variable substitution. - If the environment variable is not set, the original pattern is kept. - - Args: - value: String that may contain ${VAR_NAME} patterns - - Returns: - String with environment variables resolved - """ - def replace_var(match): - var_name = match.group(1) - return os.environ.get(var_name, match.group(0)) - - return re.sub(r'\$\{([^}]+)\}', replace_var, value) - - -def get_mcp_tools( - server_names: Optional[List[str]] = None, - server_filters: Optional[Union[ToolPredicate, List[str]]] = None, - global_filter: Optional[Union[ToolPredicate, List[str]]] = None, -) -> List[MCPToolset]: - """Get MCP tools from configured servers with optional filtering. - - Args: - server_names: Optional list of server names to include. If None, includes all servers. - server_filters: Optional dict mapping server names to ToolPredicates for per-server filtering. - global_filter: Optional ToolPredicate to apply to all servers (overridden by server_filters). - - Returns: - List of MCPToolset instances, one for each configured/filtered server. - """ - servers = _MCP_SERVERS - - # Filter servers by name if specified - if server_names is not None: - servers = [s for s in servers if s.get("name") in server_names] - - toolsets = [] - for server in servers: - server_name = server["name"] - - # Determine the URL based on server type - # For command type, use the server name as hostname (each server has its own docker-compose service) - # For remote type, use the configured URL - url = f"http://{server_name}:3000/mcp" if server["type"] == "command" else server["url"] - - # Process headers with environment variable resolution - headers = {} - if "headers" in server and server["headers"]: - for key, value in server["headers"].items(): - headers[key] = _resolve_env_vars(value) - - predicate = None - if server_filters and server_name in server_filters: - predicate = server_filters[server_name] - elif global_filter is not None: - predicate = global_filter - - # Create connection params with headers if present - if headers: - connection_params = StreamableHTTPConnectionParams(url=url, headers=headers) - else: - connection_params = StreamableHTTPConnectionParams(url=url) - - if predicate is not None: - toolsets.append(MCPToolset(connection_params=connection_params, tool_filter=predicate)) - else: - toolsets.append(MCPToolset(connection_params=connection_params)) - - return toolsets diff --git a/go/core/cli/internal/agent/frameworks/adk/python/templates/docker-compose.yaml.tmpl b/go/core/cli/internal/agent/frameworks/adk/python/templates/docker-compose.yaml.tmpl deleted file mode 100644 index c7bd87e92..000000000 --- a/go/core/cli/internal/agent/frameworks/adk/python/templates/docker-compose.yaml.tmpl +++ /dev/null @@ -1,41 +0,0 @@ -# AUTOGENERATED FILE: DO NOT EDIT -# This file is automatically generated by the kagent-adk CLI tool. -# Any changes to this file will be overwritten. - -services: - {{.Name}}: - image: localhost:5001/{{.Name}}:latest - build: - context: . - dockerfile: Dockerfile - command: ["{{.Name}}", "--local"] - ports: - - "8080:8080" - environment: - - AGENT_NAME={{.Name}} - - MODEL_PROVIDER={{.ModelProvider}} - - MODEL_NAME={{.ModelName}} - - KAGENT_URL=${KAGENT_URL:-http://localhost:8083} - - KAGENT_NAME={{.Name}} - - KAGENT_NAMESPACE=${KAGENT_NAMESPACE:-kagent} -{{- if eq .ModelProvider "anthropic" }} - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} -{{- else if eq .ModelProvider "openai" }} - - OPENAI_API_KEY=${OPENAI_API_KEY} -{{- else if eq .ModelProvider "gemini" }} - - GOOGLE_API_KEY=${GOOGLE_API_KEY} -{{- end }} -{{- range .EnvVars }} - - {{.}}=${{"{"}}{{.}}{{"}"}} -{{- end }} -{{- range .McpServers }} -{{- if eq .Type "command" }} - {{.Name}}: - image: localhost:5001/{{$.Name}}-{{.Name}}:latest - build: - context: ./{{.Name}} - dockerfile: Dockerfile - expose: - - "3000" -{{- end }} -{{- end }} \ No newline at end of file diff --git a/go/core/cli/internal/agent/frameworks/adk/python/templates/mcp_server/Dockerfile b/go/core/cli/internal/agent/frameworks/adk/python/templates/mcp_server/Dockerfile deleted file mode 100644 index b6de1ba0f..000000000 --- a/go/core/cli/internal/agent/frameworks/adk/python/templates/mcp_server/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -# AUTOGENERATED FILE: DO NOT EDIT -# This file is automatically generated by the kagent-adk CLI tool. -# Any changes to this file will be overwritten. - -FROM node:24-bookworm-slim - -# Install Python and uv for uvx support -RUN apt-get update && apt-get install -y \ - python3 \ - python3-pip \ - ca-certificates \ - curl \ - && rm -rf /var/lib/apt/lists/* - -# Install uv (which includes uvx) -RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \ - mv /root/.local/bin/uv /usr/local/bin/uv && \ - mv /root/.local/bin/uvx /usr/local/bin/uvx - -# Copy agentgateway binary -COPY --from=ghcr.io/agentgateway/agentgateway:0.10.2 /app/agentgateway /app/agentgateway - -COPY config.yaml config.yaml - -CMD ["/app/agentgateway", "-f", "config.yaml"] \ No newline at end of file diff --git a/go/core/cli/internal/agent/frameworks/adk/python/templates/mcp_server/config.yaml.tmpl b/go/core/cli/internal/agent/frameworks/adk/python/templates/mcp_server/config.yaml.tmpl deleted file mode 100644 index f1ceca480..000000000 --- a/go/core/cli/internal/agent/frameworks/adk/python/templates/mcp_server/config.yaml.tmpl +++ /dev/null @@ -1,39 +0,0 @@ -# AUTOGENERATED FILE: DO NOT EDIT -# This file is automatically generated by the kagent-adk CLI tool. -# Any changes to this file will be overwritten. - -binds: -- port: 3000 - listeners: - - routes: - - policies: - cors: - allowOrigins: - - "*" - allowHeaders: - - mcp-protocol-version - - content-type - - cache-control - backends: - - mcp: - targets: -{{- range .Targets }} - - name: {{ .Name }} - stdio: - cmd: {{ .Cmd }} -{{- if .Args }} - args: [{{ range $i, $arg := .Args }}{{ if $i }}, {{ end }}"{{ $arg }}"{{ end }}] -{{- end }} -{{- if .Env }} - env: -{{- range .Env }} - - {{ . }} -{{- end }} -{{- end }} -{{- end }} - matches: - - path: - pathPrefix: /sse - - path: - pathPrefix: /mcp - name: mcp diff --git a/go/core/cli/internal/agent/frameworks/adk/python/templates/pyproject.toml.tmpl b/go/core/cli/internal/agent/frameworks/adk/python/templates/pyproject.toml.tmpl deleted file mode 100644 index 0b0929aad..000000000 --- a/go/core/cli/internal/agent/frameworks/adk/python/templates/pyproject.toml.tmpl +++ /dev/null @@ -1,10 +0,0 @@ -[project] -name = "{{.Name}}" -version = "0.1" -description = "{{.Name}} agent" -readme = "README.md" -dependencies = [ - "google-adk==1.28.1", -] - -requires-python = ">=3.13" diff --git a/go/core/cli/internal/agent/frameworks/common/base_generator.go b/go/core/cli/internal/agent/frameworks/common/base_generator.go deleted file mode 100644 index cc3d5f863..000000000 --- a/go/core/cli/internal/agent/frameworks/common/base_generator.go +++ /dev/null @@ -1,65 +0,0 @@ -package common - -import ( - "io/fs" - - "github.com/kagent-dev/kagent/go/core/cli/internal/common/generator" -) - -// AgentConfig holds the configuration for agent project generation -type AgentConfig struct { - Name string - Directory string - Verbose bool - Instruction string - ModelProvider string - ModelName string - Framework string - Language string - KagentVersion string - McpServers []McpServerType - EnvVars []string -} - -// Implement ProjectConfig interface for AgentConfig -func (c AgentConfig) GetDirectory() string { - return c.Directory -} - -func (c AgentConfig) IsVerbose() bool { - return c.Verbose -} - -func (c AgentConfig) ShouldInitGit() bool { - return true -} - -func (c AgentConfig) ShouldSkipPath(path string) bool { - // Skip mcp_server directory - these templates are processed separately - return path == "mcp_server" -} - -// BaseGenerator provides common functionality for all project generators. -// This now wraps the shared generator.BaseGenerator. -type BaseGenerator struct { - *generator.BaseGenerator -} - -// NewBaseGenerator creates a new base generator that uses the shared generator -func NewBaseGenerator(templateFiles fs.FS) *BaseGenerator { - return &BaseGenerator{ - BaseGenerator: generator.NewBaseGenerator(templateFiles, "templates"), - } -} - -// GenerateProject generates a new project using the provided templates. -// This delegates to the shared generator implementation. -func (g *BaseGenerator) GenerateProject(config AgentConfig) error { - return g.BaseGenerator.GenerateProject(config) -} - -// RenderTemplate renders a template string with the provided data. -// This delegates to the shared generator implementation. -func (g *BaseGenerator) RenderTemplate(tmplContent string, data any) (string, error) { - return g.BaseGenerator.RenderTemplate(tmplContent, data) -} diff --git a/go/core/cli/internal/agent/frameworks/common/manifest_manager.go b/go/core/cli/internal/agent/frameworks/common/manifest_manager.go deleted file mode 100644 index b1b037f3f..000000000 --- a/go/core/cli/internal/agent/frameworks/common/manifest_manager.go +++ /dev/null @@ -1,165 +0,0 @@ -package common - -import ( - "fmt" - "net/url" - "os" - "path/filepath" - "time" - - "gopkg.in/yaml.v3" -) - -const ManifestFileName = "kagent.yaml" - -// AgentManifest represents the agent project configuration and metadata -type AgentManifest struct { - Name string `yaml:"agentName"` - Language string `yaml:"language"` - Framework string `yaml:"framework"` - ModelProvider string `yaml:"modelProvider"` - ModelName string `yaml:"modelName"` - Description string `yaml:"description"` - McpServers []McpServerType `yaml:"mcpServers,omitempty" json:"mcpServers,omitempty"` - UpdatedAt time.Time `yaml:"updatedAt,omitempty"` -} - -// McpServerType represents a single MCP server configuration -type McpServerType struct { - Type string `yaml:"type" json:"type"` - Name string `yaml:"name" json:"name"` - Image string `yaml:"image,omitempty" json:"image,omitempty"` - Build string `yaml:"build,omitempty" json:"build,omitempty"` - Command string `yaml:"command,omitempty" json:"command,omitempty"` - Args []string `yaml:"args,omitempty" json:"args,omitempty"` - Env []string `yaml:"env,omitempty" json:"env,omitempty"` - URL string `yaml:"url,omitempty" json:"url,omitempty"` - Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` -} - -// Manager handles loading and saving of agent manifests -type Manager struct { - projectRoot string -} - -// NewManifestManager creates a new manifest manager for the given project root -func NewManifestManager(projectRoot string) *Manager { - return &Manager{ - projectRoot: projectRoot, - } -} - -// Load reads and parses the kagent.yaml file -func (m *Manager) Load() (*AgentManifest, error) { - manifestPath := filepath.Join(m.projectRoot, ManifestFileName) - - data, err := os.ReadFile(manifestPath) - if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("kagent.yaml not found in %s", m.projectRoot) - } - return nil, fmt.Errorf("failed to read kagent.yaml: %w", err) - } - - var manifest AgentManifest - if err := yaml.Unmarshal(data, &manifest); err != nil { - return nil, fmt.Errorf("failed to parse kagent.yaml: %w", err) - } - - // Validate the manifest - if err := m.Validate(&manifest); err != nil { - return nil, fmt.Errorf("invalid kagent.yaml: %w", err) - } - - return &manifest, nil -} - -// Save writes the manifest to kagent.yaml -func (m *Manager) Save(manifest *AgentManifest) error { - // Update timestamp - manifest.UpdatedAt = time.Now() - - // Validate before saving - if err := m.Validate(manifest); err != nil { - return fmt.Errorf("invalid manifest: %w", err) - } - - data, err := yaml.Marshal(manifest) - if err != nil { - return fmt.Errorf("failed to marshal manifest: %w", err) - } - - manifestPath := filepath.Join(m.projectRoot, ManifestFileName) - if err := os.WriteFile(manifestPath, data, 0644); err != nil { - return fmt.Errorf("failed to write kagent.yaml: %w", err) - } - - return nil -} - -// Validate checks if the manifest is valid -func (m *Manager) Validate(manifest *AgentManifest) error { - if manifest.Name == "" { - return fmt.Errorf("agent name is required") - } - if manifest.Language == "" { - return fmt.Errorf("language is required") - } - if manifest.Framework == "" { - return fmt.Errorf("framework is required") - } - // McpServers are optional; validate only if provided - for i, srv := range manifest.McpServers { - if srv.Type == "" { - return fmt.Errorf("mcpServers[%d]: type is required", i) - } - if srv.Name == "" { - return fmt.Errorf("mcpServers[%d]: name is required", i) - } - // Enforce that only one of image or build is set - if srv.Image != "" && srv.Build != "" { - return fmt.Errorf("mcpServers[%d]: only one of image or build may be set", i) - } - switch srv.Type { - case "remote": - if srv.URL == "" { - return fmt.Errorf("mcpServers[%d]: url is required for type 'remote'", i) - } - parsed, err := url.Parse(srv.URL) - if err != nil { - return fmt.Errorf("mcpServers[%d]: url is not a valid URL: %v", i, err) - } - if parsed.Scheme == "" { - return fmt.Errorf("mcpServers[%d]: url is missing scheme (http or https required)", i) - } - if parsed.Scheme != "http" && parsed.Scheme != "https" { - return fmt.Errorf("mcpServers[%d]: url scheme must be http or https (got '%s')", i, parsed.Scheme) - } - if parsed.Host == "" { - return fmt.Errorf("mcpServers[%d]: url is missing host", i) - } - case "command": - // Command is required only if neither image nor build is provided - if srv.Command == "" && srv.Image == "" && srv.Build == "" { - return fmt.Errorf("mcpServers[%d]: at least one of command, image, or build is required for type 'command'", i) - } - default: - return fmt.Errorf("mcpServers[%d]: unsupported type '%s' (expected 'command' or 'remote')", i, srv.Type) - } - } - return nil -} - -// NewProjectManifest creates a new AgentManifest with the given values -func NewProjectManifest(agentName, language, framework, modelProvider, modelName, description string, mcpServers []McpServerType) *AgentManifest { - return &AgentManifest{ - Name: agentName, - Language: language, - Framework: framework, - ModelProvider: modelProvider, - ModelName: modelName, - Description: description, - UpdatedAt: time.Now(), - McpServers: mcpServers, - } -} diff --git a/go/core/cli/internal/agent/frameworks/frameworks.go b/go/core/cli/internal/agent/frameworks/frameworks.go deleted file mode 100644 index 58369af2f..000000000 --- a/go/core/cli/internal/agent/frameworks/frameworks.go +++ /dev/null @@ -1,27 +0,0 @@ -package frameworks - -import ( - "fmt" - - adk_python "github.com/kagent-dev/kagent/go/core/cli/internal/agent/frameworks/adk/python" -) - -// Generator interface for project generation -type Generator interface { - Generate(projectDir, agentName, instruction, modelProvider, modelName, description string, verbose bool, kagentVersion string) error -} - -// NewGenerator creates a new generator for the specified framework and language -func NewGenerator(framework, language string) (Generator, error) { - switch framework { - case "adk": - switch language { - case "python": - return adk_python.NewPythonGenerator(), nil - default: - return nil, fmt.Errorf("unsupported language '%s' for adk", language) - } - default: - return nil, fmt.Errorf("unsupported framework: %s", framework) - } -} diff --git a/go/core/cli/internal/cli/agent/add_mcp.go b/go/core/cli/internal/cli/agent/add_mcp.go deleted file mode 100644 index 025336e62..000000000 --- a/go/core/cli/internal/cli/agent/add_mcp.go +++ /dev/null @@ -1,209 +0,0 @@ -package cli - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/kagent-dev/kagent/go/core/cli/internal/agent/frameworks/common" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" - "github.com/kagent-dev/kagent/go/core/cli/internal/tui/dialogs" -) - -// mcpTarget represents an MCP server target for config.yaml template -type mcpTarget struct { - Name string - Cmd string - Args []string - Env []string - Image string - Build string -} - -// AddMcpCfg carries inputs for adding an MCP server entry to kagent.yaml -type AddMcpCfg struct { - ProjectDir string - Config *config.Config - // Non-interactive fields - Name string - RemoteURL string - Headers []string // KEY=VALUE pairs - Command string - Args []string - Env []string - Image string - Build string -} - -// AddMcpCmd runs the interactive flow to append an MCP server to kagent.yaml -func AddMcpCmd(cfg *AddMcpCfg) error { - // Determine project directory - projectDir, err := ResolveProjectDir(cfg.ProjectDir) - if err != nil { - return err - } - - // Load manifest - manifest, err := LoadManifest(projectDir) - if err != nil { - return err - } - - verbose := IsVerbose(cfg.Config) - if verbose { - fmt.Printf("Loaded manifest for agent '%s' from %s\n", manifest.Name, projectDir) - } - - // If flags provided, build non-interactively; else run wizard - var res common.McpServerType - if cfg.RemoteURL != "" || cfg.Command != "" || cfg.Image != "" || cfg.Build != "" { - if cfg.RemoteURL != "" { - headers := parseKeyValuePairs(cfg.Headers) - res = common.McpServerType{ - Type: "remote", - URL: cfg.RemoteURL, - Name: cfg.Name, - Headers: headers, - } - } else { - if cfg.Image != "" && cfg.Build != "" { - return fmt.Errorf("only one of --image or --build may be set") - } - res = common.McpServerType{ - Type: "command", - Name: cfg.Name, - Command: cfg.Command, - Args: cfg.Args, - Env: cfg.Env, - Image: cfg.Image, - Build: cfg.Build, - } - } - } else { - // Prefer the wizard experience - wiz := dialogs.NewMcpServerWizard() - p := tea.NewProgram(wiz) - if _, err := p.Run(); err != nil { - return fmt.Errorf("failed to run TUI: %w", err) - } - if !wiz.Ok() { - fmt.Println("Canceled.") - return nil - } - res = wiz.Result() - if cfg.Name != "" { - res.Name = cfg.Name - } - } - - // Ensure unique name - for _, existing := range manifest.McpServers { - if strings.EqualFold(existing.Name, res.Name) { - return fmt.Errorf("an MCP server named '%s' already exists in kagent.yaml", res.Name) - } - } - - // Append and validate - manifest.McpServers = append(manifest.McpServers, res) - manager := common.NewManifestManager(projectDir) - if err := manager.Validate(manifest); err != nil { - return fmt.Errorf("invalid MCP server configuration: %w", err) - } - - // Save back to disk - if err := manager.Save(manifest); err != nil { - return fmt.Errorf("failed to save kagent.yaml: %w", err) - } - - // Regenerate mcp_tools.py with updated MCP servers for ADK Python projects - if err := regenerateMcpToolsFile(projectDir, manifest, verbose); err != nil { - return fmt.Errorf("failed to regenerate mcp_tools.py: %w", err) - } - - // Create/update individual MCP server directories with config.yaml - if err := ensureMcpServerDirectories(projectDir, manifest, verbose); err != nil { - return fmt.Errorf("failed to ensure MCP server directories: %w", err) - } - - // Regenerate docker-compose.yaml with updated MCP server configuration - if err := RegenerateDockerCompose(projectDir, manifest, verbose); err != nil { - return fmt.Errorf("failed to regenerate docker-compose.yaml: %w", err) - } - - fmt.Printf("✓ Added MCP server '%s' (%s) to kagent.yaml\n", res.Name, res.Type) - return nil -} - -func ensureMcpServerDirectories(projectDir string, manifest *common.AgentManifest, verbose bool) error { - // Create a separate directory for each command-type MCP server - for _, srv := range manifest.McpServers { - // Skip remote type servers as they don't need local directories - if srv.Type != "command" { - continue - } - - // Create directory named after the MCP server - mcpServerDir := filepath.Join(projectDir, srv.Name) - if err := os.MkdirAll(mcpServerDir, 0o755); err != nil { - return fmt.Errorf("failed to create %s directory: %w", srv.Name, err) - } - - // Transform this specific server into a target for config.yaml template - targets := []mcpTarget{ - { - Name: srv.Name, - Cmd: srv.Command, - Args: srv.Args, - Env: srv.Env, - Image: srv.Image, - Build: srv.Build, - }, - } - - // Render and write config.yaml - templateData := struct { - Targets []mcpTarget - }{ - Targets: targets, - } - - renderedContent, err := RenderTemplate("templates/mcp_server/config.yaml.tmpl", templateData) - if err != nil { - return fmt.Errorf("failed to render config.yaml template for %s: %w", srv.Name, err) - } - - configPath := filepath.Join(mcpServerDir, "config.yaml") - if err := os.WriteFile(configPath, []byte(renderedContent), 0o644); err != nil { - return fmt.Errorf("failed to write config.yaml for %s: %w", srv.Name, err) - } - - if verbose { - fmt.Printf("Created/updated %s\n", configPath) - } - - // Copy Dockerfile if it doesn't exist - if err := CopyTemplateIfNotExists(mcpServerDir, "Dockerfile", "templates/mcp_server/Dockerfile", verbose); err != nil { - return err - } - } - - return nil -} - -// parseKeyValuePairs parses KEY=VALUE pairs from a string slice -func parseKeyValuePairs(pairs []string) map[string]string { - result := make(map[string]string) - for _, pair := range pairs { - parts := strings.SplitN(pair, "=", 2) - if len(parts) == 2 { - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - if key != "" { - result[key] = value - } - } - } - return result -} diff --git a/go/core/cli/internal/cli/agent/add_mcp_test.go b/go/core/cli/internal/cli/agent/add_mcp_test.go deleted file mode 100644 index ce39eafc8..000000000 --- a/go/core/cli/internal/cli/agent/add_mcp_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "testing" - - "github.com/kagent-dev/kagent/go/core/cli/internal/config" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" -) - -func TestAddMcpCmd_AddRemoteServer(t *testing.T) { - tmpDir := t.TempDir() - - // Create initial manifest without MCP servers - manifestContent := `agentName: test-agent -description: Test agent -framework: adk -language: python -modelProvider: anthropic -` - manifestPath := filepath.Join(tmpDir, "kagent.yaml") - err := os.WriteFile(manifestPath, []byte(manifestContent), 0644) - require.NoError(t, err) - - cfg := &AddMcpCfg{ - ProjectDir: tmpDir, - Name: "github-server", - RemoteURL: "https://api.github.com/mcp", - Headers: []string{"Authorization=Bearer ${GITHUB_TOKEN}"}, - Config: &config.Config{}, - } - - // Call AddMcpCmd - err = AddMcpCmd(cfg) - require.NoError(t, err) - - // Verify manifest was updated - content, err := os.ReadFile(manifestPath) - require.NoError(t, err) - - var manifest map[string]any - err = yaml.Unmarshal(content, &manifest) - require.NoError(t, err) - - mcpServers, ok := manifest["mcpServers"].([]any) - require.True(t, ok, "mcpServers should be an array") - require.Len(t, mcpServers, 1) - - server := mcpServers[0].(map[string]any) - assert.Equal(t, "github-server", server["name"]) - assert.Equal(t, "remote", server["type"]) - assert.Equal(t, "https://api.github.com/mcp", server["url"]) -} - -func TestAddMcpCmd_AddCommandServer(t *testing.T) { - tmpDir := t.TempDir() - - manifestContent := `agentName: test-agent -description: Test agent -framework: adk -language: python -` - manifestPath := filepath.Join(tmpDir, "kagent.yaml") - err := os.WriteFile(manifestPath, []byte(manifestContent), 0644) - require.NoError(t, err) - - cfg := &AddMcpCfg{ - ProjectDir: tmpDir, - Name: "filesystem-server", - Command: "npx", - Args: []string{"-y", "@modelcontextprotocol/server-filesystem", "/tmp"}, - Config: &config.Config{}, - } - - err = AddMcpCmd(cfg) - require.NoError(t, err) - - // Verify manifest was updated - content, err := os.ReadFile(manifestPath) - require.NoError(t, err) - - var manifest map[string]any - err = yaml.Unmarshal(content, &manifest) - require.NoError(t, err) - - mcpServers := manifest["mcpServers"].([]any) - require.Len(t, mcpServers, 1) - - server := mcpServers[0].(map[string]any) - assert.Equal(t, "filesystem-server", server["name"]) - assert.Equal(t, "command", server["type"]) - assert.Equal(t, "npx", server["command"]) -} - -func TestAddMcpCmd_MissingManifest(t *testing.T) { - tmpDir := t.TempDir() - - cfg := &AddMcpCfg{ - ProjectDir: tmpDir, - Name: "test-server", - RemoteURL: "http://example.com", - Config: &config.Config{}, - } - - err := AddMcpCmd(cfg) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to load kagent.yaml") -} - -func TestAddMcpCmd_AddMultipleServers(t *testing.T) { - tmpDir := t.TempDir() - - manifestContent := `agentName: test-agent -description: Test agent -framework: adk -language: python -` - manifestPath := filepath.Join(tmpDir, "kagent.yaml") - err := os.WriteFile(manifestPath, []byte(manifestContent), 0644) - require.NoError(t, err) - - // Add first server (remote) - cfg1 := &AddMcpCfg{ - ProjectDir: tmpDir, - Name: "server-1", - RemoteURL: "http://server1.com", - Config: &config.Config{}, - } - err = AddMcpCmd(cfg1) - require.NoError(t, err) - - // Add second server (command) - cfg2 := &AddMcpCfg{ - ProjectDir: tmpDir, - Name: "server-2", - Command: "python", - Args: []string{"server.py"}, - Config: &config.Config{}, - } - err = AddMcpCmd(cfg2) - require.NoError(t, err) - - // Verify both servers exist - content, err := os.ReadFile(manifestPath) - require.NoError(t, err) - - var manifest map[string]any - err = yaml.Unmarshal(content, &manifest) - require.NoError(t, err) - - mcpServers := manifest["mcpServers"].([]any) - require.Len(t, mcpServers, 2) - - // Verify first server - server1 := mcpServers[0].(map[string]any) - assert.Equal(t, "server-1", server1["name"]) - - // Verify second server - server2 := mcpServers[1].(map[string]any) - assert.Equal(t, "server-2", server2["name"]) -} diff --git a/go/core/cli/internal/cli/agent/build.go b/go/core/cli/internal/cli/agent/build.go deleted file mode 100644 index 92082e923..000000000 --- a/go/core/cli/internal/cli/agent/build.go +++ /dev/null @@ -1,206 +0,0 @@ -package cli - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/kagent-dev/kagent/go/core/cli/internal/agent/frameworks/common" - commonexec "github.com/kagent-dev/kagent/go/core/cli/internal/common/exec" - commonimage "github.com/kagent-dev/kagent/go/core/cli/internal/common/image" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" -) - -type BuildCfg struct { - ProjectDir string - Image string - Push bool - Platform string - Config *config.Config - SkipMCPServers bool -} - -// BuildCmd builds a Docker image for an agent project -func BuildCmd(cfg *BuildCfg) error { - // Validate project directory - if cfg.ProjectDir == "" { - return fmt.Errorf("project directory is required") - } - - // Check if project directory exists - if _, err := os.Stat(cfg.ProjectDir); os.IsNotExist(err) { - return fmt.Errorf("project directory does not exist: %s", cfg.ProjectDir) - } - - // Check if Dockerfile exists in project directory - dockerfilePath := filepath.Join(cfg.ProjectDir, "Dockerfile") - if _, err := os.Stat(dockerfilePath); os.IsNotExist(err) { - return fmt.Errorf("dockerfile not found in project directory: %s", dockerfilePath) - } - - // Check if Docker is available and running - docker := commonexec.NewDockerExecutor(cfg.Config.Verbose, cfg.ProjectDir) - if err := docker.CheckAvailability(); err != nil { - return fmt.Errorf("docker check failed: %v", err) - } - - // Load the manifest to check for MCP servers - manifest := getManifestFromProjectDir(cfg.ProjectDir) - if manifest != nil && len(manifest.McpServers) > 0 { - // Regenerate mcp_tools.py to ensure it's up-to-date before building - if err := regenerateMcpToolsFile(cfg.ProjectDir, manifest, cfg.Config.Verbose); err != nil { - return fmt.Errorf("failed to regenerate mcp_tools.py: %v", err) - } - } - - imageName := constructImageName(cfg) - var extraArgs []string - if cfg.Platform != "" { - extraArgs = append(extraArgs, "--platform", cfg.Platform) - } - if err := docker.Build(imageName, ".", extraArgs...); err != nil { - return fmt.Errorf("failed to build Docker image: %v", err) - } - - if cfg.Push { - if err := docker.Push(imageName); err != nil { - return fmt.Errorf("failed to push Docker image: %v", err) - } - } - - // Check if MCP servers exist and build images for each MCP server - // Skip if SkipMCPServers flag is set - if !cfg.SkipMCPServers && manifest != nil && len(manifest.McpServers) > 0 { - if err := buildMcpServerImages(cfg, manifest); err != nil { - return fmt.Errorf("failed to build MCP server images: %v", err) - } - - // Push the MCP server images if requested - if cfg.Push { - if err := pushMcpServerImages(cfg, manifest); err != nil { - return fmt.Errorf("failed to push MCP server images: %v", err) - } - } - } - - return nil -} - -// constructImageName constructs the full image name from the provided image or defaults -func constructImageName(cfg *BuildCfg) string { - agentName := getAgentNameFromManifest(cfg.ProjectDir) - - // If no agent name found in manifest, fall back to directory name - if agentName == "" { - agentName = filepath.Base(cfg.ProjectDir) - } - - // Construct full image name using common utility - return commonimage.ConstructImageName(cfg.Image, agentName) -} - -// getAgentNameFromManifest attempts to load the agent name from kagent.yaml -func getAgentNameFromManifest(projectDir string) string { - // Use the Manager to load the manifest - manager := common.NewManifestManager(projectDir) - manifest, err := manager.Load() - if err != nil { - // Silently fail and return empty string to fall back to directory name - return "" - } - - return manifest.Name -} - -// getManifestFromProjectDir loads the agent manifest from the project directory -func getManifestFromProjectDir(projectDir string) *common.AgentManifest { - manager := common.NewManifestManager(projectDir) - manifest, err := manager.Load() - if err != nil { - // Silently fail and return nil - return nil - } - return manifest -} - -// buildMcpServerImages builds Docker images for each MCP server -func buildMcpServerImages(cfg *BuildCfg, manifest *common.AgentManifest) error { - // Build an image for each command-type MCP server - for _, srv := range manifest.McpServers { - // Skip remote type servers as they don't need to be built - if srv.Type != "command" { - continue - } - - mcpServerDir := filepath.Join(cfg.ProjectDir, srv.Name) - if _, err := os.Stat(mcpServerDir); os.IsNotExist(err) { - // Directory doesn't exist, skip building - if cfg.Config.Verbose { - fmt.Printf("Skipping %s: directory not found\n", srv.Name) - } - continue - } - - // Check if Dockerfile exists in the MCP server directory - dockerfilePath := filepath.Join(mcpServerDir, "Dockerfile") - if _, err := os.Stat(dockerfilePath); os.IsNotExist(err) { - return fmt.Errorf("file Dockerfile not found in %s directory: %s", srv.Name, dockerfilePath) - } - - // Construct the MCP server image name and build using shared executor - imageName := constructMcpServerImageName(cfg, srv.Name) - docker := commonexec.NewDockerExecutor(cfg.Config.Verbose, mcpServerDir) - - var extraArgs []string - if cfg.Platform != "" { - extraArgs = append(extraArgs, "--platform", cfg.Platform) - } - if err := docker.Build(imageName, ".", extraArgs...); err != nil { - return fmt.Errorf("docker build failed for %s: %v", srv.Name, err) - } - } - - return nil -} - -// pushMcpServerImages pushes the MCP server Docker images to the specified registry -func pushMcpServerImages(cfg *BuildCfg, manifest *common.AgentManifest) error { - docker := commonexec.NewDockerExecutor(cfg.Config.Verbose, "") - - // Push an image for each command-type MCP server - for _, srv := range manifest.McpServers { - // Skip remote type servers - if srv.Type != "command" { - continue - } - - mcpServerDir := filepath.Join(cfg.ProjectDir, srv.Name) - if _, err := os.Stat(mcpServerDir); os.IsNotExist(err) { - // Directory doesn't exist, skip pushing - if cfg.Config.Verbose { - fmt.Printf("Skipping %s: directory not found\n", srv.Name) - } - continue - } - - imageName := constructMcpServerImageName(cfg, srv.Name) - - if err := docker.Push(imageName); err != nil { - return fmt.Errorf("docker push failed for %s: %v", srv.Name, err) - } - } - - return nil -} - -// constructMcpServerImageName constructs the MCP server image name -func constructMcpServerImageName(cfg *BuildCfg, serverName string) string { - // Get agent name from kagent.yaml file - agentName := getAgentNameFromManifest(cfg.ProjectDir) - - // If no agent name found in manifest, fall back to directory name - if agentName == "" { - agentName = filepath.Base(cfg.ProjectDir) - } - return commonimage.ConstructMCPServerImageName(agentName, serverName) -} diff --git a/go/core/cli/internal/cli/agent/build_test.go b/go/core/cli/internal/cli/agent/build_test.go deleted file mode 100644 index 7ec3f16d4..000000000 --- a/go/core/cli/internal/cli/agent/build_test.go +++ /dev/null @@ -1,187 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "testing" - - "github.com/kagent-dev/kagent/go/core/cli/internal/config" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestBuildCmd_Validation(t *testing.T) { - tests := []struct { - name string - cfg *BuildCfg - setup func(t *testing.T) string // Returns temp dir path - wantErr bool - errMsg string - }{ - { - name: "missing project directory", - cfg: &BuildCfg{ - ProjectDir: "", - Config: &config.Config{}, - }, - setup: func(t *testing.T) string { return "" }, - wantErr: true, - errMsg: "project directory is required", - }, - { - name: "non-existent project directory", - cfg: &BuildCfg{ - ProjectDir: "/nonexistent/path", - Config: &config.Config{}, - }, - setup: func(t *testing.T) string { return "" }, - wantErr: true, - errMsg: "project directory does not exist", - }, - { - name: "missing Dockerfile", - cfg: &BuildCfg{ - Config: &config.Config{}, - }, - setup: func(t *testing.T) string { - tmpDir := t.TempDir() - return tmpDir - }, - wantErr: true, - errMsg: "dockerfile not found", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.setup != nil { - dir := tt.setup(t) - if dir != "" && tt.cfg.ProjectDir == "" { - tt.cfg.ProjectDir = dir - } - } - - err := BuildCmd(tt.cfg) - if tt.wantErr { - require.Error(t, err) - if tt.errMsg != "" { - assert.Contains(t, err.Error(), tt.errMsg) - } - } else { - require.NoError(t, err) - } - }) - } -} - -func TestConstructImageName(t *testing.T) { - tests := []struct { - name string - cfg *BuildCfg - setupFile func(t *testing.T, dir string) // Create kagent.yaml if needed - want string - }{ - { - name: "custom image name provided", - cfg: &BuildCfg{ - Image: "myregistry/myagent:v1.0", - ProjectDir: "", - }, - setupFile: nil, - want: "myregistry/myagent:v1.0", - }, - { - name: "fallback to directory name", - cfg: &BuildCfg{ - Image: "", - ProjectDir: "/path/to/my-agent", - }, - setupFile: nil, - want: "localhost:5001/my-agent:latest", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.setupFile != nil { - tmpDir := t.TempDir() - tt.cfg.ProjectDir = tmpDir - tt.setupFile(t, tmpDir) - } - - got := constructImageName(tt.cfg) - assert.Equal(t, tt.want, got) - }) - } -} - -func TestGetAgentNameFromManifest(t *testing.T) { - tests := []struct { - name string - manifestYAML string - want string - }{ - { - name: "valid manifest with agent name", - manifestYAML: `agentName: test-agent -description: Test agent -framework: adk -language: python -`, - want: "test-agent", - }, - { - name: "no manifest file", - manifestYAML: "", - want: "", - }, - { - name: "invalid yaml", - manifestYAML: `invalid: yaml: content: - - broken`, - want: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tmpDir := t.TempDir() - - if tt.manifestYAML != "" { - manifestPath := filepath.Join(tmpDir, "kagent.yaml") - err := os.WriteFile(manifestPath, []byte(tt.manifestYAML), 0644) - require.NoError(t, err) - } - - got := getAgentNameFromManifest(tmpDir) - assert.Equal(t, tt.want, got) - }) - } -} - -func TestConstructMcpServerImageName(t *testing.T) { - tests := []struct { - name string - projectDir string - serverName string - want string - }{ - { - name: "basic mcp server image", - projectDir: "/path/to/my-agent", - serverName: "weather-server", - want: "localhost:5001/my-agent-weather-server:latest", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := &BuildCfg{ - ProjectDir: tt.projectDir, - } - - got := constructMcpServerImageName(cfg, tt.serverName) - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/go/core/cli/internal/cli/agent/deploy.go b/go/core/cli/internal/cli/agent/deploy.go deleted file mode 100644 index 7cfb30a12..000000000 --- a/go/core/cli/internal/cli/agent/deploy.go +++ /dev/null @@ -1,1032 +0,0 @@ -package cli - -import ( - "bufio" - "context" - "fmt" - "maps" - "os" - "regexp" - "slices" - "strings" - "time" - - "github.com/kagent-dev/kagent/go/api/v1alpha3" - "github.com/kagent-dev/kagent/go/core/cli/internal/agent/frameworks/common" - commonimage "github.com/kagent-dev/kagent/go/core/cli/internal/common/image" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" - "github.com/kagent-dev/kmcp/api/v1alpha1" - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/kubernetes/scheme" - "k8s.io/client-go/tools/clientcmd" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/yaml" -) - -const ( - // Default namespace for deployments - defaultNamespace = "default" - - // Default images for MCP servers - defaultNodeImage = "node:24-alpine3.21" - defaultUVImage = "ghcr.io/astral-sh/uv:python3.12-alpine" - - // Default timeouts - defaultTimeout = 5 * time.Second - defaultSSEReadTimeout = 5 * time.Minute - - // Environment variable pattern for matching ${VAR} or $VAR - envVarPattern = `\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)` -) - -// DeployCfg contains all configuration options for deploying an agent to Kubernetes. -type DeployCfg struct { - // ProjectDir is the path to the agent project directory (must contain kagent.yaml) - ProjectDir string - - // Image is the Docker image name (e.g., "registry/name:tag"). If empty, defaults to localhost:5001/name:latest - Image string - - // EnvFile is the path to a .env file containing environment variables to be loaded into the agent. - // This MUST include the model provider API key (e.g., ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY). - // A Secret will be created with these values for the agent ActorTemplate. - EnvFile string - - // Platform specifies the target platform for Docker builds (e.g., "linux/amd64", "linux/arm64") - Platform string - - // Config contains CLI configuration (namespace, verbosity, etc.) - Config *config.Config - - // DryRun when true, outputs YAML manifests without actually creating resources - DryRun bool -} - -// DeployCmd deploys an agent to Kubernetes -func DeployCmd(ctx context.Context, k8sClient client.Client, cfg *DeployCfg) error { - // Validate that k8sClient is provided when not in dry-run mode - if k8sClient == nil && !cfg.DryRun { - return fmt.Errorf("kubernetes client is required for non-dry-run deployments") - } - - // Step 1: Validate and load project - manifest, err := validateAndLoadProject(cfg) - if err != nil { - return err - } - - // Step 2: Validate deployment requirements - apiKeyEnvVar, err := validateDeploymentRequirements(manifest) - if err != nil { - return err - } - - // Step 3: Extract environment variable references from manifest - requiredEnvVars := extractEnvVarsFromManifest(manifest) - - // Step 4: Validate environment variables and prompt user if needed - if err := validateAndPromptEnvVars(cfg, requiredEnvVars, apiKeyEnvVar); err != nil { - return err - } - - // Step 5: Build Docker image (skip in dry-run mode) - if err := buildAndPushImage(cfg); err != nil { - return err - } - - // Step 6: Setup namespace - if cfg.Config.Namespace == "" { - cfg.Config.Namespace = defaultNamespace - } - - // Step 7: Handle env file secret (contains API key and other env vars) - envData, err := handleEnvFileSecret(ctx, k8sClient, cfg, manifest) - if err != nil { - return err - } - - // Step 8: Deploy Agent CRD - if err := createAgentCRD(ctx, k8sClient, cfg, manifest, envData, IsVerbose(cfg.Config)); err != nil { - return err - } - - // Step 9: Deploy MCP servers if defined - if err := deployMCPServersIfNeeded(ctx, k8sClient, cfg, manifest); err != nil { - return err - } - - printDeploymentResult(cfg, manifest) - return nil -} - -// validateAndLoadProject validates the project directory and loads the manifest -func validateAndLoadProject(cfg *DeployCfg) (*common.AgentManifest, error) { - if cfg.ProjectDir == "" { - return nil, fmt.Errorf("project directory is required") - } - - if _, err := os.Stat(cfg.ProjectDir); os.IsNotExist(err) { - return nil, fmt.Errorf("project directory does not exist: %s", cfg.ProjectDir) - } - - manifest, err := LoadManifest(cfg.ProjectDir) - if err != nil { - return nil, fmt.Errorf("failed to load kagent.yaml: %v", err) - } - - return manifest, nil -} - -// buildAndPushImage builds and pushes the Docker image (skipped in dry-run mode) -func buildAndPushImage(cfg *DeployCfg) error { - if cfg.DryRun { - return nil - } - - fmt.Println("Building Docker image...") - buildCfg := &BuildCfg{ - ProjectDir: cfg.ProjectDir, - Image: cfg.Image, - Push: true, // Always push when deploying - Platform: cfg.Platform, - Config: cfg.Config, - SkipMCPServers: true, // Don't build MCP servers during deploy - } - - if err := BuildCmd(buildCfg); err != nil { - return fmt.Errorf("failed to build Docker image: %v", err) - } - - return nil -} - -// validateDeploymentRequirements validates deployment-specific requirements -func validateDeploymentRequirements(manifest *common.AgentManifest) (string, error) { - if manifest.ModelProvider == "" { - return "", fmt.Errorf("model provider is required in kagent.yaml") - } - - apiKeyEnvVar := getAPIKeyEnvVar(manifest.ModelProvider) - if apiKeyEnvVar == "" { - return "", fmt.Errorf("unsupported model provider: %s", manifest.ModelProvider) - } - - return apiKeyEnvVar, nil -} - -// envFileData holds both the secret name and the parsed env var keys -type envFileData struct { - SecretName string - EnvVarKeys []string -} - -// extractEnvVarsFromManifest extracts all environment variable references from the manifest -func extractEnvVarsFromManifest(manifest *common.AgentManifest) []string { - envVarRegex := regexp.MustCompile(envVarPattern) - envVarSet := make(map[string]bool) - - // Extract from MCP servers - for _, mcpServer := range manifest.McpServers { - if mcpServer.URL != "" { - matches := envVarRegex.FindAllStringSubmatch(mcpServer.URL, -1) - for _, match := range matches { - varName := extractEnvVarName(match) - envVarSet[varName] = true - } - } - - // Check headers - for _, headerValue := range mcpServer.Headers { - matches := envVarRegex.FindAllStringSubmatch(headerValue, -1) - for _, match := range matches { - varName := extractEnvVarName(match) - envVarSet[varName] = true - } - } - - // Check env vars - for _, envVar := range mcpServer.Env { - // Parse KEY=VALUE format - parts := strings.SplitN(envVar, "=", 2) - if len(parts) == 2 { - matches := envVarRegex.FindAllStringSubmatch(parts[1], -1) - for _, match := range matches { - varName := extractEnvVarName(match) - envVarSet[varName] = true - } - } - } - } - - // Convert set to sorted slice - envVars := make([]string, 0, len(envVarSet)) - for varName := range envVarSet { - envVars = append(envVars, varName) - } - slices.Sort(envVars) - - return envVars -} - -// promptUserConfirmation prompts the user with a yes/no question and returns an error if they decline -func promptUserConfirmation(message string) error { - fmt.Print(message) - var response string - if _, err := fmt.Scanln(&response); err != nil { - return fmt.Errorf("failed to read input: %v", err) - } - - response = strings.ToLower(strings.TrimSpace(response)) - if response != "y" && response != "yes" { - return fmt.Errorf("deployment cancelled by user") - } - fmt.Println() - return nil -} - -// validateAndPromptEnvVars validates environment variables and prompts user if needed -func validateAndPromptEnvVars(cfg *DeployCfg, requiredEnvVars []string, apiKeyEnvVar string) error { - if cfg.EnvFile == "" { - return fmt.Errorf("--env-file is required and must contain %s for the model provider", apiKeyEnvVar) - } - - // Env file provided, check if it contains the API key - envFileVars, err := parseEnvFile(cfg.EnvFile) - if err != nil { - return fmt.Errorf("failed to parse env file for validation: %v", err) - } - - if _, exists := envFileVars[apiKeyEnvVar]; !exists { - return fmt.Errorf(".env file must contain %s for the model provider", apiKeyEnvVar) - } - - // Check for other missing variables referenced in kagent.yaml - missingVars := []string{} - for _, varName := range requiredEnvVars { - if varName == apiKeyEnvVar { - continue - } - if _, exists := envFileVars[varName]; !exists { - missingVars = append(missingVars, varName) - } - } - - if len(missingVars) > 0 { - fmt.Printf("\n⚠️ Warning: The following variables are referenced in kagent.yaml but missing from %s:\n", cfg.EnvFile) - for _, varName := range missingVars { - fmt.Printf(" - %s\n", varName) - } - fmt.Printf("\nWithout these variables, your MCP servers or agents may fail to start or work correctly.\n") - fmt.Printf("Consider adding them to your .env file or ensure they're available at runtime.\n") - - if !cfg.DryRun { - if err := promptUserConfirmation("\nContinue anyway? (y/N): "); err != nil { - return err - } - } - } - - return nil -} - -// handleEnvFileSecret manages environment file secret creation -func handleEnvFileSecret(ctx context.Context, k8sClient client.Client, cfg *DeployCfg, manifest *common.AgentManifest) (*envFileData, error) { - if cfg.EnvFile == "" { - return nil, nil - } - - envVars, err := parseEnvFile(cfg.EnvFile) - if err != nil { - return nil, fmt.Errorf("failed to parse env file: %v", err) - } - - secretName := fmt.Sprintf("%s-env", manifest.Name) - if err := createEnvFileSecret(ctx, k8sClient, cfg.Config.Namespace, secretName, envVars, IsVerbose(cfg.Config), cfg.DryRun); err != nil { - return nil, fmt.Errorf("failed to create env file secret: %v", err) - } - - keys := make([]string, 0, len(envVars)) - for k := range envVars { - keys = append(keys, k) - } - - return &envFileData{ - SecretName: secretName, - EnvVarKeys: keys, - }, nil -} - -// parseEnvFile reads and parses a .env file, returning a map of environment variables -func parseEnvFile(filePath string) (map[string]string, error) { - file, err := os.Open(filePath) - if err != nil { - return nil, fmt.Errorf("failed to open env file: %v", err) - } - defer func() { _ = file.Close() }() - - envVars := make(map[string]string) - scanner := bufio.NewScanner(file) - lineNum := 0 - - for scanner.Scan() { - lineNum++ - line := strings.TrimSpace(scanner.Text()) - - // Skip empty lines and comments - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - // Parse KEY=VALUE format - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { - return nil, fmt.Errorf("invalid format at line %d: expected KEY=VALUE, got %q", lineNum, line) - } - - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - - // Remove surrounding quotes if present - value = strings.Trim(value, `"'`) - - if key == "" { - return nil, fmt.Errorf("empty key at line %d", lineNum) - } - - envVars[key] = value - } - - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("error reading env file: %v", err) - } - - return envVars, nil -} - -// createEnvFileSecret creates a Kubernetes secret from env file variables -func createEnvFileSecret(ctx context.Context, k8sClient client.Client, namespace, secretName string, envVars map[string]string, verbose bool, dryRun bool) error { - // Convert string map to byte map - secretData := make(map[string][]byte) - for k, v := range envVars { - secretData[k] = []byte(v) - } - - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: namespace, - }, - Data: secretData, - } - secret.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Secret")) - - // In dry-run mode, just output the YAML - if dryRun { - return outputYAML(secret) - } - - existingSecret := &corev1.Secret{} - err := k8sClient.Get(ctx, client.ObjectKey{Namespace: namespace, Name: secretName}, existingSecret) - - if err != nil { - if apierrors.IsNotFound(err) { - if err := k8sClient.Create(ctx, secret); err != nil { - return fmt.Errorf("failed to create env file secret: %v", err) - } - if verbose { - fmt.Printf("Created env file secret '%s' in namespace '%s' with %d variables\n", secretName, namespace, len(envVars)) - } - return nil - } - return fmt.Errorf("failed to check if env file secret exists: %v", err) - } - - existingSecret.Data = secretData - if err := k8sClient.Update(ctx, existingSecret); err != nil { - return fmt.Errorf("failed to update existing env file secret: %v", err) - } - if verbose { - fmt.Printf("Updated existing env file secret '%s' in namespace '%s' with %d variables\n", secretName, namespace, len(envVars)) - } - return nil -} - -// deployMCPServersIfNeeded deploys MCP servers if any are defined in the manifest -func deployMCPServersIfNeeded(ctx context.Context, k8sClient client.Client, cfg *DeployCfg, manifest *common.AgentManifest) error { - if len(manifest.McpServers) == 0 { - return nil - } - - if IsVerbose(cfg.Config) && !cfg.DryRun { - fmt.Printf("Deploying %d MCP server(s)...\n", len(manifest.McpServers)) - } - - if err := deployMCPServers(ctx, k8sClient, cfg, manifest); err != nil { - return fmt.Errorf("failed to deploy MCP servers: %v", err) - } - - return nil -} - -// printDeploymentResult prints the appropriate success/dry-run message -func printDeploymentResult(cfg *DeployCfg, manifest *common.AgentManifest) { - if !cfg.DryRun { - fmt.Printf("\n✅ Successfully deployed agent '%s' to namespace '%s'\n", manifest.Name, cfg.Config.Namespace) - fmt.Printf("\nTo check the agent status:\n") - fmt.Printf(" kubectl get sandboxagent %s -n %s\n", manifest.Name, cfg.Config.Namespace) - } -} - -// outputYAML serializes a Kubernetes object to YAML and prints it (for dry-run mode) -func outputYAML(obj client.Object) error { - yamlBytes, err := yaml.Marshal(obj) - if err != nil { - return fmt.Errorf("failed to marshal object to YAML: %v", err) - } - - fmt.Println("---") - fmt.Print(string(yamlBytes)) - return nil -} - -// getAPIKeyEnvVar returns the environment variable name for the given model provider -func getAPIKeyEnvVar(modelProvider string) string { - switch modelProvider { - case strings.ToLower(string(v1alpha3.ModelProviderAnthropic)): - return "ANTHROPIC_API_KEY" - case strings.ToLower(string(v1alpha3.ModelProviderOpenAI)): - return "OPENAI_API_KEY" - case strings.ToLower(string(v1alpha3.ModelProviderGemini)): - return "GOOGLE_API_KEY" - default: - return "" - } -} - -// CreateKubernetesClient creates a Kubernetes client -func CreateKubernetesClient() (client.Client, error) { - // Use the standard kubeconfig loading rules - loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() - configOverrides := &clientcmd.ConfigOverrides{} - - clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) - - config, err := clientConfig.ClientConfig() - if err != nil { - return nil, fmt.Errorf("failed to get Kubernetes config: %v", err) - } - - schemes := runtime.NewScheme() - if err := scheme.AddToScheme(schemes); err != nil { - return nil, fmt.Errorf("failed to add core scheme: %v", err) - } - if err := v1alpha1.AddToScheme(schemes); err != nil { - return nil, fmt.Errorf("failed to add kagent v1alpha1 scheme: %v", err) - } - if err := v1alpha3.AddToScheme(schemes); err != nil { - return nil, fmt.Errorf("failed to add kagent v1alpha3 scheme: %v", err) - } - - k8sClient, err := client.New(config, client.Options{Scheme: schemes}) - if err != nil { - return nil, fmt.Errorf("failed to create Kubernetes client: %v", err) - } - - return k8sClient, nil -} - -// createSecret creates or updates a Kubernetes secret with the specified key-value pair -func createSecret(ctx context.Context, k8sClient client.Client, namespace, secretName, key, value string, verbose bool, dryRun bool) error { - secret := buildSecret(namespace, secretName, key, value) - - // In dry-run mode, just output the YAML - if dryRun { - return outputYAML(secret) - } - return createOrUpdateSecret(ctx, k8sClient, secret, key, value, verbose) -} - -// buildSecret constructs a Kubernetes Secret object -func buildSecret(namespace, name, key, value string) *corev1.Secret { - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: namespace, - }, - Data: map[string][]byte{ - key: []byte(value), - }, - } - secret.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Secret")) - return secret -} - -// createOrUpdateSecret creates a new secret or updates an existing one -func createOrUpdateSecret(ctx context.Context, k8sClient client.Client, secret *corev1.Secret, key, value string, verbose bool) error { - existingSecret := &corev1.Secret{} - err := k8sClient.Get(ctx, client.ObjectKey{ - Namespace: secret.Namespace, - Name: secret.Name, - }, existingSecret) - - if err != nil { - if apierrors.IsNotFound(err) { - // Create new secret - if err := k8sClient.Create(ctx, secret); err != nil { - return fmt.Errorf("failed to create secret: %v", err) - } - if verbose { - fmt.Printf("Created secret '%s' in namespace '%s'\n", secret.Name, secret.Namespace) - } - return nil - } - return fmt.Errorf("failed to check if secret exists: %v", err) - } - - // Secret exists, update it - existingSecret.Data[key] = []byte(value) - if err := k8sClient.Update(ctx, existingSecret); err != nil { - return fmt.Errorf("failed to update existing secret: %v", err) - } - if verbose { - fmt.Printf("Updated existing secret '%s' in namespace '%s'\n", secret.Name, secret.Namespace) - } - return nil -} - -// createAgentCRD creates or updates the Agent CRD -func createAgentCRD(ctx context.Context, k8sClient client.Client, cfg *DeployCfg, manifest *common.AgentManifest, envData *envFileData, verbose bool) error { - imageName := determineImageName(cfg.Image, manifest.Name) - agent := buildAgentCRD(cfg.Config.Namespace, manifest, imageName, envData) - - // In dry-run mode, just output the YAML - if cfg.DryRun { - return outputYAML(agent) - } - - // Create or update the agent - return createOrUpdateAgent(ctx, k8sClient, agent, cfg.Config.Namespace, manifest.Name, verbose) -} - -// determineImageName returns the image name to use, either from config or default -func determineImageName(configImage, agentName string) string { - return commonimage.ConstructImageName(configImage, agentName) -} - -// buildAgentCRD constructs a SandboxAgent CRD object. -func buildAgentCRD(namespace string, manifest *common.AgentManifest, imageName string, envData *envFileData) *v1alpha3.SandboxAgent { - var envVars []corev1.EnvVar - - // Add all environment variables from the env file secret - if envData != nil { - for _, key := range envData.EnvVarKeys { - envVars = append(envVars, corev1.EnvVar{ - Name: key, - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: envData.SecretName, - }, - Key: key, - }, - }, - }) - } - } - - byoSpec := v1alpha3.BYOAgentSpec{ - Image: imageName, - Cmd: &manifest.Name, - Env: envVars, - } - - agent := &v1alpha3.SandboxAgent{ - ObjectMeta: metav1.ObjectMeta{ - Name: manifest.Name, - Namespace: namespace, - }, - Spec: v1alpha3.SandboxAgentSpec{ - Type: v1alpha3.AgentType_BYO, - Description: manifest.Description, - BYO: &byoSpec, - }, - } - agent.SetGroupVersionKind(v1alpha3.GroupVersion.WithKind("SandboxAgent")) - return agent -} - -// createOrUpdateAgent creates a new agent or updates an existing one -func createOrUpdateAgent(ctx context.Context, k8sClient client.Client, agent *v1alpha3.SandboxAgent, namespace, name string, verbose bool) error { - existingAgent := &v1alpha3.SandboxAgent{} - err := k8sClient.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, existingAgent) - - if err != nil { - if apierrors.IsNotFound(err) { - // Agent does not exist, create it - if err := k8sClient.Create(ctx, agent); err != nil { - return fmt.Errorf("failed to create agent: %v", err) - } - if verbose { - fmt.Printf("Created agent '%s' in namespace '%s'\n", name, namespace) - } - return nil - } - return fmt.Errorf("failed to check if agent exists: %v", err) - } - - // Agent exists, update it - existingAgent.Spec = agent.Spec - if err := k8sClient.Update(ctx, existingAgent); err != nil { - return fmt.Errorf("failed to update existing agent: %v", err) - } - if verbose { - fmt.Printf("Updated existing agent '%s' in namespace '%s'\n", name, namespace) - } - return nil -} - -// deployMCPServers deploys all MCP servers defined in the manifest -func deployMCPServers(ctx context.Context, k8sClient client.Client, cfg *DeployCfg, manifest *common.AgentManifest) error { - verbose := IsVerbose(cfg.Config) - - for i, mcpServer := range manifest.McpServers { - if verbose && !cfg.DryRun { - fmt.Printf("Deploying MCP server '%s' (type: %s)...\n", mcpServer.Name, mcpServer.Type) - } - - switch mcpServer.Type { - case "remote": - // Deploy RemoteMCPServer (v1alpha3) - if err := deployRemoteMCPServer(ctx, k8sClient, cfg.Config.Namespace, &mcpServer, verbose, cfg.DryRun); err != nil { - return fmt.Errorf("failed to deploy remote MCP server '%s': %v", mcpServer.Name, err) - } - case "command": - // Deploy MCPServer (v1alpha1) - if err := deployCommandMCPServer(ctx, k8sClient, cfg.Config.Namespace, &mcpServer, verbose, cfg.DryRun); err != nil { - return fmt.Errorf("failed to deploy command MCP server '%s': %v", mcpServer.Name, err) - } - default: - return fmt.Errorf("mcpServers[%d]: unsupported type '%s'", i, mcpServer.Type) - } - } - - return nil -} - -// deployRemoteMCPServer creates or updates a RemoteMCPServer resource -func deployRemoteMCPServer(ctx context.Context, k8sClient client.Client, namespace string, mcpServer *common.McpServerType, verbose bool, dryRun bool) error { - // Process headers and create necessary secrets - headerRefs, err := createSecretsForHeaders(ctx, k8sClient, namespace, mcpServer, verbose, dryRun) - if err != nil { - return fmt.Errorf("failed to create secrets for headers: %v", err) - } - - remoteMCPServer := buildRemoteMCPServer(namespace, mcpServer, headerRefs) - - if dryRun { - return outputYAML(remoteMCPServer) - } - return createOrUpdateRemoteMCPServer(ctx, k8sClient, remoteMCPServer, namespace, mcpServer.Name, verbose) -} - -// buildRemoteMCPServer constructs a RemoteMCPServer CRD object -func buildRemoteMCPServer(namespace string, mcpServer *common.McpServerType, headerRefs []v1alpha3.ValueRef) *v1alpha3.RemoteMCPServer { - timeout := metav1.Duration{Duration: defaultTimeout} - sseReadTimeout := metav1.Duration{Duration: defaultSSEReadTimeout} - terminateOnClose := true - - remoteMCPServer := &v1alpha3.RemoteMCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: mcpServer.Name, - Namespace: namespace, - }, - Spec: v1alpha3.RemoteMCPServerSpec{ - Description: fmt.Sprintf("Remote MCP server: %s", mcpServer.Name), - Protocol: v1alpha3.RemoteMCPServerProtocolStreamableHttp, - URL: mcpServer.URL, - HeadersFrom: headerRefs, - Timeout: &timeout, - SseReadTimeout: &sseReadTimeout, - TerminateOnClose: &terminateOnClose, - }, - } - remoteMCPServer.SetGroupVersionKind(v1alpha3.GroupVersion.WithKind("RemoteMCPServer")) - return remoteMCPServer -} - -// createOrUpdateRemoteMCPServer creates a new RemoteMCPServer or updates an existing one -func createOrUpdateRemoteMCPServer(ctx context.Context, k8sClient client.Client, remoteMCPServer *v1alpha3.RemoteMCPServer, namespace, name string, verbose bool) error { - existingRemoteMCPServer := &v1alpha3.RemoteMCPServer{} - err := k8sClient.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, existingRemoteMCPServer) - - if err != nil { - if apierrors.IsNotFound(err) { - // Create new RemoteMCPServer - if err := k8sClient.Create(ctx, remoteMCPServer); err != nil { - return fmt.Errorf("failed to create RemoteMCPServer: %v", err) - } - if verbose { - fmt.Printf("Created RemoteMCPServer '%s' in namespace '%s'\n", name, namespace) - } - return nil - } - return fmt.Errorf("failed to check if RemoteMCPServer exists: %v", err) - } - - // RemoteMCPServer exists, update it - existingRemoteMCPServer.Spec = remoteMCPServer.Spec - if err := k8sClient.Update(ctx, existingRemoteMCPServer); err != nil { - return fmt.Errorf("failed to update existing RemoteMCPServer: %v", err) - } - if verbose { - fmt.Printf("Updated existing RemoteMCPServer '%s' in namespace '%s'\n", name, namespace) - } - return nil -} - -// deployCommandMCPServer creates or updates an MCPServer resource for command/stdio type -func deployCommandMCPServer(ctx context.Context, k8sClient client.Client, namespace string, mcpServer *common.McpServerType, verbose bool, dryRun bool) error { - // Process environment variables and create necessary secrets - envMap, secretRefs, err := createSecretsForEnv(ctx, k8sClient, namespace, mcpServer, verbose, dryRun) - if err != nil { - return fmt.Errorf("failed to create secrets for env vars: %v", err) - } - - image := determineCommandMCPServerImage(mcpServer) - mcpServerResource := buildCommandMCPServer(namespace, mcpServer, image, envMap, secretRefs) - - if dryRun { - return outputYAML(mcpServerResource) - } - - return createOrUpdateMCPServer(ctx, k8sClient, mcpServerResource, namespace, mcpServer.Name, verbose) -} - -// determineCommandMCPServerImage returns the appropriate Docker image based on the command -func determineCommandMCPServerImage(mcpServer *common.McpServerType) string { - if mcpServer.Image != "" { - return mcpServer.Image - } - - switch { - case strings.HasPrefix(mcpServer.Command, "npx"): - return defaultNodeImage - case strings.HasPrefix(mcpServer.Command, "uvx"): - return defaultUVImage - default: - return defaultNodeImage - } -} - -// buildCommandMCPServer constructs an MCPServer CRD object -func buildCommandMCPServer(namespace string, mcpServer *common.McpServerType, image string, envMap map[string]string, secretRefs []corev1.LocalObjectReference) *v1alpha1.MCPServer { - mcpServerResource := &v1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: mcpServer.Name, - Namespace: namespace, - }, - Spec: v1alpha1.MCPServerSpec{ - TransportType: v1alpha1.TransportTypeStdio, - StdioTransport: &v1alpha1.StdioTransport{}, - Deployment: v1alpha1.MCPServerDeployment{ - Image: image, - Port: 3000, - Cmd: mcpServer.Command, - Args: mcpServer.Args, - Env: envMap, - SecretRefs: secretRefs, - }, - }, - } - mcpServerResource.SetGroupVersionKind(v1alpha1.GroupVersion.WithKind("MCPServer")) - return mcpServerResource -} - -// createOrUpdateMCPServer creates a new MCPServer or updates an existing one -func createOrUpdateMCPServer(ctx context.Context, k8sClient client.Client, mcpServerResource *v1alpha1.MCPServer, namespace, name string, verbose bool) error { - existingMCPServer := &v1alpha1.MCPServer{} - err := k8sClient.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, existingMCPServer) - - if err != nil { - if apierrors.IsNotFound(err) { - // Create new MCPServer - if err := k8sClient.Create(ctx, mcpServerResource); err != nil { - return fmt.Errorf("failed to create MCPServer: %v", err) - } - if verbose { - fmt.Printf("Created MCPServer '%s' in namespace '%s'\n", name, namespace) - } - return nil - } - return fmt.Errorf("failed to check if MCPServer exists: %v", err) - } - - // MCPServer exists, update it - existingMCPServer.Spec = mcpServerResource.Spec - if err := k8sClient.Update(ctx, existingMCPServer); err != nil { - return fmt.Errorf("failed to update existing MCPServer: %v", err) - } - if verbose { - fmt.Printf("Updated existing MCPServer '%s' in namespace '%s'\n", name, namespace) - } - return nil -} - -// createSecretsForHeaders creates secrets for header values that reference environment variables -func createSecretsForHeaders(ctx context.Context, k8sClient client.Client, namespace string, mcpServer *common.McpServerType, verbose bool, dryRun bool) ([]v1alpha3.ValueRef, error) { - var headerRefs []v1alpha3.ValueRef - envVarRegex := regexp.MustCompile(envVarPattern) - - for headerName, headerValue := range mcpServer.Headers { - headerRef, err := processHeaderValue(ctx, k8sClient, namespace, mcpServer.Name, headerName, headerValue, envVarRegex, verbose, dryRun) - if err != nil { - return nil, err - } - headerRefs = append(headerRefs, headerRef) - } - - return headerRefs, nil -} - -// processHeaderValue processes a single header value and creates a secret if needed -func processHeaderValue(ctx context.Context, k8sClient client.Client, namespace, serverName, headerName, headerValue string, envVarRegex *regexp.Regexp, verbose bool, dryRun bool) (v1alpha3.ValueRef, error) { - // Check if the header value contains environment variable references - matches := envVarRegex.FindStringSubmatch(headerValue) - if len(matches) == 0 { - return v1alpha3.ValueRef{ - Name: headerName, - Value: headerValue, - }, nil - } - - envVarName := extractEnvVarName(matches) - envValue := os.Getenv(envVarName) - if envValue == "" { - return v1alpha3.ValueRef{}, fmt.Errorf("environment variable '%s' referenced in header '%s' is not set", envVarName, headerName) - } - - // Replace the environment variable reference with the actual value - // This preserves the full header value like "Bearer ${GITHUB_TOKEN}" -> "Bearer " - fullHeaderValue := envVarRegex.ReplaceAllString(headerValue, envValue) - - // Create a secret for the full header value - secretName := fmt.Sprintf("%s-%s", serverName, sanitizeForSecretName(headerName)) - secretKey := sanitizeForSecretKey(headerName) - - if err := createSecret(ctx, k8sClient, namespace, secretName, secretKey, fullHeaderValue, verbose, dryRun); err != nil { - return v1alpha3.ValueRef{}, fmt.Errorf("failed to create secret for header '%s': %v", headerName, err) - } - - // Return the header reference pointing to the secret - return v1alpha3.ValueRef{ - Name: headerName, - ValueFrom: &v1alpha3.ValueSource{ - Type: v1alpha3.SecretValueSource, - Name: secretName, - Key: secretKey, - }, - }, nil -} - -// extractEnvVarName extracts the environment variable name from regex matches -func extractEnvVarName(matches []string) string { - if matches[1] != "" { - return matches[1] // ${VAR_NAME} format - } - return matches[2] // $VAR_NAME format -} - -// sanitizeForSecretName converts a header name to a valid Kubernetes secret name -func sanitizeForSecretName(headerName string) string { - return strings.ToLower(strings.ReplaceAll(headerName, "-", "")) -} - -// sanitizeForSecretKey converts a header name to a valid secret key -func sanitizeForSecretKey(headerName string) string { - return strings.ToLower(strings.ReplaceAll(headerName, "-", "_")) -} - -// createSecretsForEnv creates secrets for environment variables and returns env map and secret refs -func createSecretsForEnv(ctx context.Context, k8sClient client.Client, namespace string, mcpServer *common.McpServerType, verbose bool, dryRun bool) (map[string]string, []corev1.LocalObjectReference, error) { - envMap := make(map[string]string) - secretData := make(map[string][]byte) - envVarRegex := regexp.MustCompile(envVarPattern) - - for _, envVar := range mcpServer.Env { - envKey, envValue, err := parseEnvVar(envVar) - if err != nil { - return nil, nil, err - } - - // Check if the value references an environment variable - matches := envVarRegex.FindStringSubmatch(envValue) - if len(matches) > 0 { - // Environment variable reference - needs to go into a secret - actualValue, err := resolveEnvVarReference(matches, envKey) - if err != nil { - return nil, nil, err - } - secretData[strings.ToLower(envKey)] = []byte(actualValue) - } else { - // Static value, add to env map - envMap[envKey] = envValue - } - } - - var secretRefs []corev1.LocalObjectReference - if len(secretData) > 0 { - secretName := fmt.Sprintf("%s-env", mcpServer.Name) - - if dryRun { - if err := outputEnvSecret(namespace, secretName, secretData); err != nil { - return nil, nil, err - } - } else { - if err := createOrUpdateEnvSecret(ctx, k8sClient, namespace, secretName, secretData, verbose); err != nil { - return nil, nil, err - } - } - - secretRefs = append(secretRefs, corev1.LocalObjectReference{Name: secretName}) - } - - return envMap, secretRefs, nil -} - -// parseEnvVar parses an environment variable in KEY=VALUE format -func parseEnvVar(envVar string) (key, value string, err error) { - parts := strings.SplitN(envVar, "=", 2) - if len(parts) != 2 { - return "", "", fmt.Errorf("invalid env var format '%s', expected KEY=VALUE", envVar) - } - return parts[0], parts[1], nil -} - -// resolveEnvVarReference resolves an environment variable reference and returns its actual value -func resolveEnvVarReference(matches []string, targetEnvKey string) (string, error) { - refEnvVarName := extractEnvVarName(matches) - actualValue := os.Getenv(refEnvVarName) - if actualValue == "" { - return "", fmt.Errorf("environment variable '%s' referenced in env var '%s' is not set", refEnvVarName, targetEnvKey) - } - return actualValue, nil -} - -// outputEnvSecret outputs a secret containing environment variables (for dry-run mode) -func outputEnvSecret(namespace, secretName string, secretData map[string][]byte) error { - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: namespace, - }, - Data: secretData, - } - secret.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Secret")) - if err := outputYAML(secret); err != nil { - return fmt.Errorf("failed to output secret YAML: %v", err) - } - return nil -} - -// createOrUpdateEnvSecret creates or updates a secret containing multiple environment variables -func createOrUpdateEnvSecret(ctx context.Context, k8sClient client.Client, namespace, secretName string, secretData map[string][]byte, verbose bool) error { - existingSecret := &corev1.Secret{} - err := k8sClient.Get(ctx, client.ObjectKey{Namespace: namespace, Name: secretName}, existingSecret) - - if err != nil { - if apierrors.IsNotFound(err) { - // Secret doesn't exist, create it with all data - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: namespace, - }, - Data: secretData, - } - secret.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Secret")) - - if err := k8sClient.Create(ctx, secret); err != nil { - return fmt.Errorf("failed to create env secret: %v", err) - } - if verbose { - fmt.Printf("Created env secret '%s' in namespace '%s'\n", secretName, namespace) - } - return nil - } - return fmt.Errorf("failed to get existing secret: %v", err) - } - - // Secret exists, merge the new data with existing data - maps.Copy(existingSecret.Data, secretData) - - if err := k8sClient.Update(ctx, existingSecret); err != nil { - return fmt.Errorf("failed to update existing secret: %v", err) - } - if verbose { - fmt.Printf("Updated env secret '%s' in namespace '%s'\n", secretName, namespace) - } - return nil -} diff --git a/go/core/cli/internal/cli/agent/deploy_test.go b/go/core/cli/internal/cli/agent/deploy_test.go deleted file mode 100644 index 7a75b2de5..000000000 --- a/go/core/cli/internal/cli/agent/deploy_test.go +++ /dev/null @@ -1,201 +0,0 @@ -package cli - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/kagent-dev/kagent/go/core/cli/internal/config" - "github.com/kagent-dev/kagent/go/core/cli/test/testutil" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestDeployCmd_DryRun_Success(t *testing.T) { - // Create temporary project directory with manifest and Dockerfile - tmpDir := t.TempDir() - - // Create kagent.yaml - manifestContent := `agentName: test-agent -description: Test agent for deployment -framework: adk -language: python -modelProvider: anthropic -` - manifestPath := filepath.Join(tmpDir, "kagent.yaml") - err := os.WriteFile(manifestPath, []byte(manifestContent), 0644) - require.NoError(t, err) - - // Create Dockerfile - dockerfileContent := `FROM python:3.12-slim -WORKDIR /app -COPY . . -CMD ["python", "main.py"] -` - dockerfilePath := filepath.Join(tmpDir, "Dockerfile") - err = os.WriteFile(dockerfilePath, []byte(dockerfileContent), 0644) - require.NoError(t, err) - - // Create .env file with API key - envContent := `ANTHROPIC_API_KEY=test-key-12345 -OTHER_VAR=value -` - envPath := filepath.Join(tmpDir, ".env") - err = os.WriteFile(envPath, []byte(envContent), 0644) - require.NoError(t, err) - - // Setup config - cfg := &DeployCfg{ - ProjectDir: tmpDir, - EnvFile: envPath, - DryRun: true, // Dry-run mode to avoid Docker build - Config: &config.Config{ - Namespace: "test-namespace", - }, - } - - // Create fake K8s client (not used in dry-run, but required by signature) - k8sClient := testutil.NewFakeControllerClient(t) - - // Call DeployCmd - err = DeployCmd(context.Background(), k8sClient, cfg) - - // Should succeed in dry-run mode - assert.NoError(t, err) -} - -func TestDeployCmd_MissingProjectDir(t *testing.T) { - cfg := &DeployCfg{ - ProjectDir: "", - Config: &config.Config{}, - } - - k8sClient := testutil.NewFakeControllerClient(t) - - err := DeployCmd(context.Background(), k8sClient, cfg) - - require.Error(t, err) - assert.Contains(t, err.Error(), "project directory is required") -} - -func TestDeployCmd_NonExistentProjectDir(t *testing.T) { - cfg := &DeployCfg{ - ProjectDir: "/nonexistent/path", - Config: &config.Config{}, - } - - k8sClient := testutil.NewFakeControllerClient(t) - - err := DeployCmd(context.Background(), k8sClient, cfg) - - require.Error(t, err) - assert.Contains(t, err.Error(), "project directory does not exist") -} - -func TestDeployCmd_MissingManifest(t *testing.T) { - tmpDir := t.TempDir() - - cfg := &DeployCfg{ - ProjectDir: tmpDir, - Config: &config.Config{}, - } - - k8sClient := testutil.NewFakeControllerClient(t) - - err := DeployCmd(context.Background(), k8sClient, cfg) - - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to load kagent.yaml") -} - -func TestDeployCmd_MissingModelProvider(t *testing.T) { - tmpDir := t.TempDir() - - // Create manifest without modelProvider - manifestContent := `agentName: test-agent -description: Test agent -framework: adk -language: python -` - manifestPath := filepath.Join(tmpDir, "kagent.yaml") - err := os.WriteFile(manifestPath, []byte(manifestContent), 0644) - require.NoError(t, err) - - cfg := &DeployCfg{ - ProjectDir: tmpDir, - DryRun: true, - Config: &config.Config{}, - } - - k8sClient := testutil.NewFakeControllerClient(t) - - err = DeployCmd(context.Background(), k8sClient, cfg) - - require.Error(t, err) - assert.Contains(t, err.Error(), "model provider is required") -} - -func TestDeployCmd_MissingEnvFile(t *testing.T) { - tmpDir := t.TempDir() - - manifestContent := `agentName: test-agent -description: Test agent -framework: adk -language: python -modelProvider: anthropic -` - manifestPath := filepath.Join(tmpDir, "kagent.yaml") - err := os.WriteFile(manifestPath, []byte(manifestContent), 0644) - require.NoError(t, err) - - cfg := &DeployCfg{ - ProjectDir: tmpDir, - EnvFile: "", // Missing env file - DryRun: true, - Config: &config.Config{}, - } - - k8sClient := testutil.NewFakeControllerClient(t) - - err = DeployCmd(context.Background(), k8sClient, cfg) - - require.Error(t, err) - assert.Contains(t, err.Error(), "--env-file is required") -} - -func TestDeployCmd_EnvFileMissingAPIKey(t *testing.T) { - tmpDir := t.TempDir() - - manifestContent := `agentName: test-agent -description: Test agent -framework: adk -language: python -modelProvider: anthropic -` - manifestPath := filepath.Join(tmpDir, "kagent.yaml") - err := os.WriteFile(manifestPath, []byte(manifestContent), 0644) - require.NoError(t, err) - - // Create .env file WITHOUT the required API key - envContent := `OTHER_VAR=value -SOME_KEY=some_value -` - envPath := filepath.Join(tmpDir, ".env") - err = os.WriteFile(envPath, []byte(envContent), 0644) - require.NoError(t, err) - - cfg := &DeployCfg{ - ProjectDir: tmpDir, - EnvFile: envPath, - DryRun: true, - Config: &config.Config{}, - } - - k8sClient := testutil.NewFakeControllerClient(t) - - err = DeployCmd(context.Background(), k8sClient, cfg) - - require.Error(t, err) - assert.Contains(t, err.Error(), "must contain ANTHROPIC_API_KEY") -} diff --git a/go/core/cli/internal/cli/agent/error_handling_test.go b/go/core/cli/internal/cli/agent/error_handling_test.go deleted file mode 100644 index 7687749c1..000000000 --- a/go/core/cli/internal/cli/agent/error_handling_test.go +++ /dev/null @@ -1,526 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "testing" - - "github.com/kagent-dev/kagent/go/core/cli/internal/config" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestMalformedManifest tests handling of corrupted or invalid YAML files -func TestMalformedManifest(t *testing.T) { - tests := []struct { - name string - manifestYAML string - wantErr bool - errContains string - }{ - { - name: "invalid YAML syntax", - manifestYAML: `agentName: test-agent -description: Test -framework: [invalid yaml syntax -`, - wantErr: true, - errContains: "failed to parse", - }, - { - name: "missing required field - agentName", - manifestYAML: `description: Test agent -framework: adk -language: python -`, - wantErr: true, - errContains: "agent name is required", - }, - { - name: "missing required field - framework", - manifestYAML: `agentName: test-agent -description: Test agent -language: python -`, - wantErr: true, - errContains: "framework is required", - }, - { - name: "missing required field - language", - manifestYAML: `agentName: test-agent -description: Test agent -framework: adk -`, - wantErr: true, - errContains: "language is required", - }, - { - name: "empty file", - manifestYAML: "", - wantErr: true, - errContains: "agent name is required", - }, - { - name: "only whitespace", - manifestYAML: " \n\n \t\t\n", - wantErr: true, - errContains: "failed to parse", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tmpDir := t.TempDir() - manifestPath := filepath.Join(tmpDir, "kagent.yaml") - - err := os.WriteFile(manifestPath, []byte(tt.manifestYAML), 0644) - require.NoError(t, err) - - _, err = LoadManifest(tmpDir) - - if tt.wantErr { - assert.Error(t, err) - if tt.errContains != "" { - assert.Contains(t, err.Error(), tt.errContains) - } - } else { - assert.NoError(t, err) - } - }) - } -} - -// TestInvalidProjectStructure tests handling of incomplete or incorrect project structures -func TestInvalidProjectStructure(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T) string - wantErr bool - errContains string - }{ - { - name: "project directory does not exist", - setup: func(t *testing.T) string { - return "/nonexistent/directory/path" - }, - wantErr: true, - errContains: "not found", - }, - { - name: "project directory is a file not directory", - setup: func(t *testing.T) string { - tmpFile := filepath.Join(t.TempDir(), "notadir") - os.WriteFile(tmpFile, []byte("content"), 0644) - return tmpFile - }, - wantErr: true, - errContains: "not a directory", - }, - { - name: "empty project directory", - setup: func(t *testing.T) string { - return t.TempDir() - }, - wantErr: true, - errContains: "kagent.yaml not found", - }, - { - name: "manifest in wrong location", - setup: func(t *testing.T) string { - tmpDir := t.TempDir() - subDir := filepath.Join(tmpDir, "subdir") - os.MkdirAll(subDir, 0755) - - // Put manifest in subdirectory instead of root - manifestPath := filepath.Join(subDir, "kagent.yaml") - content := `agentName: test-agent -framework: adk -language: python -` - os.WriteFile(manifestPath, []byte(content), 0644) - return tmpDir // Return parent dir - }, - wantErr: true, - errContains: "kagent.yaml not found", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - projectDir := tt.setup(t) - - _, err := LoadManifest(projectDir) - - if tt.wantErr { - assert.Error(t, err) - if tt.errContains != "" { - assert.Contains(t, err.Error(), tt.errContains) - } - } else { - assert.NoError(t, err) - } - }) - } -} - -// TestFilePermissionErrors tests handling of permission-related errors -func TestFilePermissionErrors(t *testing.T) { - if os.Getuid() == 0 { - t.Skip("Skipping permission tests when running as root") - } - - t.Run("unreadable manifest file", func(t *testing.T) { - tmpDir := t.TempDir() - manifestPath := filepath.Join(tmpDir, "kagent.yaml") - - // Create manifest - content := `agentName: test-agent -framework: adk -language: python -` - err := os.WriteFile(manifestPath, []byte(content), 0644) - require.NoError(t, err) - - // Make unreadable - err = os.Chmod(manifestPath, 0000) - require.NoError(t, err) - defer os.Chmod(manifestPath, 0644) // Cleanup - - _, err = LoadManifest(tmpDir) - assert.Error(t, err, "Should fail to read unreadable file") - }) - - t.Run("unwritable project directory", func(t *testing.T) { - // Change to temp directory and make it unwritable - originalWd, _ := os.Getwd() - tmpDir := t.TempDir() - os.Chdir(tmpDir) - defer func() { - os.Chmod(tmpDir, 0755) // Make writable first - os.Chdir(originalWd) - }() - - // Make directory unwritable - err := os.Chmod(tmpDir, 0555) - require.NoError(t, err) - - initCfg := &InitCfg{ - AgentName: "testagent", - Framework: "adk", - Language: "python", - ModelProvider: "Ollama", - Config: &config.Config{}, - } - - err = InitCmd(initCfg) - assert.Error(t, err, "Should fail to write to read-only directory") - }) -} - -// TestInvalidInputValidation tests validation of invalid user inputs -func TestInvalidInputValidation(t *testing.T) { - tests := []struct { - name string - cfg *InitCfg - wantErr bool - errMsg string - }{ - { - name: "agent name with invalid characters", - cfg: &InitCfg{ - AgentName: "agent@name!", - Framework: "adk", - Language: "python", - ModelProvider: "Ollama", - Config: &config.Config{}, - }, - wantErr: true, - errMsg: "invalid agent name", - }, - { - name: "agent name starts with number", - cfg: &InitCfg{ - AgentName: "123agent", - Framework: "adk", - Language: "python", - ModelProvider: "Ollama", - Config: &config.Config{}, - }, - wantErr: true, - errMsg: "must start with a letter", - }, - { - name: "empty agent name", - cfg: &InitCfg{ - AgentName: "", - Framework: "adk", - Language: "python", - ModelProvider: "OpenAI", - Config: &config.Config{}, - }, - wantErr: true, - errMsg: "agent name cannot be empty", - }, - { - name: "invalid framework", - cfg: &InitCfg{ - AgentName: "testinvalidfw", - Framework: "invalid-framework", - Language: "python", - ModelProvider: "Ollama", - Config: &config.Config{}, - }, - wantErr: true, - errMsg: "unsupported framework", - }, - { - name: "invalid language", - cfg: &InitCfg{ - AgentName: "testinvalidlang", - Framework: "adk", - Language: "ruby", - ModelProvider: "Ollama", - Config: &config.Config{}, - }, - wantErr: true, - errMsg: "unsupported language", - }, - { - name: "invalid model provider", - cfg: &InitCfg{ - AgentName: "testinvalidprov", - Framework: "adk", - Language: "python", - ModelProvider: "InvalidProvider", - Config: &config.Config{}, - }, - wantErr: true, - errMsg: "model provider", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Run InitCmd which will do validation - // We need to be in a temp directory to avoid creating directories in workspace - originalWd, _ := os.Getwd() - tmpDir := t.TempDir() - os.Chdir(tmpDir) - defer os.Chdir(originalWd) - - err := InitCmd(tt.cfg) - - if tt.wantErr { - assert.Error(t, err) - if tt.errMsg != "" { - assert.Contains(t, err.Error(), tt.errMsg) - } - } else { - assert.NoError(t, err) - } - }) - } -} - -// TestResourceConflicts tests handling of resource naming conflicts -func TestResourceConflicts(t *testing.T) { - t.Run("duplicate MCP server name", func(t *testing.T) { - tmpDir := t.TempDir() - - // Create manifest with existing MCP server - manifestPath := filepath.Join(tmpDir, "kagent.yaml") - manifestContent := `agentName: test-agent -framework: adk -language: python -mcpServers: - - name: existing-server - type: remote - url: http://example.com -` - err := os.WriteFile(manifestPath, []byte(manifestContent), 0644) - require.NoError(t, err) - - // Try to add MCP server with same name - cfg := &AddMcpCfg{ - ProjectDir: tmpDir, - Name: "existing-server", - RemoteURL: "http://other.com", - Config: &config.Config{}, - } - - err = AddMcpCmd(cfg) - assert.Error(t, err) - assert.Contains(t, err.Error(), "already exists") - }) - - t.Run("project already exists", func(t *testing.T) { - // Change to temp directory - originalWd, _ := os.Getwd() - tmpDir := t.TempDir() - os.Chdir(tmpDir) - defer os.Chdir(originalWd) - - // Create initial project - initCfg := &InitCfg{ - AgentName: "testagent", - Framework: "adk", - Language: "python", - ModelProvider: "OpenAI", - Config: &config.Config{}, - } - - err := InitCmd(initCfg) - require.NoError(t, err) - - // Try to initialize again with same name (will overwrite) - err = InitCmd(initCfg) - // Note: Current implementation may overwrite, but this tests the behavior - t.Logf("Re-init result: %v", err) - }) -} - -// TestEdgeCaseInputs tests boundary conditions and edge cases -func TestEdgeCaseInputs(t *testing.T) { - tests := []struct { - name string - cfg *InitCfg - wantErr bool - }{ - { - name: "very long agent name with null bytes", - cfg: &InitCfg{ - AgentName: "a" + string(make([]byte, 250)), - Framework: "adk", - Language: "python", - ModelProvider: "OpenAI", - Config: &config.Config{}, - }, - wantErr: true, // Should fail due to null bytes - }, - { - name: "agent name with unicode characters", - cfg: &InitCfg{ - AgentName: "测试agent", - Framework: "adk", - Language: "python", - ModelProvider: "OpenAI", - Config: &config.Config{}, - }, - wantErr: false, // Unicode letters should be allowed - }, - { - name: "agent name with hyphens not allowed", - cfg: &InitCfg{ - AgentName: "a-b-c-d-e-f", - Framework: "adk", - Language: "python", - ModelProvider: "OpenAI", - Config: &config.Config{}, - }, - wantErr: true, // Hyphens not allowed - }, - { - name: "minimum valid agent name", - cfg: &InitCfg{ - AgentName: "a", - Framework: "adk", - Language: "python", - ModelProvider: "OpenAI", - Config: &config.Config{}, - }, - wantErr: false, - }, - { - name: "agent name with underscores allowed", - cfg: &InitCfg{ - AgentName: "test_agent_name", - Framework: "adk", - Language: "python", - ModelProvider: "OpenAI", - Config: &config.Config{}, - }, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Change to temp directory before running init - originalWd, _ := os.Getwd() - tmpDir := t.TempDir() - os.Chdir(tmpDir) - defer os.Chdir(originalWd) - - err := InitCmd(tt.cfg) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} - -// TestDeployWithMissingResources tests deploy when required resources are missing -func TestDeployWithMissingResources(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T) string - wantErr bool - errMsg string - }{ - { - name: "missing docker-compose.yaml", - setup: func(t *testing.T) string { - tmpDir := t.TempDir() - // Create only manifest, no docker-compose - manifestPath := filepath.Join(tmpDir, "kagent.yaml") - content := `agentName: test-agent -framework: adk -language: python -` - os.WriteFile(manifestPath, []byte(content), 0644) - return tmpDir - }, - wantErr: false, // Deploy doesn't require docker-compose - }, - { - name: "missing agent source files", - setup: func(t *testing.T) string { - tmpDir := t.TempDir() - // Create manifest but no agent directory - manifestPath := filepath.Join(tmpDir, "kagent.yaml") - content := `agentName: test-agent -framework: adk -language: python -` - os.WriteFile(manifestPath, []byte(content), 0644) - return tmpDir - }, - wantErr: false, // Deploy validation doesn't check agent files - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - projectDir := tt.setup(t) - - cfg := &DeployCfg{ - ProjectDir: projectDir, - Config: &config.Config{}, - } - - _, err := validateAndLoadProject(cfg) - - if tt.wantErr { - assert.Error(t, err) - if tt.errMsg != "" { - assert.Contains(t, err.Error(), tt.errMsg) - } - } else { - assert.NoError(t, err) - } - }) - } -} diff --git a/go/core/cli/internal/cli/agent/format.go b/go/core/cli/internal/cli/agent/format.go deleted file mode 100644 index 2a681def7..000000000 --- a/go/core/cli/internal/cli/agent/format.go +++ /dev/null @@ -1,53 +0,0 @@ -package cli - -import ( - "encoding/json" - "fmt" - "slices" - - "github.com/jedib0t/go-pretty/v6/table" - "github.com/kagent-dev/kagent/go/core/internal/utils" - "github.com/spf13/viper" -) - -type OutputFormat string - -const ( - OutputFormatJSON OutputFormat = "json" - OutputFormatTable OutputFormat = "table" -) - -func printOutput(data any, tableHeaders []string, tableRows [][]string) error { - format := OutputFormat(viper.GetString("output_format")) - - tw := table.NewWriter() - headers := slices.Collect(utils.Map(slices.Values(tableHeaders), func(header string) any { - return header - })) - tw.AppendHeader(headers) - rows := slices.Collect(utils.Map(slices.Values(tableRows), func(row []string) table.Row { - return slices.Collect(utils.Map(slices.Values(row), func(cell string) any { - return cell - })) - })) - tw.AppendRows(rows) - - switch format { - case OutputFormatJSON: - return printJSON(data) - case OutputFormatTable: - fmt.Println(tw.Render()) - return nil - default: - return fmt.Errorf("unknown output format: %s", format) - } -} - -func printJSON(data any) error { - output, err := json.MarshalIndent(data, "", " ") - if err != nil { - return fmt.Errorf("error formatting JSON: %w", err) - } - fmt.Println(string(output)) - return nil -} diff --git a/go/core/cli/internal/cli/agent/get.go b/go/core/cli/internal/cli/agent/get.go deleted file mode 100644 index c3c6c29ec..000000000 --- a/go/core/cli/internal/cli/agent/get.go +++ /dev/null @@ -1,144 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "fmt" - "os" - "strconv" - "time" - - "github.com/kagent-dev/kagent/go/api/database" - api "github.com/kagent-dev/kagent/go/api/httpapi" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" - "github.com/kagent-dev/kagent/go/core/internal/utils" -) - -func GetAgentCmd(cfg *config.Config, resourceName string) { - client := cfg.Client() - - if resourceName == "" { - agentList, err := client.Agent.ListAgents(context.Background()) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to get agents: %v\n", err) - return - } - - if len(agentList.Data) == 0 { - fmt.Println("No agents found") - return - } - - if err := printAgents(agentList.Data); err != nil { - fmt.Fprintf(os.Stderr, "Failed to print agents: %v\n", err) - return - } - } else { - agent, err := client.Agent.GetAgent(context.Background(), resourceName) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to get agent %s: %v\n", resourceName, err) - return - } - byt, _ := json.MarshalIndent(agent, "", " ") - fmt.Fprintln(os.Stdout, string(byt)) - } -} - -func GetSessionCmd(cfg *config.Config, resourceName string) { - client := cfg.Client() - if resourceName == "" { - sessionList, err := client.Session.ListSessions(context.Background()) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to get sessions: %v\n", err) - return - } - - if len(sessionList.Data) == 0 { - fmt.Println("No sessions found") - return - } - - if err := printSessions(sessionList.Data); err != nil { - fmt.Fprintf(os.Stderr, "Failed to print sessions: %v\n", err) - return - } - } else { - session, err := client.Session.GetSession(context.Background(), resourceName) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to get session %s: %v\n", resourceName, err) - return - } - byt, _ := json.MarshalIndent(session, "", " ") - fmt.Fprintln(os.Stdout, string(byt)) - } -} - -func GetToolCmd(cfg *config.Config) { - client := cfg.Client() - toolList, err := client.Tool.ListTools(context.Background()) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to get tools: %v\n", err) - return - } - if err := printTools(toolList); err != nil { - fmt.Fprintf(os.Stderr, "Failed to print tools: %v\n", err) - return - } -} - -func printTools(tools []database.Tool) error { - headers := []string{"#", "NAME", "SERVER_NAME", "DESCRIPTION", "CREATED"} - rows := make([][]string, len(tools)) - for i, tool := range tools { - rows[i] = []string{ - strconv.Itoa(i + 1), - tool.ID, - tool.ServerName, - tool.Description, - tool.CreatedAt.Format(time.RFC3339), - } - } - - return printOutput(tools, headers, rows) -} - -func printAgents(agents []api.AgentResponse) error { - // Prepare table data - headers := []string{"#", "NAME", "CREATED", "DEPLOYMENT_READY", "ACCEPTED"} - rows := make([][]string, len(agents)) - for i, agent := range agents { - rows[i] = []string{ - strconv.Itoa(i + 1), - utils.ResourceRefString(agent.Agent.Metadata.Namespace, agent.Agent.Metadata.Name), - agent.Agent.Metadata.CreationTimestamp.Format(time.RFC3339), - strconv.FormatBool(agent.Ready), - strconv.FormatBool(agent.Accepted), - } - } - - return printOutput(agents, headers, rows) -} - -func printSessions(sessions []*database.Session) error { - headers := []string{"#", "ID", "NAME", "AGENT", "CREATED"} - rows := make([][]string, len(sessions)) - for i, session := range sessions { - agentID := "" - if session.AgentID != nil { - agentID = *session.AgentID - } - sessionName := "" - if session.Name != nil { - sessionName = *session.Name - } - rows[i] = []string{ - strconv.Itoa(i + 1), - session.ID, - sessionName, - agentID, - session.CreatedAt.Format(time.RFC3339), - } - } - - return printOutput(sessions, headers, rows) -} diff --git a/go/core/cli/internal/cli/agent/init.go b/go/core/cli/internal/cli/agent/init.go deleted file mode 100644 index 3650e8763..000000000 --- a/go/core/cli/internal/cli/agent/init.go +++ /dev/null @@ -1,128 +0,0 @@ -package cli - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "unicode" - "unicode/utf8" - - "github.com/kagent-dev/kagent/go/api/v1alpha3" - "github.com/kagent-dev/kagent/go/core/cli/internal/agent/frameworks" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" - "github.com/kagent-dev/kagent/go/core/internal/version" -) - -type InitCfg struct { - Framework string - Language string - AgentName string - InstructionFile string - ModelProvider string - ModelName string - Description string - Config *config.Config -} - -func InitCmd(cfg *InitCfg) error { - // Validate agent name - if err := validateAgentName(cfg.AgentName); err != nil { - return err - } - - // Validate framework and language - if cfg.Framework != "adk" { - return fmt.Errorf("unsupported framework: %s. Only 'adk' is supported", cfg.Framework) - } - - if cfg.Language != "python" { - return fmt.Errorf("unsupported language: %s. Only 'python' is supported for ADK", cfg.Language) - } - - if cfg.ModelName != "" && cfg.ModelProvider == "" { - return fmt.Errorf("model provider is required when model name is provided") - } - - // Validate model provider if specified - if cfg.ModelProvider != "" { - if err := validateModelProvider(cfg.ModelProvider); err != nil { - return err - } - } - - // use lower case for model provider since the templates expect the model provider in lower case - cfg.ModelProvider = strings.ToLower(cfg.ModelProvider) - - // Get current working directory for project creation - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current working directory: %v", err) - } - - // Create project directory - projectDir := filepath.Join(cwd, cfg.AgentName) - if err := os.MkdirAll(projectDir, 0755); err != nil { - return fmt.Errorf("failed to create project directory: %v", err) - } - - // Initialize the framework generator - generator, err := frameworks.NewGenerator(cfg.Framework, cfg.Language) - if err != nil { - return fmt.Errorf("failed to create generator: %v", err) - } - - // Load instruction from file if specified - var instruction string - if cfg.InstructionFile != "" { - content, err := os.ReadFile(cfg.InstructionFile) - if err != nil { - return fmt.Errorf("failed to read instruction file '%s': %v", cfg.InstructionFile, err) - } - instruction = string(content) - } - - // Get the kagent version - kagentVersion := version.Version - - // Generate the project - if err := generator.Generate(projectDir, cfg.AgentName, instruction, cfg.ModelProvider, cfg.ModelName, cfg.Description, cfg.Config.Verbose, kagentVersion); err != nil { - return fmt.Errorf("failed to generate project: %v", err) - } - - return nil -} - -// validateModelProvider checks if the provided model provider is supported -func validateModelProvider(provider string) error { - switch v1alpha3.ModelProvider(provider) { - case v1alpha3.ModelProviderOpenAI, - v1alpha3.ModelProviderAnthropic, - v1alpha3.ModelProviderGemini: - return nil - default: - return fmt.Errorf("unsupported model provider: %s. Supported providers: OpenAI, Anthropic, Gemini", provider) - } -} - -// validateAgentName checks if the agent name is a valid identifier. -// The name must start with a letter or underscore and contain only letters, digits, -// and underscores. This matches the Python identifier rules enforced by the ADK at runtime. -func validateAgentName(name string) error { - if name == "" { - return fmt.Errorf("agent name cannot be empty") - } - - first, _ := utf8.DecodeRuneInString(name) - if !unicode.IsLetter(first) && first != '_' { - return fmt.Errorf("invalid agent name %q: must start with a letter or underscore", name) - } - - for i, c := range name { - if !unicode.IsLetter(c) && !unicode.IsDigit(c) && c != '_' { - return fmt.Errorf("invalid agent name %q: character %q at position %d is not allowed. Agent names must only contain letters, digits, and underscores", name, c, i) - } - } - - return nil -} diff --git a/go/core/cli/internal/cli/agent/init_validation_test.go b/go/core/cli/internal/cli/agent/init_validation_test.go deleted file mode 100644 index 48996c7ac..000000000 --- a/go/core/cli/internal/cli/agent/init_validation_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package cli - -import ( - "strings" - "testing" -) - -func TestValidateModelProvider(t *testing.T) { - tests := []struct { - name string - provider string - wantErr bool - }{ - {name: "valid OpenAI", provider: "OpenAI", wantErr: false}, - {name: "valid Anthropic", provider: "Anthropic", wantErr: false}, - {name: "valid Gemini", provider: "Gemini", wantErr: false}, - {name: "invalid provider", provider: "InvalidProvider", wantErr: true}, - {name: "empty provider", provider: "", wantErr: true}, - {name: "lowercase openai", provider: "openai", wantErr: true}, // Case-sensitive - {name: "Azure OpenAI not supported", provider: "AzureOpenAI", wantErr: true}, - {name: "Ollama not supported", provider: "Ollama", wantErr: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateModelProvider(tt.provider) - if (err != nil) != tt.wantErr { - t.Errorf("validateModelProvider(%q) error = %v, wantErr %v", tt.provider, err, tt.wantErr) - } - }) - } -} - -func TestInitCfg_Validation(t *testing.T) { - tests := []struct { - name string - cfg *InitCfg - wantErr bool - errMsg string - }{ - { - name: "invalid agent name - empty", - cfg: &InitCfg{ - Framework: "adk", - Language: "python", - AgentName: "", - }, - wantErr: true, - errMsg: "agent name cannot be empty", - }, - { - name: "invalid agent name - starts with digit", - cfg: &InitCfg{ - Framework: "adk", - Language: "python", - AgentName: "1agent", - }, - wantErr: true, - errMsg: "must start with a letter or underscore", - }, - { - name: "invalid framework", - cfg: &InitCfg{ - Framework: "unsupported", - Language: "python", - AgentName: "test_agent", - }, - wantErr: true, - errMsg: "unsupported framework", - }, - { - name: "invalid language", - cfg: &InitCfg{ - Framework: "adk", - Language: "javascript", - AgentName: "test_agent", - }, - wantErr: true, - errMsg: "unsupported language", - }, - { - name: "model name without provider", - cfg: &InitCfg{ - Framework: "adk", - Language: "python", - AgentName: "test_agent", - ModelName: "gpt-4", - ModelProvider: "", - }, - wantErr: true, - errMsg: "model provider is required when model name is provided", - }, - { - name: "invalid model provider", - cfg: &InitCfg{ - Framework: "adk", - Language: "python", - AgentName: "test_agent", - ModelProvider: "InvalidProvider", - }, - wantErr: true, - errMsg: "unsupported model provider", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := InitCmd(tt.cfg) - if (err != nil) != tt.wantErr { - t.Errorf("InitCmd() error = %v, wantErr %v", err, tt.wantErr) - return - } - if tt.wantErr && err != nil { - if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("InitCmd() error = %v, want error containing %q", err, tt.errMsg) - } - } - }) - } -} diff --git a/go/core/cli/internal/cli/agent/integration_test.go b/go/core/cli/internal/cli/agent/integration_test.go deleted file mode 100644 index 543b760d9..000000000 --- a/go/core/cli/internal/cli/agent/integration_test.go +++ /dev/null @@ -1,225 +0,0 @@ -//go:build integration - -package cli - -import ( - "os" - "path/filepath" - "testing" - - "github.com/kagent-dev/kagent/go/core/cli/internal/config" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestInitBuildWorkflow tests the complete workflow from init to build validation -func TestInitBuildWorkflow(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test in short mode") - } - - // Change to temp directory - originalWd, _ := os.Getwd() - tmpDir := t.TempDir() - os.Chdir(tmpDir) - defer os.Chdir(originalWd) - - // Step 1: Initialize a new agent project - initCfg := &InitCfg{ - AgentName: "integration_test_agent", - Framework: "adk", - Language: "python", - ModelProvider: "OpenAI", - ModelName: "gpt_4", - Config: &config.Config{}, - } - - err := InitCmd(initCfg) - require.NoError(t, err, "Init command should succeed") - - // Verify project structure was created - projectDir := filepath.Join(tmpDir, initCfg.AgentName) - assert.FileExists(t, filepath.Join(projectDir, "kagent.yaml"), "kagent.yaml should exist") - assert.FileExists(t, filepath.Join(projectDir, "docker-compose.yaml"), "docker-compose.yaml should exist") - assert.DirExists(t, filepath.Join(projectDir, initCfg.AgentName), "agent directory should exist") - - // Verify manifest content - manifest, err := LoadManifest(projectDir) - require.NoError(t, err, "Should load manifest") - assert.Equal(t, "integration_test_agent", manifest.Name) - assert.Equal(t, "adk", manifest.Framework) - assert.Equal(t, "python", manifest.Language) - - // Step 2: Validate build configuration can read the manifest - buildCfg := &BuildCfg{ - ProjectDir: projectDir, - Config: &config.Config{}, - } - - loadedManifest, err := LoadManifest(buildCfg.ProjectDir) - require.NoError(t, err, "Build should load manifest") - assert.Equal(t, initCfg.AgentName, loadedManifest.Name) -} - -// TestInitDeployWorkflow tests init followed by deploy validation -func TestInitDeployWorkflow(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test in short mode") - } - - originalWd, _ := os.Getwd() - tmpDir := t.TempDir() - os.Chdir(tmpDir) - defer os.Chdir(originalWd) - - // Step 1: Initialize project - initCfg := &InitCfg{ - AgentName: "deploy_test_agent", - Framework: "adk", - Language: "python", - ModelProvider: "OpenAI", - Config: &config.Config{Namespace: "test_ns"}, - } - - err := InitCmd(initCfg) - require.NoError(t, err) - - projectDir := filepath.Join(tmpDir, initCfg.AgentName) - - // Step 2: Validate deploy configuration - deployCfg := &DeployCfg{ - ProjectDir: projectDir, - Config: &config.Config{Namespace: "test_ns"}, - } - - manifest, err := validateAndLoadProject(deployCfg) - require.NoError(t, err, "Deploy validation should pass after init") - assert.NotNil(t, manifest) - assert.Equal(t, "deploy_test_agent", manifest.Name) -} - -// TestInitAddMcpWorkflow tests init followed by adding MCP server -func TestInitAddMcpWorkflow(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test in short mode") - } - - originalWd, _ := os.Getwd() - tmpDir := t.TempDir() - os.Chdir(tmpDir) - defer os.Chdir(originalWd) - - // Step 1: Initialize project - initCfg := &InitCfg{ - AgentName: "mcp_test_agent", - Framework: "adk", - Language: "python", - ModelProvider: "OpenAI", - Config: &config.Config{}, - } - - err := InitCmd(initCfg) - require.NoError(t, err) - - projectDir := filepath.Join(tmpDir, initCfg.AgentName) - - // Step 2: Add MCP server to the project - addMcpCfg := &AddMcpCfg{ - ProjectDir: projectDir, - Name: "test_mcp_server", - RemoteURL: "http://localhost:3000", - Headers: []string{"Authorization=Bearer token"}, - Config: &config.Config{}, - } - - err = AddMcpCmd(addMcpCfg) - // Note: This will fail on regenerateMcpToolsFile, but we can verify the manifest was updated - if err != nil { - t.Logf("Expected error from MCP tools regeneration: %v", err) - } - - // Verify manifest was updated with MCP server - manifest, err := LoadManifest(projectDir) - require.NoError(t, err) - assert.NotNil(t, manifest.McpServers, "McpServers should be initialized") -} - -// TestProjectValidationAcrossCommands tests that validation is consistent across commands -func TestProjectValidationAcrossCommands(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test in short mode") - } - - originalWd, _ := os.Getwd() - tmpDir := t.TempDir() - os.Chdir(tmpDir) - defer os.Chdir(originalWd) - - // Initialize project - initCfg := &InitCfg{ - AgentName: "validation_test", - Framework: "adk", - Language: "python", - ModelProvider: "OpenAI", - Config: &config.Config{}, - } - - err := InitCmd(initCfg) - require.NoError(t, err) - - projectDir := filepath.Join(tmpDir, initCfg.AgentName) - - // Test 1: Deploy validation should pass - deployCfg := &DeployCfg{ - ProjectDir: projectDir, - Config: &config.Config{}, - } - _, err = validateAndLoadProject(deployCfg) - assert.NoError(t, err, "Deploy validation should pass") - - // Test 2: Build validation should pass - manifest, err := LoadManifest(projectDir) - assert.NoError(t, err, "Build validation should pass") - assert.NotNil(t, manifest) - - // Test 3: Delete manifest and verify all commands fail validation - err = os.Remove(filepath.Join(projectDir, "kagent.yaml")) - require.NoError(t, err) - - _, err = validateAndLoadProject(deployCfg) - assert.Error(t, err, "Deploy validation should fail without manifest") - - _, err = LoadManifest(projectDir) - assert.Error(t, err, "Build validation should fail without manifest") -} - -// TestErrorPropagationAcrossCommands tests that errors propagate correctly -func TestErrorPropagationAcrossCommands(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test in short mode") - } - - tmpDir := t.TempDir() - - // Test 1: Deploy without init should fail - deployCfg := &DeployCfg{ - ProjectDir: tmpDir, - Config: &config.Config{}, - } - _, err := validateAndLoadProject(deployCfg) - assert.Error(t, err, "Deploy should fail without init") - - // Test 2: Build without init should fail - _, err = LoadManifest(tmpDir) - assert.Error(t, err, "Build should fail without init") - - // Test 3: Add MCP without init should fail - addMcpCfg := &AddMcpCfg{ - ProjectDir: tmpDir, - Name: "test_mcp", - RemoteURL: "http://localhost:3000", - Config: &config.Config{}, - } - err = AddMcpCmd(addMcpCfg) - assert.Error(t, err, "Add MCP should fail without init") -} diff --git a/go/core/cli/internal/cli/agent/run.go b/go/core/cli/internal/cli/agent/run.go deleted file mode 100644 index a443a45c9..000000000 --- a/go/core/cli/internal/cli/agent/run.go +++ /dev/null @@ -1,184 +0,0 @@ -package cli - -import ( - "context" - "fmt" - "net/http" - "os" - "os/exec" - "path/filepath" - "time" - - a2atype "github.com/a2aproject/a2a-go/v2/a2a" - clia2a "github.com/kagent-dev/kagent/go/core/cli/internal/a2a" - commonexec "github.com/kagent-dev/kagent/go/core/cli/internal/common/exec" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" - "github.com/kagent-dev/kagent/go/core/cli/internal/tui" -) - -type RunCfg struct { - ProjectDir string - Config *config.Config - Build bool -} - -// RunCmd starts docker-compose in the background and launches a chat session with the local agent -func RunCmd(ctx context.Context, cfg *RunCfg) error { - // Validate project directory - if cfg.ProjectDir == "" { - return fmt.Errorf("project directory is required") - } - - // Check if project directory exists - if _, err := os.Stat(cfg.ProjectDir); os.IsNotExist(err) { - return fmt.Errorf("project directory does not exist: %s", cfg.ProjectDir) - } - - // Check if docker-compose.yaml exists - dockerComposePath := filepath.Join(cfg.ProjectDir, "docker-compose.yaml") - if _, err := os.Stat(dockerComposePath); os.IsNotExist(err) { - return fmt.Errorf("docker-compose.yaml not found in project directory: %s", dockerComposePath) - } - - // Load manifest to get agent name - manifest, err := LoadManifest(cfg.ProjectDir) - if err != nil { - return fmt.Errorf("failed to load kagent.yaml: %v", err) - } - - // Validate API key before starting docker-compose - if err := ValidateAPIKey(manifest.ModelProvider); err != nil { - return fmt.Errorf("API key validation failed: %v", err) - } - - verbose := IsVerbose(cfg.Config) - - fmt.Printf("Starting agent and tools...\n") - - // Use docker compose (newer version) or docker-compose (older version) - composeCmd := commonexec.GetComposeCommand() - args := append(composeCmd[1:], "up", "-d", "--remove-orphans") - cmd := exec.CommandContext(ctx, composeCmd[0], args...) - cmd.Dir = cfg.ProjectDir - - // Suppress output to not block - if !verbose { - cmd.Stdout = nil - cmd.Stderr = nil - } else { - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - } - - // Run docker-compose up -d synchronously (should be quick without --wait) - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to start docker-compose: %v", err) - } - - fmt.Println("✓ Docker containers started") - - // Verify containers are actually running - time.Sleep(2 * time.Second) // Give containers a moment to start - psCmd := exec.Command(composeCmd[0], append(composeCmd[1:], "ps")...) - psCmd.Dir = cfg.ProjectDir - psOutput, _ := psCmd.CombinedOutput() - - if verbose { - fmt.Printf("Container status:\n%s\n", string(psOutput)) - } - - fmt.Println("Waiting for agent to be ready...") - - // Wait for the agent to be ready by polling the health endpoint - agentURL := "http://localhost:8080" - healthURL := agentURL + "/health" - if err := waitForAgent(ctx, healthURL, 60*time.Second); err != nil { - // Print container logs if agent fails to start - fmt.Fprintln(os.Stderr, "Agent failed to start. Fetching logs...") - logsCmd := exec.Command(composeCmd[0], append(composeCmd[1:], "logs", "--tail=50")...) - logsCmd.Dir = cfg.ProjectDir - logsOutput, _ := logsCmd.CombinedOutput() - fmt.Fprintf(os.Stderr, "Container logs:\n%s\n", string(logsOutput)) - return fmt.Errorf("agent failed to start: %v", err) - } - - fmt.Printf("✓ Agent '%s' is running at %s\n", manifest.Name, agentURL) - fmt.Println("Launching chat interface...") - - // Generate a new session ID - sessionID := a2atype.NewContextID() - - // Create A2A client for local agent - a2aClient, err := clia2a.NewClient(ctx, agentURL, clia2a.ClientOptions{Timeout: cfg.Config.Timeout}) - if err != nil { - return fmt.Errorf("failed to create A2A client: %v", err) - } - - sendFn := func(ctx context.Context, req *a2atype.SendMessageRequest) <-chan clia2a.StreamResult { - return clia2a.StreamToChannel(ctx, a2aClient, req) - } - - // Launch TUI chat directly - if err := tui.RunChat(manifest.Name, sessionID, sendFn, verbose); err != nil { - return fmt.Errorf("chat session failed: %v", err) - } - - // Automatically stop docker-compose when chat ends - fmt.Println("\nStopping docker-compose...") - composeCmdStop := commonexec.GetComposeCommand() - stopCmd := exec.Command(composeCmdStop[0], append(composeCmdStop[1:], "down")...) - stopCmd.Dir = cfg.ProjectDir - - if verbose { - stopCmd.Stdout = os.Stdout - stopCmd.Stderr = os.Stderr - } - - if err := stopCmd.Run(); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to stop docker-compose: %v\n", err) - } else { - fmt.Println("✓ Stopped docker-compose") - } - - return nil -} - -// waitForAgent polls the agent's root endpoint until it's ready or timeout -func waitForAgent(ctx context.Context, agentURL string, timeout time.Duration) error { - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - client := &http.Client{ - Timeout: 2 * time.Second, - } - - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - - fmt.Print("Checking agent health") - for { - select { - case <-ctx.Done(): - fmt.Println() - return fmt.Errorf("timeout waiting for agent to be ready") - case <-ticker.C: - fmt.Print(".") - req, err := http.NewRequestWithContext(ctx, "GET", agentURL, nil) - if err != nil { - continue - } - - resp, err := client.Do(req) - if err == nil { - if err = resp.Body.Close(); err != nil { - return err - } - - if resp.StatusCode == 200 { - fmt.Println(" ✓") - return nil - } - } - } - } -} diff --git a/go/core/cli/internal/cli/agent/run_test.go b/go/core/cli/internal/cli/agent/run_test.go deleted file mode 100644 index e89c961eb..000000000 --- a/go/core/cli/internal/cli/agent/run_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package cli - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/kagent-dev/kagent/go/core/cli/internal/config" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRunCfg_Validation(t *testing.T) { - tests := []struct { - name string - cfg *RunCfg - setup func(t *testing.T) string - wantErr bool - errMsg string - }{ - { - name: "missing project directory", - cfg: &RunCfg{ - ProjectDir: "", - Config: &config.Config{}, - }, - setup: nil, - wantErr: true, - errMsg: "project directory is required", - }, - { - name: "non-existent project directory", - cfg: &RunCfg{ - ProjectDir: "/nonexistent/path", - Config: &config.Config{}, - }, - setup: nil, - wantErr: true, - errMsg: "project directory does not exist", - }, - { - name: "missing docker-compose.yaml", - cfg: &RunCfg{ - Config: &config.Config{}, - }, - setup: func(t *testing.T) string { - return t.TempDir() - }, - wantErr: true, - errMsg: "docker-compose.yaml not found", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.setup != nil { - tt.cfg.ProjectDir = tt.setup(t) - } - - err := RunCmd(context.Background(), tt.cfg) - if tt.wantErr { - require.Error(t, err) - if tt.errMsg != "" { - assert.Contains(t, err.Error(), tt.errMsg) - } - } - }) - } -} - -func TestRunCmd_DockerComposeValidation(t *testing.T) { - tmpDir := t.TempDir() - - // Create kagent.yaml - manifestPath := filepath.Join(tmpDir, "kagent.yaml") - manifestContent := `agentName: test-agent -description: Test agent -framework: adk -language: python -modelProvider: Ollama -` - err := os.WriteFile(manifestPath, []byte(manifestContent), 0644) - require.NoError(t, err) - - // Create docker-compose.yaml - composePath := filepath.Join(tmpDir, "docker-compose.yaml") - composeContent := `version: '3.8' -services: - agent: - image: test:latest -` - err = os.WriteFile(composePath, []byte(composeContent), 0644) - require.NoError(t, err) - - cfg := &RunCfg{ - ProjectDir: tmpDir, - Config: &config.Config{}, - } - - // This will fail when trying to actually run docker-compose, - // but we've validated the file checks work - err = RunCmd(context.Background(), cfg) - // Expected to fail on docker-compose execution - if err != nil { - t.Logf("Expected failure on docker-compose execution: %v", err) - } -} diff --git a/go/core/cli/internal/cli/agent/utils.go b/go/core/cli/internal/cli/agent/utils.go deleted file mode 100644 index 1760460b0..000000000 --- a/go/core/cli/internal/cli/agent/utils.go +++ /dev/null @@ -1,238 +0,0 @@ -package cli - -import ( - "fmt" - "io/fs" - "os" - "path/filepath" - "regexp" - "slices" - - "github.com/kagent-dev/kagent/go/api/v1alpha3" - pygen "github.com/kagent-dev/kagent/go/core/cli/internal/agent/frameworks/adk/python" - "github.com/kagent-dev/kagent/go/core/cli/internal/agent/frameworks/common" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" -) - -const ( - DockerComposeFilename = "docker-compose.yaml" - DockerComposeTemplate = "templates/docker-compose.yaml.tmpl" -) - -// ResolveProjectDir resolves the project directory to an absolute path -func ResolveProjectDir(projectDir string) (string, error) { - if projectDir == "" { - cwd, err := os.Getwd() - if err != nil { - return "", fmt.Errorf("failed to get current directory: %w", err) - } - return cwd, nil - } - - if filepath.IsAbs(projectDir) { - return projectDir, nil - } - - cwd, err := os.Getwd() - if err != nil { - return "", fmt.Errorf("failed to get current directory: %w", err) - } - return filepath.Join(cwd, projectDir), nil -} - -// ValidateProjectDir checks if a project directory exists -func ValidateProjectDir(projectDir string) error { - if projectDir == "" { - return fmt.Errorf("project directory is required") - } - - if _, err := os.Stat(projectDir); os.IsNotExist(err) { - return fmt.Errorf("project directory does not exist: %s", projectDir) - } - - return nil -} - -// LoadManifest loads the kagent.yaml file from the project directory -func LoadManifest(projectDir string) (*common.AgentManifest, error) { - manager := common.NewManifestManager(projectDir) - manifest, err := manager.Load() - if err != nil { - return nil, fmt.Errorf("failed to load kagent.yaml: %w", err) - } - return manifest, nil -} - -// IsVerbose checks if verbose mode is enabled -func IsVerbose(cfg *config.Config) bool { - return cfg != nil && cfg.Verbose -} - -// Template utilities - -// ReadTemplateFile reads a template file from the embedded filesystem -func ReadTemplateFile(templatePath string) ([]byte, error) { - gen := pygen.NewPythonGenerator() - return fs.ReadFile(gen.TemplateFiles, templatePath) -} - -// RenderTemplate reads and renders a template file with the given data -func RenderTemplate(templatePath string, data any) (string, error) { - gen := pygen.NewPythonGenerator() - tmplBytes, err := fs.ReadFile(gen.TemplateFiles, templatePath) - if err != nil { - return "", fmt.Errorf("failed to read template: %w", err) - } - - return gen.RenderTemplate(string(tmplBytes), data) -} - -// CopyTemplateIfNotExists copies a template file to the target directory if it doesn't exist -func CopyTemplateIfNotExists(targetDir, filename, templatePath string, verbose bool) error { - targetPath := filepath.Join(targetDir, filename) - if _, err := os.Stat(targetPath); err == nil { - // File already exists - return nil - } - - templateBytes, err := ReadTemplateFile(templatePath) - if err != nil { - return fmt.Errorf("failed to read template %s: %w", templatePath, err) - } - - if err := os.WriteFile(targetPath, templateBytes, 0o644); err != nil { - return fmt.Errorf("failed to write %s: %w", filename, err) - } - - if verbose { - fmt.Printf("Created %s\n", targetPath) - } - - return nil -} - -// RegenerateDockerCompose regenerates the docker-compose.yaml file with updated MCP server configuration -func RegenerateDockerCompose(projectDir string, manifest *common.AgentManifest, verbose bool) error { - // Extract environment variables referenced in MCP server headers - envVars := extractEnvVarsFromHeaders(manifest.McpServers) - - // Template data for docker-compose.yaml - templateData := struct { - Name string - ModelProvider string - ModelName string - EnvVars []string - McpServers []common.McpServerType - }{ - Name: manifest.Name, - ModelProvider: manifest.ModelProvider, - ModelName: manifest.ModelName, - EnvVars: envVars, - McpServers: manifest.McpServers, - } - - // Render the docker-compose.yaml template - renderedContent, err := RenderTemplate(DockerComposeTemplate, templateData) - if err != nil { - return fmt.Errorf("failed to render %s template: %w", DockerComposeFilename, err) - } - - // Write the docker-compose.yaml file - composePath := filepath.Join(projectDir, DockerComposeFilename) - if err := os.WriteFile(composePath, []byte(renderedContent), 0o644); err != nil { - return fmt.Errorf("failed to write %s: %w", DockerComposeFilename, err) - } - - if verbose { - fmt.Printf("Updated %s\n", composePath) - } - - return nil -} - -// extractEnvVarsFromHeaders extracts environment variable names from MCP server headers -// It looks for ${VAR_NAME} patterns in header values -func extractEnvVarsFromHeaders(mcpServers []common.McpServerType) []string { - envVarSet := make(map[string]bool) - - for _, server := range mcpServers { - if server.Type == "remote" && server.Headers != nil { - for _, value := range server.Headers { - // Find all ${VAR_NAME} patterns - matches := regexp.MustCompile(`\$\{([^}]+)\}`).FindAllStringSubmatch(value, -1) - for _, match := range matches { - if len(match) > 1 { - envVarSet[match[1]] = true - } - } - } - } - } - - // Convert set to sorted slice for consistent output - envVars := make([]string, 0, len(envVarSet)) - for varName := range envVarSet { - envVars = append(envVars, varName) - } - slices.Sort(envVars) - - return envVars -} - -// ValidateAPIKey checks if the required API key environment variable is set for the given model provider -func ValidateAPIKey(modelProvider string) error { - // Get the environment variable name for the provider - apiKeyEnvVar := GetProviderAPIKey(v1alpha3.ModelProvider(modelProvider)) - - // If no API key is required for this provider (e.g., Ollama, local models), skip validation - if apiKeyEnvVar == "" { - return nil - } - - // Check if the environment variable is set and non-empty - apiKey := os.Getenv(apiKeyEnvVar) - if apiKey == "" { - return fmt.Errorf(`required API key not set - -The model provider '%s' requires the %s environment variable to be set. - -Please set it before running this command: - export %s="your-api-key-here"`, modelProvider, apiKeyEnvVar, apiKeyEnvVar) - } - - return nil -} - -// regenerateMcpToolsFile regenerates mcp_tools.py with the current MCP servers from the manifest -func regenerateMcpToolsFile(projectDir string, manifest *common.AgentManifest, verbose bool) error { - // Expected agent directory for ADK Python: / - agentDir := filepath.Join(projectDir, manifest.Name) - if _, err := os.Stat(agentDir); err != nil { - // If not present, nothing to do (not an ADK Python layout) - return nil - } - - // Prepare template data with MCP servers - templateData := struct { - McpServers []common.McpServerType - }{ - McpServers: manifest.McpServers, - } - - // Render the mcp_tools.py template - renderedContent, err := RenderTemplate("templates/agent/mcp_tools.py.tmpl", templateData) - if err != nil { - return fmt.Errorf("failed to render mcp_tools.py template: %w", err) - } - - // Write the mcp_tools.py file - target := filepath.Join(agentDir, "mcp_tools.py") - if err := os.WriteFile(target, []byte(renderedContent), 0o644); err != nil { - return fmt.Errorf("failed to write mcp_tools.py: %w", err) - } - - if verbose { - fmt.Printf("Regenerated %s\n", target) - } - return nil -} diff --git a/go/core/cli/internal/common/image/image.go b/go/core/cli/internal/common/image/image.go deleted file mode 100644 index 0240d6034..000000000 --- a/go/core/cli/internal/common/image/image.go +++ /dev/null @@ -1,26 +0,0 @@ -package image - -import "fmt" - -const ( - DefaultRegistry = "localhost:5001" - DefaultTag = "latest" -) - -// ConstructImageName constructs a Docker image name with default registry and tag. -// If configImage is provided (non-empty), it is returned as-is. -// Otherwise, constructs: DefaultRegistry/imageName:DefaultTag (e.g., "localhost:5001/my-agent:latest") -func ConstructImageName(configImage, imageName string) string { - if configImage != "" { - return configImage - } - return fmt.Sprintf("%s/%s:%s", DefaultRegistry, imageName, DefaultTag) -} - -// ConstructMCPServerImageName constructs a Docker image name for an MCP server. -// The image name follows the pattern: DefaultRegistry/agentName-serverName:DefaultTag -// (e.g., "localhost:5001/my-agent-github-server:latest") -func ConstructMCPServerImageName(agentName, serverName string) string { - imageName := fmt.Sprintf("%s-%s", agentName, serverName) - return fmt.Sprintf("%s/%s:%s", DefaultRegistry, imageName, DefaultTag) -} diff --git a/go/core/cli/internal/tui/dialogs/mcp_server_wizard.go b/go/core/cli/internal/tui/dialogs/mcp_server_wizard.go deleted file mode 100644 index e10406f6c..000000000 --- a/go/core/cli/internal/tui/dialogs/mcp_server_wizard.go +++ /dev/null @@ -1,1019 +0,0 @@ -package dialogs - -import ( - "fmt" - "io" - "os" - "strings" - - "github.com/charmbracelet/bubbles/filepicker" - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/kagent-dev/kagent/go/core/cli/internal/agent/frameworks/common" - "github.com/kagent-dev/kagent/go/core/cli/internal/tui/theme" -) - -type wizardStep int - -const ( - stepPickType wizardStep = iota - stepRemoteURL - stepRemoteHeaders - stepCommandMethod - stepCommandMode - stepCommandDetails - stepArgsEnv - stepName - stepDone -) - -// Input field identifiers (typed keys for focus management) -type inputKey int - -const ( - inImage inputKey = iota - inPkg - inCommand -) - -// ServerTypeConfig defines the configuration for a server type (remote or command) -type ServerTypeConfig struct { - ID string - DisplayName string -} - -// CommandMethodConfig defines how to run an MCP command server -type CommandMethodConfig struct { - ID string - DisplayName string -} - -// CommandModeConfig defines sub-modes for command execution -type CommandModeConfig struct { - ID string - DisplayName string -} - -// WizardFlowConfig defines the step sequence and display positions for a flow -type WizardFlowConfig struct { - Name string - StepPositions map[wizardStep]int - TotalSteps int -} - -// Wizard configuration instances -var ( - // Server types - serverTypes = struct { - Remote ServerTypeConfig - Command ServerTypeConfig - }{ - Remote: ServerTypeConfig{ID: "remote", DisplayName: "Remote"}, - Command: ServerTypeConfig{ID: "command", DisplayName: "Command"}, - } - - // Command execution methods - commandMethods = struct { - Image CommandMethodConfig - Build CommandMethodConfig - Command CommandMethodConfig - }{ - Image: CommandMethodConfig{ID: "image", DisplayName: "Docker image (provide a Docker image)"}, - Build: CommandMethodConfig{ID: "build", DisplayName: "Build \u0028e.g., kmcp.yaml\u0029"}, - Command: CommandMethodConfig{ID: "command", DisplayName: "Command (npx, uvx or another command)"}, - } - - // Command sub-modes (for direct command execution) - commandModes = struct { - Custom CommandModeConfig - Npx CommandModeConfig - Uvx CommandModeConfig - }{ - Custom: CommandModeConfig{ID: "custom", DisplayName: "Custom"}, - Npx: CommandModeConfig{ID: "npx", DisplayName: "npx"}, - Uvx: CommandModeConfig{ID: "uvx", DisplayName: "uvx"}, - } - - // Wizard flows: adjust step positions to reorder the UI - wizardFlows = struct { - Command WizardFlowConfig - Remote WizardFlowConfig - }{ - Command: WizardFlowConfig{ - Name: "command", - StepPositions: map[wizardStep]int{ - stepPickType: 1, - stepCommandMethod: 2, - stepCommandMode: 2, // shares position with method - stepCommandDetails: 3, - stepArgsEnv: 4, - stepName: 5, - }, - TotalSteps: 5, - }, - Remote: WizardFlowConfig{ - Name: "remote", - StepPositions: map[wizardStep]int{ - stepPickType: 1, - stepRemoteURL: 2, - stepRemoteHeaders: 3, - stepName: 4, - }, - TotalSteps: 4, - }, - } -) - -// McpServerWizard provides a paginated wizard for creating MCP server entries. -type McpServerWizard struct { - id string - width int - height int - - step wizardStep - result common.McpServerType - ok bool - errMsg string - - // page models - typeList list.Model - methodList list.Model - - urlInput textinput.Model - imageInput textinput.Model - pkgInput textinput.Model - commandInput textinput.Model - argsInput textinput.Model - envInput textinput.Model - nameInput textinput.Model - filePicker filepicker.Model - - // Headers support for remote servers - headerKeyInput textinput.Model - headerValueInput textinput.Model - headers map[string]string - - chosenType string // serverTypes.Remote.ID or serverTypes.Command.ID - chosenMethod string // commandMethods.*.ID - commandMode string // commandModes.*.ID - modeList list.Model - buildPath string // stores selected file path from picker -} - -func NewMcpServerWizard() *McpServerWizard { - // Type list - typeItems := []list.Item{ - choiceItem{"Command (Docker image, local package via npx/uvx, kmcp.yaml)"}, - choiceItem{"Remote (connect to an already running MCP via URL)"}, - } - tl := list.New(typeItems, choiceDelegate{}, 40, 10) - tl.Title = "Choose MCP server type" - tl.SetShowStatusBar(false) - tl.SetFilteringEnabled(false) - tl.Styles.Title = lipgloss.NewStyle().Bold(true) - tl.Styles.PaginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(2) - - // Method list - methodItems := []list.Item{ - choiceItem{commandMethods.Command.DisplayName}, - choiceItem{commandMethods.Image.DisplayName}, - choiceItem{commandMethods.Build.DisplayName}, - } - ml := list.New(methodItems, choiceDelegate{}, 50, 12) - ml.Title = "How do you want to run the MCP command?" - ml.SetShowStatusBar(false) - ml.SetFilteringEnabled(false) - ml.Styles.Title = lipgloss.NewStyle().Bold(true) - ml.Styles.PaginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(2) - - mk := func(ph string, w int) textinput.Model { - ti := textinput.New() - ti.Prompt = "> " - ti.Placeholder = ph - ti.Width = w - return ti - } - - // Command sub-mode list (only used when methodCommand is chosen) - modeItems := []list.Item{ - choiceItem{commandModes.Npx.DisplayName}, - choiceItem{commandModes.Uvx.DisplayName}, - choiceItem{commandModes.Custom.DisplayName}, - } - mdl := list.New(modeItems, choiceDelegate{}, 30, 10) - mdl.Title = "Command type" - mdl.SetShowStatusBar(false) - mdl.SetFilteringEnabled(false) - mdl.Styles.Title = lipgloss.NewStyle().Bold(true) - - // File picker for Build method - fp := filepicker.New() - fp.ShowHidden = false - fp.DirAllowed = true - fp.FileAllowed = true - cwd, _ := os.Getwd() - fp.CurrentDirectory = cwd - fp.SetHeight(10) - - w := &McpServerWizard{ - id: "mcp_server_wizard", - step: stepPickType, - typeList: tl, - methodList: ml, - modeList: mdl, - urlInput: mk("https://your-mcp-server", 40), - imageInput: mk("ghcr.io/org/tool:tag", 40), - pkgInput: mk("@acme/mcp-tool", 40), - commandInput: mk("command to execute", 40), - argsInput: mk("comma-separated args (optional)", 40), - envInput: mk("comma-separated KEY=VALUE (optional)", 40), - nameInput: mk("server name", 40), - filePicker: fp, - headerKeyInput: mk("Header name (e.g., Authorization)", 40), - headerValueInput: mk("Header value (e.g., Bearer ${API_KEY})", 50), - headers: make(map[string]string), - } - return w -} - -func (w *McpServerWizard) ID() string { return w.id } -func (w *McpServerWizard) Fullscreen() bool { return true } -func (w *McpServerWizard) Ok() bool { return w.ok } -func (w *McpServerWizard) Result() common.McpServerType { return w.result } - -func (w *McpServerWizard) Init() tea.Cmd { - return w.filePicker.Init() -} - -// Update handles Bubble Tea messages and routes to the current step's components. -func (w *McpServerWizard) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - // Always update file picker so it can receive readDirMsg from Init() - var fpCmd tea.Cmd - w.filePicker, fpCmd = w.filePicker.Update(msg) - - switch m := msg.(type) { - case tea.WindowSizeMsg: - w.width, w.height = m.Width, m.Height - // Pass sizing into active list - switch w.step { - case stepPickType: - w.typeList.SetSize(maxInt(40, m.Width-20), maxInt(8, m.Height-10)) - case stepCommandMethod: - w.methodList.SetSize(maxInt(50, m.Width-20), maxInt(10, m.Height-10)) - } - return w, fpCmd - case tea.KeyMsg: - switch m.String() { - case "esc": - if w.step == stepPickType { - return w, tea.Quit - } - w.errMsg = "" - w.prevStep() - return w, nil - case "q", "ctrl+c": - return w, tea.Quit - case "enter": - return w, w.onEnter() - case "tab": - return w, w.onTab(false) - case "shift+tab": - return w, w.onTab(true) - } - } - - // Delegate updates - switch w.step { - case stepPickType: - var cmd tea.Cmd - w.typeList, cmd = w.typeList.Update(msg) - return w, tea.Batch(fpCmd, cmd) - case stepCommandMethod: - var cmd tea.Cmd - w.methodList, cmd = w.methodList.Update(msg) - return w, tea.Batch(fpCmd, cmd) - case stepCommandMode: - var cmd tea.Cmd - w.modeList, cmd = w.modeList.Update(msg) - return w, tea.Batch(fpCmd, cmd) - case stepCommandDetails: - var cmds []tea.Cmd - if fpCmd != nil { - cmds = append(cmds, fpCmd) - } - // For Build method, check if user selected a file - if w.chosenMethod == commandMethods.Build.ID { - if didSelect, path := w.filePicker.DidSelectFile(msg); didSelect { - w.buildPath = path - } - } else { - // inputs vary by method; update all but only focused viewed - w.imageInput, _ = w.imageInput.Update(msg) - w.pkgInput, _ = w.pkgInput.Update(msg) - w.commandInput, _ = w.commandInput.Update(msg) - // if method is Command and we haven't chosen mode yet, allow modeList navigation - if w.chosenMethod == commandMethods.Command.ID && w.commandMode == "" { - w.modeList, _ = w.modeList.Update(msg) - } - } - return w, tea.Batch(cmds...) - case stepRemoteURL: - var cmd tea.Cmd - w.urlInput, cmd = w.urlInput.Update(msg) - return w, tea.Batch(fpCmd, cmd) - case stepRemoteHeaders: - var cmds []tea.Cmd - if fpCmd != nil { - cmds = append(cmds, fpCmd) - } - w.headerKeyInput, _ = w.headerKeyInput.Update(msg) - w.headerValueInput, _ = w.headerValueInput.Update(msg) - return w, tea.Batch(cmds...) - case stepArgsEnv: - var cmds []tea.Cmd - if fpCmd != nil { - cmds = append(cmds, fpCmd) - } - w.argsInput, _ = w.argsInput.Update(msg) - w.envInput, _ = w.envInput.Update(msg) - return w, tea.Batch(cmds...) - case stepName: - var cmd tea.Cmd - w.nameInput, cmd = w.nameInput.Update(msg) - return w, tea.Batch(fpCmd, cmd) - } - - return w, fpCmd -} - -// View assembles the frame and delegates step-specific content rendering. -func (w *McpServerWizard) View() string { - header := w.renderHeader() - body := "" - switch w.step { - case stepPickType: - body = w.typeList.View() - case stepRemoteURL: - body = w.labeled("Remote MCP URL", w.urlInput.View()) + w.errorView() - case stepRemoteHeaders: - body = w.renderHeadersStep() - case stepCommandMethod: - body = w.methodList.View() - case stepCommandMode: - body = w.modeList.View() + w.errorView() - case stepCommandDetails: - body = w.renderCommandDetails() - case stepArgsEnv: - body = w.labeled("Args", w.argsInput.View()) + "\n" + w.labeled("Env", w.envInput.View()) + w.errorView() - case stepName: - body = w.labeled("MCP server name", w.nameInput.View()) + w.errorView() - case stepDone: - body = theme.HeadingStyle().Render("Done") - } - - // Fixed content area height so header stays at same line and help at bottom - contentTarget := maxInt(12, w.height-10) // target content height inside the box - headerLines := lineCount(header) - bodyTarget := maxInt(3, contentTarget-headerLines) - bodyPadded := lipgloss.NewStyle().Height(bodyTarget).Render(body) - - inner := lipgloss.JoinVertical(lipgloss.Left, header, bodyPadded) - - // Calculate box width: aim for 80% of screen width with reasonable min/max bounds - boxWidth := min(maxInt(60, (w.width*8)/10), w.width-10) - - box := lipgloss.NewStyle(). - Width(boxWidth). - Height(contentTarget). - Padding(1, 2). - Render(inner) - return lipgloss.Place(w.width, w.height, lipgloss.Center, lipgloss.Center, box) -} - -// onEnter handles the Enter key by delegating to a step-specific handler. -func (w *McpServerWizard) onEnter() tea.Cmd { - w.errMsg = "" - switch w.step { - case stepPickType: - return w.enterPickType() - case stepRemoteURL: - return w.enterRemoteURL() - case stepRemoteHeaders: - return w.enterRemoteHeaders() - case stepCommandMethod: - return w.enterCommandMethod() - case stepCommandMode: - return w.enterCommandMode() - case stepCommandDetails: - return w.enterCommandDetails() - case stepArgsEnv: - return w.enterArgsEnv() - case stepName: - return w.enterName() - } - return nil -} - -// enterPickType processes selection of the top-level type (Remote or Command). -func (w *McpServerWizard) enterPickType() tea.Cmd { - if it, ok := w.typeList.SelectedItem().(choiceItem); ok { - if strings.HasPrefix(it.Title(), "Remote") { - w.chooseRemoteType() - return nil - } - w.chooseCommandType() - return nil - } - return nil -} - -// enterRemoteURL validates the remote URL and advances to headers step. -func (w *McpServerWizard) enterRemoteURL() tea.Cmd { - u := strings.TrimSpace(w.urlInput.Value()) - if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { - w.errMsg = "URL must start with http:// or https://" - return nil - } - w.result.Type = serverTypes.Remote.ID - w.result.URL = u - w.step = stepRemoteHeaders - w.headerKeyInput.SetValue("") - w.headerValueInput.SetValue("") - w.headerKeyInput.Focus() - return nil -} - -// enterRemoteHeaders handles adding headers or skipping to name step. -func (w *McpServerWizard) enterRemoteHeaders() tea.Cmd { - key := strings.TrimSpace(w.headerKeyInput.Value()) - value := strings.TrimSpace(w.headerValueInput.Value()) - - // If both are empty, user wants to skip/finish adding headers - if key == "" && value == "" { - w.result.Headers = w.headers - w.step = stepName - w.nameInput.SetValue("") - w.nameInput.Focus() - return nil - } - - // Validate that both key and value are provided - if key == "" { - w.errMsg = "Header name is required (or leave both empty to continue)" - return nil - } - if value == "" { - w.errMsg = "Header value is required (or leave both empty to continue)" - return nil - } - - // Add the header - w.headers[key] = value - - // Clear inputs for next header - w.headerKeyInput.SetValue("") - w.headerValueInput.SetValue("") - w.headerKeyInput.Focus() - w.errMsg = "" - - return nil -} - -// enterCommandMethod records the chosen run method and routes to the right detail step. -func (w *McpServerWizard) enterCommandMethod() tea.Cmd { - if it, ok := w.methodList.SelectedItem().(choiceItem); ok { - w.enterMethodDetails(it.Title()) - return nil - } - return nil -} - -// enterCommandMode applies the chosen sub-mode (Custom/npx/uvx) and advances. -func (w *McpServerWizard) enterCommandMode() tea.Cmd { - if it, ok := w.modeList.SelectedItem().(choiceItem); ok { - w.applyCommandMode(it.Title()) - return nil - } - return nil -} - -// enterCommandDetails validates inputs for the chosen method and proceeds. -func (w *McpServerWizard) enterCommandDetails() tea.Cmd { - if !w.validateCommandDetails() { - return nil - } - // Command is required only for methodCommand (not for Docker image or build) - if w.chosenMethod == commandMethods.Command.ID && strings.TrimSpace(w.commandInput.Value()) == "" { - w.errMsg = "Command is required" - return nil - } - w.proceedToArgsEnv() - return nil -} - -// enterArgsEnv advances to naming with a suggested name. -func (w *McpServerWizard) enterArgsEnv() tea.Cmd { - w.step = stepName - w.nameInput.SetValue("") - w.nameInput.Focus() - return nil -} - -// enterName finalizes the result and closes the wizard. -func (w *McpServerWizard) enterName() tea.Cmd { - nm := strings.TrimSpace(w.nameInput.Value()) - if nm == "" { - w.errMsg = "Name is required" - return nil - } - w.buildFinalResult(nm) - w.ok = true - return w.close() -} - -// chooseRemoteType transitions to the remote URL entry step and prepares input. -func (w *McpServerWizard) chooseRemoteType() { - w.chosenType = serverTypes.Remote.ID - w.step = stepRemoteURL - // Clear any stale input value when entering URL step - w.urlInput.SetValue("") - w.urlInput.Focus() -} - -// chooseCommandType transitions to the command method selection step. -func (w *McpServerWizard) chooseCommandType() { - w.chosenType = serverTypes.Command.ID - w.step = stepCommandMethod -} - -// enterMethodDetails stores the chosen method and focuses the appropriate input. -func (w *McpServerWizard) enterMethodDetails(displayName string) { - // Map display name to ID - switch displayName { - case commandMethods.Image.DisplayName: - w.chosenMethod = commandMethods.Image.ID - w.step = stepCommandDetails - w.imageInput.SetValue("") - w.commandInput.SetValue("") - w.imageInput.Focus() - case commandMethods.Build.DisplayName: - w.chosenMethod = commandMethods.Build.ID - w.step = stepCommandDetails - w.buildPath = "" - // File picker will be shown for Build method - case commandMethods.Command.DisplayName: - w.chosenMethod = commandMethods.Command.ID - // Move to a separate step to choose command mode - w.commandMode = "" - w.step = stepCommandMode - } -} - -// applyCommandMode configures fields based on the chosen command mode title -// and moves to the details step, focusing the appropriate input. -func (w *McpServerWizard) applyCommandMode(title string) { - switch title { - case commandModes.Npx.DisplayName: - w.commandMode = commandModes.Npx.ID - w.pkgInput.SetValue("") - w.commandInput.SetValue("npx") - w.step = stepCommandDetails - w.pkgInput.Focus() - case commandModes.Uvx.DisplayName: - w.commandMode = commandModes.Uvx.ID - w.pkgInput.SetValue("") - w.commandInput.SetValue("uvx") - w.step = stepCommandDetails - w.pkgInput.Focus() - default: - w.commandMode = commandModes.Custom.ID - w.commandInput.SetValue("") - w.step = stepCommandDetails - w.commandInput.Focus() - } -} - -// interpretModeFromSelection reads the current selection in the mode list and applies it. -// Returns true if a selection was applied. -func (w *McpServerWizard) interpretModeFromSelection() bool { - if it, ok := w.modeList.SelectedItem().(choiceItem); ok { - w.applyCommandMode(it.Title()) - return true - } - return false -} - -// validateCommandDetails validates inputs for the selected method. For Command -// method without a chosen mode, it interprets the selected mode and stays on the page. -// Returns true if validation passes and we can proceed to the next step. -func (w *McpServerWizard) validateCommandDetails() bool { - switch w.chosenMethod { - case commandMethods.Image.ID: - if strings.TrimSpace(w.imageInput.Value()) == "" { - w.errMsg = "Image is required" - return false - } - case commandMethods.Build.ID: - if w.buildPath == "" { - w.errMsg = "Please select a kmcp.yaml file" - return false - } - case commandMethods.Command.ID: - if w.commandMode == "" { - // Interpret mode selection on Enter and remain on this step - _ = w.interpretModeFromSelection() - return false - } - } - return true -} - -// proceedToArgsEnv transitions to the args/env step and prepares inputs. -func (w *McpServerWizard) proceedToArgsEnv() { - w.step = stepArgsEnv - w.argsInput.SetValue("") - w.envInput.SetValue("") - w.argsInput.Focus() -} - -// buildFinalResult constructs the final result object from the wizard state. -func (w *McpServerWizard) buildFinalResult(name string) { - if w.chosenType == serverTypes.Command.ID { - w.result.Type = serverTypes.Command.ID - w.result.Name = name - - cmd := strings.TrimSpace(w.commandInput.Value()) - if cmd != "" { - w.result.Command = cmd - } - - switch w.chosenMethod { - case commandMethods.Image.ID: - w.result.Image = strings.TrimSpace(w.imageInput.Value()) - w.result.Build = "" - case commandMethods.Build.ID: - w.result.Build = w.buildPath - w.result.Image = "" - case commandMethods.Command.ID: - w.result.Image = "" - w.result.Build = "" - if w.commandMode == commandModes.Npx.ID || w.commandMode == commandModes.Uvx.ID { - pkg := strings.TrimSpace(w.pkgInput.Value()) - args := []string{pkg} - if s := strings.TrimSpace(w.argsInput.Value()); s != "" { - args = append(args, splitCSV(s)...) - } - w.result.Args = args - } - } - if w.chosenMethod != commandMethods.Command.ID || (w.commandMode != commandModes.Npx.ID && w.commandMode != commandModes.Uvx.ID) { - if s := strings.TrimSpace(w.argsInput.Value()); s != "" { - w.result.Args = splitCSV(s) - } - } - if s := strings.TrimSpace(w.envInput.Value()); s != "" { - w.result.Env = splitCSV(s) - } - return - } - w.result.Type = serverTypes.Remote.ID - w.result.Name = name -} - -// onTab cycles focus within the current step. -func (w *McpServerWizard) onTab(reverse bool) tea.Cmd { - switch w.step { - case stepRemoteURL: - return w.tabRemoteURL(reverse) - case stepRemoteHeaders: - return w.tabRemoteHeaders(reverse) - case stepCommandDetails: - return w.tabCommandDetails(reverse) - case stepArgsEnv: - return w.tabArgsEnv(reverse) - case stepName: - return w.tabName(reverse) - } - return nil -} - -// tabRemoteURL has a single input; nothing to cycle. -func (w *McpServerWizard) tabRemoteURL(_ bool) tea.Cmd { return nil } - -// tabRemoteHeaders toggles focus between header key and value inputs. -func (w *McpServerWizard) tabRemoteHeaders(reverse bool) tea.Cmd { - if reverse { - if w.headerValueInput.Focused() { - w.headerKeyInput.Focus() - w.headerValueInput.Blur() - } else { - w.headerKeyInput.Blur() - w.headerValueInput.Focus() - } - } else { - if w.headerKeyInput.Focused() { - w.headerKeyInput.Blur() - w.headerValueInput.Focus() - } else { - w.headerValueInput.Blur() - w.headerKeyInput.Focus() - } - } - return nil -} - -// tabCommandDetails cycles across visible detail inputs. -func (w *McpServerWizard) tabCommandDetails(reverse bool) tea.Cmd { - order := w.detailOrderKeys() - idx := 0 - for i, k := range order { - if w.isFocusedKey(k) { - idx = i - break - } - } - if reverse { - idx-- - if idx < 0 { - idx = len(order) - 1 - } - } else { - idx = (idx + 1) % len(order) - } - w.focusDetailKey(order[idx]) - return nil -} - -// tabArgsEnv toggles focus between args and env. -func (w *McpServerWizard) tabArgsEnv(reverse bool) tea.Cmd { - if reverse { - if w.envInput.Focused() { - w.argsInput.Focus() - w.envInput.Blur() - } else { - w.argsInput.Blur() - w.envInput.Focus() - } - } else { - if w.argsInput.Focused() { - w.argsInput.Blur() - w.envInput.Focus() - } else { - w.envInput.Blur() - w.argsInput.Focus() - } - } - return nil -} - -// tabName has a single input; nothing to cycle. -func (w *McpServerWizard) tabName(_ bool) tea.Cmd { return nil } - -func (w *McpServerWizard) detailOrderKeys() []inputKey { - switch w.chosenMethod { - case commandMethods.Image.ID: - return []inputKey{inImage, inCommand} - case commandMethods.Build.ID: - // Build uses file picker, no text inputs to cycle through - return []inputKey{} - case commandMethods.Command.ID: - return []inputKey{inCommand} - } - return []inputKey{inCommand} -} - -func (w *McpServerWizard) inputModel(k inputKey) *textinput.Model { - switch k { - case inImage: - return &w.imageInput - case inPkg: - return &w.pkgInput - case inCommand: - return &w.commandInput - default: - return nil - } -} - -func (w *McpServerWizard) isFocusedKey(k inputKey) bool { - m := w.inputModel(k) - if m == nil { - return false - } - return m.Focused() -} - -func (w *McpServerWizard) focusDetailKey(k inputKey) { - // blur all - w.imageInput.Blur() - w.pkgInput.Blur() - w.commandInput.Blur() - if m := w.inputModel(k); m != nil { - m.Focus() - } -} - -func (w *McpServerWizard) labeled(label, view string) string { - return lipgloss.JoinHorizontal(lipgloss.Left, theme.StatusStyle().Render(label+": "), view) -} - -type detailRow struct{ label, view string } - -func (w *McpServerWizard) renderRows(rows []detailRow) string { - if len(rows) == 0 { - return w.errorView() - } - parts := make([]string, 0, len(rows)+2) - parts = append(parts, "\n") - for _, r := range rows { - parts = append(parts, w.labeled(r.label, r.view)) - } - parts = append(parts, "\n", w.errorView()) - return lipgloss.JoinVertical(lipgloss.Left, parts...) -} - -func (w *McpServerWizard) renderCommandDetails() string { - switch w.chosenMethod { - case commandMethods.Image.ID: - return w.renderRows([]detailRow{ - {label: "Container image", view: w.imageInput.View()}, - {label: "Command (optional)", view: w.commandInput.View()}, - }) - case commandMethods.Build.ID: - // Show file picker for Build method - var content strings.Builder - content.WriteString("\n") - if w.buildPath == "" { - content.WriteString(theme.StatusStyle().Render("Select kmcp.yaml file:")) - } else { - content.WriteString(theme.StatusStyle().Render("Selected: ") + w.buildPath) - } - content.WriteString("\n\n") - content.WriteString(w.filePicker.View()) - content.WriteString("\n") - return content.String() - case commandMethods.Command.ID: - if w.commandMode == commandModes.Npx.ID || w.commandMode == commandModes.Uvx.ID { - return w.renderRows([]detailRow{{label: "Package", view: w.pkgInput.View()}}) - } - return w.renderRows([]detailRow{{label: "Command", view: w.commandInput.View()}}) - } - return "" -} - -func (w *McpServerWizard) renderHeader() string { - idx := 1 - var total int - if w.chosenType == serverTypes.Remote.ID || w.step == stepRemoteURL { - // remote flow - if v, ok := wizardFlows.Remote.StepPositions[w.step]; ok { - idx = v - } - total = wizardFlows.Remote.TotalSteps - } else { - // command flow (default) - if v, ok := wizardFlows.Command.StepPositions[w.step]; ok { - idx = v - } - total = wizardFlows.Command.TotalSteps - } - title := fmt.Sprintf("Add MCP Server — Step %d/%d", idx, total) - return theme.HeadingStyle().Render(title) -} - -func (w *McpServerWizard) errorView() string { - if strings.TrimSpace(w.errMsg) == "" { - return "" - } - return theme.ErrorStyle().Render("\nError: " + w.errMsg) -} - -func (w *McpServerWizard) close() tea.Cmd { - return tea.Quit -} - -// prevStep moves the wizard back by one logical step based on current state. -func (w *McpServerWizard) prevStep() { - switch w.step { - case stepRemoteURL: - w.step = stepPickType - case stepRemoteHeaders: - w.step = stepRemoteURL - case stepCommandMethod: - w.step = stepPickType - case stepCommandMode: - w.step = stepCommandMethod - case stepCommandDetails: - if w.chosenMethod == commandMethods.Command.ID { - w.step = stepCommandMode - } else { - w.step = stepCommandMethod - } - case stepArgsEnv: - w.step = stepCommandDetails - case stepName: - if w.chosenType == serverTypes.Remote.ID { - w.step = stepRemoteHeaders - } else { - w.step = stepArgsEnv - } - default: - w.step = stepPickType - } -} - -// renderHeadersStep displays the headers input interface with current headers. -func (w *McpServerWizard) renderHeadersStep() string { - var sb strings.Builder - - sb.WriteString("\n") - sb.WriteString(theme.StatusStyle().Render("Add HTTP headers (optional)")) - sb.WriteString("\n\n") - - // Show existing headers - if len(w.headers) > 0 { - sb.WriteString(theme.StatusStyle().Render("Current headers:")) - sb.WriteString("\n") - for k, v := range w.headers { - // Mask sensitive values but show pattern - displayValue := v - if strings.Contains(strings.ToLower(k), "auth") || strings.Contains(strings.ToLower(k), "token") || strings.Contains(strings.ToLower(k), "key") { - if len(v) > 10 { - displayValue = v[:7] + "***" - } - } - fmt.Fprintf(&sb, " • %s: %s\n", k, displayValue) - } - sb.WriteString("\n") - } - - sb.WriteString(w.labeled("Header name", w.headerKeyInput.View())) - sb.WriteString("\n") - sb.WriteString(w.labeled("Header value", w.headerValueInput.View())) - sb.WriteString("\n\n") - sb.WriteString(theme.StatusStyle().Render("💡 Tip: Use ${VAR_NAME} for environment variables (e.g., Bearer ${API_KEY})")) - sb.WriteString("\n") - sb.WriteString(theme.StatusStyle().Render(" Press Enter with both fields empty to continue")) - sb.WriteString("\n") - sb.WriteString(w.errorView()) - - return sb.String() -} - -// choice list items -type choiceItem struct{ label string } - -func (i choiceItem) Title() string { return i.label } -func (i choiceItem) Description() string { return "" } -func (i choiceItem) FilterValue() string { return i.label } - -type choiceDelegate struct{} - -func (d choiceDelegate) Height() int { return 1 } -func (d choiceDelegate) Spacing() int { return 0 } -func (d choiceDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } -func (d choiceDelegate) Render(w io.Writer, m list.Model, index int, it list.Item) { - i, ok := it.(choiceItem) - if !ok { - return - } - str := fmt.Sprintf("%d. %s", index+1, i.Title()) - normal := lipgloss.NewStyle().PaddingLeft(2) - selected := lipgloss.NewStyle().PaddingLeft(1).Foreground(theme.ColorPrimary) - if index == m.Index() { - _, _ = w.Write([]byte(selected.Render("> " + str))) - } else { - _, _ = w.Write([]byte(normal.Render(str))) - } -} - -// lineCount counts lines in a string (>=1 if non-empty) -func lineCount(s string) int { - if s == "" { - return 0 - } - return strings.Count(s, "\n") + 1 -} - -// splitCSV splits comma-separated values, trimming whitespace and skipping empties -func splitCSV(s string) []string { - parts := strings.Split(s, ",") - out := make([]string, 0, len(parts)) - for _, p := range parts { - v := strings.TrimSpace(p) - if v != "" { - out = append(out, v) - } - } - return out -} - -// maxInt returns the maximum of two ints -func maxInt(a, b int) int { - if a > b { - return a - } - return b -} From 247db177c3d5caf2712f9f9cb737de5c19a9e914 Mon Sep 17 00:00:00 2001 From: Cody Hartsook Date: Tue, 25 Aug 2026 11:26:32 -0700 Subject: [PATCH 05/10] refactor(cli): narrow agent instance client surface Signed-off-by: Cody Hartsook --- go/api/client/agentinstance.go | 43 ++++--------------- go/api/client/clientset.go | 2 +- go/api/client/grpc.go | 3 +- go/core/cli/internal/cli/agentinstance/get.go | 8 +++- .../internal/cli/agentinstance/get_test.go | 2 - .../internal/cli/agentinstance/lifecycle.go | 10 +++-- .../cli/agentinstance/lifecycle_test.go | 2 - .../cli/internal/cli/connection/connection.go | 4 +- 8 files changed, 26 insertions(+), 48 deletions(-) diff --git a/go/api/client/agentinstance.go b/go/api/client/agentinstance.go index 47465a1f6..24900a467 100644 --- a/go/api/client/agentinstance.go +++ b/go/api/client/agentinstance.go @@ -6,26 +6,17 @@ import ( apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" ) -// AgentInstance provides AgentInstance lifecycle operations. -type AgentInstance interface { - CreateAgentInstance(context.Context, *apiv1alpha1.CreateAgentInstanceRequest) (*apiv1alpha1.CreateAgentInstanceResponse, error) - GetAgentInstance(context.Context, *apiv1alpha1.GetAgentInstanceRequest) (*apiv1alpha1.GetAgentInstanceResponse, error) - ListAgentInstances(context.Context, *apiv1alpha1.ListAgentInstancesRequest) (*apiv1alpha1.ListAgentInstancesResponse, error) - SuspendAgentInstance(context.Context, *apiv1alpha1.SuspendAgentInstanceRequest) (*apiv1alpha1.SuspendAgentInstanceResponse, error) - ResumeAgentInstance(context.Context, *apiv1alpha1.ResumeAgentInstanceRequest) (*apiv1alpha1.ResumeAgentInstanceResponse, error) - DeleteAgentInstance(context.Context, *apiv1alpha1.DeleteAgentInstanceRequest) (*apiv1alpha1.DeleteAgentInstanceResponse, error) -} - -type agentInstanceClient struct { +// AgentInstanceClient provides supported AgentInstance operations. +type AgentInstanceClient struct { client *BaseClient } // NewAgentInstanceClient creates an AgentInstance client over the shared gRPC connection. -func NewAgentInstanceClient(client *BaseClient) AgentInstance { - return &agentInstanceClient{client: client} +func NewAgentInstanceClient(client *BaseClient) *AgentInstanceClient { + return &AgentInstanceClient{client: client} } -func (c *agentInstanceClient) CreateAgentInstance(ctx context.Context, request *apiv1alpha1.CreateAgentInstanceRequest) (*apiv1alpha1.CreateAgentInstanceResponse, error) { +func (c *AgentInstanceClient) CreateAgentInstance(ctx context.Context, request *apiv1alpha1.CreateAgentInstanceRequest) (*apiv1alpha1.CreateAgentInstanceResponse, error) { client, callContext, cancel, err := c.client.agentInstanceCall(ctx) if err != nil { return nil, err @@ -34,7 +25,7 @@ func (c *agentInstanceClient) CreateAgentInstance(ctx context.Context, request * return client.CreateAgentInstance(callContext, request) } -func (c *agentInstanceClient) GetAgentInstance(ctx context.Context, request *apiv1alpha1.GetAgentInstanceRequest) (*apiv1alpha1.GetAgentInstanceResponse, error) { +func (c *AgentInstanceClient) GetAgentInstance(ctx context.Context, request *apiv1alpha1.GetAgentInstanceRequest) (*apiv1alpha1.GetAgentInstanceResponse, error) { client, callContext, cancel, err := c.client.agentInstanceCall(ctx) if err != nil { return nil, err @@ -43,7 +34,7 @@ func (c *agentInstanceClient) GetAgentInstance(ctx context.Context, request *api return client.GetAgentInstance(callContext, request) } -func (c *agentInstanceClient) ListAgentInstances(ctx context.Context, request *apiv1alpha1.ListAgentInstancesRequest) (*apiv1alpha1.ListAgentInstancesResponse, error) { +func (c *AgentInstanceClient) ListAgentInstances(ctx context.Context, request *apiv1alpha1.ListAgentInstancesRequest) (*apiv1alpha1.ListAgentInstancesResponse, error) { client, callContext, cancel, err := c.client.agentInstanceCall(ctx) if err != nil { return nil, err @@ -52,25 +43,7 @@ func (c *agentInstanceClient) ListAgentInstances(ctx context.Context, request *a return client.ListAgentInstances(callContext, request) } -func (c *agentInstanceClient) SuspendAgentInstance(ctx context.Context, request *apiv1alpha1.SuspendAgentInstanceRequest) (*apiv1alpha1.SuspendAgentInstanceResponse, error) { - client, callContext, cancel, err := c.client.agentInstanceCall(ctx) - if err != nil { - return nil, err - } - defer cancel() - return client.SuspendAgentInstance(callContext, request) -} - -func (c *agentInstanceClient) ResumeAgentInstance(ctx context.Context, request *apiv1alpha1.ResumeAgentInstanceRequest) (*apiv1alpha1.ResumeAgentInstanceResponse, error) { - client, callContext, cancel, err := c.client.agentInstanceCall(ctx) - if err != nil { - return nil, err - } - defer cancel() - return client.ResumeAgentInstance(callContext, request) -} - -func (c *agentInstanceClient) DeleteAgentInstance(ctx context.Context, request *apiv1alpha1.DeleteAgentInstanceRequest) (*apiv1alpha1.DeleteAgentInstanceResponse, error) { +func (c *AgentInstanceClient) DeleteAgentInstance(ctx context.Context, request *apiv1alpha1.DeleteAgentInstanceRequest) (*apiv1alpha1.DeleteAgentInstanceResponse, error) { client, callContext, cancel, err := c.client.agentInstanceCall(ctx) if err != nil { return nil, err diff --git a/go/api/client/clientset.go b/go/api/client/clientset.go index 922bcaacb..6c68b9971 100644 --- a/go/api/client/clientset.go +++ b/go/api/client/clientset.go @@ -15,7 +15,7 @@ type ClientSet struct { Model Model Namespace Namespace Feedback Feedback - AgentInstance AgentInstance + AgentInstance *AgentInstanceClient A2A *A2AClient } diff --git a/go/api/client/grpc.go b/go/api/client/grpc.go index 0d67893c7..8d810cb4b 100644 --- a/go/api/client/grpc.go +++ b/go/api/client/grpc.go @@ -16,7 +16,8 @@ import ( ) const ( - // DefaultGRPCTarget is the local gRPC endpoint used when no target is configured. + // DefaultGRPCTarget is the implicit local target used when WithGRPCTarget is omitted. + // Callers can use it to limit local connection setup to the default endpoint. DefaultGRPCTarget = "localhost:8084" defaultGRPCTimeout = 30 * time.Second defaultGRPCMaxMessageSize = 16 << 20 diff --git a/go/core/cli/internal/cli/agentinstance/get.go b/go/core/cli/internal/cli/agentinstance/get.go index 70ed34be1..ed566e248 100644 --- a/go/core/cli/internal/cli/agentinstance/get.go +++ b/go/core/cli/internal/cli/agentinstance/get.go @@ -11,7 +11,6 @@ import ( "github.com/google/uuid" "github.com/jedib0t/go-pretty/v6/table" - kagentclient "github.com/kagent-dev/kagent/go/api/client" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/core/cli/internal/cli/connection" clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" @@ -21,6 +20,11 @@ import ( const maxPageSize = 100 +type getClient interface { + GetAgentInstance(context.Context, *apiv1alpha1.GetAgentInstanceRequest) (*apiv1alpha1.GetAgentInstanceResponse, error) + ListAgentInstances(context.Context, *apiv1alpha1.ListAgentInstancesRequest) (*apiv1alpha1.ListAgentInstancesResponse, error) +} + // GetCfg configures AgentInstance get and list operations. type GetCfg struct { Config *config.Config @@ -74,7 +78,7 @@ func validateGetCfg(cfg *GetCfg) error { func get( ctx context.Context, - client kagentclient.AgentInstance, + client getClient, cfg *GetCfg, format clioutput.Format, out io.Writer, diff --git a/go/core/cli/internal/cli/agentinstance/get_test.go b/go/core/cli/internal/cli/agentinstance/get_test.go index 86d70c086..63beb4356 100644 --- a/go/core/cli/internal/cli/agentinstance/get_test.go +++ b/go/core/cli/internal/cli/agentinstance/get_test.go @@ -7,7 +7,6 @@ import ( "testing" "time" - kagentclient "github.com/kagent-dev/kagent/go/api/client" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" "github.com/kagent-dev/kagent/go/core/cli/internal/config" @@ -86,7 +85,6 @@ func TestListAgentInstancesJSONPreservesNextPageToken(t *testing.T) { } type fakeAgentInstanceClient struct { - kagentclient.AgentInstance instance *apiv1alpha1.AgentInstance nextPageToken string getRequest *apiv1alpha1.GetAgentInstanceRequest diff --git a/go/core/cli/internal/cli/agentinstance/lifecycle.go b/go/core/cli/internal/cli/agentinstance/lifecycle.go index f02caa5f6..72ee011a3 100644 --- a/go/core/cli/internal/cli/agentinstance/lifecycle.go +++ b/go/core/cli/internal/cli/agentinstance/lifecycle.go @@ -8,7 +8,6 @@ import ( "strings" "github.com/google/uuid" - kagentclient "github.com/kagent-dev/kagent/go/api/client" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/core/cli/internal/cli/connection" clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" @@ -18,6 +17,11 @@ import ( "google.golang.org/protobuf/proto" ) +type lifecycleClient interface { + CreateAgentInstance(context.Context, *apiv1alpha1.CreateAgentInstanceRequest) (*apiv1alpha1.CreateAgentInstanceResponse, error) + DeleteAgentInstance(context.Context, *apiv1alpha1.DeleteAgentInstanceRequest) (*apiv1alpha1.DeleteAgentInstanceResponse, error) +} + // CreateCfg configures AgentInstance creation. type CreateCfg struct { Config *config.Config @@ -110,7 +114,7 @@ func validateDeleteCfg(cfg *DeleteCfg) error { func create( ctx context.Context, - client kagentclient.AgentInstance, + client lifecycleClient, cfg *CreateCfg, format clioutput.Format, out io.Writer, @@ -130,7 +134,7 @@ func create( func deleteAgentInstance( ctx context.Context, - client kagentclient.AgentInstance, + client lifecycleClient, cfg *DeleteCfg, format clioutput.Format, out io.Writer, diff --git a/go/core/cli/internal/cli/agentinstance/lifecycle_test.go b/go/core/cli/internal/cli/agentinstance/lifecycle_test.go index 9ff7319b3..3cb41961c 100644 --- a/go/core/cli/internal/cli/agentinstance/lifecycle_test.go +++ b/go/core/cli/internal/cli/agentinstance/lifecycle_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/google/uuid" - kagentclient "github.com/kagent-dev/kagent/go/api/client" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" "github.com/kagent-dev/kagent/go/core/cli/internal/config" @@ -120,7 +119,6 @@ func TestDeleteAgentInstanceAborted(t *testing.T) { } type lifecycleAgentInstanceClient struct { - kagentclient.AgentInstance createInstance *apiv1alpha1.AgentInstance deleteInstance *apiv1alpha1.AgentInstance createRequest *apiv1alpha1.CreateAgentInstanceRequest diff --git a/go/core/cli/internal/cli/connection/connection.go b/go/core/cli/internal/cli/connection/connection.go index ff177368b..33188a3e8 100644 --- a/go/core/cli/internal/cli/connection/connection.go +++ b/go/core/cli/internal/cli/connection/connection.go @@ -81,9 +81,9 @@ func (r connectionRuntime) connect(ctx context.Context, cfg *config.Config) (*Po func shouldPortForward(cfg *config.Config, err error) bool { grpcURL := cfg.KAgentGRPCURL if grpcURL == "" { - grpcURL = client.DefaultGRPCTarget + grpcURL = config.DefaultKAgentGRPCURL } - if cfg.KAgentGRPCTLS || grpcURL != client.DefaultGRPCTarget || strings.TrimRight(cfg.KAgentURL, "/") != config.DefaultKAgentURL { + if cfg.KAgentGRPCTLS || grpcURL != config.DefaultKAgentGRPCURL || strings.TrimRight(cfg.KAgentURL, "/") != config.DefaultKAgentURL { return false } code := status.Code(err) From 2c71aeed287e37f2bde9826442662167434f0e91 Mon Sep 17 00:00:00 2001 From: Cody Hartsook Date: Thu, 27 Aug 2026 11:38:45 -0700 Subject: [PATCH 06/10] fix(cli): complete API v2 integration cleanup Signed-off-by: Cody Hartsook --- go/core/cli/cmd/kagent/main.go | 70 +++--- go/core/cli/cmd/kagent/main_test.go | 229 ++++-------------- go/core/cli/internal/cli/agent/bug_report.go | 19 +- go/core/cli/internal/cli/agent/dashboard.go | 6 +- .../internal/cli/agent/dashboard_darwin.go | 6 +- go/core/cli/internal/cli/agent/install.go | 54 +---- go/core/cli/internal/cli/agent/version.go | 10 +- go/core/cli/internal/cli/agentinstance/get.go | 23 +- .../internal/cli/agentinstance/get_test.go | 13 +- .../cli/internal/cli/agentinstance/invoke.go | 20 +- .../internal/cli/agentinstance/invoke_test.go | 21 ++ .../internal/cli/agentinstance/lifecycle.go | 66 ++--- .../cli/agentinstance/lifecycle_test.go | 22 +- go/core/cli/internal/cli/agenttemplate/get.go | 22 +- .../internal/cli/agenttemplate/get_test.go | 9 +- .../cli/internal/cli/connection/connection.go | 88 +++++-- .../cli/connection/connection_test.go | 47 ++-- go/core/cli/internal/cli/mcp/add_tool.go | 15 +- go/core/cli/internal/cli/mcp/build.go | 23 +- go/core/cli/internal/cli/mcp/deploy.go | 24 +- go/core/cli/internal/cli/mcp/inspector.go | 20 +- go/core/cli/internal/cli/mcp/root.go | 34 ++- go/core/cli/internal/cli/mcp/run.go | 48 ++-- go/core/cli/internal/config/config.go | 119 --------- go/core/cli/internal/config/utils.go | 74 ------ go/core/cli/internal/config/utils_test.go | 50 ---- go/core/cli/internal/tui/workspace.go | 14 +- .../cli/internal/tui/workspace_simple_test.go | 33 ++- go/core/v2/mcp/server.go | 6 +- go/go.mod | 12 +- go/go.sum | 15 -- 31 files changed, 410 insertions(+), 802 deletions(-) delete mode 100644 go/core/cli/internal/config/config.go delete mode 100644 go/core/cli/internal/config/utils.go delete mode 100644 go/core/cli/internal/config/utils_test.go diff --git a/go/core/cli/cmd/kagent/main.go b/go/core/cli/cmd/kagent/main.go index ffa36021c..4c6e1dba2 100644 --- a/go/core/cli/cmd/kagent/main.go +++ b/go/core/cli/cmd/kagent/main.go @@ -15,8 +15,6 @@ import ( "github.com/kagent-dev/kagent/go/core/cli/internal/cli/connection" "github.com/kagent-dev/kagent/go/core/cli/internal/cli/envdoc" "github.com/kagent-dev/kagent/go/core/cli/internal/cli/mcp" - clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" "github.com/kagent-dev/kagent/go/core/cli/internal/profiles" dbcli "github.com/kagent-dev/kagent/go/core/pkg/cli/db" dbmigrate "github.com/kagent-dev/kagent/go/core/pkg/cli/db/migrate" @@ -43,13 +41,7 @@ func main() { cancel() }() - cfg, err := loadConfig() - if err != nil { - fmt.Fprintf(os.Stderr, "Error initializing config: %v\n", err) - os.Exit(1) - } - - rootCmd := newRootCommand(ctx, cfg) + rootCmd := newRootCommand(ctx, defaultRootOptions()) if err := rootCmd.ExecuteContext(ctx); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -57,27 +49,23 @@ func main() { } } -func loadConfig() (*config.Config, error) { - if err := config.Init(); err != nil { - return nil, err - } - return config.Get() +type rootOptions struct { + Connection connection.Options + OutputFormat string } -func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { +func defaultRootOptions() *rootOptions { + return &rootOptions{Connection: connection.DefaultOptions(), OutputFormat: "table"} +} + +func newRootCommand(ctx context.Context, opts *rootOptions) *cobra.Command { + cfg := &opts.Connection rootCmd := &cobra.Command{ Use: "kagent", Short: "kagent is a CLI for kagent", Long: "kagent is a CLI for kagent", SilenceErrors: true, SilenceUsage: true, - PersistentPreRunE: func(_ *cobra.Command, _ []string) error { - if err := cfg.Validate(); err != nil { - return err - } - _, err := clioutput.Parse(cfg.OutputFormat) - return err - }, RunE: func(_ *cobra.Command, _ []string) error { return fmt.Errorf("interactive mode is not available in this release; use `kagent get agent-instance` and `kagent invoke`") }, @@ -90,12 +78,12 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { rootCmd.PersistentFlags().StringVar(&cfg.KAgentGRPCCAFile, "kagent-grpc-ca-file", cfg.KAgentGRPCCAFile, "CA certificate file for KAgent gRPC") rootCmd.PersistentFlags().StringVar(&cfg.KAgentGRPCServerName, "kagent-grpc-server-name", cfg.KAgentGRPCServerName, "TLS server name for KAgent gRPC") rootCmd.PersistentFlags().StringVarP(&cfg.Namespace, "namespace", "n", cfg.Namespace, "Namespace") - rootCmd.PersistentFlags().StringVarP(&cfg.OutputFormat, "output-format", "o", cfg.OutputFormat, "Output format") + rootCmd.PersistentFlags().StringVarP(&opts.OutputFormat, "output-format", "o", opts.OutputFormat, "Output format") rootCmd.PersistentFlags().BoolVarP(&cfg.Verbose, "verbose", "v", cfg.Verbose, "Verbose output") rootCmd.PersistentFlags().DurationVar(&cfg.Timeout, "timeout", cfg.Timeout, "Timeout") rootCmd.PersistentFlags().StringVar(&cfg.UserID, "user-id", cfg.UserID, "Caller identity used to select the server-side data partition") installCfg := &cli.InstallCfg{ - Config: cfg, + Connection: cfg, } installCmd := &cobra.Command{ @@ -116,12 +104,12 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { Short: "Uninstall kagent", Long: `Uninstall kagent`, Run: func(cmd *cobra.Command, args []string) { - cli.UninstallCmd(cmd.Context(), cfg) + cli.UninstallCmd(cmd.Context(), cfg.Namespace) }, } invokeCfg := &agentinstancecli.InvokeCfg{ - Config: cfg, + Connection: cfg, } invokeCmd := &cobra.Command{ @@ -130,6 +118,7 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { Long: `Invoke an existing AgentInstance through the A2A API.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + invokeCfg.OutputFormat = opts.OutputFormat return agentinstancecli.InvokeCmd(cmd.Context(), invokeCfg, cmd.InOrStdin(), cmd.OutOrStdout()) }, Example: `kagent invoke --agent-instance 8bd650a8-9775-488f-8bc1-0d52bf7bdcab --task "Get all the pods"`, @@ -157,7 +146,7 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { if pf != nil { defer pf.Stop() } - cli.BugReportCmd(cfg) + cli.BugReportCmd(cfg.Namespace, cfg.Verbose) }, } @@ -168,7 +157,9 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { Run: func(cmd *cobra.Command, args []string) { // print out kagent CLI version regardless if a port-forward to kagent server succeeds // versions unable to obtain from the remote kagent will be reported as "unknown" - defer cli.VersionCmd(cfg) + clientSet := cfg.Client() + defer clientSet.Close() //nolint:errcheck + defer cli.VersionCmd(clientSet) if pf, _ := connection.Connect(cmd.Context(), cfg); pf != nil { defer pf.Stop() @@ -181,7 +172,7 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { Short: "Open the kagent dashboard", Long: `Open the kagent dashboard`, Run: func(cmd *cobra.Command, args []string) { - cli.DashboardCmd(cmd.Context(), cfg) + cli.DashboardCmd(cmd.Context(), cfg.Namespace) }, } @@ -194,12 +185,13 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { return fmt.Errorf("resource type is required") }, } - agentInstanceGetCfg := &agentinstancecli.GetCfg{Config: cfg} + agentInstanceGetCfg := &agentinstancecli.GetCfg{Connection: cfg} getAgentInstanceCmd := &cobra.Command{ Use: "agent-instance [ID]", Short: "Get an AgentInstance or list your AgentInstances", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + agentInstanceGetCfg.OutputFormat = opts.OutputFormat agentInstanceGetCfg.InstanceID = "" if len(args) == 1 { agentInstanceGetCfg.InstanceID = args[0] @@ -210,12 +202,14 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { getAgentInstanceCmd.Flags().Int32Var(&agentInstanceGetCfg.PageSize, "page-size", 0, "Number of AgentInstances to return (default 50, maximum 100)") getAgentInstanceCmd.Flags().StringVar(&agentInstanceGetCfg.PageToken, "page-token", "", "Token returned by the previous page") - agentTemplateGetCfg := &agenttemplatecli.GetCfg{Config: cfg} + agentTemplateGetCfg := &agenttemplatecli.GetCfg{} getAgentTemplateCmd := &cobra.Command{ Use: "agent-template [NAME]", Short: "Get an AgentTemplate or list AgentTemplates", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + agentTemplateGetCfg.Namespace = cfg.Namespace + agentTemplateGetCfg.OutputFormat = opts.OutputFormat agentTemplateGetCfg.Name = "" if len(args) == 1 { agentTemplateGetCfg.Name = args[0] @@ -223,7 +217,7 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { return agenttemplatecli.GetCmd(cmd.Context(), agentTemplateGetCfg, cmd.OutOrStdout()) }, } - getAgentTemplateCmd.Flags().Int64Var(&agentTemplateGetCfg.PageSize, "page-size", 0, "Number of AgentTemplates to return (maximum 100)") + getAgentTemplateCmd.Flags().Int64Var(&agentTemplateGetCfg.PageSize, "page-size", 0, "Number of AgentTemplates per page (0 uses 100; maximum 100)") getAgentTemplateCmd.Flags().StringVar(&agentTemplateGetCfg.PageToken, "page-token", "", "Token returned by the previous page") getCmd.AddCommand(getAgentInstanceCmd, getAgentTemplateCmd) @@ -236,12 +230,13 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { return fmt.Errorf("resource type is required") }, } - createAgentInstanceCfg := &agentinstancecli.CreateCfg{Config: cfg} + createAgentInstanceCfg := &agentinstancecli.CreateCfg{Connection: cfg} createAgentInstanceCmd := &cobra.Command{ Use: "agent-instance", Short: "Create an AgentInstance", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + createAgentInstanceCfg.OutputFormat = opts.OutputFormat return agentinstancecli.CreateCmd(cmd.Context(), createAgentInstanceCfg, cmd.OutOrStdout()) }, } @@ -260,19 +255,20 @@ func newRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { return fmt.Errorf("resource type is required") }, } - deleteAgentInstanceCfg := &agentinstancecli.DeleteCfg{Config: cfg} + deleteAgentInstanceCfg := &agentinstancecli.DeleteCfg{Connection: cfg} deleteAgentInstanceCmd := &cobra.Command{ Use: "agent-instance ID", Short: "Delete an AgentInstance", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + deleteAgentInstanceCfg.OutputFormat = opts.OutputFormat deleteAgentInstanceCfg.InstanceID = args[0] return agentinstancecli.DeleteCmd(cmd.Context(), deleteAgentInstanceCfg, cmd.OutOrStdout()) }, } deleteCmd.AddCommand(deleteAgentInstanceCmd) - rootCmd.AddCommand(installCmd, uninstallCmd, invokeCmd, bugReportCmd, versionCmd, dashboardCmd, getCmd, createCmd, deleteCmd, mcp.NewMCPCmd(), envdoc.NewEnvCmd(), dbcli.NewCommandFromFunc(migrationSources(cfg))) + rootCmd.AddCommand(installCmd, uninstallCmd, invokeCmd, bugReportCmd, versionCmd, dashboardCmd, getCmd, createCmd, deleteCmd, mcp.NewMCPCmd(), envdoc.NewEnvCmd(), dbcli.NewCommandFromFunc(migrationSources(opts))) return rootCmd } @@ -290,7 +286,7 @@ const vectorEnabledKey = "DATABASE_VECTOR_ENABLED" // environment (explicit operator intent, works without a cluster), the // controller's configmap on the live cluster (the same value the server // reads), and finally the controller's default (enabled). -func migrationSources(cfg *config.Config) dbmigrate.SourcesFunc { +func migrationSources(opts *rootOptions) dbmigrate.SourcesFunc { return func(ctx context.Context) ([]migrations.Source, error) { vectorEnabled := true if v := os.Getenv(vectorEnabledKey); v != "" { @@ -300,7 +296,7 @@ func migrationSources(cfg *config.Config) dbmigrate.SourcesFunc { } else { vectorEnabled = b } - } else if b, ok := clusterVectorEnabled(ctx, cfg.Namespace); ok { + } else if b, ok := clusterVectorEnabled(ctx, opts.Connection.Namespace); ok { vectorEnabled = b } return migrations.BuiltinSources(vectorEnabled), nil diff --git a/go/core/cli/cmd/kagent/main_test.go b/go/core/cli/cmd/kagent/main_test.go index 651de67dd..122e5002a 100644 --- a/go/core/cli/cmd/kagent/main_test.go +++ b/go/core/cli/cmd/kagent/main_test.go @@ -1,107 +1,33 @@ package main import ( + "bytes" "context" - "os" - "path/filepath" "testing" "time" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" - "github.com/spf13/pflag" - "github.com/spf13/viper" + "github.com/kagent-dev/kagent/go/core/cli/internal/cli/connection" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestLoadConfigReadsConfigFileValues(t *testing.T) { - resetConfigState(t) - - homeDir := t.TempDir() - t.Setenv("HOME", homeDir) - - configDir := filepath.Join(homeDir, ".kagent") - require.NoError(t, os.MkdirAll(configDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(` -kagent_url: http://kagent.example.test -kagent_grpc_url: grpc.kagent.example.test:443 -kagent_grpc_tls: true -kagent_grpc_ca_file: /tmp/kagent-ca.pem -kagent_grpc_server_name: grpc.kagent.example.test -namespace: configured-ns -output_format: json -verbose: true -timeout: 45s -user_id: configured-user -`), 0600)) - - cfg, err := loadConfig() - require.NoError(t, err) - - assert.Equal(t, "http://kagent.example.test", cfg.KAgentURL) - assert.Equal(t, "grpc.kagent.example.test:443", cfg.KAgentGRPCURL) - assert.True(t, cfg.KAgentGRPCTLS) - assert.Equal(t, "/tmp/kagent-ca.pem", cfg.KAgentGRPCCAFile) - assert.Equal(t, "grpc.kagent.example.test", cfg.KAgentGRPCServerName) - assert.Equal(t, "configured-ns", cfg.Namespace) - assert.Equal(t, "json", cfg.OutputFormat) - assert.True(t, cfg.Verbose) - assert.Equal(t, 45*time.Second, cfg.Timeout) - assert.Equal(t, "configured-user", cfg.UserID) -} - -func TestLoadConfigDoesNotWriteDefaults(t *testing.T) { - resetConfigState(t) - - homeDir := t.TempDir() - t.Setenv("HOME", homeDir) - - cfg, err := loadConfig() - require.NoError(t, err) - assert.Equal(t, config.DefaultUserID, cfg.UserID) - _, err = os.Stat(filepath.Join(homeDir, ".kagent")) - assert.ErrorIs(t, err, os.ErrNotExist) -} - -func TestLoadConfigReadsUserIDFromEnvironment(t *testing.T) { - resetConfigState(t) - - t.Setenv("HOME", t.TempDir()) - t.Setenv("KAGENT_USER_ID", "environment-user") - t.Setenv("KAGENT_OUTPUT_FORMAT", "json") - - cfg, err := loadConfig() - require.NoError(t, err) - assert.Equal(t, "environment-user", cfg.UserID) - assert.Equal(t, "json", cfg.OutputFormat) -} - -func TestLoadConfigRejectsInvalidUserID(t *testing.T) { - resetConfigState(t) - - t.Setenv("HOME", t.TempDir()) - t.Setenv("KAGENT_USER_ID", "invalid user") - - _, err := loadConfig() - require.Error(t, err) - assert.Contains(t, err.Error(), "caller identity must not contain whitespace") -} - -func TestRootCommandUsesConfigValuesAsFlagDefaults(t *testing.T) { - cfg := &config.Config{ - KAgentURL: "http://kagent.example.test", - KAgentGRPCURL: "grpc.kagent.example.test:443", - KAgentGRPCTLS: true, - KAgentGRPCCAFile: "/tmp/kagent-ca.pem", - KAgentGRPCServerName: "grpc.kagent.example.test", - Namespace: "configured-ns", - OutputFormat: "json", - Verbose: true, - Timeout: 45 * time.Second, - UserID: "configured-user", +func TestRootCommandUsesOptionValuesAsFlagDefaults(t *testing.T) { + opts := &rootOptions{ + Connection: connection.Options{ + KAgentURL: "http://kagent.example.test", + KAgentGRPCURL: "grpc.kagent.example.test:443", + KAgentGRPCTLS: true, + KAgentGRPCCAFile: "/tmp/kagent-ca.pem", + KAgentGRPCServerName: "grpc.kagent.example.test", + Namespace: "configured-ns", + Verbose: true, + Timeout: 45 * time.Second, + UserID: "configured-user", + }, + OutputFormat: "json", } - rootCmd := newRootCommand(context.Background(), cfg) + rootCmd := newRootCommand(context.Background(), opts) assert.Equal(t, "http://kagent.example.test", rootCmd.PersistentFlags().Lookup("kagent-url").DefValue) assert.Equal(t, "grpc.kagent.example.test:443", rootCmd.PersistentFlags().Lookup("kagent-grpc-url").DefValue) @@ -114,20 +40,21 @@ func TestRootCommandUsesConfigValuesAsFlagDefaults(t *testing.T) { assert.Equal(t, "45s", rootCmd.PersistentFlags().Lookup("timeout").DefValue) assert.Equal(t, "configured-user", rootCmd.PersistentFlags().Lookup("user-id").DefValue) - assert.Equal(t, "configured-ns", cfg.Namespace) + assert.Equal(t, "configured-ns", opts.Connection.Namespace) } -func TestRootCommandFlagsOverrideConfigValues(t *testing.T) { - cfg := &config.Config{ - KAgentURL: "http://kagent.example.test", - KAgentGRPCURL: "grpc.kagent.example.test:443", - Namespace: "configured-ns", - OutputFormat: "json", - Verbose: false, - Timeout: 45 * time.Second, +func TestRootCommandFlagsOverrideOptionValues(t *testing.T) { + opts := &rootOptions{ + Connection: connection.Options{ + KAgentURL: "http://kagent.example.test", + KAgentGRPCURL: "grpc.kagent.example.test:443", + Namespace: "configured-ns", + Timeout: 45 * time.Second, + }, + OutputFormat: "json", } - rootCmd := newRootCommand(context.Background(), cfg) + rootCmd := newRootCommand(context.Background(), opts) require.NoError(t, rootCmd.ParseFlags([]string{ "--kagent-url", "http://flag.example.test", "--kagent-grpc-url", "grpc.flag.example.test:8443", @@ -141,67 +68,28 @@ func TestRootCommandFlagsOverrideConfigValues(t *testing.T) { "--user-id", "flag-user", })) - assert.Equal(t, "http://flag.example.test", cfg.KAgentURL) - assert.Equal(t, "grpc.flag.example.test:8443", cfg.KAgentGRPCURL) - assert.True(t, cfg.KAgentGRPCTLS) - assert.Equal(t, "/tmp/flag-ca.pem", cfg.KAgentGRPCCAFile) - assert.Equal(t, "grpc.flag.example.test", cfg.KAgentGRPCServerName) - assert.Equal(t, "flag-ns", cfg.Namespace) - assert.Equal(t, "yaml", cfg.OutputFormat) - assert.True(t, cfg.Verbose) - assert.Equal(t, 10*time.Second, cfg.Timeout) - assert.Equal(t, "flag-user", cfg.UserID) -} - -func TestRootCommandRejectsInvalidUserIDFlag(t *testing.T) { - tests := []struct { - name string - args []string - }{ - {name: "empty", args: []string{"--user-id=", "version"}}, - {name: "whitespace", args: []string{"--user-id", "invalid user", "version"}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := &config.Config{ - KAgentURL: config.DefaultKAgentURL, - KAgentGRPCURL: config.DefaultKAgentGRPCURL, - UserID: config.DefaultUserID, - } - rootCmd := newRootCommand(t.Context(), cfg) - rootCmd.SetArgs(tt.args) - - err := rootCmd.ExecuteContext(t.Context()) - require.Error(t, err) - assert.Contains(t, err.Error(), "caller identity") - }) - } + assert.Equal(t, "http://flag.example.test", opts.Connection.KAgentURL) + assert.Equal(t, "grpc.flag.example.test:8443", opts.Connection.KAgentGRPCURL) + assert.True(t, opts.Connection.KAgentGRPCTLS) + assert.Equal(t, "/tmp/flag-ca.pem", opts.Connection.KAgentGRPCCAFile) + assert.Equal(t, "grpc.flag.example.test", opts.Connection.KAgentGRPCServerName) + assert.Equal(t, "flag-ns", opts.Connection.Namespace) + assert.Equal(t, "yaml", opts.OutputFormat) + assert.True(t, opts.Connection.Verbose) + assert.Equal(t, 10*time.Second, opts.Connection.Timeout) + assert.Equal(t, "flag-user", opts.Connection.UserID) } -func TestRootCommandRejectsInvalidOutputBeforeRunningCommand(t *testing.T) { - cfg := &config.Config{ - KAgentURL: config.DefaultKAgentURL, - KAgentGRPCURL: config.DefaultKAgentGRPCURL, - OutputFormat: "yaml", - UserID: config.DefaultUserID, - } - rootCmd := newRootCommand(t.Context(), cfg) - rootCmd.SetArgs([]string{"version"}) +func TestRootCommandDoesNotValidateClientFlagsForIndependentCommand(t *testing.T) { + rootCmd := newRootCommand(t.Context(), defaultRootOptions()) + rootCmd.SetArgs([]string{"--output-format", "yaml", "--user-id", "invalid user", "env"}) + rootCmd.SetOut(&bytes.Buffer{}) - err := rootCmd.ExecuteContext(t.Context()) - require.Error(t, err) - assert.Contains(t, err.Error(), `unsupported output format "yaml"`) + require.NoError(t, rootCmd.ExecuteContext(t.Context())) } func TestRootCommandInvokeContract(t *testing.T) { - cfg := &config.Config{ - KAgentURL: config.DefaultKAgentURL, - KAgentGRPCURL: config.DefaultKAgentGRPCURL, - OutputFormat: "table", - UserID: config.DefaultUserID, - } - rootCmd := newRootCommand(t.Context(), cfg) + rootCmd := newRootCommand(t.Context(), defaultRootOptions()) assert.True(t, rootCmd.SilenceErrors) assert.True(t, rootCmd.SilenceUsage) @@ -223,13 +111,7 @@ func TestRootCommandInvokeContract(t *testing.T) { } func TestRootCommandV2CatalogAndLifecycleContract(t *testing.T) { - cfg := &config.Config{ - KAgentURL: config.DefaultKAgentURL, - KAgentGRPCURL: config.DefaultKAgentGRPCURL, - OutputFormat: "table", - UserID: config.DefaultUserID, - } - rootCmd := newRootCommand(t.Context(), cfg) + rootCmd := newRootCommand(t.Context(), defaultRootOptions()) getTemplateCmd, _, err := rootCmd.Find([]string{"get", "agent-template"}) require.NoError(t, err) @@ -256,13 +138,7 @@ func TestRootCommandV2CatalogAndLifecycleContract(t *testing.T) { } func TestRootCommandRemovesLegacyPaths(t *testing.T) { - cfg := &config.Config{ - KAgentURL: config.DefaultKAgentURL, - KAgentGRPCURL: config.DefaultKAgentGRPCURL, - OutputFormat: "table", - UserID: config.DefaultUserID, - } - rootCmd := newRootCommand(t.Context(), cfg) + rootCmd := newRootCommand(t.Context(), defaultRootOptions()) rootCommands := make([]string, 0, len(rootCmd.Commands())) for _, command := range rootCmd.Commands() { @@ -289,16 +165,3 @@ func TestRootCommandRemovesLegacyPaths(t *testing.T) { assert.Contains(t, err.Error(), "interactive mode is not available") assert.Contains(t, err.Error(), "kagent invoke") } - -func resetConfigState(t *testing.T) { - t.Helper() - - oldCommandLine := pflag.CommandLine - viper.Reset() - pflag.CommandLine = pflag.NewFlagSet(os.Args[0], pflag.ContinueOnError) - - t.Cleanup(func() { - viper.Reset() - pflag.CommandLine = oldCommandLine - }) -} diff --git a/go/core/cli/internal/cli/agent/bug_report.go b/go/core/cli/internal/cli/agent/bug_report.go index f5b1ba57c..3c1083178 100644 --- a/go/core/cli/internal/cli/agent/bug_report.go +++ b/go/core/cli/internal/cli/agent/bug_report.go @@ -8,10 +8,9 @@ import ( "time" commonexec "github.com/kagent-dev/kagent/go/core/cli/internal/common/exec" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" ) -func BugReportCmd(cfg *config.Config) { +func BugReportCmd(namespace string, verbose bool) { // Create a temporary directory for bug report timestamp := time.Now().Format("20060102-150405") reportDir := fmt.Sprintf("kagent-bug-report-%s", timestamp) @@ -21,12 +20,12 @@ func BugReportCmd(cfg *config.Config) { } fmt.Println("Gathering bug report information...") - kubectl := commonexec.NewKubectlExecutor(cfg.Verbose, cfg.Namespace) + kubectl := commonexec.NewKubectlExecutor(verbose, namespace) // Get Agent, ModelConfig, and ToolServers YAMLs resources := []string{"agent", "modelconfig", "toolserver", "mcpserver", "remotemcpserver"} for _, resource := range resources { - output, err := kubectl.RunWithOutput("get", resource, "-n", cfg.Namespace, "-o", "yaml") + output, err := kubectl.RunWithOutput("get", resource, "-n", namespace, "-o", "yaml") if err != nil { fmt.Fprintf(os.Stderr, "Error getting %s resources: %v\n", resource, err) continue @@ -40,7 +39,7 @@ func BugReportCmd(cfg *config.Config) { } // Get secret names (without values) - output, err := kubectl.RunWithOutput("get", "secrets", "-n", cfg.Namespace, "-o", "custom-columns=NAME:.metadata.name") + output, err := kubectl.RunWithOutput("get", "secrets", "-n", namespace, "-o", "custom-columns=NAME:.metadata.name") if err != nil { fmt.Fprintf(os.Stderr, "Error getting secret names: %v\n", err) } else { @@ -51,7 +50,7 @@ func BugReportCmd(cfg *config.Config) { } // Get pod logs - output, err = kubectl.RunWithOutput("get", "pods", "-n", cfg.Namespace, "-o", "name") + output, err = kubectl.RunWithOutput("get", "pods", "-n", namespace, "-o", "name") if err != nil { fmt.Fprintf(os.Stderr, "Error getting pod names: %v\n", err) } else { @@ -63,7 +62,7 @@ func BugReportCmd(cfg *config.Config) { podName := strings.TrimPrefix(pod, "pod/") // Get container names for this pod - containerOutput, err := kubectl.RunWithOutput("get", "pod", podName, "-n", cfg.Namespace, "-o", "jsonpath='{.spec.containers[*].name}'") + containerOutput, err := kubectl.RunWithOutput("get", "pod", podName, "-n", namespace, "-o", "jsonpath='{.spec.containers[*].name}'") if err != nil { fmt.Fprintf(os.Stderr, "Error getting containers for pod %s: %v\n", podName, err) continue @@ -75,7 +74,7 @@ func BugReportCmd(cfg *config.Config) { if len(containers) == 0 { // Fallback to getting logs without specifying container - logs, err := kubectl.RunWithOutput("logs", "-n", cfg.Namespace, podName) + logs, err := kubectl.RunWithOutput("logs", "-n", namespace, podName) if err != nil { fmt.Fprintf(os.Stderr, "Error getting logs for pod %s: %v\n", podName, err) continue @@ -88,7 +87,7 @@ func BugReportCmd(cfg *config.Config) { } else { // Get logs for each container for _, container := range containers { - logs, err := kubectl.RunWithOutput("logs", "-n", cfg.Namespace, podName, "-c", container) + logs, err := kubectl.RunWithOutput("logs", "-n", namespace, podName, "-c", container) if err != nil { fmt.Fprintf(os.Stderr, "Error getting logs for container %s in pod %s: %v\n", container, podName, err) continue @@ -104,7 +103,7 @@ func BugReportCmd(cfg *config.Config) { } // Get versions and images - output, err = kubectl.RunWithOutput("get", "pods", "-n", cfg.Namespace, "-o", "jsonpath='{range .items[*]}{.metadata.name}{\"\\n\"}{range .spec.containers[*]}{.image}{\"\\n\"}{end}{end}'") + output, err = kubectl.RunWithOutput("get", "pods", "-n", namespace, "-o", "jsonpath='{range .items[*]}{.metadata.name}{\"\\n\"}{range .spec.containers[*]}{.image}{\"\\n\"}{end}{end}'") if err != nil { fmt.Fprintf(os.Stderr, "Error getting pod images: %v\n", err) } else { diff --git a/go/core/cli/internal/cli/agent/dashboard.go b/go/core/cli/internal/cli/agent/dashboard.go index 05e9040e6..286ce1123 100644 --- a/go/core/cli/internal/cli/agent/dashboard.go +++ b/go/core/cli/internal/cli/agent/dashboard.go @@ -6,13 +6,11 @@ import ( "context" "fmt" "os" - - "github.com/kagent-dev/kagent/go/core/cli/internal/config" ) -func DashboardCmd(ctx context.Context, cfg *config.Config) { +func DashboardCmd(ctx context.Context, namespace string) { fmt.Fprintln(os.Stderr, "Dashboard is not available on this platform") fmt.Fprintln(os.Stderr, "You can easily start the dashboard by running:") - fmt.Fprintln(os.Stderr, "kubectl port-forward -n kagent service/kagent-ui 8082:8080") + fmt.Fprintf(os.Stderr, "kubectl port-forward -n %s service/kagent-ui 8082:8080\n", namespace) fmt.Fprintln(os.Stderr, "and then opening http://localhost:8082 in your browser") } diff --git a/go/core/cli/internal/cli/agent/dashboard_darwin.go b/go/core/cli/internal/cli/agent/dashboard_darwin.go index c72c8c3f4..eddf428f4 100644 --- a/go/core/cli/internal/cli/agent/dashboard_darwin.go +++ b/go/core/cli/internal/cli/agent/dashboard_darwin.go @@ -9,13 +9,11 @@ import ( "os/exec" "strings" "time" - - "github.com/kagent-dev/kagent/go/core/cli/internal/config" ) -func DashboardCmd(ctx context.Context, cfg *config.Config) { +func DashboardCmd(ctx context.Context, namespace string) { ctx, cancel := context.WithCancel(ctx) - cmd := exec.CommandContext(ctx, "kubectl", "-n", cfg.Namespace, "port-forward", "service/kagent-ui", "8082:8080") + cmd := exec.CommandContext(ctx, "kubectl", "-n", namespace, "port-forward", "service/kagent-ui", "8082:8080") defer func() { cancel() diff --git a/go/core/cli/internal/cli/agent/install.go b/go/core/cli/internal/cli/agent/install.go index 47a184d73..2702f163a 100644 --- a/go/core/cli/internal/cli/agent/install.go +++ b/go/core/cli/internal/cli/agent/install.go @@ -13,16 +13,14 @@ import ( "github.com/kagent-dev/kagent/go/core/internal/version" "github.com/kagent-dev/kagent/go/core/pkg/env" - "github.com/abiosoft/ishell/v2" "github.com/briandowns/spinner" "github.com/kagent-dev/kagent/go/core/cli/internal/cli/connection" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" "github.com/kagent-dev/kagent/go/core/cli/internal/profiles" ) type InstallCfg struct { - Config *config.Config - Profile string + Connection *connection.Options + Profile string } // installChart installs or upgrades a Helm chart with the given parameters @@ -103,45 +101,7 @@ func InstallCmd(ctx context.Context, cfg *InstallCfg) *connection.PortForward { helmConfig.inlineValues = profiles.GetProfileYaml(cfg.Profile) } - return install(ctx, cfg.Config, helmConfig, modelProvider) -} - -func InteractiveInstallCmd(ctx context.Context, c *ishell.Context) *connection.PortForward { - if version.Version == "dev" { - fmt.Fprintln(os.Stderr, "Installation requires released version of kagent") - return nil - } - - if err := checkHelmAvailable(); err != nil { - fmt.Fprintln(os.Stderr, err) - return nil - } - - cfg := config.GetCfg(c) - - // get model provider from KAGENT_DEFAULT_MODEL_PROVIDER environment variable or use DefaultModelProvider - modelProvider := GetModelProvider() - - // if model provider is openai, check if the api key is set - apiKeyName := GetProviderAPIKey(modelProvider) - apiKeyValue := os.Getenv(apiKeyName) - - if apiKeyName != "" && apiKeyValue == "" { - fmt.Fprintf(os.Stderr, "%s is not set\n", apiKeyName) - fmt.Fprintf(os.Stderr, "Please set the %s environment variable\n", apiKeyName) - fmt.Fprintf(os.Stderr, "To use a different provider set KAGENT_DEFAULT_MODEL_PROVIDER (e.g. ollama, anthropic, gemini)\n") - return nil - } - - helmConfig := setupHelmConfig(modelProvider, apiKeyValue) - - // Add profile selection - profileIdx := c.MultiChoice(profiles.Profiles, "Select a profile:") - selectedProfile := profiles.Profiles[profileIdx] - - helmConfig.inlineValues = profiles.GetProfileYaml(selectedProfile) - - return install(ctx, cfg, helmConfig, modelProvider) + return install(ctx, cfg.Connection, helmConfig, modelProvider) } // helmConfig is the config for the kagent chart @@ -181,7 +141,7 @@ func setupHelmConfig(modelProvider v1alpha3.ModelProvider, apiKeyValue string) h } // install installs kagent and kagent-crds using the helm config -func install(ctx context.Context, cfg *config.Config, helmConfig helmConfig, modelProvider v1alpha3.ModelProvider) *connection.PortForward { +func install(ctx context.Context, cfg *connection.Options, helmConfig helmConfig, modelProvider v1alpha3.ModelProvider) *connection.PortForward { // spinner for installation progress s := spinner.New(spinner.CharSets[35], 100*time.Millisecond) @@ -271,7 +231,7 @@ func deleteCRDs(ctx context.Context) error { return nil } -func UninstallCmd(ctx context.Context, cfg *config.Config) { +func UninstallCmd(ctx context.Context, namespace string) { // Check if helm is available if err := checkHelmAvailable(); err != nil { fmt.Fprintln(os.Stderr, err) @@ -288,7 +248,7 @@ func UninstallCmd(ctx context.Context, cfg *config.Config) { "uninstall", "kagent", "--namespace", - cfg.Namespace, + namespace, } cmd := exec.CommandContext(ctx, "helm", args...) @@ -311,7 +271,7 @@ func UninstallCmd(ctx context.Context, cfg *config.Config) { "uninstall", "kagent-crds", "--namespace", - cfg.Namespace, + namespace, } cmd = exec.CommandContext(ctx, "helm", args...) diff --git a/go/core/cli/internal/cli/agent/version.go b/go/core/cli/internal/cli/agent/version.go index baaeb961b..c9cd2adc9 100644 --- a/go/core/cli/internal/cli/agent/version.go +++ b/go/core/cli/internal/cli/agent/version.go @@ -6,12 +6,11 @@ import ( "os" "time" + "github.com/kagent-dev/kagent/go/api/client" "github.com/kagent-dev/kagent/go/core/internal/version" - - "github.com/kagent-dev/kagent/go/core/cli/internal/config" ) -func VersionCmd(cfg *config.Config) { +func VersionCmd(clientSet *client.ClientSet) { versionInfo := map[string]string{ "kagent_version": version.Version, "git_commit": version.GitCommit, @@ -19,12 +18,11 @@ func VersionCmd(cfg *config.Config) { } ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) defer cancel() - client := cfg.Client() - version, err := client.Version.GetVersion(ctx) + serverVersion, err := clientSet.Version.GetVersion(ctx) if err != nil { versionInfo["backend_version"] = "unknown" } else { - versionInfo["backend_version"] = version.KAgentVersion + versionInfo["backend_version"] = serverVersion.KAgentVersion } json.NewEncoder(os.Stdout).Encode(versionInfo) //nolint:errcheck diff --git a/go/core/cli/internal/cli/agentinstance/get.go b/go/core/cli/internal/cli/agentinstance/get.go index ed566e248..7da3049e2 100644 --- a/go/core/cli/internal/cli/agentinstance/get.go +++ b/go/core/cli/internal/cli/agentinstance/get.go @@ -14,7 +14,6 @@ import ( apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/core/cli/internal/cli/connection" clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -27,15 +26,16 @@ type getClient interface { // GetCfg configures AgentInstance get and list operations. type GetCfg struct { - Config *config.Config - InstanceID string - PageSize int32 - PageToken string + Connection *connection.Options + OutputFormat string + InstanceID string + PageSize int32 + PageToken string } // GetCmd gets one AgentInstance or lists the caller's AgentInstances. func GetCmd(ctx context.Context, cfg *GetCfg, out io.Writer) (err error) { - format, err := clioutput.Parse(cfg.Config.OutputFormat) + format, err := clioutput.Parse(cfg.OutputFormat) if err != nil { return err } @@ -43,7 +43,7 @@ func GetCmd(ctx context.Context, cfg *GetCfg, out io.Writer) (err error) { return err } - portForward, err := connection.Connect(ctx, cfg.Config) + portForward, err := connection.Connect(ctx, cfg.Connection) if err != nil { return fmt.Errorf("connect to kagent: %w", err) } @@ -51,11 +51,11 @@ func GetCmd(ctx context.Context, cfg *GetCfg, out io.Writer) (err error) { defer portForward.Stop() } - clientSet := cfg.Config.Client() + clientSet := cfg.Connection.Client() defer func() { err = errors.Join(err, clientSet.Close()) }() - return get(ctx, clientSet.AgentInstance, cfg, format, out) + return get(ctx, clientSet.AgentInstance, cfg.Connection.Namespace, cfg, format, out) } func validateGetCfg(cfg *GetCfg) error { @@ -79,13 +79,14 @@ func validateGetCfg(cfg *GetCfg) error { func get( ctx context.Context, client getClient, + namespace string, cfg *GetCfg, format clioutput.Format, out io.Writer, ) error { if cfg.InstanceID != "" { response, err := client.GetAgentInstance(ctx, &apiv1alpha1.GetAgentInstanceRequest{ - Namespace: cfg.Config.Namespace, AgentInstanceId: cfg.InstanceID, + Namespace: namespace, AgentInstanceId: cfg.InstanceID, }) if err != nil { return fmt.Errorf("get AgentInstance: %w", err) @@ -100,7 +101,7 @@ func get( } response, err := client.ListAgentInstances(ctx, &apiv1alpha1.ListAgentInstancesRequest{ - Namespace: cfg.Config.Namespace, + Namespace: namespace, Page: &apiv1alpha1.PageRequest{Limit: cfg.PageSize, PageToken: cfg.PageToken}, }) if err != nil { diff --git a/go/core/cli/internal/cli/agentinstance/get_test.go b/go/core/cli/internal/cli/agentinstance/get_test.go index 63beb4356..187ff01f8 100644 --- a/go/core/cli/internal/cli/agentinstance/get_test.go +++ b/go/core/cli/internal/cli/agentinstance/get_test.go @@ -9,7 +9,6 @@ import ( apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" @@ -46,10 +45,10 @@ func TestValidateGetCfg(t *testing.T) { func TestGetAgentInstanceTableUsesFullID(t *testing.T) { client := &fakeAgentInstanceClient{instance: testInstance(), nextPageToken: "next-page"} - cfg := &GetCfg{Config: &config.Config{Namespace: "kagent"}} + cfg := &GetCfg{} var output bytes.Buffer - require.NoError(t, get(t.Context(), client, cfg, clioutput.FormatTable, &output)) + require.NoError(t, get(t.Context(), client, "kagent", cfg, clioutput.FormatTable, &output)) assert.Equal(t, &apiv1alpha1.ListAgentInstancesRequest{ Namespace: "kagent", Page: &apiv1alpha1.PageRequest{}, }, client.listRequest) @@ -61,10 +60,10 @@ func TestGetAgentInstanceTableUsesFullID(t *testing.T) { func TestGetOneAgentInstanceJSON(t *testing.T) { client := &fakeAgentInstanceClient{instance: testInstance()} - cfg := &GetCfg{Config: &config.Config{Namespace: "kagent"}, InstanceID: testInstanceID} + cfg := &GetCfg{InstanceID: testInstanceID} var output bytes.Buffer - require.NoError(t, get(t.Context(), client, cfg, clioutput.FormatJSON, &output)) + require.NoError(t, get(t.Context(), client, "kagent", cfg, clioutput.FormatJSON, &output)) assert.Equal(t, testInstanceID, client.getRequest.GetAgentInstanceId()) assert.True(t, json.Valid(output.Bytes())) assert.Contains(t, output.String(), testInstanceID) @@ -73,11 +72,11 @@ func TestGetOneAgentInstanceJSON(t *testing.T) { func TestListAgentInstancesJSONPreservesNextPageToken(t *testing.T) { client := &fakeAgentInstanceClient{instance: testInstance(), nextPageToken: "next-page"} cfg := &GetCfg{ - Config: &config.Config{Namespace: "kagent"}, PageSize: 1, PageToken: "current-page", + PageSize: 1, PageToken: "current-page", } var output bytes.Buffer - require.NoError(t, get(t.Context(), client, cfg, clioutput.FormatJSON, &output)) + require.NoError(t, get(t.Context(), client, "kagent", cfg, clioutput.FormatJSON, &output)) assert.Equal(t, int32(1), client.listRequest.GetPage().GetLimit()) assert.Equal(t, "current-page", client.listRequest.GetPage().GetPageToken()) assert.True(t, json.Valid(output.Bytes())) diff --git a/go/core/cli/internal/cli/agentinstance/invoke.go b/go/core/cli/internal/cli/agentinstance/invoke.go index f35d6b71b..5dd404ad8 100644 --- a/go/core/cli/internal/cli/agentinstance/invoke.go +++ b/go/core/cli/internal/cli/agentinstance/invoke.go @@ -16,14 +16,14 @@ import ( clia2a "github.com/kagent-dev/kagent/go/core/cli/internal/a2a" "github.com/kagent-dev/kagent/go/core/cli/internal/cli/connection" clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" "google.golang.org/grpc/metadata" ) var errTruncatedA2AStream = errors.New("a2a stream ended before returning a final result") type InvokeCfg struct { - Config *config.Config + Connection *connection.Options + OutputFormat string Task string File string AgentInstance string @@ -32,7 +32,7 @@ type InvokeCfg struct { } func InvokeCmd(ctx context.Context, cfg *InvokeCfg, in io.Reader, out io.Writer) (err error) { - format, err := clioutput.Parse(cfg.Config.OutputFormat) + format, err := clioutput.Parse(cfg.OutputFormat) if err != nil { return err } @@ -48,7 +48,7 @@ func InvokeCmd(ctx context.Context, cfg *InvokeCfg, in io.Reader, out io.Writer) return errors.New("model API key must not contain whitespace") } - portForward, err := connection.Connect(ctx, cfg.Config) + portForward, err := connection.Connect(ctx, cfg.Connection) if err != nil { return fmt.Errorf("connect to kagent: %w", err) } @@ -56,11 +56,11 @@ func InvokeCmd(ctx context.Context, cfg *InvokeCfg, in io.Reader, out io.Writer) defer portForward.Stop() } - clientSet := cfg.Config.Client() + clientSet := cfg.Connection.Client() defer func() { err = errors.Join(err, clientSet.Close()) }() - a2aClient, err := clientSet.A2A.ForAgentInstance(ctx, cfg.Config.Namespace, instanceID.String()) + a2aClient, err := clientSet.A2A.ForAgentInstance(ctx, cfg.Connection.Namespace, instanceID.String()) if err != nil { return fmt.Errorf("create AgentInstance A2A client: %w", err) } @@ -152,10 +152,12 @@ func invokeStreaming( } result, streamErr := consumeA2AStream(client.SendStreamingMessage(ctx, request), onEvent) + return finishInvokeStream(tableWriter, result, streamErr) +} + +func finishInvokeStream(tableWriter *tableStreamWriter, result a2atype.SendMessageResult, streamErr error) error { if tableWriter != nil { - if err := tableWriter.Finish(result); err != nil { - return err - } + streamErr = errors.Join(streamErr, tableWriter.Finish(result)) } if streamErr != nil { return fmt.Errorf("invoke AgentInstance stream: %w", streamErr) diff --git a/go/core/cli/internal/cli/agentinstance/invoke_test.go b/go/core/cli/internal/cli/agentinstance/invoke_test.go index 2dc43e88c..a45f84c55 100644 --- a/go/core/cli/internal/cli/agentinstance/invoke_test.go +++ b/go/core/cli/internal/cli/agentinstance/invoke_test.go @@ -306,6 +306,19 @@ func TestConsumeA2AStreamPreservesTerminalError(t *testing.T) { require.NotNil(t, result) } +func TestFinishInvokeStreamPreservesStreamAndWriteErrors(t *testing.T) { + streamErr := errors.New("stream disconnected") + writeErr := errors.New("broken pipe") + result := &a2atype.Task{ + ID: "task-1", ContextID: "instance-1", Status: a2atype.TaskStatus{State: a2atype.TaskStateWorking}, + } + + err := finishInvokeStream(&tableStreamWriter{w: failingWriter{err: writeErr}, text: "partial"}, result, streamErr) + + require.ErrorIs(t, err, streamErr) + require.ErrorIs(t, err, writeErr) +} + type streamItem struct { event a2atype.Event err error @@ -326,6 +339,14 @@ type signalWriter struct { wrote chan struct{} } +type failingWriter struct { + err error +} + +func (w failingWriter) Write([]byte) (int, error) { + return 0, w.err +} + func (w *signalWriter) Write(p []byte) (int, error) { n, err := w.Buffer.Write(p) w.signal() diff --git a/go/core/cli/internal/cli/agentinstance/lifecycle.go b/go/core/cli/internal/cli/agentinstance/lifecycle.go index 72ee011a3..8889121cb 100644 --- a/go/core/cli/internal/cli/agentinstance/lifecycle.go +++ b/go/core/cli/internal/cli/agentinstance/lifecycle.go @@ -11,7 +11,6 @@ import ( apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/core/cli/internal/cli/connection" clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" @@ -24,7 +23,8 @@ type lifecycleClient interface { // CreateCfg configures AgentInstance creation. type CreateCfg struct { - Config *config.Config + Connection *connection.Options + OutputFormat string Harness string AgentTemplate string RequestID string @@ -32,13 +32,14 @@ type CreateCfg struct { // DeleteCfg configures AgentInstance deletion. type DeleteCfg struct { - Config *config.Config - InstanceID string + Connection *connection.Options + OutputFormat string + InstanceID string } // CreateCmd creates an AgentInstance. func CreateCmd(ctx context.Context, cfg *CreateCfg, out io.Writer) (err error) { - format, err := clioutput.Parse(cfg.Config.OutputFormat) + format, err := clioutput.Parse(cfg.OutputFormat) if err != nil { return err } @@ -46,7 +47,7 @@ func CreateCmd(ctx context.Context, cfg *CreateCfg, out io.Writer) (err error) { return err } - portForward, err := connection.Connect(ctx, cfg.Config) + portForward, err := connection.Connect(ctx, cfg.Connection) if err != nil { return fmt.Errorf("connect to kagent: %w", err) } @@ -54,24 +55,40 @@ func CreateCmd(ctx context.Context, cfg *CreateCfg, out io.Writer) (err error) { defer portForward.Stop() } - clientSet := cfg.Config.Client() + clientSet := cfg.Connection.Client() defer func() { err = errors.Join(err, clientSet.Close()) }() - return create(ctx, clientSet.AgentInstance, cfg, format, out) + return create(ctx, clientSet.AgentInstance, cfg.Connection.Namespace, cfg, format, out) +} + +func prepareCreateCfg(cfg *CreateCfg) error { + if cfg.Harness == "" { + return errors.New("harness is required") + } + if cfg.AgentTemplate == "" { + return errors.New("agent template is required") + } + if cfg.RequestID == "" { + cfg.RequestID = uuid.NewString() + return nil + } + if strings.TrimSpace(cfg.RequestID) != cfg.RequestID || len(cfg.RequestID) > 128 { + return errors.New("request ID must be 1-128 characters without surrounding whitespace") + } + return nil } // DeleteCmd deletes an AgentInstance. func DeleteCmd(ctx context.Context, cfg *DeleteCfg, out io.Writer) (err error) { - format, err := clioutput.Parse(cfg.Config.OutputFormat) + format, err := clioutput.Parse(cfg.OutputFormat) if err != nil { return err } if err := validateDeleteCfg(cfg); err != nil { return err } - - portForward, err := connection.Connect(ctx, cfg.Config) + portForward, err := connection.Connect(ctx, cfg.Connection) if err != nil { return fmt.Errorf("connect to kagent: %w", err) } @@ -79,28 +96,11 @@ func DeleteCmd(ctx context.Context, cfg *DeleteCfg, out io.Writer) (err error) { defer portForward.Stop() } - clientSet := cfg.Config.Client() + clientSet := cfg.Connection.Client() defer func() { err = errors.Join(err, clientSet.Close()) }() - return deleteAgentInstance(ctx, clientSet.AgentInstance, cfg, format, out) -} - -func prepareCreateCfg(cfg *CreateCfg) error { - if cfg.Harness == "" { - return errors.New("harness is required") - } - if cfg.AgentTemplate == "" { - return errors.New("agent template is required") - } - if cfg.RequestID == "" { - cfg.RequestID = uuid.NewString() - return nil - } - if strings.TrimSpace(cfg.RequestID) != cfg.RequestID || len(cfg.RequestID) > 128 { - return errors.New("request ID must be 1-128 characters without surrounding whitespace") - } - return nil + return deleteAgentInstance(ctx, clientSet.AgentInstance, cfg.Connection.Namespace, cfg, format, out) } func validateDeleteCfg(cfg *DeleteCfg) error { @@ -115,12 +115,13 @@ func validateDeleteCfg(cfg *DeleteCfg) error { func create( ctx context.Context, client lifecycleClient, + namespace string, cfg *CreateCfg, format clioutput.Format, out io.Writer, ) error { response, err := client.CreateAgentInstance(ctx, &apiv1alpha1.CreateAgentInstanceRequest{ - Namespace: cfg.Config.Namespace, Harness: cfg.Harness, + Namespace: namespace, Harness: cfg.Harness, AgentTemplate: cfg.AgentTemplate, RequestId: cfg.RequestID, }) if err != nil { @@ -135,12 +136,13 @@ func create( func deleteAgentInstance( ctx context.Context, client lifecycleClient, + namespace string, cfg *DeleteCfg, format clioutput.Format, out io.Writer, ) error { response, err := client.DeleteAgentInstance(ctx, &apiv1alpha1.DeleteAgentInstanceRequest{ - Namespace: cfg.Config.Namespace, AgentInstanceId: cfg.InstanceID, + Namespace: namespace, AgentInstanceId: cfg.InstanceID, }) if status.Code(err) == codes.Aborted { return fmt.Errorf("delete AgentInstance: another lifecycle operation is in progress; retry after it completes: %w", err) diff --git a/go/core/cli/internal/cli/agentinstance/lifecycle_test.go b/go/core/cli/internal/cli/agentinstance/lifecycle_test.go index 3cb41961c..b688d0af8 100644 --- a/go/core/cli/internal/cli/agentinstance/lifecycle_test.go +++ b/go/core/cli/internal/cli/agentinstance/lifecycle_test.go @@ -10,7 +10,6 @@ import ( "github.com/google/uuid" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" @@ -42,9 +41,7 @@ func TestPrepareCreateCfgValidation(t *testing.T) { } func TestCreateAgentInstanceGeneratedRequestIDIsStable(t *testing.T) { - cfg := &CreateCfg{ - Config: &config.Config{Namespace: "kagent"}, Harness: "kagent", AgentTemplate: "smoke", - } + cfg := &CreateCfg{Harness: "kagent", AgentTemplate: "smoke"} require.NoError(t, prepareCreateCfg(cfg)) requestID := cfg.RequestID require.NoError(t, uuid.Validate(requestID)) @@ -52,7 +49,7 @@ func TestCreateAgentInstanceGeneratedRequestIDIsStable(t *testing.T) { assert.Equal(t, requestID, cfg.RequestID) client := &lifecycleAgentInstanceClient{createInstance: testInstance()} - require.NoError(t, create(t.Context(), client, cfg, clioutput.FormatTable, &bytes.Buffer{})) + require.NoError(t, create(t.Context(), client, "kagent", cfg, clioutput.FormatTable, &bytes.Buffer{})) assert.Equal(t, requestID, client.createRequest.GetRequestId()) } @@ -68,13 +65,11 @@ func TestCreateAgentInstanceExplicitReplayIDAndOutput(t *testing.T) { t.Run(tt.name, func(t *testing.T) { client := &lifecycleAgentInstanceClient{createInstance: testInstance()} cfg := &CreateCfg{ - Config: &config.Config{Namespace: "kagent"}, Harness: "kagent", - AgentTemplate: "smoke", RequestID: "replay-1", + Harness: "kagent", AgentTemplate: "smoke", RequestID: "replay-1", } var output bytes.Buffer - require.NoError(t, prepareCreateCfg(cfg)) - require.NoError(t, create(t.Context(), client, cfg, tt.format, &output)) + require.NoError(t, create(t.Context(), client, "kagent", cfg, tt.format, &output)) assert.Equal(t, &apiv1alpha1.CreateAgentInstanceRequest{ Namespace: "kagent", Harness: "kagent", AgentTemplate: "smoke", RequestId: "replay-1", }, client.createRequest) @@ -92,11 +87,10 @@ func TestDeleteAgentInstance(t *testing.T) { client := &lifecycleAgentInstanceClient{deleteInstance: &apiv1alpha1.AgentInstance{ Id: testInstanceID, State: apiv1alpha1.AgentInstanceState_AGENT_INSTANCE_STATE_DELETED, }} - cfg := &DeleteCfg{Config: &config.Config{Namespace: "kagent"}, InstanceID: testInstanceID} + cfg := &DeleteCfg{InstanceID: testInstanceID} var output bytes.Buffer - require.NoError(t, validateDeleteCfg(cfg)) - require.NoError(t, deleteAgentInstance(t.Context(), client, cfg, clioutput.FormatTable, &output)) + require.NoError(t, deleteAgentInstance(t.Context(), client, "kagent", cfg, clioutput.FormatTable, &output)) assert.Equal(t, &apiv1alpha1.DeleteAgentInstanceRequest{ Namespace: "kagent", AgentInstanceId: testInstanceID, }, client.deleteRequest) @@ -111,9 +105,9 @@ func TestValidateDeleteCfg(t *testing.T) { func TestDeleteAgentInstanceAborted(t *testing.T) { client := &lifecycleAgentInstanceClient{deleteErr: status.Error(codes.Aborted, "conflict")} - cfg := &DeleteCfg{Config: &config.Config{Namespace: "kagent"}, InstanceID: testInstanceID} + cfg := &DeleteCfg{InstanceID: testInstanceID} - err := deleteAgentInstance(t.Context(), client, cfg, clioutput.FormatTable, &bytes.Buffer{}) + err := deleteAgentInstance(t.Context(), client, "kagent", cfg, clioutput.FormatTable, &bytes.Buffer{}) require.ErrorContains(t, err, "another lifecycle operation is in progress; retry after it completes") assert.Equal(t, codes.Aborted, status.Code(err)) } diff --git a/go/core/cli/internal/cli/agenttemplate/get.go b/go/core/cli/internal/cli/agenttemplate/get.go index 40a0527b1..d13576aef 100644 --- a/go/core/cli/internal/cli/agenttemplate/get.go +++ b/go/core/cli/internal/cli/agenttemplate/get.go @@ -14,7 +14,6 @@ import ( typedapiv1alpha3 "github.com/kagent-dev/kagent/go/api/clientset/versioned/typed/api/v1alpha3" apiv1alpha3 "github.com/kagent-dev/kagent/go/api/v1alpha3" clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/tools/clientcmd" @@ -24,15 +23,16 @@ const maxPageSize = 100 // GetCfg configures AgentTemplate get and list operations. type GetCfg struct { - Config *config.Config - Name string - PageSize int64 - PageToken string + Namespace string + OutputFormat string + Name string + PageSize int64 + PageToken string } // GetCmd gets one AgentTemplate or lists AgentTemplates through Kubernetes. func GetCmd(ctx context.Context, cfg *GetCfg, out io.Writer) error { - format, err := clioutput.Parse(cfg.Config.OutputFormat) + format, err := clioutput.Parse(cfg.OutputFormat) if err != nil { return err } @@ -51,12 +51,12 @@ func GetCmd(ctx context.Context, cfg *GetCfg, out io.Writer) error { if err != nil { return fmt.Errorf("create Kubernetes client: %w", err) } - return get(ctx, clients.ApiV1alpha3().AgentTemplates(cfg.Config.Namespace), cfg, format, out) + return get(ctx, clients.ApiV1alpha3().AgentTemplates(cfg.Namespace), cfg, format, out) } func validateGetCfg(cfg *GetCfg) error { if cfg.PageSize < 0 || cfg.PageSize > maxPageSize { - return fmt.Errorf("page size must be between 1 and %d, or 0 for the server default", maxPageSize) + return fmt.Errorf("page size must be between 1 and %d, or 0 for the default of %d", maxPageSize, maxPageSize) } if cfg.Name != "" && (cfg.PageSize != 0 || cfg.PageToken != "") { return errors.New("pagination flags cannot be used when getting one AgentTemplate") @@ -82,7 +82,11 @@ func get( return writeTemplatesTable(out, []apiv1alpha3.AgentTemplate{*template}, false, "") } - templates, err := client.List(ctx, metav1.ListOptions{Limit: cfg.PageSize, Continue: cfg.PageToken}) + pageSize := cfg.PageSize + if pageSize == 0 { + pageSize = maxPageSize + } + templates, err := client.List(ctx, metav1.ListOptions{Limit: pageSize, Continue: cfg.PageToken}) if err != nil { return fmt.Errorf("list AgentTemplates: %w", err) } diff --git a/go/core/cli/internal/cli/agenttemplate/get_test.go b/go/core/cli/internal/cli/agenttemplate/get_test.go index 20076fdde..743af6444 100644 --- a/go/core/cli/internal/cli/agenttemplate/get_test.go +++ b/go/core/cli/internal/cli/agenttemplate/get_test.go @@ -9,7 +9,6 @@ import ( clientfake "github.com/kagent-dev/kagent/go/api/clientset/versioned/fake" apiv1alpha3 "github.com/kagent-dev/kagent/go/api/v1alpha3" clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -62,7 +61,7 @@ func TestGetAgentTemplatesTableReportsHarnessReadiness(t *testing.T) { var output bytes.Buffer err := get(context.Background(), clientSet.ApiV1alpha3().AgentTemplates("kagent"), &GetCfg{ - Config: &config.Config{Namespace: "kagent"}, PageSize: 3, PageToken: "previous-page", + Namespace: "kagent", PageSize: 3, PageToken: "previous-page", }, clioutput.FormatTable, &output) require.NoError(t, err) assert.Contains(t, output.String(), "ready-template") @@ -78,7 +77,9 @@ func TestGetAgentTemplatesTableReportsHarnessReadiness(t *testing.T) { func TestGetAgentTemplatesJSONPreservesListMetadata(t *testing.T) { clientSet := clientfake.NewSimpleClientset() - clientSet.PrependReactor("list", "agenttemplates", func(k8stesting.Action) (bool, runtime.Object, error) { + clientSet.PrependReactor("list", "agenttemplates", func(action k8stesting.Action) (bool, runtime.Object, error) { + options := action.(interface{ GetListOptions() metav1.ListOptions }).GetListOptions() + assert.Equal(t, int64(maxPageSize), options.Limit) return true, &apiv1alpha3.AgentTemplateList{ ListMeta: metav1.ListMeta{Continue: "next-page"}, Items: []apiv1alpha3.AgentTemplate{ @@ -91,7 +92,7 @@ func TestGetAgentTemplatesJSONPreservesListMetadata(t *testing.T) { var output bytes.Buffer err := get(context.Background(), clientSet.ApiV1alpha3().AgentTemplates("kagent"), &GetCfg{ - Config: &config.Config{Namespace: "kagent"}, + Namespace: "kagent", }, clioutput.FormatJSON, &output) require.NoError(t, err) assert.True(t, json.Valid(output.Bytes())) diff --git a/go/core/cli/internal/cli/connection/connection.go b/go/core/cli/internal/cli/connection/connection.go index 33188a3e8..152bb0b17 100644 --- a/go/core/cli/internal/cli/connection/connection.go +++ b/go/core/cli/internal/cli/connection/connection.go @@ -12,21 +12,75 @@ import ( "strings" "sync" "time" + "unicode" "github.com/kagent-dev/kagent/go/api/client" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) -var ErrServerConnection = errors.New("error connecting to server") +var errServerConnection = errors.New("error connecting to server") const ( + defaultKAgentURL = "http://localhost:8083" + defaultKAgentGRPCURL = client.DefaultGRPCTarget + defaultUserID = "admin@kagent.dev" + portForwardReadyTimeout = 15 * time.Second portForwardRetryDelay = 100 * time.Millisecond kubectlErrorLimit = 8 << 10 ) +// Options contains only the settings needed to connect to kagent. +type Options struct { + KAgentURL string + KAgentGRPCURL string + KAgentGRPCTLS bool + KAgentGRPCCAFile string + KAgentGRPCServerName string + Namespace string + Verbose bool + Timeout time.Duration + UserID string +} + +func DefaultOptions() Options { + return Options{ + KAgentURL: defaultKAgentURL, + KAgentGRPCURL: defaultKAgentGRPCURL, + Namespace: "kagent", + Timeout: 300 * time.Second, + UserID: defaultUserID, + } +} + +func (o *Options) Client() *client.ClientSet { + clientOptions := []client.ClientOption{client.WithUserID(o.UserID)} + if o.KAgentGRPCURL != "" { + clientOptions = append(clientOptions, client.WithGRPCTarget(o.KAgentGRPCURL)) + } + if o.Timeout > 0 { + clientOptions = append(clientOptions, client.WithGRPCTimeout(o.Timeout)) + } + if o.KAgentGRPCTLS { + clientOptions = append(clientOptions, client.WithGRPCTLS(client.GRPCTLSConfig{ + CAFile: o.KAgentGRPCCAFile, + ServerName: o.KAgentGRPCServerName, + })) + } + return client.New(o.KAgentURL, clientOptions...) +} + +func (o *Options) validate() error { + if o.UserID == "" { + return errors.New("caller identity is required") + } + if strings.IndexFunc(o.UserID, unicode.IsSpace) >= 0 { + return errors.New("caller identity must not contain whitespace") + } + return nil +} + type connectionRuntime struct { checkServer func(context.Context, *client.ClientSet) error commandContext func(context.Context, string, ...string) *exec.Cmd @@ -36,34 +90,36 @@ type connectionRuntime struct { } var defaultConnectionRuntime = connectionRuntime{ - checkServer: CheckServer, + checkServer: checkServer, commandContext: exec.CommandContext, stderr: os.Stderr, readyTimeout: portForwardReadyTimeout, retryDelay: portForwardRetryDelay, } -// CheckServer checks whether the configured server is reachable. -func CheckServer(ctx context.Context, clientSet *client.ClientSet) error { +func checkServer(ctx context.Context, clientSet *client.ClientSet) error { if clientSet == nil { - return ErrServerConnection + return errServerConnection } ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if _, err := clientSet.Version.GetVersion(ctx); err != nil { - return fmt.Errorf("%w: %w", ErrServerConnection, err) + return fmt.Errorf("%w: %w", errServerConnection, err) } return nil } // Connect checks the configured server and starts a port-forward only for an // unreachable default local endpoint. -func Connect(ctx context.Context, cfg *config.Config) (*PortForward, error) { +func Connect(ctx context.Context, cfg *Options) (*PortForward, error) { return defaultConnectionRuntime.connect(ctx, cfg) } -func (r connectionRuntime) connect(ctx context.Context, cfg *config.Config) (*PortForward, error) { +func (r connectionRuntime) connect(ctx context.Context, cfg *Options) (*PortForward, error) { + if err := cfg.validate(); err != nil { + return nil, err + } if cfg.Verbose { fmt.Fprintf(r.stderr, "Using caller identity %q\n", cfg.UserID) } @@ -78,19 +134,19 @@ func (r connectionRuntime) connect(ctx context.Context, cfg *config.Config) (*Po return r.newPortForward(ctx, cfg) } -func shouldPortForward(cfg *config.Config, err error) bool { +func shouldPortForward(cfg *Options, err error) bool { grpcURL := cfg.KAgentGRPCURL if grpcURL == "" { - grpcURL = config.DefaultKAgentGRPCURL + grpcURL = defaultKAgentGRPCURL } - if cfg.KAgentGRPCTLS || grpcURL != config.DefaultKAgentGRPCURL || strings.TrimRight(cfg.KAgentURL, "/") != config.DefaultKAgentURL { + if cfg.KAgentGRPCTLS || grpcURL != defaultKAgentGRPCURL || strings.TrimRight(cfg.KAgentURL, "/") != defaultKAgentURL { return false } code := status.Code(err) return code == codes.Unavailable || code == codes.DeadlineExceeded || errors.Is(err, context.DeadlineExceeded) } -func (r connectionRuntime) checkConfiguredServer(ctx context.Context, cfg *config.Config) (err error) { +func (r connectionRuntime) checkConfiguredServer(ctx context.Context, cfg *Options) (err error) { clientSet := cfg.Client() defer func() { err = errors.Join(err, clientSet.Close()) @@ -107,11 +163,11 @@ type PortForward struct { } // NewPortForward starts a port-forward and waits for the server to become reachable. -func NewPortForward(ctx context.Context, cfg *config.Config) (*PortForward, error) { +func NewPortForward(ctx context.Context, cfg *Options) (*PortForward, error) { return defaultConnectionRuntime.newPortForward(ctx, cfg) } -func (r connectionRuntime) newPortForward(ctx context.Context, cfg *config.Config) (*PortForward, error) { +func (r connectionRuntime) newPortForward(ctx context.Context, cfg *Options) (*PortForward, error) { ctx, cancel := context.WithCancel(ctx) cmd := r.commandContext(ctx, "kubectl", "-n", cfg.Namespace, "port-forward", "service/kagent-controller", "8083:8083", "8084:8084") stderr := newBoundedBuffer(kubectlErrorLimit) @@ -155,7 +211,7 @@ func (r connectionRuntime) newPortForward(ctx context.Context, cfg *config.Confi func portForwardExitedError(processErr, serverErr error, stderr string) error { cause := errors.Join(processErr, serverErr) if cause == nil { - cause = ErrServerConnection + cause = errServerConnection } return fmt.Errorf("kubectl port-forward exited before the server became ready%s: %w", kubectlDetails(stderr), cause) } diff --git a/go/core/cli/internal/cli/connection/connection_test.go b/go/core/cli/internal/cli/connection/connection_test.go index 9f42d50c4..cd8889470 100644 --- a/go/core/cli/internal/cli/connection/connection_test.go +++ b/go/core/cli/internal/cli/connection/connection_test.go @@ -13,7 +13,6 @@ import ( "github.com/kagent-dev/kagent/go/api/client" api "github.com/kagent-dev/kagent/go/api/httpapi" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" @@ -30,30 +29,30 @@ func (c failingVersionClient) GetVersion(context.Context) (*api.VersionResponse, func TestCheckServerPreservesCause(t *testing.T) { permissionErr := status.Error(codes.PermissionDenied, "denied") - err := CheckServer(t.Context(), &client.ClientSet{Version: failingVersionClient{err: permissionErr}}) + err := checkServer(t.Context(), &client.ClientSet{Version: failingVersionClient{err: permissionErr}}) require.Error(t, err) - assert.ErrorIs(t, err, ErrServerConnection) + assert.ErrorIs(t, err, errServerConnection) assert.Equal(t, codes.PermissionDenied, status.Code(err)) } func TestShouldPortForward(t *testing.T) { - defaultConfig := config.Config{KAgentURL: config.DefaultKAgentURL, KAgentGRPCURL: config.DefaultKAgentGRPCURL} + defaultConfig := Options{KAgentURL: defaultKAgentURL, KAgentGRPCURL: defaultKAgentGRPCURL} tests := []struct { name string - config config.Config + config Options err error want bool }{ {name: "default endpoint unavailable", config: defaultConfig, err: status.Error(codes.Unavailable, "offline"), want: true}, {name: "default endpoint gRPC deadline", config: defaultConfig, err: status.Error(codes.DeadlineExceeded, "deadline"), want: true}, {name: "default endpoint context deadline", config: defaultConfig, err: context.DeadlineExceeded, want: true}, - {name: "empty gRPC endpoint uses client default", config: config.Config{KAgentURL: config.DefaultKAgentURL}, err: status.Error(codes.Unavailable, "offline"), want: true}, + {name: "empty gRPC endpoint uses client default", config: Options{KAgentURL: defaultKAgentURL}, err: status.Error(codes.Unavailable, "offline"), want: true}, {name: "authentication failure", config: defaultConfig, err: status.Error(codes.Unauthenticated, "unauthenticated")}, {name: "authorization failure", config: defaultConfig, err: status.Error(codes.PermissionDenied, "denied")}, - {name: "explicit TLS", config: config.Config{KAgentURL: config.DefaultKAgentURL, KAgentGRPCURL: config.DefaultKAgentGRPCURL, KAgentGRPCTLS: true}, err: status.Error(codes.Unavailable, "TLS failed")}, - {name: "explicit gRPC endpoint", config: config.Config{KAgentURL: config.DefaultKAgentURL, KAgentGRPCURL: "api.example.test:443"}, err: status.Error(codes.Unavailable, "offline")}, - {name: "explicit HTTP endpoint", config: config.Config{KAgentURL: "https://api.example.test", KAgentGRPCURL: config.DefaultKAgentGRPCURL}, err: status.Error(codes.Unavailable, "offline")}, + {name: "explicit TLS", config: Options{KAgentURL: defaultKAgentURL, KAgentGRPCURL: defaultKAgentGRPCURL, KAgentGRPCTLS: true}, err: status.Error(codes.Unavailable, "TLS failed")}, + {name: "explicit gRPC endpoint", config: Options{KAgentURL: defaultKAgentURL, KAgentGRPCURL: "api.example.test:443"}, err: status.Error(codes.Unavailable, "offline")}, + {name: "explicit HTTP endpoint", config: Options{KAgentURL: "https://api.example.test", KAgentGRPCURL: defaultKAgentGRPCURL}, err: status.Error(codes.Unavailable, "offline")}, {name: "other error", config: defaultConfig, err: errors.New("invalid CA")}, } @@ -94,21 +93,21 @@ func TestConnectionRuntimeConnectStartsPortForwardAndRedials(t *testing.T) { func TestConnectionRuntimeConnectDoesNotPortForwardExplicitFailures(t *testing.T) { tests := []struct { name string - config *config.Config + config *Options err error }{ {name: "authentication failure", config: defaultTestConfig(false), err: status.Error(codes.Unauthenticated, "unauthenticated")}, - {name: "TLS endpoint", config: func() *config.Config { + {name: "TLS endpoint", config: func() *Options { cfg := defaultTestConfig(false) cfg.KAgentGRPCTLS = true return cfg }(), err: status.Error(codes.Unavailable, "TLS failed")}, - {name: "remote gRPC endpoint", config: func() *config.Config { + {name: "remote gRPC endpoint", config: func() *Options { cfg := defaultTestConfig(false) cfg.KAgentGRPCURL = "api.example.test:443" return cfg }(), err: status.Error(codes.Unavailable, "offline")}, - {name: "remote HTTP endpoint", config: func() *config.Config { + {name: "remote HTTP endpoint", config: func() *Options { cfg := defaultTestConfig(false) cfg.KAgentURL = "https://api.example.test" return cfg @@ -133,6 +132,20 @@ func TestConnectionRuntimeConnectDoesNotPortForwardExplicitFailures(t *testing.T } } +func TestConnectionRuntimeConnectRejectsInvalidClientConfig(t *testing.T) { + runtime := testConnectionRuntime(t, "wait", func(context.Context, *client.ClientSet) error { + t.Fatal("server must not be checked with invalid client configuration") + return nil + }) + cfg := defaultTestConfig(false) + cfg.UserID = "invalid user" + + portForward, err := runtime.connect(t.Context(), cfg) + require.Error(t, err) + assert.Nil(t, portForward) + assert.Contains(t, err.Error(), "caller identity") +} + func TestConnectionRuntimeNewPortForwardReportsStartFailure(t *testing.T) { runtime := testConnectionRuntime(t, "wait", func(context.Context, *client.ClientSet) error { t.Fatal("health probe must not run when kubectl cannot start") @@ -227,10 +240,10 @@ func testConnectionRuntime( } } -func defaultTestConfig(verbose bool) *config.Config { - return &config.Config{ - KAgentURL: config.DefaultKAgentURL, - KAgentGRPCURL: config.DefaultKAgentGRPCURL, +func defaultTestConfig(verbose bool) *Options { + return &Options{ + KAgentURL: defaultKAgentURL, + KAgentGRPCURL: defaultKAgentGRPCURL, Namespace: "kagent", UserID: "test-user", Verbose: verbose, diff --git a/go/core/cli/internal/cli/mcp/add_tool.go b/go/core/cli/internal/cli/mcp/add_tool.go index 007e31aa5..b5ca1faeb 100644 --- a/go/core/cli/internal/cli/mcp/add_tool.go +++ b/go/core/cli/internal/cli/mcp/add_tool.go @@ -8,7 +8,6 @@ import ( "strings" commonfs "github.com/kagent-dev/kagent/go/core/cli/internal/common/fs" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" "github.com/kagent-dev/kagent/go/core/cli/internal/mcp" "github.com/kagent-dev/kagent/go/core/cli/internal/mcp/frameworks" "github.com/kagent-dev/kagent/go/core/cli/internal/mcp/manifests" @@ -20,14 +19,10 @@ type AddToolCfg struct { Force bool Interactive bool ProjectDir string + Verbose bool } func AddToolMcp(cfg *AddToolCfg, toolName string) error { - appCfg, err := config.Get() - if err != nil { - return fmt.Errorf("failed to get config: %w", err) - } - // Validate tool name if err := validateToolName(toolName); err != nil { return fmt.Errorf("invalid tool name: %w", err) @@ -63,7 +58,7 @@ func AddToolMcp(cfg *AddToolCfg, toolName string) error { toolPath := filepath.Join("src", "tools", toolName+".py") toolExists := commonfs.FileExists(toolPath) - if appCfg.Verbose { + if cfg.Verbose { fmt.Printf("Tool file path: %s\n", toolPath) fmt.Printf("Tool exists: %v\n", toolExists) } @@ -148,11 +143,7 @@ func createToolInteractive(cfg *AddToolCfg, toolName, projectRoot, framework str } func createTool(cfg *AddToolCfg, toolName, projectRoot, framework string) error { - appCfg, err := config.Get() - if err != nil { - return fmt.Errorf("failed to get config: %w", err) - } - if appCfg.Verbose { + if cfg.Verbose { fmt.Printf("Creating tool: %s\n", toolName) } diff --git a/go/core/cli/internal/cli/mcp/build.go b/go/core/cli/internal/cli/mcp/build.go index 059e2a694..14e1f3de1 100644 --- a/go/core/cli/internal/cli/mcp/build.go +++ b/go/core/cli/internal/cli/mcp/build.go @@ -10,7 +10,6 @@ import ( commonexec "github.com/kagent-dev/kagent/go/core/cli/internal/common/exec" commonk8s "github.com/kagent-dev/kagent/go/core/cli/internal/common/k8s" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" "github.com/kagent-dev/kagent/go/core/cli/internal/mcp/builder" "github.com/kagent-dev/kagent/go/core/cli/internal/mcp/manifests" ) @@ -23,14 +22,10 @@ type BuildCfg struct { ProjectDir string Platform string KindLoadCluster string + Verbose bool } func BuildMcp(cfg *BuildCfg) error { - appCfg, err := config.Get() - if err != nil { - return fmt.Errorf("failed to get config: %w", err) - } - // Determine build directory buildDirectory := cfg.ProjectDir if buildDirectory == "" { @@ -70,7 +65,7 @@ func BuildMcp(cfg *BuildCfg) error { ProjectDir: buildDirectory, Tag: imageName, Platform: cfg.Platform, - Verbose: appCfg.Verbose, + Verbose: cfg.Verbose, } if err := mcpBuilder.Build(opts); err != nil { @@ -79,7 +74,7 @@ func BuildMcp(cfg *BuildCfg) error { if cfg.Push { fmt.Printf("Pushing Docker image %s...\n", imageName) - docker := commonexec.NewDockerExecutor(appCfg.Verbose, "") + docker := commonexec.NewDockerExecutor(cfg.Verbose, "") if err := docker.Push(imageName); err != nil { return fmt.Errorf("docker push failed: %w", err) } @@ -92,7 +87,7 @@ func BuildMcp(cfg *BuildCfg) error { var err error clusterName, err = commonk8s.GetCurrentKindClusterName() if err != nil { - if appCfg.Verbose { + if cfg.Verbose { fmt.Printf("could not detect kind cluster name: %v, using default\n", err) } clusterName = "kind" // default to kind cluster @@ -101,7 +96,7 @@ func BuildMcp(cfg *BuildCfg) error { kindArgs = append(kindArgs, "--name", clusterName) - if err := runKind(kindArgs...); err != nil { + if err := runKind(cfg.Verbose, kindArgs...); err != nil { return fmt.Errorf("kind load failed: %w", err) } fmt.Printf("✅ Docker image loaded into kind cluster %s\n", clusterName) @@ -110,12 +105,8 @@ func BuildMcp(cfg *BuildCfg) error { return nil } -func runKind(args ...string) error { - cfg, err := config.Get() - if err != nil { - return fmt.Errorf("failed to get config: %w", err) - } - if cfg.Verbose { +func runKind(verbose bool, args ...string) error { + if verbose { fmt.Printf("Running: kind %s\n", strings.Join(args, " ")) } cmd := exec.Command("kind", args...) diff --git a/go/core/cli/internal/cli/mcp/deploy.go b/go/core/cli/internal/cli/mcp/deploy.go index 97dacf1ed..63ca625f4 100644 --- a/go/core/cli/internal/cli/mcp/deploy.go +++ b/go/core/cli/internal/cli/mcp/deploy.go @@ -14,7 +14,6 @@ import ( commonexec "github.com/kagent-dev/kagent/go/core/cli/internal/common/exec" commonk8s "github.com/kagent-dev/kagent/go/core/cli/internal/common/k8s" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" "github.com/kagent-dev/kagent/go/core/cli/internal/mcp/manifests" "github.com/kagent-dev/kmcp/api/v1alpha1" ) @@ -39,6 +38,7 @@ type DeployCfg struct { File string Environment string NoInspector bool + Verbose bool // Package subcommand specific PackageManager string PackageName string @@ -165,11 +165,6 @@ func DeployMcp(cfg *DeployCfg) error { var projectDir string var err error - appCfg, err := config.Get() - if err != nil { - return fmt.Errorf("failed to get config: %w", err) - } - if cfg.File != "" { // Use specified file path projectDir, err = getProjectDirFromFile(cfg.File) @@ -207,7 +202,7 @@ func DeployMcp(cfg *DeployCfg) error { // Set namespace mcpServer.Namespace = cfg.Namespace - if appCfg.Verbose { + if cfg.Verbose { fmt.Printf("Generated MCPServer: %s/%s\n", mcpServer.Namespace, mcpServer.Name) } @@ -442,13 +437,8 @@ func parseEnvVars(envVars []string) map[string]string { } func applyToCluster(cfg *DeployCfg, projectDir, yamlContent string, mcpServer *v1alpha1.MCPServer) error { - appCfg, err := config.Get() - if err != nil { - return fmt.Errorf("failed to get config: %w", err) - } - // Create kubectl executor with namespace and verbose settings - kubectl := commonexec.NewKubectlExecutor(appCfg.Verbose, mcpServer.Namespace) + kubectl := commonexec.NewKubectlExecutor(cfg.Verbose, mcpServer.Namespace) fmt.Printf("🚀 Applying MCPServer to cluster...\n") @@ -486,18 +476,18 @@ func applyToCluster(cfg *DeployCfg, projectDir, yamlContent string, mcpServer *v "url": fmt.Sprintf("http://localhost:%d/mcp", port), } configPath = filepath.Join(projectDir, "mcp-server-config.json") - if err := createMCPInspectorConfig(mcpServer.Name, serverConfig, configPath); err != nil { + if err := createMCPInspectorConfig(mcpServer.Name, serverConfig, configPath, cfg.Verbose); err != nil { return fmt.Errorf("failed to create inspector config: %w", err) } - if err := runInspector(mcpServer, configPath, projectDir); err != nil { + if err := runInspector(mcpServer, configPath, projectDir, cfg.Verbose); err != nil { return fmt.Errorf("failed to run inspector: %w", err) } } return nil } -func runInspector(mcpServer *v1alpha1.MCPServer, configPath string, projectDir string) error { +func runInspector(mcpServer *v1alpha1.MCPServer, configPath string, projectDir string, verbose bool) error { // Check if npx is installed if err := checkNpxInstalled(); err != nil { return err @@ -517,7 +507,7 @@ func runInspector(mcpServer *v1alpha1.MCPServer, configPath string, projectDir s }() // Run the inspector - return runMCPInspector(configPath, mcpServer.Name, projectDir) + return runMCPInspector(configPath, mcpServer.Name, projectDir, verbose) } func runPortForward(mcpServer *v1alpha1.MCPServer) (*exec.Cmd, error) { diff --git a/go/core/cli/internal/cli/mcp/inspector.go b/go/core/cli/internal/cli/mcp/inspector.go index c5e92f3a8..52a3efbcf 100644 --- a/go/core/cli/internal/cli/mcp/inspector.go +++ b/go/core/cli/internal/cli/mcp/inspector.go @@ -5,8 +5,6 @@ import ( "fmt" "os" "os/exec" - - "github.com/kagent-dev/kagent/go/core/cli/internal/config" ) func checkNpxInstalled() error { @@ -18,12 +16,7 @@ func checkNpxInstalled() error { } // createMCPInspectorConfig creates an MCP inspector configuration file -func createMCPInspectorConfig(serverName string, serverConfig map[string]any, configPath string) error { - cfg, err := config.Get() - if err != nil { - return fmt.Errorf("failed to get config: %w", err) - } - +func createMCPInspectorConfig(serverName string, serverConfig map[string]any, configPath string, verbose bool) error { config := map[string]any{ "mcpServers": map[string]any{ serverName: serverConfig, @@ -39,7 +32,7 @@ func createMCPInspectorConfig(serverName string, serverConfig map[string]any, co return fmt.Errorf("failed to write mcp-server-config.json: %w", err) } - if cfg.Verbose { + if verbose { fmt.Printf("Created mcp-server-config.json: %s\n", configPath) fmt.Printf("Config content:\n%s\n", string(configData)) } @@ -58,19 +51,14 @@ func createMCPInspectorConfig(serverName string, serverConfig map[string]any, co } // runMCPInspector runs the MCP inspector with the given configuration -func runMCPInspector(configPath, serverName string, workingDir string) error { - cfg, err := config.Get() - if err != nil { - return fmt.Errorf("failed to get config: %w", err) - } - +func runMCPInspector(configPath, serverName string, workingDir string, verbose bool) error { args := []string{ "@modelcontextprotocol/inspector", "--config", configPath, "--server", serverName, } - if cfg.Verbose { + if verbose { fmt.Printf("Running: npx %s\n", args) } diff --git a/go/core/cli/internal/cli/mcp/root.go b/go/core/cli/internal/cli/mcp/root.go index f5c616c6a..b99091144 100644 --- a/go/core/cli/internal/cli/mcp/root.go +++ b/go/core/cli/internal/cli/mcp/root.go @@ -27,6 +27,15 @@ Model Context Protocol servers with dynamic tool loading.`, return mcpCmd } +func setVerbose(cmd *cobra.Command, target *bool) error { + verbose, err := cmd.Flags().GetBool("verbose") + if err != nil { + return err + } + *target = verbose + return nil +} + func newInitCmd() *cobra.Command { cfg := &InitMcpCfg{} @@ -167,7 +176,10 @@ MCP server Docker image. Examples: kagent mcp build # Build Docker image from current directory kagent mcp build --project-dir ./my-project # Build Docker image from specific directory`, - RunE: func(_ *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { + if err := setVerbose(cmd, &cfg.Verbose); err != nil { + return err + } return BuildMcp(cfg) }, } @@ -220,7 +232,10 @@ Examples: kagent mcp deploy --file /path/to/manifest.yaml # Use custom manifest.yaml file kagent mcp deploy --environment staging # Target environment for deployment (e.g., staging, production)`, Args: cobra.MaximumNArgs(1), - RunE: func(_ *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { + if err := setVerbose(cmd, &cfg.Verbose); err != nil { + return err + } return DeployMcp(cfg) }, } @@ -260,7 +275,10 @@ Examples: kagent mcp deploy package --deployment-name my-server --manager npx --args my-package --no-inspector # Deploy without starting inspector kagent mcp deploy package --deployment-name my-server --manager uvx --args mcp-server-git # Use UV and write managed tools and installables to /tmp directories`, Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { + if err := setVerbose(cmd, &packageCfg.Verbose); err != nil { + return err + } return PackageDeployMcp(packageCfg) }, } @@ -307,7 +325,10 @@ Examples: kagent mcp add-tool weather --force # Overwrite existing tool `, Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { + if err := setVerbose(cmd, &cfg.Verbose); err != nil { + return err + } return AddToolMcp(cfg, args[0]) }, } @@ -344,7 +365,10 @@ Examples: kagent run mcp --project-dir ./my-project # Run with inspector (default) kagent run mcp --no-inspector # Run server directly without inspector kagent run mcp --transport http # Run with HTTP transport`, - RunE: func(_ *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { + if err := setVerbose(cmd, &cfg.Verbose); err != nil { + return err + } return RunMcp(cfg) }, } diff --git a/go/core/cli/internal/cli/mcp/run.go b/go/core/cli/internal/cli/mcp/run.go index eb46f7166..f7977d7a7 100644 --- a/go/core/cli/internal/cli/mcp/run.go +++ b/go/core/cli/internal/cli/mcp/run.go @@ -6,7 +6,6 @@ import ( "os/exec" "path/filepath" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" "github.com/kagent-dev/kagent/go/core/cli/internal/mcp/manifests" ) @@ -15,6 +14,7 @@ type RunCfg struct { ProjectDir string NoInspector bool Transport string + Verbose bool } func RunMcp(cfg *RunCfg) error { @@ -59,12 +59,8 @@ func runFastMCPPython(cfg *RunCfg, projectDir string, manifest *manifests.Projec ) } - appCfg, err := config.Get() - if err != nil { - return fmt.Errorf("failed to get config: %w", err) - } // Run uv sync first - if appCfg.Verbose { + if cfg.Verbose { fmt.Printf("Running uv sync in: %s\n", projectDir) } syncCmd := exec.Command("uv", "sync") @@ -97,12 +93,12 @@ func runFastMCPPython(cfg *RunCfg, projectDir string, manifest *manifests.Projec // Create MCP inspector config configPath := filepath.Join(projectDir, "mcp-server-config.json") - if err := createMCPInspectorConfig(manifest.Name, serverConfig, configPath); err != nil { + if err := createMCPInspectorConfig(manifest.Name, serverConfig, configPath, cfg.Verbose); err != nil { return err } // Run the inspector - return runMCPInspector(configPath, manifest.Name, projectDir) + return runMCPInspector(configPath, manifest.Name, projectDir, cfg.Verbose) } func runMCPGo(cfg *RunCfg, projectDir string, manifest *manifests.ProjectManifest) error { @@ -112,12 +108,8 @@ func runMCPGo(cfg *RunCfg, projectDir string, manifest *manifests.ProjectManifes return fmt.Errorf("go is required to run mcp-go projects locally. Please install Go: %s", goInstallURL) } - appCfg, err := config.Get() - if err != nil { - return fmt.Errorf("failed to get config: %w", err) - } // Run go mod tidy first to ensure dependencies are up to date - if appCfg.Verbose { + if cfg.Verbose { fmt.Printf("Running go mod tidy in: %s\n", projectDir) } tidyCmd := exec.Command("go", "mod", "tidy") @@ -150,19 +142,15 @@ func runMCPGo(cfg *RunCfg, projectDir string, manifest *manifests.ProjectManifes // Create MCP inspector config configPath := filepath.Join(projectDir, "mcp-server-config.json") - if err := createMCPInspectorConfig(manifest.Name, serverConfig, configPath); err != nil { + if err := createMCPInspectorConfig(manifest.Name, serverConfig, configPath, cfg.Verbose); err != nil { return err } // Run the inspector - return runMCPInspector(configPath, manifest.Name, projectDir) + return runMCPInspector(configPath, manifest.Name, projectDir, cfg.Verbose) } func getProjectDir(cfg *RunCfg) (string, error) { - appCfg, err := config.Get() - if err != nil { - return "", fmt.Errorf("failed to get config: %w", err) - } // Determine project directory dir := cfg.ProjectDir if dir == "" { @@ -183,7 +171,7 @@ func getProjectDir(cfg *RunCfg) (string, error) { } } - if appCfg.Verbose { + if cfg.Verbose { fmt.Printf("Using project directory: %s\n", dir) } @@ -213,12 +201,8 @@ func runTypeScript(cfg *RunCfg, projectDir string, manifest *manifests.ProjectMa return fmt.Errorf("npm is required to run TypeScript projects locally. Please install Node.js and npm: %s", npmInstallURL) } - appCfg, err := config.Get() - if err != nil { - return fmt.Errorf("failed to get config: %w", err) - } // Install dependencies first - if appCfg.Verbose { + if cfg.Verbose { fmt.Printf("Installing dependencies in: %s\n", projectDir) } installCmd := exec.Command("npm", "install") @@ -251,12 +235,12 @@ func runTypeScript(cfg *RunCfg, projectDir string, manifest *manifests.ProjectMa // Create MCP inspector config configPath := filepath.Join(projectDir, "mcp-server-config.json") - if err := createMCPInspectorConfig(manifest.Name, serverConfig, configPath); err != nil { + if err := createMCPInspectorConfig(manifest.Name, serverConfig, configPath, cfg.Verbose); err != nil { return err } // Run the inspector - return runMCPInspector(configPath, manifest.Name, projectDir) + return runMCPInspector(configPath, manifest.Name, projectDir, cfg.Verbose) } func runJava(cfg *RunCfg, projectDir string, manifest *manifests.ProjectManifest) error { @@ -266,12 +250,8 @@ func runJava(cfg *RunCfg, projectDir string, manifest *manifests.ProjectManifest return fmt.Errorf("mvn is required to run Java projects locally. Please install Maven: %s", mvnInstallURL) } - appCfg, err := config.Get() - if err != nil { - return fmt.Errorf("failed to get config: %w", err) - } // Run mvn clean install first to ensure dependencies are up to date - if appCfg.Verbose { + if cfg.Verbose { fmt.Printf("Running mvn clean install in: %s\n", projectDir) } installCmd := exec.Command("mvn", "clean", "install", "-DskipTests") @@ -325,10 +305,10 @@ func runJava(cfg *RunCfg, projectDir string, manifest *manifests.ProjectManifest // Create MCP inspector config configPath := filepath.Join(projectDir, "mcp-server-config.json") - if err := createMCPInspectorConfig(manifest.Name, serverConfig, configPath); err != nil { + if err := createMCPInspectorConfig(manifest.Name, serverConfig, configPath, cfg.Verbose); err != nil { return err } // Run the inspector - return runMCPInspector(configPath, manifest.Name, projectDir) + return runMCPInspector(configPath, manifest.Name, projectDir, cfg.Verbose) } diff --git a/go/core/cli/internal/config/config.go b/go/core/cli/internal/config/config.go deleted file mode 100644 index 635565193..000000000 --- a/go/core/cli/internal/config/config.go +++ /dev/null @@ -1,119 +0,0 @@ -package config - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "time" - "unicode" - - kagentclient "github.com/kagent-dev/kagent/go/api/client" - - "github.com/spf13/pflag" - "github.com/spf13/viper" -) - -const ( - // DefaultKAgentURL is the local HTTP endpoint created by the CLI port-forward. - DefaultKAgentURL = "http://localhost:8083" - // DefaultKAgentGRPCURL is the local endpoint eligible for automatic port-forward fallback. - DefaultKAgentGRPCURL = kagentclient.DefaultGRPCTarget - // DefaultUserID preserves the caller identity used before authentication is configured. - DefaultUserID = "admin@kagent.dev" -) - -type Config struct { - KAgentURL string `mapstructure:"kagent_url"` - KAgentGRPCURL string `mapstructure:"kagent_grpc_url"` - KAgentGRPCTLS bool `mapstructure:"kagent_grpc_tls"` - KAgentGRPCCAFile string `mapstructure:"kagent_grpc_ca_file"` - KAgentGRPCServerName string `mapstructure:"kagent_grpc_server_name"` - Namespace string `mapstructure:"namespace"` - OutputFormat string `mapstructure:"output_format"` - Verbose bool `mapstructure:"verbose"` - Timeout time.Duration `mapstructure:"timeout"` - UserID string `mapstructure:"user_id"` -} - -func (c *Config) Client() *kagentclient.ClientSet { - options := []kagentclient.ClientOption{ - kagentclient.WithUserID(c.UserID), - } - if c.KAgentGRPCURL != "" { - options = append(options, kagentclient.WithGRPCTarget(c.KAgentGRPCURL)) - } - if c.Timeout > 0 { - options = append(options, kagentclient.WithGRPCTimeout(c.Timeout)) - } - if c.KAgentGRPCTLS { - options = append(options, kagentclient.WithGRPCTLS(kagentclient.GRPCTLSConfig{ - CAFile: c.KAgentGRPCCAFile, - ServerName: c.KAgentGRPCServerName, - })) - } - return kagentclient.New(c.KAgentURL, options...) -} - -// Validate rejects configuration that cannot be transported safely. -func (c *Config) Validate() error { - if c.UserID == "" { - return errors.New("caller identity is required") - } - if strings.IndexFunc(c.UserID, unicode.IsSpace) >= 0 { - return errors.New("caller identity must not contain whitespace") - } - return nil -} - -func Init() error { - home, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("error getting user home directory: %w", err) - } - - configFile := filepath.Join(home, ".kagent", "config.yaml") - - viper.SetConfigFile(configFile) - viper.SetConfigType("yaml") - - pflag.StringVar(&configFile, "config", configFile, "config file (default is $HOME/.kagent/config.yaml)") - - // Set default values - viper.SetDefault("kagent_url", DefaultKAgentURL) - viper.SetDefault("kagent_grpc_url", DefaultKAgentGRPCURL) - viper.SetDefault("kagent_grpc_tls", false) - viper.SetDefault("output_format", "table") - viper.SetDefault("namespace", "kagent") - viper.SetDefault("timeout", 300*time.Second) - viper.SetDefault("user_id", DefaultUserID) - viper.MustBindEnv("kagent_url", "KAGENT_URL") - viper.MustBindEnv("kagent_grpc_url", "KAGENT_GRPC_URL") - viper.MustBindEnv("kagent_grpc_tls", "KAGENT_GRPC_TLS") - viper.MustBindEnv("kagent_grpc_ca_file", "KAGENT_GRPC_CA_FILE") - viper.MustBindEnv("kagent_grpc_server_name", "KAGENT_GRPC_SERVER_NAME") - viper.MustBindEnv("output_format", "KAGENT_OUTPUT_FORMAT") - viper.MustBindEnv("user_id", "KAGENT_USER_ID") - - if err := viper.ReadInConfig(); err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); !ok && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("error reading config file: %w", err) - } - } - return nil -} - -func Get() (*Config, error) { - var config Config - if err := viper.Unmarshal(&config); err != nil { - return nil, fmt.Errorf("error unmarshaling config: %w", err) - } - if config.UserID == "" { - config.UserID = DefaultUserID - } - if err := config.Validate(); err != nil { - return nil, fmt.Errorf("invalid config: %w", err) - } - return &config, nil -} diff --git a/go/core/cli/internal/config/utils.go b/go/core/cli/internal/config/utils.go deleted file mode 100644 index 78b0e0a43..000000000 --- a/go/core/cli/internal/config/utils.go +++ /dev/null @@ -1,74 +0,0 @@ -package config - -import ( - "fmt" - "os" - "path" - - "github.com/abiosoft/ishell/v2" - "github.com/fatih/color" - "github.com/kagent-dev/kagent/go/api/client" -) - -const ( - configKey = "[config]" - clientKey = "[client]" -) - -func SetCfg(shell *ishell.Shell, cfg *Config) { - shell.Set(configKey, cfg) -} - -func SetClient(shell *ishell.Shell, client *client.ClientSet) { - shell.Set(clientKey, client) -} - -func GetCfg(shell *ishell.Context) *Config { - return shell.Get(configKey).(*Config) -} - -func GetClient(shell *ishell.Context) *client.ClientSet { - return shell.Get(clientKey).(*client.ClientSet) -} - -func BoldBlue(s string) string { - return color.New(color.FgBlue, color.Bold).SprintFunc()(s) -} - -func BoldGreen(s string) string { - return color.New(color.FgGreen, color.Bold).SprintFunc()(s) -} - -func BoldYellow(s string) string { - return color.New(color.FgYellow, color.Bold).SprintFunc()(s) -} - -func BoldRed(s string) string { - return color.New(color.FgRed, color.Bold).SprintFunc()(s) -} - -func GetConfigDir(homeDir string) (string, error) { - if homeDir == "" { - return "", fmt.Errorf("homeDir cannot be empty") - } - - if _, err := os.Stat(homeDir); os.IsNotExist(err) { - return "", fmt.Errorf("homeDir should be a valid directory") - } - - configDir := path.Join(homeDir, ".config", "kagent") - if err := os.MkdirAll(configDir, 0755); err != nil { - return "", fmt.Errorf("error creating config directory: %w", err) - } - return configDir, nil -} - -func SetHistoryPath(homeDir string, shell *ishell.Shell) { - configDir, err := GetConfigDir(homeDir) - if err != nil { - fmt.Fprintf(os.Stderr, "error getting config directory: %v\n", err) - return - } - historyPath := path.Join(configDir, ".kagent_history") - shell.SetHistoryPath(historyPath) -} diff --git a/go/core/cli/internal/config/utils_test.go b/go/core/cli/internal/config/utils_test.go deleted file mode 100644 index c64c3c030..000000000 --- a/go/core/cli/internal/config/utils_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package config - -import ( - "os" - "path" - "testing" -) - -func TestGetConfigDirFirstRun(t *testing.T) { - homeDir := t.TempDir() - checkGetConfig(t, homeDir) -} - -func TestGetConfigDirSubsequentRun(t *testing.T) { - homeDir := t.TempDir() - checkGetConfig(t, homeDir) - checkGetConfig(t, homeDir) -} - -func TestHandlesErrorWhenCreatingConfigDir(t *testing.T) { - homeDir := t.TempDir() - nonExistentDir := path.Join(homeDir, "/invalid/path") - result, err := GetConfigDir(nonExistentDir) - if err == nil { - t.Fatalf("Expected error, but got nil") - } - if result != "" { - t.Fatalf("Expected empty string, but got %s", result) - } -} - -func checkGetConfig(t *testing.T, homeDir string) { - configDir, err := GetConfigDir(homeDir) - - // check for error - if err != nil { - t.Fatalf("Expected no error, but got %v", err) - } - - // check it's equal to the expected path - expectedDir := path.Join(homeDir, ".config", "kagent") - if configDir != expectedDir { - t.Fatalf("Expected %s, but got %s", expectedDir, configDir) - } - - // check kagent folder is exists - if _, err := os.Stat(expectedDir); os.IsNotExist(err) { - t.Fatalf("Expected %s to exist, but it does not", path.Join(homeDir, "kagent")) - } -} diff --git a/go/core/cli/internal/tui/workspace.go b/go/core/cli/internal/tui/workspace.go index 234026481..eb3d3cb88 100644 --- a/go/core/cli/internal/tui/workspace.go +++ b/go/core/cli/internal/tui/workspace.go @@ -5,6 +5,7 @@ import ( "fmt" "slices" "strings" + "time" a2atype "github.com/a2aproject/a2a-go/v2/a2a" "github.com/charmbracelet/bubbles/help" @@ -17,7 +18,6 @@ import ( api "github.com/kagent-dev/kagent/go/api/httpapi" "github.com/kagent-dev/kagent/go/api/v1alpha3" clia2a "github.com/kagent-dev/kagent/go/core/cli/internal/a2a" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" "github.com/kagent-dev/kagent/go/core/cli/internal/tui/dialogs" "github.com/kagent-dev/kagent/go/core/cli/internal/tui/keys" "github.com/kagent-dev/kagent/go/core/cli/internal/tui/theme" @@ -25,8 +25,14 @@ import ( "github.com/kagent-dev/kagent/go/core/internal/version" ) +// Options contains the settings used by the workspace TUI. +type Options struct { + KAgentURL string + Timeout time.Duration +} + // RunWorkspace launches a split-pane TUI: sessions (left), chat (center), details (toggleable right). -func RunWorkspace(cfg *config.Config, clientSet *client.ClientSet, verbose bool) error { +func RunWorkspace(cfg Options, clientSet *client.ClientSet, verbose bool) error { m := newWorkspaceModel(cfg, clientSet, verbose) p := tea.NewProgram(m, tea.WithAltScreen()) _, err := p.Run() @@ -72,7 +78,7 @@ func (i sessionListItem) Description() string { return i.s.ID } func (i sessionListItem) FilterValue() string { return i.Title() } type workspaceModel struct { - cfg *config.Config + cfg Options client *client.ClientSet verbose bool @@ -108,7 +114,7 @@ type workspaceModel struct { help help.Model } -func newWorkspaceModel(cfg *config.Config, clientSet *client.ClientSet, verbose bool) *workspaceModel { +func newWorkspaceModel(cfg Options, clientSet *client.ClientSet, verbose bool) *workspaceModel { sessionList := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) sessionList.Title = "Sessions" sessionList.SetShowStatusBar(false) diff --git a/go/core/cli/internal/tui/workspace_simple_test.go b/go/core/cli/internal/tui/workspace_simple_test.go index 2b977b2ba..dba513989 100644 --- a/go/core/cli/internal/tui/workspace_simple_test.go +++ b/go/core/cli/internal/tui/workspace_simple_test.go @@ -8,8 +8,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/kagent-dev/kagent/go/api/client" api "github.com/kagent-dev/kagent/go/api/httpapi" - "github.com/kagent-dev/kagent/go/core/cli/internal/config" "github.com/kagent-dev/kagent/go/core/cli/test/testutil" ) @@ -19,11 +19,8 @@ func TestWorkspaceModel_Initialization(t *testing.T) { testutil.MockAgentResponse([]api.AgentResponse{}), ) - cfg := &config.Config{ - KAgentURL: mockServer.URL, - Namespace: "kagent", - } - clientSet := cfg.Client() + cfg := Options{KAgentURL: mockServer.URL} + clientSet := client.New(mockServer.URL) m := newWorkspaceModel(cfg, clientSet, false) @@ -70,8 +67,8 @@ func TestWorkspaceModel_LoadAgentsCommand(t *testing.T) { }), ) - cfg := &config.Config{KAgentURL: mockServer.URL} - clientSet := cfg.Client() + cfg := Options{KAgentURL: mockServer.URL} + clientSet := client.New(mockServer.URL) m := newWorkspaceModel(cfg, clientSet, false) // Test the loadAgents command returns a valid command @@ -130,8 +127,8 @@ func TestWorkspaceModel_WindowSizeMessage(t *testing.T) { testutil.MockAgentResponse([]api.AgentResponse{}), ) - cfg := &config.Config{KAgentURL: mockServer.URL} - clientSet := cfg.Client() + cfg := Options{KAgentURL: mockServer.URL} + clientSet := client.New(mockServer.URL) m := newWorkspaceModel(cfg, clientSet, false) // Send window size message @@ -150,8 +147,8 @@ func TestWorkspaceModel_CreateSessionCommand(t *testing.T) { w.Write([]byte(`{}`)) }) - cfg := &config.Config{KAgentURL: mockServer.URL} - clientSet := cfg.Client() + cfg := Options{KAgentURL: mockServer.URL} + clientSet := client.New(mockServer.URL) m := newWorkspaceModel(cfg, clientSet, false) m.agentRef = "test-agent" @@ -167,8 +164,8 @@ func TestWorkspaceModel_LoadSessionsCommand(t *testing.T) { w.Write([]byte(`{"data": []}`)) }) - cfg := &config.Config{KAgentURL: mockServer.URL} - clientSet := cfg.Client() + cfg := Options{KAgentURL: mockServer.URL} + clientSet := client.New(mockServer.URL) m := newWorkspaceModel(cfg, clientSet, false) m.agent = &api.AgentResponse{ID: "agent-1"} @@ -182,8 +179,8 @@ func TestWorkspaceModel_AgentsLoadedUpdate(t *testing.T) { testutil.MockAgentResponse([]api.AgentResponse{}), ) - cfg := &config.Config{KAgentURL: mockServer.URL} - clientSet := cfg.Client() + cfg := Options{KAgentURL: mockServer.URL} + clientSet := client.New(mockServer.URL) m := newWorkspaceModel(cfg, clientSet, false) // Simulate agents loaded message with valid agent data @@ -210,8 +207,8 @@ func TestWorkspaceModel_InitCommand(t *testing.T) { testutil.MockAgentResponse([]api.AgentResponse{}), ) - cfg := &config.Config{KAgentURL: mockServer.URL} - clientSet := cfg.Client() + cfg := Options{KAgentURL: mockServer.URL} + clientSet := client.New(mockServer.URL) m := newWorkspaceModel(cfg, clientSet, false) // Test Init returns a command diff --git a/go/core/v2/mcp/server.go b/go/core/v2/mcp/server.go index 64dcf1318..d296980d7 100644 --- a/go/core/v2/mcp/server.go +++ b/go/core/v2/mcp/server.go @@ -12,10 +12,10 @@ import ( a2atype "github.com/a2aproject/a2a-go/v2/a2a" "github.com/a2aproject/a2a-go/v2/a2asrv" adka2a "github.com/kagent-dev/kagent/go/adk/pkg/a2a" + kagenta2a "github.com/kagent-dev/kagent/go/api/a2a" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/core/internal/a2a" "github.com/kagent-dev/kagent/go/core/internal/version" - "github.com/kagent-dev/kagent/go/core/v2/a2agateway" "github.com/kagent-dev/kagent/go/core/v2/agentinstance" "github.com/kagent-dev/kagent/go/core/v2/checkpoint" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -198,8 +198,8 @@ func (h *Handler) invoke(ctx context.Context, input InvokeAgentInstanceInput, as func routeContext(ctx context.Context, namespace, instanceID string) context.Context { ctx = metadata.NewIncomingContext(ctx, metadata.Pairs( - a2agateway.AgentInstanceNamespaceHeader, namespace, - a2agateway.AgentInstanceIDHeader, instanceID, + kagenta2a.AgentInstanceNamespaceHeader, namespace, + kagenta2a.AgentInstanceIDHeader, instanceID, )) ctx, _ = a2asrv.NewCallContext(ctx, a2asrv.NewServiceParams(map[string][]string{ a2atype.SvcParamExtensions: {adka2a.HITLExtensionURI}, diff --git a/go/go.mod b/go/go.mod index a7561a1da..2b30b3842 100644 --- a/go/go.mod +++ b/go/go.mod @@ -8,7 +8,6 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 github.com/a2aproject/a2a-go/v2 v2.5.0 - github.com/abiosoft/ishell/v2 v2.0.2 github.com/agent-substrate/substrate v0.0.0 github.com/anthropics/anthropic-sdk-go v1.66.0 github.com/aws/aws-sdk-go-v2 v1.43.6 @@ -19,7 +18,6 @@ require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 - github.com/fatih/color v1.19.0 github.com/glebarez/sqlite v1.11.0 github.com/go-logr/logr v1.4.4 github.com/go-logr/zapr v1.3.0 @@ -31,7 +29,6 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 - github.com/hashicorp/go-multierror v1.1.1 github.com/improbable-eng/grpc-web v0.15.0 github.com/jackc/pgx/v5 v5.10.0 github.com/jedib0t/go-pretty/v6 v6.8.3 @@ -48,7 +45,6 @@ require ( github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 - github.com/spf13/viper v1.21.0 github.com/stoewer/go-strcase v1.3.1 github.com/stretchr/testify v1.12.1 github.com/testcontainers/testcontainers-go v0.44.0 @@ -116,8 +112,6 @@ require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/MirrexOne/unqueryvet v1.5.4 // indirect github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect - github.com/abiosoft/ishell v2.0.0+incompatible // indirect - github.com/abiosoft/readline v0.0.0-20180607040430-155bce2042db // indirect github.com/alecthomas/chroma/v2 v2.24.1 // indirect github.com/alecthomas/go-check-sumtype v0.3.1 // indirect github.com/alexkohler/nakedret/v2 v2.0.6 // indirect @@ -148,7 +142,6 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bkielbasa/cyclop v1.2.3 // indirect - github.com/blang/semver/v4 v4.0.0 // indirect github.com/blizzy78/varnamelen v0.8.0 // indirect github.com/bombsimon/wsl/v4 v4.7.0 // indirect github.com/bombsimon/wsl/v5 v5.8.0 // indirect @@ -201,10 +194,10 @@ require ( github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/ettle/strcase v0.2.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fatih/color v1.19.0 // indirect github.com/fatih/structtag v1.2.0 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/firefart/nonamedreturns v1.0.6 // indirect - github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.3 // indirect github.com/fzipp/gocyclo v0.6.0 // indirect @@ -270,6 +263,7 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-version v1.9.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect @@ -390,6 +384,7 @@ require ( github.com/sonatard/noctx v0.5.1 // indirect github.com/sourcegraph/go-diff v0.8.0 // indirect github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/viper v1.21.0 // indirect github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect @@ -465,7 +460,6 @@ require ( istio.io/api v1.31.0-alpha.1.0.20260819121012-5803fb6accf7 // indirect istio.io/client-go v1.31.0-alpha.0.0.20260807010324-676a810f2c1f // indirect k8s.io/apiserver v0.36.3 // indirect - k8s.io/component-base v0.36.3 // indirect k8s.io/gengo/v2 v2.0.0-20260408192533-25e2208e0dc3 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect diff --git a/go/go.sum b/go/go.sum index 12f7e73e7..f6475e474 100644 --- a/go/go.sum +++ b/go/go.sum @@ -91,12 +91,6 @@ github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMx github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= github.com/a2aproject/a2a-go/v2 v2.5.0 h1:ZdcFoxv+nZTUV0i2ue5hES76YCANFPG9vjqd7vK8yWM= github.com/a2aproject/a2a-go/v2 v2.5.0/go.mod h1:NcRp/ZHxgMzDj12/BteIC2gOjljuEBKaGRfEdJ2lNSI= -github.com/abiosoft/ishell v2.0.0+incompatible h1:zpwIuEHc37EzrsIYah3cpevrIc8Oma7oZPxr03tlmmw= -github.com/abiosoft/ishell v2.0.0+incompatible/go.mod h1:HQR9AqF2R3P4XXpMpI0NAzgHf/aS6+zVXRj14cVk9qg= -github.com/abiosoft/ishell/v2 v2.0.2 h1:5qVfGiQISaYM8TkbBl7RFO6MddABoXpATrsFbVI+SNo= -github.com/abiosoft/ishell/v2 v2.0.2/go.mod h1:E4oTCXfo6QjoCart0QYa5m9w4S+deXs/P/9jA77A9Bs= -github.com/abiosoft/readline v0.0.0-20180607040430-155bce2042db h1:CjPUSXOiYptLbTdr1RceuZgSFDQ7U15ITERUGrUORx8= -github.com/abiosoft/readline v0.0.0-20180607040430-155bce2042db/go.mod h1:rB3B4rKii8V21ydCbIzH5hZiCQE7f5E9SzUb/ZZx530= github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= @@ -258,10 +252,6 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= -github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7LspvJs= github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk= github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= @@ -365,7 +355,6 @@ github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMi github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= @@ -374,8 +363,6 @@ github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeO github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/firefart/nonamedreturns v1.0.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47AQUwhrg8E= github.com/firefart/nonamedreturns v1.0.6/go.mod h1:R8NisJnSIpvPWheCq0mNRXJok6D8h7fagJTF8EMEwCo= -github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BMXYYRWTLOJKlh+lOBt6nUQgXAfB7oVIQt5cNreqSLI= -github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:rZfgFAXFS/z/lEd6LJmf9HVZ1LkgYiHx5pHhV5DR16M= github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4= github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -814,7 +801,6 @@ github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= @@ -1412,7 +1398,6 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= From e6ed46903410c8eb3d3568dea45e7f6f63945599 Mon Sep 17 00:00:00 2001 From: Cody Hartsook Date: Fri, 28 Aug 2026 10:42:00 -0700 Subject: [PATCH 07/10] fix(cli): address review feedback Signed-off-by: Cody Hartsook --- .github/workflows/ci.yaml | 2 + go/Makefile | 5 +- go/core/cli/cmd/kagent/main.go | 8 +- go/core/cli/cmd/kagent/main_test.go | 16 +- .../cli/internal/cli/connection/connection.go | 43 +--- .../cli/connection/connection_test.go | 191 +----------------- .../test/e2e/cli_catalog_lifecycle_test.go | 98 ++++++++- go/core/test/e2e/interaction_test.go | 97 --------- 8 files changed, 126 insertions(+), 334 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 075b5eff1..5760d17a8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -141,6 +141,8 @@ jobs: test "$READY" = True export KAGENT_GRPC_URL="$(kubectl get svc -n kagent kagent-controller -o jsonpath='{.status.loadBalancer.ingress[0].ip}'):8084" echo "KAGENT_GRPC_URL: $KAGENT_GRPC_URL" + make core/bin/kagent-local + export KAGENT_E2E_CLI="$PWD/core/bin/kagent-local" go test -v github.com/kagent-dev/kagent/go/core/test/e2e -failfast -shuffle=on - name: fail print info diff --git a/go/Makefile b/go/Makefile index f98121107..be6c246cc 100644 --- a/go/Makefile +++ b/go/Makefile @@ -91,6 +91,7 @@ govulncheck: ## Run govulncheck. ##@ Build (core module — CLI binaries) +.PHONY: core/bin/kagent-local core/bin/kagent-local: go test -race ./core/cli/... CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o core/bin/kagent-local ./core/cli/cmd/kagent @@ -148,13 +149,13 @@ test: ## Run all unit tests. KIND_CLUSTER_NAME ?= kagent .PHONY: e2e -e2e: ## Run end-to-end tests. +e2e: core/bin/kagent-local ## Run end-to-end tests. @kind get clusters 2>/dev/null | grep -qx '$(KIND_CLUSTER_NAME)' || { \ echo "Error: kind cluster '$(KIND_CLUSTER_NAME)' not found. Run 'make create-kind-cluster' first." >&2; \ exit 1; \ } @kind get kubeconfig --name $(KIND_CLUSTER_NAME) > /tmp/kind-config-e2e - KUBECONFIG=/tmp/kind-config-e2e go test -v github.com/kagent-dev/kagent/go/core/test/e2e -failfast + KAGENT_E2E_CLI=$(CURDIR)/core/bin/kagent-local KUBECONFIG=/tmp/kind-config-e2e go test -v github.com/kagent-dev/kagent/go/core/test/e2e -failfast ##@ Dependencies diff --git a/go/core/cli/cmd/kagent/main.go b/go/core/cli/cmd/kagent/main.go index 4c6e1dba2..2018495be 100644 --- a/go/core/cli/cmd/kagent/main.go +++ b/go/core/cli/cmd/kagent/main.go @@ -73,10 +73,10 @@ func newRootCommand(ctx context.Context, opts *rootOptions) *cobra.Command { rootCmd.SetContext(ctx) rootCmd.PersistentFlags().StringVar(&cfg.KAgentURL, "kagent-url", cfg.KAgentURL, "KAgent REST URL") - rootCmd.PersistentFlags().StringVar(&cfg.KAgentGRPCURL, "kagent-grpc-url", cfg.KAgentGRPCURL, "KAgent gRPC target") - rootCmd.PersistentFlags().BoolVar(&cfg.KAgentGRPCTLS, "kagent-grpc-tls", cfg.KAgentGRPCTLS, "Use TLS for KAgent gRPC") - rootCmd.PersistentFlags().StringVar(&cfg.KAgentGRPCCAFile, "kagent-grpc-ca-file", cfg.KAgentGRPCCAFile, "CA certificate file for KAgent gRPC") - rootCmd.PersistentFlags().StringVar(&cfg.KAgentGRPCServerName, "kagent-grpc-server-name", cfg.KAgentGRPCServerName, "TLS server name for KAgent gRPC") + rootCmd.PersistentFlags().StringVar(&cfg.KAgentGRPCURL, "grpc-url", cfg.KAgentGRPCURL, "KAgent gRPC target") + rootCmd.PersistentFlags().BoolVar(&cfg.KAgentGRPCTLS, "grpc-tls", cfg.KAgentGRPCTLS, "Use TLS for KAgent gRPC") + rootCmd.PersistentFlags().StringVar(&cfg.KAgentGRPCCAFile, "grpc-ca-file", cfg.KAgentGRPCCAFile, "CA certificate file for KAgent gRPC") + rootCmd.PersistentFlags().StringVar(&cfg.KAgentGRPCServerName, "grpc-server-name", cfg.KAgentGRPCServerName, "TLS server name for KAgent gRPC") rootCmd.PersistentFlags().StringVarP(&cfg.Namespace, "namespace", "n", cfg.Namespace, "Namespace") rootCmd.PersistentFlags().StringVarP(&opts.OutputFormat, "output-format", "o", opts.OutputFormat, "Output format") rootCmd.PersistentFlags().BoolVarP(&cfg.Verbose, "verbose", "v", cfg.Verbose, "Verbose output") diff --git a/go/core/cli/cmd/kagent/main_test.go b/go/core/cli/cmd/kagent/main_test.go index 122e5002a..592b71fdf 100644 --- a/go/core/cli/cmd/kagent/main_test.go +++ b/go/core/cli/cmd/kagent/main_test.go @@ -30,10 +30,10 @@ func TestRootCommandUsesOptionValuesAsFlagDefaults(t *testing.T) { rootCmd := newRootCommand(context.Background(), opts) assert.Equal(t, "http://kagent.example.test", rootCmd.PersistentFlags().Lookup("kagent-url").DefValue) - assert.Equal(t, "grpc.kagent.example.test:443", rootCmd.PersistentFlags().Lookup("kagent-grpc-url").DefValue) - assert.Equal(t, "true", rootCmd.PersistentFlags().Lookup("kagent-grpc-tls").DefValue) - assert.Equal(t, "/tmp/kagent-ca.pem", rootCmd.PersistentFlags().Lookup("kagent-grpc-ca-file").DefValue) - assert.Equal(t, "grpc.kagent.example.test", rootCmd.PersistentFlags().Lookup("kagent-grpc-server-name").DefValue) + assert.Equal(t, "grpc.kagent.example.test:443", rootCmd.PersistentFlags().Lookup("grpc-url").DefValue) + assert.Equal(t, "true", rootCmd.PersistentFlags().Lookup("grpc-tls").DefValue) + assert.Equal(t, "/tmp/kagent-ca.pem", rootCmd.PersistentFlags().Lookup("grpc-ca-file").DefValue) + assert.Equal(t, "grpc.kagent.example.test", rootCmd.PersistentFlags().Lookup("grpc-server-name").DefValue) assert.Equal(t, "configured-ns", rootCmd.PersistentFlags().Lookup("namespace").DefValue) assert.Equal(t, "json", rootCmd.PersistentFlags().Lookup("output-format").DefValue) assert.Equal(t, "true", rootCmd.PersistentFlags().Lookup("verbose").DefValue) @@ -57,10 +57,10 @@ func TestRootCommandFlagsOverrideOptionValues(t *testing.T) { rootCmd := newRootCommand(context.Background(), opts) require.NoError(t, rootCmd.ParseFlags([]string{ "--kagent-url", "http://flag.example.test", - "--kagent-grpc-url", "grpc.flag.example.test:8443", - "--kagent-grpc-tls", - "--kagent-grpc-ca-file", "/tmp/flag-ca.pem", - "--kagent-grpc-server-name", "grpc.flag.example.test", + "--grpc-url", "grpc.flag.example.test:8443", + "--grpc-tls", + "--grpc-ca-file", "/tmp/flag-ca.pem", + "--grpc-server-name", "grpc.flag.example.test", "--namespace", "flag-ns", "--output-format", "yaml", "--verbose", diff --git a/go/core/cli/internal/cli/connection/connection.go b/go/core/cli/internal/cli/connection/connection.go index 152bb0b17..2ce0f049d 100644 --- a/go/core/cli/internal/cli/connection/connection.go +++ b/go/core/cli/internal/cli/connection/connection.go @@ -6,7 +6,6 @@ import ( "context" "errors" "fmt" - "io" "os" "os/exec" "strings" @@ -81,22 +80,6 @@ func (o *Options) validate() error { return nil } -type connectionRuntime struct { - checkServer func(context.Context, *client.ClientSet) error - commandContext func(context.Context, string, ...string) *exec.Cmd - stderr io.Writer - readyTimeout time.Duration - retryDelay time.Duration -} - -var defaultConnectionRuntime = connectionRuntime{ - checkServer: checkServer, - commandContext: exec.CommandContext, - stderr: os.Stderr, - readyTimeout: portForwardReadyTimeout, - retryDelay: portForwardRetryDelay, -} - func checkServer(ctx context.Context, clientSet *client.ClientSet) error { if clientSet == nil { return errServerConnection @@ -113,25 +96,21 @@ func checkServer(ctx context.Context, clientSet *client.ClientSet) error { // Connect checks the configured server and starts a port-forward only for an // unreachable default local endpoint. func Connect(ctx context.Context, cfg *Options) (*PortForward, error) { - return defaultConnectionRuntime.connect(ctx, cfg) -} - -func (r connectionRuntime) connect(ctx context.Context, cfg *Options) (*PortForward, error) { if err := cfg.validate(); err != nil { return nil, err } if cfg.Verbose { - fmt.Fprintf(r.stderr, "Using caller identity %q\n", cfg.UserID) + fmt.Fprintf(os.Stderr, "Using caller identity %q\n", cfg.UserID) } - err := r.checkConfiguredServer(ctx, cfg) + err := checkConfiguredServer(ctx, cfg) if err == nil { return nil, nil } if !shouldPortForward(cfg, err) { return nil, err } - return r.newPortForward(ctx, cfg) + return NewPortForward(ctx, cfg) } func shouldPortForward(cfg *Options, err error) bool { @@ -146,12 +125,12 @@ func shouldPortForward(cfg *Options, err error) bool { return code == codes.Unavailable || code == codes.DeadlineExceeded || errors.Is(err, context.DeadlineExceeded) } -func (r connectionRuntime) checkConfiguredServer(ctx context.Context, cfg *Options) (err error) { +func checkConfiguredServer(ctx context.Context, cfg *Options) (err error) { clientSet := cfg.Client() defer func() { err = errors.Join(err, clientSet.Close()) }() - return r.checkServer(ctx, clientSet) + return checkServer(ctx, clientSet) } // PortForward is a running kubectl port-forward process. @@ -164,12 +143,8 @@ type PortForward struct { // NewPortForward starts a port-forward and waits for the server to become reachable. func NewPortForward(ctx context.Context, cfg *Options) (*PortForward, error) { - return defaultConnectionRuntime.newPortForward(ctx, cfg) -} - -func (r connectionRuntime) newPortForward(ctx context.Context, cfg *Options) (*PortForward, error) { ctx, cancel := context.WithCancel(ctx) - cmd := r.commandContext(ctx, "kubectl", "-n", cfg.Namespace, "port-forward", "service/kagent-controller", "8083:8083", "8084:8084") + cmd := exec.CommandContext(ctx, "kubectl", "-n", cfg.Namespace, "port-forward", "service/kagent-controller", "8083:8083", "8084:8084") stderr := newBoundedBuffer(kubectlErrorLimit) cmd.Stderr = stderr if err := cmd.Start(); err != nil { @@ -184,14 +159,14 @@ func (r connectionRuntime) newPortForward(ctx context.Context, cfg *Options) (*P }() portForward := &PortForward{cmd: cmd, cancel: cancel, wait: wait} - readyCtx, cancelReady := context.WithTimeout(ctx, r.readyTimeout) + readyCtx, cancelReady := context.WithTimeout(ctx, portForwardReadyTimeout) defer cancelReady() - ticker := time.NewTicker(r.retryDelay) + ticker := time.NewTicker(portForwardRetryDelay) defer ticker.Stop() var lastErr error for { - lastErr = r.checkConfiguredServer(readyCtx, cfg) + lastErr = checkConfiguredServer(readyCtx, cfg) if lastErr == nil { return portForward, nil } diff --git a/go/core/cli/internal/cli/connection/connection_test.go b/go/core/cli/internal/cli/connection/connection_test.go index cd8889470..9d112e4e5 100644 --- a/go/core/cli/internal/cli/connection/connection_test.go +++ b/go/core/cli/internal/cli/connection/connection_test.go @@ -1,15 +1,9 @@ package connection import ( - "bytes" "context" "errors" - "fmt" - "io" - "os" - "os/exec" "testing" - "time" "github.com/kagent-dev/kagent/go/api/client" api "github.com/kagent-dev/kagent/go/api/httpapi" @@ -36,6 +30,12 @@ func TestCheckServerPreservesCause(t *testing.T) { assert.Equal(t, codes.PermissionDenied, status.Code(err)) } +func TestOptionsValidate(t *testing.T) { + assert.NoError(t, (&Options{UserID: "user@example.com"}).validate()) + assert.Error(t, (&Options{}).validate()) + assert.Error(t, (&Options{UserID: "invalid user"}).validate()) +} + func TestShouldPortForward(t *testing.T) { defaultConfig := Options{KAgentURL: defaultKAgentURL, KAgentGRPCURL: defaultKAgentGRPCURL} tests := []struct { @@ -63,135 +63,6 @@ func TestShouldPortForward(t *testing.T) { } } -func TestConnectionRuntimeConnectStartsPortForwardAndRedials(t *testing.T) { - var output bytes.Buffer - var clients []*client.ClientSet - attempts := 0 - runtime := testConnectionRuntime(t, "wait", func(_ context.Context, clientSet *client.ClientSet) error { - clients = append(clients, clientSet) - attempts++ - if attempts < 3 { - return status.Error(codes.Unavailable, "not ready") - } - return nil - }) - runtime.stderr = &output - - portForward, err := runtime.connect(t.Context(), defaultTestConfig(true)) - require.NoError(t, err) - require.NotNil(t, portForward) - t.Cleanup(portForward.Stop) - - assert.Len(t, clients, 3) - assert.NotSame(t, clients[0], clients[1]) - assert.NotSame(t, clients[1], clients[2]) - assert.Contains(t, output.String(), `Using caller identity "test-user"`) - portForward.Stop() - assert.NotNil(t, portForward.cmd.ProcessState) -} - -func TestConnectionRuntimeConnectDoesNotPortForwardExplicitFailures(t *testing.T) { - tests := []struct { - name string - config *Options - err error - }{ - {name: "authentication failure", config: defaultTestConfig(false), err: status.Error(codes.Unauthenticated, "unauthenticated")}, - {name: "TLS endpoint", config: func() *Options { - cfg := defaultTestConfig(false) - cfg.KAgentGRPCTLS = true - return cfg - }(), err: status.Error(codes.Unavailable, "TLS failed")}, - {name: "remote gRPC endpoint", config: func() *Options { - cfg := defaultTestConfig(false) - cfg.KAgentGRPCURL = "api.example.test:443" - return cfg - }(), err: status.Error(codes.Unavailable, "offline")}, - {name: "remote HTTP endpoint", config: func() *Options { - cfg := defaultTestConfig(false) - cfg.KAgentURL = "https://api.example.test" - return cfg - }(), err: status.Error(codes.Unavailable, "offline")}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - runtime := testConnectionRuntime(t, "wait", func(context.Context, *client.ClientSet) error { - return tt.err - }) - runtime.commandContext = func(context.Context, string, ...string) *exec.Cmd { - t.Fatal("kubectl must not start for this failure") - return nil - } - - portForward, err := runtime.connect(t.Context(), tt.config) - require.Error(t, err) - assert.Nil(t, portForward) - assert.Equal(t, status.Code(tt.err), status.Code(err)) - }) - } -} - -func TestConnectionRuntimeConnectRejectsInvalidClientConfig(t *testing.T) { - runtime := testConnectionRuntime(t, "wait", func(context.Context, *client.ClientSet) error { - t.Fatal("server must not be checked with invalid client configuration") - return nil - }) - cfg := defaultTestConfig(false) - cfg.UserID = "invalid user" - - portForward, err := runtime.connect(t.Context(), cfg) - require.Error(t, err) - assert.Nil(t, portForward) - assert.Contains(t, err.Error(), "caller identity") -} - -func TestConnectionRuntimeNewPortForwardReportsStartFailure(t *testing.T) { - runtime := testConnectionRuntime(t, "wait", func(context.Context, *client.ClientSet) error { - t.Fatal("health probe must not run when kubectl cannot start") - return nil - }) - runtime.commandContext = func(ctx context.Context, _ string, _ ...string) *exec.Cmd { - return exec.CommandContext(ctx, t.TempDir()+"/missing-kubectl") - } - - portForward, err := runtime.newPortForward(t.Context(), defaultTestConfig(false)) - require.Error(t, err) - assert.Nil(t, portForward) - assert.Contains(t, err.Error(), "start kubectl port-forward") -} - -func TestConnectionRuntimeNewPortForwardReportsKubectlStderr(t *testing.T) { - runtime := testConnectionRuntime(t, "fail", func(context.Context, *client.ClientSet) error { - return status.Error(codes.Unavailable, "not ready") - }) - - portForward, err := runtime.newPortForward(t.Context(), defaultTestConfig(false)) - require.Error(t, err) - assert.Nil(t, portForward) - assert.Contains(t, err.Error(), "kubectl denied port-forward") -} - -func TestConnectionRuntimeNewPortForwardStopsProcessOnTimeout(t *testing.T) { - var command *exec.Cmd - runtime := testConnectionRuntime(t, "wait", func(context.Context, *client.ClientSet) error { - return status.Error(codes.Unavailable, "not ready") - }) - runtime.readyTimeout = 30 * time.Millisecond - commandFactory := runtime.commandContext - runtime.commandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd { - command = commandFactory(ctx, name, args...) - return command - } - - portForward, err := runtime.newPortForward(t.Context(), defaultTestConfig(false)) - require.Error(t, err) - assert.Nil(t, portForward) - assert.ErrorIs(t, err, context.DeadlineExceeded) - require.NotNil(t, command) - assert.NotNil(t, command.ProcessState) -} - func TestBoundedBuffer(t *testing.T) { buffer := newBoundedBuffer(4) written, err := buffer.Write([]byte("abcdef")) @@ -199,53 +70,3 @@ func TestBoundedBuffer(t *testing.T) { assert.Equal(t, 6, written) assert.Equal(t, "abcd", buffer.String()) } - -func TestPortForwardHelperProcess(t *testing.T) { - if os.Getenv("KAGENT_PORT_FORWARD_HELPER") != "1" { - return - } - - switch os.Getenv("KAGENT_PORT_FORWARD_BEHAVIOR") { - case "fail": - _, _ = fmt.Fprintln(os.Stderr, "kubectl denied port-forward") - os.Exit(2) - case "wait": - for { - time.Sleep(time.Hour) - } - default: - os.Exit(3) - } -} - -func testConnectionRuntime( - t *testing.T, - behavior string, - checkServer func(context.Context, *client.ClientSet) error, -) connectionRuntime { - t.Helper() - return connectionRuntime{ - checkServer: checkServer, - commandContext: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { - command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestPortForwardHelperProcess$") - command.Env = append(os.Environ(), - "KAGENT_PORT_FORWARD_HELPER=1", - "KAGENT_PORT_FORWARD_BEHAVIOR="+behavior, - ) - return command - }, - stderr: io.Discard, - readyTimeout: time.Second, - retryDelay: time.Millisecond, - } -} - -func defaultTestConfig(verbose bool) *Options { - return &Options{ - KAgentURL: defaultKAgentURL, - KAgentGRPCURL: defaultKAgentGRPCURL, - Namespace: "kagent", - UserID: "test-user", - Verbose: verbose, - } -} diff --git a/go/core/test/e2e/cli_catalog_lifecycle_test.go b/go/core/test/e2e/cli_catalog_lifecycle_test.go index fe5cc2f54..6ac006050 100644 --- a/go/core/test/e2e/cli_catalog_lifecycle_test.go +++ b/go/core/test/e2e/cli_catalog_lifecycle_test.go @@ -1,9 +1,11 @@ package e2e_test import ( + "bytes" "context" "encoding/json" "os" + "os/exec" "strings" "testing" @@ -13,16 +15,16 @@ import ( "k8s.io/client-go/tools/clientcmd" ) -func TestCLIAgentTemplateCatalogAndInstanceLifecycle(t *testing.T) { +func TestE2ECLIAgentTemplateCatalogAndInstanceLifecycle(t *testing.T) { if os.Getenv("KUBECONFIG") == "" { t.Setenv("KUBECONFIG", clientcmd.RecommendedHomeFile) } target := interactionTarget(t) templateName := createInteractionTemplate(t, startInteractionMock(t)) - binary := buildKagentCLI(t) + binary := kagentCLI(t) baseArgs := []string{ - "--kagent-grpc-url", target, - "--kagent-grpc-tls=false", + "--grpc-url", target, + "--grpc-tls=false", "--namespace", "kagent", "--user-id", "e2e", } @@ -89,3 +91,91 @@ func TestCLIAgentTemplateCatalogAndInstanceLifecycle(t *testing.T) { t.Fatalf("deleted AgentInstance state = %s, want DELETED", deletedResponse.GetAgentInstance().GetState()) } } + +func TestE2ECLIAgentInstanceDiscoveryAndInvoke(t *testing.T) { + target := interactionTarget(t) + fixture := newInteractionFixture(t, target, startInteractionMock(t)) + binary := kagentCLI(t) + baseArgs := []string{ + "--grpc-url", target, + "--grpc-tls=false", + "--namespace", "kagent", + "--user-id", "e2e", + } + + listOutput := runKagentCLI(t, fixture.ctx, binary, append(baseArgs, "get", "agent-instance")...) + if !strings.Contains(listOutput, fixture.instanceID) { + t.Fatalf("list AgentInstances stdout = %q, want instance %s", listOutput, fixture.instanceID) + } + + getArgs := append(append([]string{}, baseArgs...), "--output-format", "json", "get", "agent-instance", fixture.instanceID) + getOutput := runKagentCLI(t, fixture.ctx, binary, getArgs...) + if !json.Valid([]byte(getOutput)) || !strings.Contains(getOutput, fixture.instanceID) { + t.Fatalf("get AgentInstance stdout = %q, want JSON for instance %s", getOutput, fixture.instanceID) + } + + tests := []struct { + name string + format string + stream bool + }{ + {name: "table", format: "table"}, + {name: "table stream", format: "table", stream: true}, + {name: "json", format: "json"}, + {name: "json stream", format: "json", stream: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args := append(append([]string{}, baseArgs...), + "--output-format", tt.format, + "invoke", + "--agent-instance", fixture.instanceID, + "--task", "What is 2+2?", + ) + if tt.stream { + args = append(args, "--stream") + } + stdout := runKagentCLI(t, fixture.ctx, binary, args...) + if tt.format == "table" { + if got := strings.TrimSpace(stdout); got != "The answer is 4." { + t.Fatalf("CLI stdout = %q, want final response once", got) + } + return + } + lines := strings.Split(strings.TrimSpace(stdout), "\n") + if !tt.stream && len(lines) != 1 { + t.Fatalf("non-streaming JSON stdout has %d lines, want 1", len(lines)) + } + for _, line := range lines { + if !json.Valid([]byte(line)) { + t.Fatalf("CLI stdout line is not JSON: %q", line) + } + } + }) + } +} + +func runKagentCLI(t *testing.T, ctx context.Context, binary string, args ...string) string { + t.Helper() + command := exec.CommandContext(ctx, binary, args...) + command.Env = append(os.Environ(), "HOME="+t.TempDir()) + var stdout, stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + if err := command.Run(); err != nil { + t.Fatalf("run CLI: %v\nstderr: %s", err, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("CLI stderr = %q, want empty", stderr.String()) + } + return stdout.String() +} + +func kagentCLI(t *testing.T) string { + t.Helper() + binary := os.Getenv("KAGENT_E2E_CLI") + if binary == "" { + t.Fatal("KAGENT_E2E_CLI is not set; run E2E tests through `make -C go e2e`") + } + return binary +} diff --git a/go/core/test/e2e/interaction_test.go b/go/core/test/e2e/interaction_test.go index 988142a95..ceaad6e6e 100644 --- a/go/core/test/e2e/interaction_test.go +++ b/go/core/test/e2e/interaction_test.go @@ -10,8 +10,6 @@ import ( "net/http/httptest" "net/url" "os" - "os/exec" - "path/filepath" goruntime "runtime" "strings" "sync" @@ -87,101 +85,6 @@ func TestAgentInstanceAskUserSurvivesSuspension(t *testing.T) { } } -func TestCLIAgentInstanceDiscoveryAndInvoke(t *testing.T) { - target := interactionTarget(t) - fixture := newInteractionFixture(t, target, startInteractionMock(t)) - binary := buildKagentCLI(t) - baseArgs := []string{ - "--kagent-grpc-url", target, - "--kagent-grpc-tls=false", - "--namespace", "kagent", - "--user-id", "e2e", - } - - listOutput := runKagentCLI(t, fixture.ctx, binary, append(baseArgs, "get", "agent-instance")...) - if !strings.Contains(listOutput, fixture.instanceID) { - t.Fatalf("list AgentInstances stdout = %q, want instance %s", listOutput, fixture.instanceID) - } - - getArgs := append(append([]string{}, baseArgs...), "--output-format", "json", "get", "agent-instance", fixture.instanceID) - getOutput := runKagentCLI(t, fixture.ctx, binary, getArgs...) - if !json.Valid([]byte(getOutput)) || !strings.Contains(getOutput, fixture.instanceID) { - t.Fatalf("get AgentInstance stdout = %q, want JSON for instance %s", getOutput, fixture.instanceID) - } - - tests := []struct { - name string - format string - stream bool - }{ - {name: "table", format: "table"}, - {name: "table stream", format: "table", stream: true}, - {name: "json", format: "json"}, - {name: "json stream", format: "json", stream: true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - args := append(append([]string{}, baseArgs...), - "--output-format", tt.format, - "invoke", - "--agent-instance", fixture.instanceID, - "--task", "What is 2+2?", - ) - if tt.stream { - args = append(args, "--stream") - } - stdout := runKagentCLI(t, fixture.ctx, binary, args...) - if tt.format == "table" { - if got := strings.TrimSpace(stdout); got != "The answer is 4." { - t.Fatalf("CLI stdout = %q, want final response once", got) - } - return - } - lines := strings.Split(strings.TrimSpace(stdout), "\n") - if !tt.stream && len(lines) != 1 { - t.Fatalf("non-streaming JSON stdout has %d lines, want 1", len(lines)) - } - for _, line := range lines { - if !json.Valid([]byte(line)) { - t.Fatalf("CLI stdout line is not JSON: %q", line) - } - } - }) - } -} - -func runKagentCLI(t *testing.T, ctx context.Context, binary string, args ...string) string { - t.Helper() - command := exec.CommandContext(ctx, binary, args...) - command.Env = append(os.Environ(), "HOME="+t.TempDir()) - var stdout, stderr bytes.Buffer - command.Stdout = &stdout - command.Stderr = &stderr - if err := command.Run(); err != nil { - t.Fatalf("run CLI: %v\nstderr: %s", err, stderr.String()) - } - if stderr.Len() != 0 { - t.Fatalf("CLI stderr = %q, want empty", stderr.String()) - } - return stdout.String() -} - -func buildKagentCLI(t *testing.T) string { - t.Helper() - _, source, _, ok := goruntime.Caller(0) - if !ok { - t.Fatal("locate interaction test source") - } - moduleRoot := filepath.Clean(filepath.Join(filepath.Dir(source), "../../..")) - binary := filepath.Join(t.TempDir(), "kagent") - command := exec.CommandContext(t.Context(), "go", "build", "-o", binary, "./core/cli/cmd/kagent") - command.Dir = moduleRoot - if output, err := command.CombinedOutput(); err != nil { - t.Fatalf("build CLI: %v\n%s", err, output) - } - return binary -} - func TestAgentInstanceCheckpoint(t *testing.T) { fixture := newInteractionFixture(t, interactionTarget(t), startInteractionMock(t)) _, _, task := fixture.send(t, "What is 2+2?") From 915af7b36d5c2483210f5767bf4db436122bf9aa Mon Sep 17 00:00:00 2001 From: Cody Hartsook Date: Fri, 28 Aug 2026 11:04:36 -0700 Subject: [PATCH 08/10] fix(a2a): resolve API package alias after merge Signed-off-by: Cody Hartsook --- go/api/a2a/routing.go | 6 +++--- go/core/v2/a2agateway/gateway.go | 16 ++++++++-------- go/core/v2/a2agateway/gateway_test.go | 24 ++++++++++++------------ 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/go/api/a2a/routing.go b/go/api/a2a/routing.go index 14b408f62..0e41e3a70 100644 --- a/go/api/a2a/routing.go +++ b/go/api/a2a/routing.go @@ -1,9 +1,9 @@ -// Package a2a defines kagent's public A2A routing contract. +// Package a2a defines the public A2A routing contract for AgentInstances. package a2a const ( - // AgentInstanceNamespaceHeader selects the Kubernetes namespace containing the AgentInstance. + // AgentInstanceNamespaceHeader selects the namespace scope for the AgentInstance. AgentInstanceNamespaceHeader = "x-kagent-agent-instance-namespace" - // AgentInstanceIDHeader selects the AgentInstance within that namespace. + // AgentInstanceIDHeader selects the AgentInstance within that namespace scope. AgentInstanceIDHeader = "x-kagent-agent-instance-id" ) diff --git a/go/core/v2/a2agateway/gateway.go b/go/core/v2/a2agateway/gateway.go index 538e624d3..61f49afb5 100644 --- a/go/core/v2/a2agateway/gateway.go +++ b/go/core/v2/a2agateway/gateway.go @@ -25,7 +25,7 @@ import ( "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/a2aproject/a2a-go/v2/a2asrv/eventqueue" "github.com/google/uuid" - kagenta2a "github.com/kagent-dev/kagent/go/api/a2a" + apia2a "github.com/kagent-dev/kagent/go/api/a2a" dbpkg "github.com/kagent-dev/kagent/go/api/database" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/core/pkg/auth" @@ -189,17 +189,17 @@ func (g *Gateway) storedInstance(ctx context.Context, verb auth.Verb) (*apiv1alp } func route(ctx context.Context) (namespace, id string, err error) { - namespaces := metadata.ValueFromIncomingContext(ctx, kagenta2a.AgentInstanceNamespaceHeader) - ids := metadata.ValueFromIncomingContext(ctx, kagenta2a.AgentInstanceIDHeader) + namespaces := metadata.ValueFromIncomingContext(ctx, apia2a.AgentInstanceNamespaceHeader) + ids := metadata.ValueFromIncomingContext(ctx, apia2a.AgentInstanceIDHeader) if len(namespaces) != 1 || len(ids) != 1 { - return "", "", fmt.Errorf("exactly one %s and %s header is required", kagenta2a.AgentInstanceNamespaceHeader, kagenta2a.AgentInstanceIDHeader) + return "", "", fmt.Errorf("exactly one %s and %s header is required", apia2a.AgentInstanceNamespaceHeader, apia2a.AgentInstanceIDHeader) } if problems := utilvalidation.IsDNS1123Label(namespaces[0]); len(problems) > 0 { - return "", "", fmt.Errorf("invalid %s header: %s", kagenta2a.AgentInstanceNamespaceHeader, strings.Join(problems, "; ")) + return "", "", fmt.Errorf("invalid %s header: %s", apia2a.AgentInstanceNamespaceHeader, strings.Join(problems, "; ")) } parsedID, err := uuid.Parse(ids[0]) if err != nil { - return "", "", fmt.Errorf("invalid %s header: %w", kagenta2a.AgentInstanceIDHeader, err) + return "", "", fmt.Errorf("invalid %s header: %w", apia2a.AgentInstanceIDHeader, err) } return namespaces[0], parsedID.String(), nil } @@ -456,7 +456,7 @@ func (g *Gateway) prepareSend(ctx context.Context, req *a2atype.SendMessageReque if req == nil || req.Message == nil { return nil, a2atype.NewError(a2atype.ErrInvalidRequest, "message is required") } - kagenta2a.ClearStoredTask(req.Message) + apia2a.ClearStoredTask(req.Message) if req.Message.ID == "" { return nil, a2atype.NewError(a2atype.ErrInvalidRequest, "message ID is required") } @@ -531,7 +531,7 @@ func (g *Gateway) prepareReply(ctx context.Context, instance *apiv1alpha1.AgentI } runtimeMessage := *message runtimeMessage.Metadata = maps.Clone(message.Metadata) - if err := kagenta2a.AttachStoredTask(&runtimeMessage, stored); err != nil { + if err := apia2a.AttachStoredTask(&runtimeMessage, stored); err != nil { return nil, a2atype.NewError(a2atype.ErrInternalError, "failed to prepare task continuation") } req.Message = &runtimeMessage diff --git a/go/core/v2/a2agateway/gateway_test.go b/go/core/v2/a2agateway/gateway_test.go index 4cb33e8dc..72298f0ed 100644 --- a/go/core/v2/a2agateway/gateway_test.go +++ b/go/core/v2/a2agateway/gateway_test.go @@ -15,7 +15,7 @@ import ( a2agrpc "github.com/a2aproject/a2a-go/v2/a2agrpc/v1" a2apb "github.com/a2aproject/a2a-go/v2/a2apb/v1" "github.com/a2aproject/a2a-go/v2/a2apb/v1/pbconv" - kagenta2a "github.com/kagent-dev/kagent/go/api/a2a" + apia2a "github.com/kagent-dev/kagent/go/api/a2a" dbpkg "github.com/kagent-dev/kagent/go/api/database" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/core/pkg/auth" @@ -208,7 +208,7 @@ func (r *gatewayTestRuntime) SubscribeToTask(context.Context, a2aclient.ServiceP func (r *gatewayTestRuntime) SendMessage(_ context.Context, _ a2aclient.ServiceParams, req *a2atype.SendMessageRequest) (a2atype.SendMessageResult, error) { r.sent = true - r.privateTask, _ = kagenta2a.TakeStoredTask(req.Message) + r.privateTask, _ = apia2a.TakeStoredTask(req.Message) r.sendCalls++ r.sentTaskID = req.Message.TaskID return &a2atype.Task{ID: req.Message.TaskID, ContextID: req.Message.ContextID, Status: a2atype.TaskStatus{State: a2atype.TaskStateCompleted}}, nil @@ -295,8 +295,8 @@ func gatewayTestContext() context.Context { func gatewayTestContextWithRoute(namespace, id string) context.Context { ctx := auth.AuthSessionTo(context.Background(), gatewayTestSession{}) return metadata.NewIncomingContext(ctx, metadata.Pairs( - kagenta2a.AgentInstanceNamespaceHeader, namespace, - kagenta2a.AgentInstanceIDHeader, id, + apia2a.AgentInstanceNamespaceHeader, namespace, + apia2a.AgentInstanceIDHeader, id, )) } @@ -564,8 +564,8 @@ func TestGatewayReadsRoutingHeadersFromGRPC(t *testing.T) { t.Fatal(err) } ctx := metadata.NewOutgoingContext(t.Context(), metadata.Pairs( - kagenta2a.AgentInstanceNamespaceHeader, instance.GetNamespace(), - kagenta2a.AgentInstanceIDHeader, instance.GetId(), + apia2a.AgentInstanceNamespaceHeader, instance.GetNamespace(), + apia2a.AgentInstanceIDHeader, instance.GetId(), )) if _, err := a2apb.NewA2AServiceClient(connection).SendMessage(ctx, request); err != nil { t.Fatal(err) @@ -1053,8 +1053,8 @@ func TestGatewayHonoursAgentInstanceShare(t *testing.T) { ReadOnly: true, }) ctx = metadata.NewIncomingContext(ctx, metadata.Pairs( - kagenta2a.AgentInstanceNamespaceHeader, instance.GetNamespace(), - kagenta2a.AgentInstanceIDHeader, instance.GetId(), + apia2a.AgentInstanceNamespaceHeader, instance.GetNamespace(), + apia2a.AgentInstanceIDHeader, instance.GetId(), )) if _, err := gateway.ListTasks(ctx, &a2atype.ListTasksRequest{}); err != nil { @@ -1084,8 +1084,8 @@ func TestGatewayRefusesAShareForADifferentInstance(t *testing.T) { AgentInstanceID: "00000000-0000-0000-0000-000000000000", }) ctx = metadata.NewIncomingContext(ctx, metadata.Pairs( - kagenta2a.AgentInstanceNamespaceHeader, instance.GetNamespace(), - kagenta2a.AgentInstanceIDHeader, instance.GetId(), + apia2a.AgentInstanceNamespaceHeader, instance.GetNamespace(), + apia2a.AgentInstanceIDHeader, instance.GetId(), )) if _, err := gateway.ListTasks(ctx, &a2atype.ListTasksRequest{}); err == nil { @@ -1110,8 +1110,8 @@ func TestGatewayIgnoresASessionShare(t *testing.T) { SessionID: instance.GetId(), }) ctx = metadata.NewIncomingContext(ctx, metadata.Pairs( - kagenta2a.AgentInstanceNamespaceHeader, instance.GetNamespace(), - kagenta2a.AgentInstanceIDHeader, instance.GetId(), + apia2a.AgentInstanceNamespaceHeader, instance.GetNamespace(), + apia2a.AgentInstanceIDHeader, instance.GetId(), )) if _, err := gateway.ListTasks(ctx, &a2atype.ListTasksRequest{}); err == nil { From 0517f3de9c093181e3fdbd261b0759b08522c630 Mon Sep 17 00:00:00 2001 From: Cody Hartsook Date: Fri, 28 Aug 2026 12:54:21 -0700 Subject: [PATCH 09/10] fix(cli): forward model token to A2A requests Signed-off-by: Cody Hartsook --- go/api/client/agentinstancegrpc_test.go | 34 +++++++++++-------- .../cli/internal/cli/agentinstance/invoke.go | 3 +- .../internal/cli/agentinstance/invoke_test.go | 28 ++++++++++++--- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/go/api/client/agentinstancegrpc_test.go b/go/api/client/agentinstancegrpc_test.go index 6c30e9115..23b8cf477 100644 --- a/go/api/client/agentinstancegrpc_test.go +++ b/go/api/client/agentinstancegrpc_test.go @@ -9,6 +9,7 @@ import ( "time" a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" a2apb "github.com/a2aproject/a2a-go/v2/a2apb/v1" "github.com/a2aproject/a2a-go/v2/a2apb/v1/pbconv" kagenta2a "github.com/kagent-dev/kagent/go/api/a2a" @@ -35,10 +36,11 @@ func (s *recordingAgentInstanceService) CreateAgentInstance(ctx context.Context, } type a2aCallObservation struct { - namespace string - id string - userID string - hasDeadline bool + namespace string + id string + userID string + authorization string + hasDeadline bool } type recordingA2AService struct { @@ -76,10 +78,11 @@ func (s *recordingA2AService) observe(ctx context.Context) { s.mu.Lock() defer s.mu.Unlock() s.observations = append(s.observations, a2aCallObservation{ - namespace: first(values.Get(kagenta2a.AgentInstanceNamespaceHeader)), - id: first(values.Get(kagenta2a.AgentInstanceIDHeader)), - userID: first(values.Get(userIDHeader)), - hasDeadline: hasDeadline, + namespace: first(values.Get(kagenta2a.AgentInstanceNamespaceHeader)), + id: first(values.Get(kagenta2a.AgentInstanceIDHeader)), + userID: first(values.Get(userIDHeader)), + authorization: first(values.Get("authorization")), + hasDeadline: hasDeadline, }) } @@ -115,21 +118,24 @@ func TestAgentInstanceAndA2AClientsShareGRPCConnection(t *testing.T) { a2aClient, err := clientSet.A2A.ForAgentInstance(context.Background(), "kagent", agentInstanceClientTestID) require.NoError(t, err) + a2aCtx := a2aclient.AttachServiceParams(context.Background(), a2aclient.ServiceParams{ + "authorization": {"Bearer model-key"}, + }) request := &a2atype.SendMessageRequest{Message: a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart("hi"))} - _, err = a2aClient.SendMessage(context.Background(), request) + _, err = a2aClient.SendMessage(a2aCtx, request) require.NoError(t, err) - for _, streamErr := range a2aClient.SendStreamingMessage(context.Background(), request) { + for _, streamErr := range a2aClient.SendStreamingMessage(a2aCtx, request) { require.NoError(t, streamErr) } - for _, streamErr := range a2aClient.SubscribeToTask(context.Background(), &a2atype.SubscribeToTaskRequest{ID: "task-id"}) { + for _, streamErr := range a2aClient.SubscribeToTask(a2aCtx, &a2atype.SubscribeToTaskRequest{ID: "task-id"}) { require.NoError(t, streamErr) } a2aService.mu.Lock() require.Equal(t, []a2aCallObservation{ - {namespace: "kagent", id: agentInstanceClientTestID, userID: "caller", hasDeadline: true}, - {namespace: "kagent", id: agentInstanceClientTestID, userID: "caller", hasDeadline: false}, - {namespace: "kagent", id: agentInstanceClientTestID, userID: "caller", hasDeadline: false}, + {namespace: "kagent", id: agentInstanceClientTestID, userID: "caller", authorization: "Bearer model-key", hasDeadline: true}, + {namespace: "kagent", id: agentInstanceClientTestID, userID: "caller", authorization: "Bearer model-key", hasDeadline: false}, + {namespace: "kagent", id: agentInstanceClientTestID, userID: "caller", authorization: "Bearer model-key", hasDeadline: false}, }, a2aService.observations) a2aService.mu.Unlock() assert.Equal(t, int32(1), dialCount.Load()) diff --git a/go/core/cli/internal/cli/agentinstance/invoke.go b/go/core/cli/internal/cli/agentinstance/invoke.go index 5dd404ad8..014de9353 100644 --- a/go/core/cli/internal/cli/agentinstance/invoke.go +++ b/go/core/cli/internal/cli/agentinstance/invoke.go @@ -16,7 +16,6 @@ import ( clia2a "github.com/kagent-dev/kagent/go/core/cli/internal/a2a" "github.com/kagent-dev/kagent/go/core/cli/internal/cli/connection" clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" - "google.golang.org/grpc/metadata" ) var errTruncatedA2AStream = errors.New("a2a stream ended before returning a final result") @@ -84,7 +83,7 @@ func withModelToken(ctx context.Context, token string) context.Context { if token == "" { return ctx } - return metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token) + return a2aclient.AttachServiceParams(ctx, a2aclient.ServiceParams{"authorization": {"Bearer " + token}}) } func readInvokeTask(cfg *InvokeCfg, in io.Reader) (string, error) { diff --git a/go/core/cli/internal/cli/agentinstance/invoke_test.go b/go/core/cli/internal/cli/agentinstance/invoke_test.go index a45f84c55..1ab4a371f 100644 --- a/go/core/cli/internal/cli/agentinstance/invoke_test.go +++ b/go/core/cli/internal/cli/agentinstance/invoke_test.go @@ -12,12 +12,24 @@ import ( "time" a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/cli/output" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "google.golang.org/grpc/metadata" ) +var errServiceParamsObserved = errors.New("service parameters observed") + +type serviceParamsObserver struct { + a2aclient.PassthroughInterceptor + authorization []string +} + +func (o *serviceParamsObserver) Before(ctx context.Context, request *a2aclient.Request) (context.Context, any, error) { + o.authorization = request.ServiceParams.Get("authorization") + return ctx, nil, errServiceParamsObserved +} + func TestReadInvokeTask(t *testing.T) { tests := []struct { name string @@ -67,10 +79,18 @@ func TestNewInvokeRequestUsesAgentInstanceAsContext(t *testing.T) { } func TestWithModelToken(t *testing.T) { + observer := &serviceParamsObserver{} + client, err := a2aclient.NewFromEndpoints(t.Context(), []*a2atype.AgentInterface{{ + URL: "http://unused.invalid", + ProtocolBinding: a2atype.TransportProtocolJSONRPC, + ProtocolVersion: a2atype.Version, + }}, a2aclient.WithCallInterceptors(observer)) + require.NoError(t, err) + ctx := withModelToken(context.Background(), "model-key") - values, ok := metadata.FromOutgoingContext(ctx) - require.True(t, ok) - assert.Equal(t, []string{"Bearer model-key"}, values.Get("authorization")) + _, err = client.SendMessage(ctx, newInvokeRequest("hello", "instance-id")) + require.ErrorIs(t, err, errServiceParamsObserved) + assert.Equal(t, []string{"Bearer model-key"}, observer.authorization) } func TestWriteSendResultTable(t *testing.T) { From 1d522bad820c1a96692017412dd561db843f4293 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sat, 29 Aug 2026 14:49:25 +0000 Subject: [PATCH 10/10] fix(cli): rely on server lifecycle validation Signed-off-by: Eitan Yarmush --- .../internal/cli/agentinstance/lifecycle.go | 30 ++-------------- .../cli/agentinstance/lifecycle_test.go | 34 ++----------------- 2 files changed, 4 insertions(+), 60 deletions(-) diff --git a/go/core/cli/internal/cli/agentinstance/lifecycle.go b/go/core/cli/internal/cli/agentinstance/lifecycle.go index 8889121cb..f1fce72c0 100644 --- a/go/core/cli/internal/cli/agentinstance/lifecycle.go +++ b/go/core/cli/internal/cli/agentinstance/lifecycle.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "io" - "strings" "github.com/google/uuid" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" @@ -43,9 +42,7 @@ func CreateCmd(ctx context.Context, cfg *CreateCfg, out io.Writer) (err error) { if err != nil { return err } - if err := prepareCreateCfg(cfg); err != nil { - return err - } + ensureRequestID(cfg) portForward, err := connection.Connect(ctx, cfg.Connection) if err != nil { @@ -62,21 +59,10 @@ func CreateCmd(ctx context.Context, cfg *CreateCfg, out io.Writer) (err error) { return create(ctx, clientSet.AgentInstance, cfg.Connection.Namespace, cfg, format, out) } -func prepareCreateCfg(cfg *CreateCfg) error { - if cfg.Harness == "" { - return errors.New("harness is required") - } - if cfg.AgentTemplate == "" { - return errors.New("agent template is required") - } +func ensureRequestID(cfg *CreateCfg) { if cfg.RequestID == "" { cfg.RequestID = uuid.NewString() - return nil - } - if strings.TrimSpace(cfg.RequestID) != cfg.RequestID || len(cfg.RequestID) > 128 { - return errors.New("request ID must be 1-128 characters without surrounding whitespace") } - return nil } // DeleteCmd deletes an AgentInstance. @@ -85,9 +71,6 @@ func DeleteCmd(ctx context.Context, cfg *DeleteCfg, out io.Writer) (err error) { if err != nil { return err } - if err := validateDeleteCfg(cfg); err != nil { - return err - } portForward, err := connection.Connect(ctx, cfg.Connection) if err != nil { return fmt.Errorf("connect to kagent: %w", err) @@ -103,15 +86,6 @@ func DeleteCmd(ctx context.Context, cfg *DeleteCfg, out io.Writer) (err error) { return deleteAgentInstance(ctx, clientSet.AgentInstance, cfg.Connection.Namespace, cfg, format, out) } -func validateDeleteCfg(cfg *DeleteCfg) error { - instanceID, err := uuid.Parse(cfg.InstanceID) - if err != nil { - return fmt.Errorf("invalid AgentInstance ID %q: %w", cfg.InstanceID, err) - } - cfg.InstanceID = instanceID.String() - return nil -} - func create( ctx context.Context, client lifecycleClient, diff --git a/go/core/cli/internal/cli/agentinstance/lifecycle_test.go b/go/core/cli/internal/cli/agentinstance/lifecycle_test.go index b688d0af8..9e51ebdf7 100644 --- a/go/core/cli/internal/cli/agentinstance/lifecycle_test.go +++ b/go/core/cli/internal/cli/agentinstance/lifecycle_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/json" - "strings" "testing" "github.com/google/uuid" @@ -16,36 +15,12 @@ import ( "google.golang.org/grpc/status" ) -func TestPrepareCreateCfgValidation(t *testing.T) { - tests := []struct { - name string - config CreateCfg - wantErr string - }{ - {name: "valid", config: CreateCfg{Harness: "kagent", AgentTemplate: "smoke", RequestID: "replay-1"}}, - {name: "missing harness", config: CreateCfg{AgentTemplate: "smoke"}, wantErr: "harness is required"}, - {name: "missing template", config: CreateCfg{Harness: "kagent"}, wantErr: "agent template is required"}, - {name: "request ID whitespace", config: CreateCfg{Harness: "kagent", AgentTemplate: "smoke", RequestID: " replay-1"}, wantErr: "request ID"}, - {name: "request ID too long", config: CreateCfg{Harness: "kagent", AgentTemplate: "smoke", RequestID: strings.Repeat("a", 129)}, wantErr: "request ID"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := prepareCreateCfg(&tt.config) - if tt.wantErr != "" { - require.ErrorContains(t, err, tt.wantErr) - return - } - require.NoError(t, err) - }) - } -} - func TestCreateAgentInstanceGeneratedRequestIDIsStable(t *testing.T) { cfg := &CreateCfg{Harness: "kagent", AgentTemplate: "smoke"} - require.NoError(t, prepareCreateCfg(cfg)) + ensureRequestID(cfg) requestID := cfg.RequestID require.NoError(t, uuid.Validate(requestID)) - require.NoError(t, prepareCreateCfg(cfg)) + ensureRequestID(cfg) assert.Equal(t, requestID, cfg.RequestID) client := &lifecycleAgentInstanceClient{createInstance: testInstance()} @@ -98,11 +73,6 @@ func TestDeleteAgentInstance(t *testing.T) { assert.Contains(t, output.String(), "DELETED") } -func TestValidateDeleteCfg(t *testing.T) { - cfg := &DeleteCfg{InstanceID: "not-an-id"} - require.ErrorContains(t, validateDeleteCfg(cfg), "invalid AgentInstance ID") -} - func TestDeleteAgentInstanceAborted(t *testing.T) { client := &lifecycleAgentInstanceClient{deleteErr: status.Error(codes.Aborted, "conflict")} cfg := &DeleteCfg{InstanceID: testInstanceID}