diff --git a/cmd/project/create.go b/cmd/project/create.go index 8a14edbb..ffd8881a 100644 --- a/cmd/project/create.go +++ b/cmd/project/create.go @@ -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 @@ -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()) diff --git a/cmd/project/create_test.go b/cmd/project/create_test.go index c64266c2..4b02440f 100644 --- a/cmd/project/create_test.go +++ b/cmd/project/create_test.go @@ -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) @@ -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) @@ -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 @@ -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) diff --git a/internal/pkg/create/create.go b/internal/pkg/create/create.go index 357785c3..19933a64 100644 --- a/internal/pkg/create/create.go +++ b/internal/pkg/create/create.go @@ -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. @@ -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) 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) @@ -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) @@ -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 @@ -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) } @@ -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) { diff --git a/internal/pkg/create/create_test.go b/internal/pkg/create/create_test.go index 1362d30e..cb3cb9f3 100644 --- a/internal/pkg/create/create_test.go +++ b/internal/pkg/create/create_test.go @@ -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