Repository: slackapi/slack-cli
Pull request #92: feat: add 'app settings' command to open app settings webpage for a selected app
Author: login: zimeg
Labels: name: enhancement
description: M-T: A feature request for new functionality
name: changelog
description: Use on updates to be included in the release notes
name: semver:minor
description: Use on pull requests to describe the release version increment
Created: 2025-05-14T04:30:25Z | Merged: 2025-05-15T18:38:28Z

> **Changelog**
>
> The link between code of a project and configurations on app settings is now a commanded call with the latest `slack app settings` command.
>
> Bolt developers with a remote manifest source managed on app settings might find this useful for discovering new features or making updates otherwise.

### Summary

This PR adds the `app settings` command to open app settings for a selected app.

### Preview

<img width="645" alt="help" src="https://github.com/user-attachments/assets/08c3a501-060d-43e6-924b-f221f0af0344" />

https://github.com/user-attachments/assets/7c49b2c5-93d5-4d90-9fbe-b86415b2cb19

### Reviewers

Hello please feel free to test this with various apps, perhaps a:

- Bolt app **installed**
- Bolt app **uninstalled**
- Bolt project without apps
- Deno app (this errors)
- QA or `dev` instances

```
$ slack app settings
```

For the most adventurous, the `--app` flag can be attempted too.

### Notes

- ROSI apps are not supported in app settings at this time and will cause this command to **error**! The `--force` flag can be used to attempt this opening.

### Requirements

* [x] I've read and understood the [Contributing Guidelines](/blob/main/.github/contributing.md) and have done my best effort to follow them.
* [x] I've read and agree to the [Code of Conduct](https://slackhq.github.io/code-of-conduct).


Diff:
diff --git a/cmd/app/app.go b/cmd/app/app.go
index 8d442f1e..6db78f47 100644
--- a/cmd/app/app.go
+++ b/cmd/app/app.go
@@ -34,6 +34,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {
 			{Command: "app install", Meaning: "Install a production app to a team"},
 			{Command: "app link", Meaning: "Link an existing app to the project"},
 			{Command: "app list", Meaning: "List all teams with the app installed"},
+			{Command: "app settings", Meaning: "Open app settings in a web browser"},
 			{Command: "app uninstall", Meaning: "Uninstall an app from a team"},
 			{Command: "app delete", Meaning: "Delete an app and app info from a team"},
 		}),
