agent.go

143 lines
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143
package internal

import (
	"encoding/json"
	"fmt"
	"os/exec"
	"strings"
	"time"

	"congo.gg/dev/models"
)

const claudeBin = "/home/coder/.local/bin/claude"

var agentSystemPrompt = `You are the infrastructure assistant for this Congo Dev workbench. You help manage Docker services, git repositories, and server infrastructure.

Available context:
- Docker containers are on the "internal" network
- User services are named "svc-<slug>"
- Code-server runs as "congo-dev-coder"
- Repos are at /home/coder/repos/
- The congo CLI is available for scaffolding projects

Be concise and actionable. When asked to inspect something, do it directly.`

// IsAgentAvailable checks if Claude Code CLI is authenticated in the code-server.
func IsAgentAvailable() bool {
	if !IsCoderRunning() {
		return false
	}
	out, err := CoderExec("test -f /home/coder/.claude/.credentials.json && echo yes")
	if err != nil {
		return false
	}
	return strings.TrimSpace(out) == "yes"
}

// RunAgentChat sends a message to Claude Code CLI and returns the response.
// It builds context about the current infrastructure state and passes it along.
func RunAgentChat(message string) (string, error) {
	if !IsCoderRunning() {
		return "", fmt.Errorf("code-server is not running")
	}

	// Build infrastructure context
	context := buildInfraContext()

	// Construct the prompt with context
	prompt := fmt.Sprintf("%s\n\nCurrent infrastructure state:\n%s\n\nUser request: %s", agentSystemPrompt, context, message)

	// Run claude CLI in print mode with --max-turns 1 for quick responses
	// Use --no-input to prevent interactive prompts
	cmd := exec.Command("docker", "exec", coderContainer,
		claudeBin, "-p", prompt, "--max-turns", "3")
	cmd.Env = append(cmd.Environ(), "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1")

	// Set a timeout
	done := make(chan struct{})
	var out []byte
	var err error
	go func() {
		out, err = cmd.CombinedOutput()
		close(done)
	}()

	select {
	case <-done:
		if err != nil {
			output := strings.TrimSpace(string(out))
			if output != "" {
				return "", fmt.Errorf("claude: %s", output)
			}
			return "", fmt.Errorf("claude: %w", err)
		}
		return strings.TrimSpace(string(out)), nil
	case <-time.After(2 * time.Minute):
		if cmd.Process != nil {
			cmd.Process.Kill()
		}
		return "", fmt.Errorf("claude: timed out after 2 minutes")
	}
}

// buildInfraContext gathers current state to give the agent context.
func buildInfraContext() string {
	var parts []string

	// Containers
	containers, err := ListContainers()
	if err == nil && len(containers) > 0 {
		var names []string
		for _, c := range containers {
			names = append(names, fmt.Sprintf("%s (%s)", c.Name, c.State))
		}
		parts = append(parts, "Containers: "+strings.Join(names, ", "))
	}

	// Repos
	repos, _ := models.Repositories.All()
	if len(repos) > 0 {
		var names []string
		for _, r := range repos {
			names = append(names, r.Name)
		}
		parts = append(parts, "Repos: "+strings.Join(names, ", "))
	}

	// Services
	services, _ := models.Services.All()
	if len(services) > 0 {
		var svcInfo []string
		for _, s := range services {
			status := s.Status
			if IsContainerRunning("svc-" + s.Slug) {
				status = "running"
			}
			svcInfo = append(svcInfo, fmt.Sprintf("%s (%s, port %d)", s.Name, status, s.Port))
		}
		parts = append(parts, "Services: "+strings.Join(svcInfo, ", "))
	}

	// System stats
	stats := GetSystemStats()
	if stats != nil {
		parts = append(parts, fmt.Sprintf("System: %s, %d CPU, mem %s/%s (%d%%), disk %s/%s (%d%%)",
			stats.Hostname, stats.NumCPU,
			stats.MemUsed, stats.MemTotal, stats.MemPercent,
			stats.DiskUsed, stats.DiskTotal, stats.DiskPercent))
	}

	if len(parts) == 0 {
		return "No infrastructure data available"
	}
	return strings.Join(parts, "\n")
}

func toJSON(v any) string {
	data, err := json.MarshalIndent(v, "", "  ")
	if err != nil {
		return fmt.Sprintf("%v", v)
	}
	return string(data)
}