Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6091f50
docs: add design spec for blocks preview input rework
WilliamBergamin Jul 20, 2026
6d3f066
docs: add implementation plan for blocks preview input rework
WilliamBergamin Jul 20, 2026
55afeba
feat: add experimental blocks preview command (baseline)
WilliamBergamin Jul 20, 2026
d19e65d
feat: add IsStdinTTY for detecting piped stdin
WilliamBergamin Jul 20, 2026
d97944d
feat: accept blocks via --blocks flag with stdin sentinel
WilliamBergamin Jul 20, 2026
0a95868
fix: require --team when piping blocks with multiple auths
WilliamBergamin Jul 20, 2026
739406d
fix: use enterprise id only for enterprise installs in blocks preview
WilliamBergamin Jul 20, 2026
519ff67
fix: reject invalid API hosts when building Block Kit Builder URL
WilliamBergamin Jul 20, 2026
2f8d29a
chore: hide experimental blocks command from help and docs
WilliamBergamin Jul 20, 2026
39e2cd7
feat: gate blocks command on AI-tool detection instead of experiment
WilliamBergamin Jul 20, 2026
279794b
chore: remove internal blocks preview plan and design docs
WilliamBergamin Jul 20, 2026
53b6ee1
feat: allow anyone to run blocks preview, keep it hidden from non-AI …
WilliamBergamin Jul 21, 2026
85309a5
feat: require explicit --blocks - for stdin in blocks preview
WilliamBergamin Jul 21, 2026
989cfbc
chore: trim redundant comments in blocks preview
WilliamBergamin Jul 21, 2026
46fa7a3
fix: harden blocks preview stdin, auth, and URL key order
WilliamBergamin Jul 21, 2026
252308d
docs: use redirection example in IsStdinTTY comment
WilliamBergamin Jul 21, 2026
2102c7b
simplify error message
WilliamBergamin Jul 21, 2026
006f027
Merge branch 'main' into bill-bkb-open-command
WilliamBergamin Jul 21, 2026
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
48 changes: 48 additions & 0 deletions cmd/blocks/blocks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright 2022-2026 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 blocks

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

// aiAgentFunc is a package variable so it can be stubbed in tests.
var aiAgentFunc = useragent.GetAIAgent

func NewCommand(clients *shared.ClientFactory) *cobra.Command {
cmd := &cobra.Command{
Use: "blocks <subcommand> [flags]",
Short: "Work with Block Kit blocks",
Long: "Work with Block Kit blocks, such as previewing them in the Block Kit Builder.",
Hidden: aiAgentFunc() == nil,
Example: style.ExampleCommandsf([]style.ExampleCommand{
{
Meaning: "Preview blocks in the Block Kit Builder",
Command: "blocks preview --blocks '[{\"type\":\"divider\"}]'",
},
}),
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}

cmd.AddCommand(NewPreviewCommand(clients))

return cmd
}
68 changes: 68 additions & 0 deletions cmd/blocks/blocks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright 2022-2026 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 blocks

import (
"context"
"testing"

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

func Test_Blocks_Command(t *testing.T) {
testutil.TableTestCommand(t, testutil.CommandTests{
"prints help without a subcommand": {
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
},
ExpectedOutputs: []string{
"Work with Block Kit blocks",
"preview",
},
},
}, func(cf *shared.ClientFactory) *cobra.Command {
return NewCommand(cf)
})
}

func Test_Blocks_Command_Hidden(t *testing.T) {
tests := map[string]struct {
aiAgent *useragent.AIAgent
expectedHidden bool
}{
"hidden when no AI coding tool is detected": {
aiAgent: nil,
expectedHidden: true,
},
"visible when an AI coding tool is detected": {
aiAgent: &useragent.AIAgent{Name: "claude-code"},
expectedHidden: false,
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
restore := stubAIAgent(tc.aiAgent)
defer restore()

clientsMock := shared.NewClientsMock()
clients := shared.NewClientFactory(clientsMock.MockClientFactory())
cmd := NewCommand(clients)
assert.Equal(t, tc.expectedHidden, cmd.Hidden)
})
}
}
213 changes: 213 additions & 0 deletions cmd/blocks/preview.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
// Copyright 2022-2026 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 blocks

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/url"
"strings"

"github.com/slackapi/slack-cli/internal/goutils"
"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/internal/style"
"github.com/spf13/cobra"
)

// promptTeamSlackAuthFunc is a package variable so it can be stubbed in tests.
var promptTeamSlackAuthFunc = prompts.PromptTeamSlackAuth

func NewPreviewCommand(clients *shared.ClientFactory) *cobra.Command {
var blocksFlag string
cmd := &cobra.Command{
Use: "preview [flags]",
Short: "Preview blocks in the Block Kit Builder",
Long: strings.Join([]string{
"Open a set of Block Kit blocks in the Block Kit Builder in a web browser.",
"",
"Provide blocks with the --blocks flag.",
"The input is a JSON array of blocks or a JSON object with a \"blocks\" array.",
"Pass - to --blocks to read from standard input.",
}, "\n"),
Example: style.ExampleCommandsf([]style.ExampleCommand{
{
Meaning: "Preview blocks passed as a flag value",
Command: "blocks preview --blocks '[{\"type\":\"divider\"}]'",
},
{
Meaning: "Preview blocks read from a file",
Command: "blocks preview --blocks - < blocks.json",
},
}),
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return previewCommandRunE(clients, cmd, blocksFlag, cmd.Flags().Changed("blocks"))
},
}
cmd.Flags().StringVar(&blocksFlag, "blocks", "", "blocks to preview as a JSON array or object\n (use - to read from standard input)")
return cmd
}

func previewCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, blocksFlag string, blocksFlagChanged bool) error {
ctx := cmd.Context()
clients.IO.PrintTrace(ctx, slacktrace.BlocksPreviewStart)

blocksInput, fromStdin, err := resolveBlocksInput(clients, blocksFlag, blocksFlagChanged)
if err != nil {
return err
}

blocksJSON, err := normalizeBlocksPayload(blocksInput)
if err != nil {
return err
}

auth, err := selectTeamAuth(ctx, clients, fromStdin)
if err != nil {
return err
}

builderURL, err := buildBlockKitBuilderURL(clients.API().Host(), teamOrEnterpriseID(auth), blocksJSON)
if err != nil {
return err
}

clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
Emoji: "eyes",
Text: "Block Kit Builder",
Secondary: []string{
builderURL,
},
}))
clients.Browser().OpenURL(builderURL)

clients.IO.PrintTrace(ctx, slacktrace.BlocksPreviewSuccess, builderURL)
return nil
}

func selectTeamAuth(ctx context.Context, clients *shared.ClientFactory, fromStdin bool) (*types.SlackAuth, error) {
auths, err := clients.Auth().Auths(ctx)
if err != nil {
return nil, err
}
if len(auths) == 0 {
return nil, slackerror.New(slackerror.ErrCredentialsNotFound)
}
if fromStdin && clients.Config.TeamFlag == "" && len(auths) > 1 {
return nil, slackerror.New(slackerror.ErrMissingFlag).
WithMessage("The team could not be determined").
WithRemediation("Select a team with the %s flag when reading blocks from standard input", style.Highlight("--team"))
}
return promptTeamSlackAuthFunc(ctx, clients, "Select a team to preview blocks for", nil)
}

func resolveBlocksInput(clients *shared.ClientFactory, flagValue string, flagChanged bool) (string, bool, error) {
switch {
case flagChanged && flagValue == "-":
return readStdinBlocks(clients)
case flagChanged:
input := strings.TrimSpace(flagValue)
if input == "" {
return "", false, missingBlocksError()
}
return input, false, nil
default:
return "", false, missingBlocksError()
}
}

func readStdinBlocks(clients *shared.ClientFactory) (string, bool, error) {
if clients.IO.IsStdinTTY() {
return "", true, slackerror.New(slackerror.ErrMissingInput).
WithMessage("No blocks were provided on standard input").
WithRemediation("Redirect blocks into the command with %s, e.g. %s", style.Highlight("<"), style.Highlight("slack blocks preview --blocks - < blocks.json"))
}
piped, err := io.ReadAll(clients.IO.ReadIn())
if err != nil {
return "", true, slackerror.Wrap(err, slackerror.ErrMissingInput)
}
input := strings.TrimSpace(string(piped))
if input == "" {
return "", true, missingBlocksError()
}
return input, true, nil
}

func missingBlocksError() error {
return slackerror.New(slackerror.ErrMissingInput).
WithMessage("No blocks were provided").
WithRemediation("Provide blocks with the %s flag, or pass %s to read from standard input", style.Highlight("--blocks"), style.Highlight("--blocks -"))
}

func normalizeBlocksPayload(input string) (string, error) {
var parsed any
if err := goutils.JSONUnmarshal([]byte(input), &parsed); err != nil {
return "", err
}

compacted, err := compactJSON(input)
if err != nil {
return "", slackerror.New(slackerror.ErrInvalidBlocks)
}

switch value := parsed.(type) {
case []any:
return fmt.Sprintf(`{"blocks":%s}`, compacted), nil
case map[string]any:
if _, ok := value["blocks"].([]any); !ok {
return "", slackerror.New(slackerror.ErrInvalidBlocks)
}
return compacted, nil
default:
return "", slackerror.New(slackerror.ErrInvalidBlocks)
}
}

func compactJSON(input string) (string, error) {
var buf bytes.Buffer
if err := json.Compact(&buf, []byte(input)); err != nil {
return "", err
}
return buf.String(), nil
}

func teamOrEnterpriseID(auth *types.SlackAuth) string {
if auth.IsEnterpriseInstall {
return auth.EnterpriseID
}
return auth.TeamID
}

func buildBlockKitBuilderURL(apiHost string, id string, blocksJSON string) (string, error) {
parsed, err := url.Parse(apiHost)
if err != nil {
return "", slackerror.Wrap(err, slackerror.ErrInvalidArguments)
}
if parsed.Host == "" {
return "", slackerror.New(slackerror.ErrInvalidArguments).
WithMessage("The API host %q is not a valid URL", apiHost)
}
parsed.Host = "app." + parsed.Host
parsed.Path = fmt.Sprintf("/block-kit-builder/%s/builder", id)
parsed.Fragment = blocksJSON
return parsed.String(), nil
}
Loading
Loading