Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions clients/api/agents.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package api

import (
"net/url"
)

type AgentItem struct {
AgentID string `json:"agentId"`
Name string `json:"name"`
Description string `json:"description"`
IsPublished bool `json:"isPublished"`
CanEdit bool `json:"canEdit"`
}
type AgentListResponse struct {
Agents []AgentItem `json:"agents"`
}
type AgentCreateResponse struct {
AgentID string `json:"agentId"`
}
type AgentPullResponse struct {
AgentID string `json:"agentId"`
Name string `json:"name"`
Version int `json:"version"`
DownloadURL string `json:"downloadUrl"`
}
type AgentUploadResponse struct {
UploadURL string `json:"uploadUrl"`
UploadKey string `json:"uploadKey"`
}
type AgentPushResponse struct {
AgentID string `json:"agentId"`
Name string `json:"name"`
Version int `json:"version"`
}
type AgentValidationError struct {
Path string `json:"path"`
Message string `json:"message"`
}
type AgentValidateResponse struct {
Valid bool `json:"valid"`
Errors []AgentValidationError `json:"errors"`
Warnings []string `json:"warnings"`
}
type AgentPublishResponse struct {
VersionID string `json:"versionId"`
Version int `json:"version"`
}
type AgentFile struct {
Path string `json:"path"`
Content string `json:"content"`
}

func (c *Client) ListAgents(organizationID string, includeReadOnly bool) (*AgentListResponse, error) {
query := url.Values{"organizationId": {organizationID}}
if includeReadOnly {
query.Set("includeReadOnly", "true")
}
var resp AgentListResponse
err := c.doRequest("GET", "/agents?"+query.Encode(), nil, &resp)
return &resp, err
}
func (c *Client) CreateAgent(organizationID, name, description string) (*AgentCreateResponse, error) {
var resp AgentCreateResponse
err := c.doRequest("POST", "/agents", map[string]string{"organizationId": organizationID, "name": name, "description": description}, &resp)
return &resp, err
}
func agentPath(id, action string) string { return "/agents/" + url.PathEscape(id) + "/" + action }
func (c *Client) PullAgent(id string) (*AgentPullResponse, error) {
var resp AgentPullResponse
err := c.doRequest("POST", agentPath(id, "pull"), map[string]any{}, &resp)
return &resp, err
}
func (c *Client) AgentUploadURL(id string) (*AgentUploadResponse, error) {
var resp AgentUploadResponse
err := c.doRequest("POST", agentPath(id, "push-upload-url"), map[string]any{}, &resp)
return &resp, err
}
func (c *Client) PushAgent(id, key, notes string) (*AgentPushResponse, error) {
var resp AgentPushResponse
err := c.doRequest("POST", agentPath(id, "push"), map[string]string{"uploadKey": key, "notes": notes}, &resp)
return &resp, err
}
func (c *Client) ValidateAgent(id string, files []AgentFile) (*AgentValidateResponse, error) {
var resp AgentValidateResponse
err := c.doRequest("POST", agentPath(id, "validate"), map[string]any{"files": files}, &resp)
return &resp, err
}
func (c *Client) PublishAgent(id string) (*AgentPublishResponse, error) {
var resp AgentPublishResponse
err := c.doRequest("POST", agentPath(id, "publish"), map[string]any{}, &resp)
return &resp, err
}
43 changes: 43 additions & 0 deletions clients/api/agents_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package api

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)

