From 42aeefd4d1ccdbb67d5a4d39b65938b47713c3e4 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Sat, 14 Mar 2026 22:13:18 -0700 Subject: [PATCH 1/3] revert(experiment): seperate create questions with flag parsings --- cmd/project/create_template.go | 11 - cmd/project/create_template_charm.go | 107 --------- cmd/project/create_template_charm_test.go | 258 ---------------------- cmd/project/create_test.go | 66 ------ 4 files changed, 442 deletions(-) delete mode 100644 cmd/project/create_template_charm.go delete mode 100644 cmd/project/create_template_charm_test.go diff --git a/cmd/project/create_template.go b/cmd/project/create_template.go index 0fc58dc0..45970097 100644 --- a/cmd/project/create_template.go +++ b/cmd/project/create_template.go @@ -21,7 +21,6 @@ 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" @@ -107,16 +106,6 @@ 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 deleted file mode 100644 index d8883a4b..00000000 --- a/cmd/project/create_template_charm.go +++ /dev/null @@ -1,107 +0,0 @@ -// 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" - - huh "charm.land/huh/v2" - "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 deleted file mode 100644 index 7b304d46..00000000 --- a/cmd/project/create_template_charm_test.go +++ /dev/null @@ -1,258 +0,0 @@ -// 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 "charm.land/bubbletea/v2" - huh "charm.land/huh/v2" - "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.KeyPressMsg{Code: 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.KeyPressMsg{Code: tea.KeyDown}) - doAllUpdates(f, cmd) - _, cmd = f.Update(tea.KeyPressMsg{Code: tea.KeyDown}) - doAllUpdates(f, cmd) - _, cmd = f.Update(tea.KeyPressMsg{Code: tea.KeyDown}) - doAllUpdates(f, cmd) - _, cmd = f.Update(tea.KeyPressMsg{Code: 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.KeyPressMsg{Code: tea.KeyDown}) - doAllUpdates(f, cmd) - _, cmd = f.Update(tea.KeyPressMsg{Code: tea.KeyDown}) - doAllUpdates(f, cmd) - _, cmd = f.Update(tea.KeyPressMsg{Code: 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.KeyPressMsg{Code: tea.KeyEnter}) - doAllUpdates(f, cmd) - // Select first template (Bolt for JavaScript) - _, cmd = f.Update(tea.KeyPressMsg{Code: 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.KeyPressMsg{Code: tea.KeyEnter}) - doAllUpdates(f, cmd) - // Select first template (Bolt for JavaScript) - _, cmd = f.Update(tea.KeyPressMsg{Code: 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.KeyPressMsg{Code: tea.KeyDown}) - doAllUpdates(f, cmd) - _, cmd = f.Update(tea.KeyPressMsg{Code: tea.KeyDown}) - doAllUpdates(f, cmd) - _, cmd = f.Update(tea.KeyPressMsg{Code: tea.KeyDown}) - doAllUpdates(f, cmd) - _, cmd = f.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - doAllUpdates(f, cmd) - // Select "Browse sample gallery..." - _, cmd = f.Update(tea.KeyPressMsg{Code: 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.KeyPressMsg{Code: tea.KeyDown}) - doAllUpdates(f, cmd) - _, cmd = f.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - doAllUpdates(f, cmd) - // Select first template (Bolt for JavaScript) - _, cmd = f.Update(tea.KeyPressMsg{Code: 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 55fe4702..b3ebb5a6 100644 --- a/cmd/project/create_test.go +++ b/cmd/project/create_test.go @@ -16,13 +16,9 @@ package project import ( "context" - "fmt" "testing" - tea "charm.land/bubbletea/v2" - huh "charm.land/huh/v2" "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/pkg/create" "github.com/slackapi/slack-cli/internal/shared" @@ -559,68 +555,6 @@ func TestCreateCommand(t *testing.T) { createClientMock.AssertNotCalled(t, "Create", 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.KeyPressMsg{Code: tea.KeyEnter}) - doAllUpdates(f, cmd) - _, cmd = f.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - doAllUpdates(f, cmd) - return nil - } - createClientMock = new(CreateClientMock) - createClientMock.On("Create", 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, 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) - }, - }, "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) { From 716cffb77a76868146e823685b38b2d2a0bd7017 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Sat, 14 Mar 2026 21:43:49 -0700 Subject: [PATCH 2/3] feat(experiment): inline sample descriptions in plain text --- cmd/project/create_samples.go | 32 ++++++++++++++---------------- cmd/project/create_samples_test.go | 6 ------ cmd/project/create_template.go | 6 ------ internal/iostreams/charm.go | 5 +++-- internal/iostreams/survey.go | 3 ++- internal/style/style.go | 14 +++++++++++++ internal/style/style_test.go | 30 ++++++++++++++++++++++++++++ 7 files changed, 64 insertions(+), 32 deletions(-) diff --git a/cmd/project/create_samples.go b/cmd/project/create_samples.go index adea91e2..3fdf4d3e 100644 --- a/cmd/project/create_samples.go +++ b/cmd/project/create_samples.go @@ -21,6 +21,7 @@ import ( "sort" "strings" + "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" @@ -64,14 +65,26 @@ func promptSampleSelection(ctx context.Context, clients *shared.ClientFactory, s } sortedRepos := sortRepos(filteredRepos) - selectOptions := createSelectOptions(sortedRepos) + selectOptions := make([]string, len(sortedRepos)) + for i, r := range sortedRepos { + if !clients.Config.WithExperimentOn(experiment.Charm) { + selectOptions[i] = fmt.Sprint(i+1, ". ", r.Name) + } else { + selectOptions[i] = r.Name + } + } var selectedTemplate string selection, err = clients.IO.SelectPrompt(ctx, "Select a sample to build upon:", selectOptions, iostreams.SelectPromptConfig{ Description: func(value string, index int) string { - return sortedRepos[index].Description + "\n https://github.com/" + sortedRepos[index].FullName + desc := sortedRepos[index].Description + if !clients.Config.WithExperimentOn(experiment.Charm) { + desc += "\n https://github.com/" + sortedRepos[index].FullName + } + return desc }, Flag: clients.Config.Flags.Lookup("template"), + Help: fmt.Sprintf("Guided tutorials can be found at %s", style.LinkText("https://docs.slack.dev/samples")), PageSize: 4, // Supports standard terminal height (24 rows) Required: true, Template: embedPromptSamplesTmpl, @@ -127,18 +140,3 @@ func sortRepos(sampleRepos []create.GithubRepo) []create.GithubRepo { }) return sortedRepos } - -// createSelectOptions takes in a list of repositories -// and returns an array of strings, each value being -// equal to the repository name (ie, deno-starter-template) -// and prepended with a number for a prompt visual aid -func createSelectOptions(filteredRepos []create.GithubRepo) []string { - // Create a slice of repository names to use as - // the primary item selection in the prompt - selectOptions := make([]string, 0) - for i, f := range filteredRepos { - selectOption := fmt.Sprint(i+1, ". ", f.Name) - selectOptions = append(selectOptions, selectOption) - } - return selectOptions -} diff --git a/cmd/project/create_samples_test.go b/cmd/project/create_samples_test.go index f6eb7cde..10f503c5 100644 --- a/cmd/project/create_samples_test.go +++ b/cmd/project/create_samples_test.go @@ -174,9 +174,3 @@ func TestSamples_SortRepos(t *testing.T) { assert.Equal(t, sortedRepos[3].StargazersCount, 0, "Expected sortedRepos[3].StargazersCount to equal 0") assert.Equal(t, sortedRepos[3].Description, "This is a new sample") } - -func TestSamples_CreateSelectOptions(t *testing.T) { - selectOptions := createSelectOptions(mockGitHubRepos) - assert.Equal(t, len(selectOptions), 4, "Expected selectOptions length to be 4") - assert.Contains(t, selectOptions[0], mockGitHubRepos[0].Name, "Expected selectOptions[0] to contain mockGitHubRepos[0].Name") -} diff --git a/cmd/project/create_template.go b/cmd/project/create_template.go index 45970097..421b572d 100644 --- a/cmd/project/create_template.go +++ b/cmd/project/create_template.go @@ -121,9 +121,6 @@ func promptTemplateSelection(cmd *cobra.Command, clients *shared.ClientFactory, // Prompt to choose a category selection, err := clients.IO.SelectPrompt(ctx, promptForCategory, titlesForCategory, iostreams.SelectPromptConfig{ - Description: func(value string, index int) string { - return optionsForCategory[index].Description - }, Flag: clients.Config.Flags.Lookup("template"), Required: true, Template: templateForCategory, @@ -157,9 +154,6 @@ func promptTemplateSelection(cmd *cobra.Command, clients *shared.ClientFactory, // Prompt to choose a template selection, err := clients.IO.SelectPrompt(ctx, prompt, titles, iostreams.SelectPromptConfig{ - Description: func(value string, index int) string { - return options[index].Description - }, Flag: clients.Config.Flags.Lookup("template"), Required: true, Template: template, diff --git a/internal/iostreams/charm.go b/internal/iostreams/charm.go index 41eca59e..412f82ef 100644 --- a/internal/iostreams/charm.go +++ b/internal/iostreams/charm.go @@ -71,8 +71,8 @@ func buildSelectForm(msg string, options []string, cfg SelectPromptConfig, selec for _, opt := range options { key := opt if cfg.Description != nil { - if desc := cfg.Description(opt, len(opts)); desc != "" { - key = opt + "\n " + desc + if desc := style.RemoveEmoji(cfg.Description(opt, len(opts))); desc != "" { + key = opt + " - " + desc } } opts = append(opts, huh.NewOption(key, opt)) @@ -80,6 +80,7 @@ func buildSelectForm(msg string, options []string, cfg SelectPromptConfig, selec field := huh.NewSelect[string](). Title(msg). + Description(cfg.Help). Options(opts...). Value(selected) diff --git a/internal/iostreams/survey.go b/internal/iostreams/survey.go index aebd9774..a9fdaa5c 100644 --- a/internal/iostreams/survey.go +++ b/internal/iostreams/survey.go @@ -409,9 +409,10 @@ var selectQuestionTemplate = fmt.Sprintf(` // SelectPromptConfig holds additional config for a survey.Select prompt type SelectPromptConfig struct { - Description func(value string, index int) string // Optional text displayed below each prompt option + Description func(value string, index int) string // Optional text displayed with each prompt option Flag *pflag.Flag // The single flag substitute for this prompt Flags []*pflag.Flag // Otherwise multiple flag substitutes for this prompt + Help string // Optional help text displayed below the select title PageSize int // The number of options displayed before the user needs to scroll Required bool // If a response is required Template string // Custom formatting of the selection prompt diff --git a/internal/style/style.go b/internal/style/style.go index 44fb6e2b..d8366b8a 100644 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -45,6 +45,20 @@ func RemoveANSI(str string) string { return ansiRegex.ReplaceAllString(str, "") } +// RemoveEmoji strips non-ASCII characters (such as emoji) from a string +// and collapses any resulting extra whitespace. +// +// https://en.wikipedia.org/wiki/ASCII#Printable_character_table +func RemoveEmoji(str string) string { + var b strings.Builder + for _, r := range str { + if r <= 127 { + b.WriteRune(r) + } + } + return strings.Join(strings.Fields(b.String()), " ") +} + // ToggleStyles sets styles and formatting values to the active state func ToggleStyles(active bool) { isStyleEnabled = active diff --git a/internal/style/style_test.go b/internal/style/style_test.go index 564c2a7c..695f791e 100644 --- a/internal/style/style_test.go +++ b/internal/style/style_test.go @@ -58,6 +58,36 @@ func TestRemoveANSI(t *testing.T) { } } +func TestRemoveEmoji(t *testing.T) { + tests := map[string]struct { + input string + expected string + }{ + "plain text is unchanged": { + input: "A simple description", + expected: "A simple description", + }, + "emoji flags are removed": { + input: "A translation bot 🇨🇳 🇮🇹 🇹🇭 🇫🇷", + expected: "A translation bot", + }, + "mixed emoji and text": { + input: "Hello 🌍 world 🚀 test", + expected: "Hello world test", + }, + "empty string": { + input: "", + expected: "", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + actual := RemoveEmoji(tc.input) + assert.Equal(t, tc.expected, actual) + }) + } +} + func TestToggleStyles(t *testing.T) { defer func() { ToggleStyles(false) From cd63d765ece5cc98374772690b139c9e5454b21d Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Sun, 15 Mar 2026 20:37:47 -0700 Subject: [PATCH 3/3] feat(experiment): prompt create shows more example templates and agents --- cmd/project/create.go | 7 +- cmd/project/create_samples.go | 24 ++- cmd/project/create_template.go | 345 ++++++++++++++++++++++++------ cmd/project/create_test.go | 94 ++++++++ docs/reference/experiments.md | 1 + internal/experiment/experiment.go | 8 +- internal/pkg/create/template.go | 11 + 7 files changed, 417 insertions(+), 73 deletions(-) diff --git a/cmd/project/create.go b/cmd/project/create.go index ab856cc2..8a14edbb 100644 --- a/cmd/project/create.go +++ b/cmd/project/create.go @@ -47,6 +47,7 @@ type promptObject struct { Title string // "Reverse string" Repository string // "slack-samples/reverse-string" Description string // "A function that reverses a given string" + Subdir string // "agents/hello-world" - subdirectory within the repository } const viewMoreSamples = "slack-cli#view-more-samples" @@ -158,11 +159,15 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] } } + subdir := createSubdirFlag + if subdir == "" { + subdir = template.GetSubdir() + } createArgs := create.CreateArgs{ AppName: appNameArg, Template: template, GitBranch: createGitBranchFlag, - Subdir: createSubdirFlag, + Subdir: subdir, } clients.EventTracker.SetAppTemplate(template.GetTemplatePath()) diff --git a/cmd/project/create_samples.go b/cmd/project/create_samples.go index 3fdf4d3e..d8fb4e3b 100644 --- a/cmd/project/create_samples.go +++ b/cmd/project/create_samples.go @@ -35,12 +35,24 @@ var embedPromptSamplesTmpl string // promptSampleSelection gathers upstream samples to select from func promptSampleSelection(ctx context.Context, clients *shared.ClientFactory, sampleRepos []create.GithubRepo) (string, error) { filteredRepos := []create.GithubRepo{} - selection, err := clients.IO.SelectPrompt(ctx, "Select a language:", - []string{ - fmt.Sprintf("Bolt for JavaScript %s", style.Secondary("Node.js")), - fmt.Sprintf("Bolt for Python %s", style.Secondary("Python")), - fmt.Sprintf("Deno Slack SDK %s", style.Secondary("Deno")), - }, + languageOptions := []string{ + fmt.Sprintf("Bolt for JavaScript %s", style.Secondary("Node.js")), + fmt.Sprintf("Bolt for Python %s", style.Secondary("Python")), + fmt.Sprintf("Deno Slack SDK %s", style.Secondary("Deno")), + } + if clients.Config.WithExperimentOn(experiment.Templates) { + languageOptions = []string{ + "Bolt for JavaScript", + "Bolt for Python", + "Deno Slack SDK", + } + } + languagePrompt := "Select a language:" + if clients.Config.WithExperimentOn(experiment.Templates) { + languagePrompt = "Select a framework:" + } + selection, err := clients.IO.SelectPrompt(ctx, languagePrompt, + languageOptions, iostreams.SelectPromptConfig{ Flags: []*pflag.Flag{ clients.Config.Flags.Lookup("language"), diff --git a/cmd/project/create_template.go b/cmd/project/create_template.go index 421b572d..366e5124 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" @@ -30,12 +31,57 @@ import ( "github.com/spf13/cobra" ) +// getSelectionOptions returns the app template options for a given category. func getSelectionOptions(clients *shared.ClientFactory, categoryID string) []promptObject { + if clients.Config.WithExperimentOn(experiment.Templates) { + templatePromptObjects := map[string]([]promptObject){ + "slack-cli#starter-templates": { + { + Title: "Starter template", + Description: "Getting started Slack app", + Repository: "slack-cli#starter-templates/getting-started", + }, + { + Title: "Automation template", + Description: "Custom steps and workflows", + Repository: "slack-cli#starter-templates/automation-apps", + }, + { + Title: "Search template", + Description: "Real-time enterprise search", + Repository: "slack-cli#starter-templates/search-template", + }, + { + Title: "Blank template", + Description: "Minimal setup that will start", + Repository: "slack-cli#starter-templates/blank-template", + }, + }, + "slack-cli#ai-apps": { + { + Title: "Support agent", + Description: "Resolve IT support cases with Casey", + Repository: "slack-cli#ai-apps/support-agent", + }, + { + Title: "Custom agent", + Description: "Minimal setup with the Slack MCP server", + Repository: "slack-cli#ai-apps/mcp-server", + }, + { + Title: "Assistant template", + Description: "Scaffold for a custom assistant", + Repository: "slack-cli#ai-apps/assistant-template", + }, + }, + } + return templatePromptObjects[categoryID] + } + if strings.TrimSpace(categoryID) == "" { categoryID = "slack-cli#getting-started" } - // App categories and templates templatePromptObjects := map[string]([]promptObject){ "slack-cli#getting-started": []promptObject{ { @@ -76,7 +122,127 @@ func getSelectionOptions(clients *shared.ClientFactory, categoryID string) []pro return templatePromptObjects[categoryID] } +// getFrameworkOptions returns the framework choices for a given template +// selection. +// +// The order of entries should match the unfolded selection order for sake of +// new entries. This is not an implementation requirement. +func getFrameworkOptions(template string) []promptObject { + frameworkPromptObjects := map[string][]promptObject{ + "slack-cli#starter-templates/getting-started": { + { + Title: "Bolt for JavaScript", + Repository: "slack-samples/bolt-js-starter-template", + }, + { + Title: "Bolt for JavaScript", + Description: "TypeScript", + Repository: "slack-samples/bolt-ts-starter-template", + }, + { + Title: "Bolt for Python", + Repository: "slack-samples/bolt-python-starter-template", + }, + }, + "slack-cli#starter-templates/automation-apps": { + { + Title: "Bolt for JavaScript", + Repository: "slack-samples/bolt-js-custom-function-template", + }, + { + Title: "Bolt for JavaScript", + Description: "TypeScript", + Repository: "slack-samples/bolt-ts-custom-step-template", + }, + { + Title: "Bolt for Python", + Repository: "slack-samples/bolt-python-custom-function-template", + }, + { + Title: "Deno Slack SDK", + Repository: "slack-samples/deno-starter-template", + }, + }, + "slack-cli#starter-templates/search-template": { + { + Title: "Bolt for JavaScript", + Repository: "slack-samples/bolt-js-search-template", + }, + { + Title: "Bolt for JavaScript", + Description: "TypeScript", + Repository: "slack-samples/bolt-ts-search-template", + }, + { + Title: "Bolt for Python", + Repository: "slack-samples/bolt-python-search-template", + }, + }, + "slack-cli#starter-templates/blank-template": { + { + Title: "Bolt for JavaScript", + Repository: "slack-samples/bolt-js-blank-template", + }, + }, + "slack-cli#ai-apps/support-agent": { + { + Title: "Claude Agent SDK", + Description: "Bolt for Python", + Repository: "slack-samples/bolt-python-support-agent", + Subdir: "claude-agent-sdk", + }, + { + Title: "OpenAI Agents SDK", + Description: "Bolt for Python", + Repository: "slack-samples/bolt-python-support-agent", + Subdir: "openai-agents-sdk", + }, + { + Title: "Pydantic AI", + Description: "Bolt for Python", + Repository: "slack-samples/bolt-python-support-agent", + Subdir: "pydantic-ai", + }, + }, + "slack-cli#ai-apps/assistant-template": { + { + Title: "Bolt for JavaScript", + Repository: "slack-samples/bolt-js-assistant-template", + }, + { + Title: "Bolt for Python", + Repository: "slack-samples/bolt-python-assistant-template", + }, + }, + "slack-cli#ai-apps/mcp-server": { + { + Title: "Bolt for JavaScript", + Repository: "slack-samples/bolt-js-slack-mcp-server", + }, + }, + } + return frameworkPromptObjects[template] +} + +// getSelectionOptionsForCategory returns the top-level category options for +// the create command template selection. func getSelectionOptionsForCategory(clients *shared.ClientFactory) []promptObject { + if clients.Config.WithExperimentOn(experiment.Templates) { + return []promptObject{ + { + Title: "Starter templates", + Repository: "slack-cli#starter-templates", + }, + { + Title: "AI agent apps", + Repository: "slack-cli#ai-apps", + }, + { + Title: "View more samples", + Repository: viewMoreSamples, + }, + } + } return []promptObject{ { Title: fmt.Sprintf("Starter app %s", style.Secondary("Getting started Slack app")), @@ -101,14 +267,22 @@ func getSelectionOptionsForCategory(clients *shared.ClientFactory) []promptObjec func promptTemplateSelection(cmd *cobra.Command, clients *shared.ClientFactory, categoryShortcut string) (create.Template, error) { ctx := cmd.Context() var categoryID string - var selectedTemplate string // Check if a category shortcut was provided - if categoryShortcut == "agent" { - categoryID = "slack-cli#ai-apps" + if categoryShortcut != "" { + switch categoryShortcut { + case "agent": + categoryID = "slack-cli#ai-apps" + default: + return create.Template{}, slackerror.New(slackerror.ErrInvalidArgs). + WithMessage("The %s category was not found", categoryShortcut) + } } else { // Prompt for the category promptForCategory := "Select an app:" + if clients.Config.WithExperimentOn(experiment.Templates) { + promptForCategory = "Select a category:" + } optionsForCategory := getSelectionOptionsForCategory(clients) titlesForCategory := make([]string, len(optionsForCategory)) for i, m := range optionsForCategory { @@ -128,73 +302,91 @@ func promptTemplateSelection(cmd *cobra.Command, clients *shared.ClientFactory, if err != nil { return create.Template{}, slackerror.ToSlackError(err) } else if selection.Flag { - selectedTemplate = selection.Option + template, err := create.ResolveTemplateURL(selection.Option) + if err != nil { + return create.Template{}, err + } + confirm, err := confirmExternalTemplateSelection(cmd, clients, template) + if err != nil { + return create.Template{}, slackerror.ToSlackError(err) + } else if !confirm { + return create.Template{}, slackerror.New(slackerror.ErrUntrustedSource) + } + return template, nil } else if selection.Prompt { categoryID = optionsForCategory[selection.Index].Repository } - // Set template to view more samples, so the sample prompt is triggered if categoryID == viewMoreSamples { - selectedTemplate = viewMoreSamples + sampler := api.NewHTTPClient(api.HTTPClientOptions{ + TotalTimeOut: 60 * time.Second, + }) + samples, err := create.GetSampleRepos(sampler) + if err != nil { + return create.Template{}, err + } + selectedSample, err := promptSampleSelection(ctx, clients, samples) + if err != nil { + return create.Template{}, err + } + return create.ResolveTemplateURL(selectedSample) } } - // Prompt for the template - if selectedTemplate == "" { - prompt := "Select a language:" - options := getSelectionOptions(clients, categoryID) - titles := make([]string, len(options)) - for i, m := range options { - titles[i] = m.Title - } - template := getSelectionTemplate(clients) - - // Print a trace with info about the template title options provided by CLI - clients.IO.PrintTrace(ctx, slacktrace.CreateTemplateOptions, strings.Join(titles, ", ")) + // Prompt for the example template + prompt := "Select a language:" + if clients.Config.WithExperimentOn(experiment.Templates) { + prompt = "Select a template:" + } + options := getSelectionOptions(clients, categoryID) + titles := make([]string, len(options)) + for i, m := range options { + titles[i] = m.Title + } + clients.IO.PrintTrace(ctx, slacktrace.CreateTemplateOptions, strings.Join(titles, ", ")) - // Prompt to choose a template - selection, err := clients.IO.SelectPrompt(ctx, prompt, titles, iostreams.SelectPromptConfig{ - Flag: clients.Config.Flags.Lookup("template"), - Required: true, - Template: template, - }) - if err != nil { - return create.Template{}, slackerror.ToSlackError(err) - } else if selection.Flag { - selectedTemplate = selection.Option - } else if selection.Prompt { - selectedTemplate = options[selection.Index].Repository - } + selection, err := clients.IO.SelectPrompt(ctx, prompt, titles, iostreams.SelectPromptConfig{ + Description: func(value string, index int) string { + return options[index].Description + }, + Required: true, + Template: getSelectionTemplate(clients), + }) + if err != nil { + return create.Template{}, err + } else if selection.Flag { + return create.Template{}, slackerror.New(slackerror.ErrPrompt) + } else if selection.Prompt && !clients.Config.WithExperimentOn(experiment.Templates) { + return create.ResolveTemplateURL(options[selection.Index].Repository) } + template := options[selection.Index].Repository - // Ensure user is okay to proceed if template source is from a non-trusted source - switch selectedTemplate { - case viewMoreSamples: - sampler := api.NewHTTPClient(api.HTTPClientOptions{ - TotalTimeOut: 60 * time.Second, - }) - samples, err := create.GetSampleRepos(sampler) - if err != nil { - return create.Template{}, err - } - selectedSample, err := promptSampleSelection(ctx, clients, samples) - if err != nil { - return create.Template{}, err - } - return create.ResolveTemplateURL(selectedSample) - default: - template, err := create.ResolveTemplateURL(selectedTemplate) - if err != nil { - return create.Template{}, err - } - confirm, err := confirmExternalTemplateSelection(cmd, clients, template) - if err != nil { - return create.Template{}, slackerror.ToSlackError(err) - } else if !confirm { - return create.Template{}, slackerror.New(slackerror.ErrUntrustedSource) - } - return template, nil + // Prompt for the example framework + examples := getFrameworkOptions(template) + choices := make([]string, len(examples)) + for i, opt := range examples { + choices[i] = opt.Title } + choice, err := clients.IO.SelectPrompt(ctx, "Select a framework:", choices, iostreams.SelectPromptConfig{ + Description: func(value string, index int) string { + return examples[index].Description + }, + Required: true, + }) + if err != nil { + return create.Template{}, err + } else if choice.Flag { + return create.Template{}, slackerror.New(slackerror.ErrPrompt) + } + example := examples[choice.Index] + resolved, err := create.ResolveTemplateURL(example.Repository) + if err != nil { + return create.Template{}, err + } + if example.Subdir != "" { + resolved.SetSubdir(example.Subdir) + } + return resolved, nil } // confirmExternalTemplateSelection prompts the user to confirm that they want to create an app from @@ -243,10 +435,26 @@ func listTemplates(ctx context.Context, clients *shared.ClientFactory, categoryS } var categories []categoryInfo - if categoryShortcut == "agent" { + if categoryShortcut == "agent" && clients.Config.WithExperimentOn(experiment.Templates) { + categories = []categoryInfo{ + {id: "slack-cli#ai-apps/support-agent", name: "Support agent"}, + {id: "slack-cli#ai-apps/mcp-server", name: "Custom agent"}, + {id: "slack-cli#ai-apps/assistant-template", name: "Assistant templates"}, + } + } else if categoryShortcut == "agent" { categories = []categoryInfo{ {id: "slack-cli#ai-apps", name: "AI Agent apps"}, } + } else if clients.Config.WithExperimentOn(experiment.Templates) { + categories = []categoryInfo{ + {id: "slack-cli#starter-templates/getting-started", name: "Starter templates"}, + {id: "slack-cli#starter-templates/automation-apps", name: "Automation templates"}, + {id: "slack-cli#starter-templates/search-template", name: "Search templates"}, + {id: "slack-cli#starter-templates/blank-template", name: "Blank templates"}, + {id: "slack-cli#ai-apps/support-agent", name: "Support agent"}, + {id: "slack-cli#ai-apps/mcp-server", name: "Custom agent"}, + {id: "slack-cli#ai-apps/assistant-template", name: "Assistant templates"}, + } } else { categories = []categoryInfo{ {id: "slack-cli#getting-started", name: "Getting started"}, @@ -256,10 +464,19 @@ func listTemplates(ctx context.Context, clients *shared.ClientFactory, categoryS } for _, category := range categories { - templates := getSelectionOptions(clients, category.id) - secondary := make([]string, len(templates)) - for i, tmpl := range templates { - secondary[i] = tmpl.Repository + var secondary []string + if !clients.Config.WithExperimentOn(experiment.Templates) { + for _, tmpl := range getSelectionOptions(clients, category.id) { + secondary = append(secondary, tmpl.Repository) + } + } else { + for _, tmpl := range getFrameworkOptions(category.id) { + repo := tmpl.Repository + if tmpl.Subdir != "" { + repo = fmt.Sprintf("%s --subdir %s", repo, tmpl.Subdir) + } + secondary = append(secondary, repo) + } } clients.IO.PrintInfo(ctx, false, "%s", style.Sectionf(style.TextSection{ Emoji: "house_buildings", diff --git a/cmd/project/create_test.go b/cmd/project/create_test.go index b3ebb5a6..19eb6cc0 100644 --- a/cmd/project/create_test.go +++ b/cmd/project/create_test.go @@ -178,6 +178,34 @@ func TestCreateCommand(t *testing.T) { cm.IO.AssertNotCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything) }, }, + "creates a pydantic ai agent app with templates experiment": { + CmdArgs: []string{"my-pydantic-app"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = append(cm.Config.ExperimentsFlag, "templates") + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + cm.IO.On("SelectPrompt", mock.Anything, "Select a category:", mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Prompt: true, Index: 1}, nil) + cm.IO.On("SelectPrompt", mock.Anything, "Select a template:", mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Prompt: true, Index: 0}, nil) + cm.IO.On("SelectPrompt", mock.Anything, "Select a framework:", mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Prompt: true, Index: 2}, nil) + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return("", nil) + CreateFunc = createClientMock.Create + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + template, err := create.ResolveTemplateURL("slack-samples/bolt-python-support-agent") + require.NoError(t, err) + template.SetSubdir("pydantic-ai") + expected := create.CreateArgs{ + AppName: "my-pydantic-app", + Template: template, + Subdir: "pydantic-ai", + } + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, expected) + }, + }, "creates an app named agent when template flag is provided": { CmdArgs: []string{"agent", "--template", "slack-samples/bolt-js-starter-template"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { @@ -573,6 +601,72 @@ func TestCreateCommand(t *testing.T) { assert.NotContains(t, output, "Automation apps") }, }, + "lists all templates with --list flag and templates experiment": { + CmdArgs: []string{"--list"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = append(cm.Config.ExperimentsFlag, "templates") + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + createClientMock = new(CreateClientMock) + CreateFunc = createClientMock.Create + }, + ExpectedOutputs: []string{ + "Starter templates", + "slack-samples/bolt-js-starter-template", + "slack-samples/bolt-ts-starter-template", + "slack-samples/bolt-python-starter-template", + "Automation templates", + "slack-samples/bolt-js-custom-function-template", + "slack-samples/bolt-ts-custom-step-template", + "slack-samples/bolt-python-custom-function-template", + "slack-samples/deno-starter-template", + "Search templates", + "slack-samples/bolt-js-search-template", + "slack-samples/bolt-ts-search-template", + "slack-samples/bolt-python-search-template", + "Blank templates", + "slack-samples/bolt-js-blank-template", + "Support agent", + "slack-samples/bolt-python-support-agent --subdir claude-agent-sdk", + "slack-samples/bolt-python-support-agent --subdir openai-agents-sdk", + "slack-samples/bolt-python-support-agent --subdir pydantic-ai", + "Custom agent", + "slack-samples/bolt-js-slack-mcp-server", + "Assistant templates", + "slack-samples/bolt-js-assistant-template", + "slack-samples/bolt-python-assistant-template", + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "lists agent templates with agent --list flag and templates experiment": { + CmdArgs: []string{"agent", "--list"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = append(cm.Config.ExperimentsFlag, "templates") + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + createClientMock = new(CreateClientMock) + CreateFunc = createClientMock.Create + }, + ExpectedOutputs: []string{ + "Support agent", + "slack-samples/bolt-python-support-agent --subdir claude-agent-sdk", + "slack-samples/bolt-python-support-agent --subdir openai-agents-sdk", + "slack-samples/bolt-python-support-agent --subdir pydantic-ai", + "Custom agent", + "slack-samples/bolt-js-slack-mcp-server", + "Assistant templates", + "slack-samples/bolt-js-assistant-template", + "slack-samples/bolt-python-assistant-template", + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + output := cm.GetCombinedOutput() + assert.NotContains(t, output, "Starter templates") + assert.NotContains(t, output, "Blank templates") + }, + }, }, func(cf *shared.ClientFactory) *cobra.Command { return NewCreateCommand(cf) }) diff --git a/docs/reference/experiments.md b/docs/reference/experiments.md index ca54c51b..7a5c5429 100644 --- a/docs/reference/experiments.md +++ b/docs/reference/experiments.md @@ -8,6 +8,7 @@ The following is a list of currently available experiments. We'll remove experim - `charm`: shows beautiful prompts ([PR#348](https://github.com/slackapi/slack-cli/pull/348)). - `sandboxes`: enables users who have joined the Slack Developer Program to manage their sandboxes ([PR#379](https://github.com/slackapi/slack-cli/pull/379)). +- `templates`: brings agents, search, and blank templates to the `create` command. ## Experiments changelog diff --git a/internal/experiment/experiment.go b/internal/experiment/experiment.go index 5e4e3cc4..1c9e84fa 100644 --- a/internal/experiment/experiment.go +++ b/internal/experiment/experiment.go @@ -33,11 +33,14 @@ const ( // Charm experiment enables beautiful prompts. Charm Experiment = "charm" + // Placeholder experiment is a placeholder for testing and does nothing... or does it? + Placeholder Experiment = "placeholder" + // Sandboxes experiment lets users who have joined the Slack Developer Program use the CLI to manage their sandboxes. Sandboxes Experiment = "sandboxes" - // Placeholder experiment is a placeholder for testing and does nothing... or does it? - Placeholder Experiment = "placeholder" + // Templates experiment brings agents and more to the create command templates. + Templates Experiment = "templates" ) // AllExperiments is a list of all available experiments that can be enabled @@ -46,6 +49,7 @@ var AllExperiments = []Experiment{ Charm, Placeholder, Sandboxes, + Templates, } // EnabledExperiments is a list of experiments that are permanently enabled diff --git a/internal/pkg/create/template.go b/internal/pkg/create/template.go index ee3e25d9..aeaf01fa 100644 --- a/internal/pkg/create/template.go +++ b/internal/pkg/create/template.go @@ -38,10 +38,21 @@ var trustedTemplateSources = []string{ // Template describes the app template's path and protocol type Template struct { path string // path can be a local path or remote git URL + subdir string // subdirectory within the repository isGit bool isLocal bool } +// SetSubdir sets the subdirectory within the repository +func (t *Template) SetSubdir(subdir string) { + t.subdir = subdir +} + +// GetSubdir returns the subdirectory within the repository +func (t Template) GetSubdir() string { + return t.subdir +} + // ResolveTemplateURL returns a git-clone compatible URL func ResolveTemplateURL(templateURL string) (Template, error) { template := Template{