Skip to content

Commit ace60ea

Browse files
authored
feat: add slack api command (#538)
* 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. * chore: rename internal/api/generic.go to raw_request.go * chore: move common methods to examples, sort alphabetically * fix: remove workspace/team prompt and --team flag from slack api Token resolution now errors if no token is found rather than falling back to a workspace selection prompt. Users must provide a token via --token, --app, or SLACK_BOT_TOKEN/SLACK_USER_TOKEN env vars. * docs: add function comments to cmd/api/api.go * test: add 503 retry coverage for RawRequest * fix: check env vars before app prompt in token resolution Environment variables (SLACK_BOT_TOKEN, SLACK_USER_TOKEN) now take priority over the interactive app selection prompt, preventing unexpected blocking prompts when a token is already available. * fix: propagate app prompt errors instead of swallowing them Interrupts (Ctrl+C) and app-not-found errors from the app selection prompt now surface immediately rather than falling through to a generic "no token found" message. * fix: sort response headers in --include output Go map iteration is non-deterministic, so headers printed with --include would appear in a different order on each run. Sort them alphabetically for consistent output. * refactor: consolidate body format tests into table-driven test Combines FormEncoded, JSONAutoDetect, JSONFlag, DataFlag, and GETMethod tests into a single Test_runAPICommand_BodyFormats table test with shared setup.
1 parent 251a341 commit ace60ea

5 files changed

Lines changed: 1151 additions & 0 deletions

File tree

cmd/api/api.go

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
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+
"sort"
25+
"strings"
26+
27+
"github.com/slackapi/slack-cli/internal/api"
28+
"github.com/slackapi/slack-cli/internal/config"
29+
"github.com/slackapi/slack-cli/internal/prompts"
30+
"github.com/slackapi/slack-cli/internal/shared"
31+
"github.com/slackapi/slack-cli/internal/shared/types"
32+
"github.com/slackapi/slack-cli/internal/slackerror"
33+
"github.com/slackapi/slack-cli/internal/style"
34+
"github.com/spf13/cobra"
35+
)
36+
37+
type cmdFlags struct {
38+
method string
39+
json string
40+
data string
41+
headers []string
42+
include bool
43+
}
44+
45+
var flags cmdFlags
46+
47+
// NewCommand returns a new Cobra command for calling Slack API methods
48+
func NewCommand(clients *shared.ClientFactory) *cobra.Command {
49+
cmd := &cobra.Command{
50+
Use: "api <method> [key=value ...] [flags]",
51+
Short: "Call any Slack API method",
52+
Long: strings.Join([]string{
53+
"Call any Slack API method directly.",
54+
"",
55+
"The method argument is the Slack API method name (e.g., \"chat.postMessage\").",
56+
"Parameters are passed as key=value pairs, a JSON body, or via flags.",
57+
"",
58+
"Body format is auto-detected from positional arguments:",
59+
" - Multiple key=value args: form-encoded (token in request body)",
60+
" - Single arg starting with { or [: JSON (Bearer token in header)",
61+
" - No args: token sent in Authorization header",
62+
"",
63+
"Use --json to explicitly send a JSON body, or --data for a form-encoded body string.",
64+
"",
65+
"Token resolution (in priority order):",
66+
" 1. --token flag Explicit token value",
67+
" 2. --app flag Install app and use bot token (in project)",
68+
" 3. SLACK_BOT_TOKEN env var Bot token (set during slack deploy)",
69+
" 4. SLACK_USER_TOKEN env var User token",
70+
" 5. App prompt (in project) Select installed app and use bot token",
71+
"",
72+
"See all methods at: https://docs.slack.dev/reference/methods",
73+
}, "\n"),
74+
Example: style.ExampleCommandsf([]style.ExampleCommand{
75+
{Command: "api api.test", Meaning: "Test your API connection"},
76+
{Command: "api auth.test", Meaning: "Check authentication"},
77+
{Command: "api bookmarks.add channel_id=C0123456 title=Docs link=https://example.com", Meaning: "Add a bookmark to a channel"},
78+
{Command: "api chat.postMessage channel=C0123456 text=\"Hello\"", Meaning: "Send a message to a channel using form-encoded string"},
79+
{Command: `api chat.postMessage --json '{"channel":"C0123456","text":"Hello"}'`, Meaning: "Send a message to a channel using JSON"},
80+
{Command: "api chat.update channel=C0123456 ts=1234567890.123456 text=\"Updated\"", Meaning: "Update a message"},
81+
{Command: "api conversations.create name=new-channel", Meaning: "Create a channel"},
82+
{Command: "api conversations.history channel=C0123456", Meaning: "Fetch messages from a channel"},
83+
{Command: "api conversations.info channel=C0123456", Meaning: "Get channel details"},
84+
{Command: "api conversations.list", Meaning: "List channels"},
85+
{Command: "api conversations.members channel=C0123456", Meaning: "List members in a channel"},
86+
{Command: "api files.upload channels=C0123456 filename=report.csv", Meaning: "Upload a file"},
87+
{Command: "api pins.add channel=C0123456 timestamp=1234567890.123456", Meaning: "Pin a message"},
88+
{Command: "api reactions.add channel=C0123456 timestamp=1234567890.123456 name=thumbsup", Meaning: "Add an emoji reaction"},
89+
{Command: "api reactions.list user=U0123456", Meaning: "List reactions for a user"},
90+
{Command: "api users.info user=U0123456", Meaning: "Get user details"},
91+
{Command: "api users.list", Meaning: "List workspace members"},
92+
{Command: "api users.profile.get user=U0123456", Meaning: "Get a user's profile"},
93+
{Command: "api views.open trigger_id=T0123456 view={...}", Meaning: "Open a modal view"},
94+
{Command: "api views.update view_id=V0123456 view={...}", Meaning: "Update a modal view"},
95+
}),
96+
Args: cobra.MinimumNArgs(1),
97+
PreRunE: func(cmd *cobra.Command, args []string) error {
98+
clients.Config.SetFlags(cmd)
99+
return nil
100+
},
101+
RunE: func(cmd *cobra.Command, args []string) error {
102+
return runAPICommand(cmd, clients, args)
103+
},
104+
}
105+
106+
cmd.Flags().StringVarP(&flags.method, "method", "X", "POST", "HTTP method for the request")
107+
cmd.Flags().StringVar(&flags.json, "json", "", "JSON request body (uses Bearer token in Authorization header)")
108+
cmd.Flags().StringVar(&flags.data, "data", "", "form-encoded request body string (e.g. \"key1=val1&key2=val2\")")
109+
cmd.Flags().StringSliceVarP(&flags.headers, "header", "H", nil, "additional HTTP headers (format: \"Key: Value\")")
110+
cmd.Flags().BoolVarP(&flags.include, "include", "i", false, "include HTTP status code and response headers in output")
111+
cmd.MarkFlagsMutuallyExclusive("json", "data")
112+
113+
return cmd
114+
}
115+
116+
// runAPICommand resolves a token, builds the request body, and sends a raw HTTP request to the Slack API
117+
func runAPICommand(cmd *cobra.Command, clients *shared.ClientFactory, args []string) error {
118+
ctx := cmd.Context()
119+
method := args[0]
120+
params := args[1:]
121+
122+
token, err := resolveToken(ctx, clients)
123+
if err != nil {
124+
return err
125+
}
126+
127+
apiHost := clients.Config.APIHostResolved
128+
if apiHost == "" {
129+
apiHost = "https://slack.com"
130+
}
131+
apiClient := api.NewClient(nil, apiHost, clients.IO)
132+
133+
var bodyReader *strings.Reader
134+
var contentType string
135+
136+
// When the token is placed in the request body (form-encoded), clear it so
137+
// RawRequest does not also send it in the Authorization header.
138+
switch {
139+
case flags.json != "":
140+
contentType = "application/json; charset=utf-8"
141+
bodyReader = strings.NewReader(flags.json)
142+
case flags.data != "":
143+
contentType = "application/x-www-form-urlencoded"
144+
formData := flags.data
145+
if !strings.Contains(formData, "token=") {
146+
if formData != "" {
147+
formData = formData + "&token=" + url.QueryEscape(token)
148+
} else {
149+
formData = "token=" + url.QueryEscape(token)
150+
}
151+
}
152+
bodyReader = strings.NewReader(formData)
153+
token = ""
154+
case len(params) == 1 && (strings.HasPrefix(params[0], "{") || strings.HasPrefix(params[0], "[")):
155+
contentType = "application/json; charset=utf-8"
156+
bodyReader = strings.NewReader(params[0])
157+
case len(params) > 0:
158+
contentType = "application/x-www-form-urlencoded"
159+
values := url.Values{}
160+
values.Set("token", token)
161+
for _, param := range params {
162+
key, value, ok := strings.Cut(param, "=")
163+
if !ok {
164+
return slackerror.New(slackerror.ErrInvalidArguments).
165+
WithMessage("invalid parameter %q: must be in key=value format", param)
166+
}
167+
values.Set(key, value)
168+
}
169+
bodyReader = strings.NewReader(values.Encode())
170+
token = ""
171+
default:
172+
contentType = "application/x-www-form-urlencoded"
173+
values := url.Values{}
174+
values.Set("token", token)
175+
bodyReader = strings.NewReader(values.Encode())
176+
token = ""
177+
}
178+
179+
customHeaders := map[string]string{}
180+
for _, h := range flags.headers {
181+
key, value, ok := strings.Cut(h, ":")
182+
if !ok {
183+
return slackerror.New(slackerror.ErrInvalidArguments).
184+
WithMessage("invalid header %q: must be in \"Key: Value\" format", h)
185+
}
186+
customHeaders[strings.TrimSpace(key)] = strings.TrimSpace(value)
187+
}
188+
189+
resp, err := apiClient.RawRequest(ctx, flags.method, method, token, bodyReader, contentType, customHeaders)
190+
if err != nil {
191+
return err
192+
}
193+
194+
if flags.include {
195+
fmt.Fprintf(cmd.OutOrStdout(), "HTTP %d\n", resp.StatusCode)
196+
keys := make([]string, 0, len(resp.Header))
197+
for key := range resp.Header {
198+
keys = append(keys, key)
199+
}
200+
sort.Strings(keys)
201+
for _, key := range keys {
202+
for _, v := range resp.Header[key] {
203+
fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", key, v)
204+
}
205+
}
206+
fmt.Fprintln(cmd.OutOrStdout())
207+
}
208+
209+
output := resp.Body
210+
// Pretty-print for interactive terminals, compact for piped output (gh/git convention)
211+
if clients.IO.IsTTY() {
212+
var indented bytes.Buffer
213+
if json.Indent(&indented, resp.Body, "", " ") == nil {
214+
output = indented.Bytes()
215+
}
216+
}
217+
fmt.Fprint(cmd.OutOrStdout(), string(output))
218+
219+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
220+
return slackerror.New("api_request_failed").
221+
WithMessage("API request failed with status %d", resp.StatusCode)
222+
}
223+
224+
return nil
225+
}
226+
227+
// resolveToken determines the API token to use for the request
228+
func resolveToken(ctx context.Context, clients *shared.ClientFactory) (string, error) {
229+
if clients.Config.TokenFlag != "" {
230+
return clients.Config.TokenFlag, nil
231+
}
232+
233+
if clients.Config.AppFlag != "" {
234+
if sdkConfigExists, _ := clients.SDKConfig.Exists(); sdkConfigExists {
235+
selected, err := prompts.AppSelectPrompt(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly)
236+
if err != nil {
237+
return "", err
238+
}
239+
if selected.App.AppID != "" {
240+
token, err := installAndGetBotToken(ctx, clients, selected)
241+
if err == nil && token != "" {
242+
return token, nil
243+
}
244+
}
245+
}
246+
}
247+
248+
if token := os.Getenv("SLACK_BOT_TOKEN"); token != "" {
249+
return token, nil
250+
}
251+
252+
if token := os.Getenv("SLACK_USER_TOKEN"); token != "" {
253+
return token, nil
254+
}
255+
256+
if sdkConfigExists, _ := clients.SDKConfig.Exists(); sdkConfigExists {
257+
selected, err := prompts.AppSelectPrompt(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly)
258+
if err != nil {
259+
return "", err
260+
}
261+
if selected.App.AppID != "" {
262+
token, err := installAndGetBotToken(ctx, clients, selected)
263+
if err == nil && token != "" {
264+
return token, nil
265+
}
266+
}
267+
}
268+
269+
return "", slackerror.New(slackerror.ErrNotAuthed).
270+
WithMessage("no token found").
271+
WithRemediation("Provide a token with --token, --app, or set SLACK_BOT_TOKEN")
272+
}
273+
274+
// installAndGetBotToken installs the selected app and returns its bot token
275+
func installAndGetBotToken(ctx context.Context, clients *shared.ClientFactory, selected prompts.SelectedApp) (string, error) {
276+
manifestSource, _ := clients.Config.ProjectConfig.GetManifestSource(ctx)
277+
var slackManifest types.SlackYaml
278+
var err error
279+
if manifestSource.Equals(config.ManifestSourceRemote) {
280+
slackManifest, err = clients.AppClient().Manifest.GetManifestRemote(ctx, selected.Auth.Token, selected.App.AppID)
281+
} else {
282+
slackManifest, err = clients.AppClient().Manifest.GetManifestLocal(ctx, clients.SDKConfig, clients.HookExecutor)
283+
}
284+
if err != nil {
285+
return "", err
286+
}
287+
288+
manifest := slackManifest.AppManifest
289+
botScopes := []string{}
290+
if manifest.OAuthConfig != nil && manifest.OAuthConfig.Scopes != nil {
291+
botScopes = manifest.OAuthConfig.Scopes.Bot
292+
}
293+
outgoingDomains := []string{}
294+
if manifest.OutgoingDomains != nil {
295+
outgoingDomains = *manifest.OutgoingDomains
296+
}
297+
298+
result, _, err := clients.API().DeveloperAppInstall(ctx, clients.IO, selected.Auth.Token, selected.App, botScopes, outgoingDomains, "", false)
299+
if err != nil {
300+
return "", err
301+
}
302+
303+
return result.APIAccessTokens.Bot, nil
304+
}

0 commit comments

Comments
 (0)