func TestAgentEndpointsNameAgentAndOrganization(t *testing.T) {
const agentID = "11111111-1111-4111-8111-111111111111"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-token" {
t.Errorf("missing bearer token")
}
switch r.URL.Path {
case "/cli/agents":
if r.Method != "GET" || r.URL.Query().Get("organizationId") != "org-1" || r.URL.Query().Get("includeReadOnly") != "true" {
t.Errorf("list request: %s", r.URL)
}
_ = json.NewEncoder(w).Encode(map[string]any{"agents": []any{}})
case "/cli/agents/" + agentID + "/pull":
if r.Method != "POST" {
t.Errorf("pull method: %s", r.Method)
}
_ = json.NewEncoder(w).Encode(map[string]any{"agentId": agentID, "name": "Helper", "version": 2, "downloadUrl": "https://example.com/get"})
default:
t.Errorf("unexpected URL %s", r.URL)
w.WriteHeader(404)
}
}))
defer server.Close()
previousToken := testTokenOverride
testTokenOverride = "test-token"
defer func() { testTokenOverride = previousToken }()
client := NewClient(server.URL + "/cli")
if _, err := client.ListAgents("org-1", true); err != nil {
t.Fatal(err)
}
if resp, err := client.PullAgent(agentID); err != nil || resp.Version != 2 {
t.Fatalf("pull: %+v, %v", resp, err)
}
}
7 changes: 7 additions & 0 deletions clients/api/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const (
ErrorCodeTokenNotFound = 2003
ErrorCodeInvalidDeviceCode = 2004
ErrorCodeAuthorizationPending = 2005
ErrorCodeTokenTypeNotAllowed = 2007

// Organization Errors (3000-3099)
ErrorCodeOrganizationNotFound = 3000
Expand All @@ -36,6 +37,7 @@ const (
type AppErrorDetail struct {
InternalCode int `json:"internal_code"`
ErrorString string `json:"error_string"`
Message string `json:"message"`
StatusCode int `json:"status_code"`
}

Expand Down Expand Up @@ -79,6 +81,11 @@ func ToCLIError(errResp *ErrorResponse) error {
return cliErr
}

// Unmapped codes (including 2007) carry a server message that names the next step
if errResp.Error.Message != "" {
return &clierrors.CLIError{Title: errResp.Error.Message, StatusCode: errResp.Error.StatusCode}
}

// No specific mapping - create a generic CLIError with API details
return &clierrors.CLIError{
Title: fmt.Sprintf("API Error (Code: %d)", errResp.Error.InternalCode),
Expand Down
25 changes: 25 additions & 0 deletions clients/api/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package api

import (
"errors"
"strings"
"testing"

clierrors "github.com/major-technology/cli/errors"
)

func TestToCLIErrorUsesServerMessageForUnmappedCodes(t *testing.T) {
for _, code := range []int{ErrorCodeTokenTypeNotAllowed, 9876} {
err := ToCLIError(&ErrorResponse{Error: &AppErrorDetail{InternalCode: code, StatusCode: 403, ErrorString: "token_type_not_allowed", Message: "Use create_agent instead"}})
if !strings.Contains(err.Error(), "Use create_agent instead") {
t.Fatalf("code %d: %v", code, err)
}
}
}

func TestToCLIErrorKeepsMappedSentinels(t *testing.T) {
err := ToCLIError(&ErrorResponse{Error: &AppErrorDetail{InternalCode: ErrorCodeAuthorizationPending, StatusCode: 400, ErrorString: "authorization_pending", Message: "Authorization pending"}})
if !errors.Is(err, clierrors.ErrorAuthorizationPending) {
t.Fatalf("expected authorization pending sentinel, got %v", err)
}
}
46 changes: 46 additions & 0 deletions clients/transfer/transfer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package transfer

import (
"bytes"
"fmt"
"io"
"net/http"
)

const maxArchiveBytes = 20 << 20

// Download and Upload use presigned URLs without forwarding the CLI credential.
func Download(url string) ([]byte, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download: HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxArchiveBytes+1))
if err != nil {
return nil, err
}
if len(data) > maxArchiveBytes {
return nil, fmt.Errorf("agent archive exceeds 20 MiB")
}
return data, nil
}
func Upload(url string, data []byte) error {
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/zip")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("upload: HTTP %d", resp.StatusCode)
}
return nil
}
40 changes: 40 additions & 0 deletions clients/transfer/transfer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package transfer

import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
)

func TestPresignedTransferDoesNotForwardCredential(t *testing.T) {
data := []byte("archive bytes")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "" {
t.Errorf("credential forwarded to presigned URL")
}
if r.Method == "PUT" {
if r.Header.Get("Content-Type") != "application/zip" {
t.Errorf("content type: %q", r.Header.Get("Content-Type"))
}
body, _ := io.ReadAll(r.Body)
if !bytes.Equal(body, data) {
t.Errorf("upload bytes: %q", body)
}
return
}
_, _ = w.Write(data)
}))
defer server.Close()
if err := Upload(server.URL, data); err != nil {
t.Fatal(err)
}
got, err := Download(server.URL)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, data) {
t.Fatalf("download: %q", got)
}
}
32 changes: 26 additions & 6 deletions clients/workspace/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,21 @@ func (t Target) Validate() error {
return nil
}

func (t Target) ID() string {
switch t.Kind {
case "app":
return t.ApplicationID
case "skill":
return t.SkillID
case "agent":
return t.AgentID
case "workflow":
return t.WorkflowID
default:
return ""
}
}

func (c Config) Validate() error {
if strings.TrimSpace(c.OrganizationID) == "" {
return fmt.Errorf("organizationId is required")
Expand All @@ -82,9 +97,14 @@ func (c Config) Validate() error {
}

func Load(startDir string) (*Config, error) {
_, cfg, err := Locate(startDir)
return cfg, err
}

func Locate(startDir string) (string, *Config, error) {
dir, err := filepath.Abs(startDir)
if err != nil {
return nil, err
return "", nil, err
}

for {
Expand All @@ -93,21 +113,21 @@ func Load(startDir string) (*Config, error) {
if err == nil {
cfg, parseErr := parseConfig(path, data)
if parseErr != nil {
return nil, parseErr
return "", nil, parseErr
}
return cfg, nil
return dir, cfg, nil
}
if !os.IsNotExist(err) {
return nil, err
return "", nil, err
}

if _, statErr := os.Lstat(filepath.Join(dir, ".git")); statErr == nil {
return nil, ErrNotFound
return "", nil, ErrNotFound
}

parent := filepath.Dir(dir)
if parent == dir {
return nil, ErrNotFound
return "", nil, ErrNotFound
}
dir = parent
}
Expand Down
19 changes: 19 additions & 0 deletions clients/workspace/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,25 @@ func TestWriteRejectsNullExistingConfig(t *testing.T) {
}
}

func TestLocateReturnsWorkspaceRootFromNestedDirectory(t *testing.T) {
root := t.TempDir()
cfg := Config{OrganizationID: testOrgID, Target: Target{Kind: "agent", AgentID: testTargetID}}
if err := Write(root, cfg); err != nil {
t.Fatal(err)
}
nested := filepath.Join(root, "notes")
if err := os.Mkdir(nested, 0755); err != nil {
t.Fatal(err)
}
gotRoot, got, err := Locate(nested)
if err != nil {
t.Fatal(err)
}
if gotRoot != root || got.Target.ID() != testTargetID {
t.Fatalf("root=%q target=%+v", gotRoot, got.Target)
}
}

func TestWriteReadRoundTrip(t *testing.T) {
dir := t.TempDir()
cfg := validAppConfig()
Expand Down
Loading
Loading