rdev/cmd/rdev-api/main.go
jordan 0fd4e32073 feat: Add infrastructure adapters for threesix.ai
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>
2026-01-25 22:49:58 -07:00

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"},
},
}
}