Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions internal/agents/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<repo>/agents/
SourceLocal DefinitionSource = "local"

// SourceRepo indicates the definition came from <repo>/.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/<repo>/agents/
localAgentsDir string

Expand All @@ -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/<repo>/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/<repo>/agents/*.md
func (r *Reader) ReadLocalDefinitions() ([]Definition, error) {
return readDefinitionsFromDir(r.localAgentsDir, SourceLocal)
Expand All @@ -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)
Expand All @@ -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.
Expand Down
156 changes: 150 additions & 6 deletions internal/agents/agents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 7 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand All @@ -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
}
Expand Down Expand Up @@ -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"),
}
}

Expand Down
3 changes: 2 additions & 1 deletion pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,15 @@ 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 {
t.Fatalf("EnsureDirectories() failed: %v", err)
}

// 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)
Expand Down
4 changes: 2 additions & 2 deletions test/agents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down