molecule-cli/internal/client/platform.go
Molecule AI SDK-Dev 3eabe3c780 feat: implement full CLI command tree
Implement the core CLI for molecule-cli:

- cmd/molecule/main.go: entry point calling cmd.Execute()
- internal/cmd/root.go: cobra root with global flags (--api-url,
  --verbose, --output, --config), registers all 4 command groups
- internal/cmd/workspace.go: 7 subcommands (list, create, inspect,
  delete, restart, audit, delegate)
- internal/cmd/agent.go: 4 subcommands (list, inspect, send, peers)
- internal/cmd/platform.go: 2 subcommands (audit, health)
- internal/cmd/config.go: 5 subcommands (list, get, set, init, view)
- internal/cmd/http.go: runHTTP helper shared by agent send and
  workspace delegate
- internal/client/platform.go: control plane HTTP client with
  workspace/agent/health/audit operations

All 18 subcommands wire to platform API via MOLECULE_API_URL.
Binary builds to ./bin/mol. Resolves KI-001, KI-002 (partial),
KI-003.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 01:18:24 +00:00

276 lines
7.8 KiB
Go

// Package client provides a thin HTTP client for the Molecule AI platform API.
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// Platform is the root API client.
type Platform struct {
BaseURL string
client *http.Client
}
// New returns a Platform client configured with baseURL.
func New(baseURL string) *Platform {
return &Platform{
BaseURL: baseURL,
client: &http.Client{Timeout: 30 * time.Second},
}
}
// Workspace represents a Molecule AI workspace.
type Workspace struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Role string `json:"role,omitempty"`
ParentID string `json:"parent_id,omitempty"`
Runtime string `json:"runtime,omitempty"`
WorkspaceDir string `json:"workspace_dir,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
Tier int `json:"tier,omitempty"`
Canvas *Canvas `json:"canvas,omitempty"`
}
// Canvas holds the workspace's position on the canvas.
type Canvas struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
// Agent represents an agent running in a workspace.
type Agent struct {
ID string `json:"id"`
Name string `json:"name"`
WorkspaceID string `json:"workspace_id,omitempty"`
Status string `json:"status"`
Model string `json:"model,omitempty"`
Runtime string `json:"runtime,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
}
// CreateWorkspaceRequest mirrors the platform's POST /workspaces body.
type CreateWorkspaceRequest struct {
Name string `json:"name"`
Role string `json:"role,omitempty"`
Template string `json:"template,omitempty"`
Tier int `json:"tier,omitempty"`
ParentID string `json:"parent_id,omitempty"`
Runtime string `json:"runtime,omitempty"`
WorkspaceDir string `json:"workspace_dir,omitempty"`
}
// PlatformHealth holds the /health endpoint response.
type PlatformHealth struct {
Status string `json:"status"`
Version string `json:"version,omitempty"`
Uptime string `json:"uptime,omitempty"`
Database string `json:"database,omitempty"`
}
// ConfigEntry represents a config key-value pair.
type ConfigEntry struct {
Key string `json:"key"`
Value string `json:"value,omitempty"`
}
// ListWorkspaces returns all workspaces in the org.
func (p *Platform) ListWorkspaces() ([]Workspace, error) {
var out []Workspace
if err := p.getInto("/workspaces", &out); err != nil {
return nil, err
}
return out, nil
}
// GetWorkspace returns a single workspace by ID.
func (p *Platform) GetWorkspace(id string) (*Workspace, error) {
var out Workspace
if err := p.getInto(fmt.Sprintf("/workspaces/%s", id), &out); err != nil {
return nil, err
}
return &out, nil
}
// CreateWorkspace creates a new workspace.
func (p *Platform) CreateWorkspace(req CreateWorkspaceRequest) (*Workspace, error) {
var out Workspace
if err := p.postInto("/workspaces", req, &out); err != nil {
return nil, err
}
return &out, nil
}
// DeleteWorkspace deletes a workspace by ID.
func (p *Platform) DeleteWorkspace(id string) error {
_, err := p.delete(fmt.Sprintf("/workspaces/%s?confirm=true", id))
return err
}
// RestartWorkspace triggers a restart for a workspace.
func (p *Platform) RestartWorkspace(id string) error {
_, err := p.postEmpty(fmt.Sprintf("/workspaces/%s/restart", id))
return err
}
// ListAgents returns all agents across the org.
func (p *Platform) ListAgents() ([]Agent, error) {
var out []Agent
if err := p.getInto("/agents", &out); err != nil {
return nil, err
}
return out, nil
}
// ListWorkspaceAgents returns agents for a given workspace.
func (p *Platform) ListWorkspaceAgents(workspaceID string) ([]Agent, error) {
var out []Agent
if err := p.getInto(fmt.Sprintf("/workspaces/%s/agents", workspaceID), &out); err != nil {
return nil, err
}
return out, nil
}
// GetAgent returns a single agent by ID.
func (p *Platform) GetAgent(id string) (*Agent, error) {
var out Agent
if err := p.getInto(fmt.Sprintf("/agents/%s", id), &out); err != nil {
return nil, err
}
return &out, nil
}
// Health returns the platform's /health status.
func (p *Platform) Health() (*PlatformHealth, error) {
var out PlatformHealth
if err := p.getInto("/health", &out); err != nil {
return nil, err
}
return &out, nil
}
// AuditWorkspaces returns all workspaces and agents.
func (p *Platform) AuditWorkspaces() ([]Workspace, []Agent, error) {
ws, err := p.ListWorkspaces()
if err != nil {
return nil, nil, fmt.Errorf("audit workspaces: %w", err)
}
agents, err := p.ListAgents()
if err != nil {
return ws, nil, fmt.Errorf("audit agents: %w", err)
}
return ws, agents, nil
}
// GetPeers returns peer workspaces reachable from a workspace.
func (p *Platform) GetPeers(workspaceID string) ([]Agent, error) {
var out []Agent
if err := p.getInto(fmt.Sprintf("/registry/%s/peers", workspaceID), &out); err != nil {
return nil, err
}
return out, nil
}
// GetDelegations returns delegation status for a workspace.
func (p *Platform) GetDelegations(workspaceID string) ([]map[string]interface{}, error) {
var out []map[string]interface{}
if err := p.getInto(fmt.Sprintf("/workspaces/%s/delegations", workspaceID), &out); err != nil {
return nil, err
}
return out, nil
}
// ---------------------------------------------------------------------------
// Private HTTP helpers
// ---------------------------------------------------------------------------
func (p *Platform) getInto(path string, out interface{}) error {
url := p.BaseURL + path
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("new GET request: %w", err)
}
resp, err := p.client.Do(req)
if err != nil {
return fmt.Errorf("GET %s: %w", url, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("GET %s: HTTP %d — %s", url, resp.StatusCode, string(body))
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("decode GET %s: %w", path, err)
}
return nil
}
func (p *Platform) postInto(path string, body interface{}, out interface{}) error {
encoded, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal POST body: %w", err)
}
url := p.BaseURL + path
req, err := http.NewRequest("POST", url, bytes.NewReader(encoded))
if err != nil {
return fmt.Errorf("new POST request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return fmt.Errorf("POST %s: %w", url, err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("POST %s: HTTP %d — %s", url, resp.StatusCode, string(respBody))
}
if out != nil {
if err := json.Unmarshal(respBody, out); err != nil {
return fmt.Errorf("decode POST %s response: %w", path, err)
}
}
return nil
}
func (p *Platform) delete(path string) ([]byte, error) {
url := p.BaseURL + path
req, err := http.NewRequest("DELETE", url, nil)
if err != nil {
return nil, fmt.Errorf("new DELETE request: %w", err)
}
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("DELETE %s: %w", url, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("DELETE %s: HTTP %d — %s", url, resp.StatusCode, string(body))
}
return body, nil
}
func (p *Platform) postEmpty(path string) ([]byte, error) {
url := p.BaseURL + path
req, err := http.NewRequest("POST", url, nil)
if err != nil {
return nil, fmt.Errorf("new POST request: %w", err)
}
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("POST %s: %w", url, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("POST %s: HTTP %d — %s", url, resp.StatusCode, string(body))
}
return body, nil
}