Skip to content
Merged
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
28 changes: 28 additions & 0 deletions SLACK_CLI_USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,31 @@ slack-cli search files 'report' --limit 10
slack-cli message send '#channel-name' 'Hello from the agent'
```

> ⚠️ **Do not put a formatted or multi-line body in a double-quoted shell
> argument.** The shell interprets backticks, `$`, and quotes *before*
> `slack-cli` ever sees them, so Slack mrkdwn like `` `code` `` gets executed as
> a command and silently dropped. For anything beyond a trivial single-line
> string, pass the body via `--file` or stdin instead — then the shell never
> touches it.

Read the body from a file:

```
slack-cli message send '#channel-name' --file alert.txt
```

Or pipe it via stdin (`-` as the body, ideally with a **quoted** heredoc so
nothing is interpolated):

```
slack-cli message send '#channel-name' - <<'EOF'
:rotating_light: *Alert* with `code`, $vars, and 'quotes' all kept literal
EOF
```

The body is resolved in this order: `--file` (`-` means stdin), then the
positional `text` argument, then piped stdin when no text argument is given.

### Reply in a thread

```
Expand All @@ -151,6 +176,9 @@ slack-cli message send 'https://myteam.slack.com/archives/C01234/p17720657786762
slack-cli message edit '#channel-name' 'Updated text' --ts 1772065778.676219
```

The new body also accepts `--file <path>` or stdin (same precedence as `send`),
which you should prefer for any formatted or multi-line content.

### Delete a message

```
Expand Down
92 changes: 86 additions & 6 deletions cmd/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package cmd

import (
"fmt"
"io"
"os"

"github.com/nullify/slack-cli/internal/api"
"github.com/nullify/slack-cli/internal/output"
Expand Down Expand Up @@ -146,12 +148,31 @@ var messageListCmd = &cobra.Command{
}

var messageSendCmd = &cobra.Command{
Use: "send <target> <text>",
Use: "send <target> [text]",
Short: "Send a message (optionally into a thread)",
Args: cobra.ExactArgs(2),
Long: `Send a message to a channel, user, or thread.

The message body can be supplied three ways (checked in this order):
- --file <path> read the body from a file ('-' means stdin)
- <text> a positional argument
- stdin piped input when no text argument is given

Prefer --file or stdin for multi-line or formatted messages: passing the body
as a shell argument means the shell interprets backticks, $, and quotes, which
silently corrupts Slack mrkdwn. A quoted heredoc avoids this entirely:

slack-cli message send C01234ABC - <<'EOF'
:rotating_light: *Alert* with ` + "`code`" + ` and 'quotes' kept literal
EOF`,
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
threadTS, _ := cmd.Flags().GetString("thread-ts")

text, err := resolveMessageText(cmd, args, 1)
if err != nil {
return err
}

target := urlparse.ParseMsgTarget(args[0])
client, err := getClient()
if err != nil {
Expand All @@ -172,7 +193,7 @@ var messageSendCmd = &cobra.Command{
}
}

msg, err := slack.SendMessage(cmd.Context(), client, channelID, args[1], threadTS)
msg, err := slack.SendMessage(cmd.Context(), client, channelID, text, threadTS)
if err != nil {
return err
}
Expand All @@ -182,12 +203,21 @@ var messageSendCmd = &cobra.Command{
}

var messageEditCmd = &cobra.Command{
Use: "edit <target> <text>",
Use: "edit <target> [text]",
Short: "Edit an existing message",
Args: cobra.ExactArgs(2),
Long: `Edit an existing message. The new body can be supplied via --file <path>
('-' means stdin), a positional argument, or piped stdin (checked in that
order). Prefer --file or stdin for formatted messages so the shell does not
interpret backticks, $, or quotes in the body.`,
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
ts, _ := cmd.Flags().GetString("ts")

text, err := resolveMessageText(cmd, args, 1)
if err != nil {
return err
}

target := urlparse.ParseMsgTarget(args[0])
client, err := getClient()
if err != nil {
Expand All @@ -199,7 +229,7 @@ var messageEditCmd = &cobra.Command{
return err
}

if err := slack.EditMessage(cmd.Context(), client, channelID, messageTS, args[1]); err != nil {
if err := slack.EditMessage(cmd.Context(), client, channelID, messageTS, text); err != nil {
return err
}

Expand Down Expand Up @@ -290,6 +320,54 @@ var messageReactRemoveCmd = &cobra.Command{
},
}

// resolveMessageText resolves a message body from, in order of precedence:
// the --file flag ('-' means stdin), the positional text argument at textArg,
// or piped stdin when neither is given. This lets callers avoid passing
// formatted message bodies as shell arguments, where backticks, $, and quotes
// would otherwise be interpreted by the shell and corrupt the message.
func resolveMessageText(cmd *cobra.Command, args []string, textArg int) (string, error) {
file, _ := cmd.Flags().GetString("file")

switch {
case file == "-":
return readAllStdin()
case file != "":
b, err := os.ReadFile(file)
if err != nil {
return "", fmt.Errorf("reading message body from %s: %w", file, err)
}
return string(b), nil
case len(args) > textArg:
return args[textArg], nil
case stdinIsPiped():
return readAllStdin()
default:
return "", fmt.Errorf("no message body: provide a text argument, --file, or pipe via stdin")
}
}

// stdinIsPiped reports whether stdin is a pipe or file rather than an
// interactive terminal, so we only block on a read when input is actually
// being fed in.
func stdinIsPiped() bool {
info, err := os.Stdin.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice == 0
}

func readAllStdin() (string, error) {
b, err := io.ReadAll(os.Stdin)
if err != nil {
return "", fmt.Errorf("reading message body from stdin: %w", err)
}
if len(b) == 0 {
return "", fmt.Errorf("no message body: provide a text argument, --file, or pipe via stdin")
}
return string(b), nil
}

// resolveTargetToChannel resolves any target to a channel ID.
func resolveTargetToChannel(cmd *cobra.Command, client *api.Client, target *types.MsgTarget) (string, error) {
ctx := cmd.Context()
Expand Down Expand Up @@ -348,9 +426,11 @@ func init() {

// message send flags
messageSendCmd.Flags().String("thread-ts", "", "Thread root ts to reply into")
messageSendCmd.Flags().String("file", "", "Read message body from a file ('-' for stdin) instead of the text argument")

// message edit flags
messageEditCmd.Flags().String("ts", "", "Message ts (required with channel target)")
messageEditCmd.Flags().String("file", "", "Read new message body from a file ('-' for stdin) instead of the text argument")

// message delete flags
messageDeleteCmd.Flags().String("ts", "", "Message ts (required with channel target)")
Expand Down