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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions cmd/extension/exec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// 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 extension

import (
"context"
"strings"

"github.com/slackapi/slack-cli/internal/cmdutil"
"github.com/slackapi/slack-cli/internal/experiment"
"github.com/slackapi/slack-cli/internal/hooks"
"github.com/slackapi/slack-cli/internal/shared"
"github.com/slackapi/slack-cli/internal/slackerror"
"github.com/slackapi/slack-cli/internal/style"
"github.com/spf13/cobra"
)

func NewExtensionExecCommand(clients *shared.ClientFactory) *cobra.Command {
cmd := &cobra.Command{
Use: "exec [flags] <cmd> [args]",
Hidden: true,
Short: "Run an extension",
Long: strings.Join([]string{
"Execute the extension with provided arguments",
}, "\n"),
Args: cobra.MinimumNArgs(0),
Example: style.ExampleCommandsf([]style.ExampleCommand{
{
Meaning: "Run the \"glitch\" extension",
Command: "extension exec glitch",
},
}),
PreRunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
return preRunExtensionExecCommandFunc(ctx, clients)
},
RunE: func(cmd *cobra.Command, args []string) error {
return runExtensionExecCommandFunc(clients, cmd, args)
},
}

return cmd
}

// preRunExtensionExecCommandFunc determines if the command is available.
func preRunExtensionExecCommandFunc(ctx context.Context, clients *shared.ClientFactory) error {
if !clients.Config.WithExperimentOn(experiment.Extension) {
return slackerror.New(slackerror.ErrMissingExperiment).
WithRemediation("Run the command again with the \"--experiment extension\" flag.")
}
return nil
}

// runExtensionExecCommandFunc runs the provided extension.
func runExtensionExecCommandFunc(
clients *shared.ClientFactory,
cmd *cobra.Command,
args []string,
) error {
ctx := cmd.Context()
switch {
case len(args) == 0:
return slackerror.New(slackerror.ErrMissingExtension)
case args[0] == "glitch":
clients.IO.PrintDebug(ctx, ":space_invader:")
clients.IO.PrintInfo(ctx, false, "\x1b[?25l")
return nil
}
opts := hooks.HookExecOpts{
Hook: hooks.HookScript{
Name: "extension",
Command: "slack-" + strings.Join(args, " "),
},
Stdin: clients.IO.ReadIn(),
Stdout: clients.IO.WriteOut(),
Stderr: clients.IO.WriteErr(),
Env: map[string]string{
"SLACK_CLI_ALIAS": cmdutil.GetProcessName(),
},
}
// The "default" protocol is used because a certain response is not expected of
// scripts provided to the "extension" hook.
//
// The "message boundaries" protocol appends information to scripts using flags
// which might cause some commands to error.
//
// The hook executor attached to the provided clients might use either protocol
// so we instantiate the default here.
shell := hooks.HookExecutorDefaultProtocol{
IO: clients.IO,
}
_, err := shell.Execute(ctx, opts)
if err != nil {
return err
}
return nil
}
65 changes: 65 additions & 0 deletions cmd/extension/exec_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// 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 extension

import (
"context"
"testing"

"github.com/slackapi/slack-cli/internal/shared"
"github.com/slackapi/slack-cli/internal/slackerror"
"github.com/slackapi/slack-cli/test/testutil"
"github.com/spf13/cobra"
)

func Test_Extension_ExecCommand(t *testing.T) {
testutil.TableTestCommand(t, testutil.CommandTests{
"errors without the experiment flag": {
CmdArgs: []string{"glitch"},
ExpectedError: slackerror.New(slackerror.ErrMissingExperiment),
},
"no extension to execute errors": {
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
cm.AddDefaultMocks()
cm.Config.ExperimentsFlag = []string{"extension"}
cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug)
},
ExpectedError: slackerror.New(slackerror.ErrMissingExtension),
},
"the glitch extension exists": {
CmdArgs: []string{"glitch"},
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
cm.AddDefaultMocks()
cm.Config.ExperimentsFlag = []string{"extension"}
cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug)
},
ExpectedStdoutOutputs: []string{"\x1b[?25l"},
},
"attempts to execute a missing extension": {
CmdArgs: []string{"404"},
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
cm.AddDefaultMocks()
cm.Config.ExperimentsFlag = []string{"extension"}
cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug)
},
ExpectedErrorStrings: []string{
slackerror.ErrSDKHookInvocationFailed,
},
},
}, func(cf *shared.ClientFactory) *cobra.Command {
cmd := NewExtensionExecCommand(cf)
return cmd
})
}
52 changes: 52 additions & 0 deletions cmd/extension/extension.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// 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 extension

import (
"strings"

"github.com/slackapi/slack-cli/internal/shared"
"github.com/slackapi/slack-cli/internal/style"
"github.com/spf13/cobra"
)

func NewCommand(clients *shared.ClientFactory) *cobra.Command {
cmd := &cobra.Command{
Use: "extension <subcommand>",
Hidden: true,
Short: "Manage Slack CLI extensions",
Long: strings.Join([]string{
"Run custom Slack CLI commands as extensions.",
"",
"Commands are installed separate and should be available as \"slack-example\" in",
"the current shell for example.",
"",
"One extension is included for testing purposes.",
}, "\n"),
Example: style.ExampleCommandsf([]style.ExampleCommand{
{
Meaning: "Run the \"glitch\" extension",
Command: "extension exec glitch",
},
}),
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}

cmd.AddCommand(NewExtensionExecCommand(clients))

return cmd
}
36 changes: 36 additions & 0 deletions cmd/extension/extension_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// 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 extension

import (
"testing"

"github.com/slackapi/slack-cli/internal/shared"
"github.com/slackapi/slack-cli/test/testutil"
"github.com/spf13/cobra"
)

func Test_Extension_Command(t *testing.T) {
testutil.TableTestCommand(t, testutil.CommandTests{
"shows the help page without commands or arguments or flags": {
ExpectedStdoutOutputs: []string{
"Run custom Slack CLI commands as extensions.",
},
},
}, func(clients *shared.ClientFactory) *cobra.Command {
cmd := NewCommand(clients)
return cmd
})
}
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"github.com/slackapi/slack-cli/cmd/docgen"
"github.com/slackapi/slack-cli/cmd/doctor"
"github.com/slackapi/slack-cli/cmd/env"
"github.com/slackapi/slack-cli/cmd/extension"
"github.com/slackapi/slack-cli/cmd/externalauth"
"github.com/slackapi/slack-cli/cmd/feedback"
"github.com/slackapi/slack-cli/cmd/fingerprint"
Expand Down Expand Up @@ -162,6 +163,7 @@ func Init(ctx context.Context) (*cobra.Command, *shared.ClientFactory) {
datastore.NewCommand(clients),
docgen.NewCommand(clients),
env.NewCommand(clients),
extension.NewCommand(clients),
externalauth.NewCommand(clients),
fingerprint.NewCommand(clients),
function.NewCommand(clients),
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/experiments.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ The following is a list of currently available experiments. We'll remove experim
* `bolt-install`: enables creating, installing, and running Bolt projects that manage their app manifest on app settings (remote manifest).
* `slack create` and `slack init` now set manifest source to "app settings" (remote) for Bolt JS & Bolt Python projects ([PR#96](https://github.com/slackapi/slack-cli/pull/96)).
* `slack run` and `slack install` support creating and installing Bolt Framework apps that have the manifest source set to "app settings (remote)" ([PR#111](https://github.com/slackapi/slack-cli/pull/111), [PR#154](https://github.com/slackapi/slack-cli/pull/154)).
* `extension`: enables running custom programs with the `slack extension` command.
* `read-only-collaborators`: enables creating and modifying collaborator permissions via the `slack collaborator` commands.

## Experiments changelog

Below is a list of updates related to experiments.

* **September 2025**: Added the experiment `extension`.
* **June 2025**:
* Updated the `slack run` command to create and install new and existing Bolt framework projects configured with app settings as the source of truth (remote manifest).
* Added support for creating, installing, and running Bolt projects that manage their app manifest on app settings (remote manifest). New Bolt projects are now configured to have apps managed by app settings rather than by project. When running a project for local development, the app and bot tokens are automatically set, and no longer require developers to export them as environment variables. Existing Bolt projects will continue to work with a project (local) manifest, and linking an app from app settings will configure the project to be managed by app settings (remote manifest). In an upcoming release, support for installing and deploying apps managed by app settings will be implemented.
Expand Down
4 changes: 4 additions & 0 deletions internal/experiment/experiment.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ const (
// manage their app manifest on app settings (remote manifest).
BoltInstall Experiment = "bolt-install"

// Extension experiment unlocks the extension commands.
Extension Experiment = "extension"

// The ReadOnlyAppCollaborators experiment enables creating and modifying collaborator
// permissions via the `collaborator` commands.
ReadOnlyAppCollaborators Experiment = "read-only-collaborators"
Expand All @@ -51,6 +54,7 @@ const (
var AllExperiments = []Experiment{
BoltFrameworks,
BoltInstall,
Extension,
ReadOnlyAppCollaborators,
Placeholder,
}
Expand Down
1 change: 1 addition & 0 deletions internal/experiment/experiment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func Test_Includes(t *testing.T) {

// Test expected experiments
require.Equal(t, true, Includes(Experiment("bolt")))
require.Equal(t, true, Includes(Experiment("extension")))
require.Equal(t, true, Includes(Experiment("read-only-collaborators")))

// Test invalid experiment
Expand Down
15 changes: 15 additions & 0 deletions internal/hooks/hook_executor_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ package hooks
import (
"bytes"
"context"
"os/exec"
"strings"
"syscall"

"github.com/slackapi/slack-cli/internal/iostreams"
"github.com/slackapi/slack-cli/internal/slackerror"
Expand Down Expand Up @@ -72,6 +74,19 @@ func (e *HookExecutorDefaultProtocol) Execute(ctx context.Context, opts HookExec

response := strings.TrimSpace(buffout.String())
if err != nil {
// Signal interrupts with an error code when input might be expected
if opts.Stdin != nil {
if ee, ok := err.(*exec.ExitError); ok {
if ws, ok := ee.Sys().(syscall.WaitStatus); ok {
if ws.Signaled() {
switch ws.Signal() {
case syscall.SIGINT, syscall.SIGTERM:
return "", slackerror.New(slackerror.ErrProcessInterrupted)
}
}
}
}
}
// Include stderr outputs in error details if these aren't streamed
details := slackerror.ErrorDetails{}
if opts.Stderr == nil {
Expand Down
6 changes: 6 additions & 0 deletions internal/slackerror/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ const (
ErrMissingAppTeamID = "missing_app_team_id"
ErrMissingChallenge = "missing_challenge"
ErrMissingExperiment = "missing_experiment"
ErrMissingExtension = "missing_extension"
ErrMissingFunctionIdentifier = "missing_function_identifier"
ErrMissingFlag = "missing_flag"
ErrMissingInput = "missing_input"
Expand Down Expand Up @@ -1115,6 +1116,11 @@ Otherwise start your app for local development with: %s`,
Message: "The feature is behind an experiment not toggled on",
},

ErrMissingExtension: {
Code: ErrMissingExtension,
Message: "An extension is missing",
},

ErrMissingFunctionIdentifier: {
Code: ErrMissingFunctionIdentifier,
Message: "Could not find the given workflow using the specified reference",
Expand Down
Loading