Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 22 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,16 +377,22 @@ The Podman runtime supports runtime-specific configuration for **building and co

### Skills System

Skills are reusable capabilities that can be discovered and executed by AI agents:
There are two distinct kinds of skills in this repository:

- **Location**: `.agents/skills/<skill-name>/SKILL.md`
- **Claude support**: `.claude/skills` is a symlink to `../.agents/skills`, so Claude Code discovers skills automatically
- **Format**: Each SKILL.md contains:
- YAML frontmatter with `name`, `description`, `argument-hint`
- Detailed instructions for execution
- Usage examples
**1. Contributor skills** (`.agents/skills/<name>/`) — for agents working on the kdn codebase itself. These are the skills listed in the system prompt (e.g. `/add-command-with-json`, `/working-with-config-system`). Claude Code discovers them via the `.claude/skills` symlink.

Skills can be provided to workspaces via the `skills` field in `workspace.json` (or any other config level). Each entry is the path to a single skill directory on the host. kdn mounts it read-only into the agent's skills directory inside the container using the directory's basename as the skill name:
**2. Built-in user-facing skills** (`skills/<name>/`) — for agents running inside kdn workspaces. These are embedded in the kdn binary at compile time and automatically mounted into every workspace whose agent supports skills.

#### Built-in skills (`skills/`)

- **Package**: `skills/skills.go` — embeds all listed subdirectories using `//go:embed` and provides `ExtractAll(storageDir string) ([]string, error)`
- **Extraction**: `ExtractAll` writes each embedded skill to `<storageDir>/skills/<name>/` and returns the host paths. Files are extracted with `0644` permissions (embed.FS normalises all modes to `0444`, so original permissions cannot be preserved).
- **Auto-injection**: `manager.Add()` calls `ExtractAll` whenever the agent's `SkillsDir()` is non-empty, then appends each returned path to `mergedConfig.Skills` — unless a skill with the same basename is already present (user override wins).
- **Format**: same `SKILL.md` frontmatter as contributor skills.

#### User-configured skills (`workspace.json` / config levels)

Skills can also be provided to workspaces via the `skills` field in `workspace.json` (or any other config level). Each entry is the path to a single skill directory on the host. kdn mounts it read-only into the agent's skills directory inside the container using the directory's basename as the skill name:

| Agent | Container skills directory |
|-------|--------------------------|
Expand All @@ -397,11 +403,18 @@ Skills can be provided to workspaces via the `skills` field in `workspace.json`

The `Agent` interface (`pkg/agent/agent.go`) exposes `SkillsDir() string` which returns the container path (using the `$HOME` variable) where skill directories should be mounted. The manager calls this during `Add()` to convert `WorkspaceConfig.Skills` entries into `workspace.Mount` entries before passing the config to the runtime.

### Adding a New Skill
### Adding a New Contributor Skill (for kdn development)
1. Create directory: `.agents/skills/<skill-name>/`
2. Create SKILL.md with frontmatter and instructions
3. No symlink step needed — `.claude/skills` already symlinks to `.agents/skills/`

### Adding a New Built-in Skill (distributed to all workspaces)
1. Create directory: `skills/<skill-name>/`
2. Create SKILL.md with frontmatter and instructions
3. Add the directory name to the `//go:embed` directive in `skills/skills.go`

`ExtractAll` discovers skills by walking the root of the embedded FS — no other code changes are needed.

### Adding a New Command

