feat(daemon): inject skills into agent-native directories

Write skills to provider-native paths so agents discover them
automatically instead of relying on manual path references in
CLAUDE.md/AGENTS.md.

- Claude: write to {workDir}/.claude/skills/ (native discovery)
- Codex: write to per-task CODEX_HOME/skills/ with auth/config
  seeded from ~/.codex/ (symlink auth.json, copy config files)
- Fallback: keep .agent_context/skills/ for unknown providers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jiayuan
2026-03-28 00:47:00 +08:00
parent 1b505c3a21
commit 46144646c5
6 changed files with 391 additions and 38 deletions

View File

@@ -573,6 +573,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string) (TaskR
WorkspacesRoot: d.cfg.WorkspacesRoot,
TaskID: task.ID,
AgentName: agentName,
Provider: provider,
Task: taskCtx,
}, d.logger)
if err != nil {
@@ -593,13 +594,19 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string) (TaskR
// Pass the daemon's auth credentials so the spawned agent CLI can call
// the Multica API (e.g. `multica issue get`, `multica issue comment add`).
agentEnv := map[string]string{
"MULTICA_TOKEN": d.client.Token(),
"MULTICA_SERVER_URL": d.cfg.ServerBaseURL,
}
// Point Codex to the per-task CODEX_HOME so it discovers skills natively
// without polluting the system ~/.codex/skills/.
if env.CodexHome != "" {
agentEnv["CODEX_HOME"] = env.CodexHome
}
backend, err := agent.New(provider, agent.Config{
ExecutablePath: entry.Path,
Env: map[string]string{
"MULTICA_TOKEN": d.client.Token(),
"MULTICA_SERVER_URL": d.cfg.ServerBaseURL,
},
Logger: d.logger,
Env: agentEnv,
Logger: d.logger,
})
if err != nil {
return TaskResult{}, fmt.Errorf("create agent backend: %w", err)

View File

@@ -0,0 +1,127 @@
package execenv
import (
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
)
// Files to symlink from the shared ~/.codex/ into the per-task CODEX_HOME.
// Symlinks share state (e.g. auth tokens) so changes propagate automatically.
var codexSymlinkedFiles = []string{
"auth.json",
}
// Files to copy from the shared ~/.codex/ into the per-task CODEX_HOME.
// Copies are isolated — changes don't affect the shared home.
var codexCopiedFiles = []string{
"config.json",
"config.toml",
"instructions.md",
}
// prepareCodexHome creates a per-task CODEX_HOME directory and seeds it with
// config from the shared ~/.codex/ home. Auth is symlinked (shared), config
// files are copied (isolated).
func prepareCodexHome(codexHome string, logger *slog.Logger) error {
sharedHome := resolveSharedCodexHome()
if err := os.MkdirAll(codexHome, 0o755); err != nil {
return fmt.Errorf("create codex-home dir: %w", err)
}
// Symlink shared files (auth).
for _, name := range codexSymlinkedFiles {
src := filepath.Join(sharedHome, name)
dst := filepath.Join(codexHome, name)
if err := ensureSymlink(src, dst); err != nil {
logger.Warn("execenv: codex-home symlink failed", "file", name, "error", err)
}
}
// Copy config files (isolated per task).
for _, name := range codexCopiedFiles {
src := filepath.Join(sharedHome, name)
dst := filepath.Join(codexHome, name)
if err := copyFileIfExists(src, dst); err != nil {
logger.Warn("execenv: codex-home copy failed", "file", name, "error", err)
}
}
return nil
}
// resolveSharedCodexHome returns the path to the user's shared Codex home.
// Checks $CODEX_HOME first, falls back to ~/.codex.
func resolveSharedCodexHome() string {
if v := os.Getenv("CODEX_HOME"); v != "" {
abs, err := filepath.Abs(v)
if err == nil {
return abs
}
}
home, err := os.UserHomeDir()
if err != nil {
return filepath.Join("/tmp", ".codex") // last resort fallback
}
return filepath.Join(home, ".codex")
}
// ensureSymlink creates a symlink dst → src. If src doesn't exist, it's a no-op.
// If dst already exists as a correct symlink, it's a no-op. If dst is a broken
// symlink, it's replaced.
func ensureSymlink(src, dst string) error {
if _, err := os.Stat(src); os.IsNotExist(err) {
return nil // source doesn't exist — skip
}
// Check if dst already exists.
if fi, err := os.Lstat(dst); err == nil {
if fi.Mode()&os.ModeSymlink != 0 {
// It's a symlink — check if it points to the right place.
target, err := os.Readlink(dst)
if err == nil && target == src {
return nil // already correct
}
// Wrong target — remove and recreate.
os.Remove(dst)
} else {
// Regular file exists — don't overwrite.
return nil
}
}
return os.Symlink(src, dst)
}
// copyFileIfExists copies src to dst. If src doesn't exist, it's a no-op.
// If dst already exists, it's not overwritten.
func copyFileIfExists(src, dst string) error {
if _, err := os.Stat(src); os.IsNotExist(err) {
return nil
}
// Don't overwrite existing file.
if _, err := os.Stat(dst); err == nil {
return nil
}
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open %s: %w", src, err)
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return fmt.Errorf("create %s: %w", dst, err)
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return fmt.Errorf("copy %s → %s: %w", src, dst, err)
}
return nil
}

View File

@@ -8,28 +8,58 @@ import (
"strings"
)
// writeContextFiles renders and writes .agent_context/issue_context.md and skills into workDir.
func writeContextFiles(workDir string, ctx TaskContextForEnv) error {
// writeContextFiles renders and writes .agent_context/issue_context.md and
// skills into the appropriate provider-native location.
//
// Claude: skills → {workDir}/.claude/skills/{name}/SKILL.md (native discovery)
// Codex: skills → handled separately in Prepare via codex-home
// Default: skills → {workDir}/.agent_context/skills/{name}/SKILL.md
func writeContextFiles(workDir, provider string, ctx TaskContextForEnv) error {
contextDir := filepath.Join(workDir, ".agent_context")
if err := os.MkdirAll(contextDir, 0o755); err != nil {
return fmt.Errorf("create .agent_context dir: %w", err)
}
content := renderIssueContext(ctx)
content := renderIssueContext(provider, ctx)
path := filepath.Join(contextDir, "issue_context.md")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return fmt.Errorf("write issue_context.md: %w", err)
}
if len(ctx.AgentSkills) > 0 {
if err := writeSkillFiles(contextDir, ctx.AgentSkills); err != nil {
return fmt.Errorf("write skill files: %w", err)
skillsDir, err := resolveSkillsDir(workDir, provider)
if err != nil {
return fmt.Errorf("resolve skills dir: %w", err)
}
// Codex skills are written to codex-home in Prepare; skip here.
if provider != "codex" {
if err := writeSkillFiles(skillsDir, ctx.AgentSkills); err != nil {
return fmt.Errorf("write skill files: %w", err)
}
}
}
return nil
}
// resolveSkillsDir returns the directory where skills should be written
// based on the agent provider.
func resolveSkillsDir(workDir, provider string) (string, error) {
var skillsDir string
switch provider {
case "claude":
// Claude Code natively discovers skills from .claude/skills/ in the workdir.
skillsDir = filepath.Join(workDir, ".claude", "skills")
default:
// Fallback: write to .agent_context/skills/ (referenced by meta config).
skillsDir = filepath.Join(workDir, ".agent_context", "skills")
}
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
return "", err
}
return skillsDir, nil
}
var nonAlphaNum = regexp.MustCompile(`[^a-z0-9]+`)
// sanitizeSkillName converts a skill name to a safe directory name.
@@ -43,9 +73,9 @@ func sanitizeSkillName(name string) string {
return s
}
// writeSkillFiles creates a skills/ directory with one subdirectory per skill.
func writeSkillFiles(contextDir string, skills []SkillContextForEnv) error {
skillsDir := filepath.Join(contextDir, "skills")
// writeSkillFiles writes skill directories into the given parent directory.
// Each skill gets its own subdirectory containing SKILL.md and supporting files.
func writeSkillFiles(skillsDir string, skills []SkillContextForEnv) error {
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
return fmt.Errorf("create skills dir: %w", err)
}
@@ -77,9 +107,7 @@ func writeSkillFiles(contextDir string, skills []SkillContextForEnv) error {
}
// renderIssueContext builds the markdown content for issue_context.md.
// It contains only the issue ID and pointers to CLI commands for fetching
// dynamic data. Sections with empty content are omitted.
func renderIssueContext(ctx TaskContextForEnv) string {
func renderIssueContext(provider string, ctx TaskContextForEnv) string {
var b strings.Builder
b.WriteString("# Task Assignment\n\n")
@@ -90,8 +118,7 @@ func renderIssueContext(ctx TaskContextForEnv) string {
if len(ctx.AgentSkills) > 0 {
b.WriteString("## Agent Skills\n\n")
b.WriteString("Detailed skill instructions are in `.agent_context/skills/`.\n")
b.WriteString("Each subdirectory contains a `SKILL.md` with instructions and any supporting files.\n\n")
b.WriteString("The following skills are available to you:\n\n")
for _, skill := range ctx.AgentSkills {
fmt.Fprintf(&b, "- **%s**\n", skill.Name)
}

View File

@@ -24,6 +24,7 @@ type PrepareParams struct {
RepoPath string // source git repo path (for worktree creation), provided per-task by server
TaskID string // task UUID — used for directory name
AgentName string // for git branch naming only
Provider string // agent provider ("claude", "codex") — determines skill injection paths
Task TaskContextForEnv // context data for writing files
}
@@ -57,6 +58,8 @@ type Environment struct {
Type WorkspaceType
// BranchName is the git branch name (empty for directory type).
BranchName string
// CodexHome is the path to the per-task CODEX_HOME directory (set only for codex provider).
CodexHome string
gitRoot string // source repo root (for cleanup)
logger *slog.Logger // for cleanup logging
@@ -111,7 +114,7 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) {
env.gitRoot = gitRoot
// Exclude injected directories from git tracking.
for _, pattern := range []string{".agent_context", "CLAUDE.md", "AGENTS.md"} {
for _, pattern := range []string{".agent_context", ".claude", "CLAUDE.md", "AGENTS.md"} {
if err := excludeFromGit(workDir, pattern); err != nil {
logger.Warn("execenv: failed to exclude from git", "pattern", pattern, "error", err)
}
@@ -120,11 +123,25 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) {
}
}
// Write context files into workdir.
if err := writeContextFiles(workDir, params.Task); err != nil {
// Write context files into workdir (skills go to provider-native paths).
if err := writeContextFiles(workDir, params.Provider, params.Task); err != nil {
return nil, fmt.Errorf("execenv: write context files: %w", err)
}
// For Codex, set up a per-task CODEX_HOME seeded from ~/.codex/ with skills.
if params.Provider == "codex" {
codexHome := filepath.Join(envRoot, "codex-home")
if err := prepareCodexHome(codexHome, logger); err != nil {
return nil, fmt.Errorf("execenv: prepare codex-home: %w", err)
}
if len(params.Task.AgentSkills) > 0 {
if err := writeSkillFiles(filepath.Join(codexHome, "skills"), params.Task.AgentSkills); err != nil {
return nil, fmt.Errorf("execenv: write codex skills: %w", err)
}
}
env.CodexHome = codexHome
}
logger.Info("execenv: prepared env", "root", envRoot, "type", env.Type, "branch", env.BranchName)
return env, nil
}

View File

@@ -226,7 +226,7 @@ func TestWriteContextFiles(t *testing.T) {
},
}
if err := writeContextFiles(dir, ctx); err != nil {
if err := writeContextFiles(dir, "", ctx); err != nil {
t.Fatalf("writeContextFiles failed: %v", err)
}
@@ -280,7 +280,7 @@ func TestWriteContextFilesOmitsSkillsWhenEmpty(t *testing.T) {
IssueID: "minimal-issue-id",
}
if err := writeContextFiles(dir, ctx); err != nil {
if err := writeContextFiles(dir, "", ctx); err != nil {
t.Fatalf("writeContextFiles failed: %v", err)
}
@@ -298,6 +298,56 @@ func TestWriteContextFilesOmitsSkillsWhenEmpty(t *testing.T) {
}
}
func TestWriteContextFilesClaudeNativeSkills(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "claude-skill-test",
AgentSkills: []SkillContextForEnv{
{
Name: "Go Conventions",
Content: "Follow Go conventions.",
Files: []SkillFileContextForEnv{
{Path: "templates/example.go", Content: "package main"},
},
},
},
}
if err := writeContextFiles(dir, "claude", ctx); err != nil {
t.Fatalf("writeContextFiles failed: %v", err)
}
// Skills should be in .claude/skills/ (native discovery), NOT .agent_context/skills/.
skillMd, err := os.ReadFile(filepath.Join(dir, ".claude", "skills", "go-conventions", "SKILL.md"))
if err != nil {
t.Fatalf("failed to read .claude/skills/go-conventions/SKILL.md: %v", err)
}
if !strings.Contains(string(skillMd), "Follow Go conventions.") {
t.Error("SKILL.md missing content")
}
// Supporting files should also be under .claude/skills/.
supportFile, err := os.ReadFile(filepath.Join(dir, ".claude", "skills", "go-conventions", "templates", "example.go"))
if err != nil {
t.Fatalf("failed to read supporting file: %v", err)
}
if string(supportFile) != "package main" {
t.Errorf("supporting file content = %q, want %q", string(supportFile), "package main")
}
// .agent_context/skills/ should NOT exist for Claude.
if _, err := os.Stat(filepath.Join(dir, ".agent_context", "skills")); !os.IsNotExist(err) {
t.Error("expected .agent_context/skills/ to NOT exist for Claude provider")
}
// issue_context.md should still be in .agent_context/.
if _, err := os.Stat(filepath.Join(dir, ".agent_context", "issue_context.md")); os.IsNotExist(err) {
t.Error("expected .agent_context/issue_context.md to exist")
}
}
func TestCleanupGitWorktree(t *testing.T) {
t.Parallel()
@@ -381,14 +431,18 @@ func TestInjectRuntimeConfigClaude(t *testing.T) {
"multica issue comment list",
"Go Conventions",
"PR Review",
"go-conventions/SKILL.md",
"pr-review/SKILL.md",
"1 supporting files",
"discovered automatically",
} {
if !strings.Contains(s, want) {
t.Errorf("CLAUDE.md missing %q", want)
}
}
// Skills are now discovered natively — no path references in CLAUDE.md.
for _, absent := range []string{"go-conventions/SKILL.md", ".agent_context/skills/"} {
if strings.Contains(s, absent) {
t.Errorf("CLAUDE.md should NOT contain path %q — skills are discovered natively", absent)
}
}
}
func TestInjectRuntimeConfigCodex(t *testing.T) {
@@ -492,3 +546,120 @@ func TestCleanupPreservesLogs(t *testing.T) {
t.Fatal("expected logs/test.log to be preserved")
}
}
func TestPrepareCodexHomeSeedsFromShared(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
// Create a fake shared codex home.
sharedHome := t.TempDir()
os.WriteFile(filepath.Join(sharedHome, "auth.json"), []byte(`{"token":"secret"}`), 0o644)
os.WriteFile(filepath.Join(sharedHome, "config.json"), []byte(`{"model":"o3"}`), 0o644)
os.WriteFile(filepath.Join(sharedHome, "config.toml"), []byte(`model = "o3"`), 0o644)
os.WriteFile(filepath.Join(sharedHome, "instructions.md"), []byte("Be helpful."), 0o644)
// Point CODEX_HOME to our fake shared home.
t.Setenv("CODEX_HOME", sharedHome)
codexHome := filepath.Join(t.TempDir(), "codex-home")
if err := prepareCodexHome(codexHome, testLogger()); err != nil {
t.Fatalf("prepareCodexHome failed: %v", err)
}
// auth.json should be a symlink.
authPath := filepath.Join(codexHome, "auth.json")
fi, err := os.Lstat(authPath)
if err != nil {
t.Fatalf("auth.json not found: %v", err)
}
if fi.Mode()&os.ModeSymlink == 0 {
t.Error("auth.json should be a symlink")
}
target, _ := os.Readlink(authPath)
if target != filepath.Join(sharedHome, "auth.json") {
t.Errorf("auth.json symlink target = %q, want %q", target, filepath.Join(sharedHome, "auth.json"))
}
// Verify content is accessible through symlink.
data, _ := os.ReadFile(authPath)
if string(data) != `{"token":"secret"}` {
t.Errorf("auth.json content = %q", data)
}
// config.json should be a copy (not symlink).
configPath := filepath.Join(codexHome, "config.json")
fi, err = os.Lstat(configPath)
if err != nil {
t.Fatalf("config.json not found: %v", err)
}
if fi.Mode()&os.ModeSymlink != 0 {
t.Error("config.json should be a copy, not a symlink")
}
data, _ = os.ReadFile(configPath)
if string(data) != `{"model":"o3"}` {
t.Errorf("config.json content = %q", data)
}
// config.toml should be copied.
data, _ = os.ReadFile(filepath.Join(codexHome, "config.toml"))
if string(data) != `model = "o3"` {
t.Errorf("config.toml content = %q", data)
}
// instructions.md should be copied.
data, _ = os.ReadFile(filepath.Join(codexHome, "instructions.md"))
if string(data) != "Be helpful." {
t.Errorf("instructions.md content = %q", data)
}
}
func TestPrepareCodexHomeSkipsMissingFiles(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
// Empty shared home — no files to seed.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
codexHome := filepath.Join(t.TempDir(), "codex-home")
if err := prepareCodexHome(codexHome, testLogger()); err != nil {
t.Fatalf("prepareCodexHome failed: %v", err)
}
// Directory should exist but be empty (no auth.json, no config.json, etc.).
entries, err := os.ReadDir(codexHome)
if err != nil {
t.Fatalf("failed to read codex-home: %v", err)
}
if len(entries) != 0 {
names := make([]string, len(entries))
for i, e := range entries {
names[i] = e.Name()
}
t.Errorf("expected empty codex-home, got: %v", names)
}
}
func TestEnsureSymlinkRepairsBrokenLink(t *testing.T) {
t.Parallel()
dir := t.TempDir()
src := filepath.Join(dir, "source.json")
dst := filepath.Join(dir, "link.json")
os.WriteFile(src, []byte("real"), 0o644)
// Create a broken symlink pointing to a non-existent file.
os.Symlink(filepath.Join(dir, "old-source.json"), dst)
if err := ensureSymlink(src, dst); err != nil {
t.Fatalf("ensureSymlink failed: %v", err)
}
// Should now point to src.
target, _ := os.Readlink(dst)
if target != src {
t.Errorf("symlink target = %q, want %q", target, src)
}
data, _ := os.ReadFile(dst)
if string(data) != "real" {
t.Errorf("content = %q, want %q", data, "real")
}
}

View File

@@ -8,12 +8,12 @@ import (
)
// InjectRuntimeConfig writes the meta skill content into the runtime-specific
// config file so the agent discovers .agent_context/ through its native mechanism.
// config file so the agent discovers its environment through its native mechanism.
//
// For Claude: writes {workDir}/CLAUDE.md
// For Codex: writes {workDir}/AGENTS.md
// For Claude: writes {workDir}/CLAUDE.md (skills discovered natively from .claude/skills/)
// For Codex: writes {workDir}/AGENTS.md (skills discovered natively via CODEX_HOME)
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error {
content := buildMetaSkillContent(ctx)
content := buildMetaSkillContent(provider, ctx)
switch provider {
case "claude":
@@ -28,7 +28,7 @@ func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error
// buildMetaSkillContent generates the meta skill markdown that teaches the agent
// about the Multica runtime environment and available CLI tools.
func buildMetaSkillContent(ctx TaskContextForEnv) string {
func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
var b strings.Builder
b.WriteString("# Multica Agent Runtime\n\n")
@@ -64,14 +64,18 @@ func buildMetaSkillContent(ctx TaskContextForEnv) string {
if len(ctx.AgentSkills) > 0 {
b.WriteString("## Skills\n\n")
b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n")
switch provider {
case "claude":
// Claude discovers skills natively from .claude/skills/ — just list names.
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
case "codex":
// Codex discovers skills natively via CODEX_HOME/skills/ — just list names.
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
default:
b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n")
}
for _, skill := range ctx.AgentSkills {
dirName := sanitizeSkillName(skill.Name)
fmt.Fprintf(&b, "- **%s** → `.agent_context/skills/%s/SKILL.md`", skill.Name, dirName)
if len(skill.Files) > 0 {
fmt.Fprintf(&b, " (+ %d supporting files)", len(skill.Files))
}
b.WriteString("\n")
fmt.Fprintf(&b, "- **%s**\n", skill.Name)
}
b.WriteString("\n")
}