diff --git a/internal/agents/agents.go b/internal/agents/agents.go index bf90836e..640dd9aa 100644 --- a/internal/agents/agents.go +++ b/internal/agents/agents.go @@ -29,18 +29,24 @@ type Definition struct { type DefinitionSource string const ( + // SourceGlobal indicates the definition came from ~/.multiclaude/agents/ + SourceGlobal DefinitionSource = "global" + // SourceLocal indicates the definition came from ~/.multiclaude/repos//agents/ SourceLocal DefinitionSource = "local" // SourceRepo indicates the definition came from /.multiclaude/agents/ SourceRepo DefinitionSource = "repo" - // SourceMerged indicates the definition is a merge of local (base) and repo (custom) content + // SourceMerged indicates the definition is a merge of global/local (base) and repo (custom) content SourceMerged DefinitionSource = "merged" ) // Reader reads agent definitions from the filesystem. type Reader struct { + // globalAgentsDir is ~/.multiclaude/agents/ + globalAgentsDir string + // localAgentsDir is ~/.multiclaude/repos//agents/ localAgentsDir string @@ -49,20 +55,27 @@ type Reader struct { } // NewReader creates a new agent definition reader. +// globalAgentsDir is the path to ~/.multiclaude/agents/ // localAgentsDir is the path to ~/.multiclaude/repos//agents/ // repoPath is the path to the cloned repository (will look for .multiclaude/agents/ inside) -func NewReader(localAgentsDir, repoPath string) *Reader { +func NewReader(globalAgentsDir, localAgentsDir, repoPath string) *Reader { repoAgentsDir := "" if repoPath != "" { repoAgentsDir = filepath.Join(repoPath, ".multiclaude", "agents") } return &Reader{ - localAgentsDir: localAgentsDir, - repoAgentsDir: repoAgentsDir, + globalAgentsDir: globalAgentsDir, + localAgentsDir: localAgentsDir, + repoAgentsDir: repoAgentsDir, } } +// ReadGlobalDefinitions reads agent definitions from ~/.multiclaude/agents/*.md +func (r *Reader) ReadGlobalDefinitions() ([]Definition, error) { + return readDefinitionsFromDir(r.globalAgentsDir, SourceGlobal) +} + // ReadLocalDefinitions reads agent definitions from ~/.multiclaude/repos//agents/*.md func (r *Reader) ReadLocalDefinitions() ([]Definition, error) { return readDefinitionsFromDir(r.localAgentsDir, SourceLocal) @@ -77,10 +90,16 @@ func (r *Reader) ReadRepoDefinitions() ([]Definition, error) { return readDefinitionsFromDir(r.repoAgentsDir, SourceRepo) } -// ReadAllDefinitions reads and merges definitions from both local and repo directories. -// Checked-in repo definitions win over local definitions on filename conflict. +// ReadAllDefinitions reads and merges definitions from global, local, and repo directories. +// Resolution order (lowest to highest priority): global → local → repo. +// When a higher-priority definition has the same name, its content is appended to the base. // Returns definitions sorted alphabetically by name. func (r *Reader) ReadAllDefinitions() ([]Definition, error) { + globalDefs, err := r.ReadGlobalDefinitions() + if err != nil { + return nil, fmt.Errorf("failed to read global definitions: %w", err) + } + localDefs, err := r.ReadLocalDefinitions() if err != nil { return nil, fmt.Errorf("failed to read local definitions: %w", err) @@ -91,7 +110,9 @@ func (r *Reader) ReadAllDefinitions() ([]Definition, error) { return nil, fmt.Errorf("failed to read repo definitions: %w", err) } - return MergeDefinitions(localDefs, repoDefs), nil + // Layer: global is base, local layers on top, then repo on top of that. + withLocal := MergeDefinitions(globalDefs, localDefs) + return MergeDefinitions(withLocal, repoDefs), nil } // MergeDefinitions merges local and repo definitions. diff --git a/internal/agents/agents_test.go b/internal/agents/agents_test.go index d300fabb..8ed6b105 100644 --- a/internal/agents/agents_test.go +++ b/internal/agents/agents_test.go @@ -46,7 +46,7 @@ Reviews pull requests. t.Fatal(err) } - reader := NewReader(localAgentsDir, "") + reader := NewReader("", localAgentsDir, "") defs, err := reader.ReadLocalDefinitions() if err != nil { t.Fatalf("ReadLocalDefinitions failed: %v", err) @@ -105,7 +105,7 @@ A team-specific automation bot. t.Fatal(err) } - reader := NewReader("", repoPath) + reader := NewReader("", "", repoPath) defs, err := reader.ReadRepoDefinitions() if err != nil { t.Fatalf("ReadRepoDefinitions failed: %v", err) @@ -131,7 +131,7 @@ func TestReadRepoDefinitionsNonExistent(t *testing.T) { } defer os.RemoveAll(tmpDir) - reader := NewReader("", tmpDir) + reader := NewReader("", "", tmpDir) defs, err := reader.ReadRepoDefinitions() if err != nil { t.Fatalf("ReadRepoDefinitions should not fail for non-existent directory: %v", err) @@ -287,7 +287,7 @@ func TestReadAllDefinitions(t *testing.T) { t.Fatal(err) } - reader := NewReader(localAgentsDir, repoPath) + reader := NewReader("", localAgentsDir, repoPath) defs, err := reader.ReadAllDefinitions() if err != nil { t.Fatalf("ReadAllDefinitions failed: %v", err) @@ -400,8 +400,152 @@ func TestParseDescription(t *testing.T) { } } +func TestReadGlobalDefinitions(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agents-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + globalAgentsDir := filepath.Join(tmpDir, "agents") + if err := os.MkdirAll(globalAgentsDir, 0755); err != nil { + t.Fatal(err) + } + + globalContent := `# Global Worker + +A global worker definition. +` + if err := os.WriteFile(filepath.Join(globalAgentsDir, "worker.md"), []byte(globalContent), 0644); err != nil { + t.Fatal(err) + } + + reader := NewReader(globalAgentsDir, "", "") + defs, err := reader.ReadGlobalDefinitions() + if err != nil { + t.Fatalf("ReadGlobalDefinitions failed: %v", err) + } + + if len(defs) != 1 { + t.Fatalf("expected 1 definition, got %d", len(defs)) + } + if defs[0].Name != "worker" { + t.Errorf("expected name worker, got %s", defs[0].Name) + } + if defs[0].Source != SourceGlobal { + t.Errorf("expected source global, got %s", defs[0].Source) + } +} + +func TestReadAllDefinitionsThreeLayers(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agents-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + globalAgentsDir := filepath.Join(tmpDir, "agents") + if err := os.MkdirAll(globalAgentsDir, 0755); err != nil { + t.Fatal(err) + } + + localAgentsDir := filepath.Join(tmpDir, "repos", "myrepo", "agents") + if err := os.MkdirAll(localAgentsDir, 0755); err != nil { + t.Fatal(err) + } + + repoPath := filepath.Join(tmpDir, "repo") + repoAgentsDir := filepath.Join(repoPath, ".multiclaude", "agents") + if err := os.MkdirAll(repoAgentsDir, 0755); err != nil { + t.Fatal(err) + } + + // Global: worker + global-only + if err := os.WriteFile(filepath.Join(globalAgentsDir, "worker.md"), []byte("global worker"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(globalAgentsDir, "global-only.md"), []byte("global only"), 0644); err != nil { + t.Fatal(err) + } + + // Local: worker (overrides global) + local-only + if err := os.WriteFile(filepath.Join(localAgentsDir, "worker.md"), []byte("local worker"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(localAgentsDir, "local-only.md"), []byte("local only"), 0644); err != nil { + t.Fatal(err) + } + + // Repo: worker (overrides local+global) + repo-only + if err := os.WriteFile(filepath.Join(repoAgentsDir, "worker.md"), []byte("repo worker"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repoAgentsDir, "repo-only.md"), []byte("repo only"), 0644); err != nil { + t.Fatal(err) + } + + reader := NewReader(globalAgentsDir, localAgentsDir, repoPath) + defs, err := reader.ReadAllDefinitions() + if err != nil { + t.Fatalf("ReadAllDefinitions failed: %v", err) + } + + if len(defs) != 4 { + t.Fatalf("expected 4 definitions (worker, global-only, local-only, repo-only), got %d", len(defs)) + } + + defMap := make(map[string]Definition) + for _, def := range defs { + defMap[def.Name] = def + } + + // Worker should be merged across all three layers + worker, ok := defMap["worker"] + if !ok { + t.Fatal("worker not found") + } + if worker.Source != SourceMerged { + t.Errorf("expected worker source merged, got %s", worker.Source) + } + if !strings.Contains(worker.Content, "global worker") { + t.Error("merged worker should contain global content") + } + if !strings.Contains(worker.Content, "local worker") { + t.Error("merged worker should contain local content") + } + if !strings.Contains(worker.Content, "repo worker") { + t.Error("merged worker should contain repo content") + } + + // global-only should remain from global layer + if _, ok := defMap["global-only"]; !ok { + t.Error("global-only definition not found") + } + + // local-only should remain from local layer + if _, ok := defMap["local-only"]; !ok { + t.Error("local-only definition not found") + } + + // repo-only should remain from repo layer + if _, ok := defMap["repo-only"]; !ok { + t.Error("repo-only definition not found") + } +} + +func TestEmptyGlobalDir(t *testing.T) { + reader := NewReader("", "", "") + defs, err := reader.ReadGlobalDefinitions() + if err != nil { + t.Fatalf("ReadGlobalDefinitions should not fail for empty path: %v", err) + } + if len(defs) != 0 { + t.Fatalf("expected 0 definitions, got %d", len(defs)) + } +} + func TestEmptyLocalDir(t *testing.T) { - reader := NewReader("", "") + reader := NewReader("", "", "") defs, err := reader.ReadLocalDefinitions() if err != nil { t.Fatalf("ReadLocalDefinitions should not fail for empty path: %v", err) @@ -412,7 +556,7 @@ func TestEmptyLocalDir(t *testing.T) { } func TestEmptyRepoPath(t *testing.T) { - reader := NewReader("", "") + reader := NewReader("", "", "") defs, err := reader.ReadRepoDefinitions() if err != nil { t.Fatalf("ReadRepoDefinitions should not fail for empty path: %v", err) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3e06628b..3f2a8272 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -2435,7 +2435,7 @@ func (c *CLI) listAgentDefinitions(args []string) error { repoPath := c.paths.RepoDir(repoName) // Read and merge agent definitions - reader := agents.NewReader(localAgentsDir, repoPath) + reader := agents.NewReader(c.paths.GlobalAgentsDir, localAgentsDir, repoPath) defs, err := reader.ReadAllDefinitions() if err != nil { return errors.Wrap(errors.CategoryRuntime, "failed to read agent definitions", err) @@ -5686,7 +5686,7 @@ func (c *CLI) savePromptToFile(agentName, promptText string) (string, error) { // Returns the prompt content or an error if not found. func (c *CLI) getAgentDefinition(repoName, repoPath, agentDefName string) (string, error) { localAgentsDir := c.paths.RepoAgentsDir(repoName) - reader := agents.NewReader(localAgentsDir, repoPath) + reader := agents.NewReader(c.paths.GlobalAgentsDir, localAgentsDir, repoPath) definitions, err := reader.ReadAllDefinitions() if err != nil { return "", fmt.Errorf("failed to read agent definitions: %w", err) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index c7554129..d20f6f33 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -1922,7 +1922,7 @@ func (d *Daemon) sendAgentDefinitionsToSupervisor(repoName, repoPath string, mqC // Create agent reader localAgentsDir := d.paths.RepoAgentsDir(repoName) - reader := agents.NewReader(localAgentsDir, repoPath) + reader := agents.NewReader(d.paths.GlobalAgentsDir, localAgentsDir, repoPath) // Read all definitions definitions, err := reader.ReadAllDefinitions() diff --git a/pkg/config/config.go b/pkg/config/config.go index 9bcf0c16..bc375199 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -20,6 +20,7 @@ type Paths struct { OutputDir string // output/ ClaudeConfigDir string // claude-config/ ArchiveDir string // archive/ (for paused work) + GlobalAgentsDir string // agents/ (global agent definitions) } // DefaultPaths returns the default paths for multiclaude @@ -43,6 +44,7 @@ func DefaultPaths() (*Paths, error) { OutputDir: filepath.Join(root, "output"), ClaudeConfigDir: filepath.Join(root, "claude-config"), ArchiveDir: filepath.Join(root, "archive"), + GlobalAgentsDir: filepath.Join(root, "agents"), }, nil } @@ -56,9 +58,13 @@ func (p *Paths) EnsureDirectories() error { p.OutputDir, p.ClaudeConfigDir, p.ArchiveDir, + p.GlobalAgentsDir, } for _, dir := range dirs { + if dir == "" { + continue + } if err := os.MkdirAll(dir, 0755); err != nil { return err } @@ -142,6 +148,7 @@ func NewTestPaths(tmpDir string) *Paths { OutputDir: filepath.Join(tmpDir, "output"), ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"), ArchiveDir: filepath.Join(tmpDir, "archive"), + GlobalAgentsDir: filepath.Join(tmpDir, "agents"), } } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 4bc1e2b9..bbea83ef 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -61,6 +61,7 @@ func TestEnsureDirectories(t *testing.T) { OutputDir: filepath.Join(tmpDir, "test-multiclaude", "output"), ClaudeConfigDir: filepath.Join(tmpDir, "test-multiclaude", "claude-config"), ArchiveDir: filepath.Join(tmpDir, "test-multiclaude", "archive"), + GlobalAgentsDir: filepath.Join(tmpDir, "test-multiclaude", "agents"), } if err := paths.EnsureDirectories(); err != nil { @@ -68,7 +69,7 @@ func TestEnsureDirectories(t *testing.T) { } // Verify directories were created - dirs := []string{paths.Root, paths.ReposDir, paths.WorktreesDir, paths.MessagesDir, paths.OutputDir, paths.ClaudeConfigDir, paths.ArchiveDir} + dirs := []string{paths.Root, paths.ReposDir, paths.WorktreesDir, paths.MessagesDir, paths.OutputDir, paths.ClaudeConfigDir, paths.ArchiveDir, paths.GlobalAgentsDir} for _, dir := range dirs { if _, err := os.Stat(dir); os.IsNotExist(err) { t.Errorf("Directory not created: %s", dir) diff --git a/test/agents_test.go b/test/agents_test.go index e2995df7..3b5714b2 100644 --- a/test/agents_test.go +++ b/test/agents_test.go @@ -174,7 +174,7 @@ func TestAgentDefinitionMerging(t *testing.T) { } // Create reader and read all definitions - reader := agents.NewReader(localAgentsDir, repoPath) + reader := agents.NewReader("", localAgentsDir, repoPath) definitions, err := reader.ReadAllDefinitions() if err != nil { t.Fatalf("Failed to read all definitions: %v", err) @@ -576,7 +576,7 @@ func TestAgentDefinitionsSentToSupervisor(t *testing.T) { } // Read definitions to verify they can be merged - reader := agents.NewReader(agentsDir, repoPath) + reader := agents.NewReader("", agentsDir, repoPath) definitions, err := reader.ReadAllDefinitions() if err != nil { t.Fatalf("Failed to read definitions: %v", err)