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
21 changes: 14 additions & 7 deletions cmd/project/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,16 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []
}
}

// --name flag overrides any positional app name argument
// This allows users to name their app "agent" without triggering the AI Agent shortcut
// --name flag overrides the manifest display name but preserves any path
// from the positional argument. When no positional arg is given (e.g.
// "slack create --name APPPP"), the name flag also becomes the directory
// path since there's nothing else to derive it from.
displayNameOverride := ""
if nameFlagProvided {
appNameArg = createAppNameFlag
displayNameOverride = createAppNameFlag
if appNameArg == "" {
appNameArg = createAppNameFlag
}
}

// List templates and exit early if the --list flag is set
Expand Down Expand Up @@ -164,10 +170,11 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []
subdir = template.GetSubdir()
}
createArgs := create.CreateArgs{
AppName: appNameArg,
Template: template,
GitBranch: createGitBranchFlag,
Subdir: subdir,
AppName: appNameArg,
DisplayName: displayNameOverride,
Template: template,
GitBranch: createGitBranchFlag,
Subdir: subdir,
}
clients.EventTracker.SetAppTemplate(template.GetTemplatePath())

Expand Down
24 changes: 14 additions & 10 deletions cmd/project/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,8 +304,9 @@ func TestCreateCommand(t *testing.T) {
template, err := create.ResolveTemplateURL("slack-samples/bolt-js-starter-template")
require.NoError(t, err)
expected := create.CreateArgs{
AppName: "agent",
Template: template,
AppName: "agent",
DisplayName: "agent",
Template: template,
}
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, expected)
// Verify that category prompt WAS called (shortcut was not triggered)
Expand Down Expand Up @@ -351,9 +352,10 @@ func TestCreateCommand(t *testing.T) {
require.NoError(t, err)
template.SetSubdir("claude-agent-sdk")
expected := create.CreateArgs{
AppName: "my-custom-name", // --name flag overrides
Template: template,
Subdir: "claude-agent-sdk",
AppName: "my-custom-name", // --name flag used as path when no positional arg
DisplayName: "my-custom-name",
Template: template,
Subdir: "claude-agent-sdk",
}
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, expected)
// Verify that category prompt was NOT called (shortcut was triggered)
Expand Down Expand Up @@ -387,8 +389,9 @@ func TestCreateCommand(t *testing.T) {
template, err := create.ResolveTemplateURL("slack-samples/bolt-js-starter-template")
require.NoError(t, err)
expected := create.CreateArgs{
AppName: "my-name", // --name flag overrides "my-project" positional arg
Template: template,
AppName: "my-project", // positional arg preserved as path
DisplayName: "my-name", // --name flag sets manifest display name
Template: template,
}
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, expected)
// Verify that name prompt was NOT called since --name flag was provided
Expand Down Expand Up @@ -432,9 +435,10 @@ func TestCreateCommand(t *testing.T) {
require.NoError(t, err)
template.SetSubdir("claude-agent-sdk")
expected := create.CreateArgs{
AppName: "my-name", // --name flag overrides "my-project" positional arg
Template: template,
Subdir: "claude-agent-sdk",
AppName: "my-project", // positional arg preserved as path
DisplayName: "my-name", // --name flag sets manifest display name
Template: template,
Subdir: "claude-agent-sdk",
}
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, expected)
// Verify that category prompt was NOT called (agent shortcut was triggered)
Expand Down
51 changes: 40 additions & 11 deletions internal/pkg/create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,11 @@ var copyIgnoreFiles = []string{".DS_Store"}

// CreateArgs are the arguments passed into the Create function
type CreateArgs struct {
AppName string
Template Template
GitBranch string
Subdir string
AppName string
DisplayName string
Template Template
GitBranch string
Subdir string
}

// Create will create a new Slack app on the file system and app manifest on the Slack API.
Expand All @@ -67,16 +68,21 @@ func Create(ctx context.Context, clients *shared.ClientFactory, createArgs Creat
return "", slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess)
}

// Get the app selection and accompanying app directory name (this may change when we find the unique directory name)
appDirName, err := getAppDirName(createArgs.AppName)
// Parse the app name input into a directory path and display name
appPath, displayName, err := parseAppPath(createArgs.AppName)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🐛 issue: I'm finding the app path can be changed with the "--name" flag which is unexpected as:

$ slack create path/to/my-app --name "APPPP"

📂 Created a new Slack project
   Cloning template slack-samples/bolt-js-starter-template
   To path ~/programming/tools/slack-cli/apppp

🔍 thought: I'd instead expect the argument path to be preferred here with the name being applied to the app itself? I'm unsure if separating these functions makes this more clear but think this case is good to test too!

Copy link
Copy Markdown
Contributor Author

@srtaalej srtaalej May 11, 2026

Choose a reason for hiding this comment

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

hmmm i thought the --name flag was always preferred? this was introduced with the name flag in #327
but thanks for catching this because i would also expect --name to override the display name and leave the path alone

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@srtaalej Ahh we might be finding nuance of this command 😓 I recall these discussion too and didn't account for these edges...

