Skip to content

Commit fcbeafc

Browse files
committed
cu configure: auto-configure agents
Signed-off-by: Andrea Luzzardi <[email protected]>
1 parent 4d62770 commit fcbeafc

File tree

7 files changed

+579
-0
lines changed

7 files changed

+579
-0
lines changed

agentconfig/agent.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package agentconfig
2+
3+
import (
4+
"strings"
5+
)
6+
7+
type RuleStrategy int
8+
9+
const (
10+
RuleStrategyMerge RuleStrategy = iota
11+
RuleStrategyReplace
12+
)
13+
14+
// Agent defines how to configure a coding agent to use container-use
15+
type Agent struct {
16+
// Name of the agent (e.g., "goose", "claude")
17+
Name string
18+
19+
// Detect returns true if this agent is being used in the current project
20+
Detect func(root string) bool
21+
22+
// ConfigureMCP returns the configuration to add/merge into the agent's config
23+
ConfigureMCP func(cmd string) error
24+
25+
// RulesFile is where to install agent-specific instructions
26+
RulesFile string
27+
28+
// RuleStrategy defines how to handle the rules file (e.g., "replace", "merge")
29+
RuleStrategy RuleStrategy
30+
}
31+
32+
type Agents []*Agent
33+
34+
func (agents Agents) Get(name string) *Agent {
35+
for _, a := range agents {
36+
if a.Name == name {
37+
return a
38+
}
39+
}
40+
return nil
41+
}
42+
43+
func (agents Agents) String() string {
44+
names := make([]string, len(agents))
45+
for i, a := range agents {
46+
names[i] = a.Name
47+
}
48+
49+
return strings.Join(names, ", ")
50+
}
51+
52+
// Supported returns all supported agents
53+
func Supported() Agents {
54+
return Agents{
55+
gooseAgent,
56+
claudeAgent,
57+
}
58+
}

agentconfig/claude.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package agentconfig
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
7+
)
8+
9+
var claudeAgent = &Agent{
10+
Name: "claude",
11+
RulesFile: "CLAUDE.md",
12+
Detect: func(dir string) bool {
13+
// Check for .claude directory or CLAUDE.md file
14+
if _, err := os.Stat(filepath.Join(dir, ".claude")); err == nil {
15+
return true
16+
}
17+
if _, err := os.Stat(filepath.Join(dir, "CLAUDE.md")); err == nil {
18+
return true
19+
}
20+
return false
21+
},
22+
ConfigureMCP: func(cmd string) error {
23+
c := exec.Command("claude", "mcp", "add", "container-use", "--", cmd, "stdio")
24+
c.Stdout = os.Stdout
25+
c.Stderr = os.Stderr
26+
return c.Run()
27+
},
28+
}

