Files
kit/internal/config/config_test.go
T
Sai Karthik 7f366eab84 cmd: add --no-skills, --skill, and --skills-dir CLI flags & config (#55)
* cmd: add --no-skills, --skill, and --skills-dir CLI flags

The pkg/kit Options struct already had full backend support for skills
control (NoSkills, Skills []string, SkillsDir) wired into loadSkills()
in pkg/kit/kit.go, but there were no corresponding CLI flags to drive
them. This commit closes that gap.

Changes in cmd/root.go:

- Add three package-level flag variables alongside the existing
  noExtensionsFlag/extensionPaths group:
    noSkillsFlag bool
    skillsPaths  []string
    skillsDir    string

- Register three persistent cobra flags in init():
    --no-skills        disable skill loading (auto-discovery and explicit)
    --skill <path>     load a skill file or directory (repeatable)
    --skills-dir <dir> override the project-local skills directory
                       used for auto-discovery

- Wire all three into the kitOpts struct literal in runNormalMode()
  so they flow directly into kit.New() -> loadSkills().

No changes to pkg/kit or internal/skills -- the backend was already
complete. No viper binding is needed because kit.go reads these fields
directly from opts rather than from viper (unlike NoExtensions which
uses the viper fallback path).

Example usage:
  kit --no-skills "prompt"
  kit --skill ./my-skill.md --skill ./other-skill.md "prompt"
  kit --skills-dir /path/to/skills "prompt"

Co-authored-by: Claude <claude@anthropic.com>

* docs: document --no-skills, --skill, and --skills-dir CLI flags

Add the three new skills CLI flags to all relevant documentation:

- README.md: add Skills section under Global Flags CLI reference
- www/pages/cli/flags.md: add Skills table (mirrors Extensions section pattern)
- www/pages/cli/commands.md: expand the Skills section with usage examples
  and a description of auto-discovery vs explicit loading vs --no-skills

Co-authored-by: Claude <claude@anthropic.com>

* feat: add config file support for skills options

Skills could previously only be controlled via CLI flags or SDK Options
fields. This commit wires all three skills settings into viper so they
can also be set in .kit.yml / .kit.yaml / .kit.json and via KIT_*
environment variables — matching the pattern used by no-extensions,
no-core-tools, and prompt-template.

cmd/root.go:
- Bind --no-skills, --skill, and --skills-dir flags to viper keys
  (no-skills, skill, skills-dir) so config file values flow through.

pkg/kit/kit.go:
- At skill-load time, merge opts fields with viper values:
  - noSkills = opts.NoSkills || v.GetBool("no-skills")
  - skillPaths: opts.Skills if non-empty, else v.GetStringSlice("skill")
  - skillsDir: opts.SkillsDir if non-empty, else v.GetString("skills-dir")
- Build a shallow-copied mergedOpts so loadSkills() picks up the
  resolved values without mutating the original Options struct.

docs:
- README.md: add skills keys to the Basic Configuration YAML example
- www/pages/configuration.md: add no-skills, skill, skills-dir rows to
  the All configuration keys table

Config file example (.kit.yml):
  no-skills: false
  skill:
    - /path/to/skill.md
  skills-dir: /path/to/skills/

Co-authored-by: Claude <claude@anthropic.com>

* config: add skills keys to default .kit.yml template

Add no-skills, skill, and skills-dir as commented-out examples in the
default config file generated by EnsureConfigExists(), alongside the
existing application settings block.

Co-authored-by: Claude <claude@anthropic.com>

* test: add test coverage for skills CLI flags and config keys

Four test locations updated:

pkg/kit/export_test.go:
- Add ConfigStringSliceForTest() helper to expose v.GetStringSlice()
  from the Kit's isolated viper store, needed to assert skill list values.

pkg/kit/kit_test.go (TestNewWithSkillsOptions):
- NoSkills=true: GetSkills() returns empty slice
- SkillsDir=<empty dir>: kit.New() succeeds with zero skills
- Skills=[file]: single explicit skill file is loaded and name parsed correctly

pkg/kit/viper_isolation_test.go:
- TestSkillsViperKeys: no-API-key struct-level checks for NoSkills, Skills,
  and SkillsDir fields on Options
- TestSkillsConfigFileKeys: full kit.New() round-trips via a written .kit.yml
  for each of the three config keys:
    no-skills: true  → GetSkills() returns empty
    skill: [path]    → named skill loaded from config file path
    skills-dir: dir  → custom discovery root accepted without error

internal/config/config_test.go (TestEnsureConfigExists):
- Assert generated ~/.kit.yml template contains '# Skills configuration',
  'no-skills:', and 'skills-dir:' comment blocks.

Co-authored-by: Claude <claude@anthropic.com>

---------

Co-authored-by: Claude <claude@anthropic.com>
2026-06-12 16:23:17 +03:00

722 lines
20 KiB
Go

package config
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func TestMCPServerConfig_NewFormat(t *testing.T) {
// Test new simplified format
jsonData := `{
"type": "local",
"command": ["bun", "x", "my-mcp-command"],
"environment": {
"MY_ENV_VAR": "my_env_var_value"
}
}`
var config MCPServerConfig
err := json.Unmarshal([]byte(jsonData), &config)
if err != nil {
t.Fatalf("Failed to unmarshal new format: %v", err)
}
if config.Type != "local" {
t.Errorf("Expected type 'local', got '%s'", config.Type)
}
if len(config.Command) != 3 {
t.Errorf("Expected 3 command parts, got %d", len(config.Command))
}
if config.Command[0] != "bun" || config.Command[1] != "x" || config.Command[2] != "my-mcp-command" {
t.Errorf("Command parts incorrect: %v", config.Command)
}
if config.Environment["MY_ENV_VAR"] != "my_env_var_value" {
t.Errorf("Environment variable not set correctly")
}
// Test transport type detection
transportType := config.GetTransportType()
if transportType != "stdio" {
t.Errorf("Expected transport type 'stdio', got '%s'", transportType)
}
}
func TestMCPServerConfig_RemoteFormat(t *testing.T) {
// Test remote format
jsonData := `{
"type": "remote",
"url": "https://my-mcp-server.com"
}`
var config MCPServerConfig
err := json.Unmarshal([]byte(jsonData), &config)
if err != nil {
t.Fatalf("Failed to unmarshal remote format: %v", err)
}
if config.Type != "remote" {
t.Errorf("Expected type 'remote', got '%s'", config.Type)
}
if config.URL != "https://my-mcp-server.com" {
t.Errorf("Expected URL 'https://my-mcp-server.com', got '%s'", config.URL)
}
// Test transport type detection
transportType := config.GetTransportType()
if transportType != "streamable" {
t.Errorf("Expected transport type 'streamable', got '%s'", transportType)
}
}
func TestMCPServerConfig_LegacyFormat(t *testing.T) {
// Test legacy format still works
jsonData := `{
"command": "npx",
"args": ["@modelcontextprotocol/server-filesystem", "/path"],
"env": {
"MY_VAR": "value"
}
}`
var config MCPServerConfig
err := json.Unmarshal([]byte(jsonData), &config)
if err != nil {
t.Fatalf("Failed to unmarshal legacy format: %v", err)
}
// Verify Type field is now set correctly for legacy format
if config.Type != "local" {
t.Errorf("Expected type 'local' for legacy command format, got '%s'", config.Type)
}
if len(config.Command) != 3 {
t.Errorf("Expected 3 command parts, got %d", len(config.Command))
}
if config.Command[0] != "npx" || config.Command[1] != "@modelcontextprotocol/server-filesystem" || config.Command[2] != "/path" {
t.Errorf("Command parts incorrect: %v", config.Command)
}
if config.Env["MY_VAR"] != "value" {
t.Errorf("Legacy environment variable not set correctly")
}
// Test transport type detection
transportType := config.GetTransportType()
if transportType != "stdio" {
t.Errorf("Expected transport type 'stdio', got '%s'", transportType)
}
}
func TestMCPServerConfig_LocalFormat(t *testing.T) {
jsonData := `{
"type": "local",
"command": ["npx", "@modelcontextprotocol/server-filesystem", "/tmp"]
}`
var config MCPServerConfig
err := json.Unmarshal([]byte(jsonData), &config)
if err != nil {
t.Fatalf("Failed to unmarshal local format: %v", err)
}
if config.Type != "local" {
t.Errorf("Expected type 'local', got '%s'", config.Type)
}
transportType := config.GetTransportType()
if transportType != "stdio" {
t.Errorf("Expected transport type 'stdio', got '%s'", transportType)
}
}
func TestConfig_Validate(t *testing.T) {
config := &Config{
MCPServers: map[string]MCPServerConfig{
"local-server": {
Type: "local",
Command: []string{"echo", "hello"},
},
"remote-server": {
Type: "remote",
URL: "https://example.com",
},
"another-local": {
Type: "local",
Command: []string{"echo", "world"},
},
},
}
err := config.Validate()
if err != nil {
t.Errorf("Validation failed: %v", err)
}
}
func TestEnsureConfigExists(t *testing.T) {
// Create a temporary directory for testing
tempDir, err := os.MkdirTemp("", "kit_config_test")
if err != nil {
t.Fatalf("Error creating temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
// Set HOME to temp directory
oldHome := os.Getenv("HOME")
_ = os.Setenv("HOME", tempDir)
defer func() { _ = os.Setenv("HOME", oldHome) }()
// Test config creation
err = EnsureConfigExists()
if err != nil {
t.Fatalf("Error creating config: %v", err)
}
// Verify the config file was created
configPath := filepath.Join(tempDir, ".kit.yml")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
t.Fatalf("Config file was not created at %s", configPath)
}
// Read and verify the content
content, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("Error reading config: %v", err)
}
contentStr := string(content)
// Verify it contains the expected sections
expectedSections := []string{
"# KIT Configuration File",
"mcpServers:",
"# Local MCP servers",
"# Remote MCP servers",
"type: \"local\"",
"type: \"remote\"",
"Core tools",
"# Skills configuration",
"no-skills:",
"skills-dir:",
}
for _, expected := range expectedSections {
if !strings.Contains(contentStr, expected) {
t.Errorf("Config content missing expected section: %s", expected)
}
}
// Verify it's valid YAML structure (basic check)
if !strings.Contains(contentStr, "mcpServers:") {
t.Error("Config should contain mcpServers section")
}
}
func TestMCPServerConfig_LegacyFormatTypeInference(t *testing.T) {
tests := []struct {
name string
jsonData string
expectedType string
expectedTransport string
}{
{
name: "Legacy command format should infer local type",
jsonData: `{
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
}`,
expectedType: "local",
expectedTransport: "stdio",
},
{
name: "Legacy URL format should preserve legacy behavior",
jsonData: `{
"url": "https://api.example.com/mcp"
}`,
expectedType: "", // Don't set Type to preserve legacy transport behavior
expectedTransport: "sse",
},
{
name: "Legacy format with explicit transport should still infer type",
jsonData: `{
"command": "python",
"args": ["-m", "my_mcp_server"],
"transport": "stdio"
}`,
expectedType: "local",
expectedTransport: "stdio",
},
{
name: "Legacy format with URL and explicit transport should preserve explicit transport",
jsonData: `{
"url": "https://remote-server.com",
"transport": "sse"
}`,
expectedType: "", // Don't set Type to preserve legacy behavior
expectedTransport: "sse",
},
{
name: "Empty legacy format should not set type",
jsonData: `{
"env": {"VAR": "value"}
}`,
expectedType: "",
expectedTransport: "stdio",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var config MCPServerConfig
err := json.Unmarshal([]byte(tt.jsonData), &config)
if err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if config.Type != tt.expectedType {
t.Errorf("Expected type '%s', got '%s'", tt.expectedType, config.Type)
}
transportType := config.GetTransportType()
if transportType != tt.expectedTransport {
t.Errorf("Expected transport type '%s', got '%s'", tt.expectedTransport, transportType)
}
})
}
}
func TestIssue76_ExactReproduction(t *testing.T) {
// Test the exact config from GitHub issue #76
jsonData := `{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"C:\\test"
]
}
}
}`
var cfg Config
err := json.Unmarshal([]byte(jsonData), &cfg)
if err != nil {
t.Fatalf("Error unmarshaling config: %v", err)
}
// Verify the server config was parsed correctly
if len(cfg.MCPServers) != 1 {
t.Fatalf("Expected 1 server, got %d", len(cfg.MCPServers))
}
serverConfig, exists := cfg.MCPServers["filesystem"]
if !exists {
t.Fatal("Expected 'filesystem' server to exist")
}
// Verify Type field is now set correctly
if serverConfig.Type != "local" {
t.Errorf("Expected type 'local', got '%s'", serverConfig.Type)
}
// Verify command was parsed correctly
expectedCommand := []string{"npx", "-y", "@modelcontextprotocol/server-filesystem", "C:\\test"}
if len(serverConfig.Command) != len(expectedCommand) {
t.Errorf("Expected %d command parts, got %d", len(expectedCommand), len(serverConfig.Command))
}
for i, expected := range expectedCommand {
if i >= len(serverConfig.Command) || serverConfig.Command[i] != expected {
t.Errorf("Command part %d: expected '%s', got '%s'", i, expected, serverConfig.Command[i])
}
}
// Verify transport type detection works
transportType := serverConfig.GetTransportType()
if transportType != "stdio" {
t.Errorf("Expected transport type 'stdio', got '%s'", transportType)
}
// Most importantly, verify validation passes
err = cfg.Validate()
if err != nil {
t.Errorf("Validation should pass but failed with: %v", err)
}
}
func TestMCPServerConfig_RemoteFormatWithHeaders(t *testing.T) {
tests := []struct {
name string
jsonData string
expectedType string
expectedURL string
expectedHeaders []string
expectedTransport string
}{
{
name: "Remote server with headers",
jsonData: `{
"type": "remote",
"url": "https://api.example.com/mcp",
"headers": ["Authorization: Bearer token123", "X-API-Key: key456"]
}`,
expectedType: "remote",
expectedURL: "https://api.example.com/mcp",
expectedHeaders: []string{"Authorization: Bearer token123", "X-API-Key: key456"},
expectedTransport: "streamable",
},
{
name: "Remote server without headers",
jsonData: `{
"type": "remote",
"url": "https://api.example.com/mcp"
}`,
expectedType: "remote",
expectedURL: "https://api.example.com/mcp",
expectedHeaders: nil,
expectedTransport: "streamable",
},
{
name: "Legacy remote server with headers",
jsonData: `{
"url": "https://legacy.example.com/mcp",
"headers": ["Content-Type: application/json", "User-Agent: KIT/1.0"]
}`,
expectedType: "",
expectedURL: "https://legacy.example.com/mcp",
expectedHeaders: []string{"Content-Type: application/json", "User-Agent: KIT/1.0"},
expectedTransport: "sse",
},
{
name: "Legacy remote server with explicit transport and headers",
jsonData: `{
"url": "https://legacy.example.com/mcp",
"transport": "sse",
"headers": ["Authorization: Basic dXNlcjpwYXNz"]
}`,
expectedType: "",
expectedURL: "https://legacy.example.com/mcp",
expectedHeaders: []string{"Authorization: Basic dXNlcjpwYXNz"},
expectedTransport: "sse",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var config MCPServerConfig
err := json.Unmarshal([]byte(tt.jsonData), &config)
if err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if config.Type != tt.expectedType {
t.Errorf("Expected type '%s', got '%s'", tt.expectedType, config.Type)
}
if config.URL != tt.expectedURL {
t.Errorf("Expected URL '%s', got '%s'", tt.expectedURL, config.URL)
}
if len(config.Headers) != len(tt.expectedHeaders) {
t.Errorf("Expected %d headers, got %d", len(tt.expectedHeaders), len(config.Headers))
}
for i, expectedHeader := range tt.expectedHeaders {
if i >= len(config.Headers) || config.Headers[i] != expectedHeader {
t.Errorf("Header %d: expected '%s', got '%s'", i, expectedHeader, config.Headers[i])
}
}
transportType := config.GetTransportType()
if transportType != tt.expectedTransport {
t.Errorf("Expected transport type '%s', got '%s'", tt.expectedTransport, transportType)
}
})
}
}
func TestMCPServerConfig_HeadersParsing(t *testing.T) {
// Test that headers are properly parsed in both new and legacy formats
tests := []struct {
name string
jsonData string
expected []string
}{
{
name: "New format with multiple headers",
jsonData: `{
"type": "remote",
"url": "https://api.example.com",
"headers": [
"Authorization: Bearer abc123",
"Content-Type: application/json",
"X-Custom-Header: custom-value"
]
}`,
expected: []string{
"Authorization: Bearer abc123",
"Content-Type: application/json",
"X-Custom-Header: custom-value",
},
},
{
name: "Legacy format with headers",
jsonData: `{
"url": "https://legacy.example.com",
"headers": ["API-Key: secret123", "Accept: application/json"]
}`,
expected: []string{"API-Key: secret123", "Accept: application/json"},
},
{
name: "Empty headers array",
jsonData: `{
"type": "remote",
"url": "https://api.example.com",
"headers": []
}`,
expected: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var config MCPServerConfig
err := json.Unmarshal([]byte(tt.jsonData), &config)
if err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if len(config.Headers) != len(tt.expected) {
t.Errorf("Expected %d headers, got %d", len(tt.expected), len(config.Headers))
}
for i, expected := range tt.expected {
if i >= len(config.Headers) || config.Headers[i] != expected {
t.Errorf("Header %d: expected '%s', got '%s'", i, expected, config.Headers[i])
}
}
})
}
}
func TestEnsureConfigExistsWhenFileExists(t *testing.T) {
// Create a temporary directory for testing
tempDir, err := os.MkdirTemp("", "kit_config_test")
if err != nil {
t.Fatalf("Error creating temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
// Set HOME to temp directory
oldHome := os.Getenv("HOME")
_ = os.Setenv("HOME", tempDir)
defer func() { _ = os.Setenv("HOME", oldHome) }()
// Create an existing config file
configPath := filepath.Join(tempDir, ".kit.yml")
existingContent := "# Existing config\nmcpServers:\n test: {}\n"
err = os.WriteFile(configPath, []byte(existingContent), 0644)
if err != nil {
t.Fatalf("Error creating existing config: %v", err)
}
// Test that EnsureConfigExists doesn't overwrite
err = EnsureConfigExists()
if err != nil {
t.Fatalf("Error in EnsureConfigExists: %v", err)
}
// Verify the content wasn't changed
content, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("Error reading config: %v", err)
}
if string(content) != existingContent {
t.Error("Existing config file was modified when it shouldn't have been")
}
}
func TestMCPServerConfig_OAuthFields_JSON(t *testing.T) {
jsonData := `{
"type": "remote",
"url": "https://api.githubcopilot.com/mcp/",
"oauthClientId": "Ov23liXXXXXXXXXXXXXX",
"oauthClientSecret": "secret123",
"oauthScopes": ["read:user", "repo"]
}`
var cfg MCPServerConfig
err := json.Unmarshal([]byte(jsonData), &cfg)
if err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if cfg.Type != "remote" {
t.Errorf("Expected type 'remote', got %q", cfg.Type)
}
if cfg.URL != "https://api.githubcopilot.com/mcp/" {
t.Errorf("Expected URL, got %q", cfg.URL)
}
if cfg.OAuthClientID != "Ov23liXXXXXXXXXXXXXX" {
t.Errorf("Expected OAuthClientID 'Ov23liXXXXXXXXXXXXXX', got %q", cfg.OAuthClientID)
}
if cfg.OAuthClientSecret != "secret123" {
t.Errorf("Expected OAuthClientSecret 'secret123', got %q", cfg.OAuthClientSecret)
}
if len(cfg.OAuthScopes) != 2 || cfg.OAuthScopes[0] != "read:user" || cfg.OAuthScopes[1] != "repo" {
t.Errorf("Expected OAuthScopes [read:user, repo], got %v", cfg.OAuthScopes)
}
}
func TestMCPServerConfig_OAuthFields_YAML(t *testing.T) {
yamlData := `
type: remote
url: https://api.githubcopilot.com/mcp/
oauthClientId: "Ov23liXXXXXXXXXXXXXX"
oauthScopes:
- read:user
- repo
`
var cfg MCPServerConfig
err := yaml.Unmarshal([]byte(yamlData), &cfg)
if err != nil {
t.Fatalf("Failed to unmarshal YAML: %v", err)
}
if cfg.Type != "remote" {
t.Errorf("Expected type 'remote', got %q", cfg.Type)
}
if cfg.OAuthClientID != "Ov23liXXXXXXXXXXXXXX" {
t.Errorf("Expected OAuthClientID 'Ov23liXXXXXXXXXXXXXX', got %q", cfg.OAuthClientID)
}
if len(cfg.OAuthScopes) != 2 || cfg.OAuthScopes[0] != "read:user" || cfg.OAuthScopes[1] != "repo" {
t.Errorf("Expected OAuthScopes [read:user, repo], got %v", cfg.OAuthScopes)
}
}
func TestMCPServerConfig_OAuthFields_Omitted(t *testing.T) {
// Verify that omitting OAuth fields still works (backward compat).
jsonData := `{
"type": "remote",
"url": "https://example.com/mcp"
}`
var cfg MCPServerConfig
err := json.Unmarshal([]byte(jsonData), &cfg)
if err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if cfg.OAuthClientID != "" {
t.Errorf("Expected empty OAuthClientID, got %q", cfg.OAuthClientID)
}
if cfg.OAuthClientSecret != "" {
t.Errorf("Expected empty OAuthClientSecret, got %q", cfg.OAuthClientSecret)
}
if len(cfg.OAuthScopes) != 0 {
t.Errorf("Expected empty OAuthScopes, got %v", cfg.OAuthScopes)
}
}
func TestMCPServerConfig_TasksMode_NewFormat(t *testing.T) {
jsonData := `{
"type": "remote",
"url": "https://my-mcp-server.com",
"tasksMode": "always"
}`
var cfg MCPServerConfig
if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if cfg.TasksMode != "always" {
t.Errorf("expected TasksMode 'always', got %q", cfg.TasksMode)
}
}
func TestMCPServerConfig_TasksMode_LegacyFormat(t *testing.T) {
// tasksMode also recognised in the legacy unmarshal path so users on
// the older command/args shape can opt in without migrating.
jsonData := `{
"command": "npx",
"args": ["@modelcontextprotocol/server-filesystem", "/path"],
"tasksMode": "never"
}`
var cfg MCPServerConfig
if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if cfg.TasksMode != "never" {
t.Errorf("expected TasksMode 'never', got %q", cfg.TasksMode)
}
}
func TestMCPServerConfig_TasksMode_DefaultEmpty(t *testing.T) {
// When tasksMode is not set the field stays empty, which downstream
// resolves to "auto" via tools.ParseTaskMode.
jsonData := `{"type":"remote","url":"https://x.example"}`
var cfg MCPServerConfig
if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if cfg.TasksMode != "" {
t.Errorf("expected default TasksMode to be empty, got %q", cfg.TasksMode)
}
}
func TestConfig_Validate_TasksMode(t *testing.T) {
t.Run("empty is valid", func(t *testing.T) {
cfg := &Config{
MCPServers: map[string]MCPServerConfig{
"a": {Type: "remote", URL: "https://x.example"},
},
}
if err := cfg.Validate(); err != nil {
t.Errorf("empty TasksMode should validate, got %v", err)
}
})
t.Run("known values are valid", func(t *testing.T) {
for _, mode := range []string{"auto", "never", "always", "AUTO", " always "} {
cfg := &Config{
MCPServers: map[string]MCPServerConfig{
"a": {Type: "remote", URL: "https://x.example", TasksMode: mode},
},
}
if err := cfg.Validate(); err != nil {
t.Errorf("TasksMode=%q should validate, got %v", mode, err)
}
}
})
t.Run("typo is rejected with a clear error", func(t *testing.T) {
cfg := &Config{
MCPServers: map[string]MCPServerConfig{
"buildbot": {Type: "remote", URL: "https://x.example", TasksMode: "alwasy"},
},
}
err := cfg.Validate()
if err == nil {
t.Fatal("expected validation error for invalid TasksMode")
}
// Error must mention the server name AND the bad value so the
// user knows where to look.
msg := err.Error()
if !strings.Contains(msg, "buildbot") || !strings.Contains(msg, `"alwasy"`) {
t.Errorf("error %q should mention both server name and bad value", msg)
}
})
}