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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ var (
noCoreToolsFlag bool
extensionPaths []string

// Skills control
noSkillsFlag bool
skillsPaths []string
skillsDir string

// TLS configuration
tlsSkipVerify bool

Expand Down Expand Up @@ -284,6 +289,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)")
Expand Down Expand Up @@ -334,6 +347,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

Expand Down Expand Up @@ -799,6 +815,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.
Expand Down
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions pkg/kit/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
20 changes: 18 additions & 2 deletions pkg/kit/kit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
75 changes: 75 additions & 0 deletions pkg/kit/kit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
125 changes: 125 additions & 0 deletions pkg/kit/viper_isolation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
20 changes: 20 additions & 0 deletions www/pages/cli/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions www/pages/cli/flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 3 additions & 0 deletions www/pages/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading