Skip to content
Open
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,7 @@ For detailed usage instructions, configuration options, and full command referen
## License

[MIT](LICENSE)

### Agent and skill commands

`major app list [--editable]`, `major agent list [--editable]`, `major agent get [agent-id]`, `major agent create --name NAME [--description TEXT]`, `major agent run [agent-id] --prompt TEXT [--name TITLE]`, `major agent runs list [--agent ID] [--all-users]`, `major agent runs content RUN-ID [--limit N]`, `major agent runs send RUN-ID --message TEXT`, `major agent runs stop RUN-ID`, `major agent channel connect|pause|resume|delete [agent-id] --type slack`, `major agent permissions resource|app [agent-id] TARGET-ID`, `major skill list [--editable] [--published]`, `major skill get [skill-id]`, and `major skill create` accept `--json` for route-shaped output. In an agent or skill workspace, its `.major/config.json` supplies an omitted matching ID; an explicit ID overrides it. Lists and creates use the organization bound to the API token without a checkout or keyring default. Starting runs, connecting Slack, deleting channels, and creating definitions require a human CLI token; server authorization governs other operations.
84 changes: 84 additions & 0 deletions clients/api/agent.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package api

import (
"fmt"
"net/http"
"net/url"
)

type Record = map[string]any

func (c *Client) agentRequest(method, path string, body any) (Record, error) {
var out Record
err := c.doRequest(method, path, body, &out)
return out, err
}
func agentPath(id string) string { return "/agents/" + url.PathEscape(id) }
func runPath(id string) string { return "/agent-runs/" + url.PathEscape(id) }
func (c *Client) ListAgents(editable bool) (Record, error) {
p := "/agents"
if editable {
p += "?editable=true"
}
return c.agentRequest("GET", p, nil)
}
func (c *Client) GetAgent(id string) (Record, error) {
return c.agentRequest("GET", agentPath(id), nil)
}
func (c *Client) CreateAgent(name, description string) (Record, error) {
body := Record{"name": name}
if description != "" {
body["description"] = description
}
return c.agentRequest("POST", "/agents", body)
}
func (c *Client) StartAgentRun(id, prompt, name string) (Record, error) {
body := Record{"prompt": prompt}
if name != "" {
body["name"] = name
}
return c.agentRequest("POST", agentPath(id)+"/runs", body)
}
func (c *Client) ListAgentRuns(agentID string, allUsers bool) (Record, error) {
q := url.Values{}
if agentID != "" {
q.Set("agentId", agentID)
}
if allUsers {
q.Set("allUsers", "true")
}
p := "/agent-runs"
if len(q) > 0 {
p += "?" + q.Encode()
}
return c.agentRequest("GET", p, nil)
}
func (c *Client) GetAgentRunContent(id string, limit int) (Record, error) {
p := runPath(id) + "/content"
if limit > 0 {
p += fmt.Sprintf("?n=%d", limit)
}
return c.agentRequest("GET", p, nil)
}
func (c *Client) SendAgentRunMessage(id, message string) (Record, error) {
return c.agentRequest("POST", runPath(id)+"/messages", Record{"message": message})
}
func (c *Client) StopAgentRun(id string) (Record, error) {
return c.agentRequest("POST", runPath(id)+"/stop", nil)
}
func (c *Client) AgentChannel(id, action string) (Record, error) {
method := http.MethodPost
p := agentPath(id) + "/channel"
if action != "delete" {
p += "/" + action
} else {
method = http.MethodDelete
}
return c.agentRequest(method, p, Record{"type": "slack"})
}
func (c *Client) AgentResourcePermissions(agentID, resourceID string) (Record, error) {
return c.agentRequest("GET", agentPath(agentID)+"/permissions/resources/"+url.PathEscape(resourceID), nil)
}
func (c *Client) AgentAppPermissions(agentID, appID string) (Record, error) {
return c.agentRequest("GET", agentPath(agentID)+"/permissions/apps/"+url.PathEscape(appID), nil)
}
82 changes: 82 additions & 0 deletions clients/api/agent_skill_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package api

import (
"net/http"
"net/http/httptest"
"strings"
"testing"

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

func TestAgentRunWrongTokenTypeMessage(t *testing.T) {
previous := testTokenOverride
testTokenOverride = "session-token"
defer func() { testTokenOverride = previous }()

const message = "Can't start an agent run through the CLI from an AI session. Use the MCP run_agent tool instead."
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":{"internal_code":1006,"error_string":"forbidden","status_code":403,"message":"` + message + `"}}`))
}))
defer server.Close()

_, err := NewClient(server.URL).StartAgentRun("agent-id", "hello", "")
cliErr, ok := err.(*clierrors.CLIError)
if !ok || cliErr.Title != message || cliErr.StatusCode != http.StatusForbidden {
t.Fatalf("wrong token error = %#v, want API message and 403", err)
}
}

