Add Gitea, Cloudflare DNS, and Kubernetes deployer adapters following hexagonal architecture. These enable automated project provisioning: - Git repository creation/management via Gitea - DNS record management via Cloudflare - Container deployment to Kubernetes Includes domain models, ports, handlers, and Woodpecker CI webhook integration for automated deployments on push. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1074 lines
35 KiB
Go
1074 lines
35 KiB
Go
// Package main provides the entry point for the rdev API server.
|
|
//
|
|
// rdev (Remote Developer) provides a REST API for controlling Claude Code
|
|
// instances running in Kubernetes pods. External clients (Discord bots,
|
|
// CLI tools, etc.) connect via this API.
|
|
//
|
|
// Authentication:
|
|
// - All endpoints (except /health, /ready, /docs) require X-API-Key header
|
|
// - Admin key from RDEV_ADMIN_KEY env var for key management
|
|
// - Create additional keys via POST /keys
|
|
//
|
|
// Endpoints:
|
|
// - GET /health - Health check (no auth)
|
|
// - GET /ready - Readiness check (no auth)
|
|
// - GET /docs - Scalar API documentation (no auth)
|
|
// - GET /openapi.json - OpenAPI 3.0 specification (no auth)
|
|
// - GET /keys - List API keys
|
|
// - POST /keys - Create API key
|
|
// - GET /keys/{id} - Get API key details
|
|
// - DELETE /keys/{id} - Revoke API key
|
|
// - GET /projects - List available projects
|
|
// - GET /projects/{id} - Get project details
|
|
// - POST /projects/{id}/claude - Run Claude command
|
|
// - POST /projects/{id}/shell - Run shell command
|
|
// - POST /projects/{id}/git - Run git command
|
|
// - GET /projects/{id}/events - SSE stream for output
|
|
// - GET /projects/{id}/claude-config - List commands/skills/agents
|
|
// - GET /projects/{id}/claude-config/commands - List commands
|
|
// - POST /projects/{id}/claude-config/commands - Create command
|
|
// - GET /projects/{id}/claude-config/commands/{name} - Get command
|
|
// - PUT /projects/{id}/claude-config/commands/{name} - Update command
|
|
// - DELETE /projects/{id}/claude-config/commands/{name} - Delete command
|
|
// (same pattern for /skills and /agents)
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/orchard9/rdev/internal/adapter/cloudflare"
|
|
"github.com/orchard9/rdev/internal/adapter/deployer"
|
|
"github.com/orchard9/rdev/internal/adapter/gitea"
|
|
"github.com/orchard9/rdev/internal/adapter/kubernetes"
|
|
"github.com/orchard9/rdev/internal/adapter/memory"
|
|
"github.com/orchard9/rdev/internal/adapter/postgres"
|
|
"github.com/orchard9/rdev/internal/auth"
|
|
"github.com/orchard9/rdev/internal/db"
|
|
"github.com/orchard9/rdev/internal/handlers"
|
|
"github.com/orchard9/rdev/internal/metrics"
|
|
"github.com/orchard9/rdev/internal/middleware"
|
|
"github.com/orchard9/rdev/internal/service"
|
|
"github.com/orchard9/rdev/internal/telemetry"
|
|
"github.com/orchard9/rdev/internal/webhook"
|
|
"github.com/orchard9/rdev/internal/worker"
|
|
"github.com/orchard9/rdev/pkg/api"
|
|
)
|
|
|
|
func main() {
|
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
|
Level: slog.LevelInfo,
|
|
}))
|
|
|
|
// Initialize telemetry (OpenTelemetry)
|
|
telCfg := telemetry.DefaultConfig()
|
|
telCfg.Logger = logger
|
|
tel, err := telemetry.New(context.Background(), telCfg)
|
|
if err != nil {
|
|
logger.Error("failed to initialize telemetry", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Load configuration from environment
|
|
cfg := loadConfig()
|
|
|
|
// Initialize database with auto-migrations
|
|
database, err := db.New(db.Config{
|
|
Host: cfg.DBHost,
|
|
Port: cfg.DBPort,
|
|
User: cfg.DBUser,
|
|
Password: cfg.DBPassword,
|
|
Database: cfg.DBName,
|
|
SSLMode: cfg.DBSSLMode,
|
|
}, logger)
|
|
if err != nil {
|
|
logger.Error("failed to connect to database", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() { _ = database.Close() }()
|
|
|
|
// Initialize auth service
|
|
authService := auth.NewService(database.DB, cfg.AdminKey)
|
|
|
|
// Create adapters (dependency injection)
|
|
namespace := getEnv("K8S_NAMESPACE", "rdev")
|
|
|
|
// Initialize K8s client for dynamic project discovery
|
|
// Falls back gracefully if K8s is unavailable (e.g., local development)
|
|
k8sClient := kubernetes.NewClientOrNil(kubernetes.ClientConfig{
|
|
Namespace: namespace,
|
|
Kubeconfig: os.Getenv("KUBECONFIG"),
|
|
})
|
|
if k8sClient != nil {
|
|
logger.Info("k8s client initialized, dynamic project discovery enabled")
|
|
} else {
|
|
logger.Warn("k8s client unavailable, using hardcoded fallback projects")
|
|
}
|
|
|
|
projectRepo := kubernetes.NewProjectRepositoryWithClient(namespace, k8sClient, logger)
|
|
k8sExecutor := kubernetes.NewExecutor(namespace)
|
|
streamPub := memory.NewStreamPublisher()
|
|
|
|
// Start watching for project pod changes if K8s client is available
|
|
if k8sClient != nil {
|
|
if err := projectRepo.StartWatching(context.Background()); err != nil {
|
|
logger.Warn("failed to start project watcher", "error", err)
|
|
}
|
|
}
|
|
|
|
// Initialize audit logger
|
|
auditLogger := postgres.NewAuditLogger(database.DB)
|
|
|
|
// Initialize rate limiter
|
|
rateLimiter := postgres.NewRateLimiter(database.DB)
|
|
stopRateLimitCleanup := rateLimiter.StartCleanupWorker(context.Background(), 5*time.Minute)
|
|
|
|
// Initialize command queue
|
|
commandQueue := postgres.NewCommandQueueRepository(database.DB)
|
|
|
|
// Initialize webhook repository and dispatcher
|
|
webhookRepo := postgres.NewWebhookRepository(database.DB)
|
|
webhookDispatcher := webhook.NewDispatcher(webhookRepo, &webhook.DispatcherConfig{
|
|
WorkerCount: 10,
|
|
MaxRetries: 3,
|
|
Timeout: 30 * time.Second,
|
|
RetryBackoff: 5 * time.Second,
|
|
Logger: logger,
|
|
})
|
|
if err := webhookDispatcher.Start(); err != nil {
|
|
logger.Error("failed to start webhook dispatcher", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Initialize infrastructure adapters (optional - only if configured)
|
|
var giteaClient *gitea.Client
|
|
if cfg.GiteaToken != "" && cfg.GiteaURL != "" {
|
|
var err error
|
|
giteaClient, err = gitea.NewClient(cfg.GiteaURL, cfg.GiteaToken, cfg.GiteaDefaultOrg)
|
|
if err != nil {
|
|
logger.Warn("failed to initialize gitea client", "error", err)
|
|
} else {
|
|
logger.Info("gitea client initialized", "url", cfg.GiteaURL, "org", cfg.GiteaDefaultOrg)
|
|
}
|
|
}
|
|
|
|
var dnsClient *cloudflare.Client
|
|
if cfg.CloudflareToken != "" && cfg.CloudflareZoneID != "" {
|
|
dnsClient = cloudflare.NewClient(cfg.CloudflareToken, cfg.CloudflareZoneID, cfg.DefaultDomain)
|
|
logger.Info("cloudflare DNS client initialized", "domain", cfg.DefaultDomain)
|
|
}
|
|
|
|
var deployerAdapter *deployer.Deployer
|
|
if k8sClient != nil {
|
|
deployerAdapter = deployer.NewDeployer(k8sClient, deployer.Config{
|
|
Namespace: cfg.DeployNamespace,
|
|
IngressClass: "traefik",
|
|
TLSIssuer: cfg.DeployTLSIssuer,
|
|
DefaultDomain: cfg.DefaultDomain,
|
|
DefaultReplicas: 1,
|
|
})
|
|
logger.Info("deployer initialized", "namespace", cfg.DeployNamespace)
|
|
}
|
|
|
|
// Create services
|
|
projectService := service.NewProjectService(projectRepo, k8sExecutor, streamPub).
|
|
WithAuditLogger(auditLogger).
|
|
WithCommandQueue(commandQueue).
|
|
WithWebhookDispatcher(webhookDispatcher)
|
|
|
|
// Create app
|
|
app := api.New("rdev-api",
|
|
api.WithPort(cfg.Port),
|
|
api.WithLogger(logger),
|
|
)
|
|
|
|
// Add telemetry middleware (first to capture all requests)
|
|
app.Use(telemetry.Middleware(telCfg.ServiceName))
|
|
|
|
// Add metrics middleware (before auth to track all requests)
|
|
app.Use(metrics.Middleware)
|
|
|
|
// Add auth middleware (skips /health, /ready, /docs, /openapi.json, /metrics)
|
|
app.Use(auth.Middleware(authService))
|
|
|
|
// Add rate limiting middleware (after auth, so we have API key context)
|
|
rateLimitCfg := middleware.DefaultRateLimitConfig()
|
|
rateLimitCfg.Limiter = rateLimiter
|
|
app.Use(middleware.RateLimitMiddleware(rateLimitCfg))
|
|
|
|
// Register metrics endpoint (no auth required)
|
|
app.Router().Handle("/metrics", metrics.Handler())
|
|
|
|
// Initialize handlers
|
|
projectsHandler := handlers.NewProjectsHandlerWithService(projectService)
|
|
keysHandler := handlers.NewKeysHandler(authService)
|
|
claudeConfigHandler := handlers.NewClaudeConfigHandlerWithService(projectService, projectRepo, k8sExecutor)
|
|
auditHandler := handlers.NewAuditHandler(auditLogger)
|
|
queueHandler := handlers.NewQueueHandler(commandQueue, projectRepo)
|
|
webhookHandler := handlers.NewWebhookHandler(webhookRepo, projectRepo)
|
|
|
|
// Initialize infrastructure handler (for threesix.ai git/deploy/dns)
|
|
infraHandler := handlers.NewInfrastructureHandler(
|
|
giteaClient,
|
|
dnsClient,
|
|
deployerAdapter,
|
|
projectRepo,
|
|
handlers.InfrastructureConfig{
|
|
DefaultGitOwner: cfg.GiteaDefaultOrg,
|
|
DefaultDomain: cfg.DefaultDomain,
|
|
},
|
|
)
|
|
|
|
// Initialize project infrastructure service (orchestrates full project lifecycle)
|
|
projectInfraService := service.NewProjectInfraService(
|
|
database.DB,
|
|
giteaClient,
|
|
dnsClient,
|
|
deployerAdapter,
|
|
service.ProjectInfraConfig{
|
|
DefaultGitOwner: cfg.GiteaDefaultOrg,
|
|
DefaultDomain: cfg.DefaultDomain,
|
|
ClusterIP: cfg.ClusterIP,
|
|
Logger: logger,
|
|
},
|
|
)
|
|
|
|
// Initialize project management handler
|
|
projectMgmtHandler := handlers.NewProjectManagementHandler(projectInfraService)
|
|
|
|
// Initialize Woodpecker webhook handler (for CI/CD auto-deploy)
|
|
woodpeckerHandler := handlers.NewWoodpeckerWebhookHandler(
|
|
deployerAdapter,
|
|
dnsClient,
|
|
handlers.WoodpeckerWebhookConfig{
|
|
WebhookSecret: cfg.WoodpeckerWebhookSecret,
|
|
DefaultDomain: cfg.DefaultDomain,
|
|
RegistryURL: cfg.RegistryURL,
|
|
ClusterIP: cfg.ClusterIP,
|
|
Logger: logger,
|
|
},
|
|
)
|
|
|
|
// Register routes
|
|
projectsHandler.Mount(app.Router())
|
|
keysHandler.Mount(app.Router())
|
|
claudeConfigHandler.Mount(app.Router())
|
|
auditHandler.Mount(app.Router())
|
|
queueHandler.Mount(app.Router())
|
|
webhookHandler.Mount(app.Router())
|
|
infraHandler.Mount(app.Router())
|
|
projectMgmtHandler.Mount(app.Router())
|
|
woodpeckerHandler.Mount(app.Router())
|
|
|
|
// Start queue processor worker
|
|
queueProcessor := worker.NewQueueProcessor(
|
|
commandQueue,
|
|
k8sExecutor,
|
|
projectRepo,
|
|
streamPub,
|
|
&worker.QueueProcessorConfig{
|
|
PollPeriod: 5 * time.Second,
|
|
Logger: logger,
|
|
},
|
|
).WithWebhookDispatcher(webhookDispatcher)
|
|
if err := queueProcessor.Start(); err != nil {
|
|
logger.Error("failed to start queue processor", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Enable API documentation
|
|
app.EnableDocs(buildOpenAPISpec())
|
|
|
|
// Cleanup on shutdown
|
|
app.OnShutdown(func(ctx context.Context) error {
|
|
// Stop queue processor
|
|
queueProcessor.Stop()
|
|
|
|
// Stop webhook dispatcher
|
|
webhookDispatcher.Stop()
|
|
|
|
// Stop project watcher
|
|
projectRepo.StopWatching()
|
|
|
|
// Stop rate limit cleanup worker
|
|
stopRateLimitCleanup()
|
|
|
|
// Shutdown telemetry (flush pending traces)
|
|
if err := tel.Shutdown(ctx); err != nil {
|
|
logger.Error("telemetry shutdown error", "error", err)
|
|
}
|
|
|
|
return database.Close()
|
|
})
|
|
|
|
logger.Info("rdev-api starting",
|
|
"port", cfg.Port,
|
|
"db_host", cfg.DBHost,
|
|
"admin_key_set", cfg.AdminKey != "",
|
|
)
|
|
|
|
app.Run()
|
|
}
|
|
|
|
// Config holds application configuration.
|
|
type Config struct {
|
|
Port int
|
|
DBHost string
|
|
DBPort int
|
|
DBUser string
|
|
DBPassword string
|
|
DBName string
|
|
DBSSLMode string
|
|
AdminKey string
|
|
|
|
// Infrastructure adapters (threesix.ai)
|
|
GiteaURL string
|
|
GiteaToken string
|
|
GiteaDefaultOrg string
|
|
CloudflareToken string
|
|
CloudflareZoneID string
|
|
DefaultDomain string
|
|
DeployNamespace string
|
|
DeployTLSIssuer string
|
|
ClusterIP string
|
|
RegistryURL string
|
|
WoodpeckerWebhookSecret string
|
|
}
|
|
|
|
func loadConfig() Config {
|
|
port := 8080
|
|
if v := os.Getenv("PORT"); v != "" {
|
|
if p, err := strconv.Atoi(v); err == nil {
|
|
port = p
|
|
}
|
|
}
|
|
|
|
dbPort := 5432
|
|
if v := os.Getenv("DB_PORT"); v != "" {
|
|
if p, err := strconv.Atoi(v); err == nil {
|
|
dbPort = p
|
|
}
|
|
}
|
|
|
|
return Config{
|
|
Port: port,
|
|
DBHost: getEnv("DB_HOST", "postgres.databases.svc"),
|
|
DBPort: dbPort,
|
|
DBUser: getEnv("DB_USER", "appuser"),
|
|
DBPassword: os.Getenv("DB_PASSWORD"),
|
|
DBName: getEnv("DB_NAME", "rdev"),
|
|
DBSSLMode: getEnv("DB_SSL_MODE", "disable"),
|
|
AdminKey: os.Getenv("RDEV_ADMIN_KEY"),
|
|
|
|
// Infrastructure adapters
|
|
GiteaURL: getEnv("GITEA_URL", "https://git.threesix.ai"),
|
|
GiteaToken: os.Getenv("GITEA_TOKEN"),
|
|
GiteaDefaultOrg: getEnv("GITEA_DEFAULT_ORG", "threesix"),
|
|
CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"),
|
|
CloudflareZoneID: os.Getenv("CLOUDFLARE_ZONE_ID"),
|
|
DefaultDomain: getEnv("DEFAULT_DOMAIN", "threesix.ai"),
|
|
DeployNamespace: getEnv("DEPLOY_NAMESPACE", "projects"),
|
|
DeployTLSIssuer: getEnv("DEPLOY_TLS_ISSUER", "letsencrypt-threesix"),
|
|
ClusterIP: getEnv("CLUSTER_IP", "208.122.204.172"),
|
|
RegistryURL: getEnv("REGISTRY_URL", "zot.threesix.svc.cluster.local:5000"),
|
|
WoodpeckerWebhookSecret: os.Getenv("WOODPECKER_WEBHOOK_SECRET"),
|
|
}
|
|
}
|
|
|
|
func getEnv(key, defaultVal string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return defaultVal
|
|
}
|
|
|
|
// buildOpenAPISpec creates the OpenAPI specification for the rdev API.
|
|
func buildOpenAPISpec() *api.OpenAPISpec {
|
|
spec := api.NewOpenAPISpec("rdev API", "0.5.0").
|
|
WithDescription(`Remote Developer API for controlling Claude Code instances in Kubernetes.
|
|
|
|
rdev runs Claude Code CLI in isolated pods, controlled via this REST API with SSE streaming.
|
|
External clients (Discord bots, Slack bots, CLI tools) connect here to interact with projects.
|
|
|
|
## Authentication
|
|
|
|
All endpoints except /health, /ready, and /docs require authentication via API key.
|
|
|
|
**Header**: `+"`X-API-Key: rdev_sk_xxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`"+`
|
|
|
|
Or: `+"`Authorization: Bearer rdev_sk_...`"+`
|
|
|
|
### Getting Started
|
|
|
|
1. Set RDEV_ADMIN_KEY environment variable with your super admin key
|
|
2. Use the admin key to create additional keys via POST /keys
|
|
3. Use created keys for normal operations
|
|
|
|
### Scopes
|
|
|
|
| Scope | Description |
|
|
|-------|-------------|
|
|
| projects:read | List and view projects |
|
|
| projects:execute | Run commands (claude, shell, git) |
|
|
| keys:read | List API keys (metadata only) |
|
|
| keys:write | Create and revoke keys |
|
|
| audit:read | View audit logs for command executions |
|
|
| admin | Full access (all scopes) |
|
|
|
|
## Architecture
|
|
|
|
- **rdev-api**: This Go service
|
|
- **claudebox pods**: Isolated pods running Claude Code CLI
|
|
- **postgres**: API key storage (auto-migrating)
|
|
|
|
## Streaming
|
|
|
|
Command output is streamed via Server-Sent Events (SSE) at /projects/{id}/events.
|
|
`).
|
|
WithServer("http://localhost:8080", "Local development").
|
|
WithServer("http://rdev-api.rdev.svc:8080", "Kubernetes internal")
|
|
|
|
// Tags
|
|
spec.WithTag("Authentication", "API key management")
|
|
spec.WithTag("Projects", "Project management and discovery")
|
|
spec.WithTag("Commands", "Command execution (claude, shell, git)")
|
|
spec.WithTag("Events", "Server-Sent Events for real-time output")
|
|
spec.WithTag("Claude Config", "Manage commands, skills, and agents in /workspace/.claude/")
|
|
spec.WithTag("Audit", "Command execution audit logs")
|
|
spec.WithTag("System", "Health and readiness endpoints")
|
|
|
|
// System endpoints
|
|
spec.AddPath("/health", "get", api.Op(
|
|
"Health check",
|
|
"Returns health status. No authentication required.",
|
|
"System",
|
|
))
|
|
spec.AddPath("/ready", "get", api.Op(
|
|
"Readiness check",
|
|
"Returns readiness status. No authentication required.",
|
|
"System",
|
|
))
|
|
|
|
// Key management endpoints
|
|
spec.AddPath("/keys", "get", withAuth(
|
|
"List API keys",
|
|
"Returns all API keys with metadata (not secrets). Requires keys:read scope.",
|
|
"Authentication",
|
|
"keys:read",
|
|
`[
|
|
{
|
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
"name": "discord-bot",
|
|
"key_prefix": "a1b2c3d4",
|
|
"scopes": ["projects:read", "projects:execute"],
|
|
"created_at": "2026-01-24T20:00:00Z",
|
|
"active": true
|
|
}
|
|
]`,
|
|
))
|
|
|
|
spec.AddPath("/keys", "post", withAuthAndBody(
|
|
"Create API key",
|
|
`Creates a new API key. The secret is returned only once - save it securely!
|
|
|
|
**Expiration options**: 30d, 60d, 90d, 1y, never (default: never)`,
|
|
"Authentication",
|
|
"keys:write",
|
|
`{
|
|
"name": "discord-bot",
|
|
"scopes": ["projects:read", "projects:execute"],
|
|
"project_ids": ["pantheon"],
|
|
"expires_in": "90d"
|
|
}`,
|
|
`{
|
|
"key": {
|
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
"name": "discord-bot",
|
|
"key_prefix": "a1b2c3d4",
|
|
"scopes": ["projects:read", "projects:execute"],
|
|
"project_ids": ["pantheon"],
|
|
"created_at": "2026-01-24T20:00:00Z",
|
|
"expires_at": "2026-04-24T20:00:00Z",
|
|
"active": true
|
|
},
|
|
"secret": "rdev_sk_a1b2c3d4_e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
|
|
}`,
|
|
))
|
|
|
|
spec.AddPath("/keys/{id}", "get", withAuthAndParams(
|
|
"Get API key",
|
|
"Returns details for a specific API key. Requires keys:read scope.",
|
|
"Authentication",
|
|
"keys:read",
|
|
[]param{{Name: "id", In: "path", Description: "Key ID (UUID)", Required: true}},
|
|
))
|
|
|
|
spec.AddPath("/keys/{id}", "delete", withAuthAndParams(
|
|
"Revoke API key",
|
|
"Revokes an API key immediately. The key cannot be used after revocation. Requires keys:write scope.",
|
|
"Authentication",
|
|
"keys:write",
|
|
[]param{{Name: "id", In: "path", Description: "Key ID (UUID)", Required: true}},
|
|
))
|
|
|
|
// Projects - List and Get
|
|
spec.AddPath("/projects", "get", withAuth(
|
|
"List projects",
|
|
"Returns all available projects (claudebox pods). Requires projects:read scope.",
|
|
"Projects",
|
|
"projects:read",
|
|
`[
|
|
{
|
|
"id": "pantheon",
|
|
"name": "Pantheon",
|
|
"description": "Go API backend",
|
|
"pod": "claudebox-pantheon-0",
|
|
"status": "running"
|
|
}
|
|
]`,
|
|
))
|
|
|
|
spec.AddPath("/projects/{id}", "get", withAuthAndParams(
|
|
"Get project",
|
|
"Returns details for a specific project. Requires projects:read scope.",
|
|
"Projects",
|
|
"projects:read",
|
|
[]param{{Name: "id", In: "path", Description: "Project ID (e.g., 'pantheon')", Required: true}},
|
|
))
|
|
|
|
// Commands
|
|
spec.AddPath("/projects/{id}/claude", "post", withAuthBodyAndParams(
|
|
"Run Claude command",
|
|
"Executes a Claude Code prompt in the project's claudebox pod. Requires projects:execute scope.",
|
|
"Commands",
|
|
"projects:execute",
|
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
`{
|
|
"prompt": "fix the bug in auth handler",
|
|
"stream_id": "optional-correlation-id"
|
|
}`,
|
|
`{
|
|
"id": "cmd-pantheon-001",
|
|
"project": "pantheon",
|
|
"type": "claude",
|
|
"status": "queued",
|
|
"stream_url": "/projects/pantheon/events?stream_id=cmd-pantheon-001"
|
|
}`,
|
|
))
|
|
|
|
spec.AddPath("/projects/{id}/shell", "post", withAuthBodyAndParams(
|
|
"Run shell command",
|
|
"Executes a shell command in the project's claudebox pod. Requires projects:execute scope.",
|
|
"Commands",
|
|
"projects:execute",
|
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
`{
|
|
"command": "go test ./...",
|
|
"stream_id": "optional-correlation-id"
|
|
}`,
|
|
`{
|
|
"id": "cmd-pantheon-002",
|
|
"project": "pantheon",
|
|
"type": "shell",
|
|
"status": "queued",
|
|
"stream_url": "/projects/pantheon/events?stream_id=cmd-pantheon-002"
|
|
}`,
|
|
))
|
|
|
|
spec.AddPath("/projects/{id}/git", "post", withAuthBodyAndParams(
|
|
"Run git command",
|
|
"Executes a git command in the project's claudebox pod. Requires projects:execute scope.",
|
|
"Commands",
|
|
"projects:execute",
|
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
`{
|
|
"args": ["status"],
|
|
"stream_id": "optional-correlation-id"
|
|
}`,
|
|
`{
|
|
"id": "cmd-pantheon-003",
|
|
"project": "pantheon",
|
|
"type": "git",
|
|
"status": "queued",
|
|
"stream_url": "/projects/pantheon/events?stream_id=cmd-pantheon-003"
|
|
}`,
|
|
))
|
|
|
|
// SSE Events
|
|
spec.AddPath("/projects/{id}/events", "get", map[string]any{
|
|
"summary": "Stream events",
|
|
"description": `Server-Sent Events stream for real-time command output.
|
|
|
|
Requires projects:read scope.
|
|
|
|
## Event Types
|
|
|
|
- **connected**: Initial connection confirmation
|
|
- **output**: Command output (stdout or stderr)
|
|
- **error**: Error occurred during execution
|
|
- **complete**: Command finished (includes exit_code and duration_ms)
|
|
- **heartbeat**: Keep-alive (sent every 30s)
|
|
|
|
## Example
|
|
|
|
` + "```javascript" + `
|
|
const events = new EventSource('/projects/pantheon/events?stream_id=cmd-001', {
|
|
headers: { 'X-API-Key': 'rdev_sk_...' }
|
|
});
|
|
|
|
events.addEventListener('output', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
console.log(data.line);
|
|
});
|
|
|
|
events.addEventListener('complete', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
console.log('Done:', data.exit_code);
|
|
events.close();
|
|
});
|
|
` + "```",
|
|
"tags": []string{"Events"},
|
|
"security": []map[string]any{
|
|
{"ApiKeyAuth": []string{}},
|
|
},
|
|
"parameters": []map[string]any{
|
|
{
|
|
"name": "id",
|
|
"in": "path",
|
|
"description": "Project ID",
|
|
"required": true,
|
|
"schema": map[string]any{"type": "string"},
|
|
},
|
|
{
|
|
"name": "stream_id",
|
|
"in": "query",
|
|
"description": "Command ID to filter events (optional)",
|
|
"required": false,
|
|
"schema": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
"responses": map[string]any{
|
|
"200": map[string]any{
|
|
"description": "SSE stream",
|
|
"content": map[string]any{
|
|
"text/event-stream": map[string]any{
|
|
"schema": map[string]any{
|
|
"type": "string",
|
|
"example": "event: output\ndata: {\"line\": \"Building...\", \"stream\": \"stdout\"}\n\n",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Claude Config - Overview
|
|
spec.AddPath("/projects/{id}/claude-config", "get", withAuthAndParams(
|
|
"Get config overview",
|
|
`Returns an overview of the project's Claude config (/workspace/.claude/).
|
|
|
|
Lists available commands, skills, and agents. Requires projects:read scope.`,
|
|
"Claude Config",
|
|
"projects:read",
|
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
))
|
|
|
|
// Claude Config - Commands
|
|
spec.AddPath("/projects/{id}/claude-config/commands", "get", withAuthAndParams(
|
|
"List commands",
|
|
"Lists all custom commands in /workspace/.claude/commands/. Requires projects:read scope.",
|
|
"Claude Config",
|
|
"projects:read",
|
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
))
|
|
|
|
spec.AddPath("/projects/{id}/claude-config/commands", "post", withAuthBodyAndParams(
|
|
"Create command",
|
|
`Creates a new custom command in /workspace/.claude/commands/{name}.md.
|
|
|
|
Commands are markdown files with frontmatter. Requires projects:execute scope.`,
|
|
"Claude Config",
|
|
"projects:execute",
|
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
`{
|
|
"name": "deploy",
|
|
"content": "---\ndescription: Deploy to production\n---\n\nRun the deployment..."
|
|
}`,
|
|
`{
|
|
"name": "deploy",
|
|
"type": "commands",
|
|
"content": "---\ndescription: Deploy to production\n---\n\nRun the deployment..."
|
|
}`,
|
|
))
|
|
|
|
spec.AddPath("/projects/{id}/claude-config/commands/{name}", "get", withAuthAndParams(
|
|
"Get command",
|
|
"Returns a specific command's content. Requires projects:read scope.",
|
|
"Claude Config",
|
|
"projects:read",
|
|
[]param{
|
|
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
{Name: "name", In: "path", Description: "Command name", Required: true},
|
|
},
|
|
))
|
|
|
|
spec.AddPath("/projects/{id}/claude-config/commands/{name}", "put", withAuthBodyAndParams(
|
|
"Update command",
|
|
"Updates a command's content. Requires projects:execute scope.",
|
|
"Claude Config",
|
|
"projects:execute",
|
|
[]param{
|
|
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
{Name: "name", In: "path", Description: "Command name", Required: true},
|
|
},
|
|
`{
|
|
"content": "---\ndescription: Updated description\n---\n\nUpdated content..."
|
|
}`,
|
|
`{
|
|
"name": "deploy",
|
|
"type": "commands",
|
|
"content": "---\ndescription: Updated description\n---\n\nUpdated content..."
|
|
}`,
|
|
))
|
|
|
|
spec.AddPath("/projects/{id}/claude-config/commands/{name}", "delete", withAuthAndParams(
|
|
"Delete command",
|
|
"Deletes a command. Requires projects:execute scope.",
|
|
"Claude Config",
|
|
"projects:execute",
|
|
[]param{
|
|
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
{Name: "name", In: "path", Description: "Command name", Required: true},
|
|
},
|
|
))
|
|
|
|
// Claude Config - Skills (same pattern as commands)
|
|
spec.AddPath("/projects/{id}/claude-config/skills", "get", withAuthAndParams(
|
|
"List skills",
|
|
"Lists all skills in /workspace/.claude/skills/. Requires projects:read scope.",
|
|
"Claude Config",
|
|
"projects:read",
|
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
))
|
|
|
|
spec.AddPath("/projects/{id}/claude-config/skills", "post", withAuthBodyAndParams(
|
|
"Create skill",
|
|
"Creates a new skill. Requires projects:execute scope.",
|
|
"Claude Config",
|
|
"projects:execute",
|
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
`{"name": "go-testing", "content": "# Go Testing Skill\n\n..."}`,
|
|
`{"name": "go-testing", "type": "skills", "content": "# Go Testing Skill\n\n..."}`,
|
|
))
|
|
|
|
spec.AddPath("/projects/{id}/claude-config/skills/{name}", "get", withAuthAndParams(
|
|
"Get skill",
|
|
"Returns a specific skill's content. Requires projects:read scope.",
|
|
"Claude Config",
|
|
"projects:read",
|
|
[]param{
|
|
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
{Name: "name", In: "path", Description: "Skill name", Required: true},
|
|
},
|
|
))
|
|
|
|
spec.AddPath("/projects/{id}/claude-config/skills/{name}", "put", withAuthBodyAndParams(
|
|
"Update skill",
|
|
"Updates a skill's content. Requires projects:execute scope.",
|
|
"Claude Config",
|
|
"projects:execute",
|
|
[]param{
|
|
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
{Name: "name", In: "path", Description: "Skill name", Required: true},
|
|
},
|
|
`{"content": "# Updated Skill\n\n..."}`,
|
|
`{"name": "go-testing", "type": "skills", "content": "# Updated Skill\n\n..."}`,
|
|
))
|
|
|
|
spec.AddPath("/projects/{id}/claude-config/skills/{name}", "delete", withAuthAndParams(
|
|
"Delete skill",
|
|
"Deletes a skill. Requires projects:execute scope.",
|
|
"Claude Config",
|
|
"projects:execute",
|
|
[]param{
|
|
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
{Name: "name", In: "path", Description: "Skill name", Required: true},
|
|
},
|
|
))
|
|
|
|
// Claude Config - Agents (same pattern)
|
|
spec.AddPath("/projects/{id}/claude-config/agents", "get", withAuthAndParams(
|
|
"List agents",
|
|
"Lists all agents in /workspace/.claude/agents/. Requires projects:read scope.",
|
|
"Claude Config",
|
|
"projects:read",
|
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
))
|
|
|
|
spec.AddPath("/projects/{id}/claude-config/agents", "post", withAuthBodyAndParams(
|
|
"Create agent",
|
|
"Creates a new agent. Requires projects:execute scope.",
|
|
"Claude Config",
|
|
"projects:execute",
|
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
`{"name": "code-reviewer", "content": "# Code Reviewer Agent\n\n..."}`,
|
|
`{"name": "code-reviewer", "type": "agents", "content": "# Code Reviewer Agent\n\n..."}`,
|
|
))
|
|
|
|
spec.AddPath("/projects/{id}/claude-config/agents/{name}", "get", withAuthAndParams(
|
|
"Get agent",
|
|
"Returns a specific agent's content. Requires projects:read scope.",
|
|
"Claude Config",
|
|
"projects:read",
|
|
[]param{
|
|
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
{Name: "name", In: "path", Description: "Agent name", Required: true},
|
|
},
|
|
))
|
|
|
|
spec.AddPath("/projects/{id}/claude-config/agents/{name}", "put", withAuthBodyAndParams(
|
|
"Update agent",
|
|
"Updates an agent's content. Requires projects:execute scope.",
|
|
"Claude Config",
|
|
"projects:execute",
|
|
[]param{
|
|
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
{Name: "name", In: "path", Description: "Agent name", Required: true},
|
|
},
|
|
`{"content": "# Updated Agent\n\n..."}`,
|
|
`{"name": "code-reviewer", "type": "agents", "content": "# Updated Agent\n\n..."}`,
|
|
))
|
|
|
|
spec.AddPath("/projects/{id}/claude-config/agents/{name}", "delete", withAuthAndParams(
|
|
"Delete agent",
|
|
"Deletes an agent. Requires projects:execute scope.",
|
|
"Claude Config",
|
|
"projects:execute",
|
|
[]param{
|
|
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
{Name: "name", In: "path", Description: "Agent name", Required: true},
|
|
},
|
|
))
|
|
|
|
// Audit log endpoints
|
|
spec.AddPath("/audit-log", "get", map[string]any{
|
|
"summary": "List audit log entries",
|
|
"description": `Returns audit log entries with optional filtering.
|
|
|
|
**Required scope**: ` + "`audit:read`" + `
|
|
|
|
## Query Parameters
|
|
|
|
| Parameter | Type | Description |
|
|
|-----------|------|-------------|
|
|
| project | string | Filter by project ID |
|
|
| api_key | string | Filter by API key ID |
|
|
| command_type | string | Filter by type (claude, shell, git) |
|
|
| status | string | Filter by status (running, success, error, cancelled) |
|
|
| start | string | Filter by start time (RFC3339 format) |
|
|
| end | string | Filter by end time (RFC3339 format) |
|
|
| limit | int | Max entries to return (default: 100, max: 1000) |
|
|
| offset | int | Number of entries to skip for pagination |`,
|
|
"tags": []string{"Audit"},
|
|
"security": []map[string]any{
|
|
{"ApiKeyAuth": []string{}},
|
|
},
|
|
"parameters": []map[string]any{
|
|
{"name": "project", "in": "query", "description": "Filter by project ID", "schema": map[string]any{"type": "string"}},
|
|
{"name": "api_key", "in": "query", "description": "Filter by API key ID", "schema": map[string]any{"type": "string"}},
|
|
{"name": "command_type", "in": "query", "description": "Filter by command type", "schema": map[string]any{"type": "string", "enum": []string{"claude", "shell", "git"}}},
|
|
{"name": "status", "in": "query", "description": "Filter by status", "schema": map[string]any{"type": "string", "enum": []string{"running", "success", "error", "cancelled"}}},
|
|
{"name": "start", "in": "query", "description": "Filter by start time (RFC3339)", "schema": map[string]any{"type": "string", "format": "date-time"}},
|
|
{"name": "end", "in": "query", "description": "Filter by end time (RFC3339)", "schema": map[string]any{"type": "string", "format": "date-time"}},
|
|
{"name": "limit", "in": "query", "description": "Max entries (default: 100)", "schema": map[string]any{"type": "integer", "default": 100}},
|
|
{"name": "offset", "in": "query", "description": "Entries to skip", "schema": map[string]any{"type": "integer", "default": 0}},
|
|
},
|
|
"responses": map[string]any{
|
|
"200": map[string]any{
|
|
"description": "Success",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"example": `{
|
|
"entries": [
|
|
{
|
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
"api_key_id": "key-123",
|
|
"command_id": "cmd-pantheon-001",
|
|
"project_id": "pantheon",
|
|
"command_type": "claude",
|
|
"args": "[\"fix the bug\"]",
|
|
"client_ip": "192.168.1.100",
|
|
"user_agent": "rdev-cli/1.0",
|
|
"started_at": "2026-01-25T12:00:00Z",
|
|
"completed_at": "2026-01-25T12:01:30Z",
|
|
"exit_code": 0,
|
|
"duration_ms": 90000,
|
|
"status": "success",
|
|
"output_size_bytes": 1024,
|
|
"created_at": "2026-01-25T12:00:00Z"
|
|
}
|
|
],
|
|
"total": 1,
|
|
"limit": 100,
|
|
"offset": 0
|
|
}`,
|
|
},
|
|
},
|
|
},
|
|
"401": map[string]any{"description": "Unauthorized - Missing or invalid API key"},
|
|
"403": map[string]any{"description": "Forbidden - Insufficient permissions"},
|
|
},
|
|
})
|
|
|
|
spec.AddPath("/audit-log/{command_id}", "get", withAuthAndParams(
|
|
"Get audit log entry",
|
|
"Returns a single audit log entry by command ID. Requires audit:read scope.",
|
|
"Audit",
|
|
"audit:read",
|
|
[]param{{Name: "command_id", In: "path", Description: "Command ID", Required: true}},
|
|
))
|
|
|
|
return spec
|
|
}
|
|
|
|
// param represents an OpenAPI parameter.
|
|
type param struct {
|
|
Name string
|
|
In string
|
|
Description string
|
|
Required bool
|
|
}
|
|
|
|
// withAuth creates an operation that requires authentication.
|
|
func withAuth(summary, description, tag, scope, example string) map[string]any {
|
|
return map[string]any{
|
|
"summary": summary,
|
|
"description": description + "\n\n**Required scope**: `" + scope + "`",
|
|
"tags": []string{tag},
|
|
"security": []map[string]any{
|
|
{"ApiKeyAuth": []string{}},
|
|
},
|
|
"responses": map[string]any{
|
|
"200": map[string]any{
|
|
"description": "Success",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"example": example,
|
|
},
|
|
},
|
|
},
|
|
"401": map[string]any{"description": "Unauthorized - Missing or invalid API key"},
|
|
"403": map[string]any{"description": "Forbidden - Insufficient permissions"},
|
|
},
|
|
}
|
|
}
|
|
|
|
// withAuthAndBody creates an operation with auth and request body.
|
|
func withAuthAndBody(summary, description, tag, scope, requestExample, responseExample string) map[string]any {
|
|
return map[string]any{
|
|
"summary": summary,
|
|
"description": description + "\n\n**Required scope**: `" + scope + "`",
|
|
"tags": []string{tag},
|
|
"security": []map[string]any{
|
|
{"ApiKeyAuth": []string{}},
|
|
},
|
|
"requestBody": map[string]any{
|
|
"required": true,
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"example": requestExample,
|
|
},
|
|
},
|
|
},
|
|
"responses": map[string]any{
|
|
"201": map[string]any{
|
|
"description": "Created",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"example": responseExample,
|
|
},
|
|
},
|
|
},
|
|
"400": map[string]any{"description": "Bad Request - Invalid input"},
|
|
"401": map[string]any{"description": "Unauthorized - Missing or invalid API key"},
|
|
"403": map[string]any{"description": "Forbidden - Insufficient permissions"},
|
|
},
|
|
}
|
|
}
|
|
|
|
// withAuthAndParams creates an operation with auth and path parameters.
|
|
func withAuthAndParams(summary, description, tag, scope string, params []param) map[string]any {
|
|
parameters := make([]map[string]any, len(params))
|
|
for i, p := range params {
|
|
parameters[i] = map[string]any{
|
|
"name": p.Name,
|
|
"in": p.In,
|
|
"description": p.Description,
|
|
"required": p.Required,
|
|
"schema": map[string]any{"type": "string"},
|
|
}
|
|
}
|
|
return map[string]any{
|
|
"summary": summary,
|
|
"description": description + "\n\n**Required scope**: `" + scope + "`",
|
|
"tags": []string{tag},
|
|
"security": []map[string]any{
|
|
{"ApiKeyAuth": []string{}},
|
|
},
|
|
"parameters": parameters,
|
|
"responses": map[string]any{
|
|
"200": map[string]any{"description": "Success"},
|
|
"401": map[string]any{"description": "Unauthorized - Missing or invalid API key"},
|
|
"403": map[string]any{"description": "Forbidden - Insufficient permissions"},
|
|
"404": map[string]any{"description": "Not Found"},
|
|
},
|
|
}
|
|
}
|
|
|
|
// withAuthBodyAndParams creates an operation with auth, body, and params.
|
|
func withAuthBodyAndParams(summary, description, tag, scope string, params []param, requestExample, responseExample string) map[string]any {
|
|
parameters := make([]map[string]any, len(params))
|
|
for i, p := range params {
|
|
parameters[i] = map[string]any{
|
|
"name": p.Name,
|
|
"in": p.In,
|
|
"description": p.Description,
|
|
"required": p.Required,
|
|
"schema": map[string]any{"type": "string"},
|
|
}
|
|
}
|
|
return map[string]any{
|
|
"summary": summary,
|
|
"description": description + "\n\n**Required scope**: `" + scope + "`",
|
|
"tags": []string{tag},
|
|
"security": []map[string]any{
|
|
{"ApiKeyAuth": []string{}},
|
|
},
|
|
"parameters": parameters,
|
|
"requestBody": map[string]any{
|
|
"required": true,
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"example": requestExample,
|
|
},
|
|
},
|
|
},
|
|
"responses": map[string]any{
|
|
"201": map[string]any{
|
|
"description": "Created",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"example": responseExample,
|
|
},
|
|
},
|
|
},
|
|
"400": map[string]any{"description": "Bad Request - Invalid input"},
|
|
"401": map[string]any{"description": "Unauthorized - Missing or invalid API key"},
|
|
"403": map[string]any{"description": "Forbidden - Insufficient permissions"},
|
|
},
|
|
}
|
|
}
|