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)
484 lines
14 KiB
Go
484 lines
14 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gocarina/gocsv"
|
|
"github.com/paymog/slack-cli/pkg/provider"
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/slack-go/slack"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
type UserGroup struct {
|
|
ID string `csv:"id" json:"id"`
|
|
Name string `csv:"name" json:"name"`
|
|
Handle string `csv:"handle" json:"handle"`
|
|
Description string `csv:"description" json:"description"`
|
|
UserCount int `csv:"user_count" json:"user_count"`
|
|
IsExternal bool `csv:"is_external" json:"is_external"`
|
|
DateCreate string `csv:"date_create" json:"date_create,omitempty"`
|
|
DateUpdate string `csv:"date_update" json:"date_update,omitempty"`
|
|
Users string `csv:"-" json:"users,omitempty"`
|
|
}
|
|
|
|
type UsergroupsHandler struct {
|
|
apiProvider *provider.ApiProvider
|
|
logger *zap.Logger
|
|
}
|
|
|
|
func NewUsergroupsHandler(apiProvider *provider.ApiProvider, logger *zap.Logger) *UsergroupsHandler {
|
|
return &UsergroupsHandler{
|
|
apiProvider: apiProvider,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// UsergroupsListHandler lists all user groups in the workspace
|
|
func (h *UsergroupsHandler) UsergroupsListHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
h.logger.Debug("UsergroupsListHandler called", zap.Any("params", request.Params))
|
|
|
|
if ready, err := h.apiProvider.IsReady(); !ready {
|
|
h.logger.Error("API provider not ready", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
includeUsers := request.GetBool("include_users", false)
|
|
includeCount := request.GetBool("include_count", true)
|
|
includeDisabled := request.GetBool("include_disabled", false)
|
|
|
|
h.logger.Debug("Request parameters",
|
|
zap.Bool("include_users", includeUsers),
|
|
zap.Bool("include_count", includeCount),
|
|
zap.Bool("include_disabled", includeDisabled),
|
|
)
|
|
|
|
options := []slack.GetUserGroupsOption{
|
|
slack.GetUserGroupsOptionIncludeUsers(includeUsers),
|
|
slack.GetUserGroupsOptionIncludeCount(includeCount),
|
|
slack.GetUserGroupsOptionIncludeDisabled(includeDisabled),
|
|
}
|
|
|
|
groups, err := h.apiProvider.Slack().GetUserGroupsContext(ctx, options...)
|
|
if err != nil {
|
|
h.logger.Error("GetUserGroupsContext failed", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
h.logger.Debug("Fetched user groups", zap.Int("count", len(groups)))
|
|
|
|
userGroupList := make([]UserGroup, 0, len(groups))
|
|
for _, g := range groups {
|
|
ug := UserGroup{
|
|
ID: g.ID,
|
|
Name: g.Name,
|
|
Handle: g.Handle,
|
|
Description: g.Description,
|
|
UserCount: g.UserCount,
|
|
IsExternal: g.IsExternal,
|
|
DateCreate: formatJSONTime(g.DateCreate),
|
|
DateUpdate: formatJSONTime(g.DateUpdate),
|
|
}
|
|
userGroupList = append(userGroupList, ug)
|
|
}
|
|
|
|
csvBytes, err := gocsv.MarshalBytes(&userGroupList)
|
|
if err != nil {
|
|
h.logger.Error("Failed to marshal user groups to CSV", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(csvBytes)), nil
|
|
}
|
|
|
|
// UsergroupsCreateHandler creates a new user group
|
|
func (h *UsergroupsHandler) UsergroupsCreateHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
h.logger.Debug("UsergroupsCreateHandler called", zap.Any("params", request.Params))
|
|
|
|
if ready, err := h.apiProvider.IsReady(); !ready {
|
|
h.logger.Error("API provider not ready", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
name := request.GetString("name", "")
|
|
if name == "" {
|
|
return nil, errors.New("name is required")
|
|
}
|
|
|
|
handle := request.GetString("handle", "")
|
|
description := request.GetString("description", "")
|
|
channelsStr := request.GetString("channels", "")
|
|
|
|
h.logger.Debug("Request parameters",
|
|
zap.String("name", name),
|
|
zap.String("handle", handle),
|
|
zap.String("description", description),
|
|
zap.String("channels", channelsStr),
|
|
)
|
|
|
|
userGroup := slack.UserGroup{
|
|
Name: name,
|
|
Handle: handle,
|
|
Description: description,
|
|
}
|
|
|
|
if channelsStr != "" {
|
|
channels := parseCommaSeparatedList(channelsStr)
|
|
userGroup.Prefs.Channels = channels
|
|
}
|
|
|
|
created, err := h.apiProvider.Slack().CreateUserGroupContext(ctx, userGroup)
|
|
if err != nil {
|
|
h.logger.Error("CreateUserGroupContext failed", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
h.logger.Debug("Created user group", zap.String("id", created.ID), zap.String("name", created.Name))
|
|
|
|
result := UserGroup{
|
|
ID: created.ID,
|
|
Name: created.Name,
|
|
Handle: created.Handle,
|
|
Description: created.Description,
|
|
UserCount: created.UserCount,
|
|
IsExternal: created.IsExternal,
|
|
DateCreate: formatJSONTime(created.DateCreate),
|
|
DateUpdate: formatJSONTime(created.DateUpdate),
|
|
}
|
|
|
|
jsonBytes, err := json.Marshal(result)
|
|
if err != nil {
|
|
h.logger.Error("Failed to marshal created user group to JSON", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(jsonBytes)), nil
|
|
}
|
|
|
|
// UsergroupsUpdateHandler updates an existing user group's metadata
|
|
func (h *UsergroupsHandler) UsergroupsUpdateHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
h.logger.Debug("UsergroupsUpdateHandler called", zap.Any("params", request.Params))
|
|
|
|
if ready, err := h.apiProvider.IsReady(); !ready {
|
|
h.logger.Error("API provider not ready", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
usergroupID := request.GetString("usergroup_id", "")
|
|
if usergroupID == "" {
|
|
return nil, errors.New("usergroup_id is required")
|
|
}
|
|
|
|
name := request.GetString("name", "")
|
|
handle := request.GetString("handle", "")
|
|
description := request.GetString("description", "")
|
|
channelsStr := request.GetString("channels", "")
|
|
|
|
h.logger.Debug("Request parameters",
|
|
zap.String("usergroup_id", usergroupID),
|
|
zap.String("name", name),
|
|
zap.String("handle", handle),
|
|
zap.String("description", description),
|
|
zap.String("channels", channelsStr),
|
|
)
|
|
|
|
var options []slack.UpdateUserGroupsOption
|
|
|
|
if name != "" {
|
|
options = append(options, slack.UpdateUserGroupsOptionName(name))
|
|
}
|
|
if handle != "" {
|
|
options = append(options, slack.UpdateUserGroupsOptionHandle(handle))
|
|
}
|
|
if description != "" {
|
|
options = append(options, slack.UpdateUserGroupsOptionDescription(&description))
|
|
}
|
|
if channelsStr != "" {
|
|
channels := parseCommaSeparatedList(channelsStr)
|
|
options = append(options, slack.UpdateUserGroupsOptionChannels(channels))
|
|
}
|
|
|
|
if len(options) == 0 {
|
|
return nil, errors.New("at least one update field (name, handle, description, or channels) is required")
|
|
}
|
|
|
|
updated, err := h.apiProvider.Slack().UpdateUserGroupContext(ctx, usergroupID, options...)
|
|
if err != nil {
|
|
h.logger.Error("UpdateUserGroupContext failed", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
h.logger.Debug("Updated user group", zap.String("id", updated.ID), zap.String("name", updated.Name))
|
|
|
|
result := UserGroup{
|
|
ID: updated.ID,
|
|
Name: updated.Name,
|
|
Handle: updated.Handle,
|
|
Description: updated.Description,
|
|
UserCount: updated.UserCount,
|
|
IsExternal: updated.IsExternal,
|
|
DateCreate: formatJSONTime(updated.DateCreate),
|
|
DateUpdate: formatJSONTime(updated.DateUpdate),
|
|
}
|
|
|
|
jsonBytes, err := json.Marshal(result)
|
|
if err != nil {
|
|
h.logger.Error("Failed to marshal updated user group to JSON", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(jsonBytes)), nil
|
|
}
|
|
|
|
// UsergroupsUsersUpdateHandler updates the members of a user group
|
|
func (h *UsergroupsHandler) UsergroupsUsersUpdateHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
h.logger.Debug("UsergroupsUsersUpdateHandler called", zap.Any("params", request.Params))
|
|
|
|
if ready, err := h.apiProvider.IsReady(); !ready {
|
|
h.logger.Error("API provider not ready", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
usergroupID := request.GetString("usergroup_id", "")
|
|
if usergroupID == "" {
|
|
return nil, errors.New("usergroup_id is required")
|
|
}
|
|
|
|
usersStr := request.GetString("users", "")
|
|
if usersStr == "" {
|
|
return nil, errors.New("users is required")
|
|
}
|
|
|
|
h.logger.Debug("Request parameters",
|
|
zap.String("usergroup_id", usergroupID),
|
|
zap.String("users", usersStr),
|
|
)
|
|
|
|
// UpdateUserGroupMembersContext expects a comma-separated string of user IDs
|
|
updated, err := h.apiProvider.Slack().UpdateUserGroupMembersContext(ctx, usergroupID, usersStr)
|
|
if err != nil {
|
|
h.logger.Error("UpdateUserGroupMembersContext failed", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
h.logger.Debug("Updated user group members",
|
|
zap.String("id", updated.ID),
|
|
zap.String("name", updated.Name),
|
|
zap.Int("user_count", updated.UserCount),
|
|
)
|
|
|
|
result := UserGroup{
|
|
ID: updated.ID,
|
|
Name: updated.Name,
|
|
Handle: updated.Handle,
|
|
Description: updated.Description,
|
|
UserCount: updated.UserCount,
|
|
IsExternal: updated.IsExternal,
|
|
DateCreate: formatJSONTime(updated.DateCreate),
|
|
DateUpdate: formatJSONTime(updated.DateUpdate),
|
|
Users: strings.Join(updated.Users, ","),
|
|
}
|
|
|
|
jsonBytes, err := json.Marshal(result)
|
|
if err != nil {
|
|
h.logger.Error("Failed to marshal updated user group to JSON", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(jsonBytes)), nil
|
|
}
|
|
|
|
// UsergroupsMeHandler allows the current user to list their groups, join or leave a user group
|
|
func (h *UsergroupsHandler) UsergroupsMeHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
h.logger.Debug("UsergroupsMeHandler called", zap.Any("params", request.Params))
|
|
|
|
if ready, err := h.apiProvider.IsReady(); !ready {
|
|
h.logger.Error("API provider not ready", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
action := request.GetString("action", "")
|
|
if action != "list" && action != "join" && action != "leave" {
|
|
return nil, errors.New("action must be 'list', 'join', or 'leave'")
|
|
}
|
|
|
|
// Get current user ID
|
|
authResp, err := h.apiProvider.Slack().AuthTest()
|
|
if err != nil {
|
|
h.logger.Error("AuthTest failed", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
currentUserID := authResp.UserID
|
|
h.logger.Debug("Current user ID", zap.String("user_id", currentUserID))
|
|
|
|
// Handle list action
|
|
if action == "list" {
|
|
return h.handleListMyGroups(ctx, currentUserID)
|
|
}
|
|
|
|
// For join/leave, usergroup_id is required
|
|
usergroupID := request.GetString("usergroup_id", "")
|
|
if usergroupID == "" {
|
|
return nil, errors.New("usergroup_id is required for join/leave actions")
|
|
}
|
|
|
|
h.logger.Debug("Request parameters",
|
|
zap.String("usergroup_id", usergroupID),
|
|
zap.String("action", action),
|
|
)
|
|
|
|
// Get current members of the group
|
|
members, err := h.apiProvider.Slack().GetUserGroupMembersContext(ctx, usergroupID)
|
|
if err != nil {
|
|
h.logger.Error("GetUserGroupMembersContext failed", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
h.logger.Debug("Current group members", zap.Int("count", len(members)), zap.Strings("members", members))
|
|
|
|
// Check current membership
|
|
isMember := false
|
|
memberIndex := -1
|
|
for i, uid := range members {
|
|
if uid == currentUserID {
|
|
isMember = true
|
|
memberIndex = i
|
|
break
|
|
}
|
|
}
|
|
|
|
var newMembers []string
|
|
var resultMessage string
|
|
|
|
if action == "join" {
|
|
if isMember {
|
|
return mcp.NewToolResultText("You are already a member of this user group."), nil
|
|
}
|
|
newMembers = append(members, currentUserID)
|
|
resultMessage = "Successfully joined the user group."
|
|
} else { // leave
|
|
if !isMember {
|
|
return mcp.NewToolResultText("You are not a member of this user group."), nil
|
|
}
|
|
// Remove current user from members
|
|
newMembers = append(members[:memberIndex], members[memberIndex+1:]...)
|
|
resultMessage = "Successfully left the user group."
|
|
}
|
|
|
|
// Update the group members
|
|
membersStr := strings.Join(newMembers, ",")
|
|
updated, err := h.apiProvider.Slack().UpdateUserGroupMembersContext(ctx, usergroupID, membersStr)
|
|
if err != nil {
|
|
h.logger.Error("UpdateUserGroupMembersContext failed", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
h.logger.Debug("Updated user group members",
|
|
zap.String("id", updated.ID),
|
|
zap.String("name", updated.Name),
|
|
zap.Int("new_user_count", updated.UserCount),
|
|
)
|
|
|
|
result := struct {
|
|
Message string `json:"message"`
|
|
GroupID string `json:"group_id"`
|
|
GroupName string `json:"group_name"`
|
|
UserCount int `json:"user_count"`
|
|
}{
|
|
Message: resultMessage,
|
|
GroupID: updated.ID,
|
|
GroupName: updated.Name,
|
|
UserCount: updated.UserCount,
|
|
}
|
|
|
|
jsonBytes, err := json.Marshal(result)
|
|
if err != nil {
|
|
h.logger.Error("Failed to marshal result to JSON", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(jsonBytes)), nil
|
|
}
|
|
|
|
// handleListMyGroups returns groups where the current user is a member
|
|
func (h *UsergroupsHandler) handleListMyGroups(ctx context.Context, currentUserID string) (*mcp.CallToolResult, error) {
|
|
options := []slack.GetUserGroupsOption{
|
|
slack.GetUserGroupsOptionIncludeUsers(true),
|
|
slack.GetUserGroupsOptionIncludeCount(true),
|
|
slack.GetUserGroupsOptionIncludeDisabled(false),
|
|
}
|
|
|
|
groups, err := h.apiProvider.Slack().GetUserGroupsContext(ctx, options...)
|
|
if err != nil {
|
|
h.logger.Error("GetUserGroupsContext failed", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
h.logger.Debug("Fetched user groups for filtering", zap.Int("count", len(groups)))
|
|
|
|
userGroupList := make([]UserGroup, 0)
|
|
for _, g := range groups {
|
|
isMember := false
|
|
for _, uid := range g.Users {
|
|
if uid == currentUserID {
|
|
isMember = true
|
|
break
|
|
}
|
|
}
|
|
if !isMember {
|
|
continue
|
|
}
|
|
|
|
ug := UserGroup{
|
|
ID: g.ID,
|
|
Name: g.Name,
|
|
Handle: g.Handle,
|
|
Description: g.Description,
|
|
UserCount: g.UserCount,
|
|
IsExternal: g.IsExternal,
|
|
DateCreate: formatJSONTime(g.DateCreate),
|
|
DateUpdate: formatJSONTime(g.DateUpdate),
|
|
}
|
|
userGroupList = append(userGroupList, ug)
|
|
}
|
|
|
|
h.logger.Debug("Filtered to my groups", zap.Int("count", len(userGroupList)))
|
|
|
|
csvBytes, err := gocsv.MarshalBytes(&userGroupList)
|
|
if err != nil {
|
|
h.logger.Error("Failed to marshal user groups to CSV", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(csvBytes)), nil
|
|
}
|
|
|
|
// formatJSONTime converts slack.JSONTime (Unix timestamp) to a readable string
|
|
func formatJSONTime(jt slack.JSONTime) string {
|
|
if int64(jt) == 0 {
|
|
return ""
|
|
}
|
|
t := time.Unix(int64(jt), 0)
|
|
return t.Format(time.RFC3339)
|
|
}
|
|
|
|
// parseCommaSeparatedList splits a comma-separated string into a slice of trimmed strings
|
|
func parseCommaSeparatedList(s string) []string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(s, ",")
|
|
result := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
result = append(result, p)
|
|
}
|
|
}
|
|
return result
|
|
}
|