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:
Steven Hooker
2026-02-18 15:56:32 +01:00
commit e5b07cc1d8
39 changed files with 3120 additions and 0 deletions

View 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
}
}

View 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 }

View 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
}

View 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)
}

View 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
}