diff --git a/pkg/cmd/info.go b/pkg/cmd/info.go new file mode 100644 index 0000000..c9bd741 --- /dev/null +++ b/pkg/cmd/info.go @@ -0,0 +1,153 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +package cmd + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + + "github.com/kortex-hub/kortex-cli/pkg/instances" + "github.com/kortex-hub/kortex-cli/pkg/runtimesetup" + "github.com/kortex-hub/kortex-cli/pkg/version" + "github.com/spf13/cobra" +) + +// infoResponse is the JSON output structure for the info command +type infoResponse struct { + Version string `json:"version"` + Agents []string `json:"agents"` + Runtimes []string `json:"runtimes"` +} + +// infoCmd contains the configuration for the info command +type infoCmd struct { + output string + manager instances.Manager +} + +// preRun validates the parameters and flags +func (i *infoCmd) preRun(cmd *cobra.Command, args []string) error { + // Validate output format if specified + if i.output != "" && i.output != "json" { + return fmt.Errorf("unsupported output format: %s (supported: json)", i.output) + } + + // Silence Cobra's error output when JSON mode is enabled + // This prevents "Error: ..." prefix from being printed + if i.output == "json" { + cmd.SilenceErrors = true + cmd.SilenceUsage = true + } + + storageDir, err := cmd.Flags().GetString("storage") + if err != nil { + return outputErrorIfJSON(cmd, i.output, fmt.Errorf("failed to read --storage flag: %w", err)) + } + + absStorageDir, err := filepath.Abs(storageDir) + if err != nil { + return outputErrorIfJSON(cmd, i.output, fmt.Errorf("failed to resolve storage directory path: %w", err)) + } + + manager, err := instances.NewManager(absStorageDir) + if err != nil { + return outputErrorIfJSON(cmd, i.output, fmt.Errorf("failed to create manager: %w", err)) + } + + if err := runtimesetup.RegisterAll(manager); err != nil { + return outputErrorIfJSON(cmd, i.output, fmt.Errorf("failed to register runtimes: %w", err)) + } + + i.manager = manager + + return nil +} + +// run executes the info command logic +func (i *infoCmd) run(cmd *cobra.Command, args []string) error { + runtimes := runtimesetup.ListAvailable() + + agents, err := i.manager.ListAgents() + if err != nil { + return outputErrorIfJSON(cmd, i.output, fmt.Errorf("failed to list agents: %w", err)) + } + + if i.output == "json" { + return i.outputJSON(cmd, agents, runtimes) + } + + // Text output + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Version: %s\n", version.Version) + fmt.Fprintf(out, "Agents: %s\n", strings.Join(agents, ", ")) + fmt.Fprintf(out, "Runtimes: %s\n", strings.Join(runtimes, ", ")) + + return nil +} + +// outputJSON outputs the info response as JSON +func (i *infoCmd) outputJSON(cmd *cobra.Command, agents, runtimes []string) error { + if agents == nil { + agents = []string{} + } + if runtimes == nil { + runtimes = []string{} + } + + response := infoResponse{ + Version: version.Version, + Agents: agents, + Runtimes: runtimes, + } + + jsonData, err := json.MarshalIndent(response, "", " ") + if err != nil { + return outputErrorIfJSON(cmd, i.output, fmt.Errorf("failed to marshal info to JSON: %w", err)) + } + + fmt.Fprintln(cmd.OutOrStdout(), string(jsonData)) + return nil +} + +func NewInfoCmd() *cobra.Command { + c := &infoCmd{} + + cmd := &cobra.Command{ + Use: "info", + Short: "Display information about kortex-cli", + Example: `# Show info +kortex-cli info + +# Show info in JSON format +kortex-cli info --output json + +# Show info using short flag +kortex-cli info -o json`, + Args: cobra.NoArgs, + PreRunE: c.preRun, + RunE: c.run, + } + + cmd.Flags().StringVarP(&c.output, "output", "o", "", "Output format (supported: json)") + cmd.RegisterFlagCompletionFunc("output", newOutputFlagCompletion([]string{"json"})) + + return cmd +} diff --git a/pkg/cmd/info_test.go b/pkg/cmd/info_test.go new file mode 100644 index 0000000..0e31144 --- /dev/null +++ b/pkg/cmd/info_test.go @@ -0,0 +1,252 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kortex-hub/kortex-cli/pkg/cmd/testutil" + "github.com/kortex-hub/kortex-cli/pkg/version" + "github.com/spf13/cobra" +) + +func TestInfoCmd(t *testing.T) { + t.Parallel() + + cmd := NewInfoCmd() + if cmd == nil { + t.Fatal("NewInfoCmd() returned nil") + } + + if cmd.Use != "info" { + t.Errorf("Expected Use to be 'info', got '%s'", cmd.Use) + } +} + +func TestInfoCmd_Examples(t *testing.T) { + t.Parallel() + + cmd := NewInfoCmd() + + if cmd.Example == "" { + t.Fatal("Example field should not be empty") + } + + commands, err := testutil.ParseExampleCommands(cmd.Example) + if err != nil { + t.Fatalf("Failed to parse examples: %v", err) + } + + expectedCount := 3 + if len(commands) != expectedCount { + t.Errorf("Expected %d example commands, got %d", expectedCount, len(commands)) + } + + rootCmd := NewRootCmd() + err = testutil.ValidateCommandExamples(rootCmd, cmd.Example) + if err != nil { + t.Errorf("Example validation failed: %v", err) + } +} + +func TestInfoCmd_PreRun(t *testing.T) { + t.Parallel() + + t.Run("accepts empty output flag", func(t *testing.T) { + t.Parallel() + + c := &infoCmd{} + cmd := &cobra.Command{} + cmd.Flags().String("storage", t.TempDir(), "test storage flag") + + err := c.preRun(cmd, []string{}) + if err != nil { + t.Fatalf("preRun() failed: %v", err) + } + }) + + t.Run("accepts json output format", func(t *testing.T) { + t.Parallel() + + c := &infoCmd{output: "json"} + cmd := &cobra.Command{} + cmd.Flags().String("storage", t.TempDir(), "test storage flag") + + err := c.preRun(cmd, []string{}) + if err != nil { + t.Fatalf("preRun() failed: %v", err) + } + }) + + t.Run("rejects invalid output format", func(t *testing.T) { + t.Parallel() + + c := &infoCmd{output: "xml"} + cmd := &cobra.Command{} + + err := c.preRun(cmd, []string{}) + if err == nil { + t.Fatal("Expected error for invalid output format") + } + + if !strings.Contains(err.Error(), "unsupported output format") { + t.Errorf("Expected 'unsupported output format' error, got: %v", err) + } + }) + + t.Run("creates manager in preRun", func(t *testing.T) { + t.Parallel() + + c := &infoCmd{} + cmd := &cobra.Command{} + cmd.Flags().String("storage", t.TempDir(), "test storage flag") + + err := c.preRun(cmd, []string{}) + if err != nil { + t.Fatalf("preRun() failed: %v", err) + } + + if c.manager == nil { + t.Error("Expected manager to be created") + } + }) +} + +func TestInfoCmd_E2E(t *testing.T) { + t.Parallel() + + t.Run("text output contains version and runtimes", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + + rootCmd := NewRootCmd() + buf := new(bytes.Buffer) + rootCmd.SetOut(buf) + rootCmd.SetArgs([]string{"info", "--storage", storageDir}) + + err := rootCmd.Execute() + if err != nil { + t.Fatalf("Execute() failed: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "Version: "+version.Version) { + t.Errorf("Expected output to contain version, got: %s", output) + } + if !strings.Contains(output, "Runtimes:") { + t.Errorf("Expected output to contain Runtimes, got: %s", output) + } + if !strings.Contains(output, "Agents:") { + t.Errorf("Expected output to contain Agents, got: %s", output) + } + }) + + t.Run("json output has expected fields", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + + rootCmd := NewRootCmd() + buf := new(bytes.Buffer) + rootCmd.SetOut(buf) + rootCmd.SetArgs([]string{"info", "--storage", storageDir, "-o", "json"}) + + err := rootCmd.Execute() + if err != nil { + t.Fatalf("Execute() failed: %v", err) + } + + var response infoResponse + if err := json.Unmarshal(buf.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + + if response.Version != version.Version { + t.Errorf("Expected version %s, got: %s", version.Version, response.Version) + } + + if response.Runtimes == nil { + t.Error("Expected runtimes to be present") + } + + if response.Agents == nil { + t.Error("Expected agents to be present") + } + }) + + t.Run("discovers agents from runtimes via manager", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + + // Create podman config directory with a claude.json agent config + // This simulates the podman runtime being initialized with agent configs + configDir := filepath.Join(storageDir, "runtimes", "podman", "config") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + + claudeConfig := `{"packages": [], "run_commands": [], "terminal_command": ["claude"]}` + if err := os.WriteFile(filepath.Join(configDir, "claude.json"), []byte(claudeConfig), 0644); err != nil { + t.Fatalf("Failed to write claude config: %v", err) + } + + // Also write image.json so podman config is valid + imageConfig := `{"version": "latest"}` + if err := os.WriteFile(filepath.Join(configDir, "image.json"), []byte(imageConfig), 0644); err != nil { + t.Fatalf("Failed to write image config: %v", err) + } + + rootCmd := NewRootCmd() + buf := new(bytes.Buffer) + rootCmd.SetOut(buf) + rootCmd.SetArgs([]string{"info", "--storage", storageDir, "-o", "json"}) + + err := rootCmd.Execute() + if err != nil { + t.Fatalf("Execute() failed: %v", err) + } + + var response infoResponse + if err := json.Unmarshal(buf.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + + // If podman is available, agents should include "claude" + // If podman is not available (CI), the test still passes with empty agents + if len(response.Agents) > 0 { + found := false + for _, agent := range response.Agents { + if agent == "claude" { + found = true + break + } + } + if !found { + t.Errorf("Expected agents to include 'claude', got: %v", response.Agents) + } + } + }) +} diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 9ced344..a2906d8 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -46,6 +46,7 @@ func NewRootCmd() *cobra.Command { // Add subcommands rootCmd.AddCommand(NewVersionCmd()) + rootCmd.AddCommand(NewInfoCmd()) rootCmd.AddCommand(NewInitCmd()) rootCmd.AddCommand(NewWorkspaceCmd()) rootCmd.AddCommand(NewListCmd()) diff --git a/pkg/instances/manager.go b/pkg/instances/manager.go index 0b6d16c..6ab3e4e 100644 --- a/pkg/instances/manager.go +++ b/pkg/instances/manager.go @@ -21,6 +21,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "sync" @@ -65,6 +66,10 @@ type Manager interface { Stop(ctx context.Context, id string) error // Terminal starts an interactive terminal session in a running instance Terminal(ctx context.Context, id string, command []string) error + // ListAgents returns the names of all agents configured across all registered runtimes. + // Agents are collected from runtimes that implement the runtime.AgentLister interface. + // The returned list is sorted and deduplicated. + ListAgents() ([]string, error) // List returns all registered instances List() ([]Instance, error) // Get retrieves a specific instance by ID @@ -535,6 +540,41 @@ func (m *manager) RegisterRuntime(rt runtime.Runtime) error { return m.runtimeRegistry.Register(rt) } +// ListAgents returns the names of all agents configured across all registered runtimes. +func (m *manager) ListAgents() ([]string, error) { + runtimeTypes := m.runtimeRegistry.List() + + seen := make(map[string]bool) + var agents []string + + for _, rtType := range runtimeTypes { + rt, err := m.runtimeRegistry.Get(rtType) + if err != nil { + return nil, fmt.Errorf("failed to get runtime %s: %w", rtType, err) + } + + agentLister, ok := rt.(runtime.AgentLister) + if !ok { + continue + } + + rtAgents, err := agentLister.ListAgents() + if err != nil { + return nil, fmt.Errorf("failed to list agents for runtime %s: %w", rtType, err) + } + + for _, agent := range rtAgents { + if !seen[agent] { + seen[agent] = true + agents = append(agents, agent) + } + } + } + + sort.Strings(agents) + return agents, nil +} + // generateUniqueName generates a unique name from the source directory // by extracting the last component of the path and adding an increment if needed func (m *manager) generateUniqueName(sourceDir string, instances []Instance) string { diff --git a/pkg/runtime/podman/config/config.go b/pkg/runtime/podman/config/config.go index 5a1ccb2..c1adfdf 100644 --- a/pkg/runtime/podman/config/config.go +++ b/pkg/runtime/podman/config/config.go @@ -21,6 +21,8 @@ import ( "os" "path/filepath" "regexp" + "sort" + "strings" ) var ( @@ -48,6 +50,11 @@ type Config interface { // Returns ErrInvalidConfig if the configuration is invalid. LoadAgent(agentName string) (*AgentConfig, error) + // ListAgents returns the names of all configured agents. + // It scans the configuration directory for *.json files, excluding image.json. + // Returns an empty slice if the directory does not exist. + ListAgents() ([]string, error) + // GenerateDefaults creates default configuration files if they don't exist. // Creates the configuration directory if it doesn't exist. // Does not overwrite existing configuration files. @@ -126,6 +133,36 @@ func (c *config) LoadAgent(agentName string) (*AgentConfig, error) { return &cfg, nil } +// ListAgents returns the names of all configured agents by scanning for *.json files +// in the config directory, excluding image.json. +func (c *config) ListAgents() ([]string, error) { + entries, err := os.ReadDir(c.path) + if err != nil { + if os.IsNotExist(err) { + return []string{}, nil + } + return nil, fmt.Errorf("failed to read config directory: %w", err) + } + + var agents []string + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasSuffix(name, ".json") { + continue + } + if name == ImageConfigFileName { + continue + } + agents = append(agents, strings.TrimSuffix(name, ".json")) + } + + sort.Strings(agents) + return agents, nil +} + // GenerateDefaults creates default configuration files if they don't exist func (c *config) GenerateDefaults() error { // Create the configuration directory if it doesn't exist diff --git a/pkg/runtime/podman/config/config_test.go b/pkg/runtime/podman/config/config_test.go index 354d412..80574a8 100644 --- a/pkg/runtime/podman/config/config_test.go +++ b/pkg/runtime/podman/config/config_test.go @@ -19,6 +19,7 @@ import ( "errors" "os" "path/filepath" + "slices" "strings" "testing" ) @@ -598,3 +599,90 @@ func TestLoadAgent(t *testing.T) { } }) } + +func TestListAgents(t *testing.T) { + t.Parallel() + + t.Run("returns empty list when directory does not exist", func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + configDir := filepath.Join(tempDir, "nonexistent") + + cfg, err := NewConfig(configDir) + if err != nil { + t.Fatalf("NewConfig() failed: %v", err) + } + + agents, err := cfg.ListAgents() + if err != nil { + t.Fatalf("ListAgents() failed: %v", err) + } + + if len(agents) != 0 { + t.Errorf("Expected empty list, got: %v", agents) + } + }) + + t.Run("returns claude after GenerateDefaults", func(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + + cfg, err := NewConfig(configDir) + if err != nil { + t.Fatalf("NewConfig() failed: %v", err) + } + + if err := cfg.GenerateDefaults(); err != nil { + t.Fatalf("GenerateDefaults() failed: %v", err) + } + + agents, err := cfg.ListAgents() + if err != nil { + t.Fatalf("ListAgents() failed: %v", err) + } + + expected := []string{"claude"} + if !slices.Equal(agents, expected) { + t.Errorf("Expected %v, got: %v", expected, agents) + } + }) + + t.Run("returns sorted list of multiple agents", func(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + + cfg, err := NewConfig(configDir) + if err != nil { + t.Fatalf("NewConfig() failed: %v", err) + } + + if err := cfg.GenerateDefaults(); err != nil { + t.Fatalf("GenerateDefaults() failed: %v", err) + } + + // Add a goose agent config + gooseConfig := &AgentConfig{ + Packages: []string{}, + RunCommands: []string{}, + TerminalCommand: []string{"goose"}, + } + data, _ := json.MarshalIndent(gooseConfig, "", " ") + if err := os.WriteFile(filepath.Join(configDir, "goose.json"), data, 0644); err != nil { + t.Fatalf("Failed to write goose config: %v", err) + } + + agents, err := cfg.ListAgents() + if err != nil { + t.Fatalf("ListAgents() failed: %v", err) + } + + expected := []string{"claude", "goose"} + if !slices.Equal(agents, expected) { + t.Errorf("Expected %v, got: %v", expected, agents) + } + }) + +} diff --git a/pkg/runtime/podman/podman.go b/pkg/runtime/podman/podman.go index 2434039..c9c4ba2 100644 --- a/pkg/runtime/podman/podman.go +++ b/pkg/runtime/podman/podman.go @@ -84,6 +84,19 @@ func (p *podmanRuntime) Initialize(storageDir string) error { return nil } +// Ensure podmanRuntime implements runtime.AgentLister at compile time. +var _ runtime.AgentLister = (*podmanRuntime)(nil) + +// ListAgents returns the names of all agents configured for the Podman runtime. +// It delegates to the config manager's ListAgents method. +// Returns an empty slice if the runtime has not been initialized. +func (p *podmanRuntime) ListAgents() ([]string, error) { + if p.config == nil { + return []string{}, nil + } + return p.config.ListAgents() +} + // Type returns the runtime type identifier. func (p *podmanRuntime) Type() string { return "podman" diff --git a/pkg/runtime/podman/steplogger_test.go b/pkg/runtime/podman/steplogger_test.go index 661350a..5fb32f6 100644 --- a/pkg/runtime/podman/steplogger_test.go +++ b/pkg/runtime/podman/steplogger_test.go @@ -72,6 +72,10 @@ func (f *fakeConfig) LoadAgent(agentName string) (*config.AgentConfig, error) { }, nil } +func (f *fakeConfig) ListAgents() ([]string, error) { + return []string{"claude"}, nil +} + func (f *fakeConfig) GenerateDefaults() error { return nil } diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index 65ccee2..5cd04ac 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -76,6 +76,21 @@ type RuntimeInfo struct { Info map[string]string } +// AgentLister is an optional interface for runtimes that can list their configured agents. +// Runtimes implementing this interface allow the system to discover which agents are available +// for a given runtime, enabling generic agent discovery across all registered runtimes. +// +// Example implementation: +// +// func (r *myRuntime) ListAgents() ([]string, error) { +// return r.config.ListAgents() +// } +type AgentLister interface { + // ListAgents returns the names of all agents configured for this runtime. + // Returns an empty slice if no agents are configured. + ListAgents() ([]string, error) +} + // Terminal is an optional interface for runtimes that support interactive terminal sessions. // Runtimes implementing this interface enable the terminal command for connecting to running instances. //