rdev/cmd/rdev-api/main.go
jordan 4a042a8b71 feat: Add rdev-api Go server with OpenAPI docs
Implements a fully documented API server following the aeries chassis pattern:

- pkg/api: Simplified chassis with App, Response helpers, and OpenAPI builder
- cmd/rdev-api: Entry point with full OpenAPI spec for all v0.4 endpoints
- internal/handlers: Stubbed project handlers (list, get, claude, shell, git, events)

Endpoints:
- GET  /health, /ready     - Health checks
- GET  /docs, /openapi.json - Scalar API docs
- GET  /projects           - List projects
- GET  /projects/{id}      - Get project
- POST /projects/{id}/claude, shell, git - Run commands
- GET  /projects/{id}/events - SSE streaming

Uses Scalar for dark-mode API documentation at /docs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 20:56:27 -07:00

314 lines
8.9 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.
//
// Endpoints:
// - GET /health - Health check
// - GET /ready - Readiness check
// - GET /docs - Scalar API documentation
// - GET /openapi.json - OpenAPI 3.0 specification
// - 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
package main
import (
"github.com/orchard9/rdev/internal/handlers"
"github.com/orchard9/rdev/pkg/api"
)
func main() {
app := api.New("rdev-api", api.WithPort(8080))
// Initialize handlers
projectsHandler := handlers.NewProjectsHandler()
// Register routes
projectsHandler.Mount(app.Router())
// Enable API documentation
app.EnableDocs(buildOpenAPISpec())
app.Run()
}
// buildOpenAPISpec creates the OpenAPI specification for the rdev API.
func buildOpenAPISpec() *api.OpenAPISpec {
spec := api.NewOpenAPISpec("rdev API", "0.4.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.
## Architecture
- **rdev-api**: This Go service (what you're looking at)
- **claudebox pods**: Isolated pods running Claude Code CLI
- **Projects**: Each project has its own claudebox pod with mounted workspace
## Authentication
Authentication is planned for v0.6. Currently the API is internal-only.
## Streaming
Command output is streamed via Server-Sent Events (SSE) at the /projects/{id}/events endpoint.
`).
WithServer("http://localhost:8080", "Local development").
WithServer("http://rdev-api.rdev.svc:8080", "Kubernetes internal")
// Tags
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("System", "Health and readiness endpoints")
// System endpoints (auto-added by chassis, but document them)
spec.AddPath("/health", "get", api.Op(
"Health check",
"Returns the health status of the rdev-api service",
"System",
))
spec.AddPath("/ready", "get", api.Op(
"Readiness check",
"Returns whether the service is ready to accept traffic",
"System",
))
// Projects - List and Get
spec.AddPath("/projects", "get", withResponse(
"List projects",
"Returns all available projects (claudebox pods) in the cluster",
"Projects",
`[
{
"id": "pantheon",
"name": "Pantheon",
"description": "Go API backend",
"pod": "claudebox-pantheon-0",
"status": "running"
}
]`,
))
spec.AddPath("/projects/{id}", "get", withParams(
"Get project",
"Returns details for a specific project including its configuration",
"Projects",
[]param{{Name: "id", In: "path", Description: "Project ID (e.g., 'pantheon')", Required: true}},
))
// Commands
spec.AddPath("/projects/{id}/claude", "post", withRequestBody(
"Run Claude command",
"Executes a Claude Code prompt in the project's claudebox pod. Returns a command ID for tracking via SSE.",
"Commands",
[]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", withRequestBody(
"Run shell command",
"Executes a shell command in the project's claudebox pod. Use for running tests, builds, etc.",
"Commands",
[]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", withRequestBody(
"Run git command",
"Executes a git command in the project's claudebox pod. Use for commits, pushes, pulls, etc.",
"Commands",
[]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.
Connect to this endpoint to receive output as commands execute.
## 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');
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"},
"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",
},
},
},
},
},
})
return spec
}
// param represents an OpenAPI parameter.
type param struct {
Name string
In string
Description string
Required bool
}
// withResponse creates an operation with example response.
func withResponse(summary, description, tag, example string) map[string]any {
return map[string]any{
"summary": summary,
"description": description,
"tags": []string{tag},
"responses": map[string]any{
"200": map[string]any{
"description": "Success",
"content": map[string]any{
"application/json": map[string]any{
"example": example,
},
},
},
},
}
}
// withParams creates an operation with path parameters.
func withParams(summary, description, tag 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,
"tags": []string{tag},
"parameters": parameters,
"responses": map[string]any{
"200": map[string]any{"description": "Success"},
},
}
}
// withRequestBody creates an operation with request body and example response.
func withRequestBody(summary, description, tag 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,
"tags": []string{tag},
"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": "Command queued",
"content": map[string]any{
"application/json": map[string]any{
"example": responseExample,
},
},
},
},
}
}