mirror of
https://github.com/paymog/slack-cli.git
synced 2026-09-18 23:43:49 +08:00
d781728c6c
* feat: add slack-cli, a no-daemon CLI over the slack-mcp-server engine
Turn the forked slack-mcp-server into a CLI so running many agents no
longer means one resident MCP process each. Every command is a
short-lived process that reads the shared on-disk cache.
- rename module to github.com/paymog/slack-cli (go install/homebrew/ldflags)
- internal/toolcall: invoke the upstream tool handlers in-process; the only
mcp-go coupling lives here, so pkg/handler and pkg/provider are reused
byte-for-byte (clean upstream merges, fork-and-extend)
- internal/{cli,cmds,config,credstore,runtime,output}: cobra command tree,
keyring-backed credential profiles, provider bootstrap, result printing
- 21 tools as subcommands (channels, conversations, users, usergroups,
saved, reactions, attachments, cache); write tools keep their env gating
- goreleaser + homebrew release workflow; ships a skills/slack-cli skill
- unit tests for config/credstore/toolcall; MCP server still builds
The MCP server (cmd/slack-mcp-server) is kept intact.
* chore(napkin): record real-workspace verification
* docs: explain how the CLI works (in-process handler invocation, shared cache)
448 lines
12 KiB
Go
448 lines
12 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/gocarina/gocsv"
|
|
"github.com/paymog/slack-cli/pkg/provider"
|
|
"github.com/slack-go/slack"
|
|
"github.com/paymog/slack-cli/pkg/server/auth"
|
|
"github.com/paymog/slack-cli/pkg/text"
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
type Channel struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Topic string `json:"topic"`
|
|
Purpose string `json:"purpose"`
|
|
MemberCount int `json:"memberCount"`
|
|
Cursor string `json:"cursor"`
|
|
}
|
|
|
|
type ChannelsHandler struct {
|
|
apiProvider *provider.ApiProvider
|
|
validTypes map[string]bool
|
|
logger *zap.Logger
|
|
}
|
|
|
|
func NewChannelsHandler(apiProvider *provider.ApiProvider, logger *zap.Logger) *ChannelsHandler {
|
|
validTypes := make(map[string]bool, len(provider.AllChanTypes))
|
|
for _, v := range provider.AllChanTypes {
|
|
validTypes[v] = true
|
|
}
|
|
|
|
return &ChannelsHandler{
|
|
apiProvider: apiProvider,
|
|
validTypes: validTypes,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
func (ch *ChannelsHandler) ChannelsResource(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
|
|
ch.logger.Debug("ChannelsResource called", zap.Any("params", request.Params))
|
|
|
|
// mark3labs/mcp-go does not support middlewares for resources.
|
|
if authenticated, err := auth.IsAuthenticated(ctx, ch.apiProvider.ServerTransport(), ch.logger); !authenticated {
|
|
ch.logger.Error("Authentication failed for channels resource", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
var channelList []Channel
|
|
|
|
if ready, err := ch.apiProvider.IsReady(); !ready {
|
|
ch.logger.Error("API provider not ready", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
ar, err := ch.apiProvider.Slack().AuthTest()
|
|
if err != nil {
|
|
ch.logger.Error("Auth test failed", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
ws, err := text.Workspace(ar.URL)
|
|
if err != nil {
|
|
ch.logger.Error("Failed to parse workspace from URL",
|
|
zap.String("url", ar.URL),
|
|
zap.Error(err),
|
|
)
|
|
return nil, fmt.Errorf("failed to parse workspace from URL: %v", err)
|
|
}
|
|
|
|
channels := ch.apiProvider.ProvideChannelsMaps().Channels
|
|
ch.logger.Debug("Retrieved channels from provider", zap.Int("count", len(channels)))
|
|
|
|
for _, channel := range channels {
|
|
channelList = append(channelList, Channel{
|
|
ID: channel.ID,
|
|
Name: channel.Name,
|
|
Topic: channel.Topic,
|
|
Purpose: channel.Purpose,
|
|
MemberCount: channel.MemberCount,
|
|
})
|
|
}
|
|
|
|
csvBytes, err := gocsv.MarshalBytes(&channelList)
|
|
if err != nil {
|
|
ch.logger.Error("Failed to marshal channels to CSV", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
return []mcp.ResourceContents{
|
|
mcp.TextResourceContents{
|
|
URI: "slack://" + ws + "/channels",
|
|
MIMEType: "text/csv",
|
|
Text: string(csvBytes),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (ch *ChannelsHandler) ChannelsHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
ch.logger.Debug("ChannelsHandler called")
|
|
|
|
if ready, err := ch.apiProvider.IsReady(); !ready {
|
|
ch.logger.Error("API provider not ready", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
sortType := request.GetString("sort", "popularity")
|
|
types := request.GetString("channel_types", provider.PubChanType)
|
|
cursor := request.GetString("cursor", "")
|
|
limit := request.GetInt("limit", 0)
|
|
query := request.GetString("query", "")
|
|
queryTargets := request.GetString("query_targets", "name")
|
|
|
|
ch.logger.Debug("Request parameters",
|
|
zap.String("sort", sortType),
|
|
zap.String("channel_types", types),
|
|
zap.String("cursor", cursor),
|
|
zap.Int("limit", limit),
|
|
zap.String("query", query),
|
|
zap.String("query_targets", queryTargets),
|
|
)
|
|
|
|
// MCP Inspector v0.14.0 has issues with Slice type
|
|
// introspection, so some type simplification makes sense here
|
|
channelTypes := []string{}
|
|
for _, t := range strings.Split(types, ",") {
|
|
t = strings.TrimSpace(t)
|
|
if ch.validTypes[t] {
|
|
channelTypes = append(channelTypes, t)
|
|
} else if t != "" {
|
|
ch.logger.Warn("Invalid channel type ignored", zap.String("type", t))
|
|
}
|
|
}
|
|
|
|
if len(channelTypes) == 0 {
|
|
ch.logger.Debug("No valid channel types provided, using defaults")
|
|
channelTypes = append(channelTypes, provider.PubChanType)
|
|
channelTypes = append(channelTypes, provider.PrivateChanType)
|
|
}
|
|
|
|
ch.logger.Debug("Validated channel types", zap.Strings("types", channelTypes))
|
|
|
|
if limit == 0 {
|
|
limit = 100
|
|
ch.logger.Debug("Limit not provided, using default", zap.Int("limit", limit))
|
|
}
|
|
if limit > 999 {
|
|
ch.logger.Warn("Limit exceeds maximum, capping to 999", zap.Int("requested", limit))
|
|
limit = 999
|
|
}
|
|
|
|
var (
|
|
nextcur string
|
|
channelList []Channel
|
|
)
|
|
|
|
allChannels := ch.apiProvider.ProvideChannelsMaps().Channels
|
|
ch.logger.Debug("Total channels available", zap.Int("count", len(allChannels)))
|
|
|
|
channels := filterChannelsByTypes(allChannels, channelTypes)
|
|
ch.logger.Debug("Channels after filtering by type", zap.Int("count", len(channels)))
|
|
|
|
if query != "" {
|
|
validTargets := map[string]bool{"name": true, "topic": true, "purpose": true}
|
|
targetSet := make(map[string]bool)
|
|
for _, t := range strings.Split(queryTargets, ",") {
|
|
t = strings.TrimSpace(strings.ToLower(t))
|
|
if validTargets[t] {
|
|
targetSet[t] = true
|
|
} else if t != "" {
|
|
ch.logger.Warn("Invalid query target ignored", zap.String("target", t))
|
|
}
|
|
}
|
|
if len(targetSet) == 0 {
|
|
ch.logger.Debug("No valid query targets provided, using default (name)")
|
|
targetSet["name"] = true
|
|
}
|
|
|
|
channels = filterChannelsByQuery(channels, query, targetSet)
|
|
ch.logger.Debug("Channels after keyword filter", zap.Int("count", len(channels)))
|
|
}
|
|
|
|
var chans []provider.Channel
|
|
|
|
chans, nextcur = paginateChannels(
|
|
channels,
|
|
cursor,
|
|
limit,
|
|
)
|
|
|
|
ch.logger.Debug("Pagination results",
|
|
zap.Int("returned_count", len(chans)),
|
|
zap.Bool("has_next_page", nextcur != ""),
|
|
)
|
|
|
|
for _, channel := range chans {
|
|
channelList = append(channelList, Channel{
|
|
ID: channel.ID,
|
|
Name: channel.Name,
|
|
Topic: channel.Topic,
|
|
Purpose: channel.Purpose,
|
|
MemberCount: channel.MemberCount,
|
|
})
|
|
}
|
|
|
|
switch sortType {
|
|
case "popularity":
|
|
ch.logger.Debug("Sorting channels by popularity (member count)")
|
|
sort.Slice(channelList, func(i, j int) bool {
|
|
return channelList[i].MemberCount > channelList[j].MemberCount
|
|
})
|
|
default:
|
|
ch.logger.Debug("No sorting applied", zap.String("sort_type", sortType))
|
|
}
|
|
|
|
if len(channelList) > 0 && nextcur != "" {
|
|
channelList[len(channelList)-1].Cursor = nextcur
|
|
ch.logger.Debug("Added cursor to last channel", zap.String("cursor", nextcur))
|
|
}
|
|
|
|
csvBytes, err := gocsv.MarshalBytes(&channelList)
|
|
if err != nil {
|
|
ch.logger.Error("Failed to marshal channels to CSV", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(csvBytes)), nil
|
|
}
|
|
|
|
func (ch *ChannelsHandler) ChannelsMeHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
ch.logger.Debug("ChannelsMeHandler called")
|
|
|
|
types := request.GetString("channel_types", "public_channel,private_channel")
|
|
cursor := request.GetString("cursor", "")
|
|
limit := request.GetInt("limit", 0)
|
|
|
|
if limit == 0 {
|
|
limit = 100
|
|
}
|
|
if limit > 999 {
|
|
limit = 999
|
|
}
|
|
|
|
channelTypes := []string{}
|
|
for _, t := range strings.Split(types, ",") {
|
|
t = strings.TrimSpace(t)
|
|
if ch.validTypes[t] {
|
|
channelTypes = append(channelTypes, t)
|
|
}
|
|
}
|
|
if len(channelTypes) == 0 {
|
|
channelTypes = []string{provider.PubChanType, provider.PrivateChanType}
|
|
}
|
|
|
|
// Fetch channels via the Slack API, stopping as soon as we have enough
|
|
// results and using the API's native cursor for pagination. This avoids
|
|
// fetching every channel the user belongs to on large workspaces.
|
|
usersMap := ch.apiProvider.ProvideUsersMap().Users
|
|
var allChannels []provider.Channel
|
|
var apiCursor string
|
|
var slackNextCursor string
|
|
|
|
if cursor != "" {
|
|
apiCursor = cursor
|
|
}
|
|
|
|
for {
|
|
params := &slack.GetConversationsForUserParameters{
|
|
Types: channelTypes,
|
|
Limit: 200,
|
|
Cursor: apiCursor,
|
|
ExcludeArchived: true,
|
|
}
|
|
channels, nextCursor, err := ch.apiProvider.Slack().GetConversationsForUserContext(ctx, params)
|
|
if err != nil {
|
|
ch.logger.Error("Failed to fetch user conversations", zap.Error(err))
|
|
return nil, fmt.Errorf("failed to fetch your channels: %v", err)
|
|
}
|
|
|
|
for _, c := range channels {
|
|
allChannels = append(allChannels, provider.MapChannelFromSlack(c, usersMap))
|
|
}
|
|
|
|
// Early exit: stop paginating through the Slack API once we have enough.
|
|
if len(allChannels) >= limit {
|
|
slackNextCursor = nextCursor
|
|
break
|
|
}
|
|
|
|
if nextCursor == "" {
|
|
break
|
|
}
|
|
apiCursor = nextCursor
|
|
}
|
|
|
|
ch.logger.Debug("Fetched member channels", zap.Int("count", len(allChannels)))
|
|
|
|
// Truncate to limit and use the Slack API cursor.
|
|
end := limit
|
|
if end > len(allChannels) {
|
|
end = len(allChannels)
|
|
}
|
|
var channelList []Channel
|
|
for _, channel := range allChannels[:end] {
|
|
channelList = append(channelList, Channel{
|
|
ID: channel.ID,
|
|
Name: channel.Name,
|
|
Topic: channel.Topic,
|
|
Purpose: channel.Purpose,
|
|
MemberCount: channel.MemberCount,
|
|
})
|
|
}
|
|
|
|
if len(channelList) > 0 && slackNextCursor != "" {
|
|
channelList[len(channelList)-1].Cursor = slackNextCursor
|
|
}
|
|
|
|
csvBytes, err := gocsv.MarshalBytes(&channelList)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(csvBytes)), nil
|
|
}
|
|
|
|
func filterChannelsByTypes(channels map[string]provider.Channel, types []string) []provider.Channel {
|
|
logger := zap.L()
|
|
|
|
var result []provider.Channel
|
|
typeSet := make(map[string]bool)
|
|
|
|
for _, t := range types {
|
|
typeSet[t] = true
|
|
}
|
|
|
|
publicCount := 0
|
|
privateCount := 0
|
|
imCount := 0
|
|
mpimCount := 0
|
|
|
|
for _, ch := range channels {
|
|
if typeSet["public_channel"] && !ch.IsPrivate && !ch.IsIM && !ch.IsMpIM {
|
|
result = append(result, ch)
|
|
publicCount++
|
|
}
|
|
if typeSet["private_channel"] && ch.IsPrivate && !ch.IsIM && !ch.IsMpIM {
|
|
result = append(result, ch)
|
|
privateCount++
|
|
}
|
|
if typeSet["im"] && ch.IsIM {
|
|
result = append(result, ch)
|
|
imCount++
|
|
}
|
|
if typeSet["mpim"] && ch.IsMpIM {
|
|
result = append(result, ch)
|
|
mpimCount++
|
|
}
|
|
}
|
|
|
|
logger.Debug("Channel filtering complete",
|
|
zap.Int("total_input", len(channels)),
|
|
zap.Int("total_output", len(result)),
|
|
zap.Int("public_channels", publicCount),
|
|
zap.Int("private_channels", privateCount),
|
|
zap.Int("ims", imCount),
|
|
zap.Int("mpims", mpimCount),
|
|
)
|
|
|
|
return result
|
|
}
|
|
|
|
func filterChannelsByQuery(channels []provider.Channel, query string, targetSet map[string]bool) []provider.Channel {
|
|
q := strings.ToLower(query)
|
|
var result []provider.Channel
|
|
for _, ch := range channels {
|
|
if (targetSet["name"] && strings.Contains(strings.ToLower(ch.Name), q)) ||
|
|
(targetSet["topic"] && strings.Contains(strings.ToLower(ch.Topic), q)) ||
|
|
(targetSet["purpose"] && strings.Contains(strings.ToLower(ch.Purpose), q)) {
|
|
result = append(result, ch)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func paginateChannels(channels []provider.Channel, cursor string, limit int) ([]provider.Channel, string) {
|
|
logger := zap.L()
|
|
|
|
sort.Slice(channels, func(i, j int) bool {
|
|
return channels[i].ID < channels[j].ID
|
|
})
|
|
|
|
startIndex := 0
|
|
if cursor != "" {
|
|
if decoded, err := base64.StdEncoding.DecodeString(cursor); err == nil {
|
|
lastID := string(decoded)
|
|
for i, ch := range channels {
|
|
if ch.ID > lastID {
|
|
startIndex = i
|
|
break
|
|
}
|
|
}
|
|
logger.Debug("Decoded cursor",
|
|
zap.String("cursor", cursor),
|
|
zap.String("decoded_id", lastID),
|
|
zap.Int("start_index", startIndex),
|
|
)
|
|
} else {
|
|
logger.Warn("Failed to decode cursor",
|
|
zap.String("cursor", cursor),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
}
|
|
|
|
endIndex := startIndex + limit
|
|
if endIndex > len(channels) {
|
|
endIndex = len(channels)
|
|
}
|
|
|
|
paged := channels[startIndex:endIndex]
|
|
|
|
var nextCursor string
|
|
if endIndex < len(channels) {
|
|
nextCursor = base64.StdEncoding.EncodeToString([]byte(channels[endIndex-1].ID))
|
|
logger.Debug("Generated next cursor",
|
|
zap.String("last_id", channels[endIndex-1].ID),
|
|
zap.String("next_cursor", nextCursor),
|
|
)
|
|
}
|
|
|
|
logger.Debug("Pagination complete",
|
|
zap.Int("total_channels", len(channels)),
|
|
zap.Int("start_index", startIndex),
|
|
zap.Int("end_index", endIndex),
|
|
zap.Int("page_size", len(paged)),
|
|
zap.Bool("has_more", nextCursor != ""),
|
|
)
|
|
|
|
return paged, nextCursor
|
|
}
|