From b6d70c585bb1554da940b885d33bf5a75480ca10 Mon Sep 17 00:00:00 2001 From: josegironn <30703536+josegironn@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:35:26 -0700 Subject: [PATCH 1/2] feat: add local agent build commands Add agent list/create/clone and workspace pull/push/validate/publish using the shared CLI agent endpoints. Co-authored-by: Orca --- clients/api/agents.go | 92 +++++++++++++ clients/api/agents_test.go | 43 ++++++ clients/api/errors.go | 10 ++ clients/api/errors_test.go | 15 ++ clients/transfer/transfer.go | 46 +++++++ clients/transfer/transfer_test.go | 40 ++++++ clients/workspace/config.go | 32 ++++- clients/workspace/config_test.go | 19 +++ cmd/agent/agent.go | 187 +++++++++++++++++++++++++ cmd/agent/target.go | 222 ++++++++++++++++++++++++++++++ cmd/agent/target_test.go | 102 ++++++++++++++ cmd/root.go | 8 ++ 12 files changed, 810 insertions(+), 6 deletions(-) create mode 100644 clients/api/agents.go create mode 100644 clients/api/agents_test.go create mode 100644 clients/api/errors_test.go create mode 100644 clients/transfer/transfer.go create mode 100644 clients/transfer/transfer_test.go create mode 100644 cmd/agent/agent.go create mode 100644 cmd/agent/target.go create mode 100644 cmd/agent/target_test.go 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..e492fb2 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"` } @@ -74,6 +76,14 @@ var errorCodeToCLIError = map[int]*clierrors.CLIError{ // If a specific error code mapping exists, it returns that CLIError // Otherwise, it creates a generic CLIError with the API error details func ToCLIError(errResp *ErrorResponse) error { + if errResp.Error.Message != "" { + result := &clierrors.CLIError{Title: errResp.Error.Message, StatusCode: errResp.Error.StatusCode} + if mapped, exists := errorCodeToCLIError[errResp.Error.InternalCode]; exists { + result.Suggestion = mapped.Suggestion + } + return result + } + // Check if we have a specific mapping for this error code if cliErr, exists := errorCodeToCLIError[errResp.Error.InternalCode]; exists { return cliErr diff --git a/clients/api/errors_test.go b/clients/api/errors_test.go new file mode 100644 index 0000000..d165987 --- /dev/null +++ b/clients/api/errors_test.go @@ -0,0 +1,15 @@ +package api + +import ( + "strings" + "testing" +) + +func TestToCLIErrorUsesServerMessage(t *testing.T) { + for _, code := range []int{2007, 9876, ErrorCodeApplicationNotFound} { + 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) + } + } +} 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..8548cd3 --- /dev/null +++ b/cmd/agent/agent.go @@ -0,0 +1,187 @@ +package agent + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "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 json.NewEncoder(cmd.OutOrStdout()).Encode(result) + } + cmd.Println(result) + return nil +} +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, 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.(*api.AgentPullResponse) + return output(cmd, map[string]any{"agentId": pull.AgentID, "path": dir, "version": pull.Version}) +} + +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..4f4e221 --- /dev/null +++ b/cmd/agent/target.go @@ -0,0 +1,222 @@ +package agent + +import ( + "archive/zip" + "bytes" + "encoding/json" + "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 +} + +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 + } + jsonOutput, _ := cmd.Flags().GetBool("json") + if jsonOutput { + return json.NewEncoder(cmd.OutOrStdout()).Encode(result) + } + cmd.Println(result) + return nil +} + +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 resp, 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 + } + return ctx.API.PushAgent(ctx.Config.Target.ID(), upload.UploadKey, ctx.Notes) +} +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 result, nil +} +func (t bundleTarget) Publish(ctx *TargetContext) (any, error) { + return ctx.API.PublishAgent(ctx.Config.Target.ID()) +} + +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..d39839a --- /dev/null +++ b/cmd/agent/target_test.go @@ -0,0 +1,102 @@ +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) + } +} 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" From 338d82d00b77c08b9e825953c641096c7e3194e6 Mon Sep 17 00:00:00 2001 From: josegironn <30703536+josegironn@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:43:26 -0700 Subject: [PATCH 2/2] fix(agent): keep mapped API errors and print readable agent output ToCLIError returned a new error whenever the server sent a message, which broke errors.Is checks such as the login authorization-pending poll. Only 2007 and unmapped codes now use the server message. Agent commands printed raw Go values without --json and exposed the presigned download URL. Each command now prints one readable line, and pull/clone output omits the download URL. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0167qqfryVyv1zVGs8MFVMDY Co-authored-by: Orca --- clients/api/errors.go | 13 ++++------ clients/api/errors_test.go | 14 ++++++++-- cmd/agent/agent.go | 39 ++++++++++++++++++++++++---- cmd/agent/target.go | 52 ++++++++++++++++++++++++++++++-------- cmd/agent/target_test.go | 22 ++++++++++++++++ 5 files changed, 114 insertions(+), 26 deletions(-) diff --git a/clients/api/errors.go b/clients/api/errors.go index e492fb2..a8bdae9 100644 --- a/clients/api/errors.go +++ b/clients/api/errors.go @@ -76,19 +76,16 @@ var errorCodeToCLIError = map[int]*clierrors.CLIError{ // If a specific error code mapping exists, it returns that CLIError // Otherwise, it creates a generic CLIError with the API error details func ToCLIError(errResp *ErrorResponse) error { - if errResp.Error.Message != "" { - result := &clierrors.CLIError{Title: errResp.Error.Message, StatusCode: errResp.Error.StatusCode} - if mapped, exists := errorCodeToCLIError[errResp.Error.InternalCode]; exists { - result.Suggestion = mapped.Suggestion - } - return result - } - // Check if we have a specific mapping for this error code if cliErr, exists := errorCodeToCLIError[errResp.Error.InternalCode]; exists { 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 index d165987..009d875 100644 --- a/clients/api/errors_test.go +++ b/clients/api/errors_test.go @@ -1,15 +1,25 @@ package api import ( + "errors" "strings" "testing" + + clierrors "github.com/major-technology/cli/errors" ) -func TestToCLIErrorUsesServerMessage(t *testing.T) { - for _, code := range []int{2007, 9876, ErrorCodeApplicationNotFound} { +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/cmd/agent/agent.go b/cmd/agent/agent.go index 8548cd3..d3b7b58 100644 --- a/cmd/agent/agent.go +++ b/cmd/agent/agent.go @@ -1,11 +1,11 @@ package agent import ( - "encoding/json" "errors" "fmt" "os" "path/filepath" + "strings" "github.com/charmbracelet/huh" "github.com/major-technology/cli/clients/api" @@ -42,11 +42,40 @@ func organizationID() (string, error) { func output(cmd *cobra.Command, result any) error { jsonOutput, _ := cmd.Flags().GetBool("json") if jsonOutput { - return json.NewEncoder(cmd.OutOrStdout()).Encode(result) + 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 { @@ -58,7 +87,7 @@ func newListCmd() *cobra.Command { if err != nil { return err } - return output(cmd, result) + return output(cmd, listResult{result}) }} cmd.Flags().BoolVar(&readOnly, "include-read-only", false, "Include agents you can use but not edit") return cmd @@ -148,8 +177,8 @@ func cloneToDirectory(cmd *cobra.Command, org, id, dir string) error { if err != nil { return err } - pull := result.(*api.AgentPullResponse) - return output(cmd, map[string]any{"agentId": pull.AgentID, "path": dir, "version": pull.Version}) + pull := result.(versionResult) + return output(cmd, cloneResult{AgentID: pull.AgentID, Path: dir, Version: pull.Version, name: pull.Name}) } func TargetCommands() []*cobra.Command { diff --git a/cmd/agent/target.go b/cmd/agent/target.go index 4f4e221..af494c6 100644 --- a/cmd/agent/target.go +++ b/cmd/agent/target.go @@ -3,7 +3,6 @@ package agent import ( "archive/zip" "bytes" - "encoding/json" "errors" "fmt" "io" @@ -37,6 +36,34 @@ type bundleTarget struct { 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 { @@ -75,12 +102,7 @@ func runTargetAction(cmd *cobra.Command, action string) error { if err != nil { return err } - jsonOutput, _ := cmd.Flags().GetBool("json") - if jsonOutput { - return json.NewEncoder(cmd.OutOrStdout()).Encode(result) - } - cmd.Println(result) - return nil + return output(cmd, result) } func (t bundleTarget) Pull(ctx *TargetContext) (any, error) { @@ -95,7 +117,7 @@ func (t bundleTarget) Pull(ctx *TargetContext) (any, error) { if err := unpackAgent(ctx.Root, data); err != nil { return nil, err } - return resp, nil + 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) @@ -123,7 +145,11 @@ func (t bundleTarget) Push(ctx *TargetContext) (any, error) { if err := transfer.Upload(upload.UploadURL, b.Bytes()); err != nil { return nil, err } - return ctx.API.PushAgent(ctx.Config.Target.ID(), upload.UploadKey, ctx.Notes) + 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) @@ -141,10 +167,14 @@ func (t bundleTarget) Validate(ctx *TargetContext) (any, error) { } return nil, fmt.Errorf("agent bundle is invalid: %s", strings.Join(messages, "; ")) } - return result, nil + return validateResult{result}, nil } func (t bundleTarget) Publish(ctx *TargetContext) (any, error) { - return ctx.API.PublishAgent(ctx.Config.Target.ID()) + 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) { diff --git a/cmd/agent/target_test.go b/cmd/agent/target_test.go index d39839a..44560e3 100644 --- a/cmd/agent/target_test.go +++ b/cmd/agent/target_test.go @@ -100,3 +100,25 @@ func TestRegisteredTargetRunsAllActionsWithoutRunnerChanges(t *testing.T) { 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()) + } +}