Skip to content

Commit bf28b89

Browse files
committed
feat: add slack api command for calling Slack API methods directly
Adds a new top-level `slack api <method> [key=value ...] [flags]` command that calls any Slack API method with automatic token resolution, body format detection, and response formatting. Token resolution priority: --token flag, --app/--team flags (via AppSelectPrompt in project), SLACK_BOT_TOKEN env, SLACK_USER_TOKEN env, interactive prompt fallback. Supports form-encoded key=value params, JSON auto-detection, --json and --data flags, custom headers (-H), HTTP method override (-X), response header display (--include), and TTY-aware pretty printing.
1 parent 806aa51 commit bf28b89

5 files changed

Lines changed: 1201 additions & 0 deletions

File tree

cmd/api/api.go

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
// Copyright 2022-2026 Salesforce, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package api
16+
17+
import (
18+
"bytes"
19+
"context"
20+
"encoding/json"
21+
"fmt"
22+
"net/url"
23+
"os"
24+
"strings"
25+
26+
"github.com/slackapi/slack-cli/internal/api"
27+
"github.com/slackapi/slack-cli/internal/config"
28+
"github.com/slackapi/slack-cli/internal/prompts"
29+
"github.com/slackapi/slack-cli/internal/shared"
30+
"github.com/slackapi/slack-cli/internal/shared/types"
31+
"github.com/slackapi/slack-cli/internal/slackerror"
32+
"github.com/slackapi/slack-cli/internal/style"
33+
"github.com/spf13/cobra"
34+
)
35+
36+
type cmdFlags struct {
37+
method string
38+
json string
39+
data string
40+
headers []string
41+
include bool
42+
}
43+
44+
var flags cmdFlags
45+
46+
func NewCommand(clients *shared.ClientFactory) *cobra.Command {
47+
cmd := &cobra.Command{
48+
Use: "api <method> [key=value ...] [flags]",
49+
Short: "Call any Slack API method",
50+
Long: strings.Join([]string{
51+
"Call any Slack API method directly.",
52+
"",
53+
"The method argument is the Slack API method name (e.g., \"chat.postMessage\").",
54+
"Parameters are passed as key=value pairs, a JSON body, or via flags.",
55+
"",
56+
"Body format is auto-detected from positional arguments:",
57+
" - Multiple key=value args: form-encoded (token in request body)",
58+
" - Single arg starting with { or [: JSON (Bearer token in header)",
59+
" - No args: token sent in Authorization header",
60+
"",
61+
"Use --json to explicitly send a JSON body, or --data for a form-encoded body string.",
62+
"",
63+
"Token resolution (in priority order):",
64+
" 1. --token flag Explicit token value",
65+
" 2. --app / --team flags Install app and use bot token (in project)",
66+
" 3. SLACK_BOT_TOKEN env var Bot token (set during slack deploy)",
67+
" 4. SLACK_USER_TOKEN env var User token",
68+
" 5. Interactive prompt Select from stored workspaces (CLI tooling token)",
69+
"",
70+
"See all methods at: https://docs.slack.dev/reference/methods",
71+
"",
72+
"Common methods:",
73+
" api.test Test your API connection",
74+
" auth.test Check authentication",
75+
" chat.postMessage Send a message to a channel",
76+
" chat.update Update a message",
77+
" chat.delete Delete a message",
78+
" conversations.list List channels",
79+
" conversations.history Fetch messages from a channel",
80+
" conversations.info Get channel details",
81+
" conversations.members List members in a channel",
82+
" conversations.create Create a channel",
83+
" users.list List workspace members",
84+
" users.info Get user details",
85+
" files.upload Upload a file",
86+
" reactions.add Add an emoji reaction",
87+
" reactions.list List reactions for a user",
88+
" bookmarks.add Add a bookmark to a channel",
89+
" pins.add Pin a message",
90+
" views.open Open a modal view",
91+
" views.update Update a modal view",
92+
}, "\n"),
93+
Example: style.ExampleCommandsf([]style.ExampleCommand{
94+
{Command: "api auth.test", Meaning: "Test authentication with the current workspace"},
95+
{Command: "api chat.postMessage channel=C0123456 text=\"Hello\"", Meaning: "Post a message"},
96+
{Command: "api users.list --team myworkspace", Meaning: "List users in a specific workspace"},
97+
{Command: `api chat.postMessage --json '{"channel":"C0123456","text":"Hello"}'`, Meaning: "Send a JSON body"},
98+
{Command: "api auth.test --include", Meaning: "Show HTTP status and response headers"},
99+
{Command: "api conversations.history -X GET channel=C0123456", Meaning: "Use GET method"},
100+
}),
101+
Args: cobra.MinimumNArgs(1),
102+
PreRunE: func(cmd *cobra.Command, args []string) error {
103+
clients.Config.SetFlags(cmd)
104+
return nil
105+
},
106+
RunE: func(cmd *cobra.Command, args []string) error {
107+
return runAPICommand(cmd, clients, args)
108+
},
109+
}
110+
111+
cmd.Flags().StringVarP(&flags.method, "method", "X", "POST", "HTTP method for the request")
112+
cmd.Flags().StringVar(&flags.json, "json", "", "JSON request body (uses Bearer token in Authorization header)")
113+
cmd.Flags().StringVar(&flags.data, "data", "", "form-encoded request body string (e.g. \"key1=val1&key2=val2\")")
114+
cmd.Flags().StringSliceVarP(&flags.headers, "header", "H", nil, "additional HTTP headers (format: \"Key: Value\")")
115+
cmd.Flags().BoolVarP(&flags.include, "include", "i", false, "include HTTP status code and response headers in output")
116+
cmd.MarkFlagsMutuallyExclusive("json", "data")
117+
118+
return cmd
119+
}
120+
121+
func runAPICommand(cmd *cobra.Command, clients *shared.ClientFactory, args []string) error {
122+
ctx := cmd.Context()
123+
method := args[0]
124+
params := args[1:]
125+
126+
token, err := resolveToken(ctx, clients)
127+
if err != nil {
128+
return err
129+
}
130+
131+
apiHost := clients.Config.APIHostResolved
132+
if apiHost == "" {
133+
apiHost = "https://slack.com"
134+
}
135+
apiClient := api.NewClient(nil, apiHost, clients.IO)
136+
137+
var bodyReader *strings.Reader
138+
var contentType string
139+
140+
switch {
141+
case flags.json != "":
142+
contentType = "application/json; charset=utf-8"
143+
bodyReader = strings.NewReader(flags.json)
144+
case flags.data != "":
145+
contentType = "application/x-www-form-urlencoded"
146+
formData := flags.data
147+
if !strings.Contains(formData, "token=") {
148+
if formData != "" {
149+
formData = formData + "&token=" + url.QueryEscape(token)
150+
} else {
151+
formData = "token=" + url.QueryEscape(token)
152+
}
153+
}
154+
bodyReader = strings.NewReader(formData)
155+
token = ""
156+
case len(params) == 1 && (strings.HasPrefix(params[0], "{") || strings.HasPrefix(params[0], "[")):
157+
contentType = "application/json; charset=utf-8"
158+
bodyReader = strings.NewReader(params[0])
159+
case len(params) > 0:
160+
contentType = "application/x-www-form-urlencoded"
161+
values := url.Values{}
162+
values.Set("token", token)
163+
for _, param := range params {
164+
key, value, ok := strings.Cut(param, "=")
165+
if !ok {
166+
return slackerror.New(slackerror.ErrInvalidArguments).
167+
WithMessage("invalid parameter %q: must be in key=value format", param)
168+
}
169+
values.Set(key, value)
170+
}
171+
bodyReader = strings.NewReader(values.Encode())
172+
token = ""
173+
default:
174+
contentType = "application/x-www-form-urlencoded"
175+
values := url.Values{}
176+
values.Set("token", token)
177+
bodyReader = strings.NewReader(values.Encode())
178+
token = ""
179+
}
180+
181+
customHeaders := map[string]string{}
182+
for _, h := range flags.headers {
183+
key, value, ok := strings.Cut(h, ":")
184+
if !ok {
185+
return slackerror.New(slackerror.ErrInvalidArguments).
186+
WithMessage("invalid header %q: must be in \"Key: Value\" format", h)
187+
}
188+
customHeaders[strings.TrimSpace(key)] = strings.TrimSpace(value)
189+
}
190+
191+
resp, err := apiClient.RawRequest(ctx, flags.method, method, token, bodyReader, contentType, customHeaders)
192+
if err != nil {
193+
return err
194+
}
195+
196+
if flags.include {
197+
fmt.Fprintf(cmd.OutOrStdout(), "HTTP %d\n", resp.StatusCode)
198+
for key, values := range resp.Header {
199+
for _, v := range values {
200+
fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", key, v)
201+
}
202+
}
203+
fmt.Fprintln(cmd.OutOrStdout())
204+
}
205+
206+
output := resp.Body
207+
// Pretty-print for interactive terminals, compact for piped output (gh/git convention)
208+
if clients.IO.IsTTY() {
209+
var indented bytes.Buffer
210+
if json.Indent(&indented, resp.Body, "", " ") == nil {
211+
output = indented.Bytes()
212+
}
213+
}
214+
fmt.Fprint(cmd.OutOrStdout(), string(output))
215+
216+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
217+
return slackerror.New("api_request_failed").
218+
WithMessage("API request failed with status %d", resp.StatusCode)
219+
}
220+
221+
return nil
222+
}
223+
224+
func resolveToken(ctx context.Context, clients *shared.ClientFactory) (string, error) {
225+
if clients.Config.TokenFlag != "" {
226+
return clients.Config.TokenFlag, nil
227+
}
228+
229+
if sdkConfigExists, _ := clients.SDKConfig.Exists(); sdkConfigExists {
230+
selected, err := prompts.AppSelectPrompt(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly)
231+
if err == nil && selected.App.AppID != "" {
232+
token, err := installAndGetBotToken(ctx, clients, selected)
233+
if err == nil && token != "" {
234+
return token, nil
235+
}
236+
}
237+
}
238+
239+
if token := os.Getenv("SLACK_BOT_TOKEN"); token != "" {
240+
return token, nil
241+
}
242+
243+
if token := os.Getenv("SLACK_USER_TOKEN"); token != "" {
244+
return token, nil
245+
}
246+
247+
clients.IO.PrintDebug(ctx, "Using CLI tooling token which has limited API scopes. Set SLACK_BOT_TOKEN or use --token for full access.")
248+
auth, err := prompts.PromptTeamSlackAuth(ctx, clients, "Select a workspace")
249+
if err != nil {
250+
return "", err
251+
}
252+
return auth.Token, nil
253+
}
254+
255+
func installAndGetBotToken(ctx context.Context, clients *shared.ClientFactory, selected prompts.SelectedApp) (string, error) {
256+
manifestSource, _ := clients.Config.ProjectConfig.GetManifestSource(ctx)
257+
var slackManifest types.SlackYaml
258+
var err error
259+
if manifestSource.Equals(config.ManifestSourceRemote) {
260+
slackManifest, err = clients.AppClient().Manifest.GetManifestRemote(ctx, selected.Auth.Token, selected.App.AppID)
261+
} else {
262+
slackManifest, err = clients.AppClient().Manifest.GetManifestLocal(ctx, clients.SDKConfig, clients.HookExecutor)
263+
}
264+
if err != nil {
265+
return "", err
266+
}
267+
268+
manifest := slackManifest.AppManifest
269+
botScopes := []string{}
270+
if manifest.OAuthConfig != nil && manifest.OAuthConfig.Scopes != nil {
271+
botScopes = manifest.OAuthConfig.Scopes.Bot
272+
}
273+
outgoingDomains := []string{}
274+
if manifest.OutgoingDomains != nil {
275+
outgoingDomains = *manifest.OutgoingDomains
276+
}
277+
278+
result, _, err := clients.API().DeveloperAppInstall(ctx, clients.IO, selected.Auth.Token, selected.App, botScopes, outgoingDomains, "", false)
279+
if err != nil {
280+
return "", err
281+
}
282+
283+
return result.APIAccessTokens.Bot, nil
284+
}

0 commit comments

Comments
 (0)