diff --git a/cmd/shell/banner.go b/cmd/shell/banner.go new file mode 100644 index 00000000..9926fd10 --- /dev/null +++ b/cmd/shell/banner.go @@ -0,0 +1,64 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package shell + +import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// bannerView returns the styled banner string at the given width. +func bannerView(width int, version string) string { + line := lipgloss.NewStyle().Foreground(colorPool).Render(strings.Repeat("─", width)) + face := renderSlackbot() + title := lipgloss.NewStyle().Bold(true).Foreground(colorAubergine).Render("Slack CLI Shell") + ver := lipgloss.NewStyle().Foreground(colorPool).Render(" " + version) + hint := lipgloss.NewStyle().Foreground(colorGray).Italic(true).Render("Type 'help' for commands, 'exit' to quit") + info := title + ver + "\n" + hint + body := lipgloss.JoinHorizontal(lipgloss.Center, face, " "+info) + return line + "\n" + body + "\n" + line +} + +// Slack brand colors (reused from internal/style/charm_theme.go) +var ( + colorAubergine = lipgloss.Color("#7C2852") + colorPool = lipgloss.Color("#78d7dd") + colorGray = lipgloss.Color("#5e5d60") + colorGreen = lipgloss.Color("#2eb67d") + colorBlue = lipgloss.Color("#36c5f0") +) + +// renderSlackbot returns a multi-colored ASCII slackbot face. +func renderSlackbot() string { + box := lipgloss.NewStyle().Foreground(colorPool) + eye := lipgloss.NewStyle().Foreground(colorBlue).Bold(true) + smile := lipgloss.NewStyle().Foreground(colorGreen) + lines := []string{ + box.Render(" ╭───────╮"), + box.Render(" │ ") + eye.Render("●") + box.Render(" ") + eye.Render("●") + box.Render(" │"), + box.Render(" │ ") + smile.Render("◡") + box.Render(" │"), + box.Render(" ╰───────╯"), + } + return strings.Join(lines, "\n") +} + +// renderGoodbye writes the goodbye message to the writer. +func renderGoodbye(w io.Writer) { + msg := lipgloss.NewStyle().Foreground(colorGreen).Render("Goodbye!") + fmt.Fprintf(w, "%s\n", msg) +} diff --git a/cmd/shell/input.go b/cmd/shell/input.go new file mode 100644 index 00000000..2d8606ae --- /dev/null +++ b/cmd/shell/input.go @@ -0,0 +1,137 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package shell + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/slackapi/slack-cli/internal/shared" +) + +// inputModel wraps a bubbles textinput with shell history navigation. +type inputModel struct { + textInput textinput.Model + history []string + histIndex int // len(history) = "new line" position + saved string // user's in-progress text before navigating history + done bool + value string + width int + bannerVersion string // non-empty = show banner above input +} + +// readLine runs a short-lived bubbletea program to collect one line of input. +func readLine(clients *shared.ClientFactory, history []string, bannerVersion string) (string, error) { + m := newInputModel(history, bannerVersion) + p := tea.NewProgram(m, + tea.WithInput(clients.IO.ReadIn()), + tea.WithOutput(clients.IO.WriteOut()), + ) + result, err := p.Run() + if err != nil { + return "", err + } + final := result.(inputModel) + return final.value, nil +} + +func newInputModel(history []string, bannerVersion string) inputModel { + ti := textinput.New() + ti.Prompt = "❯ " + ti.PromptStyle = lipgloss.NewStyle().Bold(true).Foreground(colorBlue) + ti.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#e8a400")) + ti.Focus() + + // Disable built-in suggestion navigation (we use Up/Down for history) + ti.KeyMap.NextSuggestion.SetEnabled(false) + ti.KeyMap.PrevSuggestion.SetEnabled(false) + + return inputModel{ + textInput: ti, + history: history, + histIndex: len(history), + bannerVersion: bannerVersion, + } +} + +func (m inputModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m inputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + return m, tea.ClearScreen + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + m.done = true + m.value = m.textInput.Value() + return m, tea.Quit + case tea.KeyCtrlC: + m.done = true + m.value = "exit" + return m, tea.Quit + case tea.KeyUp: + if m.histIndex > 0 { + // Save current text on first Up press + if m.histIndex == len(m.history) { + m.saved = m.textInput.Value() + } + m.histIndex-- + m.textInput.SetValue(m.history[m.histIndex]) + m.textInput.CursorEnd() + } + return m, nil + case tea.KeyDown: + if m.histIndex < len(m.history) { + m.histIndex++ + if m.histIndex == len(m.history) { + m.textInput.SetValue(m.saved) + } else { + m.textInput.SetValue(m.history[m.histIndex]) + } + m.textInput.CursorEnd() + } + return m, nil + } + } + + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +func (m inputModel) View() string { + if m.done { + return "" + } + w := m.width + if w <= 0 { + w = 80 + } + line := lipgloss.NewStyle().Foreground(colorPool).Render(strings.Repeat("─", w)) + content := " " + m.textInput.View() + input := line + "\n" + content + "\n" + line + + if m.bannerVersion != "" { + return bannerView(w, m.bannerVersion) + "\n" + input + } + return input +} diff --git a/cmd/shell/input_test.go b/cmd/shell/input_test.go new file mode 100644 index 00000000..207f141e --- /dev/null +++ b/cmd/shell/input_test.go @@ -0,0 +1,194 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package shell + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/assert" +) + +func TestInputModel(t *testing.T) { + tests := map[string]struct { + setup func() inputModel + actions func(inputModel) inputModel + assertFn func(t *testing.T, m inputModel) + }{ + "enter submits text": { + setup: func() inputModel { + return newInputModel(nil, "") + }, + actions: func(m inputModel) inputModel { + for _, r := range "deploy" { + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + m = updated.(inputModel) + } + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(inputModel) + return m + }, + assertFn: func(t *testing.T, m inputModel) { + assert.True(t, m.done) + assert.Equal(t, "deploy", m.value) + }, + }, + "ctrl+c returns exit": { + setup: func() inputModel { + return newInputModel(nil, "") + }, + actions: func(m inputModel) inputModel { + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + m = updated.(inputModel) + return m + }, + assertFn: func(t *testing.T, m inputModel) { + assert.True(t, m.done) + assert.Equal(t, "exit", m.value) + }, + }, + "up arrow recalls history": { + setup: func() inputModel { + return newInputModel([]string{"deploy", "run"}, "") + }, + actions: func(m inputModel) inputModel { + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp}) + m = updated.(inputModel) + return m + }, + assertFn: func(t *testing.T, m inputModel) { + assert.Equal(t, "run", m.textInput.Value()) + assert.Equal(t, 1, m.histIndex) + }, + }, + "up arrow twice recalls older history": { + setup: func() inputModel { + return newInputModel([]string{"deploy", "run"}, "") + }, + actions: func(m inputModel) inputModel { + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp}) + m = updated.(inputModel) + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp}) + m = updated.(inputModel) + return m + }, + assertFn: func(t *testing.T, m inputModel) { + assert.Equal(t, "deploy", m.textInput.Value()) + assert.Equal(t, 0, m.histIndex) + }, + }, + "down arrow restores saved text": { + setup: func() inputModel { + m := newInputModel([]string{"deploy", "run"}, "") + for _, r := range "ver" { + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + m = updated.(inputModel) + } + return m + }, + actions: func(m inputModel) inputModel { + // Go up - saves "ver", shows "run" + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp}) + m = updated.(inputModel) + // Go back down - restores "ver" + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = updated.(inputModel) + return m + }, + assertFn: func(t *testing.T, m inputModel) { + assert.Equal(t, "ver", m.textInput.Value()) + assert.Equal(t, 2, m.histIndex) + }, + }, + "up at oldest entry does nothing": { + setup: func() inputModel { + return newInputModel([]string{"deploy"}, "") + }, + actions: func(m inputModel) inputModel { + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp}) + m = updated.(inputModel) + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp}) + m = updated.(inputModel) + return m + }, + assertFn: func(t *testing.T, m inputModel) { + assert.Equal(t, "deploy", m.textInput.Value()) + assert.Equal(t, 0, m.histIndex) + }, + }, + "view renders border": { + setup: func() inputModel { + return newInputModel(nil, "") + }, + actions: func(m inputModel) inputModel { + return m + }, + assertFn: func(t *testing.T, m inputModel) { + view := ansi.Strip(m.View()) + assert.Contains(t, view, "─") + assert.Contains(t, view, "❯") + }, + }, + "view renders banner when version set": { + setup: func() inputModel { + m := newInputModel(nil, "v1.0.0") + m.width = 40 + return m + }, + actions: func(m inputModel) inputModel { return m }, + assertFn: func(t *testing.T, m inputModel) { + view := ansi.Strip(m.View()) + assert.Contains(t, view, "Slack CLI Shell") + assert.Contains(t, view, "v1.0.0") + assert.Contains(t, view, "❯") + }, + }, + "view renders no banner when version empty": { + setup: func() inputModel { + m := newInputModel(nil, "") + m.width = 40 + return m + }, + actions: func(m inputModel) inputModel { return m }, + assertFn: func(t *testing.T, m inputModel) { + view := ansi.Strip(m.View()) + assert.NotContains(t, view, "Slack CLI Shell") + assert.Contains(t, view, "❯") + }, + }, + "view is empty when done": { + setup: func() inputModel { + m := newInputModel(nil, "") + m.done = true + return m + }, + actions: func(m inputModel) inputModel { + return m + }, + assertFn: func(t *testing.T, m inputModel) { + assert.Equal(t, "", m.View()) + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + m := tc.setup() + m = tc.actions(m) + tc.assertFn(t, m) + }) + } +} diff --git a/cmd/shell/shell.go b/cmd/shell/shell.go new file mode 100644 index 00000000..bf15846a --- /dev/null +++ b/cmd/shell/shell.go @@ -0,0 +1,113 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package shell + +import ( + "context" + "fmt" + "strings" + + "github.com/slackapi/slack-cli/internal/experiment" + "github.com/slackapi/slack-cli/internal/pkg/version" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/spf13/cobra" +) + +// BuildCommandTree is set by cmd.Init() to build a fresh command tree for each +// REPL iteration. This avoids a circular import between cmd/shell and cmd. +var BuildCommandTree func(context.Context, *shared.ClientFactory) *cobra.Command + +// readLineFunc is a package-level function variable for test overriding. +var readLineFunc = readLine + +// NewCommand creates the shell command. +func NewCommand(clients *shared.ClientFactory) *cobra.Command { + return &cobra.Command{ + Use: "shell", + Short: "Start an interactive shell session", + Long: "Start an interactive shell where commands can be entered without the 'slack' prefix.", + PreRunE: func(cmd *cobra.Command, args []string) error { + if !clients.Config.WithExperimentOn(experiment.Charm) { + return fmt.Errorf("the shell command requires the charm experiment: --experiment=charm") + } + if !clients.IO.IsTTY() { + return fmt.Errorf("the shell command requires an interactive terminal (TTY)") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return RunShell(cmd.Context(), clients) + }, + } +} + +// RunShell implements the interactive REPL loop. +func RunShell(ctx context.Context, clients *shared.ClientFactory) error { + out := clients.IO.WriteOut() + + ver := version.Get() + var history []string + for { + line, err := readLineFunc(clients, history, ver) + if err != nil { + break + } + + // Clear version after first prompt so banner only shows once + ver = "" + + line = strings.TrimSpace(line) + + if line == "" { + continue + } + + if line == "exit" || line == "quit" { + renderGoodbye(out) + return nil + } + + // Add to history (skip consecutive duplicates) + if len(history) == 0 || history[len(history)-1] != line { + history = append(history, line) + } + + if line == "shell" { + fmt.Fprintln(out, "Already in shell mode") + continue + } + + if line == "help" { + line = "--help" + } + + if BuildCommandTree == nil { + return fmt.Errorf("shell command tree builder is not initialized") + } + + args := strings.Fields(line) + root := BuildCommandTree(ctx, clients) + root.SetOut(out) + root.SetErr(clients.IO.WriteErr()) + root.SetIn(clients.IO.ReadIn()) + root.SetArgs(args) + if err := root.Execute(); err != nil { + fmt.Fprintf(clients.IO.WriteErr(), "%s\n", err.Error()) + } + } + + renderGoodbye(out) + return nil +} diff --git a/cmd/shell/shell_test.go b/cmd/shell/shell_test.go new file mode 100644 index 00000000..72c6f575 --- /dev/null +++ b/cmd/shell/shell_test.go @@ -0,0 +1,181 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package shell + +import ( + "bytes" + "context" + "io" + "testing" + + "github.com/slackapi/slack-cli/internal/experiment" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +// mockBuildCommandTree returns a command tree with a single "version" command +// that prints a known string, for testing command execution. +func mockBuildCommandTree(_ context.Context, clients *shared.ClientFactory) *cobra.Command { + root := &cobra.Command{Use: "slack", SilenceErrors: true, SilenceUsage: true} + root.AddCommand(&cobra.Command{ + Use: "version", + Short: "Print version", + RunE: func(cmd *cobra.Command, args []string) error { + cmd.Println("v0.0.0-test") + return nil + }, + }) + return root +} + +// mockReadLines returns a readLineFunc that yields lines sequentially, then io.EOF. +func mockReadLines(lines []string) func(*shared.ClientFactory, []string, string) (string, error) { + i := 0 + return func(_ *shared.ClientFactory, _ []string, _ string) (string, error) { + if i >= len(lines) { + return "", io.EOF + } + line := lines[i] + i++ + return line, nil + } +} + +func setupTest(isTTY bool, charmEnabled bool) (*shared.ClientsMock, *cobra.Command) { + cm := shared.NewClientsMock() + cm.AddDefaultMocks() + cm.IO.On("IsTTY").Unset() + cm.IO.On("IsTTY").Return(isTTY) + + clients := shared.NewClientFactory(cm.MockClientFactory()) + + if charmEnabled { + clients.Config.ExperimentsFlag = []string{string(experiment.Charm)} + clients.Config.LoadExperiments(context.Background(), func(_ context.Context, _ string, _ ...interface{}) {}) + } + + cmd := NewCommand(clients) + cmd.SetContext(context.Background()) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + return cm, cmd +} + +func TestShellCommand(t *testing.T) { + // Save and restore BuildCommandTree and readLineFunc + origBuild := BuildCommandTree + origReadLine := readLineFunc + defer func() { + BuildCommandTree = origBuild + readLineFunc = origReadLine + }() + BuildCommandTree = mockBuildCommandTree + + tests := map[string]struct { + input []string + isTTY bool + charm bool + expectErr bool + errContains string + outContains string + outExcludes string + }{ + "requires charm experiment": { + input: []string{"exit"}, + isTTY: true, + charm: false, + expectErr: true, + errContains: "charm experiment", + }, + "requires TTY": { + input: []string{"exit"}, + isTTY: false, + charm: true, + expectErr: true, + errContains: "interactive terminal", + }, + "exit command exits cleanly": { + input: []string{"exit"}, + isTTY: true, + charm: true, + expectErr: false, + outContains: "Goodbye", + }, + "quit command exits cleanly": { + input: []string{"quit"}, + isTTY: true, + charm: true, + expectErr: false, + outContains: "Goodbye", + }, + "shell recursion warning": { + input: []string{"shell", "exit"}, + isTTY: true, + charm: true, + expectErr: false, + outContains: "Already in shell mode", + }, + "empty input continues": { + input: []string{"", "exit"}, + isTTY: true, + charm: true, + expectErr: false, + }, + "command execution": { + input: []string{"version", "exit"}, + isTTY: true, + charm: true, + expectErr: false, + outContains: "v0.0.0-test", + }, + "help maps to --help": { + input: []string{"help", "exit"}, + isTTY: true, + charm: true, + expectErr: false, + outContains: "slack", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + readLineFunc = mockReadLines(tc.input) + cm, cmd := setupTest(tc.isTTY, tc.charm) + stdout := &bytes.Buffer{} + cmd.SetOut(stdout) + + err := cmd.Execute() + + if tc.expectErr { + assert.Error(t, err) + if tc.errContains != "" { + assert.Contains(t, err.Error(), tc.errContains) + } + } else { + assert.NoError(t, err) + } + + output := cm.GetCombinedOutput() + stdout.String() + if tc.outContains != "" { + assert.Contains(t, output, tc.outContains) + } + if tc.outExcludes != "" { + assert.NotContains(t, output, tc.outExcludes) + } + }) + } +}