agentconfig/config.go

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
package agentconfig
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/dagger/container-use/rules"
12+
"github.com/mitchellh/go-homedir"
13+
"gopkg.in/yaml.v3"
14+
)
15+
16+
// Configure sets up an agent to use container-use
17+
func Configure(a *Agent, root string, confirm func(string) (bool, error)) error {
18+
// Install MCP server
19+
if err := installMCP(a, confirm); err != nil {
20+
return err
21+
}
22+
23+
// Install rules
24+
if err := installRules(a, root, confirm); err != nil {
25+
return err
26+
}
27+
28+
return nil
29+
}
30+
func installMCP(a *Agent, confirm func(string) (bool, error)) error {
31+
ok, err := confirm(fmt.Sprintf("Install container-use MCP server in %s?", a.Name))
32+
if err != nil {
33+
return fmt.Errorf("failed to get confirmation: %w", err)
34+
}
35+
if !ok {
36+
fmt.Printf("👉 Skipping: MCP installation (user declined)\n")
37+
return nil
38+
}
39+
40+
bin, err := os.Executable()
41+
if err != nil {
42+
return fmt.Errorf("failed to get absolute path of current executable: %w", err)
43+
}
44+
45+
if err := a.ConfigureMCP(bin); err != nil {
46+
return fmt.Errorf("failed to configure MCP: %w", err)
47+
}
48+
return nil
49+
}
50+
51+
func installRules(a *Agent, root string, confirm func(string) (bool, error)) error {
52+
// Some agents don't have rules
53+
if a.RulesFile == "" {
54+
return nil
55+
}
56+
57+
rulesFile := path.Join(root, a.RulesFile)
58+
59+
// Get confirmation
60+
ok, err := confirm(fmt.Sprintf("Install container-use rules to %s?", rulesFile))
61+
if err != nil {
62+
return fmt.Errorf("failed to get confirmation: %w", err)
63+
}
64+
if !ok {
65+
fmt.Printf("👉 Skipping: Rules installation (user declined)\n")
66+
return nil
67+
}
68+
69+
// Create rules directory
70+
if err := os.MkdirAll(filepath.Dir(rulesFile), 0755); err != nil {
71+
return fmt.Errorf("failed to create rules directory: %w", err)
72+
}
73+
74+
// Write rules file
75+
content := "# Environment\n" + rules.AgentRules
76+
77+
switch a.RuleStrategy {
78+
case RuleStrategyReplace:
79+
if err := os.WriteFile(rulesFile, []byte(content), 0644); err != nil {
80+
return fmt.Errorf("failed to write rules: %w", err)
81+
}
82+
case RuleStrategyMerge:
83+
// Merge rules file
84+
// Read existing rules
85+
existing, err := os.ReadFile(rulesFile)
86+
if err != nil && !os.IsNotExist(err) {
87+
return fmt.Errorf("failed to read existing rules: %w", err)
88+
}
89+
90+
// Look for section markers
91+
const marker = "<!-- container-use-rules -->"
92+
existingStr := string(existing)
93+
94+
if strings.Contains(existingStr, marker) {
95+
// Update existing section
96+
parts := strings.Split(existingStr, marker)
97+
if len(parts) != 3 {
98+
return fmt.Errorf("malformed rules file - expected single section marked with %s", marker)
99+
}
100+
newContent := parts[0] + marker + "\n" + content + "\n" + marker + parts[2]
101+
if err := os.WriteFile(rulesFile, []byte(newContent), 0644); err != nil {
102+
return fmt.Errorf("failed to update rules: %w", err)
103+
}
104+
} else {
105+
// Append new section
106+
newContent := string(existing)
107+
if len(newContent) > 0 && !strings.HasSuffix(newContent, "\n") {
108+
newContent += "\n"
109+
}
110+
newContent += "\n" + marker + "\n" + content + "\n" + marker + "\n"
111+
if err := os.WriteFile(rulesFile, []byte(newContent), 0644); err != nil {
112+
return fmt.Errorf("failed to append rules: %w", err)
113+
}
114+
}
115+
}
116+
117+
fmt.Printf("✨ Installed rules to %s\n", rulesFile)
118+
return nil
119+
}
120+
121+
func resolvePath(root, path string) string {
122+
home, err := os.UserHomeDir()
123+
if err != nil {
124+
panic(err)
125+
}
126+
path = strings.ReplaceAll(path, "$HOME", home)
127+
if filepath.IsAbs(path) {
128+
return path
129+
}
130+
return filepath.Join(root, path)
131+
}
132+
133+
func loadConfig(configFile string, format string) (map[string]any, error) {
134+
data, err := os.ReadFile(configFile)
135+
if err != nil {
136+
if os.IsNotExist(err) {
137+
return make(map[string]any), nil
138+
}
139+
return nil, fmt.Errorf("failed to read config: %w", err)
140+
}
141+
142+
var config map[string]any
143+
144+
switch format {
145+
case "yaml":
146+
if err := yaml.Unmarshal(data, &config); err != nil {
147+
return nil, fmt.Errorf("failed to parse yaml: %w", err)
148+
}
149+
case "json":
150+
if err := json.Unmarshal(data, &config); err != nil {
151+
return nil, fmt.Errorf("failed to parse json: %w", err)
152+
}
153+
default:
154+
return nil, fmt.Errorf("unsupported config format: %s", format)
155+
}
156+
157+
return config, nil
158+
}
159+
160+
func updateConfig(configFile string, newConfig map[string]any, format string) error {
161+
var err error
162+
configFile, err = homedir.Expand(configFile)
163+
if err != nil {
164+
return fmt.Errorf("failed to expand home directory: %w", err)
165+
}
166+
167+
currentConfig, err := loadConfig(configFile, format)
168+
if err != nil {
169+
return fmt.Errorf("failed to load config: %w", err)
170+
}
171+
172+
// Deep merge the configs
173+
merged := mergeConfigs(currentConfig, newConfig)
174+
175+
// Ensure config directory exists
176+
if err := os.MkdirAll(filepath.Dir(configFile), 0755); err != nil {
177+
return fmt.Errorf("failed to create config directory: %w", err)
178+
}
179+
180+
// Save merged config
181+
var data []byte
182+
183+
switch format {
184+
case "yaml":
185+
data, err = yaml.Marshal(merged)
186+
case "json":
187+
data, err = json.MarshalIndent(merged, "", " ")
188+
}
189+
if err != nil {
190+
return fmt.Errorf("failed to marshal config: %w", err)
191+
}
192+
193+
if err := os.WriteFile(configFile, data, 0644); err != nil {
194+
return fmt.Errorf("failed to write config: %w", err)
195+
}
196+
197+
fmt.Printf("✨ Updated configuration in %s\n", configFile)
198+
return nil
199+
}
200+
201+
func mergeConfigs(base, new any) any {
202+
switch newVal := new.(type) {
203+
case map[string]any:
204+
if baseVal, ok := base.(map[string]any); ok {
205+
result := make(map[string]any)
206+
// Copy base values
207+
for k, v := range baseVal {
208+
result[k] = v
209+
}
210+
// Merge new values
211+
for k, v := range newVal {
212+
if existing, exists := result[k]; exists {
213+
result[k] = mergeConfigs(existing, v)
214+
} else {
215+
result[k] = v
216+
}
217+
}
218+
return result
219+
}
220+
case []any:
221+
if baseVal, ok := base.([]any); ok {
222+
// For arrays, just append new values
223+
return append(baseVal, newVal...)
224+
}
225+
}
226+
// For non-maps/arrays or type mismatches, use the new value
227+
return new
228+
}

agentconfig/goose.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package agentconfig
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
)
7+
8+
var gooseAgent = &Agent{
9+
Name: "goose",
10+
Detect: func(root string) bool {
11+
if _, err := os.Stat(filepath.Join(root, ".goosehints")); err == nil {
12+
return true
13+
}
14+
return false
15+
},
16+
ConfigureMCP: func(cmd string) error {
17+
return updateConfig(
18+
"~/.config/goose/config.yaml",
19+
map[string]any{
20+
"extensions": map[string]any{
21+
"container-use": map[string]any{
22+
"name": "container-use",
23+
"type": "stdio",
24+
"enabled": true,
25+
"cmd": cmd,
26+
"args": []string{"stdio"},
27+
"envs": map[string]any{},
28+
},
29+
},
30+
},
31+
"yaml",
32+
)
33+
34+
return nil
35+
},
36+
}

0 commit comments

Comments
 (0)