mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-19 13:54:20 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cea82ea2d0 | |||
| dd7ae41a58 | |||
| bd56f4a089 |
@@ -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
|
||||
@@ -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
|
||||
@@ -691,10 +694,10 @@ host, err := kit.NewAgent(ctx,
|
||||
|
||||
Available options: `WithModel`, `WithSystemPrompt`, `WithStreaming`,
|
||||
`WithMaxTokens`, `WithThinkingLevel`, `WithTools`, `WithExtraTools`,
|
||||
`WithProviderAPIKey`, `WithProviderURL`, `WithConfigFile`, `WithDebug`, and
|
||||
`Ephemeral`. For advanced configuration not covered by the helpers (custom MCP
|
||||
config, in-process MCP servers, session backends, MCP task tuning) construct an
|
||||
`Options` value explicitly and call `kit.New`.
|
||||
`WithProviderAPIKey`, `WithProviderURL`, `WithConfigFile`, `WithDebug`,
|
||||
`WithDebugLogger`, and `Ephemeral`. For advanced configuration not covered by
|
||||
the helpers (custom MCP config, in-process MCP servers, session backends, MCP
|
||||
task tuning) construct an `Options` value explicitly and call `kit.New`.
|
||||
|
||||
### Per-instance config isolation
|
||||
|
||||
@@ -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)
|
||||
|
||||
+10
-4
@@ -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
|
||||
@@ -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)")
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,11 @@ type AgentSetupOptions struct {
|
||||
// Debug enables debug logging. When zero-value, viper is consulted.
|
||||
// Only meaningful when ProviderConfig is also set.
|
||||
Debug bool
|
||||
// DebugLogger, if non-nil, is used directly as the engine/MCP debug
|
||||
// logger — overriding the built-in SimpleDebugLogger / BufferedDebugLogger
|
||||
// selected by Debug + UseBufferedLogger. Callers supply this when they
|
||||
// want to route debug output into their own logging system.
|
||||
DebugLogger tools.DebugLogger
|
||||
// NoExtensions skips extension loading. When false, viper is consulted.
|
||||
// Only meaningful when ProviderConfig is also set.
|
||||
NoExtensions bool
|
||||
@@ -192,7 +197,12 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult,
|
||||
// Create the appropriate debug logger.
|
||||
var debugLogger tools.DebugLogger
|
||||
var bufferedLogger *tools.BufferedDebugLogger
|
||||
if debugEnabled {
|
||||
switch {
|
||||
case opts.DebugLogger != nil:
|
||||
// Caller-supplied logger wins unconditionally. Its IsDebugEnabled()
|
||||
// is the source of truth for whether downstream code emits messages.
|
||||
debugLogger = opts.DebugLogger
|
||||
case debugEnabled:
|
||||
if opts.UseBufferedLogger {
|
||||
bufferedLogger = tools.NewBufferedDebugLogger(true)
|
||||
debugLogger = bufferedLogger
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+365
-51
@@ -2,43 +2,173 @@
|
||||
//
|
||||
// Skills are markdown instruction files with optional YAML frontmatter that
|
||||
// provide domain-specific context, instructions, and workflows to the agent.
|
||||
// They follow a hierarchical discovery pattern similar to extensions:
|
||||
// They follow the cross-client agentskills.io discovery convention plus a
|
||||
// Kit-native location:
|
||||
//
|
||||
// ~/.config/kit/skills/ global skills directory
|
||||
// .kit/skills/ project-local skills directory
|
||||
// ~/.agents/skills/ user-level cross-client skills
|
||||
// ~/.config/kit/skills/ user-level Kit skills ($XDG_CONFIG_HOME aware)
|
||||
// <project>/.agents/skills/ project-local cross-client skills
|
||||
// <project>/.kit/skills/ project-local Kit skills
|
||||
//
|
||||
// Skills can be single .md/.txt files or subdirectories containing a SKILL.md file.
|
||||
// Skills can be single .md/.txt files or subdirectories containing a SKILL.md
|
||||
// file. Project-level skills take precedence over user-level skills when two
|
||||
// skills share the same name.
|
||||
package skills
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Skill represents a markdown-based instruction file that provides
|
||||
// domain-specific context and workflows to the agent.
|
||||
//
|
||||
// The Name and Description fields are required by the agentskills.io
|
||||
// specification. License, Compatibility, Metadata, and AllowedTools are
|
||||
// optional spec fields. Tags and When are Kit-specific extensions that other
|
||||
// clients ignore.
|
||||
type Skill struct {
|
||||
// Name is the human-readable identifier for this skill.
|
||||
// Name is the human-readable identifier for this skill. Required.
|
||||
Name string `yaml:"name" json:"name"`
|
||||
// Description summarises what this skill provides.
|
||||
// Description summarises what this skill provides and when to use it.
|
||||
// Required by the spec — it is the sole basis on which the model decides
|
||||
// whether a skill is relevant, so a skill without one is omitted from the
|
||||
// catalog.
|
||||
Description string `yaml:"description" json:"description"`
|
||||
// Content is the full markdown body (after frontmatter).
|
||||
Content string `yaml:"-" json:"content"`
|
||||
// Path is the absolute filesystem path the skill was loaded from.
|
||||
Path string `yaml:"-" json:"path"`
|
||||
// Tags are optional labels for categorisation.
|
||||
|
||||
// License is an optional SPDX license identifier (spec field).
|
||||
License string `yaml:"license,omitempty" json:"license,omitempty"`
|
||||
// Compatibility is an optional free-form note describing the environments
|
||||
// or clients the skill targets (spec field). The model can use it to adapt
|
||||
// execution.
|
||||
Compatibility string `yaml:"compatibility,omitempty" json:"compatibility,omitempty"`
|
||||
// Metadata is an optional bag of arbitrary string key/value pairs (spec
|
||||
// field) for client-specific annotations.
|
||||
Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
// AllowedTools optionally restricts which tools the skill may use. This is
|
||||
// an experimental spec field carried for portability; Kit does not yet
|
||||
// enforce it.
|
||||
AllowedTools string `yaml:"allowed-tools,omitempty" json:"allowed_tools,omitempty"`
|
||||
// DisableModelInvocation, when true, hides the skill from the
|
||||
// model-facing catalog (spec field). The skill can still be activated
|
||||
// explicitly via the /skill: slash command.
|
||||
DisableModelInvocation bool `yaml:"disable-model-invocation,omitempty" json:"disable_model_invocation,omitempty"`
|
||||
|
||||
// Tags are optional labels for categorisation. Kit extension.
|
||||
Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"`
|
||||
// When controls automatic inclusion: "always", "on-demand", or a
|
||||
// file-glob like "file:*.go". Empty defaults to "on-demand".
|
||||
// file-glob like "file:*.go". Empty defaults to "on-demand". Kit extension.
|
||||
When string `yaml:"when,omitempty" json:"when,omitempty"`
|
||||
|
||||
// project records whether the skill was discovered in a project-local
|
||||
// scope. Used internally for name-collision precedence (project > user).
|
||||
project bool `yaml:"-" json:"-"`
|
||||
}
|
||||
|
||||
// Diagnostic describes a validation problem with a skill. Severity is either
|
||||
// "error" (the skill cannot be used) or "warning" (the skill is usable but
|
||||
// non-compliant).
|
||||
type Diagnostic struct {
|
||||
// Severity is "error" or "warning".
|
||||
Severity string `json:"severity"`
|
||||
// Field names the frontmatter field the diagnostic relates to, if any.
|
||||
Field string `json:"field,omitempty"`
|
||||
// Message is a human-readable description of the problem.
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Validate checks the skill against the agentskills.io specification and
|
||||
// returns a list of diagnostics. An empty slice means the skill is fully
|
||||
// compliant. A missing description is reported as an error because the spec
|
||||
// makes it required for discovery.
|
||||
func (s *Skill) Validate() []Diagnostic {
|
||||
var diags []Diagnostic
|
||||
if strings.TrimSpace(s.Name) == "" {
|
||||
diags = append(diags, Diagnostic{Severity: "error", Field: "name", Message: "name is required"})
|
||||
}
|
||||
if strings.TrimSpace(s.Description) == "" {
|
||||
diags = append(diags, Diagnostic{
|
||||
Severity: "error",
|
||||
Field: "description",
|
||||
Message: "description is required for skill discovery",
|
||||
})
|
||||
}
|
||||
return diags
|
||||
}
|
||||
|
||||
// hasError reports whether diags contains a diagnostic with "error" severity.
|
||||
func hasError(diags []Diagnostic) bool {
|
||||
for _, d := range diags {
|
||||
if d.Severity == "error" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// BaseDir returns the directory the skill was loaded from. Relative resources
|
||||
// referenced by a skill (scripts/, references/, assets/) resolve against this
|
||||
// directory.
|
||||
func (s *Skill) BaseDir() string {
|
||||
if s.Path == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Dir(s.Path)
|
||||
}
|
||||
|
||||
// resourceDirs are the conventional subdirectories a skill may bundle.
|
||||
var resourceDirs = []string{"scripts", "references", "assets"}
|
||||
|
||||
// maxResources caps how many bundled resources are enumerated to avoid
|
||||
// flooding the prompt for skills with large asset trees.
|
||||
const maxResources = 50
|
||||
|
||||
// Resources walks one level into the skill's scripts/, references/, and
|
||||
// assets/ subdirectories and returns the relative paths of any files found
|
||||
// (slash-separated, relative to BaseDir). The result is capped at 50 entries.
|
||||
// It returns nil when the skill has no bundled resources or its Path is not a
|
||||
// real on-disk file.
|
||||
func (s *Skill) Resources() []string {
|
||||
base := s.BaseDir()
|
||||
if base == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, sub := range resourceDirs {
|
||||
dir := filepath.Join(base, sub)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
out = append(out, sub+"/"+e.Name())
|
||||
if len(out) >= maxResources {
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// frontmatterSep is the YAML frontmatter delimiter.
|
||||
@@ -79,7 +209,7 @@ func parseSkill(data []byte, srcPath, storePath string) (*Skill, error) {
|
||||
// Strip an optional trailing newline right after the closing ---.
|
||||
body = strings.TrimPrefix(body, "\n")
|
||||
|
||||
if err := yaml.Unmarshal([]byte(frontmatter), skill); err != nil {
|
||||
if err := unmarshalFrontmatter([]byte(frontmatter), skill); err != nil {
|
||||
return nil, fmt.Errorf("parsing frontmatter in %s: %w", srcPath, err)
|
||||
}
|
||||
skill.Content = strings.TrimSpace(body)
|
||||
@@ -105,6 +235,57 @@ func parseSkill(data []byte, srcPath, storePath string) (*Skill, error) {
|
||||
return skill, nil
|
||||
}
|
||||
|
||||
// unquotedColonRe matches a YAML scalar line whose value contains an unquoted
|
||||
// colon, e.g. `description: Use when: extracting tables`. This is the most
|
||||
// common frontmatter authoring mistake in cross-client skills and breaks
|
||||
// strict YAML parsing.
|
||||
var unquotedColonRe = regexp.MustCompile(`^(\s*[A-Za-z0-9_-]+):[ \t]+([^'"\n].*:.*)$`)
|
||||
|
||||
// unmarshalFrontmatter unmarshals YAML frontmatter into skill, tolerating the
|
||||
// common "unquoted colon in a scalar value" mistake (e.g.
|
||||
// `description: Use when: …`). On a parse failure it quotes offending scalar
|
||||
// values and retries once before giving up.
|
||||
func unmarshalFrontmatter(frontmatter []byte, skill *Skill) error {
|
||||
err := yaml.Unmarshal(frontmatter, skill)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Attempt a single recovery pass: quote scalar values that contain an
|
||||
// unquoted colon, which is the dominant cross-client failure mode.
|
||||
repaired, changed := repairUnquotedColons(string(frontmatter))
|
||||
if !changed {
|
||||
return err
|
||||
}
|
||||
if retryErr := yaml.Unmarshal([]byte(repaired), skill); retryErr != nil {
|
||||
// The original error is more useful to the author.
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// repairUnquotedColons quotes scalar values containing an unquoted colon and
|
||||
// reports whether any line was changed.
|
||||
func repairUnquotedColons(frontmatter string) (string, bool) {
|
||||
lines := strings.Split(frontmatter, "\n")
|
||||
changed := false
|
||||
for i, line := range lines {
|
||||
m := unquotedColonRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
key, value := m[1], strings.TrimRight(m[2], " \t")
|
||||
// Escape embedded double quotes before wrapping.
|
||||
value = strings.ReplaceAll(value, `"`, `\"`)
|
||||
lines[i] = fmt.Sprintf(`%s: "%s"`, key, value)
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return frontmatter, false
|
||||
}
|
||||
return strings.Join(lines, "\n"), true
|
||||
}
|
||||
|
||||
// LoadSkillsFromDir loads all skills from a single directory. It looks for:
|
||||
// - *.md and *.txt files directly in dir
|
||||
// - SKILL.md (case-insensitive) in immediate subdirectories
|
||||
@@ -223,54 +404,141 @@ func LoadSkillsFromFS(fsys fs.FS, root string) ([]*Skill, error) {
|
||||
return skills, nil
|
||||
}
|
||||
|
||||
// LoadSkills auto-discovers skills from standard directories:
|
||||
// 1. Global: $XDG_CONFIG_HOME/kit/skills/ (default ~/.config/kit/skills/)
|
||||
// 2. Project-local: <cwd>/.kit/skills/
|
||||
// LoadUserSkills discovers skills from the user-level scopes only:
|
||||
//
|
||||
// Skills from project-local directories take precedence (appended last).
|
||||
// cwd is the working directory for project-local discovery; if empty the
|
||||
// current working directory is used.
|
||||
func LoadSkills(cwd string) ([]*Skill, error) {
|
||||
// 1. ~/.agents/skills/ (cross-client convention)
|
||||
// 2. $XDG_CONFIG_HOME/kit/skills/ (default ~/.config/kit/skills/)
|
||||
//
|
||||
// The returned skills are not yet validated or deduplicated; pass them through
|
||||
// Combine together with project skills to produce the final catalog set.
|
||||
func LoadUserSkills() []*Skill {
|
||||
var loaded []*Skill
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
dir := filepath.Join(home, ".agents", "skills")
|
||||
ss, loadErr := LoadSkillsFromDir(dir)
|
||||
if loadErr != nil {
|
||||
// Missing directories are already swallowed by LoadSkillsFromDir,
|
||||
// so a non-nil error here is genuine (permission denied, read
|
||||
// failure, or a malformed skill file) and would otherwise yield a
|
||||
// silently partial catalog.
|
||||
log.Warn("failed to load some user skills", "dir", dir, "err", loadErr)
|
||||
}
|
||||
loaded = append(loaded, ss...)
|
||||
}
|
||||
if g := globalSkillsDir(); g != "" {
|
||||
ss, loadErr := LoadSkillsFromDir(g)
|
||||
if loadErr != nil {
|
||||
log.Warn("failed to load some user skills", "dir", g, "err", loadErr)
|
||||
}
|
||||
loaded = append(loaded, ss...)
|
||||
}
|
||||
for _, s := range loaded {
|
||||
s.project = false
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
|
||||
// LoadProjectSkills discovers skills from the project-local scopes only:
|
||||
//
|
||||
// 1. <cwd>/.agents/skills/ (cross-client convention)
|
||||
// 2. <cwd>/.kit/skills/ (Kit-specific)
|
||||
//
|
||||
// Because project-local skills are injected into the system prompt, callers
|
||||
// may wish to gate this on a trust check before including the result. The
|
||||
// returned skills are not yet validated or deduplicated; pass them through
|
||||
// Combine.
|
||||
func LoadProjectSkills(cwd string) []*Skill {
|
||||
if cwd == "" {
|
||||
cwd, _ = os.Getwd()
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var all []*Skill
|
||||
|
||||
addUnique := func(skills []*Skill) {
|
||||
for _, s := range skills {
|
||||
if !seen[s.Path] {
|
||||
seen[s.Path] = true
|
||||
all = append(all, s)
|
||||
}
|
||||
var loaded []*Skill
|
||||
for _, dir := range []string{
|
||||
filepath.Join(cwd, ".agents", "skills"),
|
||||
filepath.Join(cwd, ".kit", "skills"),
|
||||
} {
|
||||
ss, loadErr := LoadSkillsFromDir(dir)
|
||||
if loadErr != nil {
|
||||
log.Warn("failed to load some project skills", "dir", dir, "err", loadErr)
|
||||
}
|
||||
loaded = append(loaded, ss...)
|
||||
}
|
||||
for _, s := range loaded {
|
||||
s.project = true
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
|
||||
// Combine validates and deduplicates the union of user-level and project-level
|
||||
// skills. Skills missing a required description are skipped with a logged
|
||||
// warning; when two skills share a Name the project-level one wins (also
|
||||
// logged). User skills are considered before project skills so first-seen
|
||||
// ordering is stable.
|
||||
func Combine(user, project []*Skill) []*Skill {
|
||||
combined := make([]*Skill, 0, len(user)+len(project))
|
||||
combined = append(combined, user...)
|
||||
combined = append(combined, project...)
|
||||
return finalizeSkills(combined)
|
||||
}
|
||||
|
||||
// LoadSkills auto-discovers skills from the standard agentskills.io scopes:
|
||||
//
|
||||
// 1. User-level: ~/.agents/skills/ (cross-client convention)
|
||||
// 2. User-level: $XDG_CONFIG_HOME/kit/skills/ (default ~/.config/kit/skills/)
|
||||
// 3. Project-local: <cwd>/.agents/skills/ (cross-client convention)
|
||||
// 4. Project-local: <cwd>/.kit/skills/ (Kit-specific)
|
||||
//
|
||||
// When two skills share the same Name, the project-level skill takes
|
||||
// precedence over a user-level one and a warning is logged. cwd is the working
|
||||
// directory for project-local discovery; if empty the current working
|
||||
// directory is used.
|
||||
func LoadSkills(cwd string) ([]*Skill, error) {
|
||||
return Combine(LoadUserSkills(), LoadProjectSkills(cwd)), nil
|
||||
}
|
||||
|
||||
// finalizeSkills applies validation (skipping skills missing a required
|
||||
// description) and name-collision precedence (project overrides user). It
|
||||
// preserves first-seen ordering for stable catalog output.
|
||||
func finalizeSkills(loaded []*Skill) []*Skill {
|
||||
byName := make(map[string]int) // name → index in result
|
||||
var result []*Skill
|
||||
|
||||
for _, s := range loaded {
|
||||
if diags := s.Validate(); hasError(diags) {
|
||||
for _, d := range diags {
|
||||
if d.Severity == "error" {
|
||||
log.Warn("skipping skill: validation failed", "path", s.Path, "field", d.Field, "reason", d.Message)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if idx, ok := byName[s.Name]; ok {
|
||||
existing := result[idx]
|
||||
// Project-level skills override user-level skills.
|
||||
if s.project && !existing.project {
|
||||
log.Warn("skill name collision: project skill overrides user skill",
|
||||
"name", s.Name, "project", s.Path, "user", existing.Path)
|
||||
result[idx] = s
|
||||
} else {
|
||||
log.Warn("skill name collision: keeping earlier skill, ignoring duplicate",
|
||||
"name", s.Name, "kept", existing.Path, "ignored", s.Path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
byName[s.Name] = len(result)
|
||||
result = append(result, s)
|
||||
}
|
||||
|
||||
// Global skills.
|
||||
globalDir := globalSkillsDir()
|
||||
if globalDir != "" {
|
||||
global, _ := LoadSkillsFromDir(globalDir)
|
||||
addUnique(global)
|
||||
}
|
||||
|
||||
// Project-local skills: .agents/skills/ (standardized cross-tool convention).
|
||||
agentsDir := filepath.Join(cwd, ".agents", "skills")
|
||||
agentsSkills, _ := LoadSkillsFromDir(agentsDir)
|
||||
addUnique(agentsSkills)
|
||||
|
||||
// Project-local skills: .kit/skills/ (kit-specific).
|
||||
localDir := filepath.Join(cwd, ".kit", "skills")
|
||||
local, _ := LoadSkillsFromDir(localDir)
|
||||
addUnique(local)
|
||||
|
||||
return all, nil
|
||||
return result
|
||||
}
|
||||
|
||||
// FormatForPrompt formats skills as metadata-only XML for inclusion in a
|
||||
// system prompt. Only the name, description, and file location are included;
|
||||
// the agent reads the full skill file on demand using the read tool. This
|
||||
|
||||
// the agent reads the full skill file on demand using the read tool. Skill
|
||||
// fields are XML-escaped so that descriptions containing <, >, & or quotes
|
||||
// produce valid markup. Skills with DisableModelInvocation set are omitted
|
||||
// from the catalog (they remain available via the /skill: slash command).
|
||||
func FormatForPrompt(skills []*Skill) string {
|
||||
if len(skills) == 0 {
|
||||
return ""
|
||||
@@ -282,17 +550,63 @@ func FormatForPrompt(skills []*Skill) string {
|
||||
buf.WriteString("When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md) and use that absolute path in tool commands.\n")
|
||||
buf.WriteString("\n<available_skills>\n")
|
||||
|
||||
emitted := 0
|
||||
for _, s := range skills {
|
||||
buf.WriteString(" <skill>\n")
|
||||
fmt.Fprintf(&buf, " <name>%s</name>\n", s.Name)
|
||||
if s.Description != "" {
|
||||
fmt.Fprintf(&buf, " <description>%s</description>\n", s.Description)
|
||||
if s.DisableModelInvocation {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&buf, " <location>file://%s</location>\n", s.Path)
|
||||
buf.WriteString(" <skill>\n")
|
||||
fmt.Fprintf(&buf, " <name>%s</name>\n", escapeXML(s.Name))
|
||||
if s.Description != "" {
|
||||
fmt.Fprintf(&buf, " <description>%s</description>\n", escapeXML(s.Description))
|
||||
}
|
||||
if s.Compatibility != "" {
|
||||
fmt.Fprintf(&buf, " <compatibility>%s</compatibility>\n", escapeXML(s.Compatibility))
|
||||
}
|
||||
fmt.Fprintf(&buf, " <location>%s</location>\n", escapeXML(s.Path))
|
||||
buf.WriteString(" </skill>\n")
|
||||
emitted++
|
||||
}
|
||||
|
||||
buf.WriteString("</available_skills>")
|
||||
if emitted == 0 {
|
||||
return ""
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// escapeXML escapes a string for safe inclusion as XML text content.
|
||||
func escapeXML(s string) string {
|
||||
var buf bytes.Buffer
|
||||
if err := xml.EscapeText(&buf, []byte(s)); err != nil {
|
||||
return s
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// FormatResources renders a skill's bundled resources as a <skill_resources>
|
||||
// block, or returns the empty string when the skill bundles no resources. It
|
||||
// is used when a skill is explicitly activated so the model knows which files
|
||||
// it can read without enumerating them itself.
|
||||
func FormatResources(resources []string) string {
|
||||
if len(resources) == 0 {
|
||||
return ""
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("<skill_resources>\n")
|
||||
limit := len(resources)
|
||||
truncated := false
|
||||
if limit > maxResources {
|
||||
limit = maxResources
|
||||
truncated = true
|
||||
}
|
||||
for _, r := range resources[:limit] {
|
||||
fmt.Fprintf(&buf, " <file>%s</file>\n", escapeXML(r))
|
||||
}
|
||||
if truncated {
|
||||
buf.WriteString(" <!-- (truncated) -->\n")
|
||||
}
|
||||
buf.WriteString("</skill_resources>")
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
|
||||
+208
-26
@@ -243,15 +243,24 @@ func TestLoadSkillsFromDir_CaseInsensitiveSKILLmd(t *testing.T) {
|
||||
// LoadSkills (auto-discovery)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func writeSkill(t *testing.T, path, name, desc, body string) {
|
||||
t.Helper()
|
||||
content := "---\nname: " + name + "\ndescription: " + desc + "\n---\n" + body
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSkills_ProjectLocal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Isolate user-level scopes so the host machine's skills don't leak in.
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, "xdg"))
|
||||
t.Setenv("HOME", filepath.Join(dir, "home"))
|
||||
skillsDir := filepath.Join(dir, ".kit", "skills")
|
||||
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillsDir, "local.md"), []byte("Local skill"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeSkill(t, filepath.Join(skillsDir, "local.md"), "local", "A local skill", "Local skill")
|
||||
|
||||
skills, err := LoadSkills(dir)
|
||||
if err != nil {
|
||||
@@ -265,37 +274,64 @@ func TestLoadSkills_ProjectLocal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSkills_Deduplication(t *testing.T) {
|
||||
// TestLoadSkills_SkipsMissingDescription verifies that a skill without a
|
||||
// required description is skipped during auto-discovery (gap #2).
|
||||
func TestLoadSkills_SkipsMissingDescription(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Set XDG_CONFIG_HOME to our temp dir so global and local overlap.
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
|
||||
globalDir := filepath.Join(dir, "kit", "skills")
|
||||
localDir := filepath.Join(dir, ".kit", "skills")
|
||||
|
||||
if err := os.MkdirAll(globalDir, 0o755); err != nil {
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, "xdg"))
|
||||
t.Setenv("HOME", filepath.Join(dir, "home"))
|
||||
skillsDir := filepath.Join(dir, ".kit", "skills")
|
||||
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(localDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Same content in both directories but different paths — both should load.
|
||||
if err := os.WriteFile(filepath.Join(globalDir, "shared.md"), []byte("Global version"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(localDir, "shared.md"), []byte("Local version"), 0o644); err != nil {
|
||||
// No description — should be skipped.
|
||||
if err := os.WriteFile(filepath.Join(skillsDir, "nodesc.md"), []byte("Just a body"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeSkill(t, filepath.Join(skillsDir, "good.md"), "good", "Has a description", "Body")
|
||||
|
||||
skills, err := LoadSkills(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Different absolute paths = both loaded.
|
||||
if len(skills) != 2 {
|
||||
t.Fatalf("expected 2 skills (different paths), got %d", len(skills))
|
||||
if len(skills) != 1 {
|
||||
t.Fatalf("expected 1 skill (missing-description skipped), got %d", len(skills))
|
||||
}
|
||||
if skills[0].Name != "good" {
|
||||
t.Errorf("Name = %q, want %q", skills[0].Name, "good")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadSkills_NameCollisionPrecedence verifies project-level skills override
|
||||
// user-level skills with the same name (gap #5).
|
||||
func TestLoadSkills_NameCollisionPrecedence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Set XDG_CONFIG_HOME so the user-level skill lives under our temp dir.
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
t.Setenv("HOME", filepath.Join(dir, "home"))
|
||||
|
||||
userDir := filepath.Join(dir, "kit", "skills")
|
||||
projectDir := filepath.Join(dir, ".kit", "skills")
|
||||
if err := os.MkdirAll(userDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(projectDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
writeSkill(t, filepath.Join(userDir, "shared.md"), "shared", "User version", "USER")
|
||||
writeSkill(t, filepath.Join(projectDir, "shared.md"), "shared", "Project version", "PROJECT")
|
||||
|
||||
skills, err := LoadSkills(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(skills) != 1 {
|
||||
t.Fatalf("expected 1 skill (deduped by name), got %d", len(skills))
|
||||
}
|
||||
if !strings.Contains(skills[0].Content, "PROJECT") {
|
||||
t.Errorf("expected project version to win, got content %q", skills[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,8 +357,8 @@ func TestFormatForPrompt_SingleSkill(t *testing.T) {
|
||||
if !strings.Contains(result, "<description>A test</description>") {
|
||||
t.Errorf("result should contain description in XML")
|
||||
}
|
||||
if !strings.Contains(result, "<location>file:///tmp/test-skill/SKILL.md</location>") {
|
||||
t.Errorf("result should contain file location")
|
||||
if !strings.Contains(result, "<location>/tmp/test-skill/SKILL.md</location>") {
|
||||
t.Errorf("result should contain bare file location (no file:// prefix)")
|
||||
}
|
||||
if !strings.Contains(result, "<available_skills>") {
|
||||
t.Errorf("result should contain available_skills root element")
|
||||
@@ -352,3 +388,149 @@ func TestFormatForPrompt_MultipleSkills(t *testing.T) {
|
||||
t.Error("missing preamble instructions")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// agentskills.io spec compliance (issue #65)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestFormatForPrompt_XMLEscaping verifies special characters in name and
|
||||
// description are escaped so the catalog is valid XML (gap #1).
|
||||
func TestFormatForPrompt_XMLEscaping(t *testing.T) {
|
||||
skills := []*Skill{
|
||||
{Name: "a&b", Description: "use when <tag> & \"quoted\"", Path: "/tmp/x"},
|
||||
}
|
||||
result := FormatForPrompt(skills)
|
||||
if strings.Contains(result, "<tag>") {
|
||||
t.Errorf("raw < should have been escaped, got: %q", result)
|
||||
}
|
||||
if !strings.Contains(result, "<tag>") {
|
||||
t.Errorf("expected escaped <tag>, got: %q", result)
|
||||
}
|
||||
if !strings.Contains(result, "a&b") {
|
||||
t.Errorf("expected escaped ampersand in name, got: %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatForPrompt_DisableModelInvocation verifies that a skill flagged
|
||||
// disable-model-invocation is omitted from the catalog (gap #10).
|
||||
func TestFormatForPrompt_DisableModelInvocation(t *testing.T) {
|
||||
skills := []*Skill{
|
||||
{Name: "visible", Description: "shown", Path: "/tmp/a"},
|
||||
{Name: "hidden", Description: "not shown", Path: "/tmp/b", DisableModelInvocation: true},
|
||||
}
|
||||
result := FormatForPrompt(skills)
|
||||
if !strings.Contains(result, "<name>visible</name>") {
|
||||
t.Error("visible skill should be in catalog")
|
||||
}
|
||||
if strings.Contains(result, "<name>hidden</name>") {
|
||||
t.Error("disable-model-invocation skill should be omitted from catalog")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadSkill_NewSpecFields verifies the new frontmatter fields parse (gap #6).
|
||||
func TestLoadSkill_NewSpecFields(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "spec.md")
|
||||
content := `---
|
||||
name: spec-skill
|
||||
description: A spec-compliant skill
|
||||
license: MIT
|
||||
compatibility: claude-code, cursor
|
||||
allowed-tools: read, bash
|
||||
disable-model-invocation: true
|
||||
metadata:
|
||||
author: jane
|
||||
version: "1.2"
|
||||
---
|
||||
Body.`
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := LoadSkill(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.License != "MIT" {
|
||||
t.Errorf("License = %q, want MIT", s.License)
|
||||
}
|
||||
if s.Compatibility != "claude-code, cursor" {
|
||||
t.Errorf("Compatibility = %q", s.Compatibility)
|
||||
}
|
||||
if s.AllowedTools != "read, bash" {
|
||||
t.Errorf("AllowedTools = %q", s.AllowedTools)
|
||||
}
|
||||
if !s.DisableModelInvocation {
|
||||
t.Error("DisableModelInvocation should be true")
|
||||
}
|
||||
if s.Metadata["author"] != "jane" || s.Metadata["version"] != "1.2" {
|
||||
t.Errorf("Metadata = %v", s.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadSkill_UnquotedColonFallback verifies the YAML repair fallback for
|
||||
// the common `description: Use when: ...` mistake (gap #9).
|
||||
func TestLoadSkill_UnquotedColonFallback(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "colon.md")
|
||||
content := "---\nname: colon-skill\ndescription: Use when: extracting tables from PDFs\n---\nBody."
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := LoadSkill(path)
|
||||
if err != nil {
|
||||
t.Fatalf("expected unquoted-colon fallback to succeed, got error: %v", err)
|
||||
}
|
||||
if s.Name != "colon-skill" {
|
||||
t.Errorf("Name = %q", s.Name)
|
||||
}
|
||||
if !strings.Contains(s.Description, "extracting tables") {
|
||||
t.Errorf("Description = %q", s.Description)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate verifies the Validate diagnostics (gaps #2, #15).
|
||||
func TestValidate(t *testing.T) {
|
||||
missing := &Skill{Name: "x"}
|
||||
diags := missing.Validate()
|
||||
if !hasError(diags) {
|
||||
t.Error("expected an error diagnostic for missing description")
|
||||
}
|
||||
|
||||
ok := &Skill{Name: "x", Description: "y"}
|
||||
if len(ok.Validate()) != 0 {
|
||||
t.Error("expected no diagnostics for a complete skill")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillResources verifies bundled-resource enumeration (gaps #11, #15).
|
||||
func TestSkillResources(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
skillDir := filepath.Join(dir, "my-skill")
|
||||
if err := os.MkdirAll(filepath.Join(skillDir, "scripts"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(skillDir, "references"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillDir, "scripts", "run.py"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillDir, "references", "REF.md"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Skill{Name: "my-skill", Path: filepath.Join(skillDir, "SKILL.md")}
|
||||
res := s.Resources()
|
||||
if len(res) != 2 {
|
||||
t.Fatalf("expected 2 resources, got %d: %v", len(res), res)
|
||||
}
|
||||
if s.BaseDir() != skillDir {
|
||||
t.Errorf("BaseDir = %q, want %q", s.BaseDir(), skillDir)
|
||||
}
|
||||
formatted := FormatResources(res)
|
||||
if !strings.Contains(formatted, "<file>references/REF.md</file>") {
|
||||
t.Errorf("FormatResources output missing reference: %q", formatted)
|
||||
}
|
||||
if !strings.Contains(formatted, "<file>scripts/run.py</file>") {
|
||||
t.Errorf("FormatResources output missing script: %q", formatted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Package skilltool provides the built-in activate_skill tool, a dedicated
|
||||
// activation entry point for agentskills.io skills (issue #65, gaps #13/#14).
|
||||
//
|
||||
// While a skill can always be activated by reading its SKILL.md with the
|
||||
// generic read tool, a dedicated tool offers an enum-constrained skill name
|
||||
// (preventing hallucinated names), bundled-resource enumeration, and
|
||||
// per-session deduplication so the same skill is not re-injected repeatedly.
|
||||
package skilltool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"charm.land/fantasy"
|
||||
|
||||
"github.com/mark3labs/kit/internal/skills"
|
||||
)
|
||||
|
||||
// SkillProvider returns the skills currently available for activation. It is
|
||||
// queried on every call so runtime skill mutations are reflected.
|
||||
type SkillProvider func() []*skills.Skill
|
||||
|
||||
type activateArgs struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// activateSkillTool implements fantasy.AgentTool.
|
||||
type activateSkillTool struct {
|
||||
info fantasy.ToolInfo
|
||||
provider SkillProvider
|
||||
providerOptions fantasy.ProviderOptions
|
||||
|
||||
mu sync.Mutex
|
||||
activated map[string]bool // session-level dedup tracking
|
||||
}
|
||||
|
||||
func (t *activateSkillTool) Info() fantasy.ToolInfo { return t.info }
|
||||
func (t *activateSkillTool) ProviderOptions() fantasy.ProviderOptions { return t.providerOptions }
|
||||
func (t *activateSkillTool) SetProviderOptions(opts fantasy.ProviderOptions) {
|
||||
t.providerOptions = opts
|
||||
}
|
||||
|
||||
// New builds the activate_skill tool. names is the initial set of skill names
|
||||
// used to populate the enum constraint on the name parameter; provider is
|
||||
// queried at call time to resolve the skill by name (so runtime additions
|
||||
// resolve even if absent from the enum). Returns nil when no skill names are
|
||||
// available.
|
||||
func New(names []string, provider SkillProvider) fantasy.AgentTool {
|
||||
if len(names) == 0 || provider == nil {
|
||||
return nil
|
||||
}
|
||||
sorted := append([]string(nil), names...)
|
||||
sort.Strings(sorted)
|
||||
enum := make([]any, len(sorted))
|
||||
for i, n := range sorted {
|
||||
enum[i] = n
|
||||
}
|
||||
|
||||
return &activateSkillTool{
|
||||
info: fantasy.ToolInfo{
|
||||
Name: "activate_skill",
|
||||
Description: "Activate a skill by name to load its full instructions into context. " +
|
||||
"Use this when a task matches a skill listed in <available_skills>. " +
|
||||
"The skill body and a list of its bundled resources are returned.",
|
||||
Parameters: map[string]any{
|
||||
"name": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The exact name of the skill to activate.",
|
||||
"enum": enum,
|
||||
},
|
||||
},
|
||||
Required: []string{"name"},
|
||||
Parallel: false,
|
||||
},
|
||||
provider: provider,
|
||||
activated: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *activateSkillTool) Run(_ context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
var args activateArgs
|
||||
if call.Input != "" && call.Input != "{}" {
|
||||
if err := json.Unmarshal([]byte(call.Input), &args); err != nil {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid arguments: %v", err)), nil
|
||||
}
|
||||
}
|
||||
name := strings.TrimSpace(args.Name)
|
||||
if name == "" {
|
||||
return fantasy.NewTextErrorResponse("name is required"), nil
|
||||
}
|
||||
|
||||
// Hold the lock across the whole activation so the dedup check and the
|
||||
// subsequent mark are atomic — two concurrent calls cannot both pass the
|
||||
// check and double-activate the same skill (gap #14). The skill is only
|
||||
// marked activated on success, so a failed load can be retried.
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if t.activated[name] {
|
||||
return fantasy.NewTextResponse(
|
||||
fmt.Sprintf("Skill %q was already loaded earlier in this session.", name)), nil
|
||||
}
|
||||
|
||||
// Resolve the skill path from the current provider snapshot. Skills with
|
||||
// disable-model-invocation set are not activatable by the model (they
|
||||
// remain available via the /skill: command), mirroring their exclusion
|
||||
// from the catalog and the tool's name enum.
|
||||
var path string
|
||||
for _, s := range t.provider() {
|
||||
if s.Name == name && !s.DisableModelInvocation {
|
||||
path = s.Path
|
||||
break
|
||||
}
|
||||
}
|
||||
if path == "" {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("unknown skill %q", name)), nil
|
||||
}
|
||||
|
||||
// Re-read the file for freshness, stripping frontmatter.
|
||||
loaded, err := skills.LoadSkill(path)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to load skill %q: %v", name, err)), nil
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
fmt.Fprintf(&buf, "<skill_content name=%q location=%q>\n", loaded.Name, loaded.Path)
|
||||
fmt.Fprintf(&buf, "References are relative to %s.\n\n", loaded.BaseDir())
|
||||
buf.WriteString(loaded.Content)
|
||||
if res := skills.FormatResources(loaded.Resources()); res != "" {
|
||||
buf.WriteString("\n\n")
|
||||
buf.WriteString(res)
|
||||
}
|
||||
buf.WriteString("\n</skill_content>")
|
||||
|
||||
t.activated[name] = true
|
||||
|
||||
return fantasy.NewTextResponse(buf.String()), nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package skilltool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
|
||||
"github.com/mark3labs/kit/internal/skills"
|
||||
)
|
||||
|
||||
func writeSkillFile(t *testing.T, dir, name string) *skills.Skill {
|
||||
t.Helper()
|
||||
skillDir := filepath.Join(dir, name)
|
||||
if err := os.MkdirAll(filepath.Join(skillDir, "scripts"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillDir, "scripts", "run.py"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(skillDir, "SKILL.md")
|
||||
content := "---\nname: " + name + "\ndescription: A test skill\n---\nDo the thing."
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &skills.Skill{Name: name, Description: "A test skill", Path: path}
|
||||
}
|
||||
|
||||
func TestNew_NilWhenNoSkills(t *testing.T) {
|
||||
if tool := New(nil, func() []*skills.Skill { return nil }); tool != nil {
|
||||
t.Error("expected nil tool when no skill names provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateSkill_LoadsAndDedups(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := writeSkillFile(t, dir, "extract")
|
||||
provider := func() []*skills.Skill { return []*skills.Skill{s} }
|
||||
|
||||
tool := New([]string{"extract"}, provider)
|
||||
if tool == nil {
|
||||
t.Fatal("expected a tool")
|
||||
}
|
||||
|
||||
// First activation loads content + resources.
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{Input: `{"name":"extract"}`})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := responseText(resp)
|
||||
if !strings.Contains(out, "Do the thing.") {
|
||||
t.Errorf("expected skill body, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "<skill_content name=\"extract\"") {
|
||||
t.Errorf("expected skill_content wrapper, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "scripts/run.py") {
|
||||
t.Errorf("expected enumerated resources, got: %q", out)
|
||||
}
|
||||
|
||||
// Second activation is deduplicated.
|
||||
resp2, err := tool.Run(context.Background(), fantasy.ToolCall{Input: `{"name":"extract"}`})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(responseText(resp2), "already loaded") {
|
||||
t.Errorf("expected dedup message, got: %q", responseText(resp2))
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateSkill_UnknownSkill(t *testing.T) {
|
||||
provider := func() []*skills.Skill { return nil }
|
||||
tool := New([]string{"extract"}, provider)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{Input: `{"name":"nope"}`})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(responseText(resp), "unknown skill") {
|
||||
t.Errorf("expected unknown-skill error, got: %q", responseText(resp))
|
||||
}
|
||||
}
|
||||
|
||||
// TestActivateSkill_DisabledNotActivatable verifies a skill flagged
|
||||
// disable-model-invocation cannot be activated through the model-facing tool.
|
||||
func TestActivateSkill_DisabledNotActivatable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := writeSkillFile(t, dir, "extract")
|
||||
s.DisableModelInvocation = true
|
||||
provider := func() []*skills.Skill { return []*skills.Skill{s} }
|
||||
|
||||
tool := New([]string{"extract"}, provider)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{Input: `{"name":"extract"}`})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(responseText(resp), "unknown skill") {
|
||||
t.Errorf("disabled skill should not be activatable, got: %q", responseText(resp))
|
||||
}
|
||||
}
|
||||
|
||||
// responseText extracts the text from a tool response.
|
||||
func responseText(resp fantasy.ToolResponse) string {
|
||||
return resp.Content
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Package trust manages a persisted allowlist of project directories that the
|
||||
// user has marked as trusted for loading project-local skills.
|
||||
//
|
||||
// Project-local skills (discovered under <project>/.agents/skills/ and
|
||||
// <project>/.kit/skills/) are injected into the system prompt. A freshly
|
||||
// cloned, untrusted repository could therefore smuggle instructions into the
|
||||
// agent the moment the user runs Kit inside it. To mitigate this prompt-
|
||||
// injection vector, project-level skill loading can be gated on an explicit
|
||||
// trust decision recorded here.
|
||||
//
|
||||
// The allowlist is stored as JSON at $XDG_CONFIG_HOME/kit/trusted-projects.json
|
||||
// (default ~/.config/kit/trusted-projects.json).
|
||||
package trust
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Decision is the outcome of a trust prompt.
|
||||
type Decision int
|
||||
|
||||
const (
|
||||
// Skip declines to load project skills this session and records nothing.
|
||||
Skip Decision = iota
|
||||
// Trust loads project skills this session and persists the directory.
|
||||
Trust
|
||||
// TrustOnce loads project skills this session without persisting.
|
||||
TrustOnce
|
||||
)
|
||||
|
||||
// storeFileName is the basename of the persisted allowlist.
|
||||
const storeFileName = "trusted-projects.json"
|
||||
|
||||
// Store is a persisted set of trusted project directories. The zero value is
|
||||
// not usable — construct one with Load.
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
trusted map[string]bool
|
||||
}
|
||||
|
||||
// store mirrors the on-disk JSON layout.
|
||||
type store struct {
|
||||
Projects []string `json:"projects"`
|
||||
}
|
||||
|
||||
// DefaultPath returns the path the trust allowlist is persisted to, respecting
|
||||
// $XDG_CONFIG_HOME. Returns the empty string when no home directory can be
|
||||
// determined.
|
||||
func DefaultPath() string {
|
||||
base := os.Getenv("XDG_CONFIG_HOME")
|
||||
if base == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
base = filepath.Join(home, ".config")
|
||||
}
|
||||
return filepath.Join(base, "kit", storeFileName)
|
||||
}
|
||||
|
||||
// Load reads the trust allowlist from path. A missing file yields an empty
|
||||
// store (not an error). Pass an empty path to use DefaultPath.
|
||||
func Load(path string) (*Store, error) {
|
||||
if path == "" {
|
||||
path = DefaultPath()
|
||||
}
|
||||
s := &Store{path: path, trusted: map[string]bool{}}
|
||||
if path == "" {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return s, nil
|
||||
}
|
||||
return s, err
|
||||
}
|
||||
|
||||
var raw store
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return s, err
|
||||
}
|
||||
for _, p := range raw.Projects {
|
||||
s.trusted[normalize(p)] = true
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// IsTrusted reports whether dir has been marked trusted.
|
||||
func (s *Store) IsTrusted(dir string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.trusted[normalize(dir)]
|
||||
}
|
||||
|
||||
// Trust records dir as trusted and persists the allowlist to disk.
|
||||
func (s *Store) Trust(dir string) error {
|
||||
s.mu.Lock()
|
||||
s.trusted[normalize(dir)] = true
|
||||
s.mu.Unlock()
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// Untrust removes dir from the allowlist and persists the change.
|
||||
func (s *Store) Untrust(dir string) error {
|
||||
s.mu.Lock()
|
||||
delete(s.trusted, normalize(dir))
|
||||
s.mu.Unlock()
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// save writes the allowlist to disk, creating parent directories as needed.
|
||||
func (s *Store) save() error {
|
||||
if s.path == "" {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
projects := make([]string, 0, len(s.trusted))
|
||||
for p := range s.trusted {
|
||||
projects = append(projects, p)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
data, err := json.MarshalIndent(store{Projects: projects}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.path, data, 0o644)
|
||||
}
|
||||
|
||||
// normalize resolves dir to an absolute, symlink-evaluated path for stable
|
||||
// comparison. It falls back to the cleaned input when resolution fails.
|
||||
func normalize(dir string) string {
|
||||
if dir == "" {
|
||||
return ""
|
||||
}
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return filepath.Clean(dir)
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
return resolved
|
||||
}
|
||||
return abs
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package trust
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStore_TrustAndPersist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
storePath := filepath.Join(dir, "trusted-projects.json")
|
||||
project := filepath.Join(dir, "repo")
|
||||
|
||||
s, err := Load(storePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.IsTrusted(project) {
|
||||
t.Fatal("project should not be trusted initially")
|
||||
}
|
||||
if err := s.Trust(project); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.IsTrusted(project) {
|
||||
t.Fatal("project should be trusted after Trust")
|
||||
}
|
||||
|
||||
// Reload from disk to confirm persistence.
|
||||
s2, err := Load(storePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s2.IsTrusted(project) {
|
||||
t.Fatal("trust decision should persist across reloads")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_Untrust(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
storePath := filepath.Join(dir, "trusted-projects.json")
|
||||
project := filepath.Join(dir, "repo")
|
||||
|
||||
s, _ := Load(storePath)
|
||||
_ = s.Trust(project)
|
||||
if err := s.Untrust(project); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.IsTrusted(project) {
|
||||
t.Fatal("project should not be trusted after Untrust")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_MissingFileIsEmpty(t *testing.T) {
|
||||
s, err := Load(filepath.Join(t.TempDir(), "does-not-exist.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing file should not error: %v", err)
|
||||
}
|
||||
if s.IsTrusted("/anything") {
|
||||
t.Fatal("empty store should trust nothing")
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -74,7 +74,8 @@ host, err := kit.NewAgent(ctx,
|
||||
|
||||
Helpers: `WithModel`, `WithSystemPrompt`, `WithStreaming`, `WithMaxTokens`,
|
||||
`WithThinkingLevel`, `WithTools`, `WithExtraTools`, `WithProviderAPIKey`,
|
||||
`WithProviderURL`, `WithConfigFile`, `WithDebug`, and `Ephemeral`. `Option` is
|
||||
`WithProviderURL`, `WithConfigFile`, `WithDebug`, `WithDebugLogger`, and
|
||||
`Ephemeral`. `Option` is
|
||||
a plain `func(*Options)`, so you can define your own. For fields without a
|
||||
`With*` helper (`MCPConfig`, `InProcessMCPServers`, `SessionManager`, MCP task
|
||||
tuning) construct an `Options` value and call `kit.New`.
|
||||
@@ -329,7 +330,6 @@ kit.LLMFilePart // {Filename, Data []byte, MediaType}
|
||||
// Agent configuration — concrete Kit-owned structs and function types.
|
||||
// All fields use SDK types (e.g. `[]kit.Tool`), so consumers can construct
|
||||
// these without importing any LLM-provider package.
|
||||
kit.AgentConfig // Lower-level agent config — prefer Options unless you need direct control
|
||||
kit.DebugLogger // Interface: LogDebug(string) / IsDebugEnabled() bool
|
||||
kit.MCPTaskConfig // Task-aware MCP tools/call config (modes, polling, progress)
|
||||
kit.ToolCallHandler // func(toolCallID, toolName, toolArgs string)
|
||||
@@ -424,7 +424,8 @@ Key `Options` fields for SDK usage:
|
||||
| `SessionPath` | Open specific session file |
|
||||
| `Continue` | Resume most recent session |
|
||||
| `InProcessMCPServers` | Map of name → `*kit.MCPServer` for in-process MCP servers |
|
||||
| `Debug` | Enable debug logging |
|
||||
| `Debug` | Enable debug logging via the built-in console logger (ignored when `DebugLogger` is set) |
|
||||
| `DebugLogger` | Custom `DebugLogger` implementation — routes engine + MCP debug output into your own logging system |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
package kit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mark3labs/kit/internal/agent"
|
||||
)
|
||||
|
||||
// TestAgentConfigToInternal verifies that the SDK-side AgentConfig converts
|
||||
// faithfully to the internal agent.AgentConfig representation, preserving
|
||||
// every field consumed by the internal agent layer.
|
||||
//
|
||||
// Regression test for https://github.com/mark3labs/kit/issues/30.
|
||||
func TestAgentConfigToInternal(t *testing.T) {
|
||||
t.Run("nil receiver returns nil", func(t *testing.T) {
|
||||
var c *AgentConfig
|
||||
if got := c.toInternal(); got != nil {
|
||||
t.Errorf("nil.toInternal() = %v, want nil", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scalar fields round-trip", func(t *testing.T) {
|
||||
c := &AgentConfig{
|
||||
SystemPrompt: "sys",
|
||||
MaxSteps: 7,
|
||||
StreamingEnabled: true,
|
||||
DisableCoreTools: true,
|
||||
}
|
||||
got := c.toInternal()
|
||||
if got == nil {
|
||||
t.Fatal("toInternal() = nil")
|
||||
}
|
||||
if got.SystemPrompt != "sys" {
|
||||
t.Errorf("SystemPrompt = %q, want %q", got.SystemPrompt, "sys")
|
||||
}
|
||||
if got.MaxSteps != 7 {
|
||||
t.Errorf("MaxSteps = %d, want 7", got.MaxSteps)
|
||||
}
|
||||
if !got.StreamingEnabled {
|
||||
t.Error("StreamingEnabled = false, want true")
|
||||
}
|
||||
if !got.DisableCoreTools {
|
||||
t.Error("DisableCoreTools = false, want true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tool slices propagate without conversion", func(t *testing.T) {
|
||||
// Tool is a type alias for the underlying LLM-tool type, so the
|
||||
// SDK []Tool and internal []fantasy.AgentTool slices share the
|
||||
// same backing array after conversion.
|
||||
tool := NewTool[struct{}]("noop", "noop", nil)
|
||||
c := &AgentConfig{
|
||||
CoreTools: []Tool{tool},
|
||||
ExtraTools: []Tool{tool, tool},
|
||||
}
|
||||
got := c.toInternal()
|
||||
if len(got.CoreTools) != 1 {
|
||||
t.Errorf("CoreTools len = %d, want 1", len(got.CoreTools))
|
||||
}
|
||||
if len(got.ExtraTools) != 2 {
|
||||
t.Errorf("ExtraTools len = %d, want 2", len(got.ExtraTools))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tool wrapper is invoked through internal config", func(t *testing.T) {
|
||||
called := false
|
||||
c := &AgentConfig{
|
||||
ToolWrapper: func(in []Tool) []Tool {
|
||||
called = true
|
||||
return in
|
||||
},
|
||||
}
|
||||
got := c.toInternal()
|
||||
if got.ToolWrapper == nil {
|
||||
t.Fatal("internal ToolWrapper is nil")
|
||||
}
|
||||
_ = got.ToolWrapper(nil)
|
||||
if !called {
|
||||
t.Error("SDK ToolWrapper was not invoked through the internal config")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("OnMCPServerLoaded propagates", func(t *testing.T) {
|
||||
var captured string
|
||||
wantErr := errors.New("boom")
|
||||
c := &AgentConfig{
|
||||
OnMCPServerLoaded: func(name string, _ int, _ error) {
|
||||
captured = name
|
||||
},
|
||||
}
|
||||
got := c.toInternal()
|
||||
got.OnMCPServerLoaded("svr", 3, wantErr)
|
||||
if captured != "svr" {
|
||||
t.Errorf("OnMCPServerLoaded captured = %q, want %q", captured, "svr")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DebugLogger propagates", func(t *testing.T) {
|
||||
dl := &fakeDebugLogger{enabled: true}
|
||||
c := &AgentConfig{DebugLogger: dl}
|
||||
got := c.toInternal()
|
||||
if got.DebugLogger == nil {
|
||||
t.Fatal("internal DebugLogger is nil")
|
||||
}
|
||||
if !got.DebugLogger.IsDebugEnabled() {
|
||||
t.Error("IsDebugEnabled = false, want true")
|
||||
}
|
||||
got.DebugLogger.LogDebug("hello")
|
||||
if len(dl.messages) != 1 || dl.messages[0] != "hello" {
|
||||
t.Errorf("messages = %v, want [hello]", dl.messages)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MCPTaskConfig propagates with mode + progress", func(t *testing.T) {
|
||||
c := &AgentConfig{
|
||||
MCPTaskConfig: MCPTaskConfig{
|
||||
PerServerMode: map[string]MCPTaskMode{
|
||||
"build-svr": MCPTaskModeAlways,
|
||||
},
|
||||
DefaultTTL: 30 * time.Second,
|
||||
PollInterval: 250 * time.Millisecond,
|
||||
MaxPollInterval: 2 * time.Second,
|
||||
Timeout: 5 * time.Minute,
|
||||
Progress: func(_ MCPTaskProgress) {},
|
||||
},
|
||||
}
|
||||
got := c.toInternal()
|
||||
if got.MCPTaskConfig.DefaultTTL != 30*time.Second {
|
||||
t.Errorf("DefaultTTL = %v, want 30s", got.MCPTaskConfig.DefaultTTL)
|
||||
}
|
||||
if got.MCPTaskConfig.PollInterval != 250*time.Millisecond {
|
||||
t.Errorf("PollInterval = %v, want 250ms", got.MCPTaskConfig.PollInterval)
|
||||
}
|
||||
if got.MCPTaskConfig.MaxPollInterval != 2*time.Second {
|
||||
t.Errorf("MaxPollInterval = %v, want 2s", got.MCPTaskConfig.MaxPollInterval)
|
||||
}
|
||||
if got.MCPTaskConfig.Timeout != 5*time.Minute {
|
||||
t.Errorf("Timeout = %v, want 5m", got.MCPTaskConfig.Timeout)
|
||||
}
|
||||
mode, ok := got.MCPTaskConfig.PerServerMode["build-svr"]
|
||||
if !ok {
|
||||
t.Fatal("PerServerMode missing 'build-svr'")
|
||||
}
|
||||
if string(mode) != string(MCPTaskModeAlways) {
|
||||
t.Errorf("mode = %q, want %q", mode, MCPTaskModeAlways)
|
||||
}
|
||||
if got.MCPTaskConfig.Progress == nil {
|
||||
t.Fatal("internal Progress handler is nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("auth and token store factories are wired", func(t *testing.T) {
|
||||
auth := &fakeAuthHandler{}
|
||||
tokenCalls := 0
|
||||
var tokenServer string
|
||||
factory := MCPTokenStoreFactory(func(server string) (MCPTokenStore, error) {
|
||||
tokenCalls++
|
||||
tokenServer = server
|
||||
return nil, nil
|
||||
})
|
||||
c := &AgentConfig{
|
||||
AuthHandler: auth,
|
||||
TokenStoreFactory: factory,
|
||||
}
|
||||
got := c.toInternal()
|
||||
if got.AuthHandler == nil {
|
||||
t.Fatal("internal AuthHandler is nil")
|
||||
}
|
||||
if got.TokenStoreFactory == nil {
|
||||
t.Fatal("internal TokenStoreFactory is nil")
|
||||
}
|
||||
_, _ = got.TokenStoreFactory("https://example.test")
|
||||
if tokenCalls != 1 {
|
||||
t.Errorf("token factory call count = %d, want 1", tokenCalls)
|
||||
}
|
||||
if tokenServer != "https://example.test" {
|
||||
t.Errorf("token factory server arg = %q", tokenServer)
|
||||
}
|
||||
if got.AuthHandler.RedirectURI() != "redirect" {
|
||||
t.Errorf("RedirectURI = %q, want %q", got.AuthHandler.RedirectURI(), "redirect")
|
||||
}
|
||||
})
|
||||
|
||||
// Compile-time check that the internal type is what we expect.
|
||||
//nolint:staticcheck // QF1011: explicit type asserts the conversion target.
|
||||
var _ *agent.AgentConfig = (&AgentConfig{}).toInternal()
|
||||
}
|
||||
|
||||
// fakeAuthHandler implements both kit.MCPAuthHandler and the structurally
|
||||
// identical tools.MCPAuthHandler used by the internal layer.
|
||||
type fakeAuthHandler struct{}
|
||||
|
||||
func (f *fakeAuthHandler) RedirectURI() string { return "redirect" }
|
||||
func (f *fakeAuthHandler) HandleAuth(_ context.Context, _ string, _ string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// fakeDebugLogger implements kit.DebugLogger for tests.
|
||||
type fakeDebugLogger struct {
|
||||
enabled bool
|
||||
messages []string
|
||||
}
|
||||
|
||||
func (f *fakeDebugLogger) LogDebug(m string) { f.messages = append(f.messages, m) }
|
||||
func (f *fakeDebugLogger) IsDebugEnabled() bool { return f.enabled }
|
||||
+103
-9
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
"github.com/mark3labs/kit/internal/skills"
|
||||
"github.com/mark3labs/kit/internal/skilltool"
|
||||
"github.com/mark3labs/kit/internal/tools"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
@@ -1009,9 +1010,24 @@ type Options struct {
|
||||
|
||||
// Skills
|
||||
Skills []string // Explicit skill files/dirs to load (empty = auto-discover)
|
||||
SkillsDir string // Override default project-local skills directory
|
||||
SkillsDir string // Direct skills directory to scan (overrides auto-discovery; scanned as-is)
|
||||
NoSkills bool // Disable skill loading entirely (auto-discovery and explicit)
|
||||
|
||||
// SkillsDisable names skills (by Name) to exclude from the model-facing
|
||||
// catalog. Disabled skills remain available via the /skill: slash command.
|
||||
SkillsDisable []string
|
||||
|
||||
// SkillTrustPrompt is an optional callback invoked the first time Kit
|
||||
// auto-discovers project-local skills (under <project>/.agents/skills or
|
||||
// <project>/.kit/skills) in a directory that is not yet on the trust
|
||||
// allowlist. It receives the project directory and the number of skills
|
||||
// found, and returns a TrustDecision controlling whether the skills load.
|
||||
//
|
||||
// When nil, project-local skills are loaded without prompting (historical
|
||||
// behaviour). Directories trusted via TrustProject are persisted to
|
||||
// ~/.config/kit/trusted-projects.json and not prompted again.
|
||||
SkillTrustPrompt func(projectDir string, skillCount int) TrustDecision
|
||||
|
||||
// NoExtensions disables Yaegi extension loading entirely.
|
||||
NoExtensions bool
|
||||
|
||||
@@ -1052,9 +1068,25 @@ type Options struct {
|
||||
AutoCompact bool // Auto-compact when near context limit
|
||||
CompactionOptions *CompactionOptions // Config for auto-compaction (nil = defaults)
|
||||
|
||||
// Debug enables debug logging for the SDK.
|
||||
// Debug enables debug logging for the SDK. When DebugLogger is nil this
|
||||
// flag selects between the default no-op SimpleDebugLogger (Debug=false)
|
||||
// and the built-in console/buffered logger (Debug=true). When DebugLogger
|
||||
// is non-nil this flag is ignored — the supplied logger's
|
||||
// IsDebugEnabled() controls whether downstream code emits messages.
|
||||
Debug bool
|
||||
|
||||
// DebugLogger, if non-nil, routes low-level debug output from the engine
|
||||
// and the MCP tool plumbing to a caller-supplied implementation. This is
|
||||
// the SDK escape hatch for embedders that want to forward debug output
|
||||
// into their own logging system (zap, slog, log/charm, an in-app TUI
|
||||
// panel, etc.) instead of the built-in console logger.
|
||||
//
|
||||
// When nil (default) the Debug bool controls whether the built-in logger
|
||||
// is installed. When non-nil this logger is used unconditionally and the
|
||||
// Debug bool is ignored; the supplied logger's IsDebugEnabled() reports
|
||||
// whether downstream code should bother formatting messages.
|
||||
DebugLogger DebugLogger
|
||||
|
||||
// MCPAuthHandler handles OAuth authorization for remote MCP servers.
|
||||
// When set, remote transports (streamable HTTP, SSE) are configured
|
||||
// with OAuth support. If the server returns a 401, the handler is
|
||||
@@ -1357,6 +1389,15 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load skills: %w", err)
|
||||
}
|
||||
|
||||
// Apply per-skill disable list (--skill-disable / skill-disable
|
||||
// config key). Disabled skills stay loaded (so /skill: still
|
||||
// works) but are hidden from the model-facing catalog.
|
||||
disable := opts.SkillsDisable
|
||||
if len(disable) == 0 {
|
||||
disable = v.GetStringSlice("skill-disable")
|
||||
}
|
||||
applySkillDisableList(loadedSkills, disable)
|
||||
}
|
||||
|
||||
// Always compose the system prompt with runtime context: base prompt +
|
||||
@@ -1510,15 +1551,41 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
// Build agent setup options, pulling CLI-specific fields when available.
|
||||
// Pass the pre-built ProviderConfig and scalar viper snapshots so
|
||||
// SetupAgent doesn't need to re-read viper (which would require the lock).
|
||||
|
||||
// Register the dedicated activate_skill tool when at least one skill is
|
||||
// loaded (issue #65, gaps #13/#14). The provider closure reads the live
|
||||
// skill set from the Kit instance once it exists so runtime additions
|
||||
// resolve; skillToolKit is assigned after construction below.
|
||||
var skillToolKit *Kit
|
||||
extraTools := opts.ExtraTools
|
||||
if len(loadedSkills) > 0 {
|
||||
names := make([]string, 0, len(loadedSkills))
|
||||
for _, s := range loadedSkills {
|
||||
if !s.DisableModelInvocation {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
}
|
||||
provider := func() []*skills.Skill {
|
||||
if skillToolKit == nil {
|
||||
return loadedSkills
|
||||
}
|
||||
return skillToolKit.GetSkills()
|
||||
}
|
||||
if t := skilltool.New(names, provider); t != nil {
|
||||
extraTools = append(extraTools, t)
|
||||
}
|
||||
}
|
||||
|
||||
setupOpts := kitsetup.AgentSetupOptions{
|
||||
MCPConfig: mcpConfig,
|
||||
Quiet: opts.Quiet,
|
||||
CoreTools: opts.Tools,
|
||||
DisableCoreTools: disableCoreTools,
|
||||
ExtraTools: opts.ExtraTools,
|
||||
ExtraTools: extraTools,
|
||||
ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult),
|
||||
ProviderConfig: providerConfig,
|
||||
Debug: debug,
|
||||
DebugLogger: opts.DebugLogger,
|
||||
NoExtensions: noExtensions,
|
||||
MaxSteps: maxSteps,
|
||||
StreamingEnabled: streaming,
|
||||
@@ -1614,6 +1681,10 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
prepareStep: prepareStep,
|
||||
}
|
||||
|
||||
// Point the activate_skill provider closure at the live Kit instance so it
|
||||
// resolves skills mutated after construction.
|
||||
skillToolKit = k
|
||||
|
||||
// Bridge extension events to SDK hooks.
|
||||
if agentResult.ExtRunner != nil {
|
||||
k.bridgeExtensions(agentResult.ExtRunner)
|
||||
@@ -1724,6 +1795,14 @@ func (m *Kit) expandSkillCommand(prompt string) string {
|
||||
fmt.Fprintf(&buf, "<skill name=%q location=%q>\n", loaded.Name, loaded.Path)
|
||||
fmt.Fprintf(&buf, "References are relative to %s.\n\n", baseDir)
|
||||
buf.WriteString(loaded.Content)
|
||||
|
||||
// Enumerate bundled resources (scripts/, references/, assets/) so the model
|
||||
// knows what it can read without listing the directory itself.
|
||||
if res := skills.FormatResources(loaded.Resources()); res != "" {
|
||||
buf.WriteString("\n\n")
|
||||
buf.WriteString(res)
|
||||
}
|
||||
|
||||
buf.WriteString("\n</skill>")
|
||||
|
||||
args = strings.TrimSpace(args)
|
||||
@@ -1740,18 +1819,33 @@ func (m *Kit) expandSkillCommand(prompt string) string {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// loadSkills loads skills based on Options. If explicit paths are provided
|
||||
// they are loaded directly; otherwise auto-discovery runs.
|
||||
// they are loaded directly. If SkillsDir is set it is treated as a direct
|
||||
// skills directory (scanned as-is, not as a parent of .agents/.kit). Otherwise
|
||||
// auto-discovery runs against the standard scopes rooted at SessionDir.
|
||||
func loadSkills(opts *Options) ([]*skills.Skill, error) {
|
||||
if len(opts.Skills) > 0 {
|
||||
return loadExplicitSkills(opts.Skills)
|
||||
}
|
||||
|
||||
// Auto-discover from standard directories.
|
||||
cwd := opts.SkillsDir
|
||||
if cwd == "" {
|
||||
cwd = opts.SessionDir
|
||||
// An explicit --skills-dir is a direct skills directory: scan it as-is
|
||||
// rather than appending .agents/skills and .kit/skills beneath it.
|
||||
if opts.SkillsDir != "" {
|
||||
return skills.LoadSkillsFromDir(opts.SkillsDir)
|
||||
}
|
||||
return skills.LoadSkills(cwd)
|
||||
|
||||
// Auto-discover from the standard scopes rooted at the session directory.
|
||||
// Project-local skills are injected into the system prompt, so they are
|
||||
// gated on a trust decision when a SkillTrustPrompt is configured.
|
||||
cwd := opts.SessionDir
|
||||
if cwd == "" {
|
||||
cwd, _ = os.Getwd()
|
||||
}
|
||||
user := skills.LoadUserSkills()
|
||||
project := skills.LoadProjectSkills(cwd)
|
||||
if len(project) > 0 && !projectSkillsTrusted(opts, cwd, len(project)) {
|
||||
project = nil
|
||||
}
|
||||
return skills.Combine(user, project), nil
|
||||
}
|
||||
|
||||
// loadExplicitSkills loads skills from a list of explicit paths. Each path
|
||||
|
||||
+5
-33
@@ -102,10 +102,11 @@ type MCPTaskProgressHandler func(MCPTaskProgress)
|
||||
// are optional; the zero value disables progress callbacks and applies
|
||||
// sensible polling defaults inside the engine.
|
||||
//
|
||||
// For most consumers, the flat [Options] fields (`MCPTaskMode`,
|
||||
// `MCPTaskTTL`, `MCPTaskPollInterval`, `MCPTaskMaxPollInterval`,
|
||||
// `MCPTaskTimeout`, `MCPTaskProgress`) are the preferred entry point.
|
||||
// MCPTaskConfig is exposed for the low-level [AgentConfig] path.
|
||||
// Most consumers configure these via the flat [Options] fields
|
||||
// (`MCPTaskMode`, `MCPTaskTTL`, `MCPTaskPollInterval`,
|
||||
// `MCPTaskMaxPollInterval`, `MCPTaskTimeout`, `MCPTaskProgress`). The
|
||||
// MCPTaskConfig type itself is retained for downstream consumers that
|
||||
// receive it on engine-facing call sites.
|
||||
type MCPTaskConfig struct {
|
||||
// PerServerMode overrides the per-server task mode resolved from
|
||||
// [MCPServerConfig]. Keys are server names. Missing entries fall back
|
||||
@@ -133,35 +134,6 @@ type MCPTaskConfig struct {
|
||||
Progress MCPTaskProgressHandler
|
||||
}
|
||||
|
||||
// toToolsConfig converts the SDK-level [MCPTaskConfig] to the internal
|
||||
// tools-package representation. Keeps the dependency arrow internal-only.
|
||||
func (c MCPTaskConfig) toToolsConfig() tools.MCPTaskConfig {
|
||||
cfg := tools.MCPTaskConfig{
|
||||
DefaultTTL: c.DefaultTTL,
|
||||
PollInterval: c.PollInterval,
|
||||
MaxPollInterval: c.MaxPollInterval,
|
||||
Timeout: c.Timeout,
|
||||
}
|
||||
if len(c.PerServerMode) > 0 {
|
||||
cfg.PerServerMode = make(map[string]tools.MCPTaskMode, len(c.PerServerMode))
|
||||
for k, v := range c.PerServerMode {
|
||||
cfg.PerServerMode[k] = tools.MCPTaskMode(v)
|
||||
}
|
||||
}
|
||||
if c.Progress != nil {
|
||||
h := c.Progress
|
||||
cfg.Progress = func(p tools.MCPTaskProgress) {
|
||||
h(MCPTaskProgress{
|
||||
Server: p.Server,
|
||||
TaskID: p.TaskID,
|
||||
Status: MCPTaskStatus(p.Status),
|
||||
Message: p.Message,
|
||||
})
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// mcpTaskOptions carries SDK consumer configuration into the agent setup.
|
||||
// Stored on Options as a single value so the public surface stays compact;
|
||||
// individual fields are exposed via WithMCP* builder functions.
|
||||
|
||||
@@ -83,6 +83,17 @@ func WithConfigFile(path string) Option { return func(o *Options) { o.ConfigFile
|
||||
// WithDebug enables SDK debug logging.
|
||||
func WithDebug() Option { return func(o *Options) { o.Debug = true } }
|
||||
|
||||
// WithDebugLogger installs a caller-supplied [DebugLogger] for low-level
|
||||
// engine and MCP tool plumbing output. When set this overrides the built-in
|
||||
// logger selected by [WithDebug] — messages flow into the supplied logger
|
||||
// unconditionally, and the logger's IsDebugEnabled reports whether downstream
|
||||
// code should bother formatting them. Use this to forward Kit's debug output
|
||||
// into your application's logging system (slog, zap, charm/log, an in-app
|
||||
// panel, etc.).
|
||||
func WithDebugLogger(l DebugLogger) Option {
|
||||
return func(o *Options) { o.DebugLogger = l }
|
||||
}
|
||||
|
||||
// Ephemeral configures an in-memory session with no persistence (equivalent to
|
||||
// Options.NoSession = true).
|
||||
func Ephemeral() Option { return func(o *Options) { o.NoSession = true } }
|
||||
|
||||
+123
-10
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/internal/skills"
|
||||
"github.com/mark3labs/kit/internal/trust"
|
||||
)
|
||||
|
||||
// ==== Skills Types ====
|
||||
@@ -51,11 +52,14 @@ func LoadSkillsFromFS(fsys fs.FS, root string) ([]*Skill, error) {
|
||||
}
|
||||
|
||||
// LoadSkills auto-discovers skills from standard directories:
|
||||
// - Global: $XDG_CONFIG_HOME/kit/skills/ (default ~/.config/kit/skills/)
|
||||
// - Project-local: <cwd>/.kit/skills/
|
||||
// - User-level: ~/.agents/skills/ (cross-client convention)
|
||||
// - User-level: $XDG_CONFIG_HOME/kit/skills/ (default ~/.config/kit/skills/)
|
||||
// - Project-local: <cwd>/.agents/skills/ (cross-client convention)
|
||||
// - Project-local: <cwd>/.kit/skills/ (Kit-specific)
|
||||
//
|
||||
// cwd is the working directory for project-local discovery; if empty the
|
||||
// current working directory is used.
|
||||
// Project-level skills take precedence over user-level skills with the same
|
||||
// name. cwd is the working directory for project-local discovery; if empty
|
||||
// the current working directory is used.
|
||||
func LoadSkills(cwd string) ([]*Skill, error) {
|
||||
return skills.LoadSkills(cwd)
|
||||
}
|
||||
@@ -127,12 +131,17 @@ func (m *Kit) LoadSkillsFromDirForExtension(dir string) extensions.SkillLoadResu
|
||||
// convertSkill converts internal skill to extension-facing format.
|
||||
func (m *Kit) convertSkill(s *skills.Skill) *extensions.Skill {
|
||||
return &extensions.Skill{
|
||||
Name: s.Name,
|
||||
Description: s.Description,
|
||||
Content: s.Content,
|
||||
Path: s.Path,
|
||||
Tags: s.Tags,
|
||||
When: s.When,
|
||||
Name: s.Name,
|
||||
Description: s.Description,
|
||||
Content: s.Content,
|
||||
Path: s.Path,
|
||||
License: s.License,
|
||||
Compatibility: s.Compatibility,
|
||||
Metadata: s.Metadata,
|
||||
AllowedTools: s.AllowedTools,
|
||||
DisableModelInvocation: s.DisableModelInvocation,
|
||||
Tags: s.Tags,
|
||||
When: s.When,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,3 +309,107 @@ func (m *Kit) applyComposedSystemPrompt() {
|
||||
func (m *Kit) RefreshSystemPrompt() {
|
||||
m.applyComposedSystemPrompt()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-skill disable (Issue #65, gap #10)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// applySkillDisableList sets DisableModelInvocation on every skill whose Name
|
||||
// appears in names. Disabled skills remain loaded (so explicit /skill:
|
||||
// activation still works) but are hidden from the model-facing catalog.
|
||||
func applySkillDisableList(skillList []*skills.Skill, names []string) {
|
||||
if len(names) == 0 {
|
||||
return
|
||||
}
|
||||
disabled := make(map[string]bool, len(names))
|
||||
for _, n := range names {
|
||||
disabled[n] = true
|
||||
}
|
||||
for _, s := range skillList {
|
||||
if disabled[s.Name] {
|
||||
s.DisableModelInvocation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DisableSkill hides the named skill from the model-facing catalog while
|
||||
// keeping it loaded (so it can still be activated explicitly via /skill:).
|
||||
// The system prompt is recomposed and applied. Returns true when a skill with
|
||||
// that name was found.
|
||||
func (m *Kit) DisableSkill(name string) bool {
|
||||
return m.setSkillModelInvocation(name, true)
|
||||
}
|
||||
|
||||
// EnableSkill re-exposes a previously disabled skill in the model-facing
|
||||
// catalog. The system prompt is recomposed and applied. Returns true when a
|
||||
// skill with that name was found.
|
||||
func (m *Kit) EnableSkill(name string) bool {
|
||||
return m.setSkillModelInvocation(name, false)
|
||||
}
|
||||
|
||||
// setSkillModelInvocation toggles DisableModelInvocation on the named skill
|
||||
// and refreshes the system prompt. Returns true when the skill was found.
|
||||
func (m *Kit) setSkillModelInvocation(name string, disabled bool) bool {
|
||||
m.runtimeMu.Lock()
|
||||
found := false
|
||||
for _, s := range m.skills {
|
||||
if s.Name == name {
|
||||
s.DisableModelInvocation = disabled
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
m.runtimeMu.Unlock()
|
||||
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
m.ClearSkillCache()
|
||||
m.applyComposedSystemPrompt()
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project-skill trust gate (Issue #65, gap #8)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TrustDecision is the outcome of a project-skill trust prompt.
|
||||
type TrustDecision = trust.Decision
|
||||
|
||||
// Trust-prompt outcomes. They mirror the trust package decisions.
|
||||
const (
|
||||
// SkipProjectSkills declines to load project skills this session.
|
||||
SkipProjectSkills = trust.Skip
|
||||
// TrustProject loads project skills and persists the directory as trusted.
|
||||
TrustProject = trust.Trust
|
||||
// TrustProjectOnce loads project skills this session without persisting.
|
||||
TrustProjectOnce = trust.TrustOnce
|
||||
)
|
||||
|
||||
// projectSkillsTrusted decides whether project-local skills discovered in dir
|
||||
// should be loaded. When no SkillTrustPrompt is configured the directory is
|
||||
// trusted by default (preserving historical behaviour). Otherwise a persisted
|
||||
// allowlist is consulted first, then the prompt is invoked for an unknown
|
||||
// directory and the decision is persisted when the user chooses TrustProject.
|
||||
func projectSkillsTrusted(opts *Options, dir string, count int) bool {
|
||||
if opts.SkillTrustPrompt == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
store, err := trust.Load("")
|
||||
if err == nil && store.IsTrusted(dir) {
|
||||
return true
|
||||
}
|
||||
|
||||
switch opts.SkillTrustPrompt(dir, count) {
|
||||
case TrustProject:
|
||||
if store != nil {
|
||||
_ = store.Trust(dir)
|
||||
}
|
||||
return true
|
||||
case TrustProjectOnce:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package kit
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeSkillFile(t *testing.T, path, name, desc string) {
|
||||
t.Helper()
|
||||
content := "---\nname: " + name + "\ndescription: " + desc + "\n---\nBody."
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadSkills_SkillsDirIsDirect verifies --skills-dir scans the directory
|
||||
// directly rather than appending .agents/.kit beneath it (issue #65, gap #3).
|
||||
func TestLoadSkills_SkillsDirIsDirect(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeSkillFile(t, filepath.Join(dir, "direct.md"), "direct", "A direct skill")
|
||||
|
||||
got, err := loadSkills(&Options{SkillsDir: dir})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Name != "direct" {
|
||||
t.Fatalf("expected 1 skill named 'direct', got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplySkillDisableList verifies the disable list hides a skill from the
|
||||
// catalog (issue #65, gap #10).
|
||||
func TestApplySkillDisableList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeSkillFile(t, filepath.Join(dir, "a.md"), "a", "skill a")
|
||||
writeSkillFile(t, filepath.Join(dir, "b.md"), "b", "skill b")
|
||||
|
||||
got, err := loadSkills(&Options{SkillsDir: dir})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applySkillDisableList(got, []string{"a"})
|
||||
|
||||
var aDisabled, bDisabled bool
|
||||
for _, s := range got {
|
||||
switch s.Name {
|
||||
case "a":
|
||||
aDisabled = s.DisableModelInvocation
|
||||
case "b":
|
||||
bDisabled = s.DisableModelInvocation
|
||||
}
|
||||
}
|
||||
if !aDisabled {
|
||||
t.Error("skill 'a' should be disabled")
|
||||
}
|
||||
if bDisabled {
|
||||
t.Error("skill 'b' should not be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectSkillsTrust verifies the trust gate drops untrusted project
|
||||
// skills and honours a Trust decision (issue #65, gap #8).
|
||||
func TestProjectSkillsTrust(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, "xdg"))
|
||||
t.Setenv("HOME", filepath.Join(dir, "home"))
|
||||
|
||||
projectDir := filepath.Join(dir, "repo")
|
||||
skillsDir := filepath.Join(projectDir, ".agents", "skills")
|
||||
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeSkillFile(t, filepath.Join(skillsDir, "proj.md"), "proj", "project skill")
|
||||
|
||||
// Skip decision → no project skills loaded.
|
||||
skipped, err := loadSkills(&Options{
|
||||
SessionDir: projectDir,
|
||||
SkillTrustPrompt: func(_ string, _ int) TrustDecision {
|
||||
return SkipProjectSkills
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(skipped) != 0 {
|
||||
t.Fatalf("expected 0 skills when trust is skipped, got %d", len(skipped))
|
||||
}
|
||||
|
||||
// Trust decision → project skills loaded and directory persisted.
|
||||
prompted := 0
|
||||
trusted, err := loadSkills(&Options{
|
||||
SessionDir: projectDir,
|
||||
SkillTrustPrompt: func(_ string, _ int) TrustDecision {
|
||||
prompted++
|
||||
return TrustProject
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(trusted) != 1 {
|
||||
t.Fatalf("expected 1 skill when trusted, got %d", len(trusted))
|
||||
}
|
||||
|
||||
// A subsequent load should not prompt again (persisted trust).
|
||||
again, err := loadSkills(&Options{
|
||||
SessionDir: projectDir,
|
||||
SkillTrustPrompt: func(_ string, _ int) TrustDecision {
|
||||
t.Error("should not prompt for an already-trusted directory")
|
||||
return SkipProjectSkills
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(again) != 1 {
|
||||
t.Fatalf("expected 1 skill on trusted reload, got %d", len(again))
|
||||
}
|
||||
}
|
||||
+4
-108
@@ -5,13 +5,11 @@ import (
|
||||
|
||||
"charm.land/fantasy"
|
||||
|
||||
"github.com/mark3labs/kit/internal/agent"
|
||||
"github.com/mark3labs/kit/internal/compaction"
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/mark3labs/kit/internal/message"
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
"github.com/mark3labs/kit/internal/tools"
|
||||
"github.com/mark3labs/mcp-go/client/transport"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
@@ -83,9 +81,10 @@ type MCPServerConfig = config.MCPServerConfig
|
||||
// concurrent use.
|
||||
//
|
||||
// Most consumers do not need to provide one; pass [Options.Debug] = true
|
||||
// to use the default logger. DebugLogger is exposed for the low-level
|
||||
// [AgentConfig] path and for embedders that want to route debug output
|
||||
// into their own logging system.
|
||||
// (or use [WithDebug]) to install the built-in console logger. DebugLogger
|
||||
// is the escape hatch for embedders that want to route debug output into
|
||||
// their own logging system — install one via [Options.DebugLogger] or
|
||||
// [WithDebugLogger].
|
||||
type DebugLogger interface {
|
||||
// LogDebug records a single debug message. Implementations may drop,
|
||||
// buffer, or render the message however they choose.
|
||||
@@ -95,109 +94,6 @@ type DebugLogger interface {
|
||||
IsDebugEnabled() bool
|
||||
}
|
||||
|
||||
// AgentConfig holds configuration options for constructing an agent at the
|
||||
// SDK boundary. All fields use SDK-owned types, so consumers can populate
|
||||
// this struct without importing any underlying LLM-provider package.
|
||||
//
|
||||
// For most use cases, prefer the high-level [New] entry point with
|
||||
// [Options]. AgentConfig is exposed for advanced consumers that need
|
||||
// direct access to the lower-level agent configuration shape.
|
||||
type AgentConfig struct {
|
||||
// ModelConfig holds the LLM provider configuration. A nil value means
|
||||
// that the default provider/model resolution will be used.
|
||||
ModelConfig *ProviderConfig
|
||||
|
||||
// MCPConfig describes any MCP servers whose tools should be loaded
|
||||
// alongside core tools.
|
||||
MCPConfig *Config
|
||||
|
||||
// SystemPrompt is the system prompt sent to the LLM.
|
||||
SystemPrompt string
|
||||
|
||||
// MaxSteps caps the number of LLM iterations per turn. A value of
|
||||
// zero means no cap is applied at this layer.
|
||||
MaxSteps int
|
||||
|
||||
// StreamingEnabled controls whether the agent streams responses.
|
||||
StreamingEnabled bool
|
||||
|
||||
// AuthHandler handles OAuth authorization for remote MCP servers.
|
||||
// When nil, remote MCP servers requiring OAuth will fail to connect.
|
||||
AuthHandler MCPAuthHandler
|
||||
|
||||
// TokenStoreFactory, if non-nil, creates a custom token store for each
|
||||
// remote MCP server's OAuth tokens. When nil, the default file-based
|
||||
// token store is used.
|
||||
TokenStoreFactory MCPTokenStoreFactory
|
||||
|
||||
// CoreTools overrides the default core tool set. If empty, [AllTools]
|
||||
// is used. Provide a custom tool set (e.g. [CodingTools] or tools
|
||||
// built with a custom WorkDir) to scope agent capabilities.
|
||||
CoreTools []Tool
|
||||
|
||||
// DisableCoreTools, when true, prevents loading any core tools.
|
||||
// Combined with empty CoreTools this yields a chat-only agent with
|
||||
// no built-in tools.
|
||||
DisableCoreTools bool
|
||||
|
||||
// ExtraTools are additional tools loaded alongside core and MCP tools.
|
||||
ExtraTools []Tool
|
||||
|
||||
// ToolWrapper, if non-nil, wraps the combined tool list before it is
|
||||
// handed to the LLM. Used to intercept tool calls or results.
|
||||
ToolWrapper func([]Tool) []Tool
|
||||
|
||||
// OnMCPServerLoaded, if non-nil, is invoked once for each MCP server
|
||||
// when its tools have finished loading (or failed). Called from a
|
||||
// background goroutine.
|
||||
OnMCPServerLoaded func(serverName string, toolCount int, err error)
|
||||
|
||||
// DebugLogger receives low-level debug output from the engine and the
|
||||
// MCP tool plumbing. Nil means no debug output is emitted at this
|
||||
// layer (regardless of [Options.Debug], which feeds the higher-level
|
||||
// [New] entry point). Pass an implementation here when wiring a custom
|
||||
// logger through the lower-level AgentConfig path.
|
||||
DebugLogger DebugLogger
|
||||
|
||||
// MCPTaskConfig configures task-aware MCP tools/call execution — mode
|
||||
// overrides, polling intervals, timeouts, and the progress handler.
|
||||
// The zero value preserves historical synchronous-only behaviour for
|
||||
// any server that didn't advertise task support during initialize.
|
||||
MCPTaskConfig MCPTaskConfig
|
||||
}
|
||||
|
||||
// toInternal converts an AgentConfig to its internal representation.
|
||||
// Slice and function fields convert without allocation because [Tool]
|
||||
// is a type alias for the underlying LLM-tool type.
|
||||
func (c *AgentConfig) toInternal() *agent.AgentConfig {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
out := &agent.AgentConfig{
|
||||
ModelConfig: c.ModelConfig,
|
||||
MCPConfig: c.MCPConfig,
|
||||
SystemPrompt: c.SystemPrompt,
|
||||
MaxSteps: c.MaxSteps,
|
||||
StreamingEnabled: c.StreamingEnabled,
|
||||
CoreTools: c.CoreTools,
|
||||
DisableCoreTools: c.DisableCoreTools,
|
||||
ExtraTools: c.ExtraTools,
|
||||
ToolWrapper: c.ToolWrapper,
|
||||
OnMCPServerLoaded: c.OnMCPServerLoaded,
|
||||
}
|
||||
if c.AuthHandler != nil {
|
||||
out.AuthHandler = c.AuthHandler
|
||||
}
|
||||
if c.TokenStoreFactory != nil {
|
||||
out.TokenStoreFactory = tools.TokenStoreFactory(c.TokenStoreFactory)
|
||||
}
|
||||
if c.DebugLogger != nil {
|
||||
out.DebugLogger = c.DebugLogger
|
||||
}
|
||||
out.MCPTaskConfig = c.MCPTaskConfig.toToolsConfig()
|
||||
return out
|
||||
}
|
||||
|
||||
// ToolCallHandler is invoked when the LLM produces a tool call. It receives
|
||||
// the call ID, tool name, and the JSON-encoded input arguments.
|
||||
type ToolCallHandler func(toolCallID, toolName, toolArgs string)
|
||||
|
||||
+35
-41
@@ -264,30 +264,31 @@ func TestConvertFromLLMMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentConfigNoFantasyImport verifies AgentConfig can be populated with
|
||||
// every field — including CoreTools, ExtraTools, and ToolWrapper — using
|
||||
// only SDK-owned types. This test deliberately does not import
|
||||
// "charm.land/fantasy"; the package compiling at all is the proof that the
|
||||
// SDK no longer leaks the dependency name through AgentConfig.
|
||||
// TestOptionsNoFantasyImport verifies Options can be populated with the
|
||||
// tool-related fields — Tools and ExtraTools — using only SDK-owned types.
|
||||
// This test deliberately does not import "charm.land/fantasy"; the package
|
||||
// compiling at all is the proof that the SDK no longer leaks the dependency
|
||||
// name through the Options surface.
|
||||
//
|
||||
// Tool-call interception (formerly the AgentConfig.ToolWrapper escape hatch)
|
||||
// is covered by the hook system — [Kit.OnBeforeToolCall] /
|
||||
// [Kit.OnAfterToolResult] — whose hook payload types also use only
|
||||
// SDK-owned identifiers; see hooks_test.go.
|
||||
//
|
||||
// Regression test for https://github.com/mark3labs/kit/issues/30.
|
||||
func TestAgentConfigNoFantasyImport(t *testing.T) {
|
||||
func TestOptionsNoFantasyImport(t *testing.T) {
|
||||
myTool := kit.NewTool[struct{}]("noop", "does nothing", func(_ context.Context, _ struct{}) (kit.ToolOutput, error) {
|
||||
return kit.TextResult("ok"), nil
|
||||
})
|
||||
|
||||
wrapperCalled := false
|
||||
cfg := kit.AgentConfig{
|
||||
SystemPrompt: "you are a tester",
|
||||
MaxSteps: 5,
|
||||
StreamingEnabled: true,
|
||||
CoreTools: []kit.Tool{myTool},
|
||||
ExtraTools: []kit.Tool{myTool},
|
||||
DisableCoreTools: false,
|
||||
ToolWrapper: func(in []kit.Tool) []kit.Tool {
|
||||
wrapperCalled = true
|
||||
return in
|
||||
},
|
||||
streaming := true
|
||||
cfg := kit.Options{
|
||||
SystemPrompt: "you are a tester",
|
||||
MaxSteps: 5,
|
||||
Streaming: &streaming,
|
||||
Tools: []kit.Tool{myTool},
|
||||
ExtraTools: []kit.Tool{myTool},
|
||||
DisableCoreTools: false,
|
||||
OnMCPServerLoaded: func(_ string, _ int, _ error) {},
|
||||
}
|
||||
|
||||
@@ -297,36 +298,29 @@ func TestAgentConfigNoFantasyImport(t *testing.T) {
|
||||
if cfg.MaxSteps != 5 {
|
||||
t.Errorf("MaxSteps = %d, want 5", cfg.MaxSteps)
|
||||
}
|
||||
if !cfg.StreamingEnabled {
|
||||
t.Error("StreamingEnabled = false, want true")
|
||||
if cfg.Streaming == nil || !*cfg.Streaming {
|
||||
t.Error("Streaming = false/nil, want true")
|
||||
}
|
||||
if len(cfg.CoreTools) != 1 {
|
||||
t.Errorf("CoreTools len = %d, want 1", len(cfg.CoreTools))
|
||||
if len(cfg.Tools) != 1 {
|
||||
t.Errorf("Tools len = %d, want 1", len(cfg.Tools))
|
||||
}
|
||||
if len(cfg.ExtraTools) != 1 {
|
||||
t.Errorf("ExtraTools len = %d, want 1", len(cfg.ExtraTools))
|
||||
}
|
||||
|
||||
// Exercise the wrapper to confirm the func type is usable.
|
||||
out := cfg.ToolWrapper(cfg.CoreTools)
|
||||
if !wrapperCalled {
|
||||
t.Error("ToolWrapper was not invoked")
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Errorf("wrapped tool list len = %d, want 1", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentConfigToolWrapperSignature documents that AgentConfig.ToolWrapper
|
||||
// uses kit.Tool (not the underlying provider type) in its signature.
|
||||
func TestAgentConfigToolWrapperSignature(t *testing.T) {
|
||||
//nolint:staticcheck // QF1011: explicit type asserts the SDK-side func signature.
|
||||
var _ func([]kit.Tool) []kit.Tool = func(in []kit.Tool) []kit.Tool { return in }
|
||||
cfg := kit.AgentConfig{
|
||||
ToolWrapper: func(in []kit.Tool) []kit.Tool { return in },
|
||||
}
|
||||
if cfg.ToolWrapper == nil {
|
||||
t.Fatal("ToolWrapper assignment failed")
|
||||
// TestToolSliceSignature documents that the kit.Tool alias — used by every
|
||||
// SDK tool-related surface (Options.Tools, Options.ExtraTools, WithTools,
|
||||
// WithExtraTools, hook payloads) — is referenced under its SDK-owned name
|
||||
// in user code, without any fantasy import.
|
||||
func TestToolSliceSignature(t *testing.T) {
|
||||
var tools []kit.Tool
|
||||
tools = append(tools, kit.NewTool[struct{}]("noop", "",
|
||||
func(_ context.Context, _ struct{}) (kit.ToolOutput, error) {
|
||||
return kit.TextResult("ok"), nil
|
||||
}))
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("unexpected tool slice length: %d", len(tools))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,52 @@ func TestOptionFunctionsPlumbing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// recordingDebugLogger is a kit.DebugLogger used to verify WithDebugLogger
|
||||
// plumbs the supplied logger into Options. It records each LogDebug call.
|
||||
type recordingDebugLogger struct {
|
||||
enabled bool
|
||||
messages []string
|
||||
}
|
||||
|
||||
func (l *recordingDebugLogger) LogDebug(m string) { l.messages = append(l.messages, m) }
|
||||
func (l *recordingDebugLogger) IsDebugEnabled() bool { return l.enabled }
|
||||
|
||||
// TestWithDebugLoggerPlumbing verifies that kit.WithDebugLogger assigns the
|
||||
// supplied logger to Options.DebugLogger. End-to-end propagation into the
|
||||
// engine is covered indirectly by the existing kitsetup tests; this test
|
||||
// pins the SDK-surface contract.
|
||||
func TestWithDebugLoggerPlumbing(t *testing.T) {
|
||||
l := &recordingDebugLogger{enabled: true}
|
||||
o := &kit.Options{}
|
||||
kit.WithDebugLogger(l)(o)
|
||||
if o.DebugLogger == nil {
|
||||
t.Fatal("WithDebugLogger: expected Options.DebugLogger to be set")
|
||||
}
|
||||
if o.DebugLogger != l {
|
||||
t.Error("WithDebugLogger: expected the supplied logger to be installed verbatim")
|
||||
}
|
||||
// Sanity: the installed logger satisfies the SDK interface contract.
|
||||
if !o.DebugLogger.IsDebugEnabled() {
|
||||
t.Error("installed logger IsDebugEnabled() returned false")
|
||||
}
|
||||
o.DebugLogger.LogDebug("hello")
|
||||
if len(l.messages) != 1 || l.messages[0] != "hello" {
|
||||
t.Errorf("LogDebug not forwarded; got %v", l.messages)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithDebugLoggerNilClears verifies that passing a nil logger to
|
||||
// WithDebugLogger clears any previously-installed logger. This lets later
|
||||
// options override earlier ones the same way WithModel / WithStreaming do.
|
||||
func TestWithDebugLoggerNilClears(t *testing.T) {
|
||||
o := &kit.Options{}
|
||||
kit.WithDebugLogger(&recordingDebugLogger{enabled: true})(o)
|
||||
kit.WithDebugLogger(nil)(o)
|
||||
if o.DebugLogger != nil {
|
||||
t.Errorf("WithDebugLogger(nil): expected DebugLogger to be cleared; got %#v", o.DebugLogger)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOptionOrderingOverrides verifies later options override earlier ones.
|
||||
func TestOptionOrderingOverrides(t *testing.T) {
|
||||
o := &kit.Options{}
|
||||
|
||||
@@ -67,14 +67,61 @@ 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
|
||||
# Scan a directory directly for skills (overrides auto-discovery)
|
||||
kit --skills-dir /path/to/skills "prompt"
|
||||
|
||||
# Hide a skill from the model catalog by name (still usable via /skill:)
|
||||
kit --skill-disable noisy-skill "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.
|
||||
Skills follow the [agentskills.io](https://agentskills.io/specification) convention. They are auto-discovered from four canonical scopes:
|
||||
|
||||
| Scope | Location |
|
||||
|-------|----------|
|
||||
| User-level (cross-client) | `~/.agents/skills/` |
|
||||
| User-level (Kit) | `~/.config/kit/skills/` (honors `$XDG_CONFIG_HOME`) |
|
||||
| Project-local (cross-client) | `<project>/.agents/skills/` |
|
||||
| Project-local (Kit) | `<project>/.kit/skills/` |
|
||||
|
||||
When two skills share the same `name`, the project-level one takes precedence over the user-level one. Use `--skills-dir` to scan one directory directly instead (it is **not** treated as a parent of `.agents`/`.kit` — the directory itself is scanned). `--skill` loads files explicitly (which disables auto-discovery), and `--no-skills` suppresses all skill loading regardless of other flags.
|
||||
|
||||
Disabled skills (`--skill-disable`, the `skill-disable` config key, or `disable-model-invocation: true` in a skill's frontmatter) are hidden from the model-facing `<available_skills>` catalog but remain available for explicit activation via the `/skill:<name>` command.
|
||||
|
||||
### Skill frontmatter
|
||||
|
||||
A skill is a markdown file (`SKILL.md` in a directory, or a standalone `.md`/`.txt` file) with optional YAML frontmatter. Kit reads the full [agentskills.io](https://agentskills.io/specification) field set plus two Kit-specific extensions:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: pdf-extractor # required
|
||||
description: Use when extracting tables from PDFs # required (drives model discovery)
|
||||
license: MIT # optional, SPDX identifier
|
||||
compatibility: claude-code, cursor # optional, targeted environments
|
||||
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: [pdf, data] # Kit extension
|
||||
when: on-demand # Kit extension
|
||||
---
|
||||
```
|
||||
|
||||
`name` and `description` are required — a skill missing its description is skipped with a logged warning, since the description is the sole basis on which the model decides relevance. Descriptions are XML-escaped before they enter the catalog, so characters like `<`, `>`, and `&` are safe. A skill directory may bundle `scripts/`, `references/`, and `assets/` subdirectories; when a skill is activated those files are enumerated in a `<skill_resources>` block so the model knows what it can read.
|
||||
|
||||
### Project trust prompt
|
||||
|
||||
Because project-local skills are injected into the system prompt, entering a repository that ships `.agents/skills/` or `.kit/skills/` for the first time prompts you to trust it before any project skill loads — a safeguard against a freshly cloned, untrusted repo smuggling instructions into the agent:
|
||||
|
||||
```
|
||||
This project provides 2 skills under .agents/skills or .kit/skills:
|
||||
/path/to/repo
|
||||
Load them into the agent? [t]rust always / [o]nce / [s]kip (default skip):
|
||||
```
|
||||
|
||||
Choosing **trust always** persists the directory to `~/.config/kit/trusted-projects.json` so you are not asked again. The prompt is skipped (skills load silently) in non-interactive runs — when a prompt is passed positionally, `--quiet` is set, or stdin is not a TTY.
|
||||
|
||||
## GitHub integration
|
||||
|
||||
|
||||
@@ -53,7 +53,8 @@ These flags control Kit's behavior. When a prompt is passed as a positional argu
|
||||
| Flag | Short | Default | Description |
|
||||
|------|-------|---------|-------------|
|
||||
| `--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` | — | `false` | Disable skill loading (auto-discovery and explicit) |
|
||||
|
||||
## Generation parameters
|
||||
|
||||
@@ -49,7 +49,8 @@ stream: true
|
||||
| `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 |
|
||||
| `skills-dir` | string | — | Scan this directory directly for skills (overrides auto-discovery; not treated as a parent of `.agents`/`.kit`) |
|
||||
| `skill-disable` | list | — | Skill names to hide from the model catalog (still usable via `/skill:`) |
|
||||
|
||||
## Environment variables
|
||||
|
||||
|
||||
@@ -447,11 +447,14 @@ Load and inject skills dynamically at runtime:
|
||||
```go
|
||||
// Discover skills from standard locations
|
||||
result := ctx.DiscoverSkills() // ext.SkillLoadResult{Skills, Error}
|
||||
// Standard locations: ~/.config/kit/skills/, .kit/skills/, .agents/skills/
|
||||
// Standard locations: ~/.agents/skills/, ~/.config/kit/skills/,
|
||||
// <project>/.agents/skills/, <project>/.kit/skills/
|
||||
|
||||
// Load a specific skill file
|
||||
skill, err := ctx.LoadSkill("/path/to/skill.md") // (*ext.Skill, error string)
|
||||
// skill.Name, skill.Description, skill.Content, skill.Tags, skill.When
|
||||
// Spec fields: skill.Name, skill.Description, skill.License, skill.Compatibility,
|
||||
// skill.Metadata, skill.AllowedTools, skill.DisableModelInvocation
|
||||
// Plus content/path and Kit extensions: skill.Content, skill.Path, skill.Tags, skill.When
|
||||
|
||||
// Load all skills from a directory
|
||||
result := ctx.LoadSkillsFromDir("/path/to/skills") // ext.SkillLoadResult
|
||||
|
||||
@@ -31,6 +31,7 @@ host, err := kit.New(ctx, &kit.Options{
|
||||
Streaming: ptrBool(true), // *bool: nil = unset (default true), &false = off
|
||||
Quiet: true,
|
||||
Debug: true,
|
||||
DebugLogger: myLogger, // optional; overrides Debug + built-in logger when non-nil
|
||||
|
||||
// Generation parameters (override env/config/per-model defaults)
|
||||
MaxTokens: 16384, // 0 = auto-resolve; non-zero suppresses right-sizing
|
||||
@@ -64,9 +65,10 @@ host, err := kit.New(ctx, &kit.Options{
|
||||
AutoCompact: true,
|
||||
|
||||
// Skills
|
||||
Skills: []string{"/path/to/skill.md"},
|
||||
SkillsDir: "/path/to/skills/",
|
||||
NoSkills: true,
|
||||
Skills: []string{"/path/to/skill.md"},
|
||||
SkillsDir: "/path/to/skills/",
|
||||
SkillsDisable: []string{"noisy-skill"},
|
||||
NoSkills: true,
|
||||
|
||||
// Feature toggles
|
||||
NoExtensions: true, // disable Yaegi extension loading
|
||||
@@ -103,7 +105,8 @@ host, err := kit.New(ctx, &kit.Options{
|
||||
| `MaxSteps` | `int` | `0` | Max agent steps (0 = unlimited) |
|
||||
| `Streaming` | `*bool` | `nil` | Enable streaming output. `nil` leaves it to the precedence chain (env → config → default `true`); `&true`/`&false` forces it. Pointer so unset is distinct from explicit `false`. |
|
||||
| `Quiet` | `bool` | `false` | Suppress output |
|
||||
| `Debug` | `bool` | `false` | Enable debug logging |
|
||||
| `Debug` | `bool` | `false` | Enable debug logging via the built-in console / buffered logger. Ignored when `DebugLogger` is non-nil. |
|
||||
| `DebugLogger` | `DebugLogger` | `nil` | Caller-supplied logger that receives low-level engine + MCP tool plumbing debug output. When non-nil this overrides `Debug` — the supplied logger's `IsDebugEnabled()` controls downstream emission. See [Custom debug logger](#custom-debug-logger). |
|
||||
|
||||
### Generation parameters
|
||||
|
||||
@@ -168,13 +171,48 @@ when embedding Kit as a library.
|
||||
|-------|------|---------|-------------|
|
||||
| `SkipConfig` | `bool` | `false` | Skip `.kit.yml` file loading (viper defaults + env vars still apply) |
|
||||
| `Skills` | `[]string` | — | Explicit skill files/dirs to load |
|
||||
| `SkillsDir` | `string` | — | Override default skills directory |
|
||||
| `SkillsDir` | `string` | — | Scan this directory directly for skills (overrides auto-discovery; scanned as-is) |
|
||||
| `SkillsDisable` | `[]string` | — | Skill names to hide from the model catalog (still usable via `/skill:`) |
|
||||
| `SkillTrustPrompt` | `func(projectDir string, skillCount int) TrustDecision` | `nil` | Callback gating project-local skill loading on a trust decision (see below) |
|
||||
| `NoSkills` | `bool` | `false` | Disable skill loading entirely |
|
||||
|
||||
#### Project-skill trust gate
|
||||
|
||||
Project-local skills (under `<project>/.agents/skills/` or `<project>/.kit/skills/`)
|
||||
are injected into the system prompt, so loading them from an untrusted, freshly
|
||||
cloned repository is a prompt-injection vector. Set `SkillTrustPrompt` to gate
|
||||
that first load on an explicit decision. When `nil` (the default), project
|
||||
skills load without prompting — preserving historical behaviour.
|
||||
|
||||
```go
|
||||
opts := &kit.Options{
|
||||
SkillTrustPrompt: func(projectDir string, skillCount int) kit.TrustDecision {
|
||||
// Consult your own UI / policy here.
|
||||
if userApproves(projectDir, skillCount) {
|
||||
return kit.TrustProject // load and persist the directory as trusted
|
||||
}
|
||||
return kit.SkipProjectSkills // do not load project skills
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The callback returns one of three `TrustDecision` values:
|
||||
|
||||
| Decision | Effect |
|
||||
|----------|--------|
|
||||
| `kit.TrustProject` | Load project skills and persist `projectDir` to `~/.config/kit/trusted-projects.json` (not prompted again) |
|
||||
| `kit.TrustProjectOnce` | Load project skills for this run only, without persisting |
|
||||
| `kit.SkipProjectSkills` | Do not load project skills |
|
||||
|
||||
A directory already on the persisted allowlist is trusted without invoking the
|
||||
callback. The Kit CLI wires this to an interactive terminal prompt automatically
|
||||
for TTY sessions.
|
||||
|
||||
These fields only control the **initial** skill and context-file set picked
|
||||
up by `New()`. To add, remove, or replace skills and `AGENTS.md`-style
|
||||
context files at runtime (e.g. per user or per session), use the
|
||||
`AddSkill` / `LoadAndAddSkill` / `RemoveSkill` / `SetSkills` and
|
||||
`AddSkill` / `LoadAndAddSkill` / `RemoveSkill` / `SetSkills` /
|
||||
`DisableSkill` / `EnableSkill` and
|
||||
`AddContextFile` / `AddContextFileContent` / `RemoveContextFile` /
|
||||
`SetContextFiles` methods on `*kit.Kit`. See
|
||||
[Runtime skills and context files](/sdk/overview#runtime-skills-and-context-files).
|
||||
@@ -346,6 +384,45 @@ loaded MCP server that advertises the corresponding capability.
|
||||
Context cancellation also works end-to-end: cancelling the `ctx` passed to a
|
||||
tool execution triggers a best-effort `tasks/cancel` before the call returns.
|
||||
|
||||
## Custom debug logger
|
||||
|
||||
Kit's engine and MCP tool plumbing emit low-level debug output through a
|
||||
`DebugLogger` interface. By default, setting `Debug: true` (or calling
|
||||
`WithDebug()`) installs the built-in console logger. To route the same output
|
||||
into your application's logging system instead, provide a custom
|
||||
implementation via `Options.DebugLogger` or `WithDebugLogger`.
|
||||
|
||||
```go
|
||||
type DebugLogger interface {
|
||||
LogDebug(message string)
|
||||
IsDebugEnabled() bool
|
||||
}
|
||||
```
|
||||
|
||||
When `DebugLogger` is non-nil it takes precedence over `Debug` — the
|
||||
supplied logger's `IsDebugEnabled()` reports whether downstream code should
|
||||
bother formatting messages.
|
||||
|
||||
**Example: forward to `log/slog`:**
|
||||
|
||||
```go
|
||||
import "log/slog"
|
||||
|
||||
type slogDebugLogger struct{ l *slog.Logger }
|
||||
|
||||
func (s *slogDebugLogger) LogDebug(m string) { s.l.Debug(m) }
|
||||
func (s *slogDebugLogger) IsDebugEnabled() bool { return true }
|
||||
|
||||
host, _ := kit.NewAgent(ctx,
|
||||
kit.WithModel("anthropic/claude-sonnet-4-5-20250929"),
|
||||
kit.WithDebugLogger(&slogDebugLogger{l: slog.Default()}),
|
||||
)
|
||||
```
|
||||
|
||||
Implementations must be safe for concurrent use — messages can arrive
|
||||
from the engine goroutine, MCP connection pool, and tool execution paths
|
||||
simultaneously.
|
||||
|
||||
## Precedence
|
||||
|
||||
For any given generation or provider field, the effective value is resolved
|
||||
|
||||
@@ -80,6 +80,7 @@ Available options:
|
||||
| `WithProviderURL(string)` | `Options.ProviderURL` |
|
||||
| `WithConfigFile(string)` | `Options.ConfigFile` |
|
||||
| `WithDebug()` | `Options.Debug = true` |
|
||||
| `WithDebugLogger(DebugLogger)` | `Options.DebugLogger` (route engine + MCP debug output into a custom logger; overrides `WithDebug` when set) |
|
||||
| `Ephemeral()` | `Options.NoSession = true` |
|
||||
|
||||
Options are applied in order, so later options override earlier ones. `Option`
|
||||
@@ -381,6 +382,11 @@ host.LoadAndAddContextFile("/etc/agents/tenant-acme.md")
|
||||
host.RemoveSkill("polite-french")
|
||||
host.RemoveContextFile(fmt.Sprintf("session://%s/AGENTS.md", userID))
|
||||
|
||||
// Hide a skill from the model-facing catalog without unloading it — it stays
|
||||
// available for explicit /skill: activation. EnableSkill reverses this.
|
||||
host.DisableSkill("refund-policy")
|
||||
host.EnableSkill("refund-policy")
|
||||
|
||||
// Or replace the whole set in one call.
|
||||
host.SetSkills(activeSkillsForUser)
|
||||
host.SetContextFiles(activeContextForUser)
|
||||
@@ -406,9 +412,22 @@ Key points:
|
||||
from multiple goroutines; the underlying state is guarded by an internal
|
||||
`RWMutex`.
|
||||
- **Init-time options still apply.** `Options.Skills`, `Options.SkillsDir`,
|
||||
`Options.NoSkills`, and `Options.NoContextFiles` continue to control the
|
||||
startup set; the runtime API mutates from whatever state `New()` produced.
|
||||
`Options.SkillsDisable`, `Options.SkillTrustPrompt`, `Options.NoSkills`, and
|
||||
`Options.NoContextFiles` continue to control the startup set; the runtime API
|
||||
mutates from whatever state `New()` produced.
|
||||
See [SDK options](/sdk/options#skills--configuration).
|
||||
- **Auto-discovery scopes.** When no explicit `Skills`/`SkillsDir` are given,
|
||||
`New()` scans four [agentskills.io](https://agentskills.io/specification)
|
||||
locations: `~/.agents/skills/`, `~/.config/kit/skills/`,
|
||||
`<project>/.agents/skills/`, and `<project>/.kit/skills/`. Project-level
|
||||
skills override user-level skills of the same `name`. Skills missing a
|
||||
`description` are skipped with a logged warning, and a skill's
|
||||
`disable-model-invocation: true` (or `Options.SkillsDisable`) hides it from
|
||||
the catalog while keeping it available for explicit activation.
|
||||
- **Skill helpers.** A `kit.Skill` exposes `BaseDir()` (its directory) and
|
||||
`Resources()` (the files bundled under `scripts/`, `references/`, and
|
||||
`assets/`), which power the `<skill_resources>` enumeration shown when a skill
|
||||
is activated.
|
||||
- **`fs.FS`-backed discovery.** The package-level loaders `kit.LoadSkill`,
|
||||
`kit.LoadSkillsFromDir`, and `kit.LoadSkills` are path-string based;
|
||||
`kit.LoadSkillsFromFS(fsys, root)` is the `fs.FS`-typed counterpart for
|
||||
|
||||
Reference in New Issue
Block a user