if err != nil {
return "", slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess)
}

// --name flag overrides only the display name, preserving the path from the argument
if createArgs.DisplayName != "" {
displayName = createArgs.DisplayName
}

// Get the project's full directory path
projectDirPath := ""
if filepath.IsLocal(appDirName) {
projectDirPath = filepath.Join(workingDirPath, appDirName)
if filepath.IsLocal(appPath) {
projectDirPath = filepath.Join(workingDirPath, appPath)
projectDirPath, err = getAvailableDir(ctx, projectDirPath)
if err != nil {
return "", slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess)
Expand All @@ -86,7 +92,7 @@ func Create(ctx context.Context, clients *shared.ClientFactory, createArgs Creat
return "", slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess)
}
} else {
projectDirPath = filepath.Join(appDirName)
projectDirPath = filepath.Join(appPath)
projectDirPath, err = getAvailableDir(ctx, projectDirPath)
if err != nil {
return "", slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess)
Expand All @@ -98,7 +104,7 @@ func Create(ctx context.Context, clients *shared.ClientFactory, createArgs Creat
}

// Update the app's directory name now that the unique directory is created
appDirName = filepath.Base(projectDirPath)
appDirName := filepath.Base(projectDirPath)

// Print a bunch of information about the progress of the command to traces
// and debugs and the standard output here
Expand Down Expand Up @@ -150,7 +156,7 @@ func Create(ctx context.Context, clients *shared.ClientFactory, createArgs Creat
}()

// Update default project files' app name, bot name, etc
if err := app.UpdateDefaultProjectFiles(clients.Fs, projectDirPath, appDirName, createArgs.AppName); err != nil {
if err := app.UpdateDefaultProjectFiles(clients.Fs, projectDirPath, appDirName, displayName); err != nil {
return "", slackerror.Wrap(err, slackerror.ErrProjectFileUpdate)
}

Expand Down Expand Up @@ -192,6 +198,29 @@ func getAppDirName(appName string) (string, error) {
return appName, nil
}

// parseAppPath splits user input into a directory path (with kebab-cased basename)
// and a display name (the raw basename preserving original casing/spacing).
func parseAppPath(input string) (appPath string, displayName string, err error) {
input = strings.TrimSpace(input)
if input == "" {
return "", "", fmt.Errorf("app name is required")
}

input = filepath.Clean(input)
displayName = filepath.Base(input)
pathPrefix := filepath.Dir(input)

dirName, err := getAppDirName(displayName)
if err != nil {
return "", "", err
}

if pathPrefix == "." {
return dirName, displayName, nil
}
return filepath.Join(pathPrefix, dirName), displayName, nil
}

// getAvailableDir will return a unique directory path.
// If dirPath already exists, then a unique numbered path will be appended to the path.
func getAvailableDir(ctx context.Context, dirPath string) (string, error) {
Expand Down
79 changes: 79 additions & 0 deletions internal/pkg/create/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,85 @@ func TestGetProjectDirectoryName(t *testing.T) {
}
}

func TestParseAppPath(t *testing.T) {
tests := map[string]struct {
input string
expectedPath string
expectedDisplay string
hasError bool
}{
"simple kebab-case name": {
input: "my-app",
expectedPath: "my-app",
expectedDisplay: "my-app",
},
"name with spaces": {
input: "My Cool App",
expectedPath: "my-cool-app",
expectedDisplay: "My Cool App",
},
"relative path with simple name": {
input: "path/to/my-app",
expectedPath: filepath.Join("path", "to", "my-app"),
expectedDisplay: "my-app",
},
"relative path with spaced name": {
input: "path/to/My App",
expectedPath: filepath.Join("path", "to", "my-app"),
expectedDisplay: "My App",
},
"dot-prefixed path": {
input: "./my-app",
expectedPath: "my-app",
expectedDisplay: "my-app",
},
"absolute path": {
input: "/abs/path/app",
expectedPath: filepath.Join("/abs", "path", "app"),
expectedDisplay: "app",
},
"single directory depth": {
input: "projects/my-app",
expectedPath: filepath.Join("projects", "my-app"),
expectedDisplay: "my-app",
},
"uppercase in nested path": {
input: "projects/My Slack App",
expectedPath: filepath.Join("projects", "my-slack-app"),
expectedDisplay: "My Slack App",
},
"trailing slash is trimmed": {
input: "path/to/my-app/",
expectedPath: filepath.Join("path", "to", "my-app"),
expectedDisplay: "my-app",
},
"empty string returns error": {
input: "",
hasError: true,
},
"whitespace only returns error": {
input: " ",
hasError: true,
},
"basename with only special chars returns error": {
input: "path/to/!!!",
hasError: true,
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
appPath, displayName, err := parseAppPath(tc.input)
if tc.hasError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expectedPath, appPath)
assert.Equal(t, tc.expectedDisplay, displayName)
}
})
}
}

func TestGetAvailableDirectory(t *testing.T) {
var exists bool
var err error
Expand Down
Loading