From 9855cd6b185ccdcbc0ad2fe20fc7df8f4979e4bf Mon Sep 17 00:00:00 2001 From: Christopher Petito Date: Thu, 5 Feb 2026 11:06:23 +0100 Subject: [PATCH] standard .agents/skills support Signed-off-by: Christopher Petito --- docs/USAGE.md | 139 +++++++++++++++++++++ pkg/skills/skills.go | 86 ++++++++++++- pkg/skills/skills_test.go | 257 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 480 insertions(+), 2 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index 18de96964..6bd2a6f80 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1022,6 +1022,145 @@ them to delegate tasks to other agents: transfer_task(agent="developer", task="Create a login form", expected_output="HTML and CSS code") ``` +## Skills + +Skills provide specialized instructions for specific tasks that agents can load on demand. When a user's request matches a skill's description, the agent reads the skill's `SKILL.md` file to get detailed instructions for that task. + +### Enabling Skills + +Enable skills for an agent by setting `skills: true` in the agent configuration. The agent must also have a `filesystem` toolset with `read_file` capability: + +```yaml +agents: + root: + model: openai/gpt-4o + instruction: You are a helpful assistant. + skills: true + toolsets: + - type: filesystem # Required for reading skill files +``` + +### How Skills Work + +When skills are enabled: + +1. cagent scans default locations for `SKILL.md` files +2. Skill metadata (name, description, location) is injected into the agent's system prompt +3. When a user request matches a skill's description, the agent uses `read_file` to load the full instructions +4. The agent follows the skill's instructions to complete the task + +### SKILL.md Format + +Skills are defined as Markdown files with YAML frontmatter: + +```markdown +--- +name: my-skill +description: A brief description of what this skill does and when to use it +license: Apache-2.0 +compatibility: Requires docker and git +metadata: + author: my-org + version: "1.0" +allowed-tools: + - Bash(git:*) + - Read + - Write +--- + +# Skill Instructions + +Detailed instructions for the agent to follow when this skill is activated... +``` + +Required fields: +- `name`: Unique identifier for the skill +- `description`: Brief description used by the agent to determine when to use this skill + +Optional fields: +- `license`: License for the skill +- `compatibility`: Requirements or compatibility notes +- `metadata`: Key-value pairs for additional metadata +- `allowed-tools`: List of tools the skill is designed to work with + +### Default Skill Search Paths + +Skills are automatically discovered from the following locations (in order, later overrides earlier): + +**Global locations** (from home directory): +- `~/.codex/skills/` — Recursive search (Codex format) +- `~/.claude/skills/` — Flat search (Claude format) +- `~/.agents/skills/` — Flat search (Agent Skills standard) + +**Project locations** (from git root up to current directory): +- `.claude/skills/` — Flat search, only at current working directory +- `.agents/skills/` — Flat search, scanned from git root to current directory + +### Project Skill Discovery + +For `.agents/skills`, cagent walks up from the current working directory to the git repository root, loading skills from each directory along the way. Skills in directories closer to your current working directory take precedence over those higher up in the hierarchy. + +**Example directory structure:** +``` +my-repo/ # Git root +├── .git/ +├── .agents/skills/ +│ └── repo-skill/ +│ └── SKILL.md # Available everywhere in repo +└── frontend/ + ├── .agents/skills/ + │ └── frontend-skill/ + │ └── SKILL.md # Available in frontend/ and below + └── src/ # Current working directory +``` + +When working in `my-repo/frontend/src/`: +- Both `repo-skill` and `frontend-skill` are available +- If both define the same skill name, `frontend-skill` wins (closer to cwd) + +### Skill Precedence + +When multiple skills have the same name, the later-loaded skill wins: + +1. Global skills load first (`~/.codex/skills/`, `~/.claude/skills/`, `~/.agents/skills/`) +2. Project skills load next, from git root toward current directory +3. Skills closer to the current directory override those further away + +This allows: +- Global skills to provide defaults +- Repository-level skills to customize for a project +- Subdirectory skills to specialize further + +### Creating Skills + +To create a skill: + +1. Create a directory in one of the search paths (e.g., `~/.agents/skills/my-skill/`) +2. Add a `SKILL.md` file with frontmatter and instructions +3. The skill will automatically be available to agents with `skills: true` + +**Example:** + +```bash +mkdir -p ~/.agents/skills/create-dockerfile +cat > ~/.agents/skills/create-dockerfile/SKILL.md << 'EOF' +--- +name: create-dockerfile +description: Create optimized Dockerfiles for applications +--- + +# Creating Dockerfiles + +When asked to create a Dockerfile: + +1. Analyze the application type and language +2. Use multi-stage builds for compiled languages +3. Minimize image size by using slim base images +4. Follow security best practices (non-root user, etc.) +... +EOF +``` + ## RAG (Retrieval-Augmented Generation) Give your agents access to document knowledge bases using cagent's modular RAG system. It supports: diff --git a/pkg/skills/skills.go b/pkg/skills/skills.go index 21e3575a8..9bdcf50e9 100644 --- a/pkg/skills/skills.go +++ b/pkg/skills/skills.go @@ -28,9 +28,15 @@ type Skill struct { // Load discovers and loads all skills from standard locations. // Skills are loaded from (in order, later overrides earlier): +// +// Global locations (from home directory): // - ~/.codex/skills/ (recursive) // - ~/.claude/skills/ (flat) -// - ./.claude/skills/ (flat, project-local) +// - ~/.agents/skills/ (flat) +// +// Project locations (from git root up to cwd, closest wins): +// - .claude/skills/ (flat, only at cwd) +// - .agents/skills/ (flat, scanned from git root to cwd) func Load() []Skill { skillMap := make(map[string]Skill) @@ -44,13 +50,26 @@ func Load() []Skill { for _, skill := range loadSkillsFromDir(filepath.Join(homeDir, ".claude", "skills"), false) { skillMap[skill.Name] = skill } + // Load from agents user directory (flat) + for _, skill := range loadSkillsFromDir(filepath.Join(homeDir, ".agents", "skills"), false) { + skillMap[skill.Name] = skill + } } - // Load from project directory (flat) + // Load from project directories if cwd, err := os.Getwd(); err == nil { + // Load .claude/skills from cwd only (backward compatibility) for _, skill := range loadSkillsFromDir(filepath.Join(cwd, ".claude", "skills"), false) { skillMap[skill.Name] = skill } + + // Load .agents/skills from git root up to cwd (closest wins) + // We iterate from root to cwd so that later (closer) directories override earlier ones + for _, dir := range projectSearchDirs(cwd) { + for _, skill := range loadSkillsFromDir(filepath.Join(dir, ".agents", "skills"), false) { + skillMap[skill.Name] = skill + } + } } result := make([]Skill, 0, len(skillMap)) @@ -60,6 +79,69 @@ func Load() []Skill { return result } +// projectSearchDirs returns directories from git root to cwd (inclusive). +// If not in a git repo, returns only cwd. +// The returned slice is ordered from root to cwd so that closer directories +// can override skills from parent directories. +func projectSearchDirs(cwd string) []string { + absPath, err := filepath.Abs(cwd) + if err != nil { + return []string{cwd} + } + + // Find git root by walking up + gitRoot := findGitRoot(absPath) + if gitRoot == "" { + // Not in a git repo, just return cwd + return []string{absPath} + } + + // Build list of directories from git root to cwd + var dirs []string + current := absPath + for { + dirs = append(dirs, current) + if current == gitRoot { + break + } + parent := filepath.Dir(current) + if parent == current { + // Reached filesystem root without finding git root (shouldn't happen) + break + } + current = parent + } + + // Reverse so we go from root to cwd (earlier entries get overridden by later) + for i, j := 0, len(dirs)-1; i < j; i, j = i+1, j-1 { + dirs[i], dirs[j] = dirs[j], dirs[i] + } + + return dirs +} + +// findGitRoot finds the git repository root by looking for .git directory or file. +// Returns empty string if not in a git repository. +func findGitRoot(dir string) string { + current := dir + for { + gitPath := filepath.Join(current, ".git") + if info, err := os.Stat(gitPath); err == nil { + // .git can be a directory (normal repo) or a file (worktree/submodule) + if info.IsDir() || info.Mode().IsRegular() { + return current + } + } + + parent := filepath.Dir(current) + if parent == current { + // Reached filesystem root + return "" + } + current = parent + } +} + // BuildSkillsPrompt generates a prompt section describing available skills. func BuildSkillsPrompt(skills []Skill) string { if len(skills) == 0 { diff --git a/pkg/skills/skills_test.go b/pkg/skills/skills_test.go index 6722dfc52..d8806248b 100644 --- a/pkg/skills/skills_test.go +++ b/pkg/skills/skills_test.go @@ -301,3 +301,260 @@ description: Test project skill } assert.True(t, found, "Expected to find test-skill from project directory") } + +func TestLoad_AgentsSkillsGlobal(t *testing.T) { + // Create a temp home directory with .agents/skills + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + agentsSkillDir := filepath.Join(tmpHome, ".agents", "skills", "global-skill") + require.NoError(t, os.MkdirAll(agentsSkillDir, 0o755)) + + skillContent := `--- +name: global-skill +description: A global agents skill +--- + +# Global Skill +` + require.NoError(t, os.WriteFile(filepath.Join(agentsSkillDir, "SKILL.md"), []byte(skillContent), 0o644)) + + // Change to a temp directory that doesn't have any skills + tmpCwd := t.TempDir() + t.Chdir(tmpCwd) + + skills := Load() + + found := false + for _, s := range skills { + if s.Name == "global-skill" { + found = true + assert.Equal(t, "A global agents skill", s.Description) + assert.Equal(t, filepath.Join(agentsSkillDir, "SKILL.md"), s.FilePath) + break + } + } + assert.True(t, found, "Expected to find global-skill from ~/.agents/skills") +} + +func TestLoad_AgentsSkillsProjectFromNestedDir(t *testing.T) { + // Create a fake git repo with .agents/skills at the root + tmpRepo := t.TempDir() + + // Create .git directory to mark as git root + require.NoError(t, os.Mkdir(filepath.Join(tmpRepo, ".git"), 0o755)) + + // Create .agents/skills at repo root + agentsSkillDir := filepath.Join(tmpRepo, ".agents", "skills", "repo-skill") + require.NoError(t, os.MkdirAll(agentsSkillDir, 0o755)) + + skillContent := `--- +name: repo-skill +description: A skill from repo root +--- + +# Repo Skill +` + require.NoError(t, os.WriteFile(filepath.Join(agentsSkillDir, "SKILL.md"), []byte(skillContent), 0o644)) + + // Create a nested directory and chdir there + nestedDir := filepath.Join(tmpRepo, "sub", "nested", "deep") + require.NoError(t, os.MkdirAll(nestedDir, 0o755)) + t.Chdir(nestedDir) + + // Set HOME to a directory without skills to isolate test + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + skills := Load() + + found := false + for _, s := range skills { + if s.Name == "repo-skill" { + found = true + assert.Equal(t, "A skill from repo root", s.Description) + assert.Equal(t, filepath.Join(agentsSkillDir, "SKILL.md"), s.FilePath) + break + } + } + assert.True(t, found, "Expected to find repo-skill from .agents/skills at git root") +} + +func TestLoad_AgentsSkillsPrecedence_ProjectOverridesGlobal(t *testing.T) { + // Create a temp home directory with a global skill + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + globalSkillDir := filepath.Join(tmpHome, ".agents", "skills", "shared-skill") + require.NoError(t, os.MkdirAll(globalSkillDir, 0o755)) + + globalContent := `--- +name: shared-skill +description: Global version of shared skill +--- + +# Global Version +` + require.NoError(t, os.WriteFile(filepath.Join(globalSkillDir, "SKILL.md"), []byte(globalContent), 0o644)) + + // Create a project with the same skill name + tmpRepo := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(tmpRepo, ".git"), 0o755)) + + projectSkillDir := filepath.Join(tmpRepo, ".agents", "skills", "shared-skill") + require.NoError(t, os.MkdirAll(projectSkillDir, 0o755)) + + projectContent := `--- +name: shared-skill +description: Project version of shared skill +--- + +# Project Version +` + require.NoError(t, os.WriteFile(filepath.Join(projectSkillDir, "SKILL.md"), []byte(projectContent), 0o644)) + + t.Chdir(tmpRepo) + + skills := Load() + + found := false + for _, s := range skills { + if s.Name == "shared-skill" { + found = true + // Project should win over global + assert.Equal(t, "Project version of shared skill", s.Description) + assert.Equal(t, filepath.Join(projectSkillDir, "SKILL.md"), s.FilePath) + break + } + } + assert.True(t, found, "Expected to find shared-skill") +} + +func TestLoad_AgentsSkillsPrecedence_CloserDirWins(t *testing.T) { + // Create a git repo with skills at both root and a subdirectory + tmpRepo := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(tmpRepo, ".git"), 0o755)) + + // Skill at repo root + rootSkillDir := filepath.Join(tmpRepo, ".agents", "skills", "local-skill") + require.NoError(t, os.MkdirAll(rootSkillDir, 0o755)) + + rootContent := `--- +name: local-skill +description: Root version +--- + +# Root +` + require.NoError(t, os.WriteFile(filepath.Join(rootSkillDir, "SKILL.md"), []byte(rootContent), 0o644)) + + // Same skill in a subdirectory + subDir := filepath.Join(tmpRepo, "subproject") + require.NoError(t, os.MkdirAll(subDir, 0o755)) + + subSkillDir := filepath.Join(subDir, ".agents", "skills", "local-skill") + require.NoError(t, os.MkdirAll(subSkillDir, 0o755)) + + subContent := `--- +name: local-skill +description: Subproject version +--- + +# Subproject +` + require.NoError(t, os.WriteFile(filepath.Join(subSkillDir, "SKILL.md"), []byte(subContent), 0o644)) + + // Set HOME to empty dir + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + // From repo root, should get root version + t.Chdir(tmpRepo) + skills := Load() + for _, s := range skills { + if s.Name == "local-skill" { + assert.Equal(t, "Root version", s.Description) + break + } + } + + // From subproject, should get subproject version (closer wins) + t.Chdir(subDir) + skills = Load() + for _, s := range skills { + if s.Name == "local-skill" { + assert.Equal(t, "Subproject version", s.Description) + break + } + } +} + +func TestFindGitRoot(t *testing.T) { + t.Run("git directory at current", func(t *testing.T) { + tmpDir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(tmpDir, ".git"), 0o755)) + got := findGitRoot(tmpDir) + assert.Equal(t, tmpDir, got) + }) + + t.Run("git directory at parent", func(t *testing.T) { + tmpDir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(tmpDir, ".git"), 0o755)) + nestedDir := filepath.Join(tmpDir, "sub", "nested") + require.NoError(t, os.MkdirAll(nestedDir, 0o755)) + got := findGitRoot(nestedDir) + assert.Equal(t, tmpDir, got) + }) + + t.Run("git file (worktree)", func(t *testing.T) { + tmpDir := t.TempDir() + // .git as a file (like in worktrees) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".git"), []byte("gitdir: /somewhere/else/.git"), 0o644)) + got := findGitRoot(tmpDir) + assert.Equal(t, tmpDir, got) + }) + + t.Run("no git repo", func(t *testing.T) { + tmpDir := t.TempDir() + got := findGitRoot(tmpDir) + assert.Empty(t, got) + }) +} + +func TestProjectSearchDirs(t *testing.T) { + t.Run("in git repo", func(t *testing.T) { + tmpRepo := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(tmpRepo, ".git"), 0o755)) + + nestedDir := filepath.Join(tmpRepo, "a", "b", "c") + require.NoError(t, os.MkdirAll(nestedDir, 0o755)) + + dirs := projectSearchDirs(nestedDir) + + // Should be ordered from root to nested (root first, nested last) + require.Len(t, dirs, 4) + assert.Equal(t, tmpRepo, dirs[0]) + assert.Equal(t, filepath.Join(tmpRepo, "a"), dirs[1]) + assert.Equal(t, filepath.Join(tmpRepo, "a", "b"), dirs[2]) + assert.Equal(t, filepath.Join(tmpRepo, "a", "b", "c"), dirs[3]) + }) + + t.Run("not in git repo", func(t *testing.T) { + tmpDir := t.TempDir() + + dirs := projectSearchDirs(tmpDir) + + require.Len(t, dirs, 1) + assert.Equal(t, tmpDir, dirs[0]) + }) + + t.Run("at git root", func(t *testing.T) { + tmpRepo := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(tmpRepo, ".git"), 0o755)) + + dirs := projectSearchDirs(tmpRepo) + + require.Len(t, dirs, 1) + assert.Equal(t, tmpRepo, dirs[0]) + }) +}