Skip to content

move client configuration commands under thv client #1040

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jul 14, 2025
114 changes: 114 additions & 0 deletions cmd/thv/app/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/stacklok/toolhive/cmd/thv/app/ui"
"github.com/stacklok/toolhive/pkg/client"
"github.com/stacklok/toolhive/pkg/config"
)

var clientCmd = &cobra.Command{
Expand All @@ -29,11 +30,51 @@ var clientSetupCmd = &cobra.Command{
RunE: clientSetupCmdFunc,
}

var clientRegisterCmd = &cobra.Command{
Use: "register [client]",
Short: "Register a client for MCP server configuration",
Long: `Register a client for MCP server configuration.
Valid clients are:
- claude-code: Claude Code CLI
- cline: Cline extension for VS Code
- cursor: Cursor editor
- roo-code: Roo Code extension for VS Code
- vscode: Visual Studio Code
- vscode-insider: Visual Studio Code Insiders edition`,
Args: cobra.ExactArgs(1),
RunE: clientRegisterCmdFunc,
}

var clientRemoveCmd = &cobra.Command{
Use: "remove [client]",
Short: "Remove a client from MCP server configuration",
Long: `Remove a client from MCP server configuration.
Valid clients are:
- claude-code: Claude Code CLI
- cline: Cline extension for VS Code
- cursor: Cursor editor
- roo-code: Roo Code extension for VS Code
- vscode: Visual Studio Code
- vscode-insider: Visual Studio Code Insiders edition`,
Args: cobra.ExactArgs(1),
RunE: clientRemoveCmdFunc,
}

var clientListRegisteredCmd = &cobra.Command{
Use: "list-registered",
Short: "List all registered MCP clients",
Long: "List all clients that are registered for MCP server configuration.",
RunE: listRegisteredClientsCmdFunc,
}

func init() {
rootCmd.AddCommand(clientCmd)

clientCmd.AddCommand(clientStatusCmd)
clientCmd.AddCommand(clientSetupCmd)
clientCmd.AddCommand(clientRegisterCmd)
clientCmd.AddCommand(clientRemoveCmd)
clientCmd.AddCommand(clientListRegisteredCmd)
}

func clientStatusCmdFunc(_ *cobra.Command, _ []string) error {
Expand Down Expand Up @@ -101,3 +142,76 @@ func registerSelectedClients(cmd *cobra.Command, clientsToRegister []client.MCPC

return nil
}

func clientRegisterCmdFunc(cmd *cobra.Command, args []string) error {
clientType := args[0]

// Validate the client type
switch clientType {
case "roo-code", "cline", "cursor", "claude-code", "vscode-insider", "vscode":
// Valid client type
default:
return fmt.Errorf(
"invalid client type: %s (valid types: roo-code, cline, cursor, claude-code, vscode, vscode-insider)",
clientType)
}

ctx := cmd.Context()

manager, err := client.NewManager(ctx)
if err != nil {
return fmt.Errorf("failed to create client manager: %w", err)
}

err = manager.RegisterClients(ctx, []client.Client{
{Name: client.MCPClient(clientType)},
})
if err != nil {
return fmt.Errorf("failed to register client %s: %w", clientType, err)
}

return nil
}

func clientRemoveCmdFunc(cmd *cobra.Command, args []string) error {
clientType := args[0]

// Validate the client type
switch clientType {
case "roo-code", "cline", "cursor", "claude-code", "vscode-insider", "vscode":
// Valid client type
default:
return fmt.Errorf(
"invalid client type: %s (valid types: roo-code, cline, cursor, claude-code, vscode, vscode-insider)",
clientType)
}

ctx := cmd.Context()

manager, err := client.NewManager(ctx)
if err != nil {
return fmt.Errorf("failed to create client manager: %w", err)
}

err = manager.UnregisterClients(ctx, []client.Client{
{Name: client.MCPClient(clientType)},
})
if err != nil {
return fmt.Errorf("failed to remove client %s: %w", clientType, err)
}

return nil
}

func listRegisteredClientsCmdFunc(_ *cobra.Command, _ []string) error {
cfg := config.GetConfig()
if len(cfg.Clients.RegisteredClients) == 0 {
fmt.Println("No clients are currently registered.")
return nil
}
fmt.Println("Registered clients:")
for _, clientName := range cfg.Clients.RegisteredClients {
fmt.Printf(" - %s\n", clientName)
}
return nil
}
85 changes: 85 additions & 0 deletions cmd/thv/app/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package app

import (
"bytes"
"os"
"strings"
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"

"github.com/stacklok/toolhive/pkg/config"
)

func registerClientViaCLI(cmd *cobra.Command, client string) error {
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"client", "register", client})
return cmd.Execute()
}

func removeClientViaCLI(cmd *cobra.Command, client string) error {
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"client", "remove", client})
return cmd.Execute()
}

func TestClientRegisterCmd(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
os.Setenv("XDG_CONFIG_HOME", tempDir)

cmd := rootCmd

err := registerClientViaCLI(cmd, "vscode")
assert.NoError(t, err)

cfg := config.GetConfig()
assert.Contains(t, cfg.Clients.RegisteredClients, "vscode", "Client should be registered")
}

func TestClientRemoveCmd(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
os.Setenv("XDG_CONFIG_HOME", tempDir)

// Pre-populate config with a registered client
err := config.UpdateConfig(func(c *config.Config) {
c.Clients.RegisteredClients = []string{"vscode"}
})
assert.NoError(t, err)

cmd := rootCmd

err = removeClientViaCLI(cmd, "vscode")
assert.NoError(t, err)

cfg := config.GetConfig()
assert.NotContains(t, cfg.Clients.RegisteredClients, "vscode", "Client should be removed")
}

func TestClientRegisterCmd_InvalidClient(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
os.Setenv("XDG_CONFIG_HOME", tempDir)

cmd := rootCmd

err := registerClientViaCLI(cmd, "not-a-client")
assert.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "invalid client type"))
}

func TestClientRemoveCmd_InvalidClient(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
os.Setenv("XDG_CONFIG_HOME", tempDir)

cmd := rootCmd

err := removeClientViaCLI(cmd, "not-a-client")
assert.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "invalid client type"))
}
Loading
Loading