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
|
||||
}
|
||||
55
internal/provider/provider.go
Normal file
55
internal/provider/provider.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPending Status = "pending"
|
||||
StatusRunning Status = "running"
|
||||
StatusCompleted Status = "completed"
|
||||
StatusFailed Status = "failed"
|
||||
StatusStopped Status = "stopped"
|
||||
)
|
||||
|
||||
type SessionConfig struct {
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
AppName string `json:"app_name"`
|
||||
RepoClone string `json:"repo_clone_url"`
|
||||
Branch string `json:"branch"`
|
||||
Prompt string `json:"prompt"`
|
||||
Provider json.RawMessage `json:"provider_config,omitempty"`
|
||||
}
|
||||
|
||||
type SessionHandle struct {
|
||||
SessionID uuid.UUID
|
||||
PodName string
|
||||
}
|
||||
|
||||
type Capability struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type Info struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
Capabilities []Capability `json:"capabilities"`
|
||||
ConfigSchema json.RawMessage `json:"config_schema,omitempty"`
|
||||
}
|
||||
|
||||
type AgentProvider interface {
|
||||
Info() Info
|
||||
CreateSession(ctx context.Context, cfg SessionConfig) (*SessionHandle, error)
|
||||
StopSession(ctx context.Context, handle *SessionHandle) error
|
||||
SendMessage(ctx context.Context, handle *SessionHandle, msg string) error
|
||||
StreamOutput(ctx context.Context, handle *SessionHandle) (io.ReadCloser, error)
|
||||
GetStatus(ctx context.Context, handle *SessionHandle) (Status, error)
|
||||
}
|
||||
31
internal/provider/registry.go
Normal file
31
internal/provider/registry.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package provider
|
||||
|
||||
import "fmt"
|
||||
|
||||
type Registry struct {
|
||||
providers map[string]AgentProvider
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{providers: make(map[string]AgentProvider)}
|
||||
}
|
||||
|
||||
func (r *Registry) Register(p AgentProvider) {
|
||||
r.providers[p.Info().Name] = p
|
||||
}
|
||||
|
||||
func (r *Registry) Get(name string) (AgentProvider, error) {
|
||||
p, ok := r.providers[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (r *Registry) List() []Info {
|
||||
infos := make([]Info, 0, len(r.providers))
|
||||
for _, p := range r.providers {
|
||||
infos = append(infos, p.Info())
|
||||
}
|
||||
return infos
|
||||
}
|
||||
Reference in New Issue
Block a user