Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion cmd/project/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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())

Expand Down
252 changes: 189 additions & 63 deletions cmd/project/create_template.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -30,12 +31,52 @@ import (
"github.com/spf13/cobra"
)

// getSelectionOptions returns the app template options for a given category.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸͺ¬ thought: The changes of this PR make me think of the create selections now as:

  1. Category: Choice of template, agent, or sample
  2. Scaffold: Feature focused examples
  3. Framework: Code implementation details

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: Yea, it feels like we're expanding on the creation journey. When I think about it, I can imagine:

  1. App Category: The use-case, such as blank, getting started, automation, or agent
  2. App Template: The specific template to use, which is available in multiple frameworks
  3. App Framework(s): Simple ones like Bolt JS or Bolt Python / Complex ones like Bolt JS + Vercel or Bolt Python + Pydantic

func getSelectionOptions(clients *shared.ClientFactory, categoryID string) []promptObject {
if clients.Config.WithExperimentOn(experiment.Templates) {
templatePromptObjects := map[string]([]promptObject){
"slack-cli#getting-started": {
{
Title: fmt.Sprintf("Bolt for JavaScript %s", style.Secondary("Node.js")),
Repository: "slack-samples/bolt-js-starter-template",
},
{
Title: fmt.Sprintf("Bolt for Python %s", style.Secondary("Python")),
Repository: "slack-samples/bolt-python-starter-template",
},
},
"slack-cli#ai-apps": {
{
Title: fmt.Sprintf("Support Agent %s", style.Secondary("Resolve IT support cases")),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: I like the choice of Title Case. I'd actually really like to see that added to our slack create list of choices too, instead of "Starter app" β†’ "Starter App"

Image

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mwbrooks Amazing eye! πŸ‘οΈβ€πŸ—¨οΈ ✨

I'm a fan of promoting these selections too. I found it to read natural in testing. Were you thinking this should be a stable or experimental change? I realize that adjacent changes to samples in #400 might've gone either way and am open to exploring all of this more 🐒

Repository: "slack-cli#ai-apps/support-agent",
},
{
Title: fmt.Sprintf("Custom Agent %s", style.Secondary("Start from scratch")),
Repository: "slack-cli#ai-apps/custom-agent",
},
},
"slack-cli#automation-apps": {
{
Title: fmt.Sprintf("Bolt for JavaScript %s", style.Secondary("Node.js")),
Repository: "slack-samples/bolt-js-custom-function-template",
},
{
Title: fmt.Sprintf("Bolt for Python %s", style.Secondary("Python")),
Repository: "slack-samples/bolt-python-custom-function-template",
},
{
Title: fmt.Sprintf("Deno Slack SDK %s", style.Secondary("Deno")),
Repository: "slack-samples/deno-starter-template",
},
},
}
return templatePromptObjects[categoryID]
}

if strings.TrimSpace(categoryID) == "" {
categoryID = "slack-cli#getting-started"
}

// App categories and templates
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸͺ“ note: This comment is removed in favor of godoc above.

templatePromptObjects := map[string]([]promptObject){
"slack-cli#getting-started": []promptObject{
{
Expand Down Expand Up @@ -76,6 +117,42 @@ func getSelectionOptions(clients *shared.ClientFactory, categoryID string) []pro
return templatePromptObjects[categoryID]
}

// getFrameworkOptions returns the framework choices for a given template.
func getFrameworkOptions(template string) []promptObject {
frameworkPromptObjects := map[string][]promptObject{
"slack-cli#ai-apps/support-agent": {
{
Title: fmt.Sprintf("Claude Agent SDK %s", style.Secondary("Bolt for Python")),
Repository: "slack-samples/bolt-python-support-agent",
Subdir: "claude-agent-sdk",
},
{
Title: fmt.Sprintf("OpenAI Agents SDK %s", style.Secondary("Bolt for Python")),
Repository: "slack-samples/bolt-python-support-agent",
Subdir: "openai-agents-sdk",
},
{
Title: fmt.Sprintf("Pydantic AI %s", style.Secondary("Bolt for Python")),
Repository: "slack-samples/bolt-python-support-agent",
Subdir: "pydantic-ai",
},
},
"slack-cli#ai-apps/custom-agent": {
{
Title: fmt.Sprintf("Bolt for JavaScript %s", style.Secondary("Node.js")),
Repository: "slack-samples/bolt-js-assistant-template",
},
{
Title: fmt.Sprintf("Bolt for Python %s", style.Secondary("Python")),
Repository: "slack-samples/bolt-python-assistant-template",
},
},
}
return frameworkPromptObjects[template]
}

// getSelectionOptionsForCategory returns the top-level category options for
// the create command template selection.
func getSelectionOptionsForCategory(clients *shared.ClientFactory) []promptObject {
return []promptObject{
{
Expand All @@ -101,11 +178,16 @@ 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)
Comment on lines +188 to +189
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ note: This case should never happen, but we might want to guard against prompting for a category if a shortcut is provided using this!

}
} else {
// Prompt for the category
promptForCategory := "Select an app:"
Expand All @@ -128,73 +210,96 @@ 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
Comment on lines +213 to +223
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ₯¦ note: We now return as soon as possible if the --template flag is provided. After this, we can be confident that prompts are used.

} 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)
Comment on lines +229 to +240
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🍽️ note: An earlier return with samples prompt is also included to avoid keeping track of the categories selected template in variable.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes a lot more sense now that there are fewer changes and I can connect the dots πŸ˜†

}
}

// 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
// Prompt for the example template
prompt := "Select a language:"
if clients.Config.WithExperimentOn(experiment.Templates) {
if categoryID == "slack-cli#ai-apps" {
prompt = "Select a template:"
} else {
prompt = "Select a language:"
}
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, ", "))
}
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 && !strings.HasPrefix(options[selection.Index].Repository, "slack-cli#") {
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 language:", choices, iostreams.SelectPromptConfig{
Description: func(value string, index int) string {
return examples[index].Description
},
Required: true,
Template: getSelectionTemplate(clients),
})
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
Expand Down Expand Up @@ -243,10 +348,22 @@ 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/custom-agent", name: "Custom agent"},
}
} 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#getting-started", name: "Getting started"},
{id: "slack-cli#ai-apps/support-agent", name: "Support agent"},
{id: "slack-cli#ai-apps/custom-agent", name: "Custom agent"},
{id: "slack-cli#automation-apps", name: "Automation apps"},
}
Comment on lines +361 to +366
Copy link
Member Author

@zimeg zimeg Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 note: Preview of an extended list - edit to match recent changes:

list

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: I like the --subdir flag useage!

} else {
categories = []categoryInfo{
{id: "slack-cli#getting-started", name: "Getting started"},
Expand All @@ -256,10 +373,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 frameworks := getFrameworkOptions(category.id); len(frameworks) > 0 {
for _, tmpl := range frameworks {
repo := tmpl.Repository
if tmpl.Subdir != "" {
repo = fmt.Sprintf("%s --subdir %s", repo, tmpl.Subdir)
}
secondary = append(secondary, repo)
}
} else {
for _, tmpl := range getSelectionOptions(clients, category.id) {
secondary = append(secondary, tmpl.Repository)
}
}
clients.IO.PrintInfo(ctx, false, "%s", style.Sectionf(style.TextSection{
Emoji: "house_buildings",
Expand Down
Loading
Loading