cli/pkg/agentic/client.go
Snider f2bc912ebe feat: infrastructure packages and lint cleanup (#281)
* ci: consolidate duplicate workflows and merge CodeQL configs

Remove 17 duplicate workflow files that were split copies of the
combined originals. Each family (CI, CodeQL, Coverage, PR Build,
Alpha Release) had the same job duplicated across separate
push/pull_request/schedule/manual trigger files.

Merge codeql.yml and codescan.yml into a single codeql.yml with
a language matrix covering go, javascript-typescript, python,
and actions — matching the previous default setup coverage.

Remaining workflows (one per family):
- ci.yml (push + PR + manual)
- codeql.yml (push + PR + schedule, all languages)
- coverage.yml (push + PR + manual)
- alpha-release.yml (push + manual)
- pr-build.yml (PR + manual)
- release.yml (tag push)
- agent-verify.yml, auto-label.yml, auto-project.yml

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add collect, config, crypt, plugin packages and fix all lint issues

Add four new infrastructure packages with CLI commands:
- pkg/config: layered configuration (defaults → file → env → flags)
- pkg/crypt: crypto primitives (Argon2id, AES-GCM, ChaCha20, HMAC, checksums)
- pkg/plugin: plugin system with GitHub-based install/update/remove
- pkg/collect: collection subsystem (GitHub, BitcoinTalk, market, papers, excavate)

Fix all golangci-lint issues across the entire codebase (~100 errcheck,
staticcheck SA1012/SA1019/ST1005, unused, ineffassign fixes) so that
`core go qa` passes with 0 issues.

Closes #167, #168, #170, #250, #251, #252, #253, #254, #255, #256

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 11:34:43 +00:00

322 lines
8 KiB
Go

package agentic
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/host-uk/core/pkg/log"
)
// Client is the API client for the core-agentic service.
type Client struct {
// BaseURL is the base URL of the API server.
BaseURL string
// Token is the authentication token.
Token string
// HTTPClient is the HTTP client used for requests.
HTTPClient *http.Client
// AgentID is the identifier for this agent when claiming tasks.
AgentID string
}
// NewClient creates a new agentic API client with the given base URL and token.
func NewClient(baseURL, token string) *Client {
return &Client{
BaseURL: strings.TrimSuffix(baseURL, "/"),
Token: token,
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// NewClientFromConfig creates a new client from a Config struct.
func NewClientFromConfig(cfg *Config) *Client {
client := NewClient(cfg.BaseURL, cfg.Token)
client.AgentID = cfg.AgentID
return client
}
// ListTasks retrieves a list of tasks matching the given options.
func (c *Client) ListTasks(ctx context.Context, opts ListOptions) ([]Task, error) {
const op = "agentic.Client.ListTasks"
// Build query parameters
params := url.Values{}
if opts.Status != "" {
params.Set("status", string(opts.Status))
}
if opts.Priority != "" {
params.Set("priority", string(opts.Priority))
}
if opts.Project != "" {
params.Set("project", opts.Project)
}
if opts.ClaimedBy != "" {
params.Set("claimed_by", opts.ClaimedBy)
}
if opts.Limit > 0 {
params.Set("limit", strconv.Itoa(opts.Limit))
}
if len(opts.Labels) > 0 {
params.Set("labels", strings.Join(opts.Labels, ","))
}
endpoint := c.BaseURL + "/api/tasks"
if len(params) > 0 {
endpoint += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, log.E(op, "failed to create request", err)
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return nil, log.E(op, "API error", err)
}
var tasks []Task
if err := json.NewDecoder(resp.Body).Decode(&tasks); err != nil {
return nil, log.E(op, "failed to decode response", err)
}
return tasks, nil
}
// GetTask retrieves a single task by its ID.
func (c *Client) GetTask(ctx context.Context, id string) (*Task, error) {
const op = "agentic.Client.GetTask"
if id == "" {
return nil, log.E(op, "task ID is required", nil)
}
endpoint := fmt.Sprintf("%s/api/tasks/%s", c.BaseURL, url.PathEscape(id))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, log.E(op, "failed to create request", err)
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return nil, log.E(op, "API error", err)
}
var task Task
if err := json.NewDecoder(resp.Body).Decode(&task); err != nil {
return nil, log.E(op, "failed to decode response", err)
}
return &task, nil
}
// ClaimTask claims a task for the current agent.
func (c *Client) ClaimTask(ctx context.Context, id string) (*Task, error) {
const op = "agentic.Client.ClaimTask"
if id == "" {
return nil, log.E(op, "task ID is required", nil)
}
endpoint := fmt.Sprintf("%s/api/tasks/%s/claim", c.BaseURL, url.PathEscape(id))
// Include agent ID in the claim request if available
var body io.Reader
if c.AgentID != "" {
data, _ := json.Marshal(map[string]string{"agent_id": c.AgentID})
body = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body)
if err != nil {
return nil, log.E(op, "failed to create request", err)
}
c.setHeaders(req)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return nil, log.E(op, "API error", err)
}
// Read body once to allow multiple decode attempts
bodyData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, log.E(op, "failed to read response", err)
}
// Try decoding as ClaimResponse first
var result ClaimResponse
if err := json.Unmarshal(bodyData, &result); err == nil && result.Task != nil {
return result.Task, nil
}
// Try decoding as just a Task for simpler API responses
var task Task
if err := json.Unmarshal(bodyData, &task); err != nil {
return nil, log.E(op, "failed to decode response", err)
}
return &task, nil
}
// UpdateTask updates a task with new status, progress, or notes.
func (c *Client) UpdateTask(ctx context.Context, id string, update TaskUpdate) error {
const op = "agentic.Client.UpdateTask"
if id == "" {
return log.E(op, "task ID is required", nil)
}
endpoint := fmt.Sprintf("%s/api/tasks/%s", c.BaseURL, url.PathEscape(id))
data, err := json.Marshal(update)
if err != nil {
return log.E(op, "failed to marshal update", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(data))
if err != nil {
return log.E(op, "failed to create request", err)
}
c.setHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return log.E(op, "API error", err)
}
return nil
}
// CompleteTask marks a task as completed with the given result.
func (c *Client) CompleteTask(ctx context.Context, id string, result TaskResult) error {
const op = "agentic.Client.CompleteTask"
if id == "" {
return log.E(op, "task ID is required", nil)
}
endpoint := fmt.Sprintf("%s/api/tasks/%s/complete", c.BaseURL, url.PathEscape(id))
data, err := json.Marshal(result)
if err != nil {
return log.E(op, "failed to marshal result", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
if err != nil {
return log.E(op, "failed to create request", err)
}
c.setHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return log.E(op, "API error", err)
}
return nil
}
// setHeaders adds common headers to the request.
func (c *Client) setHeaders(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "core-agentic-client/1.0")
}
// checkResponse checks if the response indicates an error.
func (c *Client) checkResponse(resp *http.Response) error {
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
body, _ := io.ReadAll(resp.Body)
// Try to parse as APIError
var apiErr APIError
if err := json.Unmarshal(body, &apiErr); err == nil && apiErr.Message != "" {
apiErr.Code = resp.StatusCode
return &apiErr
}
// Return generic error
return &APIError{
Code: resp.StatusCode,
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, http.StatusText(resp.StatusCode)),
Details: string(body),
}
}
// Ping tests the connection to the API server.
func (c *Client) Ping(ctx context.Context) error {
const op = "agentic.Client.Ping"
endpoint := c.BaseURL + "/api/health"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return log.E(op, "failed to create request", err)
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
return log.E(op, fmt.Sprintf("server returned status %d", resp.StatusCode), nil)
}
return nil
}