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
43 changes: 41 additions & 2 deletions pkg/client/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,12 @@ type clientAppConfig struct {
YAMLDefaults map[string]any // Default values to add to entries
// TOML-specific configuration (only used when Extension == TOML)
TOMLStorageType TOMLStorageType // How servers are stored in TOML (map or array)
// ConfigDirEnvVar names an environment variable that, when set, relocates
// the client's config directory. It replaces the home-relative directory
// holding SettingsFile and LLMSettingsFile, and the global skills directory
// becomes <dir>/skills. A leading "~" expands to the home directory and a
// relative value resolves against the working directory.
ConfigDirEnvVar string
// Skill-specific configuration
SupportsSkills bool // Whether this client supports skills
SkillsGlobalPath []string // Path segments for global skills dir (from home dir)
Expand Down Expand Up @@ -1052,6 +1058,7 @@ var supportedClientIntegrations = []clientAppConfig{
types.TransportTypeSSE: defaultURLFieldName,
types.TransportTypeStreamableHTTP: defaultURLFieldName,
},
ConfigDirEnvVar: "PI_CODING_AGENT_DIR",
SupportsSkills: true,
SkillsGlobalPath: []string{".pi", "agent", skillsDirName},
SkillsProjectPath: []string{".pi", skillsDirName},
Expand Down Expand Up @@ -1286,7 +1293,7 @@ func (cm *ClientManager) CreateClientConfig(clientType ClientApp) (*ConfigFile,
}

// Build the path to the configuration file
path := buildConfigFilePath(clientCfg.SettingsFile, clientCfg.RelPath, clientCfg.PlatformPrefix, []string{cm.homeDir})
path := cm.buildMCPConfigPath(clientCfg)

// Validate that the file does not already exist
if _, err := os.Stat(path); !os.IsNotExist(err) {
Expand Down Expand Up @@ -1392,7 +1399,7 @@ func (cm *ClientManager) retrieveConfigFileMetadata(clientType ClientApp) (*Conf
}

// Build the path to the configuration file
path := buildConfigFilePath(clientCfg.SettingsFile, clientCfg.RelPath, clientCfg.PlatformPrefix, []string{cm.homeDir})
path := cm.buildMCPConfigPath(clientCfg)

// Validate that the file exists
if err := validateConfigFileExists(path); err != nil {
Expand Down Expand Up @@ -1446,6 +1453,38 @@ func (cm *ClientManager) retrieveConfigFileMetadata(clientType ClientApp) (*Conf
}, nil
}

// buildMCPConfigPath resolves the absolute path to the client's MCP settings
// file, honoring cfg.ConfigDirEnvVar when it is set.
func (cm *ClientManager) buildMCPConfigPath(cfg *clientAppConfig) string {
if dir := cm.configDirOverride(cfg); dir != "" {
return filepath.Join(dir, cfg.SettingsFile)
}
return buildConfigFilePath(cfg.SettingsFile, cfg.RelPath, cfg.PlatformPrefix, []string{cm.homeDir})
}

// configDirOverride returns the absolute directory named by cfg.ConfigDirEnvVar,
// or "" when the client has no override variable or it is unset.
func (cm *ClientManager) configDirOverride(cfg *clientAppConfig) string {
if cfg.ConfigDirEnvVar == "" {
return ""
}
dir := os.Getenv(cfg.ConfigDirEnvVar)
switch {
case dir == "":
return ""
case dir == "~":
dir = cm.homeDir
case strings.HasPrefix(dir, "~/"):
dir = filepath.Join(cm.homeDir, dir[2:])
}
if !filepath.IsAbs(dir) {
if absolute, err := filepath.Abs(dir); err == nil {
dir = absolute
}
}
return dir
}

func buildConfigFilePath(settingsFile string, relPath []string, platformPrefix map[Platform][]string, path []string) string {
if prefix, ok := platformPrefix[Platform(runtime.GOOS)]; ok {
path = append(path, prefix...)
Expand Down
15 changes: 9 additions & 6 deletions pkg/client/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,15 @@ func (cm *ClientManager) IsClientInstalled(clientType ClientApp) bool {
if cfg == nil || cfg.LLMGatewayOnly {
return false
}
var pathToCheck string
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})
}
pathToCheck := func() string {
if dir := cm.configDirOverride(cfg); dir != "" {
return dir
}
if len(cfg.RelPath) == 0 && len(cfg.PlatformPrefix) == 0 {
return filepath.Join(cm.homeDir, cfg.SettingsFile)
}
return buildConfigDirectoryPath(cfg.RelPath, cfg.PlatformPrefix, []string{cm.homeDir})
}()
_, err := os.Stat(pathToCheck)
return err == nil
}
Expand Down
19 changes: 4 additions & 15 deletions pkg/client/llm_gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -547,22 +547,11 @@ func (cm *ClientManager) LLMClientDetectionHint(clientType ClientApp) string {
}

// buildLLMSettingsPath resolves the absolute path to the LLM settings file
// for the given client using the same PlatformPrefix logic as MCP config paths.
// for the given client using the same PlatformPrefix logic as MCP config paths,
// honoring cfg.ConfigDirEnvVar when it is set.
func (cm *ClientManager) buildLLMSettingsPath(cfg *clientAppConfig) string {
if cfg.ClientType == ClientApp(Pi) {
if dir := os.Getenv("PI_CODING_AGENT_DIR"); dir != "" {
if dir == "~" {
dir = cm.homeDir
} else if strings.HasPrefix(dir, "~/") {
dir = filepath.Join(cm.homeDir, dir[2:])
}
if !filepath.IsAbs(dir) {
if absolute, err := filepath.Abs(dir); err == nil {
dir = absolute
}
}
return filepath.Join(dir, cfg.LLMSettingsFile)
}
if dir := cm.configDirOverride(cfg); dir != "" {
return filepath.Join(dir, cfg.LLMSettingsFile)
}
return buildConfigFilePath(
cfg.LLMSettingsFile,
Expand Down
28 changes: 28 additions & 0 deletions pkg/client/llm_gateway_pi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/stretchr/testify/require"

"github.com/stacklok/toolhive/pkg/llmgateway"
"github.com/stacklok/toolhive/pkg/skills"
)

func TestPiGatewaySetupAndTeardown(t *testing.T) {
Expand Down Expand Up @@ -169,6 +170,33 @@ func TestPiGatewayAgentDirOverride(t *testing.T) {
require.Equal(t, relativePath, cm.buildLLMSettingsPath(cm.lookupClientAppConfig(ClientApp(Pi))))
}

// TestPiAgentDirOverrideAppliesToMCPAndSkills pins that PI_CODING_AGENT_DIR
// relocates Pi's MCP config and global skills alongside models.json, so
// `thv client register` and `thv skill install` land in the same directory as
// `thv llm setup`.
func TestPiAgentDirOverrideAppliesToMCPAndSkills(t *testing.T) {
home, agentDir := t.TempDir(), filepath.Join(t.TempDir(), "agent")
t.Setenv("PI_CODING_AGENT_DIR", agentDir)
cm := NewTestClientManager(home, nil, supportedClientIntegrations, nil)

mcpPath, err := cm.GetConfigPath(ClientApp(Pi))
require.NoError(t, err)
require.Equal(t, filepath.Join(agentDir, "mcp.json"), mcpPath)

skillPath, err := cm.GetSkillPath(ClientApp(Pi), "my-skill", skills.ScopeUser, "")
require.NoError(t, err)
require.Equal(t, filepath.Join(agentDir, "skills", "my-skill"), skillPath)

require.False(t, cm.IsClientInstalled(ClientApp(Pi)))
require.NoError(t, os.MkdirAll(agentDir, 0o700))
require.True(t, cm.IsClientInstalled(ClientApp(Pi)))

_, err = cm.CreateClientConfig(ClientApp(Pi))
require.NoError(t, err)
require.FileExists(t, mcpPath)
require.NoFileExists(t, filepath.Join(home, ".pi", "agent", "mcp.json"))
}

func TestPiGatewayAnthropicOnly(t *testing.T) {
t.Parallel()
home := t.TempDir()
Expand Down
2 changes: 1 addition & 1 deletion pkg/client/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,5 +112,5 @@ func (cm *ClientManager) GetConfigPath(clientType ClientApp) (string, error) {
if cfg == nil {
return "", fmt.Errorf("%w: %s", ErrUnsupportedClientType, clientType)
}
return buildConfigFilePath(cfg.SettingsFile, cfg.RelPath, cfg.PlatformPrefix, []string{cm.homeDir}), nil
return cm.buildMCPConfigPath(cfg), nil
}
7 changes: 6 additions & 1 deletion pkg/client/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ func (cm *ClientManager) ListSkillSupportingClients() []ClientApp {

// GetSkillPath resolves the filesystem path for a skill installation.
//
// For [skills.ScopeUser], it returns ~/<SkillsGlobalPath>/<skillName>.
// For [skills.ScopeUser], it returns ~/<SkillsGlobalPath>/<skillName>, or
// <dir>/skills/<skillName> when the client's ConfigDirEnvVar is set.
// For [skills.ScopeProject], it returns <projectRoot>/<SkillsProjectPath>/<skillName>.
//
// Returns an error if the client doesn't support skills, the scope has no
Expand Down Expand Up @@ -125,6 +126,10 @@ func (cm *ClientManager) buildSkillsGlobalPath(cfg *clientAppConfig, skillName s
return "", fmt.Errorf("%w: %s has no global skill path", ErrNoSkillPath, cfg.ClientType)
}

if dir := cm.configDirOverride(cfg); dir != "" {
return filepath.Join(dir, skillsDirName, skillName), nil
}

parts := []string{cm.homeDir}
if prefix, ok := cfg.SkillsPlatformPrefix[Platform(runtime.GOOS)]; ok {
parts = append(parts, prefix...)
Expand Down
Loading