diff --git a/cmd/project/create_template.go b/cmd/project/create_template.go index 45970097..17e68fb9 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 := charmPromptTemplateSelectionFunc(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..70ca62a7 --- /dev/null +++ b/cmd/project/create_template_charm.go @@ -0,0 +1,106 @@ +// 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" +} + +// charmPromptTemplateSelectionFunc is a package-level function variable for test overriding. +var charmPromptTemplateSelectionFunc = charmPromptTemplateSelection + +// 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 := buildTemplateSelectionForm(clients, &category, &template).Run() + 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..59f9a868 --- /dev/null +++ b/cmd/project/create_template_charm_test.go @@ -0,0 +1,162 @@ +// 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 ( + "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" +) + +// 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, "┃") + }) +} diff --git a/cmd/project/create_test.go b/cmd/project/create_test.go index 37d83102..5f64a3b9 100644 --- a/cmd/project/create_test.go +++ b/cmd/project/create_test.go @@ -19,6 +19,7 @@ import ( "testing" "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 +525,42 @@ 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 the charm prompt function + charmPromptTemplateSelectionFunc = func(_ context.Context, _ *shared.ClientFactory) (templateSelectionResult, error) { + return templateSelectionResult{ + CategoryID: "slack-cli#getting-started", + TemplateRepo: "slack-samples/bolt-js-starter-template", + }, nil + } + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("", nil) + CreateFunc = createClientMock.Create + }, + Teardown: func() { + charmPromptTemplateSelectionFunc = charmPromptTemplateSelection + }, + 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) + }, + }, "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 f7aae47e..bb147519 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 v0.8.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