diff --git a/docs/cli/thv_client_register.md b/docs/cli/thv_client_register.md index 0ce5cfeb50..0cef577fba 100644 --- a/docs/cli/thv_client_register.md +++ b/docs/cli/thv_client_register.md @@ -34,6 +34,7 @@ Valid clients: - lm-studio: LM Studio application - mistral-vibe: Mistral Vibe IDE - opencode: OpenCode editor + - qoder: Qoder IDE - roo-code: VS Code Roo Code extension (deprecated) - trae: Trae IDE - vscode: Visual Studio Code diff --git a/docs/cli/thv_client_remove.md b/docs/cli/thv_client_remove.md index c5bb53a5e1..52ec371f4c 100644 --- a/docs/cli/thv_client_remove.md +++ b/docs/cli/thv_client_remove.md @@ -34,6 +34,7 @@ Valid clients: - lm-studio: LM Studio application - mistral-vibe: Mistral Vibe IDE - opencode: OpenCode editor + - qoder: Qoder IDE - roo-code: VS Code Roo Code extension (deprecated) - trae: Trae IDE - vscode: Visual Studio Code diff --git a/docs/server/docs.go b/docs/server/docs.go index 44296cc803..c59e7101c2 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -873,7 +873,8 @@ const docTemplate = `{ "codex", "kimi-cli", "factory", - "copilot-cli" + "copilot-cli", + "qoder" ], "type": "string", "x-enum-varnames": [ @@ -900,7 +901,8 @@ const docTemplate = `{ "Codex", "KimiCli", "Factory", - "CopilotCli" + "CopilotCli", + "Qoder" ] }, "github_com_stacklok_toolhive_pkg_client.ClientAppStatus": { diff --git a/docs/server/swagger.json b/docs/server/swagger.json index f80830c124..55497068d2 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -866,7 +866,8 @@ "codex", "kimi-cli", "factory", - "copilot-cli" + "copilot-cli", + "qoder" ], "type": "string", "x-enum-varnames": [ @@ -893,7 +894,8 @@ "Codex", "KimiCli", "Factory", - "CopilotCli" + "CopilotCli", + "Qoder" ] }, "github_com_stacklok_toolhive_pkg_client.ClientAppStatus": { diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index fde91175c8..795a5a94ec 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -1072,6 +1072,7 @@ components: - kimi-cli - factory - copilot-cli + - qoder type: string x-enum-varnames: - RooCode @@ -1098,6 +1099,7 @@ components: - KimiCli - Factory - CopilotCli + - Qoder github_com_stacklok_toolhive_pkg_client.ClientAppStatus: properties: client_type: diff --git a/pkg/client/config.go b/pkg/client/config.go index 613f8405ad..fc560430b8 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -93,6 +93,8 @@ const ( Factory ClientApp = "factory" // CopilotCli represents the GitHub Copilot CLI. CopilotCli ClientApp = "copilot-cli" + // Qoder represents the Qoder IDE. + Qoder ClientApp = "qoder" ) const ( @@ -1005,6 +1007,33 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeStreamableHTTP: defaultURLFieldName, }, }, + { + ClientType: Qoder, + Description: "Qoder IDE", + SettingsFile: "mcp.json", + MCPServersPathPrefix: "/mcpServers", + RelPath: []string{}, + PlatformPrefix: map[Platform][]string{ + PlatformDarwin: {".qoder"}, + PlatformLinux: {".qoder"}, + PlatformWindows: {"AppData", "Roaming", "Qoder", "SharedClientCache"}, + }, + Extension: JSON, + IsTransportTypeFieldSupported: true, + SupportedTransportTypesMap: map[types.TransportType]string{ + types.TransportTypeStdio: httpTransportLabel, + types.TransportTypeSSE: "sse", + types.TransportTypeStreamableHTTP: httpTransportLabel, + }, + MCPServersUrlLabelMap: map[types.TransportType]string{ + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, + }, + SupportsSkills: true, + SkillsGlobalPath: []string{".qoder", skillsDirName}, + SkillsProjectPath: []string{".qoder", skillsDirName}, + }, { // Xcode does not support MCP; it is an LLM-gateway-only entry. // Cast LLMClientApp → ClientApp for internal config storage; the type diff --git a/pkg/client/config_test.go b/pkg/client/config_test.go index 0daffd239a..2672d8fd07 100644 --- a/pkg/client/config_test.go +++ b/pkg/client/config_test.go @@ -193,6 +193,14 @@ func createMockClientConfigs() []clientAppConfig { MCPServersPathPrefix: "/mcpServers", Extension: JSON, }, + { + ClientType: Qoder, + Description: "Qoder (Mock)", + RelPath: []string{"mock_qoder"}, + SettingsFile: "mcp.json", + MCPServersPathPrefix: "/mcpServers", + Extension: JSON, + }, } } @@ -376,6 +384,7 @@ func TestSuccessfulClientConfigOperations(t *testing.T) { string(Codex), string(KimiCli), string(Factory), + string(Qoder), }, }, } @@ -456,7 +465,7 @@ func TestSuccessfulClientConfigOperations(t *testing.T) { case AmpCli: assert.Contains(t, string(content), `"mcpServers":`, "AmpCli config should contain mcpServers key") - case LMStudio, Trae, Kiro, Antigravity, GeminiCli, KimiCli, Factory, CopilotCli: + case Qoder, LMStudio, Trae, Kiro, Antigravity, GeminiCli, KimiCli, Factory, CopilotCli: assert.Contains(t, string(content), `"mcpServers":`, "Config should contain mcpServers key") case VSCodeServer: @@ -515,7 +524,7 @@ func TestSuccessfulClientConfigOperations(t *testing.T) { "VSCode config should contain the server URL") case Cursor, RooCode, ClaudeCode, Cline, Windsurf, WindsurfJetBrains, AmpCli, LMStudio, Goose, Trae, Continue, OpenCode, Kiro, Antigravity, Zed, GeminiCli, VSCodeServer, - MistralVibe, Codex, KimiCli, Factory, CopilotCli: + MistralVibe, Codex, Qoder, KimiCli, Factory, CopilotCli: assert.Contains(t, string(content), testURL, "Config should contain the server URL") } @@ -1244,8 +1253,8 @@ func TestGetAllClients(t *testing.T) { clients := GetAllClients() - // Should return all 24 supported clients - assert.Len(t, clients, 24, "Expected 24 supported clients") + // Should return all 25 supported clients + assert.Len(t, clients, 25, "Expected 25 supported clients") // Verify the list is sorted alphabetically for i := 1; i < len(clients); i++ { @@ -1459,7 +1468,7 @@ func TestGetClientListCSV(t *testing.T) { clientNames[i-1], clientNames[i]) } - // Count the number of clients (should be 24) + // Count the number of clients (should be 25) clients := strings.Split(csv, ", ") - assert.Len(t, clients, 24, "Expected 24 clients in CSV list") + assert.Len(t, clients, 25, "Expected 25 clients in CSV list") } diff --git a/pkg/client/discovery.go b/pkg/client/discovery.go index 5173b2dd24..e554076fe2 100644 --- a/pkg/client/discovery.go +++ b/pkg/client/discovery.go @@ -106,14 +106,15 @@ type ClientAppStatus struct { // IsClientInstalled reports whether the given client appears to be installed on // the current system. Detection is based on the presence of the client's -// configuration directory (or settings file when no relative path is defined). +// configuration directory (or settings file when neither a relative path nor a +// platform prefix is defined). func (cm *ClientManager) IsClientInstalled(clientType ClientApp) bool { cfg := cm.lookupClientAppConfig(clientType) if cfg == nil || cfg.LLMGatewayOnly { return false } var pathToCheck string - if len(cfg.RelPath) == 0 { + if len(cfg.RelPath) == 0 && len(cfg.PlatformPrefix) == 0 { pathToCheck = filepath.Join(cm.homeDir, cfg.SettingsFile) } else { pathToCheck = buildConfigDirectoryPath(cfg.RelPath, cfg.PlatformPrefix, []string{cm.homeDir}) diff --git a/pkg/client/discovery_test.go b/pkg/client/discovery_test.go index dc6b37dd9e..2218b94fbe 100644 --- a/pkg/client/discovery_test.go +++ b/pkg/client/discovery_test.go @@ -7,6 +7,7 @@ import ( "context" "os" "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -215,6 +216,17 @@ func TestIsClientInstalled(t *testing.T) { // VSCode path (.config/Code/User) is intentionally not created + // Qoder resolves via PlatformPrefix only (empty RelPath). Resolve its + // directory for the current OS and create it to simulate an install. + qoderPlatformPrefix := map[Platform][]string{ + PlatformDarwin: {".qoder"}, + PlatformLinux: {".qoder"}, + PlatformWindows: {"AppData", "Roaming", "Qoder", "SharedClientCache"}, + } + qoderDir := buildConfigDirectoryPath([]string{}, qoderPlatformPrefix, []string{tempHome}) + err = os.MkdirAll(qoderDir, 0700) + require.NoError(t, err) + clientIntegrations := []clientAppConfig{ { ClientType: ClaudeCode, @@ -231,6 +243,22 @@ func TestIsClientInstalled(t *testing.T) { SettingsFile: "mcp.json", RelPath: []string{".config", "Code", "User"}, // not created }, + { + // PlatformPrefix-only client whose directory is present + ClientType: Qoder, + SettingsFile: "mcp.json", + PlatformPrefix: qoderPlatformPrefix, + }, + { + // PlatformPrefix-only client whose directory is absent + ClientType: ClientApp("qoder-missing"), + SettingsFile: "mcp.json", + PlatformPrefix: map[Platform][]string{ + PlatformDarwin: {".qoder-missing"}, + PlatformLinux: {".qoder-missing"}, + PlatformWindows: {"AppData", "Roaming", "QoderMissing", "SharedClientCache"}, + }, + }, { // unknown client, no config ClientType: ClientApp("nonexistent"), @@ -249,6 +277,8 @@ func TestIsClientInstalled(t *testing.T) { {name: "ClaudeCode settings file present", clientType: ClaudeCode, want: true}, {name: "Cursor directory present", clientType: Cursor, want: true}, {name: "VSCode directory absent", clientType: VSCode, want: false}, + {name: "PlatformPrefix-only directory present", clientType: Qoder, want: true}, + {name: "PlatformPrefix-only directory absent", clientType: ClientApp("qoder-missing"), want: false}, {name: "client not in integrations", clientType: ClientApp("not-registered"), want: false}, } @@ -341,3 +371,28 @@ func TestGetClientStatus_WithGroups(t *testing.T) { assert.True(t, cursorStatus.Installed) assert.True(t, cursorStatus.Registered, "Cursor should be registered via groups") } + +func TestQoderConfigPathsResolveCorrectly(t *testing.T) { + t.Parallel() + + cfg := NewTestClientManager(t.TempDir(), nil, supportedClientIntegrations, nil).lookupClientAppConfig(Qoder) + require.NotNil(t, cfg, "Qoder config should exist") + require.Empty(t, cfg.RelPath, "Qoder uses PlatformPrefix only") + + home := t.TempDir() + + // The documented Qoder config locations (verified on a real install): + // macOS/Linux: ~/.qoder/mcp.json + // Windows: %APPDATA%\Qoder\SharedClientCache\mcp.json + expected := map[Platform]string{ + PlatformDarwin: filepath.Join(home, ".qoder"), + PlatformLinux: filepath.Join(home, ".qoder"), + PlatformWindows: filepath.Join(home, "AppData", "Roaming", "Qoder", "SharedClientCache"), + } + + want, ok := expected[Platform(runtime.GOOS)] + require.True(t, ok, "Qoder config must define a PlatformPrefix for the current platform") + + got := buildConfigDirectoryPath(cfg.RelPath, cfg.PlatformPrefix, []string{home}) + assert.Equal(t, want, got) +}