Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 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
0b0b4d3
chore: retrigger CI pipeline
WilliamBergamin Jul 23, 2026
fe16f49
Merge branch 'main' into bill-bkb-open-command
WilliamBergamin Jul 28, 2026
87fa6d3
Update cmd/blocks/blocks.go
WilliamBergamin Jul 29, 2026
1ffef7c
Update cmd/blocks/blocks.go
WilliamBergamin Jul 29, 2026
4e51a7a
Update cmd/blocks/blocks.go
WilliamBergamin Jul 29, 2026
dd90080
Update cmd/blocks/preview.go
WilliamBergamin Jul 29, 2026
2525043
Update cmd/blocks/preview.go
WilliamBergamin Jul 29, 2026
53c1129
Update cmd/blocks/preview.go
WilliamBergamin Jul 29, 2026
ffa22a7
Update cmd/blocks/preview.go
WilliamBergamin Jul 29, 2026
f2c7c6f
Update cmd/blocks/preview.go
WilliamBergamin Jul 29, 2026
345fe94
Update cmd/blocks/preview.go
WilliamBergamin Jul 29, 2026
64bbc8e
improve based on feedback
WilliamBergamin Jul 29, 2026
4503762
fix: improve the implementation
WilliamBergamin Jul 29, 2026
5874f18
fix(blocks): only scope preview to a team when --team is set
WilliamBergamin Jul 29, 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
Comment on lines +24 to +25

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

🪓 thought: I'd be alright making this command visible to standard use either now or later? This command seems so useful to casual block kit building IMHO!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My aim here was to make it available only to AI tools for now since it would let us iterate on the command and introduce breaking changes until we decide to make it visible to everyone


func NewCommand(clients *shared.ClientFactory) *cobra.Command {
cmd := &cobra.Command{
Use: "blocks <subcommand> [flags]",
Short: "Build with Block Kit",
Long: "Build layouts using Block Kit and iterate on designs with Block Kit Builder.",
Hidden: aiAgentFunc() == nil,
Example: style.ExampleCommandsf([]style.ExampleCommand{
{
Meaning: "Preview blocks in 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{
"Build layouts using Block Kit",
"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)
})
}
}
216 changes: 216 additions & 0 deletions cmd/blocks/preview.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
// 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"
"net/url"
"strings"

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

func NewPreviewCommand(clients *shared.ClientFactory) *cobra.Command {
var blocksFlag string
cmd := &cobra.Command{
Use: "preview [flags]",
Short: "Preview blocks in Block Kit Builder",
Long: strings.Join([]string{
"Preview a set of Block Kit blocks with 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, or omit all flags, 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.json",
},
{
Meaning: "Preview blocks read from a redirect and scoped to a team",
Command: "blocks preview --team T0123456 --blocks - < blocks.json",
},
Comment on lines +55 to +58

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 praise: Thanks for including this as an example! In ongoing iteration this might be a preferred pattern for me to use.

}),
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, err := resolveBlocksInput(clients, blocksFlag, blocksFlagChanged)
if err != nil {
return err
}

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

auth, err := resolveTeamAuth(ctx, clients)
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: "bricks",
Text: "Blocks Preview",
Secondary: []string{
builderURL,
},
}))
clients.Browser().OpenURL(builderURL)

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

func resolveTeamAuth(ctx context.Context, clients *shared.ClientFactory) (*types.SlackAuth, error) {
if clients.Config.TeamFlag == "" {
return nil, nil
}
auths, err := clients.Auth().Auths(ctx)
if err != nil {
return nil, err
}
for i, auth := range auths {
if clients.Config.TeamFlag == auth.TeamID || clients.Config.TeamFlag == auth.TeamDomain {
return &auths[i], nil
}
}
return nil, slackerror.New(slackerror.ErrTeamNotFound)
}

func resolveBlocksInput(clients *shared.ClientFactory, flagValue string, flagChanged bool) (string, error) {
switch {
case flagChanged && flagValue == "-":
if clients.IO.IsStdinTTY() {
return "", slackerror.New(slackerror.ErrMissingInput).
WithMessage("No blocks were provided on standard input").
WithRemediation("Redirect blocks into the command with \"<\", e.g. %s", style.Commandf("blocks preview < blocks.json", false))
}
return readStdinBlocks(clients)
Comment on lines +124 to +130

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
case flagChanged && flagValue == "-":
return readStdinBlocks(clients)
case !flagChanged:
return readStdinBlocks(clients)

⚙️ question: Would an omitted --blocks flag be meaningful instead of the "-" value for gathering stdin inputs? I find the commands afterwards clean, but might not be familiar with all hopes for the command!

$ slack blocks preview < blocks.json

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this was and pattern I considered originally but it creates weird edge cases with flags, like how do we properly handle

$ slack blocks preview --team TA1B2C3 < blocks.json

Maybe if we support no flags with the following then we don't support flags with this type of pattern 🤔

case flagChanged:
input := strings.TrimSpace(flagValue)
if input == "" {
return "", missingBlocksError()
}
return input, nil
case !clients.IO.IsStdinTTY():
return readStdinBlocks(clients)
default:
return "", missingBlocksError()
}
}

func readStdinBlocks(clients *shared.ClientFactory) (string, error) {
input, err := clients.IO.ReadInAll()
if err != nil {
return "", slackerror.Wrap(err, slackerror.ErrMissingInput)
}
if input == "" {
return "", missingBlocksError()
}
return input, nil
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚂 suggestion: We can perhaps separate the input itself and mixing errors from command details with moving this function to:

internal/iostreams/reader.go


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) {
compacted, err := goutils.CompactJSON([]byte(input))
if err != nil {
return "", slackerror.JSONUnmarshalError(err, []byte(input))
}
trimmed := bytes.TrimSpace(compacted)
if len(trimmed) == 0 {
return "", slackerror.New(slackerror.ErrInvalidBlocks)
}

switch trimmed[0] {
case '[':
return fmt.Sprintf(`{"blocks":%s}`, trimmed), nil
case '{':
var object map[string]json.RawMessage
if err := json.Unmarshal(trimmed, &object); err != nil {
return "", slackerror.New(slackerror.ErrInvalidBlocks)
}
blocks, ok := object["blocks"]
if !ok || len(bytes.TrimSpace(blocks)) == 0 || bytes.TrimSpace(blocks)[0] != '[' {
return "", slackerror.New(slackerror.ErrInvalidBlocks)
}
return string(trimmed), nil
default:
return "", slackerror.New(slackerror.ErrInvalidBlocks)
}
}

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

func buildBlockKitBuilderURL(apiHost string, teamOrEnterpriseID 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
if teamOrEnterpriseID != "" {
parsed.Path = fmt.Sprintf("/block-kit-builder/%s/builder", teamOrEnterpriseID)
} else {
parsed.Path = "/block-kit-builder"
}
parsed.Fragment = blocksJSON
return parsed.String(), nil
}
Loading
Loading