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
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,13 @@ 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
# Skills — all 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
skills-dir: "" # scan this directory directly for skills (overrides auto-discovery)
skill-disable: # hide skills from the model catalog by name (still usable via /skill:)
- some-skill
```

All of the above keys can also be set programmatically via the SDK
Expand Down Expand Up @@ -212,7 +214,8 @@ mcpServers:

# Skills
--skill Load skill file or directory (repeatable)
--skills-dir Override the project-local skills directory for auto-discovery
--skills-dir Scan this directory directly for skills (overrides auto-discovery)
--skill-disable Hide a skill from the model catalog by name (repeatable); still usable via /skill:
--no-skills Disable skill loading (auto-discovery and explicit)

# Generation parameters
Expand Down Expand Up @@ -890,6 +893,11 @@ host.AddContextFileContent(
host.RemoveSkill("polite-french")
host.RemoveContextFile(fmt.Sprintf("session://%s/AGENTS.md", userID))

// Hide a skill from the model catalog without unloading it (still usable
// via /skill:); EnableSkill reverses it.
host.DisableSkill("refund-policy")
host.EnableSkill("refund-policy")

// Or replace the whole set atomically.
host.SetSkills(activeSkillsForUser)
host.SetContextFiles(activeContextForUser)
Expand Down
14 changes: 10 additions & 4 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,10 @@ var (
extensionPaths []string

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

// TLS configuration
tlsSkipVerify bool
Expand Down Expand Up @@ -294,7 +295,9 @@ func init() {
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")
StringVar(&skillsDir, "skills-dir", "", "scan this directory directly for skills (overrides auto-discovery)")
rootCmd.PersistentFlags().
StringSliceVar(&skillsDisable, "skill-disable", nil, "hide a skill from the model catalog by name (repeatable); still usable via /skill:")

flags := rootCmd.PersistentFlags()
flags.StringVar(&providerURL, "provider-url", "", "base URL for the provider API (applies to OpenAI, Anthropic, Ollama, and Google)")
Expand Down Expand Up @@ -349,6 +352,7 @@ func init() {
_ = 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"))
_ = viper.BindPFlag("skill-disable", rootCmd.PersistentFlags().Lookup("skill-disable"))

// Defaults are already set in flag definitions, no need to duplicate in viper

Expand Down Expand Up @@ -842,6 +846,8 @@ func runNormalMode(ctx context.Context) error {
NoSkills: noSkillsFlag,
Skills: skillsPaths,
SkillsDir: skillsDir,
SkillsDisable: skillsDisable,
SkillTrustPrompt: skillTrustPrompt(),
// 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
52 changes: 52 additions & 0 deletions cmd/skill_trust.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package cmd

import (
"bufio"
"fmt"
"os"
"strings"

"golang.org/x/term"

"github.com/mark3labs/kit/pkg/kit"
)

// skillTrustPrompt returns a callback that gates project-local skill loading
// on an interactive trust decision (issue #65, gap #8). Project-local skills
// are injected into the system prompt, so a freshly cloned untrusted repo
// could smuggle instructions into the agent. The prompt asks the user whether
// to trust the directory before any project skill is loaded.
//
// It returns nil — meaning "load without prompting" — when Kit is not running
// interactively (a non-TTY stdin, --quiet, or a non-interactive one-shot
// prompt), so scripted and piped invocations keep their existing behaviour.
func skillTrustPrompt() func(projectDir string, skillCount int) kit.TrustDecision {
// Only prompt for interactive terminal sessions.
if quietFlag || positionalPrompt != "" {
return nil
}
if !term.IsTerminal(int(os.Stdin.Fd())) {
return nil
}

return func(projectDir string, skillCount int) kit.TrustDecision {
noun := "skills"
if skillCount == 1 {
noun = "skill"
}
fmt.Printf("\nThis project provides %d %s under .agents/skills or .kit/skills:\n %s\n",
skillCount, noun, projectDir)
fmt.Print("Load them into the agent? [t]rust always / [o]nce / [s]kip (default skip): ")

reader := bufio.NewReader(os.Stdin)
line, _ := reader.ReadString('\n')
switch strings.ToLower(strings.TrimSpace(line)) {
case "t", "trust", "a", "always":
return kit.TrustProject
case "o", "once", "y", "yes":
return kit.TrustProjectOnce
default:
return kit.SkipProjectSkills
}
}
}
32 changes: 32 additions & 0 deletions internal/compaction/compaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,30 @@ func roleLabel(role fantasy.MessageRole) string {
}
}

// skillContentMarkers are substrings that identify a message carrying
// explicitly-activated skill content. Such messages are exempt from
// compaction pruning per the agentskills.io spec (issue #65, gap #7): an
// activated skill must remain in context verbatim instead of being folded
// into a lossy summary.
var skillContentMarkers = []string{"<skill ", "<skill>", "<skill_content"}

// isProtectedMessage reports whether msg carries explicitly-activated skill
// content that must survive compaction unchanged.
func isProtectedMessage(msg fantasy.Message) bool {
for _, part := range msg.Content {
tp, ok := part.(fantasy.TextPart)
if !ok {
continue
}
for _, marker := range skillContentMarkers {
if strings.Contains(tp.Text, marker) {
return true
}
}
}
return false
Comment thread
ezynda3 marked this conversation as resolved.
}

// serializeMessages converts a slice of fantasy messages into a plain-text
// representation suitable for sending to the summarisation LLM. Tool result
// text is truncated to maxToolResultChars to keep the summarisation request
Expand Down Expand Up @@ -518,6 +542,14 @@ func Compact(

newMessages := make([]fantasy.Message, 0, 1+len(recentMessages))
newMessages = append(newMessages, summaryMessage)
// Carry forward any explicitly-activated skill content from the
// summarised range verbatim — skill instructions must not be lost to
// compaction (issue #65, gap #7).
for _, msg := range oldMessages {
if isProtectedMessage(msg) {
newMessages = append(newMessages, msg)
}
}
newMessages = append(newMessages, recentMessages...)

compactedTokens := EstimateMessageTokens(newMessages)
Expand Down
22 changes: 22 additions & 0 deletions internal/compaction/compaction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -439,3 +439,25 @@ func TestSortedKeys_Empty(t *testing.T) {
t.Errorf("sortedKeys(nil) = %v, want nil", got)
}
}

// ---------------------------------------------------------------------------
// Skill-content protection (issue #65, gap #7)
// ---------------------------------------------------------------------------

func TestIsProtectedMessage(t *testing.T) {
cases := []struct {
text string
want bool
}{
{`<skill name="foo" location="/x">body</skill>`, true},
{`<skill_content name="foo">body</skill_content>`, true},
{"just a normal message", false},
{"talking about skills in general", false},
}
for _, c := range cases {
msg := makeTextMessage(fantasy.MessageRoleUser, c.text)
if got := isProtectedMessage(msg); got != c.want {
t.Errorf("isProtectedMessage(%q) = %v, want %v", c.text, got, c.want)
}
}
}
17 changes: 16 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,22 @@ mcpServers:
# 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
# skills-dir: "/path/to/skills" # Scan this directory directly for skills (overrides auto-discovery)
# skill-disable: # Hide skills from the model catalog by name (still usable via /skill:)
# - "some-skill"
#
# Skill files follow the agentskills.io spec. A SKILL.md frontmatter block
# supports these fields:
# name: my-skill # required
# description: Use when ... # required (basis for model discovery)
# license: MIT # optional SPDX identifier
# compatibility: claude-code, cursor # optional targeted-environment note
# allowed-tools: read, bash # optional (experimental) tool restriction
# disable-model-invocation: false # optional; true hides from the catalog
# metadata: # optional arbitrary key/value pairs
# author: you
# tags: [example] # Kit extension
# when: on-demand # Kit extension

# API Configuration (can also use environment variables)
# provider-api-key: "your-api-key" # API key for OpenAI, Anthropic, or Google
Expand Down
20 changes: 18 additions & 2 deletions internal/extensions/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -777,7 +777,8 @@ type Context struct {
LoadSkillsFromDir func(dir string) SkillLoadResult

// DiscoverSkills finds skills in standard locations.
// Checks ~/.config/kit/skills/, .kit/skills/, .agents/skills/
// Checks ~/.agents/skills/, ~/.config/kit/skills/, <project>/.agents/skills/,
// and <project>/.kit/skills/.
DiscoverSkills func() SkillLoadResult

// InjectSkillAsContext sends a skill's content as a system message.
Expand Down Expand Up @@ -909,9 +910,24 @@ type Skill struct {
Content string
// Path is the absolute filesystem path.
Path string
// Tags are optional labels for categorization.
// License is an optional SPDX license identifier (agentskills.io field).
License string
// Compatibility is an optional note describing targeted environments
// (agentskills.io field).
Compatibility string
// Metadata is an optional bag of arbitrary string key/value pairs
// (agentskills.io field).
Metadata map[string]string
// AllowedTools optionally restricts which tools the skill may use
// (experimental agentskills.io field).
AllowedTools string
// DisableModelInvocation hides the skill from the model-facing catalog
// while keeping it available via explicit activation (agentskills.io field).
DisableModelInvocation bool
// Tags are optional labels for categorization. Kit extension.
Tags []string
// When controls automatic inclusion: "always", "on-demand", or file-glob.
// Kit extension.
When string
}

Expand Down
2 changes: 1 addition & 1 deletion internal/skills/prompt_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func TestPromptBuilder_WithSkills(t *testing.T) {
if !strings.Contains(result, "<description>Write code</description>") {
t.Error("missing skill description in XML")
}
if !strings.Contains(result, "<location>file:///tmp/coding/SKILL.md</location>") {
if !strings.Contains(result, "<location>/tmp/coding/SKILL.md</location>") {
t.Error("missing skill location")
}
}
Expand Down
Loading
Loading