diff --git a/cmd/project/create_template.go b/cmd/project/create_template.go index 45970097..0fc58dc0 100644 --- a/cmd/project/create_template.go +++ b/cmd/project/create_template.go @@ -21,6 +21,7 @@ import ( "time" "github.com/slackapi/slack-cli/internal/api" + "github.com/slackapi/slack-cli/internal/experiment" "github.com/slackapi/slack-cli/internal/iostreams" "github.com/slackapi/slack-cli/internal/pkg/create" "github.com/slackapi/slack-cli/internal/shared" @@ -106,6 +107,16 @@ func promptTemplateSelection(cmd *cobra.Command, clients *shared.ClientFactory, // Check if a category shortcut was provided if categoryShortcut == "agent" { categoryID = "slack-cli#ai-apps" + } else if clients.Config.WithExperimentOn(experiment.Charm) { + result, err := charmPromptTemplateSelection(ctx, clients) + if err != nil { + return create.Template{}, slackerror.ToSlackError(err) + } + if result.CategoryID == viewMoreSamples || result.TemplateRepo == viewMoreSamples { + selectedTemplate = viewMoreSamples + } else { + selectedTemplate = result.TemplateRepo + } } else { // Prompt for the category promptForCategory := "Select an app:" diff --git a/cmd/project/create_template_charm.go b/cmd/project/create_template_charm.go new file mode 100644 index 00000000..00bff3e3 --- /dev/null +++ b/cmd/project/create_template_charm.go @@ -0,0 +1,107 @@ +// 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 project + +import ( + "context" + "strings" + + "github.com/charmbracelet/huh" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/slackapi/slack-cli/internal/slacktrace" + "github.com/slackapi/slack-cli/internal/style" +) + +// templateSelectionResult holds the user's selections from the dynamic template form. +type templateSelectionResult struct { + CategoryID string // e.g. "slack-cli#getting-started" or viewMoreSamples + TemplateRepo string // e.g. "slack-samples/bolt-js-starter-template" +} + +// runForm executes a huh form. It is a package-level variable so tests can +// override the interactive terminal dependency while testing the surrounding logic. +var runForm = func(f *huh.Form) error { return f.Run() } + +// buildTemplateSelectionForm constructs a single-screen huh form where the category +// and template selects are in the same group. Changing the category dynamically +// updates the template options via OptionsFunc. +func buildTemplateSelectionForm(clients *shared.ClientFactory, category *string, template *string) *huh.Form { + categoryOptions := getSelectionOptionsForCategory(clients) + var catOpts []huh.Option[string] + for _, opt := range categoryOptions { + catOpts = append(catOpts, huh.NewOption(opt.Title, opt.Repository)) + } + + categorySelect := huh.NewSelect[string](). + Title("Select an app:"). + Options(catOpts...). + Value(category) + + templateSelect := huh.NewSelect[string](). + Title("Select a language:"). + OptionsFunc(func() []huh.Option[string] { + if *category == viewMoreSamples { + return []huh.Option[string]{ + huh.NewOption("Browse sample gallery...", viewMoreSamples), + } + } + + options := getSelectionOptions(clients, *category) + var opts []huh.Option[string] + for _, opt := range options { + opts = append(opts, huh.NewOption(opt.Title, opt.Repository)) + } + return opts + }, category). + Value(template) + + return huh.NewForm( + huh.NewGroup(categorySelect, templateSelect), + ).WithTheme(style.ThemeSlack()) +} + +// charmPromptTemplateSelection runs the dynamic template selection form and returns the result. +func charmPromptTemplateSelection(ctx context.Context, clients *shared.ClientFactory) (templateSelectionResult, error) { + // Print trace with category options + categoryOptions := getSelectionOptionsForCategory(clients) + categoryTitles := make([]string, len(categoryOptions)) + for i, opt := range categoryOptions { + categoryTitles[i] = opt.Title + } + clients.IO.PrintTrace(ctx, slacktrace.CreateCategoryOptions, strings.Join(categoryTitles, ", ")) + + var category string + var template string + err := runForm(buildTemplateSelectionForm(clients, &category, &template)) + if err != nil { + return templateSelectionResult{}, slackerror.ToSlackError(err) + } + + // Print trace with template options + templateOptions := getSelectionOptions(clients, category) + templateTitles := make([]string, len(templateOptions)) + for i, opt := range templateOptions { + templateTitles[i] = opt.Title + } + if len(templateTitles) > 0 { + clients.IO.PrintTrace(ctx, slacktrace.CreateTemplateOptions, strings.Join(templateTitles, ", ")) + } + + return templateSelectionResult{ + CategoryID: category, + TemplateRepo: template, + }, nil +} diff --git a/cmd/project/create_template_charm_test.go b/cmd/project/create_template_charm_test.go new file mode 100644 index 00000000..93f05643 --- /dev/null +++ b/cmd/project/create_template_charm_test.go @@ -0,0 +1,258 @@ +// 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 project + +import ( + "context" + "fmt" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/x/ansi" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// doAllUpdates recursively processes all commands returned by form updates, +// including batch messages from OptionsFunc evaluations and group transitions. +// This mirrors the helper in huh's own test suite. +func doAllUpdates(f *huh.Form, cmd tea.Cmd) { + if cmd == nil { + return + } + var cmds []tea.Cmd + switch msg := cmd().(type) { + case tea.BatchMsg: + for _, subcommand := range msg { + doAllUpdates(f, subcommand) + } + return + default: + _, result := f.Update(msg) + cmds = append(cmds, result) + } + doAllUpdates(f, tea.Batch(cmds...)) +} + +func TestBuildTemplateSelectionForm(t *testing.T) { + t.Run("renders category and template on one screen", func(t *testing.T) { + cm := shared.NewClientsMock() + cm.AddDefaultMocks() + clients := shared.NewClientFactory(cm.MockClientFactory()) + + var category, template string + f := buildTemplateSelectionForm(clients, &category, &template) + doAllUpdates(f, f.Init()) + + view := ansi.Strip(f.View()) + assert.Contains(t, view, "Select an app:") + assert.Contains(t, view, "Starter app") + assert.Contains(t, view, "AI Agent app") + assert.Contains(t, view, "Automation app") + assert.Contains(t, view, "View more samples") + assert.Contains(t, view, "Select a language:") + }) + + t.Run("selecting a category updates template options", func(t *testing.T) { + cm := shared.NewClientsMock() + cm.AddDefaultMocks() + clients := shared.NewClientFactory(cm.MockClientFactory()) + + var category, template string + f := buildTemplateSelectionForm(clients, &category, &template) + doAllUpdates(f, f.Init()) + + // Submit first option (Starter app -> getting-started) + _, cmd := f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + doAllUpdates(f, cmd) + + view := ansi.Strip(f.View()) + assert.Contains(t, view, "Bolt for JavaScript") + assert.Contains(t, view, "Bolt for Python") + }) + + t.Run("selecting view more samples shows browse option", func(t *testing.T) { + cm := shared.NewClientsMock() + cm.AddDefaultMocks() + clients := shared.NewClientFactory(cm.MockClientFactory()) + + var category, template string + f := buildTemplateSelectionForm(clients, &category, &template) + doAllUpdates(f, f.Init()) + + // Navigate down to "View more samples" (4th option, index 3) + _, cmd := f.Update(tea.KeyMsg{Type: tea.KeyDown}) + doAllUpdates(f, cmd) + _, cmd = f.Update(tea.KeyMsg{Type: tea.KeyDown}) + doAllUpdates(f, cmd) + _, cmd = f.Update(tea.KeyMsg{Type: tea.KeyDown}) + doAllUpdates(f, cmd) + _, cmd = f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + doAllUpdates(f, cmd) + + assert.Equal(t, viewMoreSamples, category) + view := ansi.Strip(f.View()) + assert.Contains(t, view, "Browse sample gallery...") + }) + + t.Run("automation category shows Deno option", func(t *testing.T) { + cm := shared.NewClientsMock() + cm.AddDefaultMocks() + clients := shared.NewClientFactory(cm.MockClientFactory()) + + var category, template string + f := buildTemplateSelectionForm(clients, &category, &template) + doAllUpdates(f, f.Init()) + + // Navigate to Automation app (3rd option, index 2) and submit + _, cmd := f.Update(tea.KeyMsg{Type: tea.KeyDown}) + doAllUpdates(f, cmd) + _, cmd = f.Update(tea.KeyMsg{Type: tea.KeyDown}) + doAllUpdates(f, cmd) + _, cmd = f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + doAllUpdates(f, cmd) + + view := ansi.Strip(f.View()) + assert.Contains(t, view, "Deno Slack SDK") + }) + + t.Run("complete flow selects a template", func(t *testing.T) { + cm := shared.NewClientsMock() + cm.AddDefaultMocks() + clients := shared.NewClientFactory(cm.MockClientFactory()) + + var category, template string + f := buildTemplateSelectionForm(clients, &category, &template) + doAllUpdates(f, f.Init()) + + // Select first category (Starter app) + _, cmd := f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + doAllUpdates(f, cmd) + // Select first template (Bolt for JavaScript) + _, cmd = f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + doAllUpdates(f, cmd) + + assert.Equal(t, "slack-cli#getting-started", category) + assert.Equal(t, "slack-samples/bolt-js-starter-template", template) + }) + + t.Run("uses Slack theme", func(t *testing.T) { + cm := shared.NewClientsMock() + cm.AddDefaultMocks() + clients := shared.NewClientFactory(cm.MockClientFactory()) + + var category, template string + f := buildTemplateSelectionForm(clients, &category, &template) + doAllUpdates(f, f.Init()) + + view := f.View() + assert.Contains(t, view, "┃") + }) +} + +func TestCharmPromptTemplateSelection(t *testing.T) { + originalRunForm := runForm + t.Cleanup(func() { runForm = originalRunForm }) + + t.Run("returns selected category and template", func(t *testing.T) { + cm := shared.NewClientsMock() + cm.AddDefaultMocks() + clients := shared.NewClientFactory(cm.MockClientFactory()) + + runForm = func(f *huh.Form) error { + doAllUpdates(f, f.Init()) + // Select first category (Starter app) + _, cmd := f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + doAllUpdates(f, cmd) + // Select first template (Bolt for JavaScript) + _, cmd = f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + doAllUpdates(f, cmd) + return nil + } + + result, err := charmPromptTemplateSelection(context.Background(), clients) + require.NoError(t, err) + assert.Equal(t, "slack-cli#getting-started", result.CategoryID) + assert.Equal(t, "slack-samples/bolt-js-starter-template", result.TemplateRepo) + }) + + t.Run("returns error when form fails", func(t *testing.T) { + cm := shared.NewClientsMock() + cm.AddDefaultMocks() + clients := shared.NewClientFactory(cm.MockClientFactory()) + + runForm = func(f *huh.Form) error { + return fmt.Errorf("user cancelled") + } + + _, err := charmPromptTemplateSelection(context.Background(), clients) + require.Error(t, err) + assert.Contains(t, err.Error(), "user cancelled") + }) + + t.Run("returns view more samples selection", func(t *testing.T) { + cm := shared.NewClientsMock() + cm.AddDefaultMocks() + clients := shared.NewClientFactory(cm.MockClientFactory()) + + runForm = func(f *huh.Form) error { + doAllUpdates(f, f.Init()) + // Navigate to "View more samples" (4th option) + _, cmd := f.Update(tea.KeyMsg{Type: tea.KeyDown}) + doAllUpdates(f, cmd) + _, cmd = f.Update(tea.KeyMsg{Type: tea.KeyDown}) + doAllUpdates(f, cmd) + _, cmd = f.Update(tea.KeyMsg{Type: tea.KeyDown}) + doAllUpdates(f, cmd) + _, cmd = f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + doAllUpdates(f, cmd) + // Select "Browse sample gallery..." + _, cmd = f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + doAllUpdates(f, cmd) + return nil + } + + result, err := charmPromptTemplateSelection(context.Background(), clients) + require.NoError(t, err) + assert.Equal(t, viewMoreSamples, result.CategoryID) + assert.Equal(t, viewMoreSamples, result.TemplateRepo) + }) + + t.Run("selects AI agent category and template", func(t *testing.T) { + cm := shared.NewClientsMock() + cm.AddDefaultMocks() + clients := shared.NewClientFactory(cm.MockClientFactory()) + + runForm = func(f *huh.Form) error { + doAllUpdates(f, f.Init()) + // Navigate to "AI Agent app" (2nd option) + _, cmd := f.Update(tea.KeyMsg{Type: tea.KeyDown}) + doAllUpdates(f, cmd) + _, cmd = f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + doAllUpdates(f, cmd) + // Select first template (Bolt for JavaScript) + _, cmd = f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + doAllUpdates(f, cmd) + return nil + } + + result, err := charmPromptTemplateSelection(context.Background(), clients) + require.NoError(t, err) + assert.Equal(t, "slack-cli#ai-apps", result.CategoryID) + assert.Equal(t, "slack-samples/bolt-js-assistant-template", result.TemplateRepo) + }) +} diff --git a/cmd/project/create_test.go b/cmd/project/create_test.go index 37d83102..1460b5f0 100644 --- a/cmd/project/create_test.go +++ b/cmd/project/create_test.go @@ -16,9 +16,13 @@ package project import ( "context" + "fmt" "testing" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" "github.com/slackapi/slack-cli/internal/config" + "github.com/slackapi/slack-cli/internal/experiment" "github.com/slackapi/slack-cli/internal/iostreams" "github.com/slackapi/slack-cli/internal/logger" "github.com/slackapi/slack-cli/internal/pkg/create" @@ -524,6 +528,68 @@ func TestCreateCommand(t *testing.T) { createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything) }, }, + "creates a bolt application with charm dynamic form": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + cm.IO.On("IsTTY").Unset() + cm.IO.On("IsTTY").Return(true) + cm.IO.On("InputPrompt", mock.Anything, "Name your app:", mock.Anything). + Return("my-charm-app", nil) + // Enable the charm experiment + cm.Config.ExperimentsFlag = []string{string(experiment.Charm)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + // Override runForm to simulate form completion without a terminal + runForm = func(f *huh.Form) error { + doAllUpdates(f, f.Init()) + // Select first category (Starter app) then first template (Bolt for JS) + _, cmd := f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + doAllUpdates(f, cmd) + _, cmd = f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + doAllUpdates(f, cmd) + return nil + } + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("", nil) + CreateFunc = createClientMock.Create + }, + Teardown: func() { + runForm = func(f *huh.Form) error { return f.Run() } + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + template, err := create.ResolveTemplateURL("slack-samples/bolt-js-starter-template") + require.NoError(t, err) + expected := create.CreateArgs{ + AppName: "my-charm-app", + Template: template, + } + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, expected) + // Verify that the survey-based SelectPrompt for category was NOT called + cm.IO.AssertNotCalled(t, "SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything) + }, + }, + "charm dynamic form returns error": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + cm.IO.On("IsTTY").Unset() + cm.IO.On("IsTTY").Return(true) + // Enable the charm experiment + cm.Config.ExperimentsFlag = []string{string(experiment.Charm)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + // Override runForm to return an error + runForm = func(f *huh.Form) error { + return fmt.Errorf("user cancelled") + } + createClientMock = new(CreateClientMock) + CreateFunc = createClientMock.Create + }, + Teardown: func() { + runForm = func(f *huh.Form) error { return f.Run() } + }, + ExpectedErrorStrings: []string{"user cancelled"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }, + }, "lists agent templates with agent --list flag": { CmdArgs: []string{"agent", "--list"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { diff --git a/go.mod b/go.mod index 94ffa4a7..dd981c35 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.0 require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/briandowns/spinner v1.23.2 + github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 @@ -42,7 +43,6 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/chainguard-dev/git-urls v1.0.2 // indirect - github.com/charmbracelet/bubbles v1.0.0 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/strings v0.1.0 // indirect