@@ -66,6 +67,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {
 	cmd.AddCommand(NewDeleteCommand(clients))
 	cmd.AddCommand(NewLinkCommand(clients))
 	cmd.AddCommand(NewListCommand(clients))
+	cmd.AddCommand(NewSettingsCommand(clients))
 	cmd.AddCommand(NewUninstallCommand(clients))
 
 	return cmd
diff --git a/cmd/app/settings.go b/cmd/app/settings.go
new file mode 100644
index 00000000..38da92c7
--- /dev/null
+++ b/cmd/app/settings.go
@@ -0,0 +1,122 @@
+// Copyright 2022-2025 Salesforce, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/slackapi/slack-cli/internal/cmdutil"
+	"github.com/slackapi/slack-cli/internal/config"
+	"github.com/slackapi/slack-cli/internal/prompts"
+	"github.com/slackapi/slack-cli/internal/shared"
+	"github.com/slackapi/slack-cli/internal/slackerror"
+	"github.com/slackapi/slack-cli/internal/slacktrace"
+	"github.com/slackapi/slack-cli/internal/style"
+	"github.com/spf13/cobra"
+)
+
+var settingsAppSelectPromptFunc = prompts.AppSelectPrompt
+
+func NewSettingsCommand(clients *shared.ClientFactory) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "settings [flags]",
+		Short: "Open app settings for configurations",
+		Long: strings.Join([]string{
+			"Open app settings to configure an application in a web browser.",
+			"",
+			"Discovering new features and customizing an app manifest can be done from this",
+			fmt.Sprintf("web interface for apps with a \"%s\" manifest source.", config.ManifestSourceRemote.String()),
+			"",
+			"This command does not support apps deployed to Run on Slack infrastructure.",
+		}, "\n"),
+		Example: style.ExampleCommandsf([]style.ExampleCommand{
+			{
+				Meaning: "Open app settings for a prompted app",
+				Command: "app settings",
+			},
+			{
+				Meaning: "Open app settings for a specific app",
+				Command: "app settings --app A0123456789",
+			},
+		}),
+		Args: cobra.MaximumNArgs(0),
+		PreRunE: func(cmd *cobra.Command, args []string) error {
+			return appSettingsCommandPreRunE(clients, cmd, args)
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return appSettingsCommandRunE(clients, cmd, args)
+		},
+	}
+	return cmd
+}
+
+// appSettingsCommandPreRunE determines if the command can be run in a project
+func appSettingsCommandPreRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+	err := cmdutil.IsValidProjectDirectory(clients)
+	if err != nil {
+		return err
+	}
+	// Allow the force flag to ignore hosted apps and try to open app settings
+	if clients.Config.ForceFlag {
+		return nil
+	}
+	err = cmdutil.IsSlackHostedProject(ctx, clients)
+	if err != nil {
+		if slackerror.Is(err, slackerror.ErrAppNotHosted) {
+			return nil
+		} else {
+			return err
+		}
+	}
+	return slackerror.New(slackerror.ErrAppHosted).
+		WithDetails(slackerror.ErrorDetails{
+			{
+				Message: "App settings is not supported with Run on Slack infrastructure",
+			},
+		})
+}
+
+// appSettingsCommandRunE opens app settings in a browser for the selected app
+func appSettingsCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+	clients.IO.PrintTrace(ctx, slacktrace.AppSettingsStart)
+
+	app, err := settingsAppSelectPromptFunc(ctx, clients, prompts.ShowInstalledAndUninstalledApps)
+	if err != nil {
+		return err
+	}
+	host := clients.API().Host()
+	parsed, err := url.Parse(host)
+	if err != nil {
+		return err
+	}
+	parsed.Host = "api." + parsed.Host
+	settingsURL := fmt.Sprintf("%s/apps/%s", parsed.String(), app.App.AppID)
+
+	clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
+		Emoji: "house",
+		Text:  "App Settings",
+		Secondary: []string{
+			settingsURL,
+		},
+	}))
+	clients.Browser().OpenURL(settingsURL)
+
+	clients.IO.PrintTrace(ctx, slacktrace.AppSettingsSuccess, settingsURL)
+	return nil
+}
diff --git a/cmd/app/settings_test.go b/cmd/app/settings_test.go
new file mode 100644
index 00000000..d1203589
--- /dev/null
+++ b/cmd/app/settings_test.go
@@ -0,0 +1,199 @@
+// Copyright 2022-2025 Salesforce, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"context"
+	"testing"
+
+	"github.com/slackapi/slack-cli/internal/app"
+	"github.com/slackapi/slack-cli/internal/config"
+	"github.com/slackapi/slack-cli/internal/prompts"
+	"github.com/slackapi/slack-cli/internal/shared"
+	"github.com/slackapi/slack-cli/internal/shared/types"
+	"github.com/slackapi/slack-cli/internal/slackerror"
+	"github.com/slackapi/slack-cli/internal/slacktrace"
+	"github.com/slackapi/slack-cli/test/testutil"
+	"github.com/spf13/cobra"
+	"github.com/stretchr/testify/mock"
+)
+
+func Test_App_SettingsCommand(t *testing.T) {
+	testutil.TableTestCommand(t, testutil.CommandTests{
+		"requires a valid project directory": {
+			ExpectedError: slackerror.New(slackerror.ErrInvalidAppDirectory),
+		},
+		"errors for rosi applications": {
+			Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
+				cf.SDKConfig.WorkingDirectory = "."
+				projectConfigMock := config.NewProjectConfigMock()
+				projectConfigMock.On(
+					"GetManifestSource",
+					mock.Anything,
+				).Return(
+					config.ManifestSourceLocal,
+					nil,
+				)
+				cm.Config.ProjectConfig = projectConfigMock
+				manifestMock := &app.ManifestMockObject{}
+				manifestMock.On(
+					"GetManifestLocal",
+					mock.Anything,
+					mock.Anything,
+					mock.Anything,
+				).Return(
+					types.SlackYaml{
+						AppManifest: types.AppManifest{
+							Settings: &types.AppSettings{FunctionRuntime: types.SlackHosted},
+						},
+					},
+					nil,
+				)
+				cm.AppClient.Manifest = manifestMock
+			},
+			ExpectedError: slackerror.New(slackerror.ErrAppHosted),
+		},
+		"opens a rosi application with the force flag": {
+			CmdArgs: []string{"--force"},
+			Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
+				cf.SDKConfig.WorkingDirectory = "."
+				projectConfigMock := config.NewProjectConfigMock()
+				projectConfigMock.On(
+					"GetManifestSource",
+					mock.Anything,
+				).Return(
+					config.ManifestSourceLocal,
+					nil,
+				)
+				cm.Config.ProjectConfig = projectConfigMock
+				manifestMock := &app.ManifestMockObject{}
+				manifestMock.On(
+					"GetManifestLocal",
+					mock.Anything,
+					mock.Anything,
+					mock.Anything,
+				).Return(
+					types.SlackYaml{
+						AppManifest: types.AppManifest{
+							Settings: &types.AppSettings{FunctionRuntime: types.SlackHosted},
+						},
+					},
+					nil,
+				)
+				cm.AppClient.Manifest = manifestMock
+				appSelectMock := prompts.NewAppSelectMock()
+				appSelectMock.On(
+					"AppSelectPrompt",
+				).Return(
+					prompts.SelectedApp{App: types.App{AppID: "A0101010101"}},
+					nil,
+				)
+				settingsAppSelectPromptFunc = appSelectMock.AppSelectPrompt
+			},
+			ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
+				expectedURL := "https://api.slack.com/apps/A0101010101"
+				cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
+				cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.AppSettingsStart, mock.Anything)
+				cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.AppSettingsSuccess, []string{expectedURL})
+			},
+		},
+		"requires an existing application": {
+			Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
+				cf.SDKConfig.WorkingDirectory = "."
+				projectConfigMock := config.NewProjectConfigMock()
+				projectConfigMock.On(
+					"GetManifestSource",
+					mock.Anything,
+				).Return(
+					config.ManifestSourceRemote,
+					nil,
+				)
+				cm.Config.ProjectConfig = projectConfigMock
+				appSelectMock := prompts.NewAppSelectMock()
+				appSelectMock.On(
+					"AppSelectPrompt",
+				).Return(
+					prompts.SelectedApp{},
+					slackerror.New(slackerror.ErrInstallationRequired),
+				)
+				settingsAppSelectPromptFunc = appSelectMock.AppSelectPrompt
+			},
+			ExpectedError: slackerror.New(slackerror.ErrInstallationRequired),
+		},
+		"opens the url to app settings of an app in production": {
+			Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
+				cf.SDKConfig.WorkingDirectory = "."
+				projectConfigMock := config.NewProjectConfigMock()
+				projectConfigMock.On(
+					"GetManifestSource",
+					mock.Anything,
+				).Return(
+					config.ManifestSourceRemote,
+					nil,
+				)
+				cm.Config.ProjectConfig = projectConfigMock
+				appSelectMock := prompts.NewAppSelectMock()
+				appSelectMock.On(
+					"AppSelectPrompt",
+				).Return(
+					prompts.SelectedApp{App: types.App{AppID: "A0123456789"}},
+					nil,
+				)
+				settingsAppSelectPromptFunc = appSelectMock.AppSelectPrompt
+			},
+			ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
+				expectedURL := "https://api.slack.com/apps/A0123456789"
+				cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
+				cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.AppSettingsStart, mock.Anything)
+				cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.AppSettingsSuccess, []string{expectedURL})
+			},
+		},
+		"opens the url to app settings of an app in development": {
+			Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
+				host := "https://dev1234.slack.com"
+				cf.SDKConfig.WorkingDirectory = "."
+				projectConfigMock := config.NewProjectConfigMock()
+				projectConfigMock.On(
+					"GetManifestSource",
+					mock.Anything,
+				).Return(
+					config.ManifestSourceRemote,
+					nil,
+				)
+				cm.Config.ProjectConfig = projectConfigMock
+				appSelectMock := prompts.NewAppSelectMock()
+				appSelectMock.On(
+					"AppSelectPrompt",
+				).Return(
+					prompts.SelectedApp{
+						App:  types.App{AppID: "A0123456789"},
+						Auth: types.SlackAuth{APIHost: &host},
+					},
+					nil,
+				)
+				settingsAppSelectPromptFunc = appSelectMock.AppSelectPrompt
+				cm.API.On("Host").Return(host) // SetHost is implemented in AppSelectPrompt
+			},
+			ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
+				expectedURL := "https://api.dev1234.slack.com/apps/A0123456789"
+				cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
+				cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.AppSettingsStart, mock.Anything)
+				cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.AppSettingsSuccess, []string{expectedURL})
+			},
+		},
+	}, func(cf *shared.ClientFactory) *cobra.Command {
+		return NewSettingsCommand(cf)
+	})
+}
diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go
index a1061369..869e4c3e 100644
--- a/internal/slackerror/errors.go
+++ b/internal/slackerror/errors.go
@@ -42,6 +42,7 @@ const (
 	ErrAppExists                                     = "app_add_exists"
 	ErrAppFlagRequired                               = "app_flag_required"
 	ErrAppFound                                      = "app_found"
+	ErrAppHosted                                     = "app_hosted"
 	ErrAppInstall                                    = "app_install_error"
 	ErrAppManifestAccess                             = "app_manifest_access_error"
 	ErrAppManifestCreate                             = "app_manifest_create_error"
@@ -381,6 +382,11 @@ Otherwise start your app for local development with: %s`,
 		Message: "An app was found",
 	},
 
+	ErrAppHosted: {
+		Code:    ErrAppHosted,
+		Message: "App is configured for Run on Slack infrastructure",
+	},
+
 	ErrAppInstall: {
 		Code:    ErrAppInstall,
 		Message: "Couldn't install your app to a workspace",
diff --git a/internal/slacktrace/slacktrace.go b/internal/slacktrace/slacktrace.go
index 816b9218..cc7c777c 100644
--- a/internal/slacktrace/slacktrace.go
+++ b/internal/slacktrace/slacktrace.go
@@ -44,6 +44,8 @@ const (
 	AdminAppApprovalRequestRequired        = "SLACK_TRACE_ADMIN_APPROVAL_REQUIRED"
 	AppLinkStart                           = "SLACK_TRACE_APP_LINK_START"
 	AppLinkSuccess                         = "SLACK_TRACE_APP_LINK_SUCCESS"
+	AppSettingsStart                       = "SLACK_TRACE_APP_SETTINGS_START"
+	AppSettingsSuccess                     = "SLACK_TRACE_APP_SETTINGS_SUCCESS"
 	AuthListCount                          = "SLACK_TRACE_AUTH_LIST_COUNT"
 	AuthListInfo                           = "SLACK_TRACE_AUTH_LIST_INFO"
 	AuthListSuccess                        = "SLACK_TRACE_AUTH_LIST_SUCCESS"


Reviews:
user: login: mwbrooks
body: ✅ Amazing work @zimeg 🎉 

🧪 Manual testing is looking great and you've got very high test coverage on this command 📈 

📝 I left a few minor suggestions that you can take, leave, or riff with. I've also left a lengthy suggestion to follow-up with a 2nd PR that attempts to introduce a ~best~ better practice for our global `xyzFunc` swaps.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-15T04:16:24Z
user: login: zimeg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T17:27:05Z
user: login: zimeg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T17:29:33Z
user: login: mwbrooks
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T17:37:50Z
user: login: zimeg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T17:38:05Z
user: login: zimeg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T17:40:19Z
user: login: zimeg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T17:45:48Z
user: login: zimeg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T17:46:20Z
user: login: zimeg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T17:59:52Z
user: login: zimeg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T18:37:44Z
user: login: mwbrooks
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T02:55:17Z
user: login: mwbrooks
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T02:56:34Z
user: login: mwbrooks
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T02:58:25Z

Review comments:
diff_hunk: @@ -34,6 +34,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {
 			{Command: "install", Meaning: "Install a production app to a team"},
 			{Command: "link", Meaning: "Link an existing app to the project"},
 			{Command: "list", Meaning: "List all teams with the app installed"},
+			{Command: "app settings", Meaning: "Open app settings in a web browser"},
path: cmd/app/app.go
user: login: mwbrooks
body: note: Foreseeing a future of alphabetically ordered examples prefixed with `app` 🔮 
created_at: 2025-05-15T03:33:46Z
author_association: MEMBER
diff_hunk: @@ -42,6 +42,7 @@ const (
 	ErrAppExists                                     = "app_add_exists"
 	ErrAppFlagRequired                               = "app_flag_required"
 	ErrAppFound                                      = "app_found"
+	ErrAppHosted                                     = "app_hosted"
path: internal/slackerror/errors.go
user: login: mwbrooks
body: note: I can see this being a useful error for us in the future!
created_at: 2025-05-15T03:34:45Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+// Copyright 2022-2025 Salesforce, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/slackapi/slack-cli/internal/cmdutil"
+	"github.com/slackapi/slack-cli/internal/config"
+	"github.com/slackapi/slack-cli/internal/prompts"
+	"github.com/slackapi/slack-cli/internal/shared"
+	"github.com/slackapi/slack-cli/internal/slackerror"
+	"github.com/slackapi/slack-cli/internal/slacktrace"
+	"github.com/slackapi/slack-cli/internal/style"
+	"github.com/spf13/cobra"
+)
+
+var settingsAppSelectPromptFunc = prompts.AppSelectPrompt
+
+func NewSettingsCommand(clients *shared.ClientFactory) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "settings [flags]",
+		Short: "Open app settings for configurations",
+		Long: strings.Join([]string{
+			"Open app settings to configure an application in a web browser.",
+			"",
+			"Discovering new features and customizing an app manifest can be done from this",
+			fmt.Sprintf("intuitive interface for apps with a \"%s\" manifest source.", config.ManifestSourceRemote.String()),
path: cmd/app/settings.go
user: login: mwbrooks
body: suggestion: Rather than "intuitive" what about "web"? Web implies a UI, which is usually more intuitive, and feels a little less market-y.

```suggestion
			fmt.Sprintf("web interface for apps with a \"%s\" manifest source.", config.ManifestSourceRemote.String()),
```

<img width="762" alt="image" src="https://github.com/user-attachments/assets/45b3681f-601f-493f-8654-bbea385f98ef" />

created_at: 2025-05-15T03:39:30Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+// Copyright 2022-2025 Salesforce, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/slackapi/slack-cli/internal/cmdutil"
+	"github.com/slackapi/slack-cli/internal/config"
+	"github.com/slackapi/slack-cli/internal/prompts"
+	"github.com/slackapi/slack-cli/internal/shared"
+	"github.com/slackapi/slack-cli/internal/slackerror"
+	"github.com/slackapi/slack-cli/internal/slacktrace"
+	"github.com/slackapi/slack-cli/internal/style"
+	"github.com/spf13/cobra"
+)
+
+var settingsAppSelectPromptFunc = prompts.AppSelectPrompt
path: cmd/app/settings.go
user: login: mwbrooks
body: thought: **[Non-Blocker]** I'd like us to use this new command as a way to nudge out best practices for maintainability and testability. While we use the `xyzFunc` pattern for testability, it always feels like a hack.

I think the better approach would be dependency injection: `NewSettingsCommand(clients, prompts.AppSelectPrompt)`.

But, how do we design this to scale for all commands and `xyzFunc` workarounds?

Maybe we use `clients`? This would remove the global swap-a-roo and isolate it to the instance of clients.

```
// clients.go
clients.AppSelectPrompt = prompts.AppSelectPrompt

// clients_mock.go
clients.AppSelectPrompt = appSelectMock.AppSelectPrompt
```

I think this is out-of-scope of this PR, but perhaps we can follow-up with some approach in a future PR? We can take some time to see if it helps address other `xyzFunc` use-cases as well.
created_at: 2025-05-15T04:01:51Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+// Copyright 2022-2025 Salesforce, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/slackapi/slack-cli/internal/cmdutil"
+	"github.com/slackapi/slack-cli/internal/config"
+	"github.com/slackapi/slack-cli/internal/prompts"
+	"github.com/slackapi/slack-cli/internal/shared"
+	"github.com/slackapi/slack-cli/internal/slackerror"
+	"github.com/slackapi/slack-cli/internal/slacktrace"
+	"github.com/slackapi/slack-cli/internal/style"
+	"github.com/spf13/cobra"
+)
+
+var settingsAppSelectPromptFunc = prompts.AppSelectPrompt
+
+func NewSettingsCommand(clients *shared.ClientFactory) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "settings [flags]",
+		Short: "Open app settings for configurations",
+		Long: strings.Join([]string{
+			"Open app settings to configure an application in a web browser.",
+			"",
+			"Discovering new features and customizing an app manifest can be done from this",
+			fmt.Sprintf("intuitive interface for apps with a \"%s\" manifest source.", config.ManifestSourceRemote.String()),
+			"",
+			"This command does not support apps deployed to Run on Slack infrastructure.",
+		}, "\n"),
+		Example: style.ExampleCommandsf([]style.ExampleCommand{
+			{
+				Meaning: "Open app settings for a prompted app",
+				Command: "app settings",
+			},
+			{
+				Meaning: "Open app settings for a specific app",
+				Command: "app settings --app A0123456789",
+			},
+		}),
+		Args: cobra.MaximumNArgs(0),
+		PreRunE: func(cmd *cobra.Command, args []string) error {
+			return appSettingsCommandPreRunE(clients, cmd, args)
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return appSettingsCommandRunE(clients, cmd, args)
+		},
+	}
+	return cmd
+}
+
+// appSettingsCommandPreRunE determines if the command can be run in a project
+func appSettingsCommandPreRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+	err := cmdutil.IsValidProjectDirectory(clients)
+	if err != nil {
+		return err
+	}
+	if clients.Config.ForceFlag {
+		return nil
+	}
path: cmd/app/settings.go
user: login: mwbrooks
body: suggestion: It might be worth adding a comment here to help other maintainers know why we return early when the `--force` flag is provided 🤔 
```suggestion
	// Allow the force flag to ignore hosted apps and try to open app settings
	if clients.Config.ForceFlag {
		return nil
	}
```
created_at: 2025-05-15T04:04:27Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+// Copyright 2022-2025 Salesforce, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/slackapi/slack-cli/internal/cmdutil"
+	"github.com/slackapi/slack-cli/internal/config"
+	"github.com/slackapi/slack-cli/internal/prompts"
+	"github.com/slackapi/slack-cli/internal/shared"
+	"github.com/slackapi/slack-cli/internal/slackerror"
+	"github.com/slackapi/slack-cli/internal/slacktrace"
+	"github.com/slackapi/slack-cli/internal/style"
+	"github.com/spf13/cobra"
+)
+
+var settingsAppSelectPromptFunc = prompts.AppSelectPrompt
+
+func NewSettingsCommand(clients *shared.ClientFactory) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "settings [flags]",
+		Short: "Open app settings for configurations",
+		Long: strings.Join([]string{
+			"Open app settings to configure an application in a web browser.",
+			"",
+			"Discovering new features and customizing an app manifest can be done from this",
+			fmt.Sprintf("intuitive interface for apps with a \"%s\" manifest source.", config.ManifestSourceRemote.String()),
+			"",
+			"This command does not support apps deployed to Run on Slack infrastructure.",
+		}, "\n"),
+		Example: style.ExampleCommandsf([]style.ExampleCommand{
+			{
+				Meaning: "Open app settings for a prompted app",
+				Command: "app settings",
+			},
+			{
+				Meaning: "Open app settings for a specific app",
+				Command: "app settings --app A0123456789",
+			},
+		}),
+		Args: cobra.MaximumNArgs(0),
+		PreRunE: func(cmd *cobra.Command, args []string) error {
+			return appSettingsCommandPreRunE(clients, cmd, args)
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return appSettingsCommandRunE(clients, cmd, args)
+		},
+	}
+	return cmd
+}
+
+// appSettingsCommandPreRunE determines if the command can be run in a project
+func appSettingsCommandPreRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+	err := cmdutil.IsValidProjectDirectory(clients)
+	if err != nil {
+		return err
+	}
+	if clients.Config.ForceFlag {
+		return nil
+	}
+	err = cmdutil.IsSlackHostedProject(ctx, clients)
+	if err != nil {
+		if slackerror.Is(err, slackerror.ErrAppNotHosted) {
+			return nil
+		} else {
+			return err
+		}
+	}
+	return slackerror.New(slackerror.ErrAppHosted).
+		WithDetails(slackerror.ErrorDetails{
+			{
+				Message: "App settings is not supported with Run on Slack infrastructure",
+			},
+		})
+}
+
+// appSettingsCommandRunE opens app settings in a browser for the selected app
+func appSettingsCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+	clients.IO.PrintTrace(ctx, slacktrace.AppSettingsStart)
+
+	app, err := settingsAppSelectPromptFunc(ctx, clients, prompts.ShowInstalledAndUninstalledApps)
+	if err != nil {
+		return err
+	}
+	host := clients.API().Host()
+	parsed, err := url.Parse(host)
+	if err != nil {
+		return err
+	}
+	parsed.Host = "api." + parsed.Host
+	settingsURL := fmt.Sprintf("%s/apps/%s", parsed.String(), app.App.AppID)
path: cmd/app/settings.go
user: login: mwbrooks
body: praise: Very nice work 👏🏻 
created_at: 2025-05-15T04:06:49Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+// Copyright 2022-2025 Salesforce, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/slackapi/slack-cli/internal/cmdutil"
+	"github.com/slackapi/slack-cli/internal/config"
+	"github.com/slackapi/slack-cli/internal/prompts"
+	"github.com/slackapi/slack-cli/internal/shared"
+	"github.com/slackapi/slack-cli/internal/slackerror"
+	"github.com/slackapi/slack-cli/internal/slacktrace"
+	"github.com/slackapi/slack-cli/internal/style"
+	"github.com/spf13/cobra"
+)
+
+var settingsAppSelectPromptFunc = prompts.AppSelectPrompt
+
+func NewSettingsCommand(clients *shared.ClientFactory) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "settings [flags]",
+		Short: "Open app settings for configurations",
+		Long: strings.Join([]string{
+			"Open app settings to configure an application in a web browser.",
+			"",
+			"Discovering new features and customizing an app manifest can be done from this",
+			fmt.Sprintf("intuitive interface for apps with a \"%s\" manifest source.", config.ManifestSourceRemote.String()),
+			"",
+			"This command does not support apps deployed to Run on Slack infrastructure.",
+		}, "\n"),
+		Example: style.ExampleCommandsf([]style.ExampleCommand{
+			{
+				Meaning: "Open app settings for a prompted app",
+				Command: "app settings",
+			},
+			{
+				Meaning: "Open app settings for a specific app",
+				Command: "app settings --app A0123456789",
+			},
+		}),
+		Args: cobra.MaximumNArgs(0),
+		PreRunE: func(cmd *cobra.Command, args []string) error {
+			return appSettingsCommandPreRunE(clients, cmd, args)
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return appSettingsCommandRunE(clients, cmd, args)
+		},
+	}
+	return cmd
+}
+
+// appSettingsCommandPreRunE determines if the command can be run in a project
+func appSettingsCommandPreRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+	err := cmdutil.IsValidProjectDirectory(clients)
+	if err != nil {
+		return err
+	}
+	if clients.Config.ForceFlag {
+		return nil
+	}
+	err = cmdutil.IsSlackHostedProject(ctx, clients)
+	if err != nil {
+		if slackerror.Is(err, slackerror.ErrAppNotHosted) {
+			return nil
+		} else {
+			return err
+		}
+	}
+	return slackerror.New(slackerror.ErrAppHosted).
+		WithDetails(slackerror.ErrorDetails{
+			{
+				Message: "App settings is not supported with Run on Slack infrastructure",
+			},
+		})
+}
+
+// appSettingsCommandRunE opens app settings in a browser for the selected app
+func appSettingsCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+	clients.IO.PrintTrace(ctx, slacktrace.AppSettingsStart)
+
+	app, err := settingsAppSelectPromptFunc(ctx, clients, prompts.ShowInstalledAndUninstalledApps)
+	if err != nil {
+		return err
+	}
+	host := clients.API().Host()
+	parsed, err := url.Parse(host)
+	if err != nil {
+		return err
+	}
+	parsed.Host = "api." + parsed.Host
+	settingsURL := fmt.Sprintf("%s/apps/%s", parsed.String(), app.App.AppID)
+
+	clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
+		Emoji: "house",
+		Text:  "App Settings",
+		Secondary: []string{
+			settingsURL,
+		},
path: cmd/app/settings.go
user: login: mwbrooks
body: suggestion: What do you think about adding output explaining the action being taken (opening the web browser)?
```suggestion
		Secondary: []string{
			"Opening default web browser:",
			settingsURL,
		},
```

☝🏻 You might have better wording than that.
created_at: 2025-05-15T04:11:30Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+// Copyright 2022-2025 Salesforce, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/slackapi/slack-cli/internal/cmdutil"
+	"github.com/slackapi/slack-cli/internal/config"
+	"github.com/slackapi/slack-cli/internal/prompts"
+	"github.com/slackapi/slack-cli/internal/shared"
+	"github.com/slackapi/slack-cli/internal/slackerror"
+	"github.com/slackapi/slack-cli/internal/slacktrace"
+	"github.com/slackapi/slack-cli/internal/style"
+	"github.com/spf13/cobra"
+)
+
+var settingsAppSelectPromptFunc = prompts.AppSelectPrompt
+
+func NewSettingsCommand(clients *shared.ClientFactory) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "settings [flags]",
+		Short: "Open app settings for configurations",
+		Long: strings.Join([]string{
+			"Open app settings to configure an application in a web browser.",
+			"",
+			"Discovering new features and customizing an app manifest can be done from this",
+			fmt.Sprintf("intuitive interface for apps with a \"%s\" manifest source.", config.ManifestSourceRemote.String()),
+			"",
+			"This command does not support apps deployed to Run on Slack infrastructure.",
+		}, "\n"),
+		Example: style.ExampleCommandsf([]style.ExampleCommand{
+			{
+				Meaning: "Open app settings for a prompted app",
+				Command: "app settings",
+			},
+			{
+				Meaning: "Open app settings for a specific app",
+				Command: "app settings --app A0123456789",
+			},
+		}),
+		Args: cobra.MaximumNArgs(0),
+		PreRunE: func(cmd *cobra.Command, args []string) error {
+			return appSettingsCommandPreRunE(clients, cmd, args)
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return appSettingsCommandRunE(clients, cmd, args)
+		},
+	}
+	return cmd
+}
+
+// appSettingsCommandPreRunE determines if the command can be run in a project
+func appSettingsCommandPreRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+	err := cmdutil.IsValidProjectDirectory(clients)
+	if err != nil {
+		return err
+	}
+	if clients.Config.ForceFlag {
+		return nil
+	}
+	err = cmdutil.IsSlackHostedProject(ctx, clients)
+	if err != nil {
+		if slackerror.Is(err, slackerror.ErrAppNotHosted) {
+			return nil
+		} else {
+			return err
+		}
+	}
+	return slackerror.New(slackerror.ErrAppHosted).
+		WithDetails(slackerror.ErrorDetails{
+			{
+				Message: "App settings is not supported with Run on Slack infrastructure",
+			},
+		})
+}
+
+// appSettingsCommandRunE opens app settings in a browser for the selected app
+func appSettingsCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+	clients.IO.PrintTrace(ctx, slacktrace.AppSettingsStart)
+
+	app, err := settingsAppSelectPromptFunc(ctx, clients, prompts.ShowInstalledAndUninstalledApps)
+	if err != nil {
+		return err
+	}
+	host := clients.API().Host()
+	parsed, err := url.Parse(host)
+	if err != nil {
+		return err
+	}
+	parsed.Host = "api." + parsed.Host
+	settingsURL := fmt.Sprintf("%s/apps/%s", parsed.String(), app.App.AppID)
+
+	clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
+		Emoji: "house",
+		Text:  "App Settings",
+		Secondary: []string{
+			settingsURL,
+		},
+	}))
+	clients.Browser().OpenURL(settingsURL)
+
+	clients.IO.PrintTrace(ctx, slacktrace.AppSettingsSuccess, settingsURL)
path: cmd/app/settings.go
user: login: mwbrooks
body: praise: heh, you read my mind to add `settingsURL` 😉 
created_at: 2025-05-15T04:12:10Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,155 @@
+// Copyright 2022-2025 Salesforce, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"context"
+	"testing"
+
+	"github.com/slackapi/slack-cli/internal/app"
+	"github.com/slackapi/slack-cli/internal/config"
+	"github.com/slackapi/slack-cli/internal/prompts"
+	"github.com/slackapi/slack-cli/internal/shared"
+	"github.com/slackapi/slack-cli/internal/shared/types"
+	"github.com/slackapi/slack-cli/internal/slackerror"
+	"github.com/slackapi/slack-cli/internal/slacktrace"
+	"github.com/slackapi/slack-cli/test/testutil"
+	"github.com/spf13/cobra"
+	"github.com/stretchr/testify/mock"
+)
+
+func Test_App_SettingsCommand(t *testing.T) {
+	testutil.TableTestCommand(t, testutil.CommandTests{
+		"requires a valid project directory": {
+			ExpectedError: slackerror.New(slackerror.ErrInvalidAppDirectory),
+		},
+		"errors for rosi applications": {
+			Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
+				cf.SDKConfig.WorkingDirectory = "."
+				projectConfigMock := config.NewProjectConfigMock()
+				projectConfigMock.On(
+					"GetManifestSource",
+					mock.Anything,
+				).Return(
+					config.ManifestSourceLocal,
+					nil,
+				)
+				cm.Config.ProjectConfig = projectConfigMock
+				manifestMock := &app.ManifestMockObject{}
+				manifestMock.On(
+					"GetManifestLocal",
+					mock.Anything,
+					mock.Anything,
+					mock.Anything,
+				).Return(
+					types.SlackYaml{
+						AppManifest: types.AppManifest{
+							Settings: &types.AppSettings{FunctionRuntime: types.SlackHosted},
+						},
+					},
+					nil,
+				)
+				cm.AppClient.Manifest = manifestMock
+			},
+			ExpectedError: slackerror.New(slackerror.ErrAppHosted),
+		},
+		"requires an existing application": {
path: cmd/app/settings_test.go
user: login: mwbrooks
body: note: Nice, I tested this manually and I'm happy to see you already added a test!
created_at: 2025-05-15T04:13:46Z
author_association: MEMBER
diff_hunk: @@ -34,6 +34,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {
 			{Command: "install", Meaning: "Install a production app to a team"},
 			{Command: "link", Meaning: "Link an existing app to the project"},
 			{Command: "list", Meaning: "List all teams with the app installed"},
+			{Command: "app settings", Meaning: "Open app settings in a web browser"},
path: cmd/app/app.go
user: login: zimeg
body: As much as I appreicate alphabetics I was thinking these examples were ordered with expected steps in mind?

To me this tells an interesting story:

1. `install`
2. `link`
3. `list`
4. `settings`
5. `uninstall`
6. `delete`

Which is almost in alphabetics I realize now! So perhaps following "install" with "delete" isn't so wild 👾 
created_at: 2025-05-15T17:27:05Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+// Copyright 2022-2025 Salesforce, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/slackapi/slack-cli/internal/cmdutil"
+	"github.com/slackapi/slack-cli/internal/config"
+	"github.com/slackapi/slack-cli/internal/prompts"
+	"github.com/slackapi/slack-cli/internal/shared"
+	"github.com/slackapi/slack-cli/internal/slackerror"
+	"github.com/slackapi/slack-cli/internal/slacktrace"
+	"github.com/slackapi/slack-cli/internal/style"
+	"github.com/spf13/cobra"
+)
+
+var settingsAppSelectPromptFunc = prompts.AppSelectPrompt
+
+func NewSettingsCommand(clients *shared.ClientFactory) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "settings [flags]",
+		Short: "Open app settings for configurations",
+		Long: strings.Join([]string{
+			"Open app settings to configure an application in a web browser.",
+			"",
+			"Discovering new features and customizing an app manifest can be done from this",
+			fmt.Sprintf("intuitive interface for apps with a \"%s\" manifest source.", config.ManifestSourceRemote.String()),
path: cmd/app/settings.go
user: login: zimeg
body: @mwbrooks Good catch as always 🙏 ✨ 

For some reason I thought the "I" in "UI" stood for intuitive but let's let it mean "interface" here. It makes the double "u" more meaningful for web as well 😉 
created_at: 2025-05-15T17:29:33Z
author_association: MEMBER
diff_hunk: @@ -34,6 +34,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {
 			{Command: "install", Meaning: "Install a production app to a team"},
 			{Command: "link", Meaning: "Link an existing app to the project"},
 			{Command: "list", Meaning: "List all teams with the app installed"},
+			{Command: "app settings", Meaning: "Open app settings in a web browser"},
path: cmd/app/app.go
user: login: mwbrooks
body: Ahhhh, that's alright as well. For now, the order flows well and it's easy to see where `unlink` & `update` would land.
created_at: 2025-05-15T17:37:50Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+// Copyright 2022-2025 Salesforce, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/slackapi/slack-cli/internal/cmdutil"
+	"github.com/slackapi/slack-cli/internal/config"
+	"github.com/slackapi/slack-cli/internal/prompts"
+	"github.com/slackapi/slack-cli/internal/shared"
+	"github.com/slackapi/slack-cli/internal/slackerror"
+	"github.com/slackapi/slack-cli/internal/slacktrace"
+	"github.com/slackapi/slack-cli/internal/style"
+	"github.com/spf13/cobra"
+)
+
+var settingsAppSelectPromptFunc = prompts.AppSelectPrompt
+
+func NewSettingsCommand(clients *shared.ClientFactory) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "settings [flags]",
+		Short: "Open app settings for configurations",
+		Long: strings.Join([]string{
+			"Open app settings to configure an application in a web browser.",
+			"",
+			"Discovering new features and customizing an app manifest can be done from this",
+			fmt.Sprintf("intuitive interface for apps with a \"%s\" manifest source.", config.ManifestSourceRemote.String()),
+			"",
+			"This command does not support apps deployed to Run on Slack infrastructure.",
+		}, "\n"),
+		Example: style.ExampleCommandsf([]style.ExampleCommand{
+			{
+				Meaning: "Open app settings for a prompted app",
+				Command: "app settings",
+			},
+			{
+				Meaning: "Open app settings for a specific app",
+				Command: "app settings --app A0123456789",
+			},
+		}),
+		Args: cobra.MaximumNArgs(0),
+		PreRunE: func(cmd *cobra.Command, args []string) error {
+			return appSettingsCommandPreRunE(clients, cmd, args)
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return appSettingsCommandRunE(clients, cmd, args)
+		},
+	}
+	return cmd
+}
+
+// appSettingsCommandPreRunE determines if the command can be run in a project
+func appSettingsCommandPreRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+	err := cmdutil.IsValidProjectDirectory(clients)
+	if err != nil {
+		return err
+	}
+	if clients.Config.ForceFlag {
+		return nil
+	}
path: cmd/app/settings.go
user: login: zimeg
body: @mwbrooks Sounds good! I agree the placement is otherwise left to interpretation 📚 

I noticed too a test for this case is missing which doesn't seem ideal to me since this is not an erroring path that we support. Albeit the `--force` flag might make this more opinion, but let's avoid regressions with another test 🔍 
created_at: 2025-05-15T17:38:04Z
author_association: MEMBER
diff_hunk: @@ -34,6 +34,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {
 			{Command: "install", Meaning: "Install a production app to a team"},
 			{Command: "link", Meaning: "Link an existing app to the project"},
 			{Command: "list", Meaning: "List all teams with the app installed"},
+			{Command: "app settings", Meaning: "Open app settings in a web browser"},
path: cmd/app/app.go
user: login: zimeg
body: When `unlink` lands we should revisit this. Background thoughts are now nudging me towards preferring the more standard order 😳 
created_at: 2025-05-15T17:40:19Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+// Copyright 2022-2025 Salesforce, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/slackapi/slack-cli/internal/cmdutil"
+	"github.com/slackapi/slack-cli/internal/config"
+	"github.com/slackapi/slack-cli/internal/prompts"
+	"github.com/slackapi/slack-cli/internal/shared"
+	"github.com/slackapi/slack-cli/internal/slackerror"
+	"github.com/slackapi/slack-cli/internal/slacktrace"
+	"github.com/slackapi/slack-cli/internal/style"
+	"github.com/spf13/cobra"
+)
+
+var settingsAppSelectPromptFunc = prompts.AppSelectPrompt
+
+func NewSettingsCommand(clients *shared.ClientFactory) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "settings [flags]",
+		Short: "Open app settings for configurations",
+		Long: strings.Join([]string{
+			"Open app settings to configure an application in a web browser.",
+			"",
+			"Discovering new features and customizing an app manifest can be done from this",
+			fmt.Sprintf("intuitive interface for apps with a \"%s\" manifest source.", config.ManifestSourceRemote.String()),
+			"",
+			"This command does not support apps deployed to Run on Slack infrastructure.",
+		}, "\n"),
+		Example: style.ExampleCommandsf([]style.ExampleCommand{
+			{
+				Meaning: "Open app settings for a prompted app",
+				Command: "app settings",
+			},
+			{
+				Meaning: "Open app settings for a specific app",
+				Command: "app settings --app A0123456789",
+			},
+		}),
+		Args: cobra.MaximumNArgs(0),
+		PreRunE: func(cmd *cobra.Command, args []string) error {
+			return appSettingsCommandPreRunE(clients, cmd, args)
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return appSettingsCommandRunE(clients, cmd, args)
+		},
+	}
+	return cmd
+}
+
+// appSettingsCommandPreRunE determines if the command can be run in a project
+func appSettingsCommandPreRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+	err := cmdutil.IsValidProjectDirectory(clients)
+	if err != nil {
+		return err
+	}
+	if clients.Config.ForceFlag {
+		return nil
+	}
+	err = cmdutil.IsSlackHostedProject(ctx, clients)
+	if err != nil {
+		if slackerror.Is(err, slackerror.ErrAppNotHosted) {
+			return nil
+		} else {
+			return err
+		}
+	}
+	return slackerror.New(slackerror.ErrAppHosted).
+		WithDetails(slackerror.ErrorDetails{
+			{
+				Message: "App settings is not supported with Run on Slack infrastructure",
+			},
+		})
+}
+
+// appSettingsCommandRunE opens app settings in a browser for the selected app
+func appSettingsCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+	clients.IO.PrintTrace(ctx, slacktrace.AppSettingsStart)
+
+	app, err := settingsAppSelectPromptFunc(ctx, clients, prompts.ShowInstalledAndUninstalledApps)
+	if err != nil {
+		return err
+	}
+	host := clients.API().Host()
+	parsed, err := url.Parse(host)
+	if err != nil {
+		return err
+	}
+	parsed.Host = "api." + parsed.Host
+	settingsURL := fmt.Sprintf("%s/apps/%s", parsed.String(), app.App.AppID)
+
+	clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
+		Emoji: "house",
+		Text:  "App Settings",
+		Secondary: []string{
+			settingsURL,
+		},
path: cmd/app/settings.go
user: login: zimeg
body: 🤔 I might have preference against this since erroring cases might cause confusion that the standalone link saves?

The balance of text also feels lopsided but I don't think this is significant reason against this:

```
🏠 App Settings
   Opening default web browser:
   https://api.slack.com/apps/A08SPJ83H4L
```
created_at: 2025-05-15T17:45:48Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+// Copyright 2022-2025 Salesforce, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/slackapi/slack-cli/internal/cmdutil"
+	"github.com/slackapi/slack-cli/internal/config"
+	"github.com/slackapi/slack-cli/internal/prompts"
+	"github.com/slackapi/slack-cli/internal/shared"
+	"github.com/slackapi/slack-cli/internal/slackerror"
+	"github.com/slackapi/slack-cli/internal/slacktrace"
+	"github.com/slackapi/slack-cli/internal/style"
+	"github.com/spf13/cobra"
+)
+
+var settingsAppSelectPromptFunc = prompts.AppSelectPrompt
+
+func NewSettingsCommand(clients *shared.ClientFactory) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "settings [flags]",
+		Short: "Open app settings for configurations",
+		Long: strings.Join([]string{
+			"Open app settings to configure an application in a web browser.",
+			"",
+			"Discovering new features and customizing an app manifest can be done from this",
+			fmt.Sprintf("intuitive interface for apps with a \"%s\" manifest source.", config.ManifestSourceRemote.String()),
+			"",
+			"This command does not support apps deployed to Run on Slack infrastructure.",
+		}, "\n"),
+		Example: style.ExampleCommandsf([]style.ExampleCommand{
+			{
+				Meaning: "Open app settings for a prompted app",
+				Command: "app settings",
+			},
+			{
+				Meaning: "Open app settings for a specific app",
+				Command: "app settings --app A0123456789",
+			},
+		}),
+		Args: cobra.MaximumNArgs(0),
+		PreRunE: func(cmd *cobra.Command, args []string) error {
+			return appSettingsCommandPreRunE(clients, cmd, args)
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return appSettingsCommandRunE(clients, cmd, args)
+		},
+	}
+	return cmd
+}
+
+// appSettingsCommandPreRunE determines if the command can be run in a project
+func appSettingsCommandPreRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+	err := cmdutil.IsValidProjectDirectory(clients)
+	if err != nil {
+		return err
+	}
+	if clients.Config.ForceFlag {
+		return nil
+	}
+	err = cmdutil.IsSlackHostedProject(ctx, clients)
+	if err != nil {
+		if slackerror.Is(err, slackerror.ErrAppNotHosted) {
+			return nil
+		} else {
+			return err
+		}
+	}
+	return slackerror.New(slackerror.ErrAppHosted).
+		WithDetails(slackerror.ErrorDetails{
+			{
+				Message: "App settings is not supported with Run on Slack infrastructure",
+			},
+		})
+}
+
+// appSettingsCommandRunE opens app settings in a browser for the selected app
+func appSettingsCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+	clients.IO.PrintTrace(ctx, slacktrace.AppSettingsStart)
+
+	app, err := settingsAppSelectPromptFunc(ctx, clients, prompts.ShowInstalledAndUninstalledApps)
+	if err != nil {
+		return err
+	}
+	host := clients.API().Host()
+	parsed, err := url.Parse(host)
+	if err != nil {
+		return err
+	}
+	parsed.Host = "api." + parsed.Host
+	settingsURL := fmt.Sprintf("%s/apps/%s", parsed.String(), app.App.AppID)
+
+	clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
+		Emoji: "house",
+		Text:  "App Settings",
+		Secondary: []string{
+			settingsURL,
+		},
path: cmd/app/settings.go
user: login: zimeg
body: 📝 It does make me think `--no-browser` might be a flag to later support with the default behavior though!
created_at: 2025-05-15T17:46:20Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+// Copyright 2022-2025 Salesforce, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/slackapi/slack-cli/internal/cmdutil"
+	"github.com/slackapi/slack-cli/internal/config"
+	"github.com/slackapi/slack-cli/internal/prompts"
+	"github.com/slackapi/slack-cli/internal/shared"
+	"github.com/slackapi/slack-cli/internal/slackerror"
+	"github.com/slackapi/slack-cli/internal/slacktrace"
+	"github.com/slackapi/slack-cli/internal/style"
+	"github.com/spf13/cobra"
+)
+
+var settingsAppSelectPromptFunc = prompts.AppSelectPrompt
path: cmd/app/settings.go
user: login: zimeg
body: @mwbrooks I'm so interested in this as well 🧠 ✨ 

I considered mocking the calls used in `AppSelectPrompt` to avoid this pattern altogether, but found that this adds so much overhead and perhaps mixes concerns...

Using `clients` might be a decent approach! It seems like we're settling on patterns of having identical function signatures within a command:

```go
RunE: func(cmd *cobra.Command, args []string) error {
	return appSettingsCommandRunE(clients, cmd, args)
},
```

But we've discussed avoiding `clients` for sake of more obvious usage of packages and dependencies. I'm wondering if within the command handler `clients` might be alright to keep, but for all other code following it should be avoided?

I'll hold off on making changes here so we can keep these patterns consistent for now, but I want to know what you think and I'm happy to follow up with changes we do decide on 🤖
created_at: 2025-05-15T17:59:52Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+// Copyright 2022-2025 Salesforce, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/slackapi/slack-cli/internal/cmdutil"
+	"github.com/slackapi/slack-cli/internal/config"
+	"github.com/slackapi/slack-cli/internal/prompts"
+	"github.com/slackapi/slack-cli/internal/shared"
+	"github.com/slackapi/slack-cli/internal/slackerror"
+	"github.com/slackapi/slack-cli/internal/slacktrace"
+	"github.com/slackapi/slack-cli/internal/style"
+	"github.com/spf13/cobra"
+)
+
+var settingsAppSelectPromptFunc = prompts.AppSelectPrompt
+
+func NewSettingsCommand(clients *shared.ClientFactory) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "settings [flags]",
+		Short: "Open app settings for configurations",
+		Long: strings.Join([]string{
+			"Open app settings to configure an application in a web browser.",
+			"",
+			"Discovering new features and customizing an app manifest can be done from this",
+			fmt.Sprintf("intuitive interface for apps with a \"%s\" manifest source.", config.ManifestSourceRemote.String()),
+			"",
+			"This command does not support apps deployed to Run on Slack infrastructure.",
+		}, "\n"),
+		Example: style.ExampleCommandsf([]style.ExampleCommand{
+			{
+				Meaning: "Open app settings for a prompted app",
+				Command: "app settings",
+			},
+			{
+				Meaning: "Open app settings for a specific app",
+				Command: "app settings --app A0123456789",
+			},
+		}),
+		Args: cobra.MaximumNArgs(0),
+		PreRunE: func(cmd *cobra.Command, args []string) error {
+			return appSettingsCommandPreRunE(clients, cmd, args)
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return appSettingsCommandRunE(clients, cmd, args)
+		},
+	}
+	return cmd
+}
+
+// appSettingsCommandPreRunE determines if the command can be run in a project
+func appSettingsCommandPreRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+	err := cmdutil.IsValidProjectDirectory(clients)
+	if err != nil {
+		return err
+	}
+	if clients.Config.ForceFlag {
+		return nil
+	}
+	err = cmdutil.IsSlackHostedProject(ctx, clients)
+	if err != nil {
+		if slackerror.Is(err, slackerror.ErrAppNotHosted) {
+			return nil
+		} else {
+			return err
+		}
+	}
+	return slackerror.New(slackerror.ErrAppHosted).
+		WithDetails(slackerror.ErrorDetails{
+			{
+				Message: "App settings is not supported with Run on Slack infrastructure",
+			},
+		})
+}
+
+// appSettingsCommandRunE opens app settings in a browser for the selected app
+func appSettingsCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+	clients.IO.PrintTrace(ctx, slacktrace.AppSettingsStart)
+
+	app, err := settingsAppSelectPromptFunc(ctx, clients, prompts.ShowInstalledAndUninstalledApps)
+	if err != nil {
+		return err
+	}
+	host := clients.API().Host()
+	parsed, err := url.Parse(host)
+	if err != nil {
+		return err
+	}
+	parsed.Host = "api." + parsed.Host
+	settingsURL := fmt.Sprintf("%s/apps/%s", parsed.String(), app.App.AppID)
+
+	clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
+		Emoji: "house",
+		Text:  "App Settings",
+		Secondary: []string{
+			settingsURL,
+		},
+	}))
+	clients.Browser().OpenURL(settingsURL)
+
+	clients.IO.PrintTrace(ctx, slacktrace.AppSettingsSuccess, settingsURL)
path: cmd/app/settings.go
user: login: zimeg
body: The setups of `PrintTrace` continue to impress me! 👾

I'm hoping soon we can equip all commands with at least a simple "start" and "success" with follow ups in `@slack/cli-test` but for now I'm also so glad we can print a success message with additional detail 📚 ✨ 
created_at: 2025-05-15T18:37:44Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+// Copyright 2022-2025 Salesforce, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/slackapi/slack-cli/internal/cmdutil"
+	"github.com/slackapi/slack-cli/internal/config"
+	"github.com/slackapi/slack-cli/internal/prompts"
+	"github.com/slackapi/slack-cli/internal/shared"
+	"github.com/slackapi/slack-cli/internal/slackerror"
+	"github.com/slackapi/slack-cli/internal/slacktrace"
+	"github.com/slackapi/slack-cli/internal/style"
+	"github.com/spf13/cobra"
+)
+
+var settingsAppSelectPromptFunc = prompts.AppSelectPrompt
+
+func NewSettingsCommand(clients *shared.ClientFactory) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "settings [flags]",
+		Short: "Open app settings for configurations",
+		Long: strings.Join([]string{
+			"Open app settings to configure an application in a web browser.",
+			"",
+			"Discovering new features and customizing an app manifest can be done from this",
+			fmt.Sprintf("intuitive interface for apps with a \"%s\" manifest source.", config.ManifestSourceRemote.String()),
+			"",
+			"This command does not support apps deployed to Run on Slack infrastructure.",
+		}, "\n"),
+		Example: style.ExampleCommandsf([]style.ExampleCommand{
+			{
+				Meaning: "Open app settings for a prompted app",
+				Command: "app settings",
+			},
+			{
+				Meaning: "Open app settings for a specific app",
+				Command: "app settings --app A0123456789",
+			},
+		}),
+		Args: cobra.MaximumNArgs(0),
+		PreRunE: func(cmd *cobra.Command, args []string) error {
+			return appSettingsCommandPreRunE(clients, cmd, args)
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return appSettingsCommandRunE(clients, cmd, args)
+		},
+	}
+	return cmd
+}
+
+// appSettingsCommandPreRunE determines if the command can be run in a project
+func appSettingsCommandPreRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+	err := cmdutil.IsValidProjectDirectory(clients)
+	if err != nil {
+		return err
+	}
+	if clients.Config.ForceFlag {
+		return nil
+	}
+	err = cmdutil.IsSlackHostedProject(ctx, clients)
+	if err != nil {
+		if slackerror.Is(err, slackerror.ErrAppNotHosted) {
+			return nil
+		} else {
+			return err
+		}
+	}
+	return slackerror.New(slackerror.ErrAppHosted).
+		WithDetails(slackerror.ErrorDetails{
+			{
+				Message: "App settings is not supported with Run on Slack infrastructure",
+			},
+		})
+}
+
+// appSettingsCommandRunE opens app settings in a browser for the selected app
+func appSettingsCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+	clients.IO.PrintTrace(ctx, slacktrace.AppSettingsStart)
+
+	app, err := settingsAppSelectPromptFunc(ctx, clients, prompts.ShowInstalledAndUninstalledApps)
+	if err != nil {
+		return err
+	}
+	host := clients.API().Host()
+	parsed, err := url.Parse(host)
+	if err != nil {
+		return err
+	}
+	parsed.Host = "api." + parsed.Host
+	settingsURL := fmt.Sprintf("%s/apps/%s", parsed.String(), app.App.AppID)
+
+	clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
+		Emoji: "house",
+		Text:  "App Settings",
+		Secondary: []string{
+			settingsURL,
+		},
path: cmd/app/settings.go
user: login: mwbrooks
body: Sounds good to me. Also `--no-browser` would be an appropriate future flag, since `activity --browser` already exists to open the activity in the browser.
created_at: 2025-05-16T02:55:16Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+// Copyright 2022-2025 Salesforce, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/slackapi/slack-cli/internal/cmdutil"
+	"github.com/slackapi/slack-cli/internal/config"
+	"github.com/slackapi/slack-cli/internal/prompts"
+	"github.com/slackapi/slack-cli/internal/shared"
+	"github.com/slackapi/slack-cli/internal/slackerror"
+	"github.com/slackapi/slack-cli/internal/slacktrace"
+	"github.com/slackapi/slack-cli/internal/style"
+	"github.com/spf13/cobra"
+)
+
+var settingsAppSelectPromptFunc = prompts.AppSelectPrompt
path: cmd/app/settings.go
user: login: mwbrooks
body: Totally agree, good call on not adding more complexity in this PR. Let's discuss it offline to figure out what we want to try out!
created_at: 2025-05-16T02:56:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+// Copyright 2022-2025 Salesforce, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/slackapi/slack-cli/internal/cmdutil"
+	"github.com/slackapi/slack-cli/internal/config"
+	"github.com/slackapi/slack-cli/internal/prompts"
+	"github.com/slackapi/slack-cli/internal/shared"
+	"github.com/slackapi/slack-cli/internal/slackerror"
+	"github.com/slackapi/slack-cli/internal/slacktrace"
+	"github.com/slackapi/slack-cli/internal/style"
+	"github.com/spf13/cobra"
+)
+
+var settingsAppSelectPromptFunc = prompts.AppSelectPrompt
+
+func NewSettingsCommand(clients *shared.ClientFactory) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "settings [flags]",
+		Short: "Open app settings for configurations",
+		Long: strings.Join([]string{
+			"Open app settings to configure an application in a web browser.",
+			"",
+			"Discovering new features and customizing an app manifest can be done from this",
+			fmt.Sprintf("intuitive interface for apps with a \"%s\" manifest source.", config.ManifestSourceRemote.String()),
+			"",
+			"This command does not support apps deployed to Run on Slack infrastructure.",
+		}, "\n"),
+		Example: style.ExampleCommandsf([]style.ExampleCommand{
+			{
+				Meaning: "Open app settings for a prompted app",
+				Command: "app settings",
+			},
+			{
+				Meaning: "Open app settings for a specific app",
+				Command: "app settings --app A0123456789",
+			},
+		}),
+		Args: cobra.MaximumNArgs(0),
+		PreRunE: func(cmd *cobra.Command, args []string) error {
+			return appSettingsCommandPreRunE(clients, cmd, args)
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return appSettingsCommandRunE(clients, cmd, args)
+		},
+	}
+	return cmd
+}
+
+// appSettingsCommandPreRunE determines if the command can be run in a project
+func appSettingsCommandPreRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+	err := cmdutil.IsValidProjectDirectory(clients)
+	if err != nil {
+		return err
+	}
+	if clients.Config.ForceFlag {
+		return nil
+	}
+	err = cmdutil.IsSlackHostedProject(ctx, clients)
+	if err != nil {
+		if slackerror.Is(err, slackerror.ErrAppNotHosted) {
+			return nil
+		} else {
+			return err
+		}
+	}
+	return slackerror.New(slackerror.ErrAppHosted).
+		WithDetails(slackerror.ErrorDetails{
+			{
+				Message: "App settings is not supported with Run on Slack infrastructure",
+			},
+		})
+}
+
+// appSettingsCommandRunE opens app settings in a browser for the selected app
+func appSettingsCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+	clients.IO.PrintTrace(ctx, slacktrace.AppSettingsStart)
+
+	app, err := settingsAppSelectPromptFunc(ctx, clients, prompts.ShowInstalledAndUninstalledApps)
+	if err != nil {
+		return err
+	}
+	host := clients.API().Host()
+	parsed, err := url.Parse(host)
+	if err != nil {
+		return err
+	}
+	parsed.Host = "api." + parsed.Host
+	settingsURL := fmt.Sprintf("%s/apps/%s", parsed.String(), app.App.AppID)
+
+	clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
+		Emoji: "house",
+		Text:  "App Settings",
+		Secondary: []string{
+			settingsURL,
+		},
+	}))
+	clients.Browser().OpenURL(settingsURL)
+
+	clients.IO.PrintTrace(ctx, slacktrace.AppSettingsSuccess, settingsURL)
path: cmd/app/settings.go
user: login: mwbrooks
body: It seems like a reasonable PR to add a "start" and "success" to each command and land the equivalent definitions in `@slack/cli-test`.
created_at: 2025-05-16T02:58:25Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-05-14T05:26:59Z
body: ## [Codecov](https://app.codecov.io/gh/slackapi/slack-cli/pull/92?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=slackapi) Report
Attention: Patch coverage is `92.59259%` with `6 lines` in your changes missing coverage. Please review.
> Project coverage is 63.31%. Comparing base [(`4485405`)](https://app.codecov.io/gh/slackapi/slack-cli/commit/44854051f95e65a99a63a0182f2e06f8d2381014?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=slackapi) to head [(`2d78fb8`)](https://app.codecov.io/gh/slackapi/slack-cli/commit/2d78fb86f9782498e138c552d04e59e91c6c6e64?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=slackapi).
> Report is 1 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/slackapi/slack-cli/pull/92?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=slackapi) | Patch % | Lines |
|---|---|---|
| [cmd/app/settings.go](https://app.codecov.io/gh/slackapi/slack-cli/pull/92?src=pr&el=tree&filepath=cmd%2Fapp%2Fsettings.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=slackapi#diff-Y21kL2FwcC9zZXR0aW5ncy5nbw==) | 92.40% | [4 Missing and 2 partials :warning: ](https://app.codecov.io/gh/slackapi/slack-cli/pull/92?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=slackapi) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main      #92      +/-   ##
==========================================
+ Coverage   63.18%   63.31%   +0.12%     
==========================================
  Files         210      211       +1     
  Lines       22195    22276      +81     
==========================================
+ Hits        14025    14104      +79     
  Misses       7085     7085              
- Partials     1085     1087       +2     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/slackapi/slack-cli/pull/92?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=slackapi).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=slackapi).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: zimeg
created_at: 2025-05-15T18:35:31Z
body: @mwbrooks I am so excited for this to land in the next release! 🚀 ✨ 

Thanks for helping refine this to a better place as always. I left a few responses about perhaps much larger changes that this PR poked at, but for now I will merge this.

Follow up for particular flag options is also interesting to me, but also might be outside the scope of a first iteration.
author_association: MEMBER