diff --git a/README.md b/README.md index 9f8f354a..84c7f186 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,12 @@ temperature: 0.7 stream: true thinking-level: off # off, none, minimal, low, medium, high no-core-tools: false # set to true to disable all built-in core tools + +# Skills — all three keys are optional +no-skills: false # set to true to disable all skill loading +skill: # explicit skill files/dirs (disables auto-discovery) + - /path/to/skill.md +skills-dir: "" # override project-local directory for auto-discovery ``` All of the above keys can also be set programmatically via the SDK @@ -203,6 +209,11 @@ mcpServers: --prompt-template Load a specific prompt template by name --no-prompt-templates Disable prompt template loading +# Skills +--skill Load skill file or directory (repeatable) +--skills-dir Override the project-local skills directory for auto-discovery +--no-skills Disable skill loading (auto-discovery and explicit) + # Generation parameters --max-tokens Maximum tokens in response (default: 8192, auto-raised up to 32768 for models with larger known output limits) --temperature Randomness 0.0-1.0 (default: 0.7) diff --git a/cmd/root.go b/cmd/root.go index 37753239..e5739167 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -73,6 +73,11 @@ var ( noCoreToolsFlag bool extensionPaths []string + // Skills control + noSkillsFlag bool + skillsPaths []string + skillsDir string + // TLS configuration tlsSkipVerify bool @@ -283,6 +288,14 @@ func init() { rootCmd.PersistentFlags(). StringSliceVarP(&extensionPaths, "extension", "e", nil, "load additional extension file(s)") + // Skills flags + rootCmd.PersistentFlags(). + BoolVar(&noSkillsFlag, "no-skills", false, "disable skill loading (auto-discovery and explicit)") + rootCmd.PersistentFlags(). + StringSliceVar(&skillsPaths, "skill", nil, "load skill file or directory (repeatable)") + rootCmd.PersistentFlags(). + StringVar(&skillsDir, "skills-dir", "", "override the project-local skills directory for auto-discovery") + flags := rootCmd.PersistentFlags() flags.StringVar(&providerURL, "provider-url", "", "base URL for the provider API (applies to OpenAI, Anthropic, Ollama, and Google)") flags.StringVar(&providerAPIKey, "provider-api-key", "", "API key for the provider (applies to OpenAI, Anthropic, and Google)") @@ -333,6 +346,9 @@ func init() { _ = viper.BindPFlag("extension", rootCmd.PersistentFlags().Lookup("extension")) _ = viper.BindPFlag("prompt-template", rootCmd.PersistentFlags().Lookup("prompt-template")) _ = viper.BindPFlag("no-prompt-templates", rootCmd.PersistentFlags().Lookup("no-prompt-templates")) + _ = viper.BindPFlag("no-skills", rootCmd.PersistentFlags().Lookup("no-skills")) + _ = viper.BindPFlag("skill", rootCmd.PersistentFlags().Lookup("skill")) + _ = viper.BindPFlag("skills-dir", rootCmd.PersistentFlags().Lookup("skills-dir")) // Defaults are already set in flag definitions, no need to duplicate in viper @@ -820,6 +836,9 @@ func runNormalMode(ctx context.Context) error { AutoCompact: autoCompactFlag, MCPAuthHandler: authHandler, DisableCoreTools: viper.GetBool("no-core-tools"), + NoSkills: noSkillsFlag, + Skills: skillsPaths, + SkillsDir: skillsDir, // This callback is called when each MCP server finishes loading. // We use a closure that captures appInstancePtr which is set after // app.New() is called below. diff --git a/internal/config/config.go b/internal/config/config.go index 33428d41..b6312fb2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -493,6 +493,12 @@ mcpServers: # maxTokens: 16384 # systemPrompt: "You are a deep reasoning assistant." # or a file path +# Skills configuration (all optional) +# no-skills: false # Set to true to disable all skill loading +# skill: # Explicit skill files/dirs (disables auto-discovery) +# - "/path/to/skill.md" +# skills-dir: "/path/to/skills" # Override project-local directory for auto-discovery + # API Configuration (can also use environment variables) # provider-api-key: "your-api-key" # API key for OpenAI, Anthropic, or Google # provider-url: "https://api.openai.com/v1" # Base URL for OpenAI, Anthropic, or Ollama diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 095acfb6..8aef1f3d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -205,6 +205,9 @@ func TestEnsureConfigExists(t *testing.T) { "type: \"local\"", "type: \"remote\"", "Core tools", + "# Skills configuration", + "no-skills:", + "skills-dir:", } for _, expected := range expectedSections { diff --git a/pkg/kit/export_test.go b/pkg/kit/export_test.go index f42f5514..ade7221a 100644 --- a/pkg/kit/export_test.go +++ b/pkg/kit/export_test.go @@ -20,3 +20,9 @@ func (m *Kit) ConfigFloatForTest(key string) float64 { return m.v.GetFloat64(key // ConfigBoolForTest returns the bool value of key from this Kit's isolated // configuration store. func (m *Kit) ConfigBoolForTest(key string) bool { return m.v.GetBool(key) } + +// ConfigStringSliceForTest returns the string slice value of key from this +// Kit's isolated configuration store. +func (m *Kit) ConfigStringSliceForTest(key string) []string { + return m.v.GetStringSlice(key) +} diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index 12724842..2b4aa9dd 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -1330,9 +1330,25 @@ func New(ctx context.Context, opts *Options) (*Kit, error) { } // Load skills — either from explicit paths or via auto-discovery. - if !opts.NoSkills { + // Merge viper config with opts: CLI flag / config file values are + // already bound to viper by cmd/root.go, so v.GetBool("no-skills"), + // v.GetStringSlice("skill"), and v.GetString("skills-dir") capture + // both --flag and .kit.yml keys transparently. + noSkills := opts.NoSkills || v.GetBool("no-skills") + skillPaths := opts.Skills + if len(skillPaths) == 0 { + skillPaths = v.GetStringSlice("skill") + } + skillsDir := opts.SkillsDir + if skillsDir == "" { + skillsDir = v.GetString("skills-dir") + } + if !noSkills { + mergedOpts := *opts + mergedOpts.Skills = skillPaths + mergedOpts.SkillsDir = skillsDir var err error - loadedSkills, err = loadSkills(opts) + loadedSkills, err = loadSkills(&mergedOpts) if err != nil { return fmt.Errorf("failed to load skills: %w", err) } diff --git a/pkg/kit/kit_test.go b/pkg/kit/kit_test.go index 3bd9d957..81256549 100644 --- a/pkg/kit/kit_test.go +++ b/pkg/kit/kit_test.go @@ -365,6 +365,81 @@ func TestNewSystemPromptFilePath(t *testing.T) { } } +// TestNewWithSkillsOptions verifies that the three skills-related Options +// fields (NoSkills, Skills, SkillsDir) are wired correctly into kit.New(). +func TestNewWithSkillsOptions(t *testing.T) { + if os.Getenv("ANTHROPIC_API_KEY") == "" { + t.Skip("Skipping test: ANTHROPIC_API_KEY not set") + } + + ctx := context.Background() + + t.Run("NoSkills disables skill loading", func(t *testing.T) { + host, err := kit.New(ctx, &kit.Options{ + Model: "anthropic/claude-sonnet-4-5-20250929", + Quiet: true, + NoSession: true, + NoSkills: true, + }) + if err != nil { + t.Fatalf("kit.New failed: %v", err) + } + defer func() { _ = host.Close() }() + + if got := host.GetSkills(); len(got) != 0 { + t.Errorf("NoSkills=true: expected 0 skills, got %d", len(got)) + } + }) + + t.Run("SkillsDir propagates", func(t *testing.T) { + // Use a non-existent dir — no skills will load but the option must be + // accepted without error and result in zero skills. + dir := t.TempDir() + host, err := kit.New(ctx, &kit.Options{ + Model: "anthropic/claude-sonnet-4-5-20250929", + Quiet: true, + NoSession: true, + SkillsDir: dir, + }) + if err != nil { + t.Fatalf("kit.New failed: %v", err) + } + defer func() { _ = host.Close() }() + + // Empty dir → no skills; the important thing is no error. + _ = host.GetSkills() + }) + + t.Run("explicit Skills paths load correctly", func(t *testing.T) { + // Write a minimal skill file to a temp dir. + dir := t.TempDir() + skillFile := dir + "/my-skill.md" + content := "---\nname: test-skill\ndescription: A test skill\n---\nDo the thing.\n" + if err := os.WriteFile(skillFile, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write skill file: %v", err) + } + + host, err := kit.New(ctx, &kit.Options{ + Model: "anthropic/claude-sonnet-4-5-20250929", + Quiet: true, + NoSession: true, + Skills: []string{skillFile}, + }) + if err != nil { + t.Fatalf("kit.New failed: %v", err) + } + defer func() { _ = host.Close() }() + + skills := host.GetSkills() + if len(skills) != 1 { + t.Fatalf("expected 1 skill, got %d", len(skills)) + } + if skills[0].Name != "test-skill" { + t.Errorf("skill name = %q; want %q", skills[0].Name, "test-skill") + } + }) +} + // TestNewSystemPromptInline confirms that inline system-prompt strings still // flow through unchanged after the file-path resolution change. func TestNewSystemPromptInline(t *testing.T) { diff --git a/pkg/kit/viper_isolation_test.go b/pkg/kit/viper_isolation_test.go index 3cca7063..c0b4ba2a 100644 --- a/pkg/kit/viper_isolation_test.go +++ b/pkg/kit/viper_isolation_test.go @@ -205,6 +205,131 @@ func TestNewZeroOptionsKeepsStreamingDefault(t *testing.T) { } } +// TestSkillsViperKeys verifies that the three skills config keys (no-skills, +// skill, skills-dir) flow through viper when set via a config file, matching +// the pattern used by no-extensions and no-core-tools. This test does not +// require an API key because it only exercises Options struct plumbing. +func TestSkillsViperKeys(t *testing.T) { + t.Run("NoSkills option disables skill loading", func(t *testing.T) { + o := &kit.Options{} + o.NoSkills = true + if !o.NoSkills { + t.Error("Options.NoSkills = true not reflected on struct") + } + }) + + t.Run("Skills paths set on Options", func(t *testing.T) { + o := &kit.Options{ + Skills: []string{"/a/skill.md", "/b/skill.md"}, + } + if len(o.Skills) != 2 { + t.Errorf("Options.Skills: got %d paths, want 2", len(o.Skills)) + } + if o.Skills[0] != "/a/skill.md" { + t.Errorf("Options.Skills[0] = %q; want %q", o.Skills[0], "/a/skill.md") + } + }) + + t.Run("SkillsDir set on Options", func(t *testing.T) { + o := &kit.Options{ + SkillsDir: "/custom/skills", + } + if o.SkillsDir != "/custom/skills" { + t.Errorf("Options.SkillsDir = %q; want %q", o.SkillsDir, "/custom/skills") + } + }) +} + +// TestSkillsConfigFileKeys verifies that no-skills, skill, and skills-dir +// config file keys are read via viper and applied correctly. Requires an API +// key because kit.New() is called to exercise the full config-load path. +func TestSkillsConfigFileKeys(t *testing.T) { + if os.Getenv("ANTHROPIC_API_KEY") == "" { + t.Skip("Skipping test: ANTHROPIC_API_KEY not set") + } + + ctx := context.Background() + + t.Run("no-skills config key disables skill loading", func(t *testing.T) { + // Write a config file with no-skills: true. + cfgFile := t.TempDir() + "/.kit.yml" + if err := os.WriteFile(cfgFile, []byte("no-skills: true\n"), 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + host, err := kit.New(ctx, &kit.Options{ + Model: "anthropic/claude-sonnet-4-5-20250929", + Quiet: true, + NoSession: true, + ConfigFile: cfgFile, + }) + if err != nil { + t.Fatalf("kit.New failed: %v", err) + } + defer func() { _ = host.Close() }() + + if got := host.GetSkills(); len(got) != 0 { + t.Errorf("no-skills:true in config: expected 0 skills, got %d", len(got)) + } + }) + + t.Run("skill config key loads explicit skill files", func(t *testing.T) { + dir := t.TempDir() + skillFile := dir + "/cfg-skill.md" + if err := os.WriteFile(skillFile, []byte("---\nname: cfg-skill\ndescription: from config\n---\nContent.\n"), 0o644); err != nil { + t.Fatalf("failed to write skill file: %v", err) + } + + cfgContent := "skill:\n - " + skillFile + "\n" + cfgFile := dir + "/.kit.yml" + if err := os.WriteFile(cfgFile, []byte(cfgContent), 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + host, err := kit.New(ctx, &kit.Options{ + Model: "anthropic/claude-sonnet-4-5-20250929", + Quiet: true, + NoSession: true, + ConfigFile: cfgFile, + }) + if err != nil { + t.Fatalf("kit.New failed: %v", err) + } + defer func() { _ = host.Close() }() + + skills := host.GetSkills() + if len(skills) != 1 { + t.Fatalf("expected 1 skill from config, got %d", len(skills)) + } + if skills[0].Name != "cfg-skill" { + t.Errorf("skill name = %q; want %q", skills[0].Name, "cfg-skill") + } + }) + + t.Run("skills-dir config key overrides auto-discovery root", func(t *testing.T) { + dir := t.TempDir() + cfgContent := "skills-dir: " + dir + "\n" + cfgFile := dir + "/.kit.yml" + if err := os.WriteFile(cfgFile, []byte(cfgContent), 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + host, err := kit.New(ctx, &kit.Options{ + Model: "anthropic/claude-sonnet-4-5-20250929", + Quiet: true, + NoSession: true, + ConfigFile: cfgFile, + }) + if err != nil { + t.Fatalf("kit.New failed: %v", err) + } + defer func() { _ = host.Close() }() + + // Empty dir → 0 skills; the key point is no error during init. + _ = host.GetSkills() + }) +} + // TestNewStreamingExplicitOptOut verifies that a raw Options can still disable // streaming by setting Streaming to a pointer to false. func TestNewStreamingExplicitOptOut(t *testing.T) { diff --git a/www/pages/cli/commands.md b/www/pages/cli/commands.md index 6cf17eb7..b46263bd 100644 --- a/www/pages/cli/commands.md +++ b/www/pages/cli/commands.md @@ -56,6 +56,26 @@ kit install --all # Install all extensions without prompting kit skill # Install the Kit extensions skill via skills.sh ``` +### Skills CLI flags + +Control which skills are loaded at startup: + +```bash +# Load a specific skill file +kit --skill path/to/skill.md "prompt" + +# Load multiple skill files or directories (flag is repeatable) +kit --skill ./skill1.md --skill ./skill2.md "prompt" + +# Load all skills from a custom directory instead of the default locations +kit --skills-dir /path/to/skills "prompt" + +# Disable all skill loading (auto-discovery and explicit) +kit --no-skills "prompt" +``` + +Skills are auto-discovered from `~/.config/kit/skills/`, `.kit/skills/`, and `.agents/skills/` by default. Use `--skills-dir` to override the project-local search root, or `--skill` to load files explicitly (which disables auto-discovery). `--no-skills` suppresses all skill loading regardless of other flags. + ## Interactive slash commands These commands are available inside the Kit TUI during an interactive session: diff --git a/www/pages/cli/flags.md b/www/pages/cli/flags.md index f85daa64..17f46590 100644 --- a/www/pages/cli/flags.md +++ b/www/pages/cli/flags.md @@ -48,6 +48,14 @@ These flags control Kit's behavior. When a prompt is passed as a positional argu | `--prompt-template` | — | — | Load a specific prompt template by name | | `--no-prompt-templates` | — | `false` | Disable prompt template loading | +## Skills + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--skill` | — | — | Load skill file or directory (repeatable) | +| `--skills-dir` | — | — | Override the project-local skills directory for auto-discovery | +| `--no-skills` | — | `false` | Disable skill loading (auto-discovery and explicit) | + ## Generation parameters | Flag | Short | Default | Description | diff --git a/www/pages/configuration.md b/www/pages/configuration.md index 79fa852e..7f7f9fe7 100644 --- a/www/pages/configuration.md +++ b/www/pages/configuration.md @@ -47,6 +47,9 @@ stream: true | `theme` | object or string | — | UI theme ([inline overrides or file path](/themes)) | | `prompt-templates` | bool | `true` | Enable prompt template loading | | `prompt-template` | string | — | Specific template to load by name | +| `no-skills` | bool | `false` | Disable skill loading (auto-discovery and explicit) | +| `skill` | list | — | Explicit skill files or directories to load (disables auto-discovery) | +| `skills-dir` | string | — | Override the project-local directory used for skill auto-discovery | ## Environment variables