diff --git a/AGENTS.md b/AGENTS.md index b4be285..7ad793d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -163,6 +163,7 @@ The runtime system provides a pluggable architecture for managing workspaces on **Optional Interfaces:** - **StorageAware**: Enables runtimes to persist data in a dedicated storage directory +- **AgentLister**: Enables runtimes to report which agents they support - **Terminal**: Enables interactive terminal sessions with running instances **For detailed runtime implementation guidance, use:** `/working-with-runtime-system` diff --git a/README.md b/README.md index 80935ea..dc8bbad 100644 --- a/README.md +++ b/README.md @@ -1854,6 +1854,63 @@ The system works without any configuration files and merges only the ones that e ## Commands +### `info` - Display Information About kortex-cli + +Displays version, available agents, and supported runtimes. + +#### Usage + +```bash +kortex-cli info [flags] +``` + +#### Flags + +- `--output, -o ` - Output format (supported: `json`) +- `--storage ` - Storage directory for kortex-cli data (default: `$HOME/.kortex-cli`) + +#### Examples + +**Show info (human-readable format):** +```bash +kortex-cli info +``` +Output: +```text +Version: 0.3.0 +Agents: claude +Runtimes: fake, podman +``` + +**Show info in JSON format:** +```bash +kortex-cli info --output json +``` +Output: +```json +{ + "version": "0.3.0", + "agents": [ + "claude" + ], + "runtimes": [ + "fake", + "podman" + ] +} +``` + +**Show info using short flag:** +```bash +kortex-cli info -o json +``` + +#### Notes + +- Agents are discovered from runtimes that support agent configuration (e.g., the Podman runtime reports agents from its configuration files) +- Runtimes are listed based on availability in the current environment (e.g., the Podman runtime only appears if the `podman` CLI is installed) +- **JSON error handling**: When `--output json` is used, errors are written to stdout (not stderr) in JSON format, and the CLI exits with code 1. Always check the exit code to determine success/failure + ### `init` - Register a New Workspace Registers a new workspace with kortex-cli, making it available for agent launch and configuration. diff --git a/go.mod b/go.mod index 1551018..168903d 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.1 require ( github.com/fatih/color v1.19.0 github.com/goccy/go-yaml v1.19.2 - github.com/kortex-hub/kortex-cli-api/cli/go v0.0.0-20260402113340-592f26f380bc + github.com/kortex-hub/kortex-cli-api/cli/go v0.0.0-20260403083702-361dae1613c4 github.com/kortex-hub/kortex-cli-api/workspace-configuration/go v0.0.0-20260331070743-a7c5f045c21c github.com/rodaine/table v1.3.1 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index b967b40..10afce0 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/kortex-hub/kortex-cli-api/cli/go v0.0.0-20260402113340-592f26f380bc h1:7/9dbYdsvDPNduAN/Uq8dUDH9BYUbeG0yVZ2JEuhY+k= -github.com/kortex-hub/kortex-cli-api/cli/go v0.0.0-20260402113340-592f26f380bc/go.mod h1:jWKudiw26CIjuOsROQ80FOuuk2m2/5BVkuATpmD5xQQ= +github.com/kortex-hub/kortex-cli-api/cli/go v0.0.0-20260403083702-361dae1613c4 h1:pPnBONB9KiWDl6UrNBkeQYeasFddAiDvqNn8NH8RLX0= +github.com/kortex-hub/kortex-cli-api/cli/go v0.0.0-20260403083702-361dae1613c4/go.mod h1:jWKudiw26CIjuOsROQ80FOuuk2m2/5BVkuATpmD5xQQ= github.com/kortex-hub/kortex-cli-api/workspace-configuration/go v0.0.0-20260331070743-a7c5f045c21c h1:kFz1ImgIiAJEmliiuASCJLKjiDIGFowLh6Fdd4hjFM8= github.com/kortex-hub/kortex-cli-api/workspace-configuration/go v0.0.0-20260331070743-a7c5f045c21c/go.mod h1:N4tLoDdBbAPYJ9ALLfYbqYScydJb546JgKQ6EjHswLw= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= diff --git a/pkg/cmd/autocomplete.go b/pkg/cmd/autocomplete.go index 76f7380..7ac6d7f 100644 --- a/pkg/cmd/autocomplete.go +++ b/pkg/cmd/autocomplete.go @@ -119,19 +119,10 @@ func newOutputFlagCompletion(validFormats []string) func(cmd *cobra.Command, arg } // completeRuntimeFlag provides completion for the --runtime flag -// It lists all available runtimes, excluding the "fake" runtime (used only for testing) func completeRuntimeFlag(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { // Get all available runtimes without requiring a manager instance // This avoids creating storage directories during tab-completion runtimes := runtimesetup.ListAvailable() - // Filter out "fake" runtime (used only for testing) - var filteredRuntimes []string - for _, rt := range runtimes { - if rt != "fake" { - filteredRuntimes = append(filteredRuntimes, rt) - } - } - - return filteredRuntimes, cobra.ShellCompDirectiveNoFileComp + return runtimes, cobra.ShellCompDirectiveNoFileComp } diff --git a/pkg/cmd/info.go b/pkg/cmd/info.go new file mode 100644 index 0000000..1f80d8c --- /dev/null +++ b/pkg/cmd/info.go @@ -0,0 +1,128 @@ +/********************************************************************** + * 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" + + api "github.com/kortex-hub/kortex-cli-api/cli/go" + "github.com/kortex-hub/kortex-cli/pkg/runtimesetup" + "github.com/kortex-hub/kortex-cli/pkg/version" + "github.com/spf13/cobra" +) + +// infoCmd contains the configuration for the info command +type infoCmd struct { + output string +} + +// 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) + } + + return nil +} + +// run executes the info command logic +func (i *infoCmd) run(cmd *cobra.Command, args []string) error { + runtimes := runtimesetup.ListAvailable() + + // Discover agents from runtimes that implement AgentLister + 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)) + } + + runtimeStorageDir := filepath.Join(absStorageDir, "runtimes") + agents, err := runtimesetup.ListAgents(runtimeStorageDir) + 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 := api.Info{ + 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..71dff90 --- /dev/null +++ b/pkg/cmd/info_test.go @@ -0,0 +1,225 @@ +/********************************************************************** + * 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" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + api "github.com/kortex-hub/kortex-cli-api/cli/go" + "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{} + + 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{} + + 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) + } + }) +} + +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 api.Info + 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("json output with agents from config", func(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("podman"); err != nil { + t.Skip("podman not available, skipping agent discovery test") + } + + storageDir := t.TempDir() + + // Create podman config directory with agent config files + configDir := filepath.Join(storageDir, "runtimes", "podman", "config") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + + agentConfig := `{"packages": [], "run_commands": [], "terminal_command": []}` + for _, agent := range []string{"claude", "cursor", "goose"} { + if err := os.WriteFile(filepath.Join(configDir, agent+".json"), []byte(agentConfig), 0644); err != nil { + t.Fatalf("Failed to write %s config: %v", agent, 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 api.Info + if err := json.Unmarshal(buf.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + + expected := []string{"claude", "cursor", "goose"} + if !slices.Equal(response.Agents, expected) { + t.Errorf("Expected agents %v, got: %v", expected, response.Agents) + } + }) +} diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index f632126..671a1a8 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -86,6 +86,7 @@ func NewRootCmd() *cobra.Command { // Commands without a group (will appear under "Additional Commands") rootCmd.AddCommand(NewVersionCmd()) + rootCmd.AddCommand(NewInfoCmd()) // Global flags rootCmd.PersistentFlags().String("storage", defaultStoragePath, "Directory where kortex-cli will store all its files") diff --git a/pkg/cmd/workspace_list_test.go b/pkg/cmd/workspace_list_test.go index 9a7f827..28b5b6f 100644 --- a/pkg/cmd/workspace_list_test.go +++ b/pkg/cmd/workspace_list_test.go @@ -534,8 +534,9 @@ func TestWorkspaceListCmd_E2E(t *testing.T) { if workspace.Name != addedInstance.GetName() { t.Errorf("Expected Name %s, got %s", addedInstance.GetName(), workspace.Name) } - if workspace.Project != addedInstance.GetProject() { - t.Errorf("Expected Project %s, got %s", addedInstance.GetProject(), workspace.Project) + expectedProject := addedInstance.GetProject() + if workspace.Project != expectedProject { + t.Errorf("Expected Project %s, got %s", expectedProject, workspace.Project) } if workspace.Paths.Source != addedInstance.GetSourceDir() { t.Errorf("Expected Source %s, got %s", addedInstance.GetSourceDir(), workspace.Paths.Source) diff --git a/pkg/runtime/podman/config/config.go b/pkg/runtime/podman/config/config.go index 2f5b761..79136cf 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. @@ -152,6 +159,36 @@ func (c *config) generateConfigFile(filename string, config interface{}) error { return 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 9bf9f7f..ab34e34 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" ) @@ -740,3 +741,97 @@ 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) + } + + // GenerateDefaults creates configs for all default agents + expected := []string{"claude", "cursor", "goose"} + 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) + } + + // Manually create specific agent configs (not using GenerateDefaults) + claudeConfig := &AgentConfig{ + Packages: []string{}, + RunCommands: []string{}, + TerminalCommand: []string{"claude"}, + } + data, _ := json.MarshalIndent(claudeConfig, "", " ") + if err := os.WriteFile(filepath.Join(configDir, "claude.json"), data, 0644); err != nil { + t.Fatalf("Failed to write claude config: %v", err) + } + + 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 fd3c080..c234ee3 100644 --- a/pkg/runtime/podman/podman.go +++ b/pkg/runtime/podman/podman.go @@ -39,6 +39,9 @@ var _ runtime.Runtime = (*podmanRuntime)(nil) // Ensure podmanRuntime implements runtime.StorageAware at compile time. var _ runtime.StorageAware = (*podmanRuntime)(nil) +// Ensure podmanRuntime implements runtime.AgentLister at compile time. +var _ runtime.AgentLister = (*podmanRuntime)(nil) + // New creates a new Podman runtime instance. func New() runtime.Runtime { return newWithDeps(system.New(), exec.New()) @@ -84,6 +87,16 @@ func (p *podmanRuntime) Initialize(storageDir string) error { return nil } +// ListAgents implements runtime.AgentLister. +// It returns the names of all configured agents by delegating to the config manager. +// 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/podman_test.go b/pkg/runtime/podman/podman_test.go index 275c160..ec173dd 100644 --- a/pkg/runtime/podman/podman_test.go +++ b/pkg/runtime/podman/podman_test.go @@ -17,6 +17,7 @@ package podman import ( "os" "path/filepath" + "slices" "testing" "github.com/kortex-hub/kortex-cli/pkg/runtime/podman/config" @@ -204,6 +205,63 @@ func TestPodmanRuntime_WorkspaceSourcesPath(t *testing.T) { } } +func TestPodmanRuntime_ListAgents(t *testing.T) { + t.Parallel() + + t.Run("returns empty slice when not initialized", func(t *testing.T) { + t.Parallel() + + rt := newWithDeps(system.New(), exec.New()) + + lister, ok := rt.(interface{ ListAgents() ([]string, error) }) + if !ok { + t.Fatal("Expected runtime to implement AgentLister interface") + } + + agents, err := lister.ListAgents() + if err != nil { + t.Fatalf("ListAgents() failed: %v", err) + } + + if len(agents) != 0 { + t.Errorf("Expected empty agents, got: %v", agents) + } + }) + + t.Run("returns agents after initialization", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + rt := newWithDeps(system.New(), exec.New()) + + storageAware, ok := rt.(interface{ Initialize(string) error }) + if !ok { + t.Fatal("Expected runtime to implement StorageAware interface") + } + + err := storageAware.Initialize(storageDir) + if err != nil { + t.Fatalf("Initialize() failed: %v", err) + } + + lister, ok := rt.(interface{ ListAgents() ([]string, error) }) + if !ok { + t.Fatal("Expected runtime to implement AgentLister interface") + } + + agents, err := lister.ListAgents() + if err != nil { + t.Fatalf("ListAgents() failed: %v", err) + } + + // Default initialization creates config files for all default agents + expected := []string{"claude", "cursor", "goose"} + if !slices.Equal(agents, expected) { + t.Errorf("Expected %v, got: %v", expected, agents) + } + }) +} + // fakeSystem is a fake implementation of system.System for testing. type fakeSystem struct { commandExists bool 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 b21756f..03826bf 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -88,6 +88,26 @@ type RuntimeInfo struct { Info map[string]string } +// AgentLister is an optional interface for runtimes that can report which agents they support. +// Runtimes implementing this interface enable discovery of available agents +// without requiring direct knowledge of the runtime's internal configuration. +// +// Example implementation: +// +// type myRuntime struct { +// configDir string +// } +// +// func (r *myRuntime) ListAgents() ([]string, error) { +// // Scan configuration directory for agent definitions +// return []string{"claude", "goose"}, nil +// } +type AgentLister interface { + // ListAgents returns the names of all agents supported by 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. // diff --git a/pkg/runtimesetup/register.go b/pkg/runtimesetup/register.go index 8fdf698..20bb040 100644 --- a/pkg/runtimesetup/register.go +++ b/pkg/runtimesetup/register.go @@ -16,6 +16,8 @@ package runtimesetup import ( + "sort" + "github.com/kortex-hub/kortex-cli/pkg/runtime" "github.com/kortex-hub/kortex-cli/pkg/runtime/fake" "github.com/kortex-hub/kortex-cli/pkg/runtime/podman" @@ -60,7 +62,8 @@ var availableRuntimes = []runtimeFactory{ podman.New, } -// ListAvailable returns the names of all available runtimes. +// ListAvailable returns the names of all available runtimes, excluding +// internal runtimes like "fake" (used only for testing). // It checks each runtime's availability without requiring a manager instance. // This is useful for tab-completion and other contexts where we want to avoid // creating on-disk state. @@ -81,12 +84,70 @@ func listAvailableWithFactories(factories []runtimeFactory) []string { continue } + // Skip internal runtimes not intended for display + if rt.Type() == "fake" { + continue + } + names = append(names, rt.Type()) } return names } +// ListAgents returns the names of all agents supported by available runtimes. +// It creates a temporary registry with the given storage directory, registers all +// available runtimes (which initializes StorageAware runtimes), and then queries +// each runtime that implements the AgentLister interface. +func ListAgents(runtimeStorageDir string) ([]string, error) { + return listAgentsWithFactories(runtimeStorageDir, availableRuntimes) +} + +// listAgentsWithFactories returns agent names from the given runtime factories. +// This function is internal and used for testing with custom runtime lists. +func listAgentsWithFactories(runtimeStorageDir string, factories []runtimeFactory) ([]string, error) { + registry, err := runtime.NewRegistry(runtimeStorageDir) + if err != nil { + return nil, err + } + + agentSet := make(map[string]struct{}) + for _, factory := range factories { + rt := factory() + + // Skip runtimes that are not available in this environment + if avail, ok := rt.(Available); ok && !avail.Available() { + continue + } + + // Register to initialize StorageAware runtimes with their storage directory + if err := registry.Register(rt); err != nil { + continue + } + + lister, ok := rt.(runtime.AgentLister) + if !ok { + continue + } + + agents, err := lister.ListAgents() + if err != nil { + continue + } + + for _, agent := range agents { + agentSet[agent] = struct{}{} + } + } + + agents := make([]string, 0, len(agentSet)) + for agent := range agentSet { + agents = append(agents, agent) + } + sort.Strings(agents) + return agents, nil +} + // RegisterAll registers all available runtimes to the given registrar. // It skips runtimes that implement the Available interface and report false. // Returns an error if any runtime fails to register. diff --git a/skills/add-runtime/SKILL.md b/skills/add-runtime/SKILL.md index 95928b5..2497111 100644 --- a/skills/add-runtime/SKILL.md +++ b/skills/add-runtime/SKILL.md @@ -430,6 +430,21 @@ When implemented, the registry will: 2. Call `Initialize()` with the path 3. The runtime can use this directory to persist data +### AgentLister Interface (optional) + +Implement if the runtime can report which agents it supports: + +```go +type AgentLister interface { + ListAgents() ([]string, error) +} +``` + +Use this to: +- Report agents discovered from configuration files +- Enable the `info` command to display available agents +- Allow agent discovery without runtime-specific knowledge + ### Available Interface (optional) Implement to control runtime availability: diff --git a/skills/working-with-runtime-system/SKILL.md b/skills/working-with-runtime-system/SKILL.md index 64a41ca..604f8fb 100644 --- a/skills/working-with-runtime-system/SKILL.md +++ b/skills/working-with-runtime-system/SKILL.md @@ -91,6 +91,36 @@ func (r *myRuntime) Create(ctx context.Context, params runtime.CreateParams) (ru } ``` +### AgentLister Interface + +The AgentLister interface enables runtimes to report which agents they support. This is used by the `info` command to discover available agents without requiring direct knowledge of runtime-specific configuration. + +```go +type AgentLister interface { + ListAgents() ([]string, error) +} +``` + +**How it works:** + +When a runtime implements AgentLister, the `runtimesetup.ListAgents()` function will: +1. Create a registry and register all available runtimes (triggering StorageAware initialization) +2. Query each runtime that implements AgentLister +3. Collect and deduplicate agent names across all runtimes + +**Example implementation (Podman runtime):** + +```go +func (p *podmanRuntime) ListAgents() ([]string, error) { + if p.config == nil { + return []string{}, nil + } + return p.config.ListAgents() +} +``` + +This pattern decouples agent discovery from runtime-specific configuration details, allowing the `info` command to query agents generically through the runtime interface. + ### Terminal Interface The Terminal interface enables interactive terminal sessions for connecting to running instances. This is used by the `terminal` command.