From abf0f2ccb2735fb7048adb333bbcde27ff6ca074 Mon Sep 17 00:00:00 2001 From: Jonathan Lam Date: Thu, 4 Jun 2026 09:57:00 +1000 Subject: [PATCH] feat: accept message body via --file or stdin for send/edit `message send`/`message edit` previously required the body as a positional shell argument. For formatted Slack mrkdwn this is a footgun: in a double-quoted argument the shell interprets backticks, `$`, and quotes before slack-cli sees them, so e.g. `` `org-slug` `` is run as a command and silently dropped from the message (and emits a "command not found" to stderr) while the send still "succeeds" with corrupted text. Add a `--file ` flag ('-' means stdin) and implicit piped-stdin support to both commands. Body resolution precedence: --file, positional text, then piped stdin. The positional arg stays optional and fully backward compatible. A quoted heredoc (`- <<'EOF'`) now lets callers send arbitrary formatted bodies with zero shell interpretation. Interactive stdin (a TTY) with no body returns a clear error instead of hanging. Docs updated in SLACK_CLI_USAGE.md with the hazard note and new usage. Co-Authored-By: Claude Opus 4.8 (1M context) --- SLACK_CLI_USAGE.md | 28 ++++++++++++++ cmd/message.go | 92 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/SLACK_CLI_USAGE.md b/SLACK_CLI_USAGE.md index 7c296cc..253a82f 100644 --- a/SLACK_CLI_USAGE.md +++ b/SLACK_CLI_USAGE.md @@ -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 ``` @@ -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 ` or stdin (same precedence as `send`), +which you should prefer for any formatted or multi-line content. + ### Delete a message ``` diff --git a/cmd/message.go b/cmd/message.go index 6535687..2c3fc36 100644 --- a/cmd/message.go +++ b/cmd/message.go @@ -2,6 +2,8 @@ package cmd import ( "fmt" + "io" + "os" "github.com/nullify/slack-cli/internal/api" "github.com/nullify/slack-cli/internal/output" @@ -146,12 +148,31 @@ var messageListCmd = &cobra.Command{ } var messageSendCmd = &cobra.Command{ - Use: "send ", + Use: "send [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 read the body from a file ('-' means stdin) + - 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 { @@ -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 } @@ -182,12 +203,21 @@ var messageSendCmd = &cobra.Command{ } var messageEditCmd = &cobra.Command{ - Use: "edit ", + Use: "edit [text]", Short: "Edit an existing message", - Args: cobra.ExactArgs(2), + Long: `Edit an existing message. The new body can be supplied via --file +('-' 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 { @@ -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 } @@ -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() @@ -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)")