Files
Paymahn Moghadasian f7ee20e122 fix: retrieve image attachments and always enable attachments get (#4)
* fix: retrieve image attachment bytes via `attachments get -o`

Image files come back from the upstream FilesGet handler as an out-of-band
MCP ImageContent block; the CLI's toolcall adapter extracted only
TextContent, so `attachments get <image_id>` emitted only metadata and
silently dropped the image bytes (even with --raw). Non-image binaries
were unaffected (single base64-in-JSON text envelope).

CLI-layer fix (pkg/handler stays byte-for-byte upstream for clean merges):
- toolcall.InvokeResult captures ImageContent into Result.Images; Invoke
  is now a thin text-only wrapper, so existing callers are unchanged.
- `attachments get` gains -o/--output <path>: decodes the bytes straight
  to a file and prints a small metadata confirmation, keeping stdout free
  of a multi-MB base64 blob. Without -o, image bytes are folded into the
  same {..,"encoding":"base64","content":..} envelope as non-image
  binaries, so `jq -r .content | base64 --decode` works uniformly.
- Update SKILL.md and README with the -o flag and decode recipes.

Adds unit tests for InvokeResult image capture and the attachment
rendering paths (recover-in-envelope, -o writes raw bytes, stdout has no
blob).

* fix: always enable `attachments get` (drop SLACK_MCP_ATTACHMENT_TOOL gate)

Downloading an attachment is a read-only operation, not a mutation like
posting or reacting, so requiring an opt-in env var was needless friction.
The upstream FilesGet handler reads SLACK_MCP_ATTACHMENT_TOOL from the
environment at parse time; the CLI now sets it to "true" in-process before
invoking (same in-process-env pattern as auth/govslack), so `attachments
get` always works. pkg/handler stays byte-for-byte upstream.

Verified live against the real workspace: `attachments get F0000000000`
with no env var now returns Slack `file_not_found` instead of the
"attachment_get_data tool is disabled" gate error.

Docs: move `attachments get` from the write/sensitive section to the read
commands and drop the env-var prefix from recipes (SKILL.md + README).
2026-07-07 12:12:36 -05:00

172 lines
6.1 KiB
Go

package cmds
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"github.com/paymog/slack-cli/internal/config"
"github.com/paymog/slack-cli/internal/output"
"github.com/paymog/slack-cli/internal/runtime"
"github.com/paymog/slack-cli/internal/toolcall"
"github.com/paymog/slack-cli/pkg/handler"
"github.com/spf13/cobra"
)
func newAttachmentsCommand(cfg *config.Config) *cobra.Command {
cmd := &cobra.Command{
Use: "attachments",
Aliases: []string{"attachment", "files"},
Short: "Download attachment data (requires SLACK_MCP_ATTACHMENT_TOOL)",
}
cmd.AddCommand(attachmentsGetCommand(cfg))
return cmd
}
func attachmentsGetCommand(cfg *config.Config) *cobra.Command {
var outPath string
cmd := &cobra.Command{
Use: "get <file_id>",
Short: "Download an attachment by file ID (Fxxxxxxxxxx); max 5MB",
Long: "Download an attachment by file ID (Fxxxxxxxxxx); max 5MB.\n\n" +
"By default the file is printed as a JSON envelope with the bytes inline,\n" +
"base64-encoded for images and other binaries (decode with\n" +
"`jq -r .content | base64 --decode`). Pass -o/--output to write the decoded\n" +
"bytes straight to a file and keep stdout to a small metadata confirmation —\n" +
"recommended for images and large binaries so a multi-MB blob does not flood\n" +
"the terminal.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// attachments get is a read-only download, not a mutation like
// posting or reacting, so the CLI always enables the upstream
// attachment gate in-process instead of making users set
// SLACK_MCP_ATTACHMENT_TOOL. The handler reads this env var at parse
// time; the standalone MCP server still gates via its own env.
_ = os.Setenv("SLACK_MCP_ATTACHMENT_TOOL", "true")
p, logger, err := runtime.PrepareRead(cmd.Context(), cfg)
if err != nil {
return err
}
h := handler.NewConversationsHandler(p, logger)
res, err := toolcall.InvokeResult(cmd.Context(), h.FilesGetHandler, "attachment_get_data", map[string]any{"file_id": args[0]})
if err != nil {
return err
}
return writeAttachment(cmd.OutOrStdout(), res, outPath, cfg.Raw)
},
}
cmd.Flags().StringVarP(&outPath, "output", "o", "", "write the decoded file bytes to this path instead of printing them inline")
return cmd
}
// attachmentMeta is the JSON envelope the FilesGet handler emits for a file.
// For images the handler returns the bytes as a separate MCP image block, so
// Encoding/Content are empty in this text envelope and the bytes arrive in
// toolcall.Result.Images instead.
type attachmentMeta struct {
FileID string `json:"file_id"`
Filename string `json:"filename"`
Mimetype string `json:"mimetype"`
Size int `json:"size"`
Encoding string `json:"encoding"`
Content string `json:"content"`
}
// writeAttachment renders a fetched attachment. Without -o it prints the JSON
// envelope with the bytes inline, folding image bytes (which the handler
// delivers out-of-band as a native MCP image block) back into the "content"
// field so nothing is lost. With -o it decodes the bytes to disk and prints a
// small metadata confirmation, so an agent's stdout is not flooded with a
// multi-MB base64 blob.
func writeAttachment(w io.Writer, res *toolcall.Result, outPath string, raw bool) error {
if outPath == "" {
text := res.Text
if len(res.Images) > 0 {
text = injectContent(res.Text, "base64", res.Images[0].Data)
}
return output.Print(w, text, raw, false)
}
data, meta, err := attachmentBytes(res)
if err != nil {
return err
}
if err := os.WriteFile(outPath, data, 0o644); err != nil {
return fmt.Errorf("write %s: %w", outPath, err)
}
return printAttachmentConfirmation(w, meta, outPath, len(data))
}
// attachmentBytes returns the decoded file bytes plus their metadata. Image
// bytes come from the out-of-band image block; every other file carries its
// bytes in the envelope's "content" field (base64 for binaries, verbatim for
// text).
func attachmentBytes(res *toolcall.Result) ([]byte, attachmentMeta, error) {
var meta attachmentMeta
if strings.TrimSpace(res.Text) != "" {
if err := json.Unmarshal([]byte(res.Text), &meta); err != nil {
return nil, meta, fmt.Errorf("parse attachment metadata: %w", err)
}
}
if len(res.Images) > 0 {
if meta.Mimetype == "" {
meta.Mimetype = res.Images[0].MIMEType
}
data, err := base64.StdEncoding.DecodeString(res.Images[0].Data)
if err != nil {
return nil, meta, fmt.Errorf("decode image data: %w", err)
}
return data, meta, nil
}
if meta.Encoding == "base64" {
data, err := base64.StdEncoding.DecodeString(meta.Content)
if err != nil {
return nil, meta, fmt.Errorf("decode base64 content: %w", err)
}
return data, meta, nil
}
return []byte(meta.Content), meta, nil
}
// printAttachmentConfirmation writes the post-download metadata as JSON (HTML
// escaping off so filenames stay legible), reporting how many bytes landed
// where. size is the number of bytes actually written.
func printAttachmentConfirmation(w io.Writer, meta attachmentMeta, path string, n int) error {
conf := struct {
FileID string `json:"file_id"`
Filename string `json:"filename"`
Mimetype string `json:"mimetype"`
Size int `json:"size"`
Path string `json:"path"`
}{meta.FileID, meta.Filename, meta.Mimetype, n, path}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(conf); err != nil {
return err
}
_, err := w.Write(buf.Bytes())
return err
}
// injectContent folds encoding/content fields into the handler's metadata JSON
// object, preserving field order (a struct round-trip would sort keys and
// HTML-escape Slack markup). b64 is base64 text, so it needs no escaping.
func injectContent(metaJSON, encoding, b64 string) string {
trimmed := strings.TrimSpace(metaJSON)
if !strings.HasSuffix(trimmed, "}") {
return fmt.Sprintf(`{"encoding":%q,"content":%q}`, encoding, b64)
}
inner := strings.TrimSuffix(trimmed, "}")
sep := ","
if strings.TrimSpace(strings.TrimPrefix(inner, "{")) == "" {
sep = ""
}
return fmt.Sprintf(`%s%s"encoding":%q,"content":%q}`, inner, sep, encoding, b64)
}