**Available Skills:**
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ kdn is part of the [Kaiden](https://openkaiden.ai/) project — an open platform
- Automatic workspace setup with `kdn autoconf` — scans environment variables and files to create secrets, detects programming languages and exposed ports to add devcontainer features and port-forwarding configuration, all with no manual JSON editing
- Control network access with allow/deny policies per workspace
- Consistent configuration for MCP servers, skills, and dev container features across all supported agents — define once, works with Claude Code, Cursor, Goose, and OpenCode
- Built-in skills automatically available in every workspace — including `config-kdn-workspace` to help agents configure the workspace interactively
Comment thread
feloy marked this conversation as resolved.
- Integrate with various LLM providers (Vertex AI, Ollama, OpenRouter, and any OpenAI-compatible API)
- Consistent CLI interface across different agent types and runtimes

Expand Down Expand Up @@ -1897,10 +1898,14 @@ Paths can also be absolute (e.g., `/absolute/path`).

### Skills

Configure skill directories to make available to the agent inside the workspace.
kdn automatically injects a set of built-in skills into every workspace. These skills are embedded in the kdn binary and extracted to the storage directory at workspace creation time. For example, the `config-kdn-workspace` skill helps agents configure the workspace interactively.

You can also configure additional skill directories to make available to the agent inside the workspace.

Each entry is a path to a directory on the host that contains a single skill — a `SKILL.md` file and any related files. The directory is mounted read-only inside the agent's skills directory using the directory's basename as the skill name, allowing the agent to discover and use it.

A user-configured skill whose basename matches a built-in skill overrides the built-in one.

**Structure:**
```json
{
Expand Down
32 changes: 32 additions & 0 deletions pkg/instances/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import (
"github.com/openkaiden/kdn/pkg/runtime"
"github.com/openkaiden/kdn/pkg/secret"
"github.com/openkaiden/kdn/pkg/secretservice"
"github.com/openkaiden/kdn/skills"
)

const (
Expand Down Expand Up @@ -341,6 +342,37 @@ func (m *manager) Add(ctx context.Context, opts AddOptions) (Instance, error) {
}
}

// Inject built-in skills when the agent supports skills
if agentImpl.SkillsDir() != "" {
builtInPaths, err := skills.ExtractAll(m.storageDir)
if err != nil {
return nil, fmt.Errorf("failed to extract built-in skills: %w", err)
}
for _, builtInPath := range builtInPaths {
builtInBase := filepath.Base(builtInPath)
alreadyPresent := false
if mergedConfig != nil && mergedConfig.Skills != nil {
for _, s := range *mergedConfig.Skills {
if filepath.Base(s) == builtInBase {
alreadyPresent = true
break
}
}
}
if !alreadyPresent {
if mergedConfig == nil {
mergedConfig = &workspace.WorkspaceConfiguration{}
}
if mergedConfig.Skills == nil {
s := []string{builtInPath}
mergedConfig.Skills = &s
} else {
*mergedConfig.Skills = append(*mergedConfig.Skills, builtInPath)
}
}
}
}

// Convert skills directories to mounts using the agent's skills directory
if skillsDir := agentImpl.SkillsDir(); skillsDir != "" && mergedConfig != nil && mergedConfig.Skills != nil {
roTrue := true
Expand Down
173 changes: 171 additions & 2 deletions pkg/instances/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"fmt"
"os"
"path/filepath"
goruntime "runtime"
"strings"
"sync"
"testing"
Expand Down Expand Up @@ -3924,10 +3925,12 @@ func TestManager_Add_ConvertsSkillsToMounts(t *testing.T) {
}

mounts := *params.WorkspaceConfig.Mounts
if len(mounts) != 1 {
t.Fatalf("Expected 1 mount, got %d: %v", len(mounts), mounts)
// 1 user skill + built-in skills injected automatically
if len(mounts) < 2 {
t.Fatalf("Expected at least 2 mounts (user skill + built-ins), got %d: %v", len(mounts), mounts)
}

// First mount is the user-configured skill
if mounts[0].Host != skillsPath {
t.Errorf("Mount host = %q, want %q", mounts[0].Host, skillsPath)
}
Expand Down Expand Up @@ -4050,6 +4053,172 @@ func TestManager_Add_ConvertsSkillsToMounts(t *testing.T) {
})
}

func TestManager_Add_InjectsBuiltInSkills(t *testing.T) {
t.Parallel()

newManagerWithAgent := func(t *testing.T, skillsDir string) (Manager, *spyRuntime, string) {
t.Helper()
storageDir := t.TempDir()
manager, err := NewManager(storageDir)
if err != nil {
t.Fatalf("Failed to create manager: %v", err)
}
spy := newSpyRuntime(fake.New())
if err := manager.RegisterRuntime(spy); err != nil {
t.Fatalf("Failed to register spy runtime: %v", err)
}
trackingAgent := newTrackingAgentWithSkillsDir("test-agent", skillsDir)
if err := manager.RegisterAgent("test-agent", trackingAgent); err != nil {
t.Fatalf("Failed to register tracking agent: %v", err)
}
return manager, spy, storageDir
}

newInstance := func(t *testing.T) Instance {
t.Helper()
instanceTmpDir := t.TempDir()
sourceDir := filepath.Join(instanceTmpDir, "source")
configDir := filepath.Join(instanceTmpDir, "config")
if err := os.MkdirAll(sourceDir, 0755); err != nil {
t.Fatalf("Failed to create source directory: %v", err)
}
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatalf("Failed to create config directory: %v", err)
}
inst, err := NewInstance(NewInstanceParams{SourceDir: sourceDir, ConfigDir: configDir})
if err != nil {
t.Fatalf("Failed to create instance: %v", err)
}
return inst
}

t.Run("built-in skills are injected when no user skills configured", func(t *testing.T) {
t.Parallel()

manager, spy, _ := newManagerWithAgent(t, "$HOME/.claude/skills")

_, err := manager.Add(context.Background(), AddOptions{
Instance: newInstance(t),
RuntimeType: "fake",
Agent: "test-agent",
})
if err != nil {
t.Fatalf("Add() error = %v", err)
}

params := spy.LastCreateParams()
if params.WorkspaceConfig == nil || params.WorkspaceConfig.Mounts == nil {
t.Fatal("Expected built-in skill mounts even with no user-configured skills")
}
mounts := *params.WorkspaceConfig.Mounts
if len(mounts) == 0 {
t.Fatal("Expected at least one built-in skill mount, got none")
}
// At least one mount should target the built-in config-kdn-workspace skill
found := false
for _, m := range mounts {
if filepath.Base(m.Host) == "config-kdn-workspace" {
found = true
wantTarget := "$HOME/.claude/skills/config-kdn-workspace"
if m.Target != wantTarget {
t.Errorf("Built-in skill target = %q, want %q", m.Target, wantTarget)
}
if m.Ro == nil || !*m.Ro {
t.Error("Expected built-in skill mount to be read-only")
}
}
}
if !found {
t.Errorf("Built-in config-kdn-workspace skill not found in mounts: %v", mounts)
}
})

t.Run("built-in skill is not duplicated when user configures same basename", func(t *testing.T) {
t.Parallel()

manager, spy, storageDir := newManagerWithAgent(t, "$HOME/.claude/skills")
// User configures their own version of the built-in skill (same basename)
userSkillPath := filepath.Join(storageDir, "my-skills", "config-kdn-workspace")
if err := os.MkdirAll(userSkillPath, 0755); err != nil {
t.Fatalf("Failed to create user skill dir: %v", err)
}

_, err := manager.Add(context.Background(), AddOptions{
Instance: newInstance(t),
RuntimeType: "fake",
Agent: "test-agent",
WorkspaceConfig: &workspace.WorkspaceConfiguration{Skills: &[]string{userSkillPath}},
})
if err != nil {
t.Fatalf("Add() error = %v", err)
}

params := spy.LastCreateParams()
if params.WorkspaceConfig == nil || params.WorkspaceConfig.Mounts == nil {
t.Fatal("Expected mounts to be set")
}
count := 0
for _, m := range *params.WorkspaceConfig.Mounts {
if filepath.Base(m.Host) == "config-kdn-workspace" {
count++
}
}
if count != 1 {
t.Errorf("Expected exactly 1 config-kdn-workspace mount, got %d", count)
}
})

t.Run("built-in skills are not injected when agent has no SkillsDir", func(t *testing.T) {
t.Parallel()

manager, spy, _ := newManagerWithAgent(t, "")

_, err := manager.Add(context.Background(), AddOptions{
Instance: newInstance(t),
RuntimeType: "fake",
Agent: "test-agent",
})
if err != nil {
t.Fatalf("Add() error = %v", err)
}

params := spy.LastCreateParams()
if params.WorkspaceConfig != nil && params.WorkspaceConfig.Mounts != nil {
t.Errorf("Expected no mounts when agent has empty SkillsDir, got %v", *params.WorkspaceConfig.Mounts)
}
})

t.Run("returns error when built-in skill extraction fails", func(t *testing.T) {
t.Parallel()

if goruntime.GOOS == "windows" {
t.Skip("chmod-based permission tests do not apply on Windows")
}

manager, _, storageDir := newManagerWithAgent(t, "$HOME/.claude/skills")

// Make the skills directory read-only so ExtractAll cannot create
// subdirectories inside it, forcing an error on Add().
skillsDir := filepath.Join(storageDir, "skills")
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
t.Fatalf("failed to create skills dir: %v", err)
}
if err := os.Chmod(skillsDir, 0o555); err != nil {
t.Fatalf("failed to chmod skills dir: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(skillsDir, 0o755) })

_, err := manager.Add(context.Background(), AddOptions{
Instance: newInstance(t),
RuntimeType: "fake",
Agent: "test-agent",
})
if err == nil {
t.Fatal("Add() expected error when built-in skill extraction fails, got nil")
}
})
}

func TestManager_Add_AppliesAgentMCPServers(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading