Skip to content

Commit 02805c7

Browse files
srtaalejzimeg
andauthored
fix: preserve path separators in create command argument (#526)
Co-authored-by: Eden Zimbelman <eden.zimbelman@salesforce.com>
1 parent 59d720d commit 02805c7

5 files changed

Lines changed: 171 additions & 52 deletions

File tree

cmd/project/create.go

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []
8989
ctx := cmd.Context()
9090

9191
// Get optional app name passed as an arg and check for category shortcuts
92-
appNameArg := ""
92+
appPathArg := ""
9393
categoryShortcut := ""
9494
templateFlagProvided := cmd.Flags().Changed("template")
9595
nameFlagProvided := cmd.Flags().Changed("name")
@@ -105,23 +105,17 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []
105105
categoryShortcut = "agent"
106106
// Check if a second argument was provided as the app name
107107
if len(args) > 1 {
108-
appNameArg = args[1]
108+
appPathArg = args[1]
109109
}
110110
} else {
111111
// When --template is provided, "agent" is the app name
112-
appNameArg = args[0]
112+
appPathArg = args[0]
113113
}
114114
default:
115-
appNameArg = args[0]
115+
appPathArg = args[0]
116116
}
117117
}
118118

119-
// --name flag overrides any positional app name argument
120-
// This allows users to name their app "agent" without triggering the AI Agent shortcut
121-
if nameFlagProvided {
122-
appNameArg = createAppNameFlag
123-
}
124-
125119
// List templates and exit early if the --list flag is set
126120
if createListFlag {
127121
return listTemplates(ctx, clients, categoryShortcut)
@@ -139,8 +133,20 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []
139133
return err
140134
}
141135

136+
// --name flag overrides the manifest display name but preserves any path
137+
// from the positional argument. When no positional arg is given (e.g.
138+
// "slack create --name APPPP"), the name flag also becomes the directory
139+
// path since there's nothing else to derive it from.
140+
displayName := ""
141+
if nameFlagProvided {
142+
displayName = createAppNameFlag
143+
if appPathArg == "" {
144+
appPathArg = createAppNameFlag
145+
}
146+
}
147+
142148
// Prompt for app name if not provided via flag or argument
143-
if appNameArg == "" {
149+
if appPathArg == "" {
144150
if clients.IO.IsTTY() {
145151
defaultName := generateRandomAppName()
146152
name, err := clients.IO.InputPrompt(ctx, "Name your app:", iostreams.InputPromptConfig{
@@ -150,12 +156,12 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []
150156
return err
151157
}
152158
if name != "" {
153-
appNameArg = name
159+
appPathArg = name
154160
} else {
155-
appNameArg = defaultName
161+
appPathArg = defaultName
156162
}
157163
} else {
158-
appNameArg = generateRandomAppName()
164+
appPathArg = generateRandomAppName()
159165
}
160166
}
161167

@@ -164,10 +170,11 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []
164170
subdir = template.GetSubdir()
165171
}
166172
createArgs := create.CreateArgs{
167-
AppName: appNameArg,
168-
Template: template,
169-
GitBranch: createGitBranchFlag,
170-
Subdir: subdir,
173+
AppPath: appPathArg,
174+
DisplayName: displayName,
175+
Template: template,
176+
GitBranch: createGitBranchFlag,
177+
Subdir: subdir,
171178
}
172179
clients.EventTracker.SetAppTemplate(template.GetTemplatePath())
173180

cmd/project/create_test.go

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ func TestCreateCommand(t *testing.T) {
7272
template, err := create.ResolveTemplateURL("slack-samples/bolt-js-starter-template")
7373
require.NoError(t, err)
7474
expected := create.CreateArgs{
75-
AppName: "my-app",
75+
AppPath: "my-app",
7676
Template: template,
7777
}
7878
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, expected)
@@ -109,7 +109,7 @@ func TestCreateCommand(t *testing.T) {
109109
template, err := create.ResolveTemplateURL("slack-samples/deno-starter-template")
110110
require.NoError(t, err)
111111
expected := create.CreateArgs{
112-
AppName: "my-deno-app",
112+
AppPath: "my-deno-app",
113113
Template: template,
114114
}
115115
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, expected)
@@ -156,7 +156,7 @@ func TestCreateCommand(t *testing.T) {
156156
require.NoError(t, err)
157157
template.SetSubdir("claude-agent-sdk")
158158
expected := create.CreateArgs{
159-
AppName: "my-agent",
159+
AppPath: "my-agent",
160160
Template: template,
161161
Subdir: "claude-agent-sdk",
162162
}
@@ -203,7 +203,7 @@ func TestCreateCommand(t *testing.T) {
203203
require.NoError(t, err)
204204
template.SetSubdir("claude-agent-sdk")
205205
expected := create.CreateArgs{
206-
AppName: "my-agent-app",
206+
AppPath: "my-agent-app",
207207
Template: template,
208208
Subdir: "claude-agent-sdk",
209209
}
@@ -234,7 +234,7 @@ func TestCreateCommand(t *testing.T) {
234234
require.NoError(t, err)
235235
template.SetSubdir("pydantic-ai")
236236
expected := create.CreateArgs{
237-
AppName: "my-pydantic-app",
237+
AppPath: "my-pydantic-app",
238238
Template: template,
239239
Subdir: "pydantic-ai",
240240
}
@@ -268,7 +268,7 @@ func TestCreateCommand(t *testing.T) {
268268
template, err := create.ResolveTemplateURL("slack-samples/bolt-js-starter-template")
269269
require.NoError(t, err)
270270
expected := create.CreateArgs{
271-
AppName: "agent",
271+
AppPath: "agent",
272272
Template: template,
273273
}
274274
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, expected)
@@ -304,8 +304,9 @@ func TestCreateCommand(t *testing.T) {
304304
template, err := create.ResolveTemplateURL("slack-samples/bolt-js-starter-template")
305305
require.NoError(t, err)
306306
expected := create.CreateArgs{
307-
AppName: "agent",
308-
Template: template,
307+
AppPath: "agent",
308+
DisplayName: "agent",
309+
Template: template,
309310
}
310311
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, expected)
311312
// Verify that category prompt WAS called (shortcut was not triggered)
@@ -351,9 +352,10 @@ func TestCreateCommand(t *testing.T) {
351352
require.NoError(t, err)
352353
template.SetSubdir("claude-agent-sdk")
353354
expected := create.CreateArgs{
354-
AppName: "my-custom-name", // --name flag overrides
355-
Template: template,
356-
Subdir: "claude-agent-sdk",
355+
AppPath: "my-custom-name", // --name flag used as path when no positional arg
356+
DisplayName: "my-custom-name",
357+
Template: template,
358+
Subdir: "claude-agent-sdk",
357359
}
358360
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, expected)
359361
// Verify that category prompt was NOT called (shortcut was triggered)
@@ -387,8 +389,9 @@ func TestCreateCommand(t *testing.T) {
387389
template, err := create.ResolveTemplateURL("slack-samples/bolt-js-starter-template")
388390
require.NoError(t, err)
389391
expected := create.CreateArgs{
390-
AppName: "my-name", // --name flag overrides "my-project" positional arg
391-
Template: template,
392+
AppPath: "my-project", // positional arg preserved as path
393+
DisplayName: "my-name", // --name flag sets manifest display name
394+
Template: template,
392395
}
393396
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, expected)
394397
// Verify that name prompt was NOT called since --name flag was provided
@@ -432,9 +435,10 @@ func TestCreateCommand(t *testing.T) {
432435
require.NoError(t, err)
433436
template.SetSubdir("claude-agent-sdk")
434437
expected := create.CreateArgs{
435-
AppName: "my-name", // --name flag overrides "my-project" positional arg
436-
Template: template,
437-
Subdir: "claude-agent-sdk",
438+
AppPath: "my-project", // positional arg preserved as path
439+
DisplayName: "my-name", // --name flag sets manifest display name
440+
Template: template,
441+
Subdir: "claude-agent-sdk",
438442
}
439443
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, expected)
440444
// Verify that category prompt was NOT called (agent shortcut was triggered)
@@ -505,7 +509,7 @@ func TestCreateCommand(t *testing.T) {
505509
cm.IO.AssertCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything)
506510
// When the user accepts the default (empty return), the generated name is used
507511
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.MatchedBy(func(args create.CreateArgs) bool {
508-
return args.AppName != ""
512+
return args.AppPath != ""
509513
}))
510514
},
511515
},
@@ -538,7 +542,7 @@ func TestCreateCommand(t *testing.T) {
538542
cm.IO.AssertNotCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything)
539543
// Should still call Create with a non-empty generated name
540544
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.MatchedBy(func(args create.CreateArgs) bool {
541-
return args.AppName != ""
545+
return args.AppPath != ""
542546
}))
543547
},
544548
},
@@ -569,7 +573,7 @@ func TestCreateCommand(t *testing.T) {
569573
template, err := create.ResolveTemplateURL("slack-samples/bolt-js-starter-template")
570574
require.NoError(t, err)
571575
expected := create.CreateArgs{
572-
AppName: "my-project",
576+
AppPath: "my-project",
573577
Template: template,
574578
}
575579
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, expected)
@@ -615,7 +619,7 @@ func TestCreateCommand(t *testing.T) {
615619
template, err := create.ResolveTemplateURL("slack-samples/bolt-js-starter-template")
616620
require.NoError(t, err)
617621
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.MatchedBy(func(args create.CreateArgs) bool {
618-
return args.AppName != "" && args.Template == template && args.Subdir == "apps/my-app"
622+
return args.AppPath != "" && args.Template == template && args.Subdir == "apps/my-app"
619623
}))
620624
},
621625
},

cmd/project/samples_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,14 @@ func TestSamplesCommand(t *testing.T) {
7373
)
7474
CreateFunc = func(ctx context.Context, clients *shared.ClientFactory, createArgs createPkg.CreateArgs) (appDirPath string, err error) {
7575
capturedArgs = createArgs
76-
return createArgs.AppName, nil
76+
return createArgs.AppPath, nil
7777
}
7878
},
7979
ExpectedOutputs: []string{
8080
"cd my-sample-app/",
8181
},
8282
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
83-
assert.Equal(t, "my-sample-app", capturedArgs.AppName)
83+
assert.Equal(t, "my-sample-app", capturedArgs.AppPath)
8484
assert.Equal(t, "slack-samples/deno-starter-template", capturedArgs.Template.GetTemplatePath())
8585
},
8686
},

internal/pkg/create/create.go

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,11 @@ var copyIgnoreFiles = []string{".DS_Store"}
5050

5151
// CreateArgs are the arguments passed into the Create function
5252
type CreateArgs struct {
53-
AppName string
54-
Template Template
55-
GitBranch string
56-
Subdir string
53+
AppPath string
54+
DisplayName string
55+
Template Template
56+
GitBranch string
57+
Subdir string
5758
}
5859

5960
// 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
6768
return "", slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess)
6869
}
6970

70-
// Get the app selection and accompanying app directory name (this may change when we find the unique directory name)
71-
appDirName, err := getAppDirName(createArgs.AppName)
71+
// Parse the app name input into a directory path and display name
72+
appPath, displayName, err := parseAppPath(createArgs.AppPath)
7273
if err != nil {
7374
return "", slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess)
7475
}
7576

77+
// --name flag overrides only the display name, preserving the path from the argument
78+
if createArgs.DisplayName != "" {
79+
displayName = createArgs.DisplayName
80+
}
81+
7682
// Get the project's full directory path
7783
projectDirPath := ""
78-
if filepath.IsLocal(appDirName) {
79-
projectDirPath = filepath.Join(workingDirPath, appDirName)
84+
if filepath.IsLocal(appPath) {
85+
projectDirPath = filepath.Join(workingDirPath, appPath)
8086
projectDirPath, err = getAvailableDir(ctx, projectDirPath)
8187
if err != nil {
8288
return "", slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess)
@@ -86,7 +92,7 @@ func Create(ctx context.Context, clients *shared.ClientFactory, createArgs Creat
8692
return "", slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess)
8793
}
8894
} else {
89-
projectDirPath = filepath.Join(appDirName)
95+
projectDirPath = filepath.Join(appPath)
9096
projectDirPath, err = getAvailableDir(ctx, projectDirPath)
9197
if err != nil {
9298
return "", slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess)
@@ -98,7 +104,7 @@ func Create(ctx context.Context, clients *shared.ClientFactory, createArgs Creat
98104
}
99105

100106
// Update the app's directory name now that the unique directory is created
101-
appDirName = filepath.Base(projectDirPath)
107+
appDirName := filepath.Base(projectDirPath)
102108

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

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

@@ -174,7 +180,7 @@ func getAppDirName(appName string) (string, error) {
174180
return "", fmt.Errorf("app name is required")
175181
}
176182

177-
// Normalize to kebab-case: lowercase, replace non-alphanumeric with dashes, collapse, and trim
183+
// Normalize to a variation of kebab-case: replace non-alphanumeric with dashes, collapse, and trim
178184
appName = strings.TrimSpace(appName)
179185
appName = strings.ToLower(appName)
180186
appName = nonAlphanumericRe.ReplaceAllString(appName, "-")
@@ -192,6 +198,29 @@ func getAppDirName(appName string) (string, error) {
192198
return appName, nil
193199
}
194200

201+
// parseAppPath splits user input into a directory path (with kebab-cased basename)
202+
// and a display name (the raw basename preserving original casing/spacing).
203+
func parseAppPath(input string) (appPath string, displayName string, err error) {
204+
input = strings.TrimSpace(input)
205+
if input == "" {
206+
return "", "", fmt.Errorf("app name is required")
207+
}
208+
209+
input = filepath.Clean(input)
210+
displayName = filepath.Base(input)
211+
pathPrefix := filepath.Dir(input)
212+
213+
dirName, err := getAppDirName(displayName)
214+
if err != nil {
215+
return "", "", err
216+
}
217+
218+
if pathPrefix == "." {
219+
return dirName, displayName, nil
220+
}
221+
return filepath.Join(pathPrefix, dirName), displayName, nil
222+
}
223+
195224
// getAvailableDir will return a unique directory path.
196225
// If dirPath already exists, then a unique numbered path will be appended to the path.
197226
func getAvailableDir(ctx context.Context, dirPath string) (string, error) {

0 commit comments

Comments
 (0)