mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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:
@@ -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)
|
||||
|
||||
127
server/internal/daemon/execenv/codex_home.go
Normal file
127
server/internal/daemon/execenv/codex_home.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user