Files
Paymahn Moghadasian d781728c6c feat: slack-cli — a no-daemon CLI over the slack-mcp-server engine (#1)
* 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)
2026-06-26 12:45:38 -05:00

79 lines
2.0 KiB
Go

package credstore_test
import (
"testing"
"github.com/paymog/slack-cli/internal/credstore"
)
type fakeKeyring struct{ m map[string]string }
func (f *fakeKeyring) Get(a string) (string, error) {
v, ok := f.m[a]
if !ok {
return "", credstore.ErrNotFound
}
return v, nil
}
func (f *fakeKeyring) Set(a, s string) error { f.m[a] = s; return nil }
func (f *fakeKeyring) Delete(a string) error { delete(f.m, a); return nil }
func TestUnitStoreRoundTrip(t *testing.T) {
t.Setenv("SLACK_CLI_CONFIG_DIR", t.TempDir())
prev := credstore.SetBackend(&fakeKeyring{m: map[string]string{}})
defer credstore.SetBackend(prev)
s, err := credstore.Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(s.Names()) != 0 {
t.Fatalf("expected empty store, got %v", s.Names())
}
if err := s.Add("default", credstore.Profile{Mode: "user"}, credstore.Tokens{XOXP: "x1"}); err != nil {
t.Fatalf("Add: %v", err)
}
// First profile becomes default.
if s.Default != "default" {
t.Fatalf("first profile should be default, got %q", s.Default)
}
// Reload from disk and confirm metadata + keyring round-trip.
s2, err := credstore.Load()
if err != nil {
t.Fatalf("reload: %v", err)
}
tok, err := s2.Tokens("default")
if err != nil {
t.Fatalf("Tokens: %v", err)
}
if tok.XOXP != "x1" {
t.Fatalf("token round-trip = %q", tok.XOXP)
}
// Adding a second profile keeps the original default.
if err := s2.Add("prod", credstore.Profile{Mode: "bot"}, credstore.Tokens{XOXB: "b1"}); err != nil {
t.Fatalf("Add prod: %v", err)
}
if s2.Default != "default" {
t.Fatalf("default should stay 'default', got %q", s2.Default)
}
if err := s2.SetDefault("prod"); err != nil {
t.Fatalf("SetDefault: %v", err)
}
// Removing the default reassigns it to a remaining profile.
if err := s2.Remove("prod"); err != nil {
t.Fatalf("Remove: %v", err)
}
s3, _ := credstore.Load()
if s3.Default != "default" {
t.Fatalf("default after removing prod = %q", s3.Default)
}
if _, err := s3.Tokens("prod"); err == nil {
t.Fatal("expected prod keyring entry to be deleted")
}
}