Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions pkg/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -772,9 +772,13 @@ func (a *clientPathAdapter) GetSkillPath(clientType, skillName string, scope ski

func (a *clientPathAdapter) ListSkillSupportingClients() []string {
clients := a.cm.ListSkillSupportingClients()
result := make([]string, len(clients))
for i, c := range clients {
result[i] = string(c)
var result []string
for _, c := range clients {
if a.cm.IsClientInstalled(c) {
result = append(result, string(c))
} else {
slog.Debug("skipping client for skill install: not detected on system", "client", c)
}
}
return result
}
Expand Down
36 changes: 19 additions & 17 deletions pkg/client/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,24 @@ type ClientAppStatus struct {
SupportsSkills bool `json:"supports_skills"`
}

// 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).
func (cm *ClientManager) IsClientInstalled(clientType ClientApp) bool {
cfg := cm.lookupClientAppConfig(clientType)
if cfg == nil {
return false
}
var pathToCheck string
if len(cfg.RelPath) == 0 {
pathToCheck = filepath.Join(cm.homeDir, cfg.SettingsFile)
} else {
pathToCheck = buildConfigDirectoryPath(cfg.RelPath, cfg.PlatformPrefix, []string{cm.homeDir})
}
_, err := os.Stat(pathToCheck)
return err == nil
}

// GetClientStatus returns the status of all supported MCP clients using this manager's dependencies
func (cm *ClientManager) GetClientStatus(ctx context.Context) ([]ClientAppStatus, error) {
var statuses []ClientAppStatus
Expand Down Expand Up @@ -106,26 +124,10 @@ func (cm *ClientManager) GetClientStatus(ctx context.Context) ([]ClientAppStatus
for _, cfg := range cm.clientIntegrations {
status := ClientAppStatus{
ClientType: cfg.ClientType,
Installed: false, // start with assuming client is not installed
Installed: cm.IsClientInstalled(cfg.ClientType),
Registered: registeredClients[string(cfg.ClientType)],
SupportsSkills: cfg.SupportsSkills,
}

// Determine path to check based on configuration
var pathToCheck string
if len(cfg.RelPath) == 0 {
// If RelPath is empty, look at just the settings file
pathToCheck = filepath.Join(cm.homeDir, cfg.SettingsFile)
} else {
// Otherwise build the directory path using RelPath
pathToCheck = buildConfigDirectoryPath(cfg.RelPath, cfg.PlatformPrefix, []string{cm.homeDir})
}

// Check if the path exists
if _, err := os.Stat(pathToCheck); err == nil {
status.Installed = true
}

statuses = append(statuses, status)
}

Expand Down
60 changes: 60 additions & 0 deletions pkg/client/discovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,66 @@ func TestGetClientStatus_Sorting(t *testing.T) {
}
}

func TestIsClientInstalled(t *testing.T) {
t.Parallel()

tempHome := t.TempDir()

// Create a .claude.json file (simulates ClaudeCode installed)
_, err := os.Create(filepath.Join(tempHome, ".claude.json"))
require.NoError(t, err)

// Create a .cursor directory (simulates Cursor installed via RelPath)
err = os.Mkdir(filepath.Join(tempHome, ".cursor"), 0700)
require.NoError(t, err)

// VSCode path (.config/Code/User) is intentionally not created

clientIntegrations := []clientAppConfig{
{
ClientType: ClaudeCode,
SettingsFile: ".claude.json",
RelPath: []string{}, // file directly in home dir
},
{
ClientType: Cursor,
SettingsFile: "mcp.json",
RelPath: []string{".cursor"}, // directory in home dir
},
{
ClientType: VSCode,
SettingsFile: "mcp.json",
RelPath: []string{".config", "Code", "User"}, // not created
},
{
// unknown client, no config
ClientType: ClientApp("nonexistent"),
SettingsFile: "settings.json",
RelPath: []string{".nonexistent"},
},
}

manager := NewTestClientManager(tempHome, nil, clientIntegrations, nil)

tests := []struct {
name string
clientType ClientApp
want bool
}{
{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: "client not in integrations", clientType: ClientApp("not-registered"), want: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, manager.IsClientInstalled(tt.clientType))
})
}
}

func TestGetClientStatus_WithGroups(t *testing.T) {
t.Parallel()

Expand Down
5 changes: 3 additions & 2 deletions pkg/skills/skillsvc/skillsvc.go
Original file line number Diff line number Diff line change
Expand Up @@ -1448,8 +1448,9 @@ func (s *service) resolveAndValidateClients(
clients := s.pathResolver.ListSkillSupportingClients()
if len(clients) == 0 {
return nil, nil, httperr.WithCode(
errors.New("no skill-supporting clients configured"),
http.StatusInternalServerError,
errors.New("no supported clients detected on this system; "+
"use --clients to target a specific client explicitly"),
http.StatusBadRequest,
)
}
requested = clients
Expand Down
6 changes: 6 additions & 0 deletions test/e2e/api_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -78,6 +79,11 @@ func NewServer(config *ServerConfig) (*Server, error) {
tempHome = GinkgoT().TempDir()
}

// Create a stub claude-code settings file so that at least one skill-supporting
// client is detected as installed. Without this, installs that omit --clients
// would fail because no client config paths exist in the temp home dir.
_ = os.WriteFile(filepath.Join(tempHome, ".claude.json"), []byte("{}"), 0600)

ctx, cancel := context.WithCancel(context.Background())

// Create string builders to capture output
Expand Down
Loading