Skip to content

Commit 0314e43

Browse files
isaacrowntreeclaude
andcommitted
feat: improve agent resilience — send shortcut, bot-fallback, doctor command
- Add top-level `slackbuzz send` shortcut (alias for `message send`) - Add `slackbuzz doctor` command to validate tokens and probe scopes - Bot-fallback: when user token lacks channels:read for channel resolution, transparently retry with bot token while still posting as the user - Agent mode (SLACKBUZZ_AGENT=1): prefer bot token, skip interactive prompts, emit structured JSON errors to stderr - Add channels:read to user token scopes in app manifest - Add IsMissingScopeError() and FormatResolveError() error helpers - Improve missing_scope error message with actionable fix suggestions - Fix SKILL.md bash expansion issue with \! in backtick spans Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent aed5f77 commit 0314e43

8 files changed

Lines changed: 308 additions & 20 deletions

File tree

internal/api/errors.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,20 @@ func IsAuthRelatedSlackError(slackErr string) bool {
2929
return false
3030
}
3131

32+
// IsMissingScopeError checks if an error is (or wraps) a Slack "missing_scope" error.
33+
func IsMissingScopeError(err error) bool {
34+
if err == nil {
35+
return false
36+
}
37+
// Walk the error chain
38+
for e := err; e != nil; e = errors.Unwrap(e) {
39+
if e.Error() == "missing_scope" {
40+
return true
41+
}
42+
}
43+
return strings.Contains(err.Error(), "missing_scope")
44+
}
45+
3246
// SlackErrorMessage maps Slack API error codes to user-friendly messages.
3347
func SlackErrorMessage(slackErr string) string {
3448
messages := map[string]string{
@@ -41,7 +55,7 @@ func SlackErrorMessage(slackErr string) string {
4155
"invalid_auth": "Authentication failed. Run 'slackbuzz auth login' to re-authenticate.",
4256
"account_inactive": "Account is inactive or token has been revoked.",
4357
"token_revoked": "Token has been revoked. Run 'slackbuzz auth login' to re-authenticate.",
44-
"missing_scope": "Token is missing required scopes. Check your Slack app configuration.",
58+
"missing_scope": "Token is missing required scopes. Run 'slackbuzz auth status' to check capabilities, or try --as-bot.",
4559
"not_authed": "Not authenticated. Run 'slackbuzz auth login' to authenticate.",
4660
"user_not_found": "User not found. Check the username or ID.",
4761
"thread_not_found": "Thread not found. Check the thread timestamp.",
@@ -54,6 +68,26 @@ func SlackErrorMessage(slackErr string) string {
5468
return fmt.Sprintf("Slack API error: %s", slackErr)
5569
}
5670

71+
// FormatResolveError produces an actionable error message when channel/user
72+
// resolution fails. It detects missing_scope errors and suggests fixes.
73+
func FormatResolveError(err error, target string) string {
74+
if err == nil {
75+
return ""
76+
}
77+
78+
if IsMissingScopeError(err) {
79+
return fmt.Sprintf(
80+
"Channel resolution for %q failed: missing_scope (likely channels:read).\n"+
81+
" Fix: Re-install your Slack app with updated scopes, or run:\n"+
82+
" slackbuzz app create (creates app with correct scopes)\n"+
83+
" Workaround: slackbuzz send --as-bot %s <text>",
84+
target, target,
85+
)
86+
}
87+
88+
return fmt.Sprintf("failed to resolve %q: %s", target, err.Error())
89+
}
90+
5791
// FormatError converts a Slack API error to a user-friendly message.
5892
func FormatError(err error) string {
5993
if err == nil {

internal/app/app.go

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package app
22

33
import (
4+
"encoding/json"
45
"errors"
56
"fmt"
67

@@ -22,23 +23,49 @@ func Run() int {
2223
return 1
2324
}
2425

26+
exitCode := 1
27+
errType := "error"
28+
2529
// Auth expired (401 from Slack API)
2630
var authExpired *api.AuthExpiredError
2731
if errors.As(err, &authExpired) {
28-
fmt.Fprintln(ios.ErrOut, authExpired.Error())
29-
return 4
32+
exitCode = 4
33+
errType = "auth_expired"
34+
} else if cmdutil.IsAuthError(err) {
35+
exitCode = 4
36+
errType = "auth_required"
37+
} else if cmdutil.IsTokenTypeError(err) {
38+
exitCode = 4
39+
errType = "token_type"
40+
} else if api.IsMissingScopeError(err) {
41+
errType = "missing_scope"
3042
}
3143

32-
if cmdutil.IsAuthError(err) {
33-
fmt.Fprintln(ios.ErrOut, err.Error())
34-
return 4
35-
}
36-
if cmdutil.IsTokenTypeError(err) {
37-
fmt.Fprintln(ios.ErrOut, err.Error())
38-
return 4
44+
// Agent mode: structured JSON to stderr
45+
if f.IsAgentMode() {
46+
suggestion := ""
47+
switch errType {
48+
case "auth_expired", "auth_required":
49+
suggestion = "Run 'slackbuzz auth login' to re-authenticate"
50+
case "token_type":
51+
suggestion = "Run 'slackbuzz auth login' with the required token type"
52+
case "missing_scope":
53+
suggestion = "Re-install app with updated scopes, or use --as-bot"
54+
}
55+
agentErr := map[string]string{
56+
"error": err.Error(),
57+
"type": errType,
58+
}
59+
if suggestion != "" {
60+
agentErr["suggestion"] = suggestion
61+
}
62+
data, _ := json.Marshal(agentErr)
63+
fmt.Fprintln(ios.ErrOut, string(data))
64+
return exitCode
3965
}
66+
4067
fmt.Fprintln(ios.ErrOut, err.Error())
41-
return 1
68+
return exitCode
4269
}
4370

4471
return 0

pkg/cmd/app/create.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ var appManifest = map[string]interface{}{
8585
"users:read",
8686
},
8787
"user": []string{
88+
"channels:read",
8889
"chat:write",
8990
"im:write",
9091
"search:read",

pkg/cmd/doctor/doctor.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package doctor
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/slack-go/slack"
7+
"github.com/spf13/cobra"
8+
"github.com/triptechtravel/slackbuzz-cli/internal/auth"
9+
"github.com/triptechtravel/slackbuzz-cli/pkg/cmdutil"
10+
)
11+
12+
// NewCmdDoctor returns the "doctor" command.
13+
func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command {
14+
cmd := &cobra.Command{
15+
Use: "doctor",
16+
Short: "Check token health and required scopes",
17+
Long: `Validate bot and user tokens, then probe key API scopes to detect
18+
permission gaps before they cause errors.
19+
20+
Checks:
21+
- Bot token validity and channels:read scope
22+
- User token validity and channels:read scope
23+
- Reports pass/fail with remediation advice
24+
25+
Exit code 1 if any check fails.`,
26+
Example: ` slackbuzz doctor`,
27+
RunE: func(cmd *cobra.Command, args []string) error {
28+
return doctorRun(f)
29+
},
30+
}
31+
return cmd
32+
}
33+
34+
type checkResult struct {
35+
name string
36+
passed bool
37+
detail string
38+
fix string
39+
}
40+
41+
func doctorRun(f *cmdutil.Factory) error {
42+
ios := f.IOStreams
43+
cs := ios.ColorScheme()
44+
45+
var results []checkResult
46+
anyFailed := false
47+
48+
// --- Bot token ---
49+
botToken, botErr := auth.GetBotToken()
50+
if botErr != nil || botToken == "" {
51+
results = append(results, checkResult{
52+
name: "Bot token",
53+
passed: false,
54+
detail: "not configured",
55+
fix: "slackbuzz auth login --bot-token",
56+
})
57+
anyFailed = true
58+
} else {
59+
results = append(results, checkResult{
60+
name: "Bot token",
61+
passed: true,
62+
detail: "configured",
63+
})
64+
65+
// Probe channels:read with bot token
66+
botClient := slack.New(botToken)
67+
_, _, err := botClient.GetConversations(&slack.GetConversationsParameters{
68+
Types: []string{"public_channel"},
69+
Limit: 1,
70+
})
71+
if err != nil {
72+
results = append(results, checkResult{
73+
name: "Bot channels:read",
74+
passed: false,
75+
detail: err.Error(),
76+
fix: "Add channels:read to bot scopes at api.slack.com/apps > OAuth & Permissions",
77+
})
78+
anyFailed = true
79+
} else {
80+
results = append(results, checkResult{
81+
name: "Bot channels:read",
82+
passed: true,
83+
detail: "OK",
84+
})
85+
}
86+
}
87+
88+
// --- User token ---
89+
userToken, userErr := auth.GetUserToken()
90+
if userErr != nil || userToken == "" {
91+
results = append(results, checkResult{
92+
name: "User token",
93+
passed: false,
94+
detail: "not configured",
95+
fix: "slackbuzz auth login --user-token",
96+
})
97+
anyFailed = true
98+
} else {
99+
results = append(results, checkResult{
100+
name: "User token",
101+
passed: true,
102+
detail: "configured",
103+
})
104+
105+
// Probe channels:read with user token
106+
userClient := slack.New(userToken)
107+
_, _, err := userClient.GetConversations(&slack.GetConversationsParameters{
108+
Types: []string{"public_channel"},
109+
Limit: 1,
110+
})
111+
if err != nil {
112+
results = append(results, checkResult{
113+
name: "User channels:read",
114+
passed: false,
115+
detail: err.Error(),
116+
fix: "Add channels:read to user scopes, or re-run: slackbuzz app create",
117+
})
118+
anyFailed = true
119+
} else {
120+
results = append(results, checkResult{
121+
name: "User channels:read",
122+
passed: true,
123+
detail: "OK",
124+
})
125+
}
126+
}
127+
128+
// --- Print results ---
129+
fmt.Fprintln(ios.Out)
130+
fmt.Fprintln(ios.Out, cs.Bold("SlackBuzz Doctor"))
131+
fmt.Fprintln(ios.Out, "────────────────────────────────────────")
132+
133+
for _, r := range results {
134+
var icon string
135+
if r.passed {
136+
icon = cs.Green("PASS")
137+
} else {
138+
icon = cs.Red("FAIL")
139+
}
140+
fmt.Fprintf(ios.Out, " [%s] %-22s %s\n", icon, r.name, r.detail)
141+
if !r.passed && r.fix != "" {
142+
fmt.Fprintf(ios.Out, " %s %s\n", cs.Yellow("Fix:"), r.fix)
143+
}
144+
}
145+
146+
fmt.Fprintln(ios.Out)
147+
148+
if anyFailed {
149+
fmt.Fprintf(ios.ErrOut, "%s Some checks failed. See above for fixes.\n", cs.Red("!"))
150+
return &cmdutil.SilentError{Err: fmt.Errorf("doctor checks failed")}
151+
}
152+
153+
fmt.Fprintf(ios.Out, "%s All checks passed.\n", cs.Green("✓"))
154+
return nil
155+
}

pkg/cmd/message/send.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,13 @@ func sendRun(opts *sendOptions) error {
7575
ios := opts.factory.IOStreams
7676
cs := ios.ColorScheme()
7777

78+
agentMode := opts.factory.IsAgentMode()
79+
80+
// In agent mode, prefer bot token (avoids user-token scope gaps);
81+
// otherwise default to user token so messages post as the user.
7882
var client *api.Client
7983
var err error
80-
if opts.asBot {
84+
if opts.asBot || agentMode {
8185
client, err = opts.factory.DefaultClient()
8286
} else {
8387
client, err = opts.factory.UserClient()
@@ -95,13 +99,32 @@ func sendRun(opts *sendOptions) error {
9599
} else {
96100
channelID, err = resolver.ResolveChannel(opts.channel)
97101
}
102+
103+
// Bot-fallback: if resolution failed with missing_scope on the user token,
104+
// retry with the bot client (which typically has channels:read).
105+
if err != nil && api.IsMissingScopeError(err) && !opts.asBot && !agentMode {
106+
botClient, botErr := opts.factory.BotClient()
107+
if botErr == nil {
108+
fmt.Fprintf(ios.ErrOut, "%s User token missing scope for channel lookup — falling back to bot token\n", cs.Yellow("!"))
109+
botResolver := api.NewResolver(botClient.Slack)
110+
if api.LooksLikeUser(opts.channel) {
111+
channelID, err = botResolver.ResolveDM(opts.channel)
112+
} else {
113+
channelID, err = botResolver.ResolveChannel(opts.channel)
114+
}
115+
}
116+
}
117+
98118
if err != nil {
99-
return err
119+
return fmt.Errorf("%s", api.FormatResolveError(err, opts.channel))
100120
}
101121

102122
// Get message text from args or stdin
103123
text := opts.text
104124
if text == "" {
125+
if agentMode {
126+
return fmt.Errorf("message text is required (SLACKBUZZ_AGENT=1 disables interactive prompts)")
127+
}
105128
// Read from stdin
106129
if !ios.IsTerminal() {
107130
data, readErr := io.ReadAll(ios.In)

pkg/cmd/root/root.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/triptechtravel/slackbuzz-cli/pkg/cmd/channel"
99
"github.com/triptechtravel/slackbuzz-cli/pkg/cmd/completion"
1010
"github.com/triptechtravel/slackbuzz-cli/pkg/cmd/digest"
11+
"github.com/triptechtravel/slackbuzz-cli/pkg/cmd/doctor"
1112
"github.com/triptechtravel/slackbuzz-cli/pkg/cmd/dm"
1213
"github.com/triptechtravel/slackbuzz-cli/pkg/cmd/file"
1314
"github.com/triptechtravel/slackbuzz-cli/pkg/cmd/later"
@@ -140,6 +141,13 @@ TIPS
140141
statusCmd.GroupID = "workspace"
141142
cmd.AddCommand(statusCmd)
142143

144+
// Top-level shortcut: slackbuzz send → slackbuzz message send
145+
sendCmd := message.NewCmdSend(f)
146+
sendCmd.Use = "send <channel|user> [text]"
147+
sendCmd.Short = "Send a message (shortcut for 'message send')"
148+
sendCmd.GroupID = "messaging"
149+
cmd.AddCommand(sendCmd)
150+
143151
// Utility commands
144152
versionCmd := version.NewCmdVersion()
145153
versionCmd.GroupID = "tools"
@@ -149,5 +157,9 @@ TIPS
149157
completionCmd.GroupID = "tools"
150158
cmd.AddCommand(completionCmd)
151159

160+
doctorCmd := doctor.NewCmdDoctor(f)
161+
doctorCmd.GroupID = "tools"
162+
cmd.AddCommand(doctorCmd)
163+
152164
return cmd
153165
}

pkg/cmdutil/factory.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmdutil
22

33
import (
4+
"os"
45
"sync"
56

67
"github.com/triptechtravel/slackbuzz-cli/internal/api"
@@ -83,3 +84,10 @@ func (f *Factory) DefaultClient() (*api.Client, error) {
8384
}
8485
return f.UserClient()
8586
}
87+
88+
// IsAgentMode returns true when SLACKBUZZ_AGENT=1 is set.
89+
// Agent mode prefers bot tokens, disables interactive prompts, and
90+
// outputs structured JSON errors to stderr.
91+
func (f *Factory) IsAgentMode() bool {
92+
return os.Getenv("SLACKBUZZ_AGENT") == "1"
93+
}

0 commit comments

Comments
 (0)