func TestAgentSkillRoutes(t *testing.T) {
previous := testTokenOverride
testTokenOverride = "test-token"
defer func() { testTokenOverride = previous }()
cases := []struct {
name, method, path, body string
call func(*Client) error
}{
{"agents", "GET", "/agents?editable=true", "", func(c *Client) error { _, e := c.ListAgents(true); return e }},
{"run", "POST", "/agents/a%2Fb/runs", `{"prompt":"hello"}`, func(c *Client) error { _, e := c.StartAgentRun("a/b", "hello", ""); return e }},
{"runs", "GET", "/agent-runs?allUsers=true", "", func(c *Client) error { _, e := c.ListAgentRuns("", true); return e }},
{"content", "GET", "/agent-runs/a%2Fb/content?n=3", "", func(c *Client) error { _, e := c.GetAgentRunContent("a/b", 3); return e }},
{"send", "POST", "/agent-runs/r/messages", `{"message":"hi"}`, func(c *Client) error { _, e := c.SendAgentRunMessage("r", "hi"); return e }},
{"channel", "DELETE", "/agents/a/channel", `{"type":"slack"}`, func(c *Client) error { _, e := c.AgentChannel("a", "delete"); return e }},
{"skills", "GET", "/skills?published=true", "", func(c *Client) error { _, e := c.ListSkills(false, true); return e }},
{"apps", "GET", "/apps?editable=true", "", func(c *Client) error { _, e := c.ListApps(true); return e }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
status := http.StatusOK
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != tc.method || r.URL.EscapedPath()+func() string {
if r.URL.RawQuery != "" {
return "?" + r.URL.RawQuery
}
return ""
}() != tc.path {
t.Errorf("got %s %s", r.Method, r.URL.String())
}
b := make([]byte, 1024)
n, _ := r.Body.Read(b)
if string(b[:n]) != tc.body {
t.Errorf("body %q want %q", b[:n], tc.body)
}
if r.Header.Get("Authorization") != "Bearer test-token" {
t.Error("missing auth")
}
w.WriteHeader(status)
w.Write([]byte(`{}`))
}))
defer s.Close()
c := NewClient(s.URL)
if err := tc.call(c); err != nil {
t.Fatal(err)
}
status = 403
if err := tc.call(c); err == nil || strings.Contains(err.Error(), "test-token") {
t.Fatalf("expected safe HTTP error, got %v", err)
}
})
}
}
10 changes: 9 additions & 1 deletion clients/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,15 @@ func (c *Client) GetResources(organizationID string) (*GetResourcesResponse, err
return &resp, nil
}

// ListResources retrieves resources in the authenticated token's organization.
func (c *Client) ListResources() (*GetResourcesResponse, error) {
var resp GetResourcesResponse
if err := c.doRequest("GET", "/resources", nil, &resp); err != nil {
return nil, err
}
return &resp, nil
}

// SaveApplicationResources saves the selected resources for an application
func (c *Client) SaveApplicationResources(organizationID, applicationID string, resourceIDs []string) (*SaveApplicationResourcesResponse, error) {
req := SaveApplicationResourcesRequest{
Expand All @@ -330,7 +339,6 @@ func (c *Client) SaveApplicationResources(organizationID, applicationID string,
return &resp, nil
}


// --- Version Check endpoints ---

// CheckVersion checks if the CLI version is up to date
Expand Down
13 changes: 10 additions & 3 deletions clients/api/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type AppErrorDetail struct {
InternalCode int `json:"internal_code"`
ErrorString string `json:"error_string"`
StatusCode int `json:"status_code"`
Message string `json:"message"`
}

// ErrorResponse represents an error response from the API (new format only)
Expand Down Expand Up @@ -79,10 +80,16 @@ func ToCLIError(errResp *ErrorResponse) error {
return cliErr
}

// No specific mapping - create a generic CLIError with API details
// Preserve the API's specific explanation for errors without a CLI mapping.
title := errResp.Error.Message
suggestion := ""
if title == "" {
title = fmt.Sprintf("API Error (Code: %d)", errResp.Error.InternalCode)
suggestion = "Please try again or contact support if the issue persists."
}
return &clierrors.CLIError{
Title: fmt.Sprintf("API Error (Code: %d)", errResp.Error.InternalCode),
Suggestion: "Please try again or contact support if the issue persists.",
Title: title,
Suggestion: suggestion,
Err: fmt.Errorf("%s", errResp.Error.ErrorString),
StatusCode: errResp.Error.StatusCode,
}
Expand Down
29 changes: 29 additions & 0 deletions clients/api/skill.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package api

import "net/url"

func (c *Client) ListSkills(editable, published bool) (Record, error) {
q := url.Values{}
if editable {
q.Set("editable", "true")
}
if published {
q.Set("published", "true")
}
p := "/skills"
if len(q) > 0 {
p += "?" + q.Encode()
}
return c.agentRequest("GET", p, nil)
}
func (c *Client) GetSkill(id string) (Record, error) {
return c.agentRequest("GET", "/skills/"+url.PathEscape(id), nil)
}
func (c *Client) CreateSkill() (Record, error) { return c.agentRequest("POST", "/skills", Record{}) }
func (c *Client) ListApps(editable bool) (Record, error) {
p := "/apps"
if editable {
p += "?editable=true"
}
return c.agentRequest("GET", p, nil)
}
Loading
Loading