diff --git a/clients/api/agents.go b/clients/api/agents.go
new file mode 100644
index 0000000..c1296bb
--- /dev/null
+++ b/clients/api/agents.go
@@ -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
+}
diff --git a/clients/api/agents_test.go b/clients/api/agents_test.go
new file mode 100644
index 0000000..fbb5603
--- /dev/null
+++ b/clients/api/agents_test.go
@@ -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)
+ }
+}
diff --git a/clients/api/errors.go b/clients/api/errors.go
index e273a4b..a8bdae9 100644
--- a/clients/api/errors.go
+++ b/clients/api/errors.go
@@ -15,6 +15,7 @@ const (
ErrorCodeTokenNotFound = 2003
ErrorCodeInvalidDeviceCode = 2004
ErrorCodeAuthorizationPending = 2005
+ ErrorCodeTokenTypeNotAllowed = 2007
// Organization Errors (3000-3099)
ErrorCodeOrganizationNotFound = 3000
@@ -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"`
}
@@ -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),
diff --git a/clients/api/errors_test.go b/clients/api/errors_test.go
new file mode 100644
index 0000000..009d875
--- /dev/null
+++ b/clients/api/errors_test.go
@@ -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)
+ }
+}
diff --git a/clients/transfer/transfer.go b/clients/transfer/transfer.go
new file mode 100644
index 0000000..5f25c48
--- /dev/null
+++ b/clients/transfer/transfer.go
@@ -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
+}
diff --git a/clients/transfer/transfer_test.go b/clients/transfer/transfer_test.go
new file mode 100644
index 0000000..94137f7
--- /dev/null
+++ b/clients/transfer/transfer_test.go
@@ -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)
+ }
+}
diff --git a/clients/workspace/config.go b/clients/workspace/config.go
index e2d478b..b7e530a 100644
--- a/clients/workspace/config.go
+++ b/clients/workspace/config.go
@@ -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")
@@ -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 {
@@ -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
}
diff --git a/clients/workspace/config_test.go b/clients/workspace/config_test.go
index f27ef57..4f4c250 100644
--- a/clients/workspace/config_test.go
+++ b/clients/workspace/config_test.go
@@ -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()
diff --git a/cmd/agent/agent.go b/cmd/agent/agent.go
new file mode 100644
index 0000000..d3b7b58
--- /dev/null
+++ b/cmd/agent/agent.go
@@ -0,0 +1,216 @@
+package agent
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/charmbracelet/huh"
+ "github.com/major-technology/cli/clients/api"
+ mjrToken "github.com/major-technology/cli/clients/token"
+ "github.com/major-technology/cli/clients/workspace"
+ "github.com/major-technology/cli/singletons"
+ "github.com/major-technology/cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var Cmd = &cobra.Command{Use: "agent", Short: "Manage file-based agents"}
+
+func init() {
+ Cmd.PersistentFlags().Bool("json", false, "Print one JSON result")
+ Cmd.AddCommand(newListCmd(), newCreateCmd(), newCloneCmd())
+}
+
+func organizationID() (string, error) {
+ cwd, err := os.Getwd()
+ if err != nil {
+ return "", err
+ }
+ if _, cfg, err := workspace.Locate(cwd); err == nil {
+ return cfg.OrganizationID, nil
+ } else if !errors.Is(err, workspace.ErrNotFound) {
+ return "", err
+ }
+ id, _, err := mjrToken.GetDefaultOrg()
+ if err != nil {
+ return "", fmt.Errorf("select a default organization with major org select")
+ }
+ return id, nil
+}
+func output(cmd *cobra.Command, result any) error {
+ jsonOutput, _ := cmd.Flags().GetBool("json")
+ if jsonOutput {
+ return utils.WriteJSON(cmd, result)
+ }
+ cmd.Println(result)
+ return nil
+}
+
+type listResult struct{ *api.AgentListResponse }
+
+func (r listResult) String() string {
+ if len(r.Agents) == 0 {
+ return "No agents found."
+ }
+ lines := make([]string, 0, len(r.Agents))
+ for _, item := range r.Agents {
+ status := "draft"
+ if item.IsPublished {
+ status = "published"
+ }
+ lines = append(lines, fmt.Sprintf("%s %s (%s)", item.AgentID, item.Name, status))
+ }
+ return strings.Join(lines, "\n")
+}
+
+type cloneResult struct {
+ AgentID string `json:"agentId"`
+ Path string `json:"path"`
+ Version int `json:"version"`
+ name string
+}
+
+func (r cloneResult) String() string {
+ return fmt.Sprintf("Cloned %s (version %d) into %s.", r.name, r.Version, r.Path)
+}
+
+func newListCmd() *cobra.Command {
+ var readOnly bool
+ cmd := &cobra.Command{Use: "list", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error {
+ org, err := organizationID()
+ if err != nil {
+ return err
+ }
+ result, err := singletons.GetAPIClient().ListAgents(org, readOnly)
+ if err != nil {
+ return err
+ }
+ return output(cmd, listResult{result})
+ }}
+ cmd.Flags().BoolVar(&readOnly, "include-read-only", false, "Include agents you can use but not edit")
+ return cmd
+}
+func newCreateCmd() *cobra.Command {
+ var name, description string
+ cmd := &cobra.Command{Use: "create
", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
+ if name == "" {
+ return fmt.Errorf("--name is required")
+ }
+ dir, err := filepath.Abs(args[0])
+ if err != nil {
+ return err
+ }
+ if _, err := os.Lstat(dir); err == nil {
+ return fmt.Errorf("directory %s already exists", dir)
+ } else if !os.IsNotExist(err) {
+ return err
+ }
+ org, err := organizationID()
+ if err != nil {
+ return err
+ }
+ created, err := singletons.GetAPIClient().CreateAgent(org, name, description)
+ if err != nil {
+ return err
+ }
+ return cloneToDirectory(cmd, org, created.AgentID, dir)
+ }}
+ cmd.Flags().StringVar(&name, "name", "", "Agent name")
+ cmd.Flags().StringVar(&description, "description", "", "Agent description")
+ return cmd
+}
+func newCloneCmd() *cobra.Command {
+ var id, dir string
+ cmd := &cobra.Command{Use: "clone", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error {
+ org, err := organizationID()
+ if err != nil {
+ return err
+ }
+ if id == "" {
+ if utils.IsNonInteractive(cmd) {
+ return fmt.Errorf("major agent clone requires --id in non-interactive mode")
+ }
+ list, err := singletons.GetAPIClient().ListAgents(org, false)
+ if err != nil {
+ return err
+ }
+ if len(list.Agents) == 0 {
+ return fmt.Errorf("no editable agents in this organization")
+ }
+ options := make([]huh.Option[string], 0, len(list.Agents))
+ for _, item := range list.Agents {
+ options = append(options, huh.NewOption(item.Name+" ("+item.AgentID+")", item.AgentID))
+ }
+ if err := huh.NewSelect[string]().Title("Choose an agent").Options(options...).Value(&id).Run(); err != nil {
+ return err
+ }
+ }
+ if dir == "" {
+ dir = id
+ }
+ abs, err := filepath.Abs(dir)
+ if err != nil {
+ return err
+ }
+ if _, err := os.Lstat(abs); err == nil {
+ return fmt.Errorf("directory %s already exists", abs)
+ } else if !os.IsNotExist(err) {
+ return err
+ }
+ return cloneToDirectory(cmd, org, id, abs)
+ }}
+ cmd.Flags().StringVar(&id, "id", "", "Agent ID")
+ cmd.Flags().StringVar(&dir, "dir", "", "New directory (defaults to agent ID)")
+ return cmd
+}
+func cloneToDirectory(cmd *cobra.Command, org, id, dir string) error {
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return err
+ }
+ cfg := workspace.Config{OrganizationID: org, Target: workspace.Target{Kind: "agent", AgentID: id}}
+ if err := workspace.Write(dir, cfg); err != nil {
+ return err
+ }
+ result, err := (&bundleTarget{}).Pull(&TargetContext{Root: dir, Config: &cfg, API: singletons.GetAPIClient(), Command: cmd})
+ if err != nil {
+ return err
+ }
+ pull := result.(versionResult)
+ return output(cmd, cloneResult{AgentID: pull.AgentID, Path: dir, Version: pull.Version, name: pull.Name})
+}
+
+func TargetCommands() []*cobra.Command {
+ commands := make([]*cobra.Command, 0, 4)
+ for _, action := range []string{"pull", "push", "validate", "publish"} {
+ action := action
+ cmd := &cobra.Command{Use: action, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error {
+ if action == "publish" {
+ yes, _ := cmd.Flags().GetBool("yes")
+ if !yes {
+ if utils.IsNonInteractive(cmd) {
+ return fmt.Errorf("major publish requires --yes in non-interactive mode")
+ }
+ var confirmed bool
+ if err := huh.NewConfirm().Title("Publish the latest saved agent version?").Value(&confirmed).Run(); err != nil {
+ return err
+ }
+ if !confirmed {
+ return fmt.Errorf("publish cancelled")
+ }
+ }
+ }
+ return runTargetAction(cmd, action)
+ }}
+ cmd.Flags().Bool("json", false, "Print one JSON result")
+ if action == "push" {
+ cmd.Flags().StringP("message", "m", "", "Version notes")
+ }
+ if action == "publish" {
+ cmd.Flags().Bool("yes", false, "Skip confirmation")
+ }
+ commands = append(commands, cmd)
+ }
+ return commands
+}
diff --git a/cmd/agent/target.go b/cmd/agent/target.go
new file mode 100644
index 0000000..af494c6
--- /dev/null
+++ b/cmd/agent/target.go
@@ -0,0 +1,252 @@
+package agent
+
+import (
+ "archive/zip"
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/major-technology/cli/clients/api"
+ "github.com/major-technology/cli/clients/transfer"
+ "github.com/major-technology/cli/clients/workspace"
+ "github.com/major-technology/cli/singletons"
+ "github.com/spf13/cobra"
+)
+
+type TargetContext struct {
+ Root string
+ Config *workspace.Config
+ API *api.Client
+ Command *cobra.Command
+ Notes string
+}
+type Target interface {
+ Pull(*TargetContext) (any, error)
+ Push(*TargetContext) (any, error)
+ Validate(*TargetContext) (any, error)
+ Publish(*TargetContext) (any, error)
+}
+type bundleTarget struct {
+ kind string
+ apiPath string
+ files []string
+}
+
+// versionResult is the pull/push output; it leaves out the presigned download URL.
+type versionResult struct {
+ AgentID string `json:"agentId"`
+ Name string `json:"name"`
+ Version int `json:"version"`
+ action string
+}
+
+func (r versionResult) String() string {
+ return fmt.Sprintf("%s %s (version %d).", r.action, r.Name, r.Version)
+}
+
+type validateResult struct{ *api.AgentValidateResponse }
+
+func (r validateResult) String() string {
+ lines := []string{"Agent bundle is valid."}
+ for _, warning := range r.Warnings {
+ lines = append(lines, "Warning: "+warning)
+ }
+ return strings.Join(lines, "\n")
+}
+
+type publishResult struct{ *api.AgentPublishResponse }
+
+func (r publishResult) String() string {
+ return fmt.Sprintf("Published version %d.", r.Version)
+}
+
+var registry = map[string]Target{"agent": bundleTarget{kind: "agent", apiPath: "agents", files: []string{"agent.jsonc", "agent.json", "prompt.md"}}}
+
+func runTargetAction(cmd *cobra.Command, action string) error {
+ cwd, err := os.Getwd()
+ if err != nil {
+ return err
+ }
+ root, cfg, err := workspace.Locate(cwd)
+ if errors.Is(err, workspace.ErrNotFound) {
+ return fmt.Errorf("no .major/config.json found; run major agent clone or major agent create")
+ }
+ if err != nil {
+ return err
+ }
+ target, ok := registry[cfg.Target.Kind]
+ if !ok {
+ return fmt.Errorf("major %s supports agent targets only for now", action)
+ }
+ ctx := &TargetContext{Root: root, Config: cfg, API: singletons.GetAPIClient(), Command: cmd}
+ if flag := cmd.Flags().Lookup("message"); flag != nil {
+ ctx.Notes, _ = cmd.Flags().GetString("message")
+ }
+ var result any
+ switch action {
+ case "pull":
+ result, err = target.Pull(ctx)
+ case "push":
+ result, err = target.Push(ctx)
+ case "validate":
+ result, err = target.Validate(ctx)
+ case "publish":
+ result, err = target.Publish(ctx)
+ default:
+ return fmt.Errorf("unknown target action %q", action)
+ }
+ if err != nil {
+ return err
+ }
+ return output(cmd, result)
+}
+
+func (t bundleTarget) Pull(ctx *TargetContext) (any, error) {
+ resp, err := ctx.API.PullAgent(ctx.Config.Target.ID())
+ if err != nil {
+ return nil, err
+ }
+ data, err := transfer.Download(resp.DownloadURL)
+ if err != nil {
+ return nil, err
+ }
+ if err := unpackAgent(ctx.Root, data); err != nil {
+ return nil, err
+ }
+ return versionResult{AgentID: resp.AgentID, Name: resp.Name, Version: resp.Version, action: "Pulled"}, nil
+}
+func (t bundleTarget) Push(ctx *TargetContext) (any, error) {
+ files, err := readAgentFiles(ctx.Root)
+ if err != nil {
+ return nil, err
+ }
+ var b bytes.Buffer
+ z := zip.NewWriter(&b)
+ for _, file := range files {
+ w, err := z.Create(file.Path)
+ if err != nil {
+ return nil, err
+ }
+ if _, err = w.Write([]byte(file.Content)); err != nil {
+ return nil, err
+ }
+ }
+ if err := z.Close(); err != nil {
+ return nil, err
+ }
+ upload, err := ctx.API.AgentUploadURL(ctx.Config.Target.ID())
+ if err != nil {
+ return nil, err
+ }
+ if err := transfer.Upload(upload.UploadURL, b.Bytes()); err != nil {
+ return nil, err
+ }
+ pushed, err := ctx.API.PushAgent(ctx.Config.Target.ID(), upload.UploadKey, ctx.Notes)
+ if err != nil {
+ return nil, err
+ }
+ return versionResult{AgentID: pushed.AgentID, Name: pushed.Name, Version: pushed.Version, action: "Saved"}, nil
+}
+func (t bundleTarget) Validate(ctx *TargetContext) (any, error) {
+ files, err := readAgentFiles(ctx.Root)
+ if err != nil {
+ return nil, err
+ }
+ result, err := ctx.API.ValidateAgent(ctx.Config.Target.ID(), files)
+ if err != nil {
+ return nil, err
+ }
+ if !result.Valid {
+ messages := make([]string, 0, len(result.Errors))
+ for _, item := range result.Errors {
+ messages = append(messages, item.Message)
+ }
+ return nil, fmt.Errorf("agent bundle is invalid: %s", strings.Join(messages, "; "))
+ }
+ return validateResult{result}, nil
+}
+func (t bundleTarget) Publish(ctx *TargetContext) (any, error) {
+ published, err := ctx.API.PublishAgent(ctx.Config.Target.ID())
+ if err != nil {
+ return nil, err
+ }
+ return publishResult{published}, nil
+}
+
+func readAgentFiles(root string) ([]api.AgentFile, error) {
+ var files []api.AgentFile
+ for _, name := range []string{"agent.jsonc", "agent.json", "prompt.md"} {
+ path := filepath.Join(root, name)
+ info, err := os.Lstat(path)
+ if os.IsNotExist(err) {
+ continue
+ }
+ if err != nil {
+ return nil, err
+ }
+ if !info.Mode().IsRegular() {
+ return nil, fmt.Errorf("%s must be a regular file", name)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ files = append(files, api.AgentFile{Path: name, Content: string(data)})
+ }
+ if len(files) != 2 {
+ return nil, fmt.Errorf("agent workspace requires prompt.md and exactly one of agent.jsonc or agent.json")
+ }
+ if files[0].Path == "prompt.md" || files[1].Path != "prompt.md" {
+ return nil, fmt.Errorf("agent workspace requires prompt.md and exactly one agent definition")
+ }
+ return files, nil
+}
+func unpackAgent(root string, data []byte) error {
+ reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
+ if err != nil {
+ return err
+ }
+ files := map[string][]byte{}
+ for _, entry := range reader.File {
+ if entry.Name != "agent.jsonc" && entry.Name != "agent.json" && entry.Name != "prompt.md" {
+ return fmt.Errorf("unexpected agent archive entry %q", entry.Name)
+ }
+ if _, exists := files[entry.Name]; exists {
+ return fmt.Errorf("duplicate agent archive entry %q", entry.Name)
+ }
+ if entry.UncompressedSize64 > 20<<20 {
+ return fmt.Errorf("agent archive entry too large")
+ }
+ in, err := entry.Open()
+ if err != nil {
+ return err
+ }
+ content, err := io.ReadAll(io.LimitReader(in, (20<<20)+1))
+ _ = in.Close()
+ if err != nil {
+ return err
+ }
+ if len(content) > 20<<20 {
+ return fmt.Errorf("agent archive entry too large")
+ }
+ files[entry.Name] = content
+ }
+ if len(files) != 2 || files["prompt.md"] == nil || (files["agent.jsonc"] == nil && files["agent.json"] == nil) {
+ return fmt.Errorf("agent archive requires prompt.md and exactly one definition")
+ }
+ for _, name := range []string{"agent.jsonc", "agent.json", "prompt.md"} {
+ if err := os.Remove(filepath.Join(root, name)); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ }
+ for name, content := range files {
+ if err := os.WriteFile(filepath.Join(root, name), content, 0644); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/cmd/agent/target_test.go b/cmd/agent/target_test.go
new file mode 100644
index 0000000..44560e3
--- /dev/null
+++ b/cmd/agent/target_test.go
@@ -0,0 +1,124 @@
+package agent
+
+import (
+ "archive/zip"
+ "bytes"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/major-technology/cli/clients/workspace"
+ "github.com/spf13/cobra"
+)
+
+type fakeTarget struct{ called []string }
+
+func (f *fakeTarget) Pull(*TargetContext) (any, error) {
+ f.called = append(f.called, "pull")
+ return map[string]any{"action": "pull"}, nil
+}
+func (f *fakeTarget) Push(*TargetContext) (any, error) {
+ f.called = append(f.called, "push")
+ return map[string]any{"action": "push"}, nil
+}
+func (f *fakeTarget) Validate(*TargetContext) (any, error) {
+ f.called = append(f.called, "validate")
+ return map[string]any{"action": "validate"}, nil
+}
+func (f *fakeTarget) Publish(*TargetContext) (any, error) {
+ f.called = append(f.called, "publish")
+ return map[string]any{"action": "publish"}, nil
+}
+
+func TestPullOverwritesOnlyAgentFilesAndRejectsUnexpectedArchiveEntries(t *testing.T) {
+ root := t.TempDir()
+ if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("keep"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ var b bytes.Buffer
+ writer := zip.NewWriter(&b)
+ for name, content := range map[string]string{"agent.jsonc": "{}", "prompt.md": "hello"} {
+ file, err := writer.Create(name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := file.Write([]byte(content)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := unpackAgent(root, b.Bytes()); err != nil {
+ t.Fatal(err)
+ }
+ if data, err := os.ReadFile(filepath.Join(root, "notes.txt")); err != nil || string(data) != "keep" {
+ t.Fatalf("unrelated file: %s %v", data, err)
+ }
+ b.Reset()
+ writer = zip.NewWriter(&b)
+ file, err := writer.Create("../outside")
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, _ = file.Write([]byte("bad"))
+ _ = writer.Close()
+ if err := unpackAgent(root, b.Bytes()); err == nil {
+ t.Fatal("traversal entry accepted")
+ }
+ if data, _ := os.ReadFile(filepath.Join(root, "prompt.md")); string(data) != "hello" {
+ t.Fatalf("rejected archive changed workspace: %s", data)
+ }
+}
+
+func TestRegisteredTargetRunsAllActionsWithoutRunnerChanges(t *testing.T) {
+ root := t.TempDir()
+ if err := workspace.Write(root, workspace.Config{OrganizationID: "org-1", Target: workspace.Target{Kind: "skill", SkillID: "11111111-1111-4111-8111-111111111111"}}); err != nil {
+ t.Fatal(err)
+ }
+ original, _ := os.Getwd()
+ defer os.Chdir(original)
+ if err := os.Chdir(root); err != nil {
+ t.Fatal(err)
+ }
+ f := &fakeTarget{}
+ registry["skill"] = f
+ defer delete(registry, "skill")
+ for _, action := range []string{"pull", "push", "validate", "publish"} {
+ cmd := &cobra.Command{Use: action}
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.Flags().Bool("json", true, "")
+ if err := runTargetAction(cmd, action); err != nil {
+ t.Fatalf("%s: %v", action, err)
+ }
+ if !bytes.Contains(out.Bytes(), []byte(`"action":"`+action+`"`)) {
+ t.Fatalf("%s output: %q", action, out.String())
+ }
+ }
+ if len(f.called) != 4 {
+ t.Fatalf("calls=%v", f.called)
+ }
+}
+
+func TestTextOutputIsReadableAndOmitsDownloadURL(t *testing.T) {
+ cmd := &cobra.Command{}
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ cmd.Flags().Bool("json", false, "")
+ if err := output(cmd, versionResult{AgentID: "a1", Name: "Helper", Version: 2, action: "Pulled"}); err != nil {
+ t.Fatal(err)
+ }
+ if out.String() != "Pulled Helper (version 2).\n" {
+ t.Fatalf("text output: %q", out.String())
+ }
+ out.Reset()
+ _ = cmd.Flags().Set("json", "true")
+ if err := output(cmd, versionResult{AgentID: "a1", Name: "Helper", Version: 2, action: "Pulled"}); err != nil {
+ t.Fatal(err)
+ }
+ if out.String() != "{\"agentId\":\"a1\",\"name\":\"Helper\",\"version\":2}\n" {
+ t.Fatalf("json output: %q", out.String())
+ }
+}
diff --git a/cmd/root.go b/cmd/root.go
index 23ff13c..42c3f4d 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -10,6 +10,7 @@ import (
"github.com/major-technology/cli/clients/api"
"github.com/major-technology/cli/clients/config"
mjrToken "github.com/major-technology/cli/clients/token"
+ "github.com/major-technology/cli/cmd/agent"
"github.com/major-technology/cli/cmd/app"
cliconfig "github.com/major-technology/cli/cmd/config"
"github.com/major-technology/cli/cmd/demo"
@@ -125,6 +126,13 @@ func init() {
app.Cmd.GroupID = "main"
rootCmd.AddCommand(app.Cmd)
+ agent.Cmd.GroupID = "main"
+ rootCmd.AddCommand(agent.Cmd)
+ for _, command := range agent.TargetCommands() {
+ command.GroupID = "main"
+ rootCmd.AddCommand(command)
+ }
+
rootCmd.AddCommand(demo.Cmd)
resource.Cmd.GroupID = "main"