From 46144646c59e33de66662a84cfe66642a64388a7 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Sat, 28 Mar 2026 00:47:00 +0800 Subject: [PATCH] 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 --- server/internal/daemon/daemon.go | 17 +- server/internal/daemon/execenv/codex_home.go | 127 ++++++++++++ server/internal/daemon/execenv/context.go | 53 +++-- server/internal/daemon/execenv/execenv.go | 23 ++- .../internal/daemon/execenv/execenv_test.go | 181 +++++++++++++++++- .../internal/daemon/execenv/runtime_config.go | 28 +-- 6 files changed, 391 insertions(+), 38 deletions(-) create mode 100644 server/internal/daemon/execenv/codex_home.go diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index a327a795..a58f19fb 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -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) diff --git a/server/internal/daemon/execenv/codex_home.go b/server/internal/daemon/execenv/codex_home.go new file mode 100644 index 00000000..dd4a33b9 --- /dev/null +++ b/server/internal/daemon/execenv/codex_home.go @@ -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 +} diff --git a/server/internal/daemon/execenv/context.go b/server/internal/daemon/execenv/context.go index 35e59669..937c7207 100644 --- a/server/internal/daemon/execenv/context.go +++ b/server/internal/daemon/execenv/context.go @@ -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) } diff --git a/server/internal/daemon/execenv/execenv.go b/server/internal/daemon/execenv/execenv.go index 92208829..c53ae88b 100644 --- a/server/internal/daemon/execenv/execenv.go +++ b/server/internal/daemon/execenv/execenv.go @@ -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 } diff --git a/server/internal/daemon/execenv/execenv_test.go b/server/internal/daemon/execenv/execenv_test.go index 7512a5d0..f76d75b6 100644 --- a/server/internal/daemon/execenv/execenv_test.go +++ b/server/internal/daemon/execenv/execenv_test.go @@ -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") + } +} diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go index 7ad0e7ec..fc0d7640 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -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") }