diff --git a/README.md b/README.md index b5d190c..2eeaf20 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/clients/api/agent.go b/clients/api/agent.go new file mode 100644 index 0000000..ee3255f --- /dev/null +++ b/clients/api/agent.go @@ -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) +} diff --git a/clients/api/agent_skill_test.go b/clients/api/agent_skill_test.go new file mode 100644 index 0000000..5f8d518 --- /dev/null +++ b/clients/api/agent_skill_test.go @@ -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) + } + }) + } +} diff --git a/clients/api/client.go b/clients/api/client.go index 7746fe1..1136201 100644 --- a/clients/api/client.go +++ b/clients/api/client.go @@ -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{ @@ -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 diff --git a/clients/api/errors.go b/clients/api/errors.go index e273a4b..17e4c35 100644 --- a/clients/api/errors.go +++ b/clients/api/errors.go @@ -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) @@ -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, } diff --git a/clients/api/skill.go b/clients/api/skill.go new file mode 100644 index 0000000..b3280a5 --- /dev/null +++ b/clients/api/skill.go @@ -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) +} diff --git a/cmd/agent/agent.go b/cmd/agent/agent.go new file mode 100644 index 0000000..541853c --- /dev/null +++ b/cmd/agent/agent.go @@ -0,0 +1,189 @@ +package agent + +import ( + "errors" + "fmt" + "os" + "text/tabwriter" + + "github.com/major-technology/cli/clients/api" + "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 agents", Args: cobra.NoArgs} + +func target(id, kind string) (string, error) { + if id != "" { + return id, nil + } + dir, err := os.Getwd() + if err != nil { + return "", err + } + cfg, err := workspace.Load(dir) + if err != nil { + if errors.Is(err, workspace.ErrNotFound) { + return "", fmt.Errorf("%s ID required outside a %s workspace", kind, kind) + } + return "", err + } + if cfg.Target.Kind != kind { + return "", fmt.Errorf("%s ID required: workspace target is %s", kind, cfg.Target.Kind) + } + if kind == "agent" { + return cfg.Target.AgentID, nil + } + return cfg.Target.SkillID, nil +} +func command(use string, min, max int, invoke func(*cobra.Command, []string) (api.Record, error)) *cobra.Command { + c := &cobra.Command{Use: use, Args: cobra.RangeArgs(min, max), RunE: func(cmd *cobra.Command, args []string) error { + v, e := invoke(cmd, args) + if e != nil { + return e + } + if j, _ := cmd.Flags().GetBool("json"); j { + return utils.WriteJSON(cmd, v) + } + return printResult(cmd, v) + }} + c.Flags().Bool("json", false, "Output route fields as JSON") + return c +} +func printResult(cmd *cobra.Command, v api.Record) error { + if runs, ok := v["runs"].([]any); ok { + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintln(w, "Run ID\tAgent ID\tTitle\tStarted by\tStatus"); err != nil { + return err + } + for _, item := range runs { + r, _ := item.(map[string]any) + user := "—" + if u, ok := r["user"].(map[string]any); ok { + user = fmt.Sprint(u["name"]) + } + if _, err := fmt.Fprintf(w, "%v\t%v\t%v\t%s\t%v\n", r["threadId"], r["agentId"], r["title"], user, r["status"]); err != nil { + return err + } + } + return w.Flush() + } + return utils.WriteJSON(cmd, v) +} +func id(args []string) (string, error) { + s := "" + if len(args) > 0 { + s = args[0] + } + return target(s, "agent") +} +func init() { + list := command("list", 0, 0, func(c *cobra.Command, _ []string) (api.Record, error) { + b, _ := c.Flags().GetBool("editable") + return singletons.GetAPIClient().ListAgents(b) + }) + list.Flags().Bool("editable", false, "Only editable agents") + Cmd.AddCommand(list) + get := command("get [agent-id]", 0, 1, func(_ *cobra.Command, a []string) (api.Record, error) { + x, e := id(a) + if e != nil { + return nil, e + } + return singletons.GetAPIClient().GetAgent(x) + }) + Cmd.AddCommand(get) + create := command("create", 0, 0, func(c *cobra.Command, _ []string) (api.Record, error) { + n, _ := c.Flags().GetString("name") + d, _ := c.Flags().GetString("description") + return singletons.GetAPIClient().CreateAgent(n, d) + }) + create.Flags().String("name", "", "Agent name") + create.MarkFlagRequired("name") + create.Flags().String("description", "", "Description") + Cmd.AddCommand(create) + run := command("run [agent-id]", 0, 1, func(c *cobra.Command, a []string) (api.Record, error) { + x, e := id(a) + if e != nil { + return nil, e + } + p, _ := c.Flags().GetString("prompt") + n, _ := c.Flags().GetString("name") + return singletons.GetAPIClient().StartAgentRun(x, p, n) + }) + run.Flags().String("prompt", "", "Run prompt") + run.MarkFlagRequired("prompt") + run.Flags().String("name", "", "Run title") + Cmd.AddCommand(run) + runs := &cobra.Command{Use: "runs", Short: "Manage independent agent runs"} + Cmd.AddCommand(runs) + rl := command("list", 0, 0, func(c *cobra.Command, _ []string) (api.Record, error) { + a, _ := c.Flags().GetString("agent") + all, _ := c.Flags().GetBool("all-users") + return singletons.GetAPIClient().ListAgentRuns(a, all) + }) + rl.Flags().String("agent", "", "Filter by agent ID") + rl.Flags().Bool("all-users", false, "Include editable agents' runs started by others") + runs.AddCommand(rl) + content := command("content ", 1, 1, func(c *cobra.Command, a []string) (api.Record, error) { + n, _ := c.Flags().GetInt("limit") + if n < 0 || (c.Flags().Changed("limit") && n == 0) { + return nil, fmt.Errorf("limit must be positive") + } + return singletons.GetAPIClient().GetAgentRunContent(a[0], n) + }) + content.Flags().Int("limit", 0, "Maximum messages") + runs.AddCommand(content) + send := command("send ", 1, 1, func(c *cobra.Command, a []string) (api.Record, error) { + m, _ := c.Flags().GetString("message") + return singletons.GetAPIClient().SendAgentRunMessage(a[0], m) + }) + send.Flags().String("message", "", "Message") + send.MarkFlagRequired("message") + runs.AddCommand(send) + runs.AddCommand(command("stop ", 1, 1, func(_ *cobra.Command, a []string) (api.Record, error) { + return singletons.GetAPIClient().StopAgentRun(a[0]) + })) + channel := &cobra.Command{Use: "channel", Short: "Manage Slack channel"} + Cmd.AddCommand(channel) + for _, action := range []string{"connect", "pause", "resume", "delete"} { + action := action + c := command(action+" [agent-id]", 0, 1, func(c *cobra.Command, a []string) (api.Record, error) { + typ, _ := c.Flags().GetString("type") + if typ != "slack" { + return nil, fmt.Errorf("only slack is supported") + } + x, e := id(a) + if e != nil { + return nil, e + } + return singletons.GetAPIClient().AgentChannel(x, action) + }) + c.Flags().String("type", "", "Channel type (slack)") + c.MarkFlagRequired("type") + channel.AddCommand(c) + } + permissions := &cobra.Command{Use: "permissions", Short: "Inspect published permissions"} + Cmd.AddCommand(permissions) + for _, kind := range []string{"resource", "app"} { + kind := kind + c := command(kind+" [agent-id] <"+kind+"-id>", 1, 2, func(_ *cobra.Command, a []string) (api.Record, error) { + agentID := "" + other := a[0] + if len(a) == 2 { + agentID = a[0] + other = a[1] + } + x, e := target(agentID, "agent") + if e != nil { + return nil, e + } + if kind == "resource" { + return singletons.GetAPIClient().AgentResourcePermissions(x, other) + } + return singletons.GetAPIClient().AgentAppPermissions(x, other) + }) + permissions.AddCommand(c) + } +} diff --git a/cmd/agent/agent_test.go b/cmd/agent/agent_test.go new file mode 100644 index 0000000..eccdb08 --- /dev/null +++ b/cmd/agent/agent_test.go @@ -0,0 +1,300 @@ +package agent + +import ( + "bytes" + "encoding/json" + "errors" + "github.com/major-technology/cli/clients/api" + "github.com/major-technology/cli/clients/workspace" + "github.com/major-technology/cli/singletons" + "github.com/spf13/cobra" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +func TestCommandsRegistered(t *testing.T) { + for _, path := range []string{"list", "get", "create", "run", "runs list", "runs content", "runs send", "runs stop", "channel connect", "channel pause", "channel resume", "channel delete", "permissions resource", "permissions app"} { + found, _, err := Cmd.Find(strings.Fields(path)) + parts := strings.Fields(path) + if err != nil || found.Name() != parts[len(parts)-1] { + t.Errorf("missing %s: %v", path, err) + } else if found.Flags().Lookup("json") == nil { + t.Errorf("missing --json for %s", path) + } + } +} + +func TestTargetUsesExplicitIDWithoutCheckout(t *testing.T) { + dir := t.TempDir() + old, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer os.Chdir(old) + if got, err := target("explicit", "agent"); err != nil || got != "explicit" { + t.Fatalf("got %q: %v", got, err) + } + if _, err := target("", "agent"); err == nil { + t.Fatal("missing ID accepted") + } +} +func TestTargetUsesMatchingWorkspaceOnly(t *testing.T) { + dir := t.TempDir() + old, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer os.Chdir(old) + cfg := workspace.Config{OrganizationID: "org", Target: workspace.Target{Kind: "agent", AgentID: "11111111-1111-4111-8111-111111111111"}} + if err := workspace.Write(dir, cfg); err != nil { + t.Fatal(err) + } + if got, err := target("", "agent"); err != nil || got != cfg.Target.AgentID { + t.Fatalf("got %q: %v", got, err) + } + if got, err := target("explicit", "agent"); err != nil || got != "explicit" { + t.Fatalf("override %q: %v", got, err) + } + if _, err := target("", "skill"); err == nil { + t.Fatal("mismatched target accepted") + } +} + +func TestGetUsesWorkspaceAndExplicitOverrideJSON(t *testing.T) { + dir := t.TempDir() + old, _ := os.Getwd() + os.Chdir(dir) + defer os.Chdir(old) + local := "11111111-1111-4111-8111-111111111111" + explicit := "22222222-2222-4222-8222-222222222222" + if err := workspace.Write(dir, workspace.Config{OrganizationID: "org", Target: workspace.Target{Kind: "agent", AgentID: local}}); err != nil { + t.Fatal(err) + } + t.Setenv("MAJOR_TOKEN", "injected-test-token") + var requested string + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requested = r.URL.Path + w.Write([]byte(`{"id":"` + strings.TrimPrefix(requested, "/agents/") + `","name":"Test"}`)) + })) + defer s.Close() + previous := singletons.GetAPIClient() + singletons.SetAPIClient(api.NewClient(s.URL)) + defer singletons.SetAPIClient(previous) + for _, tc := range []struct { + args []string + want string + }{{[]string{"--json"}, local}, {[]string{explicit, "--json"}, explicit}} { + var out bytes.Buffer + get := Cmd.Commands()[0] + for _, c := range Cmd.Commands() { + if c.Name() == "get" { + get = c + break + } + } + get.SetOut(&out) + get.Flags().Set("json", "true") + args := tc.args[:len(tc.args)-1] + if err := get.RunE(get, args); err != nil { + t.Fatal(err) + } + if requested != "/agents/"+tc.want || !strings.Contains(out.String(), `"name":"Test"`) { + t.Fatalf("request %s output %s", requested, out.String()) + } + } +} + +// executeAgent exercises Cobra flag parsing and required-flag handling, not RunE directly. +func executeAgent(t *testing.T, args ...string) (string, error) { + t.Helper() + root := &cobra.Command{Use: "major", SilenceUsage: true, SilenceErrors: true} + root.AddCommand(Cmd) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs(append([]string{"agent"}, args...)) + err := root.Execute() + return out.String(), err +} + +func TestContentRejectsExplicitNonpositiveLimit(t *testing.T) { + content, _, _ := Cmd.Find([]string{"runs", "content"}) + limit := content.Flags().Lookup("limit") + limit.Changed = false + if err := limit.Value.Set("0"); err != nil { + t.Fatal(err) + } + defer func() { limit.Changed = false; _ = limit.Value.Set("0") }() + t.Setenv("MAJOR_TOKEN", "injected-test-token") + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++; w.Write([]byte(`{"messages":[]}`)) })) + defer srv.Close() + old := singletons.GetAPIClient() + singletons.SetAPIClient(api.NewClient(srv.URL)) + defer singletons.SetAPIClient(old) + out, err := executeAgent(t, "runs", "content", "run-id", "--json") + if err != nil || !strings.Contains(out, `"messages":[]`) { + t.Fatalf("omitted limit output %q: %v", out, err) + } + for _, value := range []string{"0", "-1"} { + _, err := executeAgent(t, "runs", "content", "run-id", "--limit", value, "--json") + if err == nil || !strings.Contains(err.Error(), "limit must be positive") { + t.Errorf("--limit %s: %v", value, err) + } + } + if calls != 1 { + t.Fatalf("invalid limits sent requests; total %d", calls) + } +} + +func TestRunListAllUsersCobraFlag(t *testing.T) { + t.Setenv("MAJOR_TOKEN", "injected-test-token") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.RequestURI() != "/agent-runs?agentId=target&allUsers=true" { + t.Errorf("request %s", r.URL.RequestURI()) + } + w.Write([]byte(`{"runs":[],"hasMore":false}`)) + })) + defer srv.Close() + old := singletons.GetAPIClient() + singletons.SetAPIClient(api.NewClient(srv.URL)) + defer singletons.SetAPIClient(old) + out, err := executeAgent(t, "runs", "list", "--agent", "target", "--all-users", "--json") + if err != nil || !strings.Contains(out, `"hasMore":false`) { + t.Fatalf("output %q: %v", out, err) + } +} + +func TestRunPromptAndWorkspaceIDOverrideCobraFlags(t *testing.T) { + t.Setenv("MAJOR_TOKEN", "injected-test-token") + dir := t.TempDir() + oldDir, _ := os.Getwd() + os.Chdir(dir) + defer os.Chdir(oldDir) + local := "11111111-1111-4111-8111-111111111111" + explicit := "22222222-2222-4222-8222-222222222222" + if err := workspace.Write(dir, workspace.Config{OrganizationID: "org", Target: workspace.Target{Kind: "agent", AgentID: local}}); err != nil { + t.Fatal(err) + } + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.URL.Path != "/agents/"+explicit+"/runs" || r.Method != "POST" { + t.Errorf("request %s %s", r.Method, r.URL.Path) + } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if body["prompt"] != "say hello" || body["name"] != "demo" { + t.Errorf("body %+v", body) + } + w.Write([]byte(`{"chatThreadId":"run","status":"started"}`)) + })) + defer srv.Close() + old := singletons.GetAPIClient() + singletons.SetAPIClient(api.NewClient(srv.URL)) + defer singletons.SetAPIClient(old) + out, err := executeAgent(t, "run", explicit, "--prompt", "say hello", "--name", "demo", "--json") + if err != nil || !strings.Contains(out, `"status":"started"`) { + t.Fatalf("output %q: %v", out, err) + } + if calls != 1 { + t.Fatalf("calls %d", calls) + } +} + +func TestChannelTypeAndWorkspaceIDOverrideCobraFlags(t *testing.T) { + t.Setenv("MAJOR_TOKEN", "injected-test-token") + dir := t.TempDir() + oldDir, _ := os.Getwd() + os.Chdir(dir) + defer os.Chdir(oldDir) + local := "11111111-1111-4111-8111-111111111111" + explicit := "22222222-2222-4222-8222-222222222222" + if err := workspace.Write(dir, workspace.Config{OrganizationID: "org", Target: workspace.Target{Kind: "agent", AgentID: local}}); err != nil { + t.Fatal(err) + } + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Method != "POST" || r.URL.Path != "/agents/"+explicit+"/channel/pause" { + t.Errorf("request %s %s", r.Method, r.URL.Path) + } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if body["type"] != "slack" { + t.Errorf("body %+v", body) + } + w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + old := singletons.GetAPIClient() + singletons.SetAPIClient(api.NewClient(srv.URL)) + defer singletons.SetAPIClient(old) + out, err := executeAgent(t, "channel", "pause", explicit, "--type", "slack", "--json") + if err != nil || !strings.Contains(out, `"ok":true`) { + t.Fatalf("output %q: %v", out, err) + } + if calls != 1 { + t.Fatalf("calls %d", calls) + } +} + +func TestPermissionsResourceExplicitIDOverridesWorkspaceCobra(t *testing.T) { + t.Setenv("MAJOR_TOKEN", "injected-test-token") + dir := t.TempDir() + oldDir, _ := os.Getwd() + os.Chdir(dir) + defer os.Chdir(oldDir) + if err := workspace.Write(dir, workspace.Config{OrganizationID: "org", Target: workspace.Target{Kind: "agent", AgentID: "11111111-1111-4111-8111-111111111111"}}); err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/agents/explicit-agent/permissions/resources/resource-id" { + t.Errorf("request %s %s", r.Method, r.URL.Path) + } + w.Write([]byte(`{"resourcePermissions":[]}`)) + })) + defer srv.Close() + old := singletons.GetAPIClient() + singletons.SetAPIClient(api.NewClient(srv.URL)) + defer singletons.SetAPIClient(old) + out, err := executeAgent(t, "permissions", "resource", "explicit-agent", "resource-id", "--json") + if err != nil || !strings.Contains(out, `"resourcePermissions":[]`) { + t.Fatalf("output %q: %v", out, err) + } +} + +var errOutput = errors.New("output failed") + +type failingWriter struct{} + +func (failingWriter) Write([]byte) (int, error) { return 0, errOutput } + +func TestAgentCommandPropagatesNonJSONOutputError(t *testing.T) { + for _, tc := range []struct { + name string + response api.Record + }{ + {"record", api.Record{"id": "agent-id"}}, + {"run table", api.Record{"runs": []any{map[string]any{"threadId": "run-id", "agentId": "agent-id", "title": "test", "status": "running"}}}}, + } { + t.Run(tc.name, func(t *testing.T) { + c := command("probe", 0, 0, func(*cobra.Command, []string) (api.Record, error) { return tc.response, nil }) + c.SetOut(failingWriter{}) + c.SetErr(io.Discard) + c.SilenceErrors = true + c.SilenceUsage = true + if err := c.Execute(); !errors.Is(err, errOutput) { + t.Fatalf("output error = %v, want %v", err, errOutput) + } + }) + } +} diff --git a/cmd/agent/target.go b/cmd/agent/target.go new file mode 100644 index 0000000..a50b30f --- /dev/null +++ b/cmd/agent/target.go @@ -0,0 +1,3 @@ +package agent + +func ResolveSkillID(id string) (string, error) { return target(id, "skill") } diff --git a/cmd/app/app.go b/cmd/app/app.go index 28b64d8..be29383 100644 --- a/cmd/app/app.go +++ b/cmd/app/app.go @@ -12,11 +12,19 @@ var Cmd = &cobra.Command{ Short: "Application management commands", Long: `Commands for creating and managing applications.`, Args: utils.NoArgs, - PersistentPreRunE: middleware.ChainParent( - middleware.CheckNodeInstalled, - middleware.CheckNodeVersion("22.12"), - middleware.CheckPnpmInstalled, - ), + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if cmd.Name() == "list" { + if root := cmd.Parent().Parent(); root != nil && root.PersistentPreRunE != nil { + return root.PersistentPreRunE(cmd, args) + } + return nil + } + return middleware.ChainParent( + middleware.CheckNodeInstalled, + middleware.CheckNodeVersion("22.12"), + middleware.CheckPnpmInstalled, + )(cmd, args) + }, RunE: func(cmd *cobra.Command, args []string) error { cmd.Help() return nil diff --git a/cmd/app/list.go b/cmd/app/list.go index e7be1e2..e06f2f9 100644 --- a/cmd/app/list.go +++ b/cmd/app/list.go @@ -1,48 +1,35 @@ package app import ( - "encoding/json" - "os" - - mjrToken "github.com/major-technology/cli/clients/token" + "fmt" "github.com/major-technology/cli/singletons" + "github.com/major-technology/cli/utils" "github.com/spf13/cobra" + "text/tabwriter" ) -var listCmd = &cobra.Command{ - Use: "list", - Short: "List all applications in the current organization", - Hidden: true, - RunE: func(cmd *cobra.Command, args []string) error { - return runList() - }, -} - -type appListItem struct { - ID string `json:"id"` - Name string `json:"name"` -} - -func runList() error { - orgID, _, err := mjrToken.GetDefaultOrg() +var listCmd = &cobra.Command{Use: "list", Short: "List accessible applications", Args: cobra.NoArgs, RunE: func(c *cobra.Command, _ []string) error { + editable, _ := c.Flags().GetBool("editable") + resp, err := singletons.GetAPIClient().ListApps(editable) if err != nil { return err } - - apiClient := singletons.GetAPIClient() - - resp, err := apiClient.GetOrganizationApplications(orgID) - if err != nil { - return err + j, _ := c.Flags().GetBool("json") + if j { + return utils.WriteJSON(c, resp) } - - items := make([]appListItem, len(resp.Applications)) - for i, app := range resp.Applications { - items[i] = appListItem{ - ID: app.ID, - Name: app.Name, + w := tabwriter.NewWriter(c.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "ID\tName\tEditable") + if apps, ok := resp["applications"].([]any); ok { + for _, item := range apps { + a, _ := item.(map[string]any) + fmt.Fprintf(w, "%v\t%v\t%v\n", a["id"], a["name"], a["canEdit"]) } } + return w.Flush() +}} - return json.NewEncoder(os.Stdout).Encode(items) +func init() { + listCmd.Flags().Bool("editable", false, "Only editable applications") + listCmd.Flags().Bool("json", false, "Output route fields as JSON") } diff --git a/cmd/app/list_test.go b/cmd/app/list_test.go new file mode 100644 index 0000000..5f53fd3 --- /dev/null +++ b/cmd/app/list_test.go @@ -0,0 +1,42 @@ +package app + +import ( + "bytes" + "github.com/major-technology/cli/clients/api" + "github.com/major-technology/cli/singletons" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestListUsesTokenRouteAndPreservesJSON(t *testing.T) { + t.Setenv("MAJOR_TOKEN", "injected-test-token") + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.RequestURI() != "/apps?editable=true" { + t.Errorf("path %s", r.URL.RequestURI()) + } + w.Write([]byte(`{"applications":[{"id":"a","name":"A","description":"D","canEdit":true}]}`)) + })) + defer s.Close() + old := singletons.GetAPIClient() + singletons.SetAPIClient(api.NewClient(s.URL)) + defer singletons.SetAPIClient(old) + var out bytes.Buffer + listCmd.SetOut(&out) + listCmd.Flags().Set("json", "true") + listCmd.Flags().Set("editable", "true") + if err := listCmd.RunE(listCmd, nil); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), `"canEdit":true`) { + t.Fatal(out.String()) + } +} + +func TestListDoesNotNeedNodeOrPnpm(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + if err := Cmd.PersistentPreRunE(listCmd, nil); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/output_contract_test.go b/cmd/output_contract_test.go index a7d33a6..0a34174 100644 --- a/cmd/output_contract_test.go +++ b/cmd/output_contract_test.go @@ -333,16 +333,10 @@ func TestVarsUnsetJSONSingleEnvironmentNamedAll(t *testing.T) { func TestResourceListJSONKeepsFullIDs(t *testing.T) { const resourceID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" stdout, stderr, err := runContractCommand(t, contractServer(t, map[string]http.HandlerFunc{ - "GET /applications/" + contractAppID + "/info": func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, contractInfoBody) - }, "GET /verify": func(w http.ResponseWriter, r *http.Request) { writeJSON(w, `{"active":true,"user_id":"user-1"}`) }, - "POST /resources": func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, `{"resources":[{"id":"`+resourceID+`","name":"db","type":"postgres","description":"db"}]}`) - }, - "GET /applications/" + contractAppID + "/resources": func(w http.ResponseWriter, r *http.Request) { + "GET /resources": func(w http.ResponseWriter, r *http.Request) { writeJSON(w, `{"resources":[{"id":"`+resourceID+`","name":"db","type":"postgres","description":"db"}]}`) }, }), []string{"resource", "list"}, nil, true, nil) @@ -368,6 +362,9 @@ func TestResourceListJSONKeepsFullIDs(t *testing.T) { if row["id"] != resourceID { t.Fatalf("id truncated or missing: %#v", row["id"]) } + if _, exists := row["isAttached"]; exists { + t.Fatalf("obsolete isAttached field: %#v", row) + } } func TestJSONAPIErrorsHaveNoSuccessResult(t *testing.T) { @@ -492,7 +489,7 @@ func TestPrintErrorWritesToStderrWithoutToken(t *testing.T) { var stdout, stderr bytes.Buffer cmd.SetOut(&stdout) cmd.SetErr(&stderr) - clierrors.PrintError(cmd, clierrors.ErrorUnauthorized) + clierrors.PrintError(cmd, clierrors.ErrorUnauthorized, false) if stdout.Len() != 0 { t.Fatalf("PrintError wrote stdout: %q", stdout.String()) } @@ -504,6 +501,20 @@ func TestPrintErrorWritesToStderrWithoutToken(t *testing.T) { } } +func TestInjectedTokenErrorIsPlainText(t *testing.T) { + cmd := &cobra.Command{} + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + clierrors.PrintError(cmd, &clierrors.CLIError{Title: "Use the MCP run_agent tool instead."}, true) + if got := stderr.String(); got != "Error: Use the MCP run_agent tool instead.\n" { + t.Fatalf("plain error = %q", got) + } + if stdout.Len() != 0 { + t.Fatalf("error wrote stdout: %q", stdout.String()) + } +} + func TestNonTTYJSONStillWorks(t *testing.T) { stdout, stderr, err := runContractCommand(t, contractServer(t, map[string]http.HandlerFunc{ "GET /applications/" + contractAppID + "/info": func(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/resource/list.go b/cmd/resource/list.go index c9fb145..7534bb0 100644 --- a/cmd/resource/list.go +++ b/cmd/resource/list.go @@ -1,7 +1,6 @@ package resource import ( - "github.com/major-technology/cli/errors" "github.com/major-technology/cli/middleware" "github.com/major-technology/cli/singletons" "github.com/major-technology/cli/utils" @@ -13,7 +12,7 @@ var flagListJSON bool var listCmd = &cobra.Command{ Use: "list", Short: "List available resources", - Long: `List all resources in the organization, showing which are attached to the current app.`, + Long: `List resources in the current organization.`, PreRunE: middleware.Compose( middleware.CheckLogin, ), @@ -27,46 +26,10 @@ func init() { } func runList(cobraCmd *cobra.Command) error { - appInfo, err := utils.GetApplicationInfo("") + resp, err := singletons.GetAPIClient().ListResources() if err != nil { - return errors.WrapError("failed to identify application", err) + return err } - apiClient := singletons.GetAPIClient() - - orgResources, err := apiClient.GetResources(appInfo.OrganizationID) - if err != nil { - return errors.WrapError("failed to get resources", err) - } - - appResources, err := apiClient.GetApplicationResources(appInfo.ApplicationID) - if err != nil { - return errors.WrapError("failed to get application resources", err) - } - - attached := make(map[string]bool) - for _, r := range appResources.Resources { - attached[r.ID] = true - } - - type resourceJSON struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Description string `json:"description"` - IsAttached bool `json:"isAttached"` - } - - resources := make([]resourceJSON, len(orgResources.Resources)) - for i, r := range orgResources.Resources { - resources[i] = resourceJSON{ - ID: r.ID, - Name: r.Name, - Type: r.Type, - Description: r.Description, - IsAttached: attached[r.ID], - } - } - - return utils.WriteJSON(cobraCmd, resources) + return utils.WriteJSON(cobraCmd, resp.Resources) } diff --git a/cmd/resource/remove.go b/cmd/resource/remove.go index 7df96ab..1cf6021 100644 --- a/cmd/resource/remove.go +++ b/cmd/resource/remove.go @@ -18,7 +18,7 @@ var ( var removeCmd = &cobra.Command{ Use: "remove", Short: "Remove a resource from the current application", - Long: `Remove a resource by ID from the current application. Use 'major resource list' to see attached resources.`, + Long: `Remove a resource by ID from the current application. Use 'major resource list' to see available resources.`, PreRunE: middleware.ChainParent( middleware.CheckLogin, middleware.CheckNodeInstalled, diff --git a/cmd/resource/workspace_context_test.go b/cmd/resource/workspace_context_test.go index ba7a1c4..fc12274 100644 --- a/cmd/resource/workspace_context_test.go +++ b/cmd/resource/workspace_context_test.go @@ -2,6 +2,7 @@ package resource import ( "bytes" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -13,50 +14,59 @@ import ( "github.com/spf13/cobra" ) -func TestRunListUsesSharedResolverNotFromRepo(t *testing.T) { +func TestRunListDoesNotRequireAnApplication(t *testing.T) { const ( appID = "11111111-1111-4111-8111-111111111111" orgID = "22222222-2222-4222-8222-222222222222" ) - dir := t.TempDir() - if err := workspace.Write(dir, workspace.Config{ - OrganizationID: orgID, - Target: workspace.Target{Kind: "app", ApplicationID: appID}, - }); err != nil { - t.Fatal(err) - } - t.Chdir(dir) + for _, target := range []string{"app", "agent", "none"} { + t.Run(target, func(t *testing.T) { + dir := t.TempDir() + if target != "none" { + cfg := workspace.Config{OrganizationID: orgID, Target: workspace.Target{Kind: target}} + if target == "app" { + cfg.Target.ApplicationID = appID + } else { + cfg.Target.AgentID = appID + } + if err := workspace.Write(dir, cfg); err != nil { + t.Fatal(err) + } + } + t.Chdir(dir) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/applications/" + appID + "/info": - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"applicationId":"`+appID+`","organizationId":"`+orgID+`","urlSlug":"prototype","name":"Prototype","deployStatus":"not_deployed","appUrl":null}`) - case "/resources": - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"resources":[]}`) - case "/applications/" + appID + "/resources": - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"resources":[]}`) - case "/application/from-repo": - t.Errorf("resource list must not call /application/from-repo") - w.WriteHeader(http.StatusNotFound) - default: - t.Errorf("unexpected request: %s", r.URL.Path) - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/resources" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"resources":[{"id":"resource-1","name":"Database","type":"postgres","description":"Test"}]}`) + })) + defer server.Close() - t.Setenv("MAJOR_TOKEN", "test-injected-token") - prev := singletons.GetAPIClient() - singletons.SetAPIClient(api.NewClient(server.URL)) - t.Cleanup(func() { singletons.SetAPIClient(prev) }) + t.Setenv("MAJOR_TOKEN", "test-injected-token") + prev := singletons.GetAPIClient() + singletons.SetAPIClient(api.NewClient(server.URL)) + t.Cleanup(func() { singletons.SetAPIClient(prev) }) - cmd := &cobra.Command{} - var out bytes.Buffer - cmd.SetOut(&out) - if err := runList(cmd); err != nil { - t.Fatalf("runList() = %v", err) + cmd := &cobra.Command{} + var out bytes.Buffer + cmd.SetOut(&out) + if err := runList(cmd); err != nil { + t.Fatalf("runList() = %v", err) + } + var resources []map[string]any + if err := json.Unmarshal(out.Bytes(), &resources); err != nil { + t.Fatal(err) + } + if len(resources) != 1 || resources[0]["id"] != "resource-1" { + t.Fatalf("unexpected resources: %v", resources) + } + if _, exists := resources[0]["isAttached"]; exists { + t.Fatalf("obsolete isAttached field in %v", resources[0]) + } + }) } } diff --git a/cmd/root.go b/cmd/root.go index 23ff13c..1fd8618 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" @@ -17,6 +18,7 @@ import ( "github.com/major-technology/cli/cmd/org" "github.com/major-technology/cli/cmd/project" "github.com/major-technology/cli/cmd/resource" + "github.com/major-technology/cli/cmd/skill" "github.com/major-technology/cli/cmd/user" "github.com/major-technology/cli/cmd/vars" clierrors "github.com/major-technology/cli/errors" @@ -96,7 +98,8 @@ func rejectInjectedAuthManagement(cmd *cobra.Command, args []string) error { func Execute() { if err := rootCmd.Execute(); err != nil { - clierrors.PrintError(rootCmd, err) + nonInteractive, _ := rootCmd.PersistentFlags().GetBool("non-interactive") + clierrors.PrintError(rootCmd, err, mjrToken.HasInjectedToken() || nonInteractive) os.Exit(1) } } @@ -124,6 +127,10 @@ func init() { app.Cmd.GroupID = "main" rootCmd.AddCommand(app.Cmd) + agent.Cmd.GroupID = "main" + rootCmd.AddCommand(agent.Cmd) + skill.Cmd.GroupID = "main" + rootCmd.AddCommand(skill.Cmd) rootCmd.AddCommand(demo.Cmd) diff --git a/cmd/skill/skill.go b/cmd/skill/skill.go new file mode 100644 index 0000000..14a23d5 --- /dev/null +++ b/cmd/skill/skill.go @@ -0,0 +1,50 @@ +package skill + +import ( + "github.com/major-technology/cli/clients/api" + "github.com/major-technology/cli/cmd/agent" + "github.com/major-technology/cli/singletons" + "github.com/major-technology/cli/utils" + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{Use: "skill", Short: "Manage skills", Args: cobra.NoArgs} + +func output(c *cobra.Command, v api.Record, e error) error { + if e != nil { + return e + } + return utils.WriteJSON(c, v) +} +func init() { + l := &cobra.Command{Use: "list", Args: cobra.NoArgs, RunE: func(c *cobra.Command, _ []string) error { + a, _ := c.Flags().GetBool("editable") + p, _ := c.Flags().GetBool("published") + v, e := singletons.GetAPIClient().ListSkills(a, p) + return output(c, v, e) + }} + l.Flags().Bool("editable", false, "Only editable skills") + l.Flags().Bool("published", false, "Only published skills") + Cmd.AddCommand(l) + g := &cobra.Command{Use: "get [skill-id]", Args: cobra.MaximumNArgs(1), RunE: func(c *cobra.Command, a []string) error { + x := "" + if len(a) > 0 { + x = a[0] + } + id, e := agent.ResolveSkillID(x) + if e != nil { + return e + } + v, e := singletons.GetAPIClient().GetSkill(id) + return output(c, v, e) + }} + Cmd.AddCommand(g) + create := &cobra.Command{Use: "create", Args: cobra.NoArgs, RunE: func(c *cobra.Command, _ []string) error { + v, e := singletons.GetAPIClient().CreateSkill() + return output(c, v, e) + }} + Cmd.AddCommand(create) + for _, c := range []*cobra.Command{l, g, create} { + c.Flags().Bool("json", false, "Output route fields as JSON") + } +} diff --git a/cmd/skill/skill_test.go b/cmd/skill/skill_test.go new file mode 100644 index 0000000..e8220f1 --- /dev/null +++ b/cmd/skill/skill_test.go @@ -0,0 +1,127 @@ +package skill + +import ( + "bytes" + "github.com/major-technology/cli/clients/api" + "github.com/major-technology/cli/clients/workspace" + "github.com/major-technology/cli/cmd/agent" + "github.com/major-technology/cli/singletons" + "github.com/spf13/cobra" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +func TestCommandsRegistered(t *testing.T) { + for _, name := range []string{"list", "get", "create"} { + if c, _, e := Cmd.Find([]string{name}); e != nil || c.Name() != name { + t.Errorf("missing %s", name) + } + } +} + +func TestGetSkillMatchingWorkspaceAndExplicitOverride(t *testing.T) { + dir := t.TempDir() + old, _ := os.Getwd() + os.Chdir(dir) + defer os.Chdir(old) + local := "11111111-1111-4111-8111-111111111111" + explicit := "22222222-2222-4222-8222-222222222222" + if err := workspace.Write(dir, workspace.Config{OrganizationID: "org", Target: workspace.Target{Kind: "skill", SkillID: local}}); err != nil { + t.Fatal(err) + } + t.Setenv("MAJOR_TOKEN", "injected-test-token") + var path string + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + w.Write([]byte(`{"id":"` + strings.TrimPrefix(path, "/skills/") + `"}`)) + })) + defer s.Close() + previous := singletons.GetAPIClient() + singletons.SetAPIClient(api.NewClient(s.URL)) + defer singletons.SetAPIClient(previous) + g, _, _ := Cmd.Find([]string{"get"}) + var out bytes.Buffer + g.SetOut(&out) + for _, tc := range []struct { + args []string + want string + }{{nil, local}, {[]string{explicit}, explicit}} { + if err := g.RunE(g, tc.args); err != nil { + t.Fatal(err) + } + if path != "/skills/"+tc.want { + t.Fatalf("path %s", path) + } + } + if _, err := agent.ResolveSkillID(""); err != nil { + t.Fatal(err) + } + if err := workspace.Write(dir, workspace.Config{OrganizationID: "org", Target: workspace.Target{Kind: "app", ApplicationID: local}}); err != nil { + t.Fatal(err) + } + if err := g.RunE(g, nil); err == nil { + t.Fatal("accepted app target") + } +} + +func TestListPublishedCobraFlag(t *testing.T) { + t.Setenv("MAJOR_TOKEN", "injected-test-token") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.RequestURI() != "/skills?editable=true&published=true" { + t.Errorf("request %s %s", r.Method, r.URL.RequestURI()) + } + w.Write([]byte(`{"skills":[]}`)) + })) + defer srv.Close() + previous := singletons.GetAPIClient() + singletons.SetAPIClient(api.NewClient(srv.URL)) + defer singletons.SetAPIClient(previous) + root := &cobra.Command{Use: "major", SilenceErrors: true, SilenceUsage: true} + root.AddCommand(Cmd) + var out bytes.Buffer + root.SetOut(&out) + root.SetArgs([]string{"skill", "list", "--published", "--editable", "--json"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), `"skills":[]`) { + t.Fatal(out.String()) + } +} + +func TestGetSkillExplicitIDOverridesWorkspaceCobra(t *testing.T) { + t.Setenv("MAJOR_TOKEN", "injected-test-token") + dir := t.TempDir() + oldDir, _ := os.Getwd() + os.Chdir(dir) + defer os.Chdir(oldDir) + if err := workspace.Write(dir, workspace.Config{OrganizationID: "org", Target: workspace.Target{Kind: "skill", SkillID: "11111111-1111-4111-8111-111111111111"}}); err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/skills/explicit-skill" { + t.Errorf("request %s %s", r.Method, r.URL.Path) + } + w.Write([]byte(`{"id":"explicit-skill"}`)) + })) + defer srv.Close() + previous := singletons.GetAPIClient() + singletons.SetAPIClient(api.NewClient(srv.URL)) + defer singletons.SetAPIClient(previous) + root := &cobra.Command{Use: "major", SilenceErrors: true, SilenceUsage: true} + root.AddCommand(Cmd) + var out bytes.Buffer + root.SetOut(&out) + get, _, _ := Cmd.Find([]string{"get"}) + get.SetOut(&out) + root.SetArgs([]string{"skill", "get", "explicit-skill", "--json"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), `"id":"explicit-skill"`) { + t.Fatal(out.String()) + } +} diff --git a/errors/errors.go b/errors/errors.go index 5e2a898..8e3fcef 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -48,7 +48,7 @@ func WrapError(msg string, ogerr error) *CLIError { } } -func PrintError(cmd *cobra.Command, err error) { +func PrintError(cmd *cobra.Command, err error, plain bool) { errorStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#FF5F87")). @@ -78,6 +78,14 @@ func PrintError(cmd *cobra.Command, err error) { message = title } + if plain { + cmd.PrintErrln("Error: " + title) + if suggestion != "" { + cmd.PrintErrln(suggestion) + } + return + } + cmd.PrintErrln(errorStyle.Render(message)) } diff --git a/plugins/major/skills/major/SKILL.md b/plugins/major/skills/major/SKILL.md index 06263e3..53f9d83 100644 --- a/plugins/major/skills/major/SKILL.md +++ b/plugins/major/skills/major/SKILL.md @@ -1,8 +1,8 @@ --- name: major description: > - Use the Major platform to create, develop, and deploy Next.js web applications. - Triggers when user mentions Major apps, deploying, creating apps, + Use the Major platform to manage apps, agents, skills, resources, and deployments. + Triggers when user mentions Major apps, agents, skills, deploying, managing resources, or working with the Major CLI. disable-model-invocation: false allowed-tools: Bash(major *), Read(**/plugins/major/skills/major/docs/*) @@ -23,7 +23,7 @@ Major is a platform for building and deploying Next.js web applications. It crea | `major app start` | Start local dev server (warns if behind origin) | Direct | | `major app deploy --message "description" --no-wait` | Deploy to production (returns version ID) | Direct | | `major app deploy-status --version-id "ID"` | Check deployment status (JSON: status, appUrl, error) | Direct | -| `major app list` | List all apps in org (JSON: id, name) | Direct | +| `major app list [--editable] [--json]` | List visible apps, including undeployed apps; optionally only editable ones | Direct | | `major app info` | Show app ID, name, deploy status, URL | Direct | | `major app info --json` | App info as JSON | Direct | | `major app configure` | Open app settings in browser | Direct | @@ -41,6 +41,37 @@ Major is a platform for building and deploying Next.js web applications. It crea | `major app ai-proxy status` | Inspect AI proxy configuration and spend | Direct | | `major app ai-proxy enable` | Enable the proxy with a $10/month limit (ask the user first) | Direct | +### Agent Commands + +| Command | Description | Mode | +|---------|-------------|------| +| `major agent list [--editable]` | List visible agents; optionally only editable ones | Direct | +| `major agent get [agent-id]` | Read agent detail and declared env-key status (never values) | Direct | +| `major agent create --name "X" [--description "Y"]` | Create an unpublished draft; does not mount it | Direct | +| `major agent run [agent-id] --prompt "..." [--name "title"]` | Start an independent run — **human CLI token only**; AI sessions use MCP `run_agent` | MCP for AI | +| `major agent runs list [--agent ] [--all-users]` | List your runs; `--all-users` includes others' runs only on editable agents | Direct | +| `major agent runs content [--limit N]` | Read run messages | Direct | +| `major agent runs send --message "..."` | Send a follow-up message | Direct | +| `major agent runs stop ` | Stop a run | Direct | +| `major agent channel connect [agent-id] --type slack` | Connect Slack — **human CLI token only**; AI sessions use MCP `connect_agent_to_slack` | MCP for AI | +| `major agent channel pause [agent-id] --type slack` | Pause Slack replies | Direct | +| `major agent channel resume [agent-id] --type slack` | Resume Slack replies | Direct | +| `major agent channel delete [agent-id] --type slack` | Delete a Slack connection — human CLI only; agents must not do this | Human only | +| `major agent permissions resource [agent-id] ` | Inspect published resource tool permissions | Direct | +| `major agent permissions app [agent-id] ` | Inspect published app endpoint permissions | Direct | + +### Skill Commands + +| Command | Description | Mode | +|---------|-------------|------| +| `major skill list [--editable] [--published]` | List visible skills; optionally narrow to editable or published | Direct | +| `major skill get [skill-id]` | Read skill detail | Direct | +| `major skill create` | Create an unpublished draft; does not mount it | Direct | + +Agent and skill target commands use the matching `.major/config.json` in a mounted workspace when the ID is omitted; an explicit ID wins. Lists and creates do not need a workspace. Use `--json` for machine-readable output. + +Before mounting, use MCP `list_apps`, `list_agents`, or `list_skills` to discover targets. After mounting, use the CLI for reads and run follow-ups. To start a run in an AI session, call the approval-gated `mcp__orchestrator-platform__run_agent({agentId, prompt})`, not `major agent run`. To connect Slack, use approval-gated `mcp__orchestrator-platform__connect_agent_to_slack({agentId})`, not the CLI. Do not use the removed app-scoped `major-app` run tools. The deployed-app runtime API is separate. + ### Environment Variable Commands | Command | Description | Mode | @@ -65,7 +96,7 @@ All vars commands accept `--env ` to target a specific environment (case-i | Command | Description | Mode | |---------|-------------|------| -| `major resource list` | List org resources as JSON (shows which are attached to app) | Direct | +| `major resource list` | List org resources as JSON (no app workspace required) | Direct | | `major resource add --id "UUID"` | Add a resource to current app | Direct | | `major resource remove --id "UUID"` | Remove a resource from current app | Direct | | `major resource env` | View/switch environments (interactive, or `--id` for non-interactive) | Direct | @@ -103,6 +134,8 @@ All vars commands accept `--env ` to target a specific environment (case-i ## Rules **Direct** commands: Run these yourself via Bash. +**MCP for AI** commands: Use the named approval-gated MCP tool; the CLI form is for human CLI tokens only. +**Human only** commands: Do not run these as an agent. **Interactive** commands: Tell the user to run these in their terminal -- they require browser or TUI interaction. ### Critical Rules diff --git a/plugins/major/skills/major/docs/resource-workflows.md b/plugins/major/skills/major/docs/resource-workflows.md index 8acb4d7..5c5755c 100644 --- a/plugins/major/skills/major/docs/resource-workflows.md +++ b/plugins/major/skills/major/docs/resource-workflows.md @@ -23,10 +23,10 @@ Opens the resource creation page in the browser. Resources are created at the or major resource list ``` -Lists all resources in the organization as JSON. Each resource includes `isAttached` to show if it's connected to the current app. Example output: +Lists resources in the authenticated token's organization as JSON, without requiring an app workspace. Example output: ```json -[{"id":"uuid","name":"My DB","type":"postgresql","description":"Production database","isAttached":true}] +[{"id":"uuid","name":"My DB","type":"postgresql","description":"Production database"}] ``` ## Adding a Resource to an App