initial agent-mgr: app builder platform MVP
Go API server + Preact UI + Claude Code adapter. - App-centric model (ideas, not repos) - AgentProvider interface for multi-agent support - K8s pod lifecycle for sandboxed agent sessions - Gitea integration (create repos, push branches) - WebSocket streaming for live session output - Woodpecker CI/CD pipelines (kaniko build + kubectl deploy)
This commit is contained in:
127
internal/provider/claudecode/adapter.go
Normal file
127
internal/provider/claudecode/adapter.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package claudecode
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/agentsphere/agent-mgr/internal/provider"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/kubernetes/scheme"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/remotecommand"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
MaxTurns int `json:"max_turns,omitempty"`
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
client kubernetes.Interface
|
||||
restConfig *rest.Config
|
||||
}
|
||||
|
||||
func New(client kubernetes.Interface, restConfig *rest.Config) *Provider {
|
||||
return &Provider{client: client, restConfig: restConfig}
|
||||
}
|
||||
|
||||
func (p *Provider) Info() provider.Info {
|
||||
return provider.Info{
|
||||
Name: "claude-code",
|
||||
DisplayName: "Claude Code",
|
||||
Description: "Anthropic Claude Code CLI agent — builds full applications from natural language",
|
||||
Capabilities: []provider.Capability{
|
||||
{Name: "create-app", Description: "Create new applications from scratch"},
|
||||
{Name: "edit-code", Description: "Modify existing codebases"},
|
||||
{Name: "interactive", Description: "Supports follow-up messages during a session"},
|
||||
},
|
||||
ConfigSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string", "description": "Claude model to use", "default": ""},
|
||||
"max_turns": {"type": "integer", "description": "Maximum agentic turns", "default": 0}
|
||||
}
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) CreateSession(ctx context.Context, cfg provider.SessionConfig) (*provider.SessionHandle, error) {
|
||||
var opts *Config
|
||||
if len(cfg.Provider) > 0 {
|
||||
opts = &Config{}
|
||||
if err := json.Unmarshal(cfg.Provider, opts); err != nil {
|
||||
return nil, fmt.Errorf("parse claude-code config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
pod := buildPod(cfg, opts)
|
||||
created, err := p.client.CoreV1().Pods(Namespace).Create(ctx, pod, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pod: %w", err)
|
||||
}
|
||||
|
||||
return &provider.SessionHandle{
|
||||
SessionID: cfg.SessionID,
|
||||
PodName: created.Name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) StopSession(ctx context.Context, handle *provider.SessionHandle) error {
|
||||
return p.client.CoreV1().Pods(Namespace).Delete(ctx, handle.PodName, metav1.DeleteOptions{})
|
||||
}
|
||||
|
||||
func (p *Provider) SendMessage(ctx context.Context, handle *provider.SessionHandle, msg string) error {
|
||||
req := p.client.CoreV1().RESTClient().Post().
|
||||
Resource("pods").
|
||||
Name(handle.PodName).
|
||||
Namespace(Namespace).
|
||||
SubResource("attach").
|
||||
VersionedParams(&corev1.PodAttachOptions{
|
||||
Container: "claude",
|
||||
Stdin: true,
|
||||
Stdout: false,
|
||||
Stderr: false,
|
||||
}, scheme.ParameterCodec)
|
||||
|
||||
exec, err := remotecommand.NewSPDYExecutor(p.restConfig, "POST", req.URL())
|
||||
if err != nil {
|
||||
return fmt.Errorf("create attach executor: %w", err)
|
||||
}
|
||||
|
||||
return exec.StreamWithContext(ctx, remotecommand.StreamOptions{
|
||||
Stdin: bytes.NewReader([]byte(msg + "\n")),
|
||||
})
|
||||
}
|
||||
|
||||
func (p *Provider) StreamOutput(ctx context.Context, handle *provider.SessionHandle) (io.ReadCloser, error) {
|
||||
req := p.client.CoreV1().Pods(Namespace).GetLogs(handle.PodName, &corev1.PodLogOptions{
|
||||
Container: "claude",
|
||||
Follow: true,
|
||||
})
|
||||
return req.Stream(ctx)
|
||||
}
|
||||
|
||||
func (p *Provider) GetStatus(ctx context.Context, handle *provider.SessionHandle) (provider.Status, error) {
|
||||
pod, err := p.client.CoreV1().Pods(Namespace).Get(ctx, handle.PodName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return provider.StatusFailed, fmt.Errorf("get pod: %w", err)
|
||||
}
|
||||
|
||||
switch pod.Status.Phase {
|
||||
case corev1.PodPending:
|
||||
return provider.StatusPending, nil
|
||||
case corev1.PodRunning:
|
||||
return provider.StatusRunning, nil
|
||||
case corev1.PodSucceeded:
|
||||
return provider.StatusCompleted, nil
|
||||
case corev1.PodFailed:
|
||||
return provider.StatusFailed, nil
|
||||
default:
|
||||
return provider.StatusFailed, nil
|
||||
}
|
||||
}
|
||||
122
internal/provider/claudecode/pod.go
Normal file
122
internal/provider/claudecode/pod.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package claudecode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/agentsphere/agent-mgr/internal/provider"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
Namespace = "agent-mgr"
|
||||
RunnerImage = "git.asp.now/platform/claude-code-runner:latest"
|
||||
GitImage = "alpine/git:latest"
|
||||
)
|
||||
|
||||
func buildPod(cfg provider.SessionConfig, opts *Config) *corev1.Pod {
|
||||
podName := fmt.Sprintf("agent-%s", cfg.SessionID.String()[:8])
|
||||
branch := cfg.Branch
|
||||
|
||||
cloneScript := fmt.Sprintf(`
|
||||
set -eu
|
||||
git clone %s /workspace
|
||||
cd /workspace
|
||||
git checkout -b %s
|
||||
git config user.name "agent-mgr-bot"
|
||||
git config user.email "bot@asp.now"
|
||||
`, cfg.RepoClone, branch)
|
||||
|
||||
claudeArgs := []string{"--output-format", "stream-json", "--permission-mode", "auto-accept-only"}
|
||||
if opts != nil {
|
||||
if opts.Model != "" {
|
||||
claudeArgs = append(claudeArgs, "--model", opts.Model)
|
||||
}
|
||||
if opts.MaxTurns > 0 {
|
||||
claudeArgs = append(claudeArgs, "--max-turns", fmt.Sprintf("%d", opts.MaxTurns))
|
||||
}
|
||||
}
|
||||
|
||||
return &corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: podName,
|
||||
Namespace: Namespace,
|
||||
Labels: map[string]string{
|
||||
"app": "agent-session",
|
||||
"session-id": cfg.SessionID.String(),
|
||||
},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
RestartPolicy: corev1.RestartPolicyNever,
|
||||
ServiceAccountName: "agent-runner",
|
||||
InitContainers: []corev1.Container{
|
||||
{
|
||||
Name: "git-clone",
|
||||
Image: GitImage,
|
||||
Command: []string{"sh", "-c", cloneScript},
|
||||
VolumeMounts: []corev1.VolumeMount{
|
||||
{Name: "workspace", MountPath: "/workspace"},
|
||||
},
|
||||
Resources: corev1.ResourceRequirements{
|
||||
Requests: corev1.ResourceList{
|
||||
corev1.ResourceCPU: resource.MustParse("50m"),
|
||||
corev1.ResourceMemory: resource.MustParse("64Mi"),
|
||||
},
|
||||
Limits: corev1.ResourceList{
|
||||
corev1.ResourceCPU: resource.MustParse("200m"),
|
||||
corev1.ResourceMemory: resource.MustParse("128Mi"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "claude",
|
||||
Image: RunnerImage,
|
||||
Args: claudeArgs,
|
||||
Stdin: true,
|
||||
TTY: false,
|
||||
WorkingDir: "/workspace",
|
||||
Env: []corev1.EnvVar{
|
||||
{
|
||||
Name: "ANTHROPIC_API_KEY",
|
||||
ValueFrom: &corev1.EnvVarSource{
|
||||
SecretKeyRef: &corev1.SecretKeySelector{
|
||||
LocalObjectReference: corev1.LocalObjectReference{Name: "agent-mgr-secrets"},
|
||||
Key: "anthropic-api-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
{Name: "SESSION_ID", Value: cfg.SessionID.String()},
|
||||
},
|
||||
VolumeMounts: []corev1.VolumeMount{
|
||||
{Name: "workspace", MountPath: "/workspace"},
|
||||
},
|
||||
Resources: corev1.ResourceRequirements{
|
||||
Requests: corev1.ResourceList{
|
||||
corev1.ResourceCPU: resource.MustParse("200m"),
|
||||
corev1.ResourceMemory: resource.MustParse("256Mi"),
|
||||
},
|
||||
Limits: corev1.ResourceList{
|
||||
corev1.ResourceCPU: resource.MustParse("500m"),
|
||||
corev1.ResourceMemory: resource.MustParse("512Mi"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Volumes: []corev1.Volume{
|
||||
{
|
||||
Name: "workspace",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
EmptyDir: &corev1.EmptyDirVolumeSource{
|
||||
SizeLimit: resourcePtr(resource.MustParse("1Gi")),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func resourcePtr(q resource.Quantity) *resource.Quantity { return &q }
|
||||
75
internal/provider/claudecode/progress.go
Normal file
75
internal/provider/claudecode/progress.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package claudecode
|
||||
|
||||
import "strings"
|
||||
|
||||
type Milestone struct {
|
||||
Label string `json:"label"`
|
||||
Status string `json:"status"` // "done", "active", "pending"
|
||||
}
|
||||
|
||||
type ProgressTracker struct {
|
||||
filesSeen map[string]bool
|
||||
milestones []Milestone
|
||||
}
|
||||
|
||||
func NewProgressTracker() *ProgressTracker {
|
||||
return &ProgressTracker{
|
||||
filesSeen: make(map[string]bool),
|
||||
milestones: []Milestone{
|
||||
{Label: "Setting up project", Status: "active"},
|
||||
{Label: "Creating data models", Status: "pending"},
|
||||
{Label: "Building UI components", Status: "pending"},
|
||||
{Label: "Adding styling and polish", Status: "pending"},
|
||||
{Label: "Final checks", Status: "pending"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (pt *ProgressTracker) Milestones() []Milestone {
|
||||
cp := make([]Milestone, len(pt.milestones))
|
||||
copy(cp, pt.milestones)
|
||||
return cp
|
||||
}
|
||||
|
||||
func (pt *ProgressTracker) ProcessLine(line string) {
|
||||
lower := strings.ToLower(line)
|
||||
|
||||
if strings.Contains(lower, "\"tool\":\"write\"") || strings.Contains(lower, "\"tool\":\"create\"") {
|
||||
pt.advanceTo(0)
|
||||
}
|
||||
|
||||
if containsAny(lower, "model", "schema", "database", "migration", "struct", "type ") {
|
||||
pt.advanceTo(1)
|
||||
}
|
||||
|
||||
if containsAny(lower, "component", "page", "route", "template", "html", "tsx", "jsx", "vue") {
|
||||
pt.advanceTo(2)
|
||||
}
|
||||
|
||||
if containsAny(lower, "css", "style", "tailwind", "theme", "color", "font", "layout") {
|
||||
pt.advanceTo(3)
|
||||
}
|
||||
|
||||
if containsAny(lower, "test", "readme", "done", "complete", "finish", "final") {
|
||||
pt.advanceTo(4)
|
||||
}
|
||||
}
|
||||
|
||||
func (pt *ProgressTracker) advanceTo(idx int) {
|
||||
for i := range pt.milestones {
|
||||
if i < idx {
|
||||
pt.milestones[i].Status = "done"
|
||||
} else if i == idx {
|
||||
pt.milestones[i].Status = "active"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsAny(s string, substrs ...string) bool {
|
||||
for _, sub := range substrs {
|
||||
if strings.Contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user