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{