From 6352d996478a885124a55ca18f2a0054289be40a Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Sun, 10 May 2026 16:11:35 +0000 Subject: [PATCH 1/5] feat(skills): embed and auto-inject built-in skills into workspaces Introduces a skills/ package that embeds user-facing skill directories into the kdn binary at compile time. manager.Add() calls ExtractAll() to extract and mount all built-in skills automatically for agents that support them; user-configured skills with the same basename override the built-in version. The first built-in skill, config-kdn-workspace, helps agents running inside a workspace configure environment variables, mounts, secrets, MCP servers, network policies, and other settings interactively. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Philippe Martin --- AGENTS.md | 31 +- README.md | 7 +- pkg/instances/manager.go | 32 ++ pkg/instances/manager_test.go | 142 ++++++++- skills/config-kdn-workspace/SKILL.md | 449 +++++++++++++++++++++++++++ skills/skills.go | 85 +++++ skills/skills_test.go | 110 +++++++ 7 files changed, 844 insertions(+), 12 deletions(-) create mode 100644 skills/config-kdn-workspace/SKILL.md create mode 100644 skills/skills.go create mode 100644 skills/skills_test.go diff --git a/AGENTS.md b/AGENTS.md index ffa14e20..d85d913c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.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//`) — 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//`) — 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 `/skills//` 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 | |-------|--------------------------| @@ -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//` 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//` +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:** diff --git a/README.md b/README.md index f543bc3c..13a94400 100644 --- a/README.md +++ b/README.md @@ -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 - Integrate with various LLM providers (Vertex AI, Ollama, OpenRouter, and any OpenAI-compatible API) - Consistent CLI interface across different agent types and runtimes @@ -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 { diff --git a/pkg/instances/manager.go b/pkg/instances/manager.go index eb85e40f..32ac0566 100644 --- a/pkg/instances/manager.go +++ b/pkg/instances/manager.go @@ -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 ( @@ -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 diff --git a/pkg/instances/manager_test.go b/pkg/instances/manager_test.go index 39014368..cc4733fd 100644 --- a/pkg/instances/manager_test.go +++ b/pkg/instances/manager_test.go @@ -3924,10 +3924,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) } @@ -4050,6 +4052,142 @@ 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) + } + }) +} + func TestManager_Add_AppliesAgentMCPServers(t *testing.T) { t.Parallel() diff --git a/skills/config-kdn-workspace/SKILL.md b/skills/config-kdn-workspace/SKILL.md new file mode 100644 index 00000000..289bd50b --- /dev/null +++ b/skills/config-kdn-workspace/SKILL.md @@ -0,0 +1,449 @@ +--- +name: config-kdn-workspace +description: Configure your kdn workspace interactively — add environment variables, mounts, secrets, MCP servers, skills, network policies, dev container features, and port forwarding at any configuration level (workspace, global, project, or agent) +argument-hint: "what do you want to configure? (e.g. 'add my GitHub token', 'mount my .gitconfig globally', 'allow network access to api.example.com')" +--- + +# Configure a kdn Workspace + +Use this skill to help users configure their kdn workspace. Read the goal from the argument (or ask the user what they want to set up), then guide them to the right configuration level and produce the correct JSON. + +## Important: sandbox context + +The agent runs inside a sandboxed workspace container. Only files from the mounted source directory are directly accessible: + +- **Workspace config** (`.kaiden/workspace.json`) → available inside the workspace at `/workspace/sources/.kaiden/workspace.json`. **Can always be edited directly.** +- **Global / project config** (`~/.kdn/config/projects.json`) and **agent config** (`~/.kdn/config/agents.json`) → live on the **host machine**. They are only accessible from inside the workspace if the user has mounted `~/.kdn/config` into the workspace. + +### Accessing multi-level configs from inside the workspace + +If the user wants the agent to help with global, project, or agent config, suggest mounting the config directory. Add this to `.kaiden/workspace.json` (or the agent/global config on the host): + +```json +{ + "mounts": [ + { "host": "$HOME/.kdn/config", "target": "$HOME/.kdn/config" } + ] +} +``` + +After adding this mount and re-registering the workspace, `~/.kdn/config/` will be available inside the container. + +**Never touch files outside `~/.kdn/config/`** — do not read, write, or suggest modifying anything else under `~/.kdn/` (such as instances, runtimes, or binary caches). Only `~/.kdn/config/` is in scope for this skill. + +When `~/.kdn/config` is not mounted, generate the JSON snippet and tell the user exactly where to apply it on their host. + +## Overview + +kdn workspace configuration controls what gets injected into a workspace at runtime: + +- **Environment variables** — plain values or references to secrets +- **Mounts** — host directories made available inside the container +- **Skills** — skill directories mounted into the agent +- **MCP servers** — local (stdio) or remote (SSE) tool servers for the agent +- **Network access** — allow-all or deny with an explicit host allowlist +- **Secrets** — kdn secrets injected as HTTP headers via OneCLI (distinct from Podman secrets used in environment variable entries) +- **Dev container features** — tools installed into the image at build time +- **Port forwarding** — workspace ports exposed on the host + +## Step 1: Choose the right configuration level + +Ask the user which scope they need. Present these choices: + +| Level | File (host path) | In-workspace path | When to use | +|---|---|---|---| +| **Workspace** | `/.kaiden/workspace.json` | `/workspace/sources/.kaiden/workspace.json` | Project-specific, shared with all developers, committed to git | +| **Global** | `~/.kdn/config/projects.json` (`""` key) | `~/.kdn/config/projects.json` if mounted | Applies to every project on this machine (e.g. `.gitconfig`, SSH keys) | +| **Project** | `~/.kdn/config/projects.json` (project ID key) | `~/.kdn/config/projects.json` if mounted | This project only, stays on your machine | +| **Agent** | `~/.kdn/config/agents.json` | `~/.kdn/config/agents.json` if mounted | Agent-specific settings (e.g. Claude-only or Goose-only) | + +**Precedence (highest wins):** Agent > Project > Global > Workspace + +If unsure: personal credentials → global or agent config; project tooling → workspace config. + +## Step 2: Identify the configuration target + +- **Workspace config**: edit `/workspace/sources/.kaiden/workspace.json`. Create the `.kaiden/` directory if it doesn't exist. +- **Global / project config**: edit `~/.kdn/config/projects.json` (requires `~/.kdn/config` to be mounted, or apply on the host). + - Use `""` as the key for global settings. + - Use the project ID for project-specific settings. Find it by running `kdn workspace list --output json` on the **host** and reading the `project` field for the workspace. +- **Agent config**: edit `~/.kdn/config/agents.json` (requires `~/.kdn/config` to be mounted, or apply on the host). Use the agent name (`claude`, `goose`, `cursor`, `opencode`) as the key. + +## Step 3: Build the JSON + +Use the sections below to assemble the JSON snippet and merge it into the right file. + +--- + +### Environment variables + +```json +{ + "environment": [ + { "name": "NODE_ENV", "value": "development" }, + { "name": "SECRET_VAR", "secret": "my-podman-secret" } + ] +} +``` + +Rules: +- `name`: valid Unix variable name (letter/underscore first, then letters/digits/underscores) +- Use `value` for plain text +- Use `secret` to reference a **Podman secret** created with `podman secret create` — this is a runtime-specific mechanism supported only by the Podman runtime. The Podman runtime injects the secret value as the environment variable inside the container. It is **not** the same as a kdn secret created with `kdn secret create`. +- `value` and `secret` are mutually exclusive + +--- + +### Mounts + +```json +{ + "mounts": [ + { "host": "$HOME/.gitconfig", "target": "$HOME/.gitconfig", "ro": true }, + { "host": "$SOURCES/../sibling-dir", "target": "$SOURCES/../sibling-dir" }, + { "host": "/absolute/path", "target": "/workspace/data", "ro": true } + ] +} +``` + +Path variables (resolved relative to the **host** at workspace creation time): +- `$HOME` → host home dir / `/home/agent` inside the container +- `$SOURCES` → workspace sources dir on host / `/workspace/sources` inside the container + +Rules: +- Both `host` and `target` must be absolute or start with `$SOURCES` or `$HOME` +- `ro: true` makes the mount read-only (omit or set `false` for read-write) + +--- + +### Skills + +```json +{ + "skills": [ + "/absolute/path/to/my-skill", + "$HOME/skills/review-skill" + ] +} +``` + +Each entry is a **host** directory containing a `SKILL.md`. The directory is mounted read-only inside the agent's skills directory using its basename: + +| Agent | Mount target | +|---|---| +| Claude Code | `~/.claude/skills//` | +| Goose | `~/.agents/skills//` | +| Cursor | `~/.cursor/skills//` | +| OpenCode | `~/.opencode/skills//` | + +Rules: paths must be absolute or start with `$HOME` (`$SOURCES` is not supported). These are host paths resolved at workspace creation time. + +--- + +### MCP servers + +```json +{ + "mcp": { + "commands": [ + { + "name": "filesystem", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace/sources"], + "env": { "NODE_ENV": "production" } + } + ], + "servers": [ + { + "name": "remote-api", + "url": "https://api.example.com/mcp", + "headers": { "Authorization": "Bearer mytoken" } + } + ] + } +} +``` + +Rules: +- `name` must be unique across **both** `commands` and `servers` +- `command` (for command entries) and `url` (for server entries) are required +- MCP configuration is baked into the agent settings at `kdn init` time, not at runtime + +--- + +### Network access + +```json +{ + "network": { + "mode": "deny", + "hosts": ["api.github.com", "registry.npmjs.org"] + } +} +``` + +- `mode: "allow"` — unrestricted outbound access (do not set `hosts`) +- `mode: "deny"` — block everything except listed hosts (and hosts auto-injected from secrets/credentials) +- Omit `hosts` with `deny` for a fully isolated workspace + +**Auto-injected hosts:** When secrets are configured, their required hosts are added automatically. For example, a `github` secret automatically allows `api.github.com` — no explicit `hosts` entry needed. + +Network merging across levels: the **stricter** (lower-precedence) policy wins. A workspace-level `deny` cannot be overridden to `allow` by an agent config. + +--- + +### Secrets (kdn secrets — HTTP header injection) + +kdn secrets are created on the **host** with `kdn secret create`, stored in the system keychain, and injected as **HTTP headers** by OneCLI into matching outbound requests. The agent inside the workspace cannot create kdn secrets. + +```bash +# Run on the HOST (not inside the workspace) +kdn service list # list available service types +kdn secret create my-github-token --type github --value ghp_xxxx +``` + +Then reference the secret by name in the `secrets` list of any config level: + +```json +{ + "secrets": ["my-github-token"] +} +``` + +**This is distinct from the `secret` field in environment variables**, which references Podman secrets (`podman secret create`) — a Podman-only mechanism for injecting values as environment variables. kdn secrets and Podman secrets are separate systems: + +| | kdn secrets (`kdn secret create`) | Podman secrets (`podman secret create`) | +|---|---|---| +| Config field | `secrets: ["name"]` (top-level list) | `environment[*].secret: "name"` | +| Delivery | HTTP header injected by OneCLI | Environment variable inside container | +| Runtime support | All runtimes | Podman only | +| Use case | Outbound API credentials | Any value a local tool needs as an env var | + +--- + +### Dev container features + +```json +{ + "features": { + "ghcr.io/devcontainers/features/go:1": { "version": "1.23" }, + "ghcr.io/devcontainers/features/node:1": { "version": "20" }, + "./tools/my-local-feature": {} + } +} +``` + +Features are installed into the image at **build time** (`kdn init`), not at runtime. Adding or changing features requires re-registering the workspace. Use `{}` to accept all defaults. + +Rules: IDs must be OCI references or relative paths (`./…`). `https://` tarball URIs are not supported. Local paths are resolved relative to `.kaiden/`. + +--- + +### Port forwarding + +```json +{ + "ports": [8080, 3000] +} +``` + +kdn allocates a free host port for each listed workspace port at creation time. Use `kdn workspace open ` on the host to open a forwarded port in the browser. + +--- + +## Step 4: Apply the change + +### Changes to workspace config (`/workspace/sources/.kaiden/workspace.json`) + +This file is directly editable from inside the workspace. Read the current file (if it exists), merge the new JSON into it, and write it back. Create the `.kaiden/` directory and `workspace.json` if they don't exist. + +The change takes effect the next time the workspace is registered. If the workspace is already registered, re-register it on the host: + +```bash +# Run on the HOST +kdn workspace remove +kdn init --runtime --agent +``` + +### Changes to global / project config (`~/.kdn/config/projects.json`) + +**If `~/.kdn/config` is mounted:** edit the file directly at `~/.kdn/config/projects.json`. + +**If not mounted:** present the JSON snippet to the user: + +> Please apply this change to `~/.kdn/config/projects.json` on your host machine: +> +> ```json +> { +> "": { +> "mounts": [ +> { "host": "$HOME/.gitconfig", "target": "$HOME/.gitconfig", "ro": true } +> ] +> } +> } +> ``` +> +> Merge this into the existing file (create it at `~/.kdn/config/projects.json` if it doesn't exist). The change takes effect the next time you run `kdn init` for this workspace. + +For project-specific settings, use the project ID as the key. Find it on the host with: + +```bash +kdn workspace list --output json # read the "project" field +``` + +To make future config changes easier, consider adding this mount to `.kaiden/workspace.json` so the agent can edit global and agent configs directly: + +```json +{ + "mounts": [ + { "host": "$HOME/.kdn/config", "target": "$HOME/.kdn/config" } + ] +} +``` + +### Changes to agent config (`~/.kdn/config/agents.json`) + +Same as above: edit directly if `~/.kdn/config` is mounted, otherwise present the JSON to the user. + +```json +{ + "claude": { + "environment": [ + { "name": "ANTHROPIC_MODEL", "value": "claude-sonnet-4-20250514" } + ] + } +} +``` + +--- + +## Common use cases + +### Share git credentials across all projects (global) + +Add to `~/.kdn/config/projects.json` under the `""` key (apply on the host or via mounted config): + +```json +{ + "": { + "mounts": [ + { "host": "$HOME/.gitconfig", "target": "$HOME/.gitconfig", "ro": true } + ] + } +} +``` + +### Reuse Claude Code settings (agent config) + +Add to `~/.kdn/config/agents.json` under the `"claude"` key (apply on the host or via mounted config): + +```json +{ + "claude": { + "mounts": [ + { "host": "$HOME/.claude", "target": "$HOME/.claude" }, + { "host": "$HOME/.claude.json", "target": "$HOME/.claude.json" } + ] + } +} +``` + +### Inject a GitHub token (secret + network) + +Instruct the user to run on the host: + +```bash +kdn secret create my-github-token --type github --value ghp_xxxx +``` + +Then add to `.kaiden/workspace.json` (editable from inside the workspace): + +```json +{ + "secrets": ["my-github-token"], + "network": { "mode": "deny" } +} +``` + +The token is injected as a `Bearer` header for `api.github.com` requests. The host is added to the allowlist automatically. + +### Mount a git worktree + +Add to `.kaiden/workspace.json`: + +```json +{ + "mounts": [ + { "host": "$SOURCES/../main", "target": "$SOURCES/../main" } + ] +} +``` + +### Allow network access to specific hosts + +Add to `.kaiden/workspace.json`: + +```json +{ + "network": { + "mode": "deny", + "hosts": ["api.example.com", "registry.npmjs.org"] + } +} +``` + +### Add a dev container feature (Go toolchain) + +Add to `.kaiden/workspace.json`: + +```json +{ + "features": { + "ghcr.io/devcontainers/features/go:1": { "version": "1.23" } + } +} +``` + +Then re-register on the host: `kdn workspace remove -f && kdn init --runtime podman --agent ` + +--- + +## Auto-configuration shortcut + +For common cases (API keys from environment variables, home config file mounts, language detection), suggest running on the **host**: + +```bash +kdn autoconf # interactive — detect and prompt for each item +kdn autoconf --yes # apply immediately to global config without prompts +``` + +`kdn autoconf` detects known API keys, config files, and programming languages, and writes the resulting secrets and mounts to the appropriate config file. + +--- + +## Validation + +Configuration is validated when running `kdn init` on the host. Common errors and fixes: + +| Error | Fix | +|---|---| +| `has both value and secret set` | Remove one — `value` and `secret` are mutually exclusive | +| `missing host` / `missing target` | Add both `host` and `target` to every mount entry | +| `invalid variable name` | Variable names must start with a letter/underscore, no hyphens or spaces | +| `hosts must not be set when mode is allow` | Remove `hosts` when `mode` is `"allow"` | +| `duplicate MCP server name` | Names must be unique across both `commands` and `servers` | + +--- + +## Merging behavior summary + +| Field | How configs merge | +|---|---| +| `environment` | Later (higher-precedence) level wins by variable name | +| `mounts` | Deduplicated by `host`+`target`; first occurrence wins | +| `skills` | Deduplicated by path; base first | +| `mcp` | Deduplicated by `name`; higher-precedence wins | +| `network` | Stricter (lower-precedence) policy wins; deny cannot be loosened by a higher level | +| `secrets` | Deduplicated by name; base first | +| `features` | Higher-precedence level wins by feature ID | +| `ports` | Union-merged, deduplicated | diff --git a/skills/skills.go b/skills/skills.go new file mode 100644 index 00000000..986a867d --- /dev/null +++ b/skills/skills.go @@ -0,0 +1,85 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package skills + +import ( + "embed" + "io/fs" + "os" + "path/filepath" +) + +// Adding a new built-in skill +// +// 1. Create a directory under skills/ named after the skill (e.g. skills/my-skill/). +// 2. Add a SKILL.md file inside it following the standard skill frontmatter format. +// 3. Add the directory name to the //go:embed directive below (space-separated). +// +// ExtractAll discovers embedded skills by walking the root of builtInFS and +// extracting every top-level directory it finds, so no other code changes are +// needed — the skill is automatically injected into every workspace whose agent +// supports skills. + +//go:embed config-kdn-workspace +var builtInFS embed.FS + +// ExtractAll extracts all built-in skill directories embedded in the binary +// into storageDir/skills/ and returns their host paths. Existing files are +// overwritten to keep skills up to date with the installed binary. +// New skills are picked up automatically when their directory is added under +// skills/ and listed in the embed directive above. +func ExtractAll(storageDir string) ([]string, error) { + entries, err := builtInFS.ReadDir(".") + if err != nil { + return nil, err + } + var paths []string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + destDir := filepath.Join(storageDir, "skills", entry.Name()) + if err := extractDir(builtInFS, entry.Name(), destDir); err != nil { + return nil, err + } + paths = append(paths, destDir) + } + return paths, nil +} + +func extractDir(src embed.FS, srcDir, destDir string) error { + return fs.WalkDir(src, srcDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(srcDir, filepath.FromSlash(path)) + if err != nil { + return err + } + dest := filepath.Join(destDir, rel) + if d.IsDir() { + return os.MkdirAll(dest, 0o755) + } + data, err := src.ReadFile(path) + if err != nil { + return err + } + // embed.FS normalizes all file permissions to 0444 regardless of the + // original on-disk permissions, so we cannot preserve them through + // embedding. Use 0644 so that files are writable and can be + // overwritten on subsequent extractions (e.g., after a binary upgrade). + return os.WriteFile(dest, data, 0o644) + }) +} diff --git a/skills/skills_test.go b/skills/skills_test.go new file mode 100644 index 00000000..33dbaba8 --- /dev/null +++ b/skills/skills_test.go @@ -0,0 +1,110 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package skills + +import ( + "os" + "path/filepath" + "testing" +) + +func TestExtractAll(t *testing.T) { + t.Parallel() + + t.Run("extracts all built-in skills to storageDir/skills/", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + paths, err := ExtractAll(storageDir) + if err != nil { + t.Fatalf("ExtractAll() error = %v", err) + } + + if len(paths) == 0 { + t.Fatal("ExtractAll() returned no paths") + } + + for _, p := range paths { + info, err := os.Stat(p) + if err != nil { + t.Errorf("extracted path %q does not exist: %v", p, err) + continue + } + if !info.IsDir() { + t.Errorf("extracted path %q is not a directory", p) + } + // Every extracted skill must have a SKILL.md + skillMD := filepath.Join(p, "SKILL.md") + if _, err := os.Stat(skillMD); err != nil { + t.Errorf("extracted skill %q is missing SKILL.md: %v", p, err) + } + } + }) + + t.Run("config-kdn-workspace skill is extracted", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + paths, err := ExtractAll(storageDir) + if err != nil { + t.Fatalf("ExtractAll() error = %v", err) + } + + found := false + for _, p := range paths { + if filepath.Base(p) == "config-kdn-workspace" { + found = true + wantDir := filepath.Join(storageDir, "skills", "config-kdn-workspace") + if p != wantDir { + t.Errorf("path = %q, want %q", p, wantDir) + } + } + } + if !found { + t.Errorf("config-kdn-workspace not found in extracted paths: %v", paths) + } + }) + + t.Run("existing files are overwritten on re-extraction", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + paths, err := ExtractAll(storageDir) + if err != nil { + t.Fatalf("first ExtractAll() error = %v", err) + } + if len(paths) == 0 { + t.Fatal("first ExtractAll() returned no paths") + } + + // Corrupt one file + skillMD := filepath.Join(paths[0], "SKILL.md") + if err := os.WriteFile(skillMD, []byte("corrupted"), 0o644); err != nil { + t.Fatalf("failed to corrupt SKILL.md: %v", err) + } + + // Re-extract should restore the original content + if _, err := ExtractAll(storageDir); err != nil { + t.Fatalf("second ExtractAll() error = %v", err) + } + data, err := os.ReadFile(skillMD) + if err != nil { + t.Fatalf("failed to read SKILL.md after re-extraction: %v", err) + } + if string(data) == "corrupted" { + t.Error("SKILL.md was not overwritten on re-extraction") + } + }) +} From 6947020ce9c2c16e9faff731f2673d4b6b377e9f Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Sun, 10 May 2026 16:26:44 +0000 Subject: [PATCH 2/5] test(skills): increase coverage to 96% with fs.FS injection Refactor extractAll into an fs.FS-accepting inner function to enable custom file system injection in tests. Add TestExtractAll_internal with four cases: ReadDir failure, ReadFile failure during walk, non-directory root entries skipped, and the WalkDir callback error path via readDirErrorFS. Also add the extraction-failure path to TestManager_Add_InjectsBuiltInSkills. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Philippe Martin --- pkg/instances/manager_test.go | 26 +++++++ skills/skills.go | 14 +++- skills/skills_test.go | 143 ++++++++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+), 4 deletions(-) diff --git a/pkg/instances/manager_test.go b/pkg/instances/manager_test.go index cc4733fd..72515bba 100644 --- a/pkg/instances/manager_test.go +++ b/pkg/instances/manager_test.go @@ -4186,6 +4186,32 @@ func TestManager_Add_InjectsBuiltInSkills(t *testing.T) { 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() + + 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) { diff --git a/skills/skills.go b/skills/skills.go index 986a867d..12d69a26 100644 --- a/skills/skills.go +++ b/skills/skills.go @@ -41,7 +41,13 @@ var builtInFS embed.FS // New skills are picked up automatically when their directory is added under // skills/ and listed in the embed directive above. func ExtractAll(storageDir string) ([]string, error) { - entries, err := builtInFS.ReadDir(".") + return extractAll(builtInFS, storageDir) +} + +// extractAll is the testable core of ExtractAll. It accepts any fs.FS so that +// tests can inject custom file systems to exercise all code paths. +func extractAll(srcFS fs.FS, storageDir string) ([]string, error) { + entries, err := fs.ReadDir(srcFS, ".") if err != nil { return nil, err } @@ -51,7 +57,7 @@ func ExtractAll(storageDir string) ([]string, error) { continue } destDir := filepath.Join(storageDir, "skills", entry.Name()) - if err := extractDir(builtInFS, entry.Name(), destDir); err != nil { + if err := extractDir(srcFS, entry.Name(), destDir); err != nil { return nil, err } paths = append(paths, destDir) @@ -59,7 +65,7 @@ func ExtractAll(storageDir string) ([]string, error) { return paths, nil } -func extractDir(src embed.FS, srcDir, destDir string) error { +func extractDir(src fs.FS, srcDir, destDir string) error { return fs.WalkDir(src, srcDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err @@ -72,7 +78,7 @@ func extractDir(src embed.FS, srcDir, destDir string) error { if d.IsDir() { return os.MkdirAll(dest, 0o755) } - data, err := src.ReadFile(path) + data, err := fs.ReadFile(src, path) if err != nil { return err } diff --git a/skills/skills_test.go b/skills/skills_test.go index 33dbaba8..6ae9f8fe 100644 --- a/skills/skills_test.go +++ b/skills/skills_test.go @@ -15,9 +15,12 @@ package skills import ( + "errors" + "io/fs" "os" "path/filepath" "testing" + "testing/fstest" ) func TestExtractAll(t *testing.T) { @@ -107,4 +110,144 @@ func TestExtractAll(t *testing.T) { t.Error("SKILL.md was not overwritten on re-extraction") } }) + + t.Run("returns error when destination is not writable", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + skillsDir := filepath.Join(storageDir, "skills") + if err := os.MkdirAll(skillsDir, 0o755); err != nil { + t.Fatalf("failed to create skills dir: %v", err) + } + // Make skills/ read-only so extractDir cannot create subdirectories inside it. + 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 := ExtractAll(storageDir) + if err == nil { + t.Error("ExtractAll() expected error when destination is not writable, got nil") + } + }) +} + +// TestExtractAll_internal exercises code paths that are unreachable via the +// real embedded FS by injecting custom fs.FS implementations into extractAll. +func TestExtractAll_internal(t *testing.T) { + t.Parallel() + + t.Run("non-directory root entries are skipped", func(t *testing.T) { + t.Parallel() + + srcFS := fstest.MapFS{ + "my-skill/SKILL.md": &fstest.MapFile{Data: []byte("# skill")}, + "loose-file.txt": &fstest.MapFile{Data: []byte("ignored")}, + } + storageDir := t.TempDir() + paths, err := extractAll(srcFS, storageDir) + if err != nil { + t.Fatalf("extractAll() error = %v", err) + } + if len(paths) != 1 { + t.Fatalf("expected 1 path (only my-skill/), got %d: %v", len(paths), paths) + } + if filepath.Base(paths[0]) != "my-skill" { + t.Errorf("path = %q, want basename my-skill", paths[0]) + } + // loose-file.txt must not have been extracted + if _, err := os.Stat(filepath.Join(storageDir, "skills", "loose-file.txt")); err == nil { + t.Error("loose-file.txt should not have been extracted") + } + }) + + t.Run("returns error when srcFS ReadDir fails", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + _, err := extractAll(errorFS{readDirErr: errors.New("read dir failed")}, storageDir) + if err == nil { + t.Error("expected error when ReadDir fails, got nil") + } + }) + + t.Run("returns error when ReadFile fails during walk", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + inner := fstest.MapFS{ + "my-skill/SKILL.md": &fstest.MapFile{Data: []byte("# skill")}, + } + srcFS := &readFileErrorFS{MapFS: &inner, failOn: "my-skill/SKILL.md"} + _, err := extractAll(srcFS, storageDir) + if err == nil { + t.Error("expected error when ReadFile fails, got nil") + } + }) + + t.Run("returns error when WalkDir passes error for a directory", func(t *testing.T) { + t.Parallel() + + // readDirErrorFS makes ReadDir fail for a specific subdirectory, which + // causes fs.WalkDir to invoke the callback with a non-nil err for that + // entry — exercising the err-check at the top of the walkDir callback. + srcFS := readDirErrorFS{ + MapFS: fstest.MapFS{ + "my-skill/SKILL.md": &fstest.MapFile{Data: []byte("# skill")}, + }, + failDir: "my-skill", + } + storageDir := t.TempDir() + _, err := extractAll(srcFS, storageDir) + if err == nil { + t.Error("expected error when WalkDir reports a dir error, got nil") + } + }) +} + +// errorFS is an fs.FS that returns a configurable error from ReadDir. +type errorFS struct { + readDirErr error +} + +func (e errorFS) Open(name string) (fs.File, error) { + return nil, e.readDirErr +} + +// readFileErrorFS wraps fstest.MapFS and returns an error when ReadFile is +// called for a specific path. It overrides both Open and ReadFile because +// fs.ReadFile prefers the ReadFileFS interface over Open when available. +type readFileErrorFS struct { + *fstest.MapFS + failOn string +} + +func (r *readFileErrorFS) Open(name string) (fs.File, error) { + if name == r.failOn { + return nil, errors.New("read file failed") + } + return r.MapFS.Open(name) +} + +func (r *readFileErrorFS) ReadFile(name string) ([]byte, error) { + if name == r.failOn { + return nil, errors.New("read file failed") + } + return r.MapFS.ReadFile(name) +} + +// readDirErrorFS wraps fstest.MapFS and returns an error from ReadDir for a +// specific directory name. This causes fs.WalkDir to invoke the walk callback +// with a non-nil err for that entry, exercising the error-check at the top of +// the walkDir callback. +type readDirErrorFS struct { + fstest.MapFS + failDir string +} + +func (r readDirErrorFS) ReadDir(name string) ([]fs.DirEntry, error) { + if name == r.failDir { + return nil, errors.New("read dir failed") + } + return r.MapFS.ReadDir(name) } From cba682b076f26af5d8fe826fa669307fffa68009 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Sun, 10 May 2026 16:36:14 +0000 Subject: [PATCH 3/5] test(skills): skip chmod permission tests on Windows chmod-based write-permission simulation does not enforce access restrictions on Windows, causing the "not writable" error tests to fail. Skip them with runtime.GOOS == "windows", matching the pattern already used in pkg/config and pkg/credential tests. Co-Authored-By: Claude Code (claude-sonnet-4-6) Signed-off-by: Philippe Martin --- pkg/instances/manager_test.go | 5 +++++ skills/skills_test.go | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/pkg/instances/manager_test.go b/pkg/instances/manager_test.go index 72515bba..fd79752a 100644 --- a/pkg/instances/manager_test.go +++ b/pkg/instances/manager_test.go @@ -21,6 +21,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" "sync" "testing" @@ -4190,6 +4191,10 @@ func TestManager_Add_InjectsBuiltInSkills(t *testing.T) { t.Run("returns error when built-in skill extraction fails", func(t *testing.T) { t.Parallel() + if runtime.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 diff --git a/skills/skills_test.go b/skills/skills_test.go index 6ae9f8fe..205b334a 100644 --- a/skills/skills_test.go +++ b/skills/skills_test.go @@ -19,6 +19,7 @@ import ( "io/fs" "os" "path/filepath" + "runtime" "testing" "testing/fstest" ) @@ -114,6 +115,10 @@ func TestExtractAll(t *testing.T) { t.Run("returns error when destination is not writable", func(t *testing.T) { t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("chmod-based permission tests do not apply on Windows") + } + storageDir := t.TempDir() skillsDir := filepath.Join(storageDir, "skills") if err := os.MkdirAll(skillsDir, 0o755); err != nil { From b34bb0cffe1eab089e03be67c748651021dbffc3 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Sun, 10 May 2026 16:38:22 +0000 Subject: [PATCH 4/5] fix(instances): alias stdlib runtime to avoid import conflict in tests The manager_test.go already imports pkg/runtime as "runtime"; the stdlib "runtime" package added for GOOS must use a goruntime alias to avoid the redeclared-in-block vet error. Co-Authored-By: Claude Code (claude-sonnet-4-6) Signed-off-by: Philippe Martin --- pkg/instances/manager_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/instances/manager_test.go b/pkg/instances/manager_test.go index fd79752a..27a113ca 100644 --- a/pkg/instances/manager_test.go +++ b/pkg/instances/manager_test.go @@ -21,7 +21,7 @@ import ( "fmt" "os" "path/filepath" - "runtime" + goruntime "runtime" "strings" "sync" "testing" @@ -4191,7 +4191,7 @@ func TestManager_Add_InjectsBuiltInSkills(t *testing.T) { t.Run("returns error when built-in skill extraction fails", func(t *testing.T) { t.Parallel() - if runtime.GOOS == "windows" { + if goruntime.GOOS == "windows" { t.Skip("chmod-based permission tests do not apply on Windows") } From 173458a9c2fbff1fd902bec9d52699120b2fdc76 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Mon, 11 May 2026 13:51:05 +0200 Subject: [PATCH 5/5] feat(skill): add more info related to features in config-kdn-workspace skill Signed-off-by: Philippe Martin Co-Authored-By: Claude Code (Claude Sonnet 4.5) --- skills/config-kdn-workspace/SKILL.md | 134 +++++++++++++++++++++++++-- 1 file changed, 128 insertions(+), 6 deletions(-) diff --git a/skills/config-kdn-workspace/SKILL.md b/skills/config-kdn-workspace/SKILL.md index 289bd50b..85e47fc8 100644 --- a/skills/config-kdn-workspace/SKILL.md +++ b/skills/config-kdn-workspace/SKILL.md @@ -43,9 +43,20 @@ kdn workspace configuration controls what gets injected into a workspace at runt - **MCP servers** — local (stdio) or remote (SSE) tool servers for the agent - **Network access** — allow-all or deny with an explicit host allowlist - **Secrets** — kdn secrets injected as HTTP headers via OneCLI (distinct from Podman secrets used in environment variable entries) -- **Dev container features** — tools installed into the image at build time +- **Dev container features** — tools installed into the image at build time (⚠️ **base image is Fedora**: verify feature compatibility before recommending; many features only support Debian/Ubuntu) - **Port forwarding** — workspace ports exposed on the host +## Important: Base Image Compatibility + +⚠️ **The workspace base image is Fedora (Red Hat based).** When recommending dev container features: + +1. **Check feature compatibility first** — many features are Debian/Ubuntu only and will fail +2. **Look for `dnf`/RPM support** in the feature's documentation or install script +3. **Avoid features using `apt-get`** or Debian-specific commands +4. **Prefer local features** (using `dnf`) when compatibility is uncertain + +This only affects **dev container features**. Other configuration types (mounts, environment variables, secrets, etc.) are not affected. + ## Step 1: Choose the right configuration level Ask the user which scope they need. Present these choices: @@ -223,11 +234,27 @@ Then reference the secret by name in the `secrets` list of any config level: ### Dev container features +⚠️ **CRITICAL: Feature Compatibility Check Required** + +The base image is **Fedora** (Red Hat based, uses `dnf`). **Many dev container features are designed for Debian/Ubuntu only** and will fail on Fedora. Before proposing any feature to the user: + +1. **Check the feature's documentation** to verify it supports Red Hat/Fedora/RPM-based systems +2. **Look for `apt-get` or `apt` usage** in the feature's install script — these will NOT work on Fedora +3. **Prefer features that explicitly support multiple distros** or are distro-agnostic +4. **When in doubt, suggest a local feature** using `dnf` instead + +**Common compatible features** (verified to work on Fedora): +- `ghcr.io/devcontainers/features/common-utils` — shell utilities, supports RPM +- `ghcr.io/devcontainers/features/git` — git and git-lfs, supports RPM + +**Common INCOMPATIBLE features** (Debian/Ubuntu only): +- Many language features may only support `apt-get` +- Check each feature's GitHub repo before recommending + ```json { "features": { - "ghcr.io/devcontainers/features/go:1": { "version": "1.23" }, - "ghcr.io/devcontainers/features/node:1": { "version": "20" }, + "ghcr.io/devcontainers/features/common-utils:1": {}, "./tools/my-local-feature": {} } } @@ -237,6 +264,75 @@ Features are installed into the image at **build time** (`kdn init`), not at run Rules: IDs must be OCI references or relative paths (`./…`). `https://` tarball URIs are not supported. Local paths are resolved relative to `.kaiden/`. +#### Local features + +**Recommended approach for Fedora compatibility:** Since many public dev container features only support Debian/Ubuntu, creating local features using `dnf` is often the most reliable way to install tools. + +Local features should be placed in the `.kaiden/` directory (or a subdirectory) and referenced with a relative path starting with `./`: + +**Directory structure example:** + +```text +.kaiden/ + workspace.json + features/ + custom-tools/ + devcontainer-feature.json + install.sh +``` + +**Feature reference in workspace.json:** + +```json +{ + "features": { + "./features/custom-tools": {} + } +} +``` + +**Feature metadata** (`.kaiden/features/custom-tools/devcontainer-feature.json`): + +```json +{ + "id": "custom-tools", + "version": "1.0.0", + "name": "Custom Tools", + "description": "Installs project-specific tools", + "options": { + "version": { + "type": "string", + "default": "latest", + "description": "Version to install" + } + } +} +``` + +**Install script** (`.kaiden/features/custom-tools/install.sh`): + +```bash +#!/bin/bash +set -e + +# The base image is Fedora, so use dnf for package installation +dnf install -y jq wget curl + +# Install other tools as needed +# Options are available as environment variables (uppercase with underscores) +echo "Installing version: ${VERSION}" +``` + +**Important notes for local features:** +- The base image is **Fedora**, so use `dnf` for package management (not `apt`, `yum`, or `apk`) +- Features run as **root** during image build, but the `agent` user and `/home/agent/` already exist +- Environment variables are available to install scripts: + - `_REMOTE_USER=agent` — the container user + - `_REMOTE_USER_HOME=/home/agent` — the user's home directory + - Option values from `devcontainer-feature.json` (uppercased, non-alphanumeric → `_`) +- Install scripts must be executable (`chmod +x install.sh`) +- Use `set -e` to fail fast on errors + --- ### Port forwarding @@ -392,20 +488,46 @@ Add to `.kaiden/workspace.json`: } ``` -### Add a dev container feature (Go toolchain) +### Add a dev container feature (verified Fedora-compatible) -Add to `.kaiden/workspace.json`: +⚠️ **Always verify feature compatibility with Fedora before recommending.** Check the feature's documentation or install script for RPM/dnf support. + +If the feature is compatible, add to `.kaiden/workspace.json`: ```json { "features": { - "ghcr.io/devcontainers/features/go:1": { "version": "1.23" } + "ghcr.io/devcontainers/features/common-utils:1": {} } } ``` Then re-register on the host: `kdn workspace remove -f && kdn init --runtime podman --agent ` +**If unsure about compatibility, suggest a local feature instead** (see next example). + +### Add a local dev container feature (custom tools) + +Create the feature directory structure in `.kaiden/features/project-tools/` with a `devcontainer-feature.json` and `install.sh`. The install script should use `dnf` for packages (base image is Fedora): + +```bash +#!/bin/bash +set -e +dnf install -y ripgrep fd-find +``` + +Then reference it in `.kaiden/workspace.json`: + +```json +{ + "features": { + "./features/project-tools": {} + } +} +``` + +Re-register to apply: `kdn workspace remove -f && kdn init --runtime podman --agent ` + --- ## Auto-configuration shortcut