From 6091f501cb9d007bbd068927fb61ee897c237d59 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 20 Jul 2026 14:43:31 -0400 Subject: [PATCH 01/17] docs: add design spec for blocks preview input rework Design for reworking `slack blocks preview` input handling (--blocks flag with `-` stdin sentinel) and folding in the seven code-review findings. Co-Authored-By: Claude --- ...7-20-blocks-preview-input-rework-design.md | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-20-blocks-preview-input-rework-design.md diff --git a/docs/superpowers/specs/2026-07-20-blocks-preview-input-rework-design.md b/docs/superpowers/specs/2026-07-20-blocks-preview-input-rework-design.md new file mode 100644 index 00000000..3da028d2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-blocks-preview-input-rework-design.md @@ -0,0 +1,185 @@ +# `slack blocks preview` — input rework and review fixes + +**Date:** 2026-07-20 +**Status:** Approved, pending implementation plan +**Branch:** bill-bkb-open-command + +## Background + +The new `slack blocks preview` command (in `cmd/blocks/`, gated behind the +`block-kit-builder` experiment) opens a set of Block Kit blocks in the Block Kit +Builder in a browser. A code review of the initial implementation surfaced +seven findings. This design reworks how the command accepts input and folds in +the remaining fixes. + +### Findings addressed + +1. **#1 Interactive hang** — bare `slack blocks preview` (no arg, no pipe) calls + `io.ReadAll(stdin)` with no TTY guard and blocks forever on an interactive + terminal. +2. **#2 Wrong enterprise ID** — `teamOrEnterpriseID` keys off + `EnterpriseID != ""` instead of `IsEnterpriseInstall`, so an org-grid + *workspace* install opens the Builder in the org (`E…`) context instead of the + workspace (`T…`) context. +3. **#3 Stdin/team-prompt conflict** — after consuming piped stdin for the + blocks, the interactive "Select a team" prompt reads the now-exhausted stdin + pipe and fails on EOF (only masked when the user has exactly one auth). +4. **#4 Experimental command not hidden** — the `blocks` command is listed in + `slack --help` for all users despite being experimental and non-functional + without the flag. +5. **#5 Unfriendly empty-arg error** — an empty input produces a raw + `ErrUnableToParseJSON` instead of the friendly "No blocks were provided". +6. **#6 Malformed URL for bad host** — `buildBlockKitBuilderURL` silently emits + garbage (`//app./…`) for an empty or scheme-less host. +7. **#7 Unwrapped `url.Parse` error** — the raw stdlib error is returned across a + package boundary without a structured `slackerror` code (CLAUDE.md rule). + +## Design + +### 1. Input model: `--blocks` flag with `-` stdin sentinel + +Replace the positional `[blocks]` argument with a `--blocks` string flag, +following the `slack api --json ''` precedent. The command becomes +`Args: cobra.NoArgs`. + +Behavior matrix: + +| Invocation | Source | Notes | +|---|---|---| +| `preview --blocks '[...]'` | literal flag value | stdin untouched | +| `cat f \| preview --blocks -` | stdin (explicit `-` sentinel) | | +| `cat f \| preview` | stdin (auto-detected pipe) | no flag needed | +| `preview` on a TTY, no flag | friendly `ErrMissingInput` error | **no hang** (fixes #1) | + +A new `resolveBlocksInput` function resolves the source in this order: + +1. `--blocks` set and value `!= "-"` → use the literal value. An empty string + returns a friendly `ErrMissingInput` "No blocks were provided" (fixes #5). +2. `--blocks -`, **or** flag unset but stdin is a pipe → `io.ReadAll(stdin)`. +3. Flag unset and stdin is an interactive terminal → friendly `ErrMissingInput` + with remediation. Critically, **no `ReadAll` runs against a TTY**, so the + command cannot block (fixes #1). + +`resolveBlocksInput` also returns a boolean indicating whether the blocks came +from stdin; Section 2 consumes it. + +#### Pipe detection (infrastructure) + +For `cat f | preview`, stdout is still a TTY — only *stdin* is the pipe — so the +existing `IsTTY()` (which stats stdout) cannot gate stdin reads. We generalize +the existing TTY mechanism rather than special-casing stdin: + +- Add `Stdin() types.File` to the `types.Os` interface, the `Os` implementation + (`return os.Stdin`), and `OsMock` (`AddDefaultMocks` gains + `m.On("Stdin").Return(os.Stdin)`), mirroring the existing `Stdout()`. +- Add `IsStdinTTY() bool` to the `IOStreamer` interface, implemented exactly like + `IsTTY()` but statting stdin: + `(mode & os.ModeCharDevice) == os.ModeCharDevice`. A pipe is therefore + "not a stdin TTY". + +This keeps the fix at the right altitude and remains mockable, so tests stay +hermetic. + +### 2. Team selection when stdin is consumed (fix #3) + +When blocks are read from stdin, the interactive team picker would read the +exhausted pipe and fail. We rely on what `PromptTeamSlackAuth` already does: + +- returns the sole auth directly when exactly one exists (no prompt — works even + with consumed stdin), and +- honors the `--team` flag via `SelectPromptConfig{Flag: teamFlag}` before + touching stdin. + +The only broken case is **blocks-from-stdin + ≥2 auths + no `--team`**. We detect +exactly that case *before* calling the picker and return a clean, actionable +error instead of proceeding into a survey form that fails on dead stdin: + +``` +Error: the team could not be determined +Suggestion: Select a team with the --team flag when piping blocks +``` + +When blocks come from `--blocks ''`, stdin is untouched and the picker +works interactively as before — no change. The "came from stdin" boolean from +`resolveBlocksInput` drives this; no extra plumbing. + +### 3. Remaining fixes + +**#2 — Enterprise ID selection.** Change the discriminator to +`auth.IsEnterpriseInstall`, matching `SlackAuth.AuthLevel()`: + +```go +func teamOrEnterpriseID(auth *types.SlackAuth) string { + if auth.IsEnterpriseInstall { + return auth.EnterpriseID + } + return auth.TeamID +} +``` + +An org-grid workspace install (`EnterpriseID` set, `IsEnterpriseInstall=false`) +now correctly opens the Builder in the workspace `T…` context. + +**#4 — Hide the experimental command.** Set `Hidden: true` on the `blocks` parent +command in `blocks.go`, with a comment to remove it when the `block-kit-builder` +experiment graduates. The `preview` `PreRunE` experiment gate stays as-is. + +**#6 + #7 — URL hardening in `buildBlockKitBuilderURL`.** + +- Wrap the `url.Parse` error: + `return "", slackerror.Wrap(err, slackerror.ErrInvalidArguments)`. +- After parsing, if `parsed.Host == ""` (the result for `""` or a scheme-less + `"app.slack.com"`), return `slackerror.New(slackerror.ErrInvalidArguments)` + instead of silently building `//app./…`. + +## Testing + +### `cmd/blocks/preview_test.go` + +- `--blocks '[...]'` literal → opens builder (replaces the old positional case). +- `cat | preview --blocks -` → explicit stdin sentinel. +- `cat | preview` (piped, no flag) → auto-detected stdin; mock stdin stat reports + a pipe (not `ModeCharDevice`). +- bare `preview` on a TTY, no flag → `ErrMissingInput`; assert neither + `OpenURL` nor a blocking `ReadAll` is reached (no hang). +- `--blocks ""` → friendly "No blocks were provided". +- blocks-from-stdin + ≥2 auths + no `--team` → clean error pointing to `--team` + (fix #3). +- blocks-from-stdin + `--team` set → succeeds. +- org-grid workspace install (`EnterpriseID` set, `IsEnterpriseInstall=false`) → + uses `T…` (fix #2). +- `buildBlockKitBuilderURL` empty-host and scheme-less-host → error (#6/#7). + +### `cmd/blocks/blocks_test.go` + +- assert the `blocks` command has `Hidden == true` (#4). + +### Infrastructure tests + +- `IsStdinTTY()` and `Os.Stdin()` follow the existing `IsTTY()`/`Stdout()` mock + patterns. + +## Docs + +Rerun `slack docgen ./docs/reference` to regenerate `slack_blocks.md` and +`slack_blocks_preview.md` (the `--blocks` flag, `NoArgs`, and updated examples +flow from the command definitions). + +Because `blocks` becomes `Hidden`, confirm whether docgen still emits hidden +commands. If hidden commands are excluded from generated docs, revert the +`slack.md` index line and the two generated pages so the published docs match +reality until the experiment graduates. + +## Verification + +- `make test testdir=cmd/blocks` +- `make lint` +- `gofmt -w` on all changed Go files +- Manual smoke test: + `./bin/slack blocks preview --experiment block-kit-builder --blocks '[{"type":"divider"}]'` + +## Out of scope + +- Any change to the Block Kit Builder URL format beyond the empty-host guard. +- Removing the `block-kit-builder` experiment gate (graduation is a later + change). From 6d3f0660ed7a50b822f8d94545ad36197c8396fa Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 20 Jul 2026 15:17:17 -0400 Subject: [PATCH 02/17] docs: add implementation plan for blocks preview input rework Co-Authored-By: Claude --- .../2026-07-20-blocks-preview-input-rework.md | 982 ++++++++++++++++++ 1 file changed, 982 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-20-blocks-preview-input-rework.md diff --git a/docs/superpowers/plans/2026-07-20-blocks-preview-input-rework.md b/docs/superpowers/plans/2026-07-20-blocks-preview-input-rework.md new file mode 100644 index 00000000..1fbcaef5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-blocks-preview-input-rework.md @@ -0,0 +1,982 @@ +# Blocks Preview Input Rework Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rework `slack blocks preview` to take blocks via a `--blocks` flag (with a `-` stdin sentinel and auto pipe-detection) and fold in the seven code-review findings. + +**Architecture:** Add a small, testable stdin-TTY detection capability to the shared `Os`/`IOStreams` infrastructure, then rework `cmd/blocks/preview.go` input resolution on top of it. The remaining fixes (enterprise ID, team-prompt guard, URL hardening, hidden command, docs) are localized edits to `cmd/blocks/`. + +**Tech Stack:** Go, Cobra, testify/mock, afero. Test harness: `test/testutil.TableTestCommand`. + +## Global Constraints + +- Wrap errors crossing package boundaries with `slackerror.Wrap(err, slackerror.ErrCode)` (repo CLAUDE.md). +- Register new error codes in `internal/slackerror/errors.go`, alphabetically, in both the const block and `ErrorCodeMap`. (No new codes are needed in this plan — `ErrInvalidBlocks`, `ErrMissingInput`, `ErrMissingFlag`, `ErrInvalidArguments` all already exist.) +- Use `clients.Fs` / the `Os` abstraction, never direct `os` calls, so code stays testable. +- Test naming: `Test_StructName_FunctionName` or `Test_FunctionName`. Constructors first, then alphabetical. +- Table-driven tests use the map pattern with `tc` as the case variable. +- Run `gofmt -w` on every changed Go file. Run `make lint` before finishing. +- Commit messages use conventional-commit prefixes and end with: + `Co-Authored-By: Claude ` +- The entire `cmd/blocks/` feature is currently uncommitted working-tree state on this branch. "Modify" below means editing an existing working-tree file even though it is not yet committed. + +--- + +### Task 1: Stdin-TTY detection infrastructure + +Adds `Os.Stdin()` and `IOStreams.IsStdinTTY()`, mirroring the existing `Os.Stdout()` / `IsTTY()`. `IsTTY()` stats **stdout**; for `cat f | preview` stdout is still a terminal while only stdin is a pipe, so a separate stdin check is required. + +**Files:** +- Modify: `internal/shared/types/slackdeps.go` (add `Stdin() File` to the `Os` interface, after `Stdout() File` at line 67) +- Modify: `internal/slackdeps/os.go` (add `Stdin()` impl after `Stdout()` at line 100) +- Modify: `internal/slackdeps/os_mock.go` (add `Stdin()` mock method + default in `AddDefaultMocks`) +- Modify: `internal/iostreams/iostreams.go` (add `IsStdinTTY()` to `IOStreamer` interface + impl) +- Modify: `internal/iostreams/iostreams_mock.go` (add `IsStdinTTY()` mock method + default) +- Test: `internal/iostreams/iostreams_test.go` + +**Interfaces:** +- Produces: + - `types.Os` interface gains `Stdin() File` + - `(*slackdeps.Os).Stdin() types.File` + - `(*slackdeps.OsMock).Stdin() types.File` + - `IOStreamer` interface gains `IsStdinTTY() bool` + - `(*iostreams.IOStreams).IsStdinTTY() bool` — returns true when stdin is a character device (interactive terminal), false when piped/redirected or on stat error + - `(*iostreams.IOStreamsMock).IsStdinTTY() bool` + +- [ ] **Step 1: Write the failing test** + +Add to `internal/iostreams/iostreams_test.go` (immediately after `Test_IOStreams_IsTTY`, keeping alphabetical-ish grouping with the other `IsTTY` test): + +```go +func Test_IOStreams_IsStdinTTY(t *testing.T) { + tests := map[string]struct { + fileInfo os.FileInfo + expected bool + }{ + "interactive when stdin is a char device": { + fileInfo: &slackdeps.FileInfoCharDevice{}, + expected: true, + }, + "not interactive when stdin is a named pipe": { + fileInfo: &slackdeps.FileInfoNamedPipe{}, + expected: false, + }, + "not interactive when the stat check errors": { + expected: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + fsMock := slackdeps.NewFsMock() + osMock := slackdeps.NewOsMock() + config := config.NewConfig(fsMock, osMock) + osMock.On("Stdin").Return(&slackdeps.FileMock{FileInfo: tc.fileInfo}) + io := NewIOStreams(config, fsMock, osMock) + + isStdinTTY := io.IsStdinTTY() + assert.Equal(t, tc.expected, isStdinTTY) + }) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `make test testdir=internal/iostreams testname=Test_IOStreams_IsStdinTTY` +Expected: FAIL — compile error, `io.IsStdinTTY` undefined and `osMock.On("Stdin")` has no matching method. + +- [ ] **Step 3: Add `Stdin()` to the `Os` interface** + +In `internal/shared/types/slackdeps.go`, add to the `Os` interface right after the `Stdout() File` line (line 67): + +```go + // Stdout returns the file descriptor for stdout + Stdout() File + + // Stdin returns the file descriptor for stdin + Stdin() File +``` + +- [ ] **Step 4: Implement `Os.Stdin()`** + +In `internal/slackdeps/os.go`, add after the `Stdout()` method (after line 100): + +```go +// Stdin returns the file descriptor for stdin +func (c *Os) Stdin() types.File { + return os.Stdin +} +``` + +- [ ] **Step 5: Add the `OsMock.Stdin()` method and default** + +In `internal/slackdeps/os_mock.go`, add to `AddDefaultMocks` right after `m.On("Stdout").Return(os.Stdout)` (line 58): + +```go + m.On("Stdin").Return(os.Stdin) +``` + +Then add the method after the `Stdout()` mock method (after line 138): + +```go +// Stdin mocks the stdin with a file that can be adjusted +func (m *OsMock) Stdin() types.File { + args := m.Called() + return args.Get(0).(types.File) +} +``` + +- [ ] **Step 6: Add `IsStdinTTY()` to the `IOStreamer` interface + implement it** + +In `internal/iostreams/iostreams.go`, add to the `IOStreamer` interface right after the `IsTTY() bool` declaration (line 83): + +```go + // IsTTY returns true if the device is an interactive terminal + IsTTY() bool + + // IsStdinTTY returns true if stdin is an interactive terminal (not a pipe + // or redirected file) + IsStdinTTY() bool +``` + +Then add the implementation right after the `IsTTY()` method (after its closing brace near line 131): + +```go +// IsStdinTTY returns true if stdin is an interactive terminal +// +// Unlike IsTTY, which inspects stdout, this inspects stdin so that piped or +// redirected input (e.g. `cat blocks.json | slack ...`) is detected even when +// stdout is still attached to a terminal. +func (io *IOStreams) IsStdinTTY() bool { + if o, err := io.os.Stdin().Stat(); o == nil || err != nil { + return false + } else { + return (o.Mode() & os.ModeCharDevice) == os.ModeCharDevice + } +} +``` + +- [ ] **Step 7: Add the `IOStreamsMock.IsStdinTTY()` method and default** + +In `internal/iostreams/iostreams_mock.go`, add to `AddDefaultMocks` right after `m.On("IsTTY").Return(false)` (line 73): + +```go + m.On("IsStdinTTY").Return(false) +``` + +Then add the method right after the `IsTTY()` mock method (after line 98): + +```go +func (m *IOStreamsMock) IsStdinTTY() bool { + args := m.Called() + return args.Bool(0) +} +``` + +- [ ] **Step 8: Run the test to verify it passes** + +Run: `make test testdir=internal/iostreams testname=Test_IOStreams_IsStdinTTY` +Expected: PASS (all three sub-cases). + +- [ ] **Step 9: Run the broader packages to catch interface-conformance breaks** + +Run: `make test testdir=internal/iostreams` then `make test testdir=internal/slackdeps` +Expected: PASS. (If any other `IOStreamer` implementer exists it would fail to compile here; there is only `IOStreams` and `IOStreamsMock`.) + +- [ ] **Step 10: Format and commit** + +```bash +gofmt -w internal/shared/types/slackdeps.go internal/slackdeps/os.go internal/slackdeps/os_mock.go internal/iostreams/iostreams.go internal/iostreams/iostreams_mock.go internal/iostreams/iostreams_test.go +git add internal/shared/types/slackdeps.go internal/slackdeps/os.go internal/slackdeps/os_mock.go internal/iostreams/iostreams.go internal/iostreams/iostreams_mock.go internal/iostreams/iostreams_test.go +git commit -m "$(cat <<'EOF' +feat: add IsStdinTTY for detecting piped stdin + +Adds Os.Stdin() and IOStreams.IsStdinTTY(), mirroring Stdout()/IsTTY(), +so callers can distinguish piped/redirected stdin from an interactive +terminal even when stdout is still a TTY. + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +### Task 2: Rework preview input model (`--blocks` flag, `-` sentinel, no-hang) + +Replaces the positional `[blocks]` arg with a `--blocks` string flag, adds `-` and auto-pipe stdin handling, and makes bare `preview` on a terminal error instead of hanging. Fixes findings #1 and #5. + +**Files:** +- Modify: `cmd/blocks/preview.go` +- Test: `cmd/blocks/preview_test.go` + +**Interfaces:** +- Consumes: `clients.IO.IsStdinTTY() bool` (Task 1); `clients.IO.ReadIn() io.Reader`; `promptTeamSlackAuthFunc` (existing package var). +- Produces: + - `resolveBlocksInput(clients *shared.ClientFactory, flagValue string, flagChanged bool) (input string, fromStdin bool, err error)` + - `previewCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, blocksFlag string, blocksFlagChanged bool) error` (signature changes — no longer takes `args`) + - `--blocks` flag on the preview command; command is now `Args: cobra.NoArgs` + - `fromStdin` boolean consumed by Task 3 + +- [ ] **Step 1: Write the failing tests** + +Replace the body of `Test_Blocks_PreviewCommand` in `cmd/blocks/preview_test.go` with the cases below (the argument-based case becomes flag-based; add the stdin-sentinel, auto-pipe, and no-hang cases). Keep the existing `enableExperiment` and `stubTeamAuth` helpers. + +```go +func Test_Blocks_PreviewCommand(t *testing.T) { + var restore func() + testutil.TableTestCommand(t, testutil.CommandTests{ + "opens the builder with blocks from the --blocks flag": { + CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + enableExperiment(ctx, cm) + restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + expectedURL := `https://app.slack.com/block-kit-builder/T123/builder#%7B%22blocks%22:%5B%7B%22type%22:%22divider%22%7D%5D%7D` + cm.Browser.AssertCalled(t, "OpenURL", expectedURL) + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.BlocksPreviewSuccess, []string{expectedURL}) + }, + Teardown: func() { restore() }, + }, + "opens the builder with blocks from stdin via the - sentinel": { + CmdArgs: []string{"--blocks", "-"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) + enableExperiment(ctx, cm) + restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + expectedURL := `https://app.slack.com/block-kit-builder/T123/builder#%7B%22blocks%22:%5B%7B%22type%22:%22divider%22%7D%5D%7D` + cm.Browser.AssertCalled(t, "OpenURL", expectedURL) + }, + Teardown: func() { restore() }, + }, + "opens the builder with auto-detected piped stdin": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) + // default IsStdinTTY() is false (piped) + enableExperiment(ctx, cm) + restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + expectedURL := `https://app.slack.com/block-kit-builder/T123/builder#%7B%22blocks%22:%5B%7B%22type%22:%22divider%22%7D%5D%7D` + cm.Browser.AssertCalled(t, "OpenURL", expectedURL) + }, + Teardown: func() { restore() }, + }, + "accepts a blocks object payload": { + CmdArgs: []string{"--blocks", `{"blocks":[{"type":"divider"}]}`}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + enableExperiment(ctx, cm) + restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + expectedURL := `https://app.slack.com/block-kit-builder/T123/builder#%7B%22blocks%22:%5B%7B%22type%22:%22divider%22%7D%5D%7D` + cm.Browser.AssertCalled(t, "OpenURL", expectedURL) + }, + Teardown: func() { restore() }, + }, + "errors when the experiment is not enabled": { + CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, + ExpectedErrorStrings: []string{slackerror.ErrMissingExperiment, "experimental"}, + }, + "errors without hanging when no blocks are provided on a terminal": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("IsStdinTTY").Return(true) + enableExperiment(ctx, cm) + }, + ExpectedErrorStrings: []string{slackerror.ErrMissingInput, "No blocks were provided"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertNotCalled(t, "OpenURL", mock.Anything) + }, + }, + "errors when the --blocks flag is empty": { + CmdArgs: []string{"--blocks", ""}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + enableExperiment(ctx, cm) + }, + ExpectedErrorStrings: []string{slackerror.ErrMissingInput, "No blocks were provided"}, + }, + "errors when the blocks are not valid json": { + CmdArgs: []string{"--blocks", `not json`}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + enableExperiment(ctx, cm) + }, + ExpectedErrorStrings: []string{slackerror.ErrUnableToParseJSON}, + }, + "errors when the json is not a blocks payload": { + CmdArgs: []string{"--blocks", `{"foo":"bar"}`}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + enableExperiment(ctx, cm) + }, + ExpectedErrorStrings: []string{slackerror.ErrInvalidBlocks}, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewPreviewCommand(cf) + }) +} +``` + +Note: the `Test_buildBlockKitBuilderURL` and `Test_normalizeBlocksPayload` functions in this file are unchanged by this task. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `make test testdir=cmd/blocks testname=Test_Blocks_PreviewCommand` +Expected: FAIL — the command still uses a positional arg, so `--blocks` is an unknown flag and the argument-based expectations do not match. + +- [ ] **Step 3: Rework the command constructor and input resolution** + +In `cmd/blocks/preview.go`, replace `NewPreviewCommand`, `previewCommandRunE`, and `readBlocksInput` with the following. Keep `previewCommandPreRunE`, `normalizeBlocksPayload`, `teamOrEnterpriseID`, and `buildBlockKitBuilderURL` as they are (later tasks touch two of them). The `io` import stays (used by `readStdinBlocks`). + +```go +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 or pipe them through standard input.", + "The input is a JSON array of blocks or a JSON object with a \"blocks\" array.", + "Pass - to --blocks to read explicitly from standard input.", + }, "\n"), + Example: style.ExampleCommandsf([]style.ExampleCommand{ + { + Meaning: "Preview blocks passed with a flag", + Command: "blocks preview --blocks '[{\"type\":\"divider\"}]'", + }, + { + Meaning: "Preview blocks piped from a file", + Command: "blocks preview < blocks.json", + }, + }), + Args: cobra.NoArgs, + PreRunE: func(cmd *cobra.Command, args []string) error { + return previewCommandPreRunE(clients) + }, + 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 +} + +// previewCommandRunE resolves blocks from the flag or standard input and opens +// them in the Block Kit Builder for the selected team +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 := promptTeamSlackAuthFunc(ctx, clients, "Select a team to preview blocks for", nil) + 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 +} + +// resolveBlocksInput returns the blocks to preview and whether they were read +// from standard input. Resolution order: an explicit --blocks value, the - +// sentinel or an auto-detected stdin pipe, otherwise a friendly error. Reading +// stdin is never attempted against an interactive terminal, so a bare command +// on a TTY errors instead of blocking on io.ReadAll. +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 + case !clients.IO.IsStdinTTY(): + return readStdinBlocks(clients) + default: + return "", false, missingBlocksError() + } +} + +// readStdinBlocks reads and trims blocks from standard input +func readStdinBlocks(clients *shared.ClientFactory) (string, bool, error) { + 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 +} + +// missingBlocksError is the friendly error returned when no blocks are supplied +func missingBlocksError() error { + return slackerror.New(slackerror.ErrMissingInput). + WithMessage("No blocks were provided"). + WithRemediation("Provide blocks with the %s flag or pipe them through standard input", style.Highlight("--blocks")) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `make test testdir=cmd/blocks testname=Test_Blocks_PreviewCommand` +Expected: PASS (all cases, including the no-hang and empty-flag cases). + +- [ ] **Step 5: Format and commit** + +```bash +gofmt -w cmd/blocks/preview.go cmd/blocks/preview_test.go +git add cmd/blocks/preview.go cmd/blocks/preview_test.go +git commit -m "$(cat <<'EOF' +feat: accept blocks via --blocks flag with stdin sentinel + +Replaces the positional argument with a --blocks flag, supports the - +stdin sentinel and auto-detected piped stdin, and errors on a bare +interactive invocation instead of blocking forever on stdin. + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +### Task 3: Guard team selection when blocks come from stdin + +When blocks are read from stdin, the interactive team picker would read the exhausted stdin pipe and fail on EOF. Detect the one broken case — stdin input + more than one auth + no `--team` — and return a clean, actionable error before prompting. Fixes finding #3. + +**Files:** +- Modify: `cmd/blocks/preview.go` +- Test: `cmd/blocks/preview_test.go` + +**Interfaces:** +- Consumes: `fromStdin` from `resolveBlocksInput` (Task 2); `clients.Config.TeamFlag string`; `clients.Auth().Auths(ctx) ([]types.SlackAuth, error)`; `promptTeamSlackAuthFunc`. +- Produces: `selectTeamAuth(ctx context.Context, clients *shared.ClientFactory, fromStdin bool) (*types.SlackAuth, error)` + +- [ ] **Step 1: Write the failing tests** + +Add these two cases inside the `testutil.CommandTests{...}` map in `Test_Blocks_PreviewCommand` (in `cmd/blocks/preview_test.go`): + +```go + "errors when piping blocks with multiple teams and no --team flag": { + CmdArgs: []string{"--blocks", "-"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {TeamID: "T123", TeamDomain: "team-a"}, + {TeamID: "T456", TeamDomain: "team-b"}, + }, nil) + enableExperiment(ctx, cm) + }, + ExpectedErrorStrings: []string{slackerror.ErrMissingFlag, "--team"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertNotCalled(t, "OpenURL", mock.Anything) + }, + }, + "opens the builder when piping blocks with the --team flag set": { + CmdArgs: []string{"--blocks", "-", "--team", "T123"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) + enableExperiment(ctx, cm) + restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", mock.MatchedBy(func(url string) bool { + return assert.Contains(t, url, "/block-kit-builder/T123/builder") + })) + }, + Teardown: func() { restore() }, + }, +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `make test testdir=cmd/blocks testname=Test_Blocks_PreviewCommand` +Expected: FAIL — the multi-team stdin case currently proceeds to the stubbed/real prompt instead of returning `ErrMissingFlag`. + +- [ ] **Step 3: Add the team-selection guard** + +In `cmd/blocks/preview.go`, add the `context` import if it is not already imported (it is needed for the new function signature), then add this function directly below `previewCommandRunE`: + +```go +// selectTeamAuth chooses the team whose Block Kit Builder to open. When blocks +// were read from stdin the interactive picker cannot prompt (stdin is spent), +// so with more than one authorization and no --team flag we return an +// actionable error instead of failing on EOF inside the prompt. +func selectTeamAuth(ctx context.Context, clients *shared.ClientFactory, fromStdin bool) (*types.SlackAuth, error) { + if fromStdin && clients.Config.TeamFlag == "" { + auths, err := clients.Auth().Auths(ctx) + if err != nil { + return nil, err + } + if 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 piping blocks", style.Highlight("--team")) + } + } + return promptTeamSlackAuthFunc(ctx, clients, "Select a team to preview blocks for", nil) +} +``` + +Then, in `previewCommandRunE`, capture the `fromStdin` return value and route team selection through `selectTeamAuth`. Change: + +```go + blocksInput, _, err := resolveBlocksInput(clients, blocksFlag, blocksFlagChanged) +``` +to: +```go + blocksInput, fromStdin, err := resolveBlocksInput(clients, blocksFlag, blocksFlagChanged) +``` + +and change: + +```go + auth, err := promptTeamSlackAuthFunc(ctx, clients, "Select a team to preview blocks for", nil) +``` +to: +```go + auth, err := selectTeamAuth(ctx, clients, fromStdin) +``` + +Add `"context"` to the import block if absent. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `make test testdir=cmd/blocks testname=Test_Blocks_PreviewCommand` +Expected: PASS. In particular the multi-team stdin case returns `ErrMissingFlag` and does not open a browser, and the single-team stdin cases from Task 2 still pass (the stub returns before the `len(auths) > 1` check matters). + +- [ ] **Step 5: Format and commit** + +```bash +gofmt -w cmd/blocks/preview.go cmd/blocks/preview_test.go +git add cmd/blocks/preview.go cmd/blocks/preview_test.go +git commit -m "$(cat <<'EOF' +fix: require --team when piping blocks with multiple auths + +Reading blocks from stdin consumes the pipe, so the interactive team +picker cannot prompt. Detect stdin input with more than one auth and no +--team flag and return an actionable error instead of failing on EOF. + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +### Task 4: Use the enterprise ID only for enterprise installs + +`teamOrEnterpriseID` currently keys off `EnterpriseID != ""`, so an org-grid workspace install (enterprise ID present but not an enterprise install) opens the wrong Builder context. Key off `IsEnterpriseInstall` instead, matching `SlackAuth.AuthLevel()`. Fixes finding #2. + +**Files:** +- Modify: `cmd/blocks/preview.go:170-175` +- Test: `cmd/blocks/preview_test.go` + +**Interfaces:** +- Produces: `teamOrEnterpriseID(auth *types.SlackAuth) string` — unchanged signature, corrected behavior. + +- [ ] **Step 1: Write the failing tests** + +Add these two cases to the `testutil.CommandTests{...}` map in `Test_Blocks_PreviewCommand`: + +```go + "uses the enterprise id for enterprise installs": { + CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + enableExperiment(ctx, cm) + restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123", EnterpriseID: "E456", IsEnterpriseInstall: true}) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", mock.MatchedBy(func(url string) bool { + return assert.Contains(t, url, "/block-kit-builder/E456/builder") + })) + }, + Teardown: func() { restore() }, + }, + "uses the team id for org-grid workspace installs": { + CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + enableExperiment(ctx, cm) + restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123", EnterpriseID: "E456", IsEnterpriseInstall: false}) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", mock.MatchedBy(func(url string) bool { + return assert.Contains(t, url, "/block-kit-builder/T123/builder") + })) + }, + Teardown: func() { restore() }, + }, +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `make test testdir=cmd/blocks testname=Test_Blocks_PreviewCommand` +Expected: FAIL — the org-grid workspace case currently produces `/block-kit-builder/E456/builder` instead of `T123`. + +- [ ] **Step 3: Fix the discriminator** + +In `cmd/blocks/preview.go`, replace `teamOrEnterpriseID`: + +```go +// teamOrEnterpriseID returns the enterprise ID for enterprise installs and the +// team ID otherwise, matching the identifier used in Block Kit Builder URLs +func teamOrEnterpriseID(auth *types.SlackAuth) string { + if auth.IsEnterpriseInstall { + return auth.EnterpriseID + } + return auth.TeamID +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `make test testdir=cmd/blocks testname=Test_Blocks_PreviewCommand` +Expected: PASS (both the enterprise-install and org-grid-workspace cases). + +- [ ] **Step 5: Format and commit** + +```bash +gofmt -w cmd/blocks/preview.go cmd/blocks/preview_test.go +git add cmd/blocks/preview.go cmd/blocks/preview_test.go +git commit -m "$(cat <<'EOF' +fix: use enterprise id only for enterprise installs in blocks preview + +An org-grid workspace install has an enterprise ID but is not an +enterprise install; key off IsEnterpriseInstall so those installs open +the Builder in the workspace context. + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +### Task 5: Harden `buildBlockKitBuilderURL` against bad hosts + +Guard against an empty or scheme-less host (which otherwise silently yields a malformed URL like `//app./...`) and wrap the `url.Parse` error with a structured code. Fixes findings #6 and #7. + +**Files:** +- Modify: `cmd/blocks/preview.go:180-189` +- Test: `cmd/blocks/preview_test.go` + +**Interfaces:** +- Produces: `buildBlockKitBuilderURL(apiHost string, id string, blocksJSON string) (string, error)` — unchanged signature; now errors on invalid hosts. + +- [ ] **Step 1: Write the failing tests** + +In `cmd/blocks/preview_test.go`, extend `Test_buildBlockKitBuilderURL` to cover error cases. Replace the test with: + +```go +func Test_buildBlockKitBuilderURL(t *testing.T) { + tests := map[string]struct { + apiHost string + id string + blocksJSON string + expected string + expectedErr string + }{ + "production host": { + apiHost: "https://slack.com", + id: "T123", + blocksJSON: `{"blocks":[]}`, + expected: "https://app.slack.com/block-kit-builder/T123/builder#%7B%22blocks%22:%5B%5D%7D", + }, + "developer host": { + apiHost: "https://dev1234.slack.com", + id: "E456", + blocksJSON: `{"blocks":[]}`, + expected: "https://app.dev1234.slack.com/block-kit-builder/E456/builder#%7B%22blocks%22:%5B%5D%7D", + }, + "empty host": { + apiHost: "", + id: "T123", + blocksJSON: `{"blocks":[]}`, + expectedErr: slackerror.ErrInvalidArguments, + }, + "scheme-less host": { + apiHost: "app.slack.com", + id: "T123", + blocksJSON: `{"blocks":[]}`, + expectedErr: slackerror.ErrInvalidArguments, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + actual, err := buildBlockKitBuilderURL(tc.apiHost, tc.id, tc.blocksJSON) + if tc.expectedErr != "" { + require.Error(t, err) + assert.Equal(t, tc.expectedErr, slackerror.ToSlackError(err).Code) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expected, actual) + }) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `make test testdir=cmd/blocks testname=Test_buildBlockKitBuilderURL` +Expected: FAIL — the empty and scheme-less host cases currently return no error. + +- [ ] **Step 3: Add the host guard and wrap the parse error** + +In `cmd/blocks/preview.go`, replace `buildBlockKitBuilderURL`: + +```go +// buildBlockKitBuilderURL constructs the Block Kit Builder URL for the given +// API host, team or enterprise ID, and compact blocks JSON. The blocks JSON is +// placed in the URL fragment, which url.URL.String percent-encodes. +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 +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `make test testdir=cmd/blocks testname=Test_buildBlockKitBuilderURL` +Expected: PASS (all four cases). + +- [ ] **Step 5: Format and commit** + +```bash +gofmt -w cmd/blocks/preview.go cmd/blocks/preview_test.go +git add cmd/blocks/preview.go cmd/blocks/preview_test.go +git commit -m "$(cat <<'EOF' +fix: reject invalid API hosts when building Block Kit Builder URL + +Guard against empty or scheme-less hosts that would otherwise yield a +malformed URL, and wrap the url.Parse error with a structured code. + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +### Task 6: Hide the experimental command and remove its generated docs + +Hide the `blocks` command until the `block-kit-builder` experiment graduates, and remove the generated doc pages/index entry (docgen skips hidden commands via `IsAvailableCommand()`). Fixes finding #4. + +**Files:** +- Modify: `cmd/blocks/blocks.go` +- Test: `cmd/blocks/blocks_test.go` +- Delete: `docs/reference/commands/slack_blocks.md` (currently untracked) +- Delete: `docs/reference/commands/slack_blocks_preview.md` (currently untracked) +- Modify: `docs/reference/commands/slack.md` (remove the added `slack blocks` index line) + +**Interfaces:** +- Consumes: nothing new. +- Produces: `blocks` command has `Hidden: true`; example text updated to `--blocks` form. + +- [ ] **Step 1: Write the failing test** + +Replace `Test_Blocks_Command` in `cmd/blocks/blocks_test.go` with a version that asserts the command is hidden. Because a hidden command still prints its own help when invoked directly, the existing output expectations remain valid; add the hidden assertion via `ExpectedAsserts` is not suitable here (it inspects mocks, not the command), so assert on the constructed command directly in a small standalone test: + +```go +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) { + clientsMock := shared.NewClientsMock() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + cmd := NewCommand(clients) + assert.True(t, cmd.Hidden, "blocks command should be hidden while experimental") +} +``` + +Add the import `"github.com/stretchr/testify/assert"` to `cmd/blocks/blocks_test.go`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `make test testdir=cmd/blocks testname=Test_Blocks_Command_Hidden` +Expected: FAIL — `cmd.Hidden` is false. + +- [ ] **Step 3: Hide the command and update the example** + +In `cmd/blocks/blocks.go`, add `Hidden: true` with an explanatory comment and update the example to the `--blocks` form: + +```go + cmd := &cobra.Command{ + Use: "blocks [flags]", + Short: "Work with Block Kit blocks", + Long: "Work with Block Kit blocks, such as previewing them in the Block Kit Builder.", + // Hidden while gated behind the block-kit-builder experiment. Remove + // when the experiment graduates. + Hidden: true, + 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() + }, + } +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `make test testdir=cmd/blocks` +Expected: PASS (both `Test_Blocks_Command` and `Test_Blocks_Command_Hidden`). + +- [ ] **Step 5: Remove the generated docs and index entry** + +The two `slack_blocks*.md` pages are untracked; remove them from disk. Edit `slack.md` to drop the index line. + +```bash +rm docs/reference/commands/slack_blocks.md docs/reference/commands/slack_blocks_preview.md +``` + +In `docs/reference/commands/slack.md`, remove this line: + +``` +* [slack blocks](slack_blocks) - Work with Block Kit blocks +``` + +Optional verification that docgen agrees (regenerates docs without the hidden command; requires a build): + +```bash +make build-ci && ./bin/slack docgen ./docs/reference && git status docs/reference/commands +``` +Expected: no `slack_blocks*.md` files reappear and `slack.md` has no `slack blocks` line. If docgen produces unrelated diffs, discard them (`git checkout -- `); this task only owns the blocks-related doc changes. + +- [ ] **Step 6: Format and commit** + +```bash +gofmt -w cmd/blocks/blocks.go cmd/blocks/blocks_test.go +git add cmd/blocks/blocks.go cmd/blocks/blocks_test.go docs/reference/commands/slack.md +git rm --ignore-unmatch docs/reference/commands/slack_blocks.md docs/reference/commands/slack_blocks_preview.md +git commit -m "$(cat <<'EOF' +chore: hide experimental blocks command from help and docs + +Hide the blocks command while gated behind the block-kit-builder +experiment and drop its generated doc pages, which docgen omits for +hidden commands. + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +### Task 7: Full verification sweep + +Confirm the whole feature builds, lints, and passes tests together, plus a manual smoke test. + +**Files:** none (verification only). + +- [ ] **Step 1: Run the full affected test packages** + +Run: +```bash +make test testdir=cmd/blocks +make test testdir=internal/iostreams +make test testdir=internal/slackdeps +``` +Expected: PASS for all. + +- [ ] **Step 2: Lint** + +Run: `make lint` +Expected: no findings in `cmd/blocks/`, `internal/iostreams/`, `internal/slackdeps/`, `internal/shared/types/`. + +- [ ] **Step 3: Build** + +Run: `make build-ci` +Expected: builds `./bin/slack` with no errors. + +- [ ] **Step 4: Manual smoke test — flag path** + +Run: `./bin/slack blocks preview --experiment block-kit-builder --blocks '[{"type":"divider"}]' --team ` +Expected: prints the Block Kit Builder section with an `https://app./block-kit-builder//builder#...` URL and attempts to open the browser. + +- [ ] **Step 5: Manual smoke test — no hang on bare invocation** + +Run: `./bin/slack blocks preview --experiment block-kit-builder` in an interactive terminal (no pipe). +Expected: returns immediately with "No blocks were provided" and remediation mentioning `--blocks` — does NOT hang waiting on stdin. + +- [ ] **Step 6: Manual smoke test — piped stdin** + +Run: `echo '[{"type":"divider"}]' | ./bin/slack blocks preview --experiment block-kit-builder --team ` +Expected: same successful output as Step 4. + +- [ ] **Step 7: Confirm the command is hidden** + +Run: `./bin/slack --help` and `./bin/slack --help --verbose` +Expected: `blocks` is absent from the standard command list. + +--- + +## Notes for the implementer + +- Findings mapped to tasks: #1 → Task 2; #2 → Task 4; #3 → Task 3; #4 → Task 6; #5 → Task 2; #6 → Task 5; #7 → Task 5. The stdin-TTY infrastructure (Task 1) is the enabler for #1. +- `normalizeBlocksPayload` is intentionally unchanged — its existing tests in `Test_normalizeBlocksPayload` stay green throughout. +- Tasks 2–6 all edit `cmd/blocks/preview.go` (except Task 6, which edits `blocks.go`); implement them in order so each commit is coherent. From 55afeba9a422cd80ceb1ed31f40a2f6a5c39d0a7 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 20 Jul 2026 15:17:17 -0400 Subject: [PATCH 03/17] feat: add experimental blocks preview command (baseline) Baseline commit of the in-progress `slack blocks preview` feature before the input-rework and review-fix tasks. Opens Block Kit blocks in the Block Kit Builder, gated behind the block-kit-builder experiment. Co-Authored-By: Claude --- cmd/blocks/blocks.go | 43 ++++ cmd/blocks/blocks_test.go | 39 +++ cmd/blocks/preview.go | 189 +++++++++++++++ cmd/blocks/preview_test.go | 229 ++++++++++++++++++ cmd/root.go | 2 + docs/reference/commands/slack.md | 1 + docs/reference/commands/slack_blocks.md | 45 ++++ .../commands/slack_blocks_preview.md | 50 ++++ docs/reference/errors.md | 8 + internal/experiment/experiment.go | 5 + internal/slackerror/errors.go | 7 + internal/slacktrace/slacktrace.go | 2 + 12 files changed, 620 insertions(+) create mode 100644 cmd/blocks/blocks.go create mode 100644 cmd/blocks/blocks_test.go create mode 100644 cmd/blocks/preview.go create mode 100644 cmd/blocks/preview_test.go create mode 100644 docs/reference/commands/slack_blocks.md create mode 100644 docs/reference/commands/slack_blocks_preview.md diff --git a/cmd/blocks/blocks.go b/cmd/blocks/blocks.go new file mode 100644 index 00000000..3040d91c --- /dev/null +++ b/cmd/blocks/blocks.go @@ -0,0 +1,43 @@ +// 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/spf13/cobra" +) + +func NewCommand(clients *shared.ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "blocks [flags]", + Short: "Work with Block Kit blocks", + Long: "Work with Block Kit blocks, such as previewing them in the Block Kit Builder.", + Example: style.ExampleCommandsf([]style.ExampleCommand{ + { + Meaning: "Preview blocks in the Block Kit Builder", + Command: "blocks preview '[{\"type\":\"divider\"}]'", + }, + }), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand(NewPreviewCommand(clients)) + + return cmd +} diff --git a/cmd/blocks/blocks_test.go b/cmd/blocks/blocks_test.go new file mode 100644 index 00000000..ef67cfbc --- /dev/null +++ b/cmd/blocks/blocks_test.go @@ -0,0 +1,39 @@ +// 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/test/testutil" + "github.com/spf13/cobra" +) + +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) + }) +} diff --git a/cmd/blocks/preview.go b/cmd/blocks/preview.go new file mode 100644 index 00000000..0972b96d --- /dev/null +++ b/cmd/blocks/preview.go @@ -0,0 +1,189 @@ +// 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 ( + "encoding/json" + "fmt" + "io" + "net/url" + "strings" + + "github.com/slackapi/slack-cli/internal/experiment" + "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 selects the team whose Block Kit Builder should open. +// It is a package variable so that it can be stubbed in tests. +var promptTeamSlackAuthFunc = prompts.PromptTeamSlackAuth + +func NewPreviewCommand(clients *shared.ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "preview [blocks]", + 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.", + "", + "Blocks can be passed as an argument or piped in through standard input. The", + "input is a JSON array of blocks or a JSON object with a \"blocks\" array.", + }, "\n"), + Example: style.ExampleCommandsf([]style.ExampleCommand{ + { + Meaning: "Preview blocks passed as an argument", + Command: "blocks preview '[{\"type\":\"divider\"}]'", + }, + { + Meaning: "Preview blocks piped from a file", + Command: "blocks preview < blocks.json", + }, + }), + Args: cobra.MaximumNArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + return previewCommandPreRunE(clients) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return previewCommandRunE(clients, cmd, args) + }, + } + return cmd +} + +// previewCommandPreRunE gates the command behind the block-kit-builder experiment +func previewCommandPreRunE(clients *shared.ClientFactory) error { + if !clients.Config.WithExperimentOn(experiment.BlockKitBuilder) { + return slackerror.New(slackerror.ErrMissingExperiment). + WithMessage("The blocks preview command is experimental"). + WithRemediation("Enable this command with the %s flag", style.Highlight("--experiment "+string(experiment.BlockKitBuilder))) + } + return nil +} + +// previewCommandRunE reads blocks from an argument or standard input and opens +// them in the Block Kit Builder for the selected team +func previewCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + clients.IO.PrintTrace(ctx, slacktrace.BlocksPreviewStart) + + blocksInput, err := readBlocksInput(clients, args) + if err != nil { + return err + } + + blocksJSON, err := normalizeBlocksPayload(blocksInput) + if err != nil { + return err + } + + auth, err := promptTeamSlackAuthFunc(ctx, clients, "Select a team to preview blocks for", nil) + 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 +} + +// readBlocksInput returns the blocks provided as an argument or, when no +// argument is given, read from standard input +func readBlocksInput(clients *shared.ClientFactory, args []string) (string, error) { + if len(args) == 1 { + return strings.TrimSpace(args[0]), nil + } + + piped, err := io.ReadAll(clients.IO.ReadIn()) + if err != nil { + return "", slackerror.Wrap(err, slackerror.ErrMissingInput) + } + input := strings.TrimSpace(string(piped)) + if input == "" { + return "", slackerror.New(slackerror.ErrMissingInput). + WithMessage("No blocks were provided"). + WithRemediation("Provide blocks as an argument or pipe them through standard input") + } + return input, nil +} + +// normalizeBlocksPayload parses the provided input and returns a compact JSON +// string in the shape expected by the Block Kit Builder: {"blocks":[...]}. +// The input may be a bare array of blocks or an object containing a "blocks" key. +func normalizeBlocksPayload(input string) (string, error) { + var parsed any + if err := goutils.JSONUnmarshal([]byte(input), &parsed); err != nil { + return "", err + } + + var payload map[string]any + switch value := parsed.(type) { + case []any: + payload = map[string]any{"blocks": value} + case map[string]any: + if _, ok := value["blocks"].([]any); !ok { + return "", slackerror.New(slackerror.ErrInvalidBlocks) + } + payload = value + default: + return "", slackerror.New(slackerror.ErrInvalidBlocks) + } + + compact, err := json.Marshal(payload) + if err != nil { + return "", slackerror.New(slackerror.ErrInvalidBlocks) + } + return string(compact), nil +} + +// teamOrEnterpriseID returns the enterprise ID for enterprise installs and the +// team ID otherwise, matching the identifier used in Block Kit Builder URLs +func teamOrEnterpriseID(auth *types.SlackAuth) string { + if auth.EnterpriseID != "" { + return auth.EnterpriseID + } + return auth.TeamID +} + +// buildBlockKitBuilderURL constructs the Block Kit Builder URL for the given +// API host, team or enterprise ID, and compact blocks JSON. The blocks JSON is +// placed in the URL fragment, which url.URL.String percent-encodes. +func buildBlockKitBuilderURL(apiHost string, id string, blocksJSON string) (string, error) { + parsed, err := url.Parse(apiHost) + if err != nil { + return "", err + } + parsed.Host = "app." + parsed.Host + parsed.Path = fmt.Sprintf("/block-kit-builder/%s/builder", id) + parsed.Fragment = blocksJSON + return parsed.String(), nil +} diff --git a/cmd/blocks/preview_test.go b/cmd/blocks/preview_test.go new file mode 100644 index 00000000..c5b27d4a --- /dev/null +++ b/cmd/blocks/preview_test.go @@ -0,0 +1,229 @@ +// 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" + "testing" + + "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/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// enableExperiment turns on the block-kit-builder experiment for a test. +// Default mocks are installed first so LoadExperiments can read config and log +// debug output. Register any custom mocks (such as the API host) before calling +// this so that they take precedence over the defaults. +func enableExperiment(ctx context.Context, cm *shared.ClientsMock) { + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = append(cm.Config.ExperimentsFlag, "block-kit-builder") + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) +} + +// stubTeamAuth stubs the team selection to return the provided auth +func stubTeamAuth(auth *types.SlackAuth) func() { + original := promptTeamSlackAuthFunc + promptTeamSlackAuthFunc = func(ctx context.Context, clients *shared.ClientFactory, promptText string, promptConfig *prompts.PromptTeamSlackAuthConfig) (*types.SlackAuth, error) { + return auth, nil + } + return func() { promptTeamSlackAuthFunc = original } +} + +func Test_Blocks_PreviewCommand(t *testing.T) { + var restore func() + testutil.TableTestCommand(t, testutil.CommandTests{ + "opens the builder with blocks from an argument": { + CmdArgs: []string{`[{"type":"divider"}]`}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + enableExperiment(ctx, cm) + restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + expectedURL := `https://app.slack.com/block-kit-builder/T123/builder#%7B%22blocks%22:%5B%7B%22type%22:%22divider%22%7D%5D%7D` + cm.Browser.AssertCalled(t, "OpenURL", expectedURL) + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.BlocksPreviewSuccess, []string{expectedURL}) + }, + Teardown: func() { restore() }, + }, + "opens the builder with blocks piped from stdin": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) + enableExperiment(ctx, cm) + restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + expectedURL := `https://app.slack.com/block-kit-builder/T123/builder#%7B%22blocks%22:%5B%7B%22type%22:%22divider%22%7D%5D%7D` + cm.Browser.AssertCalled(t, "OpenURL", expectedURL) + }, + Teardown: func() { restore() }, + }, + "accepts a blocks object payload": { + CmdArgs: []string{`{"blocks":[{"type":"divider"}]}`}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + enableExperiment(ctx, cm) + restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + expectedURL := `https://app.slack.com/block-kit-builder/T123/builder#%7B%22blocks%22:%5B%7B%22type%22:%22divider%22%7D%5D%7D` + cm.Browser.AssertCalled(t, "OpenURL", expectedURL) + }, + Teardown: func() { restore() }, + }, + "uses the enterprise id for enterprise installs": { + CmdArgs: []string{`[{"type":"divider"}]`}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + enableExperiment(ctx, cm) + restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123", EnterpriseID: "E456", IsEnterpriseInstall: true}) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", mock.MatchedBy(func(url string) bool { + return assert.Contains(t, url, "/block-kit-builder/E456/builder") + })) + }, + Teardown: func() { restore() }, + }, + "derives the host for developer instances": { + CmdArgs: []string{`[{"type":"divider"}]`}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://dev1234.slack.com") + enableExperiment(ctx, cm) + restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", mock.MatchedBy(func(url string) bool { + return assert.Contains(t, url, "https://app.dev1234.slack.com/block-kit-builder/T123/builder") + })) + }, + Teardown: func() { restore() }, + }, + "errors when the experiment is not enabled": { + CmdArgs: []string{`[{"type":"divider"}]`}, + ExpectedErrorStrings: []string{slackerror.ErrMissingExperiment, "experimental"}, + }, + "errors when no blocks are provided": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + enableExperiment(ctx, cm) + }, + ExpectedErrorStrings: []string{slackerror.ErrMissingInput, "No blocks were provided"}, + }, + "errors when the blocks are not valid json": { + CmdArgs: []string{`not json`}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + enableExperiment(ctx, cm) + }, + ExpectedErrorStrings: []string{slackerror.ErrUnableToParseJSON}, + }, + "errors when the json is not a blocks payload": { + CmdArgs: []string{`{"foo":"bar"}`}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + enableExperiment(ctx, cm) + }, + ExpectedErrorStrings: []string{slackerror.ErrInvalidBlocks}, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewPreviewCommand(cf) + }) +} + +func Test_buildBlockKitBuilderURL(t *testing.T) { + tests := map[string]struct { + apiHost string + id string + blocksJSON string + expected string + }{ + "production host": { + apiHost: "https://slack.com", + id: "T123", + blocksJSON: `{"blocks":[]}`, + expected: "https://app.slack.com/block-kit-builder/T123/builder#%7B%22blocks%22:%5B%5D%7D", + }, + "developer host": { + apiHost: "https://dev1234.slack.com", + id: "E456", + blocksJSON: `{"blocks":[]}`, + expected: "https://app.dev1234.slack.com/block-kit-builder/E456/builder#%7B%22blocks%22:%5B%5D%7D", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + actual, err := buildBlockKitBuilderURL(tc.apiHost, tc.id, tc.blocksJSON) + require.NoError(t, err) + assert.Equal(t, tc.expected, actual) + }) + } +} + +func Test_normalizeBlocksPayload(t *testing.T) { + tests := map[string]struct { + input string + expected string + expectedErr string + }{ + "wraps a bare array": { + input: `[{"type":"divider"}]`, + expected: `{"blocks":[{"type":"divider"}]}`, + }, + "passes through a blocks object": { + input: `{"blocks":[{"type":"divider"}]}`, + expected: `{"blocks":[{"type":"divider"}]}`, + }, + "compacts whitespace": { + input: "[\n {\n \"type\": \"divider\"\n }\n]", + expected: `{"blocks":[{"type":"divider"}]}`, + }, + "rejects invalid json": { + input: `not json`, + expectedErr: slackerror.ErrUnableToParseJSON, + }, + "rejects an object without blocks": { + input: `{"foo":"bar"}`, + expectedErr: slackerror.ErrInvalidBlocks, + }, + "rejects a non array blocks value": { + input: `{"blocks":"nope"}`, + expectedErr: slackerror.ErrInvalidBlocks, + }, + "rejects a scalar value": { + input: `42`, + expectedErr: slackerror.ErrInvalidBlocks, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + actual, err := normalizeBlocksPayload(tc.input) + if tc.expectedErr != "" { + require.Error(t, err) + assert.Equal(t, tc.expectedErr, slackerror.ToSlackError(err).Code) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expected, actual) + }) + } +} diff --git a/cmd/root.go b/cmd/root.go index f5d7573f..eeb7eb4f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,6 +25,7 @@ import ( apicmd "github.com/slackapi/slack-cli/cmd/api" "github.com/slackapi/slack-cli/cmd/app" "github.com/slackapi/slack-cli/cmd/auth" + "github.com/slackapi/slack-cli/cmd/blocks" "github.com/slackapi/slack-cli/cmd/collaborators" "github.com/slackapi/slack-cli/cmd/datastore" "github.com/slackapi/slack-cli/cmd/docgen" @@ -167,6 +168,7 @@ func Init(ctx context.Context) (*cobra.Command, *shared.ClientFactory) { apicmd.NewCommand(clients), app.NewCommand(clients), auth.NewCommand(clients), + blocks.NewCommand(clients), collaborators.NewCommand(clients), datastore.NewCommand(clients), docgen.NewCommand(clients), diff --git a/docs/reference/commands/slack.md b/docs/reference/commands/slack.md index 536ec38d..b71f17a5 100644 --- a/docs/reference/commands/slack.md +++ b/docs/reference/commands/slack.md @@ -45,6 +45,7 @@ $ slack docs # Open Slack developer docs * [slack api](slack_api) - Call any Slack API method * [slack app](slack_app) - Install, uninstall, and list teams with the app installed * [slack auth](slack_auth) - Add and remove local team authorizations +* [slack blocks](slack_blocks) - Work with Block Kit blocks * [slack collaborator](slack_collaborator) - Manage app collaborators * [slack create](slack_create) - Create a new Slack project * [slack datastore](slack_datastore) - Interact with an app's datastore diff --git a/docs/reference/commands/slack_blocks.md b/docs/reference/commands/slack_blocks.md new file mode 100644 index 00000000..43d638d8 --- /dev/null +++ b/docs/reference/commands/slack_blocks.md @@ -0,0 +1,45 @@ +# `slack blocks` + +Work with Block Kit blocks + +## Description + +Work with Block Kit blocks, such as previewing them in the Block Kit Builder. + +``` +slack blocks [flags] +``` + +## Flags + +``` + -h, --help help for blocks +``` + +## Global flags + +``` + --accessible use accessible prompts for screen readers + -a, --app string use a specific app ID or environment + --config-dir string use a custom path for system config directory + -e, --experiment strings use the experiment(s) in the command + -f, --force ignore warnings and continue executing command + --no-color remove styles and formatting from outputs + -s, --skip-update skip checking for latest version of CLI + -w, --team string select workspace or organization by team name or ID + --token string set the access token associated with a team + -v, --verbose print debug logging and additional info +``` + +## Examples + +``` +# Preview blocks in the Block Kit Builder +$ slack blocks preview '[{"type":"divider"}]' +``` + +## See also + +* [slack](slack) - Slack command-line tool +* [slack blocks preview](slack_blocks_preview) - Preview blocks in the Block Kit Builder + diff --git a/docs/reference/commands/slack_blocks_preview.md b/docs/reference/commands/slack_blocks_preview.md new file mode 100644 index 00000000..98059094 --- /dev/null +++ b/docs/reference/commands/slack_blocks_preview.md @@ -0,0 +1,50 @@ +# `slack blocks preview` + +Preview blocks in the Block Kit Builder + +## Description + +Open a set of Block Kit blocks in the Block Kit Builder in a web browser. + +Blocks can be passed as an argument or piped in through standard input. The +input is a JSON array of blocks or a JSON object with a "blocks" array. + +``` +slack blocks preview [blocks] [flags] +``` + +## Flags + +``` + -h, --help help for preview +``` + +## Global flags + +``` + --accessible use accessible prompts for screen readers + -a, --app string use a specific app ID or environment + --config-dir string use a custom path for system config directory + -e, --experiment strings use the experiment(s) in the command + -f, --force ignore warnings and continue executing command + --no-color remove styles and formatting from outputs + -s, --skip-update skip checking for latest version of CLI + -w, --team string select workspace or organization by team name or ID + --token string set the access token associated with a team + -v, --verbose print debug logging and additional info +``` + +## Examples + +``` +# Preview blocks passed as an argument +$ slack blocks preview '[{"type":"divider"}]' + +# Preview blocks piped from a file +$ slack blocks preview < blocks.json +``` + +## See also + +* [slack blocks](slack_blocks) - Work with Block Kit blocks + diff --git a/docs/reference/errors.md b/docs/reference/errors.md index a484c3b5..53f164a5 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -820,6 +820,14 @@ Or choose a specific app with `--app ` --- +### invalid_blocks {#invalid_blocks} + +**Message**: The provided blocks are not valid Block Kit blocks + +**Remediation**: Provide a JSON array of blocks or a JSON object with a "blocks" array. Design blocks with the Block Kit Builder or see the reference: https://docs.slack.dev/reference/block-kit/blocks + +--- + ### invalid_challenge {#invalid_challenge} **Message**: The challenge code is invalid diff --git a/internal/experiment/experiment.go b/internal/experiment/experiment.go index 3c029c5b..0fedfa14 100644 --- a/internal/experiment/experiment.go +++ b/internal/experiment/experiment.go @@ -30,6 +30,10 @@ type Experiment string // e.g. --experiment=first-toggle,second-toggle const ( + // BlockKitBuilder experiment enables the blocks preview command that opens + // the Block Kit Builder in a browser. + BlockKitBuilder Experiment = "block-kit-builder" + // Lipgloss experiment shows pretty styles. Lipgloss Experiment = "lipgloss" @@ -43,6 +47,7 @@ const ( // AllExperiments is a list of all available experiments that can be enabled // Please also add here 👇 var AllExperiments = []Experiment{ + BlockKitBuilder, Lipgloss, Placeholder, SetIcon, diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index 1959724f..0c85ff12 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -140,6 +140,7 @@ const ( ErrInvalidArguments = "invalid_arguments" ErrInvalidArgumentsCustomizableInputs = "invalid_arguments_customizable_inputs" ErrInvalidAuth = "invalid_auth" + ErrInvalidBlocks = "invalid_blocks" ErrInvalidChallenge = "invalid_challenge" ErrInvalidChannelID = "invalid_channel_id" ErrInvalidCursor = "invalid_cursor" @@ -937,6 +938,12 @@ Otherwise start your app for local development with: %s`, ), }, + ErrInvalidBlocks: { + Code: ErrInvalidBlocks, + Message: "The provided blocks are not valid Block Kit blocks", + Remediation: "Provide a JSON array of blocks or a JSON object with a \"blocks\" array. Design blocks with the Block Kit Builder or see the reference: https://docs.slack.dev/reference/block-kit/blocks", + }, + ErrInvalidChallenge: { Code: ErrInvalidChallenge, Message: "The challenge code is invalid", diff --git a/internal/slacktrace/slacktrace.go b/internal/slacktrace/slacktrace.go index 9ebc08ec..727b0690 100644 --- a/internal/slacktrace/slacktrace.go +++ b/internal/slacktrace/slacktrace.go @@ -57,6 +57,8 @@ const ( AuthLogoutSuccess = "SLACK_TRACE_AUTH_LOGOUT_SUCCESS" AuthRevokeStart = "SLACK_TRACE_AUTH_REVOKE_START" AuthRevokeSuccess = "SLACK_TRACE_AUTH_REVOKE_SUCCESS" + BlocksPreviewStart = "SLACK_TRACE_BLOCKS_PREVIEW_START" + BlocksPreviewSuccess = "SLACK_TRACE_BLOCKS_PREVIEW_SUCCESS" CollaboratorAddCollaborator = "SLACK_TRACE_COLLABORATOR_ADD_COLLABORATOR" CollaboratorAddSuccess = "SLACK_TRACE_COLLABORATOR_ADD_SUCCESS" CollaboratorListCollaborator = "SLACK_TRACE_COLLABORATOR_LIST_COLLABORATOR" From d19e65d311fc4e4deef1b6cbb58f0a61fcafe7d1 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 20 Jul 2026 15:19:54 -0400 Subject: [PATCH 04/17] feat: add IsStdinTTY for detecting piped stdin Adds Os.Stdin() and IOStreams.IsStdinTTY(), mirroring Stdout()/IsTTY(), so callers can distinguish piped/redirected stdin from an interactive terminal even when stdout is still a TTY. Co-Authored-By: Claude --- internal/iostreams/iostreams.go | 17 +++++++++++++++ internal/iostreams/iostreams_mock.go | 6 ++++++ internal/iostreams/iostreams_test.go | 32 ++++++++++++++++++++++++++++ internal/shared/types/slackdeps.go | 3 +++ internal/slackdeps/os.go | 5 +++++ internal/slackdeps/os_mock.go | 7 ++++++ 6 files changed, 70 insertions(+) diff --git a/internal/iostreams/iostreams.go b/internal/iostreams/iostreams.go index 6dc85ea1..ec861df5 100644 --- a/internal/iostreams/iostreams.go +++ b/internal/iostreams/iostreams.go @@ -82,6 +82,10 @@ type IOStreamer interface { // IsTTY returns true if the device is an interactive terminal IsTTY() bool + // IsStdinTTY returns true if stdin is an interactive terminal (not a pipe + // or redirected file) + IsStdinTTY() bool + // GetExitCode returns the most-recently set desired exit code in a thread safe way GetExitCode() ExitCode // SetExitCode sets the desired process exit code in a thread safe way @@ -129,6 +133,19 @@ func (io *IOStreams) IsTTY() bool { } } +// IsStdinTTY returns true if stdin is an interactive terminal +// +// Unlike IsTTY, which inspects stdout, this inspects stdin so that piped or +// redirected input (e.g. `cat blocks.json | slack ...`) is detected even when +// stdout is still attached to a terminal. +func (io *IOStreams) IsStdinTTY() bool { + if o, err := io.os.Stdin().Stat(); o == nil || err != nil { + return false + } else { + return (o.Mode() & os.ModeCharDevice) == os.ModeCharDevice + } +} + // SetExitCode sets the desired exit code in a thread-safe way func (io *IOStreams) SetExitCode(code ExitCode) { atomic.StoreInt32((*int32)(&io.exitCode), int32(code)) diff --git a/internal/iostreams/iostreams_mock.go b/internal/iostreams/iostreams_mock.go index fcb86ac5..07845f83 100644 --- a/internal/iostreams/iostreams_mock.go +++ b/internal/iostreams/iostreams_mock.go @@ -71,6 +71,7 @@ func NewIOStreamsMock(config *config.Config, fsm *slackdeps.FsMock, osm *slackde // AddDefaultMocks prepares default mock methods to fallback to func (m *IOStreamsMock) AddDefaultMocks() { m.On("IsTTY").Return(false) + m.On("IsStdinTTY").Return(false) m.On("PrintDebug", mock.Anything, mock.Anything, mock.MatchedBy(func(args ...any) bool { return true })) m.On("PrintTrace", mock.Anything, mock.Anything, mock.MatchedBy(func(args []string) bool { return true })) m.On("PrintWarning", mock.Anything, mock.Anything, mock.MatchedBy(func(args ...any) bool { return true })) @@ -97,6 +98,11 @@ func (m *IOStreamsMock) IsTTY() bool { return args.Bool(0) } +func (m *IOStreamsMock) IsStdinTTY() bool { + args := m.Called() + return args.Bool(0) +} + // SetExitCode sets the desired exit code in a thread-safe way func (m *IOStreamsMock) SetExitCode(code ExitCode) { atomic.StoreInt32((*int32)(&m.exitCode), int32(code)) diff --git a/internal/iostreams/iostreams_test.go b/internal/iostreams/iostreams_test.go index 1042a49d..cb8b0790 100644 --- a/internal/iostreams/iostreams_test.go +++ b/internal/iostreams/iostreams_test.go @@ -97,6 +97,38 @@ func Test_IOStreams_IsTTY(t *testing.T) { } } +func Test_IOStreams_IsStdinTTY(t *testing.T) { + tests := map[string]struct { + fileInfo os.FileInfo + expected bool + }{ + "interactive when stdin is a char device": { + fileInfo: &slackdeps.FileInfoCharDevice{}, + expected: true, + }, + "not interactive when stdin is a named pipe": { + fileInfo: &slackdeps.FileInfoNamedPipe{}, + expected: false, + }, + "not interactive when the stat check errors": { + expected: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + fsMock := slackdeps.NewFsMock() + osMock := slackdeps.NewOsMock() + config := config.NewConfig(fsMock, osMock) + osMock.On("Stdin").Return(&slackdeps.FileMock{FileInfo: tc.fileInfo}) + io := NewIOStreams(config, fsMock, osMock) + + isStdinTTY := io.IsStdinTTY() + assert.Equal(t, tc.expected, isStdinTTY) + }) + } +} + func Test_IOStreams_SetCmdIO(t *testing.T) { fsMock := slackdeps.NewFsMock() osMock := slackdeps.NewOsMock() diff --git a/internal/shared/types/slackdeps.go b/internal/shared/types/slackdeps.go index 858c5201..9f82aae3 100644 --- a/internal/shared/types/slackdeps.go +++ b/internal/shared/types/slackdeps.go @@ -65,4 +65,7 @@ type Os interface { // Stdout returns the file descriptor for stdout Stdout() File + + // Stdin returns the file descriptor for stdin + Stdin() File } diff --git a/internal/slackdeps/os.go b/internal/slackdeps/os.go index 4a353235..5d3edbb4 100644 --- a/internal/slackdeps/os.go +++ b/internal/slackdeps/os.go @@ -98,3 +98,8 @@ func (c *Os) Exit(code int) { func (c *Os) Stdout() types.File { return os.Stdout } + +// Stdin returns the file descriptor for stdin +func (c *Os) Stdin() types.File { + return os.Stdin +} diff --git a/internal/slackdeps/os_mock.go b/internal/slackdeps/os_mock.go index f8f1620f..8bb6065f 100644 --- a/internal/slackdeps/os_mock.go +++ b/internal/slackdeps/os_mock.go @@ -56,6 +56,7 @@ func (m *OsMock) AddDefaultMocks() { m.On("Exit", mock.Anything).Return() m.On("Stat", mock.Anything).Return() m.On("Stdout").Return(os.Stdout) + m.On("Stdin").Return(os.Stdin) } // Getenv mocks returning an environment variable @@ -136,3 +137,9 @@ func (m *OsMock) Stdout() types.File { args := m.Called() return args.Get(0).(types.File) } + +// Stdin mocks the stdin with a file that can be adjusted +func (m *OsMock) Stdin() types.File { + args := m.Called() + return args.Get(0).(types.File) +} From d97944d7d4e2af46cf9c03f4b91a4c9fd80031d6 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 20 Jul 2026 15:24:31 -0400 Subject: [PATCH 05/17] feat: accept blocks via --blocks flag with stdin sentinel Replaces the positional argument with a --blocks flag, supports the - stdin sentinel and auto-detected piped stdin, and errors on a bare interactive invocation instead of blocking forever on stdin. Co-Authored-By: Claude --- cmd/blocks/preview.go | 65 ++++++++++++++++++++++++++------------ cmd/blocks/preview_test.go | 50 ++++++++++++++--------------- 2 files changed, 69 insertions(+), 46 deletions(-) diff --git a/cmd/blocks/preview.go b/cmd/blocks/preview.go index 0972b96d..7d2d62d2 100644 --- a/cmd/blocks/preview.go +++ b/cmd/blocks/preview.go @@ -37,33 +37,36 @@ import ( var promptTeamSlackAuthFunc = prompts.PromptTeamSlackAuth func NewPreviewCommand(clients *shared.ClientFactory) *cobra.Command { + var blocksFlag string cmd := &cobra.Command{ - Use: "preview [blocks]", + 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.", "", - "Blocks can be passed as an argument or piped in through standard input. The", - "input is a JSON array of blocks or a JSON object with a \"blocks\" array.", + "Provide blocks with the --blocks flag or pipe them through standard input.", + "The input is a JSON array of blocks or a JSON object with a \"blocks\" array.", + "Pass - to --blocks to read explicitly from standard input.", }, "\n"), Example: style.ExampleCommandsf([]style.ExampleCommand{ { - Meaning: "Preview blocks passed as an argument", - Command: "blocks preview '[{\"type\":\"divider\"}]'", + Meaning: "Preview blocks passed with a flag", + Command: "blocks preview --blocks '[{\"type\":\"divider\"}]'", }, { Meaning: "Preview blocks piped from a file", Command: "blocks preview < blocks.json", }, }), - Args: cobra.MaximumNArgs(1), + Args: cobra.NoArgs, PreRunE: func(cmd *cobra.Command, args []string) error { return previewCommandPreRunE(clients) }, RunE: func(cmd *cobra.Command, args []string) error { - return previewCommandRunE(clients, cmd, args) + 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 } @@ -77,13 +80,13 @@ func previewCommandPreRunE(clients *shared.ClientFactory) error { return nil } -// previewCommandRunE reads blocks from an argument or standard input and opens +// previewCommandRunE resolves blocks from the flag or standard input and opens // them in the Block Kit Builder for the selected team -func previewCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error { +func previewCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, blocksFlag string, blocksFlagChanged bool) error { ctx := cmd.Context() clients.IO.PrintTrace(ctx, slacktrace.BlocksPreviewStart) - blocksInput, err := readBlocksInput(clients, args) + blocksInput, _, err := resolveBlocksInput(clients, blocksFlag, blocksFlagChanged) if err != nil { return err } @@ -116,24 +119,46 @@ func previewCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, args return nil } -// readBlocksInput returns the blocks provided as an argument or, when no -// argument is given, read from standard input -func readBlocksInput(clients *shared.ClientFactory, args []string) (string, error) { - if len(args) == 1 { - return strings.TrimSpace(args[0]), nil +// resolveBlocksInput returns the blocks to preview and whether they were read +// from standard input. Resolution order: an explicit --blocks value, the - +// sentinel or an auto-detected stdin pipe, otherwise a friendly error. Reading +// stdin is never attempted against an interactive terminal, so a bare command +// on a TTY errors instead of blocking on io.ReadAll. +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 + case !clients.IO.IsStdinTTY(): + return readStdinBlocks(clients) + default: + return "", false, missingBlocksError() } +} +// readStdinBlocks reads and trims blocks from standard input +func readStdinBlocks(clients *shared.ClientFactory) (string, bool, error) { piped, err := io.ReadAll(clients.IO.ReadIn()) if err != nil { - return "", slackerror.Wrap(err, slackerror.ErrMissingInput) + return "", true, slackerror.Wrap(err, slackerror.ErrMissingInput) } input := strings.TrimSpace(string(piped)) if input == "" { - return "", slackerror.New(slackerror.ErrMissingInput). - WithMessage("No blocks were provided"). - WithRemediation("Provide blocks as an argument or pipe them through standard input") + return "", true, missingBlocksError() } - return input, nil + return input, true, nil +} + +// missingBlocksError is the friendly error returned when no blocks are supplied +func missingBlocksError() error { + return slackerror.New(slackerror.ErrMissingInput). + WithMessage("No blocks were provided"). + WithRemediation("Provide blocks with the %s flag or pipe them through standard input", style.Highlight("--blocks")) } // normalizeBlocksPayload parses the provided input and returns a compact JSON diff --git a/cmd/blocks/preview_test.go b/cmd/blocks/preview_test.go index c5b27d4a..4a18b345 100644 --- a/cmd/blocks/preview_test.go +++ b/cmd/blocks/preview_test.go @@ -53,8 +53,8 @@ func stubTeamAuth(auth *types.SlackAuth) func() { func Test_Blocks_PreviewCommand(t *testing.T) { var restore func() testutil.TableTestCommand(t, testutil.CommandTests{ - "opens the builder with blocks from an argument": { - CmdArgs: []string{`[{"type":"divider"}]`}, + "opens the builder with blocks from the --blocks flag": { + CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cm.API.On("Host").Return("https://slack.com") enableExperiment(ctx, cm) @@ -67,7 +67,8 @@ func Test_Blocks_PreviewCommand(t *testing.T) { }, Teardown: func() { restore() }, }, - "opens the builder with blocks piped from stdin": { + "opens the builder with blocks from stdin via the - sentinel": { + CmdArgs: []string{"--blocks", "-"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cm.API.On("Host").Return("https://slack.com") cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) @@ -80,10 +81,11 @@ func Test_Blocks_PreviewCommand(t *testing.T) { }, Teardown: func() { restore() }, }, - "accepts a blocks object payload": { - CmdArgs: []string{`{"blocks":[{"type":"divider"}]}`}, + "opens the builder with auto-detected piped stdin": { Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cm.API.On("Host").Return("https://slack.com") + cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) + // default IsStdinTTY() is false (piped) enableExperiment(ctx, cm) restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) }, @@ -93,53 +95,49 @@ func Test_Blocks_PreviewCommand(t *testing.T) { }, Teardown: func() { restore() }, }, - "uses the enterprise id for enterprise installs": { - CmdArgs: []string{`[{"type":"divider"}]`}, + "accepts a blocks object payload": { + CmdArgs: []string{"--blocks", `{"blocks":[{"type":"divider"}]}`}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cm.API.On("Host").Return("https://slack.com") enableExperiment(ctx, cm) - restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123", EnterpriseID: "E456", IsEnterpriseInstall: true}) + restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - cm.Browser.AssertCalled(t, "OpenURL", mock.MatchedBy(func(url string) bool { - return assert.Contains(t, url, "/block-kit-builder/E456/builder") - })) + expectedURL := `https://app.slack.com/block-kit-builder/T123/builder#%7B%22blocks%22:%5B%7B%22type%22:%22divider%22%7D%5D%7D` + cm.Browser.AssertCalled(t, "OpenURL", expectedURL) }, Teardown: func() { restore() }, }, - "derives the host for developer instances": { - CmdArgs: []string{`[{"type":"divider"}]`}, + "errors when the experiment is not enabled": { + CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, + ExpectedErrorStrings: []string{slackerror.ErrMissingExperiment, "experimental"}, + }, + "errors without hanging when no blocks are provided on a terminal": { Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("Host").Return("https://dev1234.slack.com") + cm.IO.On("IsStdinTTY").Return(true) enableExperiment(ctx, cm) - restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) }, + ExpectedErrorStrings: []string{slackerror.ErrMissingInput, "No blocks were provided"}, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - cm.Browser.AssertCalled(t, "OpenURL", mock.MatchedBy(func(url string) bool { - return assert.Contains(t, url, "https://app.dev1234.slack.com/block-kit-builder/T123/builder") - })) + cm.Browser.AssertNotCalled(t, "OpenURL", mock.Anything) }, - Teardown: func() { restore() }, - }, - "errors when the experiment is not enabled": { - CmdArgs: []string{`[{"type":"divider"}]`}, - ExpectedErrorStrings: []string{slackerror.ErrMissingExperiment, "experimental"}, }, - "errors when no blocks are provided": { + "errors when the --blocks flag is empty": { + CmdArgs: []string{"--blocks", ""}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { enableExperiment(ctx, cm) }, ExpectedErrorStrings: []string{slackerror.ErrMissingInput, "No blocks were provided"}, }, "errors when the blocks are not valid json": { - CmdArgs: []string{`not json`}, + CmdArgs: []string{"--blocks", `not json`}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { enableExperiment(ctx, cm) }, ExpectedErrorStrings: []string{slackerror.ErrUnableToParseJSON}, }, "errors when the json is not a blocks payload": { - CmdArgs: []string{`{"foo":"bar"}`}, + CmdArgs: []string{"--blocks", `{"foo":"bar"}`}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { enableExperiment(ctx, cm) }, From 0a95868ff59c371b1802acdb570b83292d158b78 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 20 Jul 2026 15:28:42 -0400 Subject: [PATCH 06/17] fix: require --team when piping blocks with multiple auths Reading blocks from stdin consumes the pipe, so the interactive team picker cannot prompt. Detect stdin input with more than one auth and no --team flag and return an actionable error instead of failing on EOF. Co-Authored-By: Claude --- cmd/blocks/preview.go | 24 ++++++++++++++++++++++-- cmd/blocks/preview_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/cmd/blocks/preview.go b/cmd/blocks/preview.go index 7d2d62d2..9dd20578 100644 --- a/cmd/blocks/preview.go +++ b/cmd/blocks/preview.go @@ -15,6 +15,7 @@ package blocks import ( + "context" "encoding/json" "fmt" "io" @@ -86,7 +87,7 @@ func previewCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, block ctx := cmd.Context() clients.IO.PrintTrace(ctx, slacktrace.BlocksPreviewStart) - blocksInput, _, err := resolveBlocksInput(clients, blocksFlag, blocksFlagChanged) + blocksInput, fromStdin, err := resolveBlocksInput(clients, blocksFlag, blocksFlagChanged) if err != nil { return err } @@ -96,7 +97,7 @@ func previewCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, block return err } - auth, err := promptTeamSlackAuthFunc(ctx, clients, "Select a team to preview blocks for", nil) + auth, err := selectTeamAuth(ctx, clients, fromStdin) if err != nil { return err } @@ -119,6 +120,25 @@ func previewCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, block return nil } +// selectTeamAuth chooses the team whose Block Kit Builder to open. When blocks +// were read from stdin the interactive picker cannot prompt (stdin is spent), +// so with more than one authorization and no --team flag we return an +// actionable error instead of failing on EOF inside the prompt. +func selectTeamAuth(ctx context.Context, clients *shared.ClientFactory, fromStdin bool) (*types.SlackAuth, error) { + if fromStdin && clients.Config.TeamFlag == "" { + auths, err := clients.Auth().Auths(ctx) + if err != nil { + return nil, err + } + if 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 piping blocks", style.Highlight("--team")) + } + } + return promptTeamSlackAuthFunc(ctx, clients, "Select a team to preview blocks for", nil) +} + // resolveBlocksInput returns the blocks to preview and whether they were read // from standard input. Resolution order: an explicit --blocks value, the - // sentinel or an auto-detected stdin pipe, otherwise a friendly error. Reading diff --git a/cmd/blocks/preview_test.go b/cmd/blocks/preview_test.go index 4a18b345..301c7757 100644 --- a/cmd/blocks/preview_test.go +++ b/cmd/blocks/preview_test.go @@ -143,6 +143,36 @@ func Test_Blocks_PreviewCommand(t *testing.T) { }, ExpectedErrorStrings: []string{slackerror.ErrInvalidBlocks}, }, + "errors when piping blocks with multiple teams and no --team flag": { + CmdArgs: []string{"--blocks", "-"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {TeamID: "T123", TeamDomain: "team-a"}, + {TeamID: "T456", TeamDomain: "team-b"}, + }, nil) + enableExperiment(ctx, cm) + }, + ExpectedErrorStrings: []string{slackerror.ErrMissingFlag, "--team"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertNotCalled(t, "OpenURL", mock.Anything) + }, + }, + "opens the builder when piping blocks with the --team flag set": { + CmdArgs: []string{"--blocks", "-", "--team", "T123"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) + enableExperiment(ctx, cm) + restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", mock.MatchedBy(func(url string) bool { + return assert.Contains(t, url, "/block-kit-builder/T123/builder") + })) + }, + Teardown: func() { restore() }, + }, }, func(cf *shared.ClientFactory) *cobra.Command { return NewPreviewCommand(cf) }) From 739406d965ba974ad21e727a850ef2b00c525a71 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 20 Jul 2026 15:32:21 -0400 Subject: [PATCH 07/17] fix: use enterprise id only for enterprise installs in blocks preview An org-grid workspace install has an enterprise ID but is not an enterprise install; key off IsEnterpriseInstall so those installs open the Builder in the workspace context. Co-Authored-By: Claude --- cmd/blocks/preview.go | 2 +- cmd/blocks/preview_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/cmd/blocks/preview.go b/cmd/blocks/preview.go index 9dd20578..ce382adb 100644 --- a/cmd/blocks/preview.go +++ b/cmd/blocks/preview.go @@ -213,7 +213,7 @@ func normalizeBlocksPayload(input string) (string, error) { // teamOrEnterpriseID returns the enterprise ID for enterprise installs and the // team ID otherwise, matching the identifier used in Block Kit Builder URLs func teamOrEnterpriseID(auth *types.SlackAuth) string { - if auth.EnterpriseID != "" { + if auth.IsEnterpriseInstall { return auth.EnterpriseID } return auth.TeamID diff --git a/cmd/blocks/preview_test.go b/cmd/blocks/preview_test.go index 301c7757..329dfb4f 100644 --- a/cmd/blocks/preview_test.go +++ b/cmd/blocks/preview_test.go @@ -173,6 +173,34 @@ func Test_Blocks_PreviewCommand(t *testing.T) { }, Teardown: func() { restore() }, }, + "uses the enterprise id for enterprise installs": { + CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + enableExperiment(ctx, cm) + restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123", EnterpriseID: "E456", IsEnterpriseInstall: true}) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", mock.MatchedBy(func(url string) bool { + return assert.Contains(t, url, "/block-kit-builder/E456/builder") + })) + }, + Teardown: func() { restore() }, + }, + "uses the team id for org-grid workspace installs": { + CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + enableExperiment(ctx, cm) + restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123", EnterpriseID: "E456", IsEnterpriseInstall: false}) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", mock.MatchedBy(func(url string) bool { + return assert.Contains(t, url, "/block-kit-builder/T123/builder") + })) + }, + Teardown: func() { restore() }, + }, }, func(cf *shared.ClientFactory) *cobra.Command { return NewPreviewCommand(cf) }) From 519ff67831c1534a2cdea63f6725597fea0bcad9 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 20 Jul 2026 15:34:22 -0400 Subject: [PATCH 08/17] fix: reject invalid API hosts when building Block Kit Builder URL Guard against empty or scheme-less hosts that would otherwise yield a malformed URL, and wrap the url.Parse error with a structured code. Co-Authored-By: Claude --- cmd/blocks/preview.go | 6 +++++- cmd/blocks/preview_test.go | 26 ++++++++++++++++++++++---- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/cmd/blocks/preview.go b/cmd/blocks/preview.go index ce382adb..865aceb3 100644 --- a/cmd/blocks/preview.go +++ b/cmd/blocks/preview.go @@ -225,7 +225,11 @@ func teamOrEnterpriseID(auth *types.SlackAuth) string { func buildBlockKitBuilderURL(apiHost string, id string, blocksJSON string) (string, error) { parsed, err := url.Parse(apiHost) if err != nil { - return "", err + 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) diff --git a/cmd/blocks/preview_test.go b/cmd/blocks/preview_test.go index 329dfb4f..8a0b1222 100644 --- a/cmd/blocks/preview_test.go +++ b/cmd/blocks/preview_test.go @@ -208,10 +208,11 @@ func Test_Blocks_PreviewCommand(t *testing.T) { func Test_buildBlockKitBuilderURL(t *testing.T) { tests := map[string]struct { - apiHost string - id string - blocksJSON string - expected string + apiHost string + id string + blocksJSON string + expected string + expectedErr string }{ "production host": { apiHost: "https://slack.com", @@ -225,10 +226,27 @@ func Test_buildBlockKitBuilderURL(t *testing.T) { blocksJSON: `{"blocks":[]}`, expected: "https://app.dev1234.slack.com/block-kit-builder/E456/builder#%7B%22blocks%22:%5B%5D%7D", }, + "empty host": { + apiHost: "", + id: "T123", + blocksJSON: `{"blocks":[]}`, + expectedErr: slackerror.ErrInvalidArguments, + }, + "scheme-less host": { + apiHost: "app.slack.com", + id: "T123", + blocksJSON: `{"blocks":[]}`, + expectedErr: slackerror.ErrInvalidArguments, + }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { actual, err := buildBlockKitBuilderURL(tc.apiHost, tc.id, tc.blocksJSON) + if tc.expectedErr != "" { + require.Error(t, err) + assert.Equal(t, tc.expectedErr, slackerror.ToSlackError(err).Code) + return + } require.NoError(t, err) assert.Equal(t, tc.expected, actual) }) From 2f8d29a953fe22e520e888975dff652a593ce677 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 20 Jul 2026 16:21:37 -0400 Subject: [PATCH 09/17] chore: hide experimental blocks command from help and docs Hide the blocks command while gated behind the block-kit-builder experiment and drop its generated doc pages, which docgen omits for hidden commands. Co-Authored-By: Claude --- cmd/blocks/blocks.go | 5 +- cmd/blocks/blocks_test.go | 8 +++ docs/reference/commands/slack.md | 1 - docs/reference/commands/slack_blocks.md | 45 ----------------- .../commands/slack_blocks_preview.md | 50 ------------------- 5 files changed, 12 insertions(+), 97 deletions(-) delete mode 100644 docs/reference/commands/slack_blocks.md delete mode 100644 docs/reference/commands/slack_blocks_preview.md diff --git a/cmd/blocks/blocks.go b/cmd/blocks/blocks.go index 3040d91c..9a1a0473 100644 --- a/cmd/blocks/blocks.go +++ b/cmd/blocks/blocks.go @@ -25,10 +25,13 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { Use: "blocks [flags]", Short: "Work with Block Kit blocks", Long: "Work with Block Kit blocks, such as previewing them in the Block Kit Builder.", + // Hidden while gated behind the block-kit-builder experiment. Remove + // when the experiment graduates. + Hidden: true, Example: style.ExampleCommandsf([]style.ExampleCommand{ { Meaning: "Preview blocks in the Block Kit Builder", - Command: "blocks preview '[{\"type\":\"divider\"}]'", + Command: "blocks preview --blocks '[{\"type\":\"divider\"}]'", }, }), Args: cobra.NoArgs, diff --git a/cmd/blocks/blocks_test.go b/cmd/blocks/blocks_test.go index ef67cfbc..57356443 100644 --- a/cmd/blocks/blocks_test.go +++ b/cmd/blocks/blocks_test.go @@ -21,6 +21,7 @@ import ( "github.com/slackapi/slack-cli/internal/shared" "github.com/slackapi/slack-cli/test/testutil" "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" ) func Test_Blocks_Command(t *testing.T) { @@ -37,3 +38,10 @@ func Test_Blocks_Command(t *testing.T) { return NewCommand(cf) }) } + +func Test_Blocks_Command_Hidden(t *testing.T) { + clientsMock := shared.NewClientsMock() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + cmd := NewCommand(clients) + assert.True(t, cmd.Hidden, "blocks command should be hidden while experimental") +} diff --git a/docs/reference/commands/slack.md b/docs/reference/commands/slack.md index b71f17a5..536ec38d 100644 --- a/docs/reference/commands/slack.md +++ b/docs/reference/commands/slack.md @@ -45,7 +45,6 @@ $ slack docs # Open Slack developer docs * [slack api](slack_api) - Call any Slack API method * [slack app](slack_app) - Install, uninstall, and list teams with the app installed * [slack auth](slack_auth) - Add and remove local team authorizations -* [slack blocks](slack_blocks) - Work with Block Kit blocks * [slack collaborator](slack_collaborator) - Manage app collaborators * [slack create](slack_create) - Create a new Slack project * [slack datastore](slack_datastore) - Interact with an app's datastore diff --git a/docs/reference/commands/slack_blocks.md b/docs/reference/commands/slack_blocks.md deleted file mode 100644 index 43d638d8..00000000 --- a/docs/reference/commands/slack_blocks.md +++ /dev/null @@ -1,45 +0,0 @@ -# `slack blocks` - -Work with Block Kit blocks - -## Description - -Work with Block Kit blocks, such as previewing them in the Block Kit Builder. - -``` -slack blocks [flags] -``` - -## Flags - -``` - -h, --help help for blocks -``` - -## Global flags - -``` - --accessible use accessible prompts for screen readers - -a, --app string use a specific app ID or environment - --config-dir string use a custom path for system config directory - -e, --experiment strings use the experiment(s) in the command - -f, --force ignore warnings and continue executing command - --no-color remove styles and formatting from outputs - -s, --skip-update skip checking for latest version of CLI - -w, --team string select workspace or organization by team name or ID - --token string set the access token associated with a team - -v, --verbose print debug logging and additional info -``` - -## Examples - -``` -# Preview blocks in the Block Kit Builder -$ slack blocks preview '[{"type":"divider"}]' -``` - -## See also - -* [slack](slack) - Slack command-line tool -* [slack blocks preview](slack_blocks_preview) - Preview blocks in the Block Kit Builder - diff --git a/docs/reference/commands/slack_blocks_preview.md b/docs/reference/commands/slack_blocks_preview.md deleted file mode 100644 index 98059094..00000000 --- a/docs/reference/commands/slack_blocks_preview.md +++ /dev/null @@ -1,50 +0,0 @@ -# `slack blocks preview` - -Preview blocks in the Block Kit Builder - -## Description - -Open a set of Block Kit blocks in the Block Kit Builder in a web browser. - -Blocks can be passed as an argument or piped in through standard input. The -input is a JSON array of blocks or a JSON object with a "blocks" array. - -``` -slack blocks preview [blocks] [flags] -``` - -## Flags - -``` - -h, --help help for preview -``` - -## Global flags - -``` - --accessible use accessible prompts for screen readers - -a, --app string use a specific app ID or environment - --config-dir string use a custom path for system config directory - -e, --experiment strings use the experiment(s) in the command - -f, --force ignore warnings and continue executing command - --no-color remove styles and formatting from outputs - -s, --skip-update skip checking for latest version of CLI - -w, --team string select workspace or organization by team name or ID - --token string set the access token associated with a team - -v, --verbose print debug logging and additional info -``` - -## Examples - -``` -# Preview blocks passed as an argument -$ slack blocks preview '[{"type":"divider"}]' - -# Preview blocks piped from a file -$ slack blocks preview < blocks.json -``` - -## See also - -* [slack blocks](slack_blocks) - Work with Block Kit blocks - From 39e2cd77487f9d729542673a32280c14de3ea4fb Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 20 Jul 2026 17:03:54 -0400 Subject: [PATCH 10/17] feat: gate blocks command on AI-tool detection instead of experiment Surface and allow the blocks command only when the CLI is invoked by an AI coding tool, reusing the existing useragent.GetAIAgent detection. Replaces the block-kit-builder experiment: the parent command is hidden from help/docs unless an AI tool is detected, and preview's PreRunE now returns command_unavailable for non-AI callers. Removes the BlockKitBuilder experiment. Co-Authored-By: Claude --- cmd/blocks/blocks.go | 11 ++++-- cmd/blocks/blocks_test.go | 29 +++++++++++++--- cmd/blocks/preview.go | 16 ++++----- cmd/blocks/preview_test.go | 57 ++++++++++++++----------------- docs/reference/errors.md | 6 ++++ internal/experiment/experiment.go | 5 --- internal/slackerror/errors.go | 6 ++++ 7 files changed, 78 insertions(+), 52 deletions(-) diff --git a/cmd/blocks/blocks.go b/cmd/blocks/blocks.go index 9a1a0473..638b2d52 100644 --- a/cmd/blocks/blocks.go +++ b/cmd/blocks/blocks.go @@ -17,17 +17,22 @@ 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 detects the AI coding tool invoking the CLI. It is a package +// variable so that it can be stubbed in tests. +var aiAgentFunc = useragent.GetAIAgent + func NewCommand(clients *shared.ClientFactory) *cobra.Command { cmd := &cobra.Command{ Use: "blocks [flags]", Short: "Work with Block Kit blocks", Long: "Work with Block Kit blocks, such as previewing them in the Block Kit Builder.", - // Hidden while gated behind the block-kit-builder experiment. Remove - // when the experiment graduates. - Hidden: true, + // The command is intended for AI coding tools, so it is hidden from help + // and docs unless one is detected. + Hidden: aiAgentFunc() == nil, Example: style.ExampleCommandsf([]style.ExampleCommand{ { Meaning: "Preview blocks in the Block Kit Builder", diff --git a/cmd/blocks/blocks_test.go b/cmd/blocks/blocks_test.go index 57356443..48e711c2 100644 --- a/cmd/blocks/blocks_test.go +++ b/cmd/blocks/blocks_test.go @@ -19,6 +19,7 @@ import ( "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" @@ -40,8 +41,28 @@ func Test_Blocks_Command(t *testing.T) { } func Test_Blocks_Command_Hidden(t *testing.T) { - clientsMock := shared.NewClientsMock() - clients := shared.NewClientFactory(clientsMock.MockClientFactory()) - cmd := NewCommand(clients) - assert.True(t, cmd.Hidden, "blocks command should be hidden while experimental") + 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) + }) + } } diff --git a/cmd/blocks/preview.go b/cmd/blocks/preview.go index 865aceb3..f4e0c655 100644 --- a/cmd/blocks/preview.go +++ b/cmd/blocks/preview.go @@ -22,7 +22,6 @@ import ( "net/url" "strings" - "github.com/slackapi/slack-cli/internal/experiment" "github.com/slackapi/slack-cli/internal/goutils" "github.com/slackapi/slack-cli/internal/prompts" "github.com/slackapi/slack-cli/internal/shared" @@ -61,7 +60,7 @@ func NewPreviewCommand(clients *shared.ClientFactory) *cobra.Command { }), Args: cobra.NoArgs, PreRunE: func(cmd *cobra.Command, args []string) error { - return previewCommandPreRunE(clients) + return previewCommandPreRunE() }, RunE: func(cmd *cobra.Command, args []string) error { return previewCommandRunE(clients, cmd, blocksFlag, cmd.Flags().Changed("blocks")) @@ -71,12 +70,13 @@ func NewPreviewCommand(clients *shared.ClientFactory) *cobra.Command { return cmd } -// previewCommandPreRunE gates the command behind the block-kit-builder experiment -func previewCommandPreRunE(clients *shared.ClientFactory) error { - if !clients.Config.WithExperimentOn(experiment.BlockKitBuilder) { - return slackerror.New(slackerror.ErrMissingExperiment). - WithMessage("The blocks preview command is experimental"). - WithRemediation("Enable this command with the %s flag", style.Highlight("--experiment "+string(experiment.BlockKitBuilder))) +// previewCommandPreRunE gates the command to AI coding tools, which are the +// intended callers +func previewCommandPreRunE() error { + if aiAgentFunc() == nil { + return slackerror.New(slackerror.ErrCommandUnavailable). + WithMessage("The blocks preview command is only available to AI coding tools"). + WithRemediation("Run this command through a supported AI coding tool") } return nil } diff --git a/cmd/blocks/preview_test.go b/cmd/blocks/preview_test.go index 8a0b1222..0e6a6a44 100644 --- a/cmd/blocks/preview_test.go +++ b/cmd/blocks/preview_test.go @@ -24,6 +24,7 @@ import ( "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/useragent" "github.com/slackapi/slack-cli/test/testutil" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" @@ -31,14 +32,12 @@ import ( "github.com/stretchr/testify/require" ) -// enableExperiment turns on the block-kit-builder experiment for a test. -// Default mocks are installed first so LoadExperiments can read config and log -// debug output. Register any custom mocks (such as the API host) before calling -// this so that they take precedence over the defaults. -func enableExperiment(ctx context.Context, cm *shared.ClientsMock) { - cm.AddDefaultMocks() - cm.Config.ExperimentsFlag = append(cm.Config.ExperimentsFlag, "block-kit-builder") - cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) +// stubAIAgent stubs the detected AI coding tool and returns a function that +// restores the original detection. +func stubAIAgent(agent *useragent.AIAgent) func() { + original := aiAgentFunc + aiAgentFunc = func() *useragent.AIAgent { return agent } + return func() { aiAgentFunc = original } } // stubTeamAuth stubs the team selection to return the provided auth @@ -51,13 +50,17 @@ func stubTeamAuth(auth *types.SlackAuth) func() { } func Test_Blocks_PreviewCommand(t *testing.T) { + // The command is gated to AI coding tools, so detect one for every case. + // The "errors when not run by an AI coding tool" case overrides this. + restoreAIAgent := stubAIAgent(&useragent.AIAgent{Name: "claude-code"}) + defer restoreAIAgent() + var restore func() testutil.TableTestCommand(t, testutil.CommandTests{ "opens the builder with blocks from the --blocks flag": { CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cm.API.On("Host").Return("https://slack.com") - enableExperiment(ctx, cm) restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { @@ -72,7 +75,6 @@ func Test_Blocks_PreviewCommand(t *testing.T) { Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cm.API.On("Host").Return("https://slack.com") cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) - enableExperiment(ctx, cm) restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { @@ -86,7 +88,6 @@ func Test_Blocks_PreviewCommand(t *testing.T) { cm.API.On("Host").Return("https://slack.com") cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) // default IsStdinTTY() is false (piped) - enableExperiment(ctx, cm) restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { @@ -99,7 +100,6 @@ func Test_Blocks_PreviewCommand(t *testing.T) { CmdArgs: []string{"--blocks", `{"blocks":[{"type":"divider"}]}`}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cm.API.On("Host").Return("https://slack.com") - enableExperiment(ctx, cm) restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { @@ -108,14 +108,20 @@ func Test_Blocks_PreviewCommand(t *testing.T) { }, Teardown: func() { restore() }, }, - "errors when the experiment is not enabled": { - CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, - ExpectedErrorStrings: []string{slackerror.ErrMissingExperiment, "experimental"}, + "errors when not run by an AI coding tool": { + CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + restore = stubAIAgent(nil) + }, + ExpectedErrorStrings: []string{slackerror.ErrCommandUnavailable, "AI coding tools"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertNotCalled(t, "OpenURL", mock.Anything) + }, + Teardown: func() { restore() }, }, "errors without hanging when no blocks are provided on a terminal": { Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cm.IO.On("IsStdinTTY").Return(true) - enableExperiment(ctx, cm) }, ExpectedErrorStrings: []string{slackerror.ErrMissingInput, "No blocks were provided"}, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { @@ -123,24 +129,15 @@ func Test_Blocks_PreviewCommand(t *testing.T) { }, }, "errors when the --blocks flag is empty": { - CmdArgs: []string{"--blocks", ""}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - enableExperiment(ctx, cm) - }, + CmdArgs: []string{"--blocks", ""}, ExpectedErrorStrings: []string{slackerror.ErrMissingInput, "No blocks were provided"}, }, "errors when the blocks are not valid json": { - CmdArgs: []string{"--blocks", `not json`}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - enableExperiment(ctx, cm) - }, + CmdArgs: []string{"--blocks", `not json`}, ExpectedErrorStrings: []string{slackerror.ErrUnableToParseJSON}, }, "errors when the json is not a blocks payload": { - CmdArgs: []string{"--blocks", `{"foo":"bar"}`}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - enableExperiment(ctx, cm) - }, + CmdArgs: []string{"--blocks", `{"foo":"bar"}`}, ExpectedErrorStrings: []string{slackerror.ErrInvalidBlocks}, }, "errors when piping blocks with multiple teams and no --team flag": { @@ -151,7 +148,6 @@ func Test_Blocks_PreviewCommand(t *testing.T) { {TeamID: "T123", TeamDomain: "team-a"}, {TeamID: "T456", TeamDomain: "team-b"}, }, nil) - enableExperiment(ctx, cm) }, ExpectedErrorStrings: []string{slackerror.ErrMissingFlag, "--team"}, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { @@ -163,7 +159,6 @@ func Test_Blocks_PreviewCommand(t *testing.T) { Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cm.API.On("Host").Return("https://slack.com") cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) - enableExperiment(ctx, cm) restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { @@ -177,7 +172,6 @@ func Test_Blocks_PreviewCommand(t *testing.T) { CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cm.API.On("Host").Return("https://slack.com") - enableExperiment(ctx, cm) restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123", EnterpriseID: "E456", IsEnterpriseInstall: true}) }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { @@ -191,7 +185,6 @@ func Test_Blocks_PreviewCommand(t *testing.T) { CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cm.API.On("Host").Return("https://slack.com") - enableExperiment(ctx, cm) restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123", EnterpriseID: "E456", IsEnterpriseInstall: false}) }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { diff --git a/docs/reference/errors.md b/docs/reference/errors.md index 53f164a5..473e4626 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -371,6 +371,12 @@ Move the .slack/cli.json file to .slack/hooks.json and try again. --- +### command_unavailable {#command_unavailable} + +**Message**: The command is not available in this environment + +--- + ### comment_required {#comment_required} **Message**: Your admin is requesting a reason to approve installation of this app diff --git a/internal/experiment/experiment.go b/internal/experiment/experiment.go index 0fedfa14..3c029c5b 100644 --- a/internal/experiment/experiment.go +++ b/internal/experiment/experiment.go @@ -30,10 +30,6 @@ type Experiment string // e.g. --experiment=first-toggle,second-toggle const ( - // BlockKitBuilder experiment enables the blocks preview command that opens - // the Block Kit Builder in a browser. - BlockKitBuilder Experiment = "block-kit-builder" - // Lipgloss experiment shows pretty styles. Lipgloss Experiment = "lipgloss" @@ -47,7 +43,6 @@ const ( // AllExperiments is a list of all available experiments that can be enabled // Please also add here 👇 var AllExperiments = []Experiment{ - BlockKitBuilder, Lipgloss, Placeholder, SetIcon, diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index 0c85ff12..43ccd6f0 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -76,6 +76,7 @@ const ( ErrCannotRemoveOwners = "cannot_remove_owner" ErrCannotRevokeOrgBotToken = "cannot_revoke_org_bot_token" ErrChannelNotFound = "channel_not_found" + ErrCommandUnavailable = "command_unavailable" ErrCommentRequired = "comment_required" ErrConnectedOrgDenied = "connected_org_denied" ErrConnectedTeamDenied = "connected_team_denied" @@ -588,6 +589,11 @@ Otherwise start your app for local development with: %s`, Remediation: "Try adding your app as a member to the channel.", }, + ErrCommandUnavailable: { + Code: ErrCommandUnavailable, + Message: "The command is not available in this environment", + }, + ErrCommentRequired: { Code: ErrCommentRequired, Message: "Your admin is requesting a reason to approve installation of this app", From 279794bb712c0b21786658c848c66a2dc2d593d5 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 20 Jul 2026 17:08:26 -0400 Subject: [PATCH 11/17] chore: remove internal blocks preview plan and design docs These agent-workflow planning artifacts under docs/superpowers/ are not part of the shipped product and are unreferenced. Remove them from the branch. Co-Authored-By: Claude --- .../2026-07-20-blocks-preview-input-rework.md | 982 ------------------ ...7-20-blocks-preview-input-rework-design.md | 185 ---- 2 files changed, 1167 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-20-blocks-preview-input-rework.md delete mode 100644 docs/superpowers/specs/2026-07-20-blocks-preview-input-rework-design.md diff --git a/docs/superpowers/plans/2026-07-20-blocks-preview-input-rework.md b/docs/superpowers/plans/2026-07-20-blocks-preview-input-rework.md deleted file mode 100644 index 1fbcaef5..00000000 --- a/docs/superpowers/plans/2026-07-20-blocks-preview-input-rework.md +++ /dev/null @@ -1,982 +0,0 @@ -# Blocks Preview Input Rework Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Rework `slack blocks preview` to take blocks via a `--blocks` flag (with a `-` stdin sentinel and auto pipe-detection) and fold in the seven code-review findings. - -**Architecture:** Add a small, testable stdin-TTY detection capability to the shared `Os`/`IOStreams` infrastructure, then rework `cmd/blocks/preview.go` input resolution on top of it. The remaining fixes (enterprise ID, team-prompt guard, URL hardening, hidden command, docs) are localized edits to `cmd/blocks/`. - -**Tech Stack:** Go, Cobra, testify/mock, afero. Test harness: `test/testutil.TableTestCommand`. - -## Global Constraints - -- Wrap errors crossing package boundaries with `slackerror.Wrap(err, slackerror.ErrCode)` (repo CLAUDE.md). -- Register new error codes in `internal/slackerror/errors.go`, alphabetically, in both the const block and `ErrorCodeMap`. (No new codes are needed in this plan — `ErrInvalidBlocks`, `ErrMissingInput`, `ErrMissingFlag`, `ErrInvalidArguments` all already exist.) -- Use `clients.Fs` / the `Os` abstraction, never direct `os` calls, so code stays testable. -- Test naming: `Test_StructName_FunctionName` or `Test_FunctionName`. Constructors first, then alphabetical. -- Table-driven tests use the map pattern with `tc` as the case variable. -- Run `gofmt -w` on every changed Go file. Run `make lint` before finishing. -- Commit messages use conventional-commit prefixes and end with: - `Co-Authored-By: Claude ` -- The entire `cmd/blocks/` feature is currently uncommitted working-tree state on this branch. "Modify" below means editing an existing working-tree file even though it is not yet committed. - ---- - -### Task 1: Stdin-TTY detection infrastructure - -Adds `Os.Stdin()` and `IOStreams.IsStdinTTY()`, mirroring the existing `Os.Stdout()` / `IsTTY()`. `IsTTY()` stats **stdout**; for `cat f | preview` stdout is still a terminal while only stdin is a pipe, so a separate stdin check is required. - -**Files:** -- Modify: `internal/shared/types/slackdeps.go` (add `Stdin() File` to the `Os` interface, after `Stdout() File` at line 67) -- Modify: `internal/slackdeps/os.go` (add `Stdin()` impl after `Stdout()` at line 100) -- Modify: `internal/slackdeps/os_mock.go` (add `Stdin()` mock method + default in `AddDefaultMocks`) -- Modify: `internal/iostreams/iostreams.go` (add `IsStdinTTY()` to `IOStreamer` interface + impl) -- Modify: `internal/iostreams/iostreams_mock.go` (add `IsStdinTTY()` mock method + default) -- Test: `internal/iostreams/iostreams_test.go` - -**Interfaces:** -- Produces: - - `types.Os` interface gains `Stdin() File` - - `(*slackdeps.Os).Stdin() types.File` - - `(*slackdeps.OsMock).Stdin() types.File` - - `IOStreamer` interface gains `IsStdinTTY() bool` - - `(*iostreams.IOStreams).IsStdinTTY() bool` — returns true when stdin is a character device (interactive terminal), false when piped/redirected or on stat error - - `(*iostreams.IOStreamsMock).IsStdinTTY() bool` - -- [ ] **Step 1: Write the failing test** - -Add to `internal/iostreams/iostreams_test.go` (immediately after `Test_IOStreams_IsTTY`, keeping alphabetical-ish grouping with the other `IsTTY` test): - -```go -func Test_IOStreams_IsStdinTTY(t *testing.T) { - tests := map[string]struct { - fileInfo os.FileInfo - expected bool - }{ - "interactive when stdin is a char device": { - fileInfo: &slackdeps.FileInfoCharDevice{}, - expected: true, - }, - "not interactive when stdin is a named pipe": { - fileInfo: &slackdeps.FileInfoNamedPipe{}, - expected: false, - }, - "not interactive when the stat check errors": { - expected: false, - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - fsMock := slackdeps.NewFsMock() - osMock := slackdeps.NewOsMock() - config := config.NewConfig(fsMock, osMock) - osMock.On("Stdin").Return(&slackdeps.FileMock{FileInfo: tc.fileInfo}) - io := NewIOStreams(config, fsMock, osMock) - - isStdinTTY := io.IsStdinTTY() - assert.Equal(t, tc.expected, isStdinTTY) - }) - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `make test testdir=internal/iostreams testname=Test_IOStreams_IsStdinTTY` -Expected: FAIL — compile error, `io.IsStdinTTY` undefined and `osMock.On("Stdin")` has no matching method. - -- [ ] **Step 3: Add `Stdin()` to the `Os` interface** - -In `internal/shared/types/slackdeps.go`, add to the `Os` interface right after the `Stdout() File` line (line 67): - -```go - // Stdout returns the file descriptor for stdout - Stdout() File - - // Stdin returns the file descriptor for stdin - Stdin() File -``` - -- [ ] **Step 4: Implement `Os.Stdin()`** - -In `internal/slackdeps/os.go`, add after the `Stdout()` method (after line 100): - -```go -// Stdin returns the file descriptor for stdin -func (c *Os) Stdin() types.File { - return os.Stdin -} -``` - -- [ ] **Step 5: Add the `OsMock.Stdin()` method and default** - -In `internal/slackdeps/os_mock.go`, add to `AddDefaultMocks` right after `m.On("Stdout").Return(os.Stdout)` (line 58): - -```go - m.On("Stdin").Return(os.Stdin) -``` - -Then add the method after the `Stdout()` mock method (after line 138): - -```go -// Stdin mocks the stdin with a file that can be adjusted -func (m *OsMock) Stdin() types.File { - args := m.Called() - return args.Get(0).(types.File) -} -``` - -- [ ] **Step 6: Add `IsStdinTTY()` to the `IOStreamer` interface + implement it** - -In `internal/iostreams/iostreams.go`, add to the `IOStreamer` interface right after the `IsTTY() bool` declaration (line 83): - -```go - // IsTTY returns true if the device is an interactive terminal - IsTTY() bool - - // IsStdinTTY returns true if stdin is an interactive terminal (not a pipe - // or redirected file) - IsStdinTTY() bool -``` - -Then add the implementation right after the `IsTTY()` method (after its closing brace near line 131): - -```go -// IsStdinTTY returns true if stdin is an interactive terminal -// -// Unlike IsTTY, which inspects stdout, this inspects stdin so that piped or -// redirected input (e.g. `cat blocks.json | slack ...`) is detected even when -// stdout is still attached to a terminal. -func (io *IOStreams) IsStdinTTY() bool { - if o, err := io.os.Stdin().Stat(); o == nil || err != nil { - return false - } else { - return (o.Mode() & os.ModeCharDevice) == os.ModeCharDevice - } -} -``` - -- [ ] **Step 7: Add the `IOStreamsMock.IsStdinTTY()` method and default** - -In `internal/iostreams/iostreams_mock.go`, add to `AddDefaultMocks` right after `m.On("IsTTY").Return(false)` (line 73): - -```go - m.On("IsStdinTTY").Return(false) -``` - -Then add the method right after the `IsTTY()` mock method (after line 98): - -```go -func (m *IOStreamsMock) IsStdinTTY() bool { - args := m.Called() - return args.Bool(0) -} -``` - -- [ ] **Step 8: Run the test to verify it passes** - -Run: `make test testdir=internal/iostreams testname=Test_IOStreams_IsStdinTTY` -Expected: PASS (all three sub-cases). - -- [ ] **Step 9: Run the broader packages to catch interface-conformance breaks** - -Run: `make test testdir=internal/iostreams` then `make test testdir=internal/slackdeps` -Expected: PASS. (If any other `IOStreamer` implementer exists it would fail to compile here; there is only `IOStreams` and `IOStreamsMock`.) - -- [ ] **Step 10: Format and commit** - -```bash -gofmt -w internal/shared/types/slackdeps.go internal/slackdeps/os.go internal/slackdeps/os_mock.go internal/iostreams/iostreams.go internal/iostreams/iostreams_mock.go internal/iostreams/iostreams_test.go -git add internal/shared/types/slackdeps.go internal/slackdeps/os.go internal/slackdeps/os_mock.go internal/iostreams/iostreams.go internal/iostreams/iostreams_mock.go internal/iostreams/iostreams_test.go -git commit -m "$(cat <<'EOF' -feat: add IsStdinTTY for detecting piped stdin - -Adds Os.Stdin() and IOStreams.IsStdinTTY(), mirroring Stdout()/IsTTY(), -so callers can distinguish piped/redirected stdin from an interactive -terminal even when stdout is still a TTY. - -Co-Authored-By: Claude -EOF -)" -``` - ---- - -### Task 2: Rework preview input model (`--blocks` flag, `-` sentinel, no-hang) - -Replaces the positional `[blocks]` arg with a `--blocks` string flag, adds `-` and auto-pipe stdin handling, and makes bare `preview` on a terminal error instead of hanging. Fixes findings #1 and #5. - -**Files:** -- Modify: `cmd/blocks/preview.go` -- Test: `cmd/blocks/preview_test.go` - -**Interfaces:** -- Consumes: `clients.IO.IsStdinTTY() bool` (Task 1); `clients.IO.ReadIn() io.Reader`; `promptTeamSlackAuthFunc` (existing package var). -- Produces: - - `resolveBlocksInput(clients *shared.ClientFactory, flagValue string, flagChanged bool) (input string, fromStdin bool, err error)` - - `previewCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, blocksFlag string, blocksFlagChanged bool) error` (signature changes — no longer takes `args`) - - `--blocks` flag on the preview command; command is now `Args: cobra.NoArgs` - - `fromStdin` boolean consumed by Task 3 - -- [ ] **Step 1: Write the failing tests** - -Replace the body of `Test_Blocks_PreviewCommand` in `cmd/blocks/preview_test.go` with the cases below (the argument-based case becomes flag-based; add the stdin-sentinel, auto-pipe, and no-hang cases). Keep the existing `enableExperiment` and `stubTeamAuth` helpers. - -```go -func Test_Blocks_PreviewCommand(t *testing.T) { - var restore func() - testutil.TableTestCommand(t, testutil.CommandTests{ - "opens the builder with blocks from the --blocks flag": { - CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("Host").Return("https://slack.com") - enableExperiment(ctx, cm) - restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) - }, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - expectedURL := `https://app.slack.com/block-kit-builder/T123/builder#%7B%22blocks%22:%5B%7B%22type%22:%22divider%22%7D%5D%7D` - cm.Browser.AssertCalled(t, "OpenURL", expectedURL) - cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.BlocksPreviewSuccess, []string{expectedURL}) - }, - Teardown: func() { restore() }, - }, - "opens the builder with blocks from stdin via the - sentinel": { - CmdArgs: []string{"--blocks", "-"}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("Host").Return("https://slack.com") - cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) - enableExperiment(ctx, cm) - restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) - }, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - expectedURL := `https://app.slack.com/block-kit-builder/T123/builder#%7B%22blocks%22:%5B%7B%22type%22:%22divider%22%7D%5D%7D` - cm.Browser.AssertCalled(t, "OpenURL", expectedURL) - }, - Teardown: func() { restore() }, - }, - "opens the builder with auto-detected piped stdin": { - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("Host").Return("https://slack.com") - cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) - // default IsStdinTTY() is false (piped) - enableExperiment(ctx, cm) - restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) - }, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - expectedURL := `https://app.slack.com/block-kit-builder/T123/builder#%7B%22blocks%22:%5B%7B%22type%22:%22divider%22%7D%5D%7D` - cm.Browser.AssertCalled(t, "OpenURL", expectedURL) - }, - Teardown: func() { restore() }, - }, - "accepts a blocks object payload": { - CmdArgs: []string{"--blocks", `{"blocks":[{"type":"divider"}]}`}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("Host").Return("https://slack.com") - enableExperiment(ctx, cm) - restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) - }, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - expectedURL := `https://app.slack.com/block-kit-builder/T123/builder#%7B%22blocks%22:%5B%7B%22type%22:%22divider%22%7D%5D%7D` - cm.Browser.AssertCalled(t, "OpenURL", expectedURL) - }, - Teardown: func() { restore() }, - }, - "errors when the experiment is not enabled": { - CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, - ExpectedErrorStrings: []string{slackerror.ErrMissingExperiment, "experimental"}, - }, - "errors without hanging when no blocks are provided on a terminal": { - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.IO.On("IsStdinTTY").Return(true) - enableExperiment(ctx, cm) - }, - ExpectedErrorStrings: []string{slackerror.ErrMissingInput, "No blocks were provided"}, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - cm.Browser.AssertNotCalled(t, "OpenURL", mock.Anything) - }, - }, - "errors when the --blocks flag is empty": { - CmdArgs: []string{"--blocks", ""}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - enableExperiment(ctx, cm) - }, - ExpectedErrorStrings: []string{slackerror.ErrMissingInput, "No blocks were provided"}, - }, - "errors when the blocks are not valid json": { - CmdArgs: []string{"--blocks", `not json`}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - enableExperiment(ctx, cm) - }, - ExpectedErrorStrings: []string{slackerror.ErrUnableToParseJSON}, - }, - "errors when the json is not a blocks payload": { - CmdArgs: []string{"--blocks", `{"foo":"bar"}`}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - enableExperiment(ctx, cm) - }, - ExpectedErrorStrings: []string{slackerror.ErrInvalidBlocks}, - }, - }, func(cf *shared.ClientFactory) *cobra.Command { - return NewPreviewCommand(cf) - }) -} -``` - -Note: the `Test_buildBlockKitBuilderURL` and `Test_normalizeBlocksPayload` functions in this file are unchanged by this task. - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `make test testdir=cmd/blocks testname=Test_Blocks_PreviewCommand` -Expected: FAIL — the command still uses a positional arg, so `--blocks` is an unknown flag and the argument-based expectations do not match. - -- [ ] **Step 3: Rework the command constructor and input resolution** - -In `cmd/blocks/preview.go`, replace `NewPreviewCommand`, `previewCommandRunE`, and `readBlocksInput` with the following. Keep `previewCommandPreRunE`, `normalizeBlocksPayload`, `teamOrEnterpriseID`, and `buildBlockKitBuilderURL` as they are (later tasks touch two of them). The `io` import stays (used by `readStdinBlocks`). - -```go -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 or pipe them through standard input.", - "The input is a JSON array of blocks or a JSON object with a \"blocks\" array.", - "Pass - to --blocks to read explicitly from standard input.", - }, "\n"), - Example: style.ExampleCommandsf([]style.ExampleCommand{ - { - Meaning: "Preview blocks passed with a flag", - Command: "blocks preview --blocks '[{\"type\":\"divider\"}]'", - }, - { - Meaning: "Preview blocks piped from a file", - Command: "blocks preview < blocks.json", - }, - }), - Args: cobra.NoArgs, - PreRunE: func(cmd *cobra.Command, args []string) error { - return previewCommandPreRunE(clients) - }, - 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 -} - -// previewCommandRunE resolves blocks from the flag or standard input and opens -// them in the Block Kit Builder for the selected team -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 := promptTeamSlackAuthFunc(ctx, clients, "Select a team to preview blocks for", nil) - 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 -} - -// resolveBlocksInput returns the blocks to preview and whether they were read -// from standard input. Resolution order: an explicit --blocks value, the - -// sentinel or an auto-detected stdin pipe, otherwise a friendly error. Reading -// stdin is never attempted against an interactive terminal, so a bare command -// on a TTY errors instead of blocking on io.ReadAll. -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 - case !clients.IO.IsStdinTTY(): - return readStdinBlocks(clients) - default: - return "", false, missingBlocksError() - } -} - -// readStdinBlocks reads and trims blocks from standard input -func readStdinBlocks(clients *shared.ClientFactory) (string, bool, error) { - 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 -} - -// missingBlocksError is the friendly error returned when no blocks are supplied -func missingBlocksError() error { - return slackerror.New(slackerror.ErrMissingInput). - WithMessage("No blocks were provided"). - WithRemediation("Provide blocks with the %s flag or pipe them through standard input", style.Highlight("--blocks")) -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `make test testdir=cmd/blocks testname=Test_Blocks_PreviewCommand` -Expected: PASS (all cases, including the no-hang and empty-flag cases). - -- [ ] **Step 5: Format and commit** - -```bash -gofmt -w cmd/blocks/preview.go cmd/blocks/preview_test.go -git add cmd/blocks/preview.go cmd/blocks/preview_test.go -git commit -m "$(cat <<'EOF' -feat: accept blocks via --blocks flag with stdin sentinel - -Replaces the positional argument with a --blocks flag, supports the - -stdin sentinel and auto-detected piped stdin, and errors on a bare -interactive invocation instead of blocking forever on stdin. - -Co-Authored-By: Claude -EOF -)" -``` - ---- - -### Task 3: Guard team selection when blocks come from stdin - -When blocks are read from stdin, the interactive team picker would read the exhausted stdin pipe and fail on EOF. Detect the one broken case — stdin input + more than one auth + no `--team` — and return a clean, actionable error before prompting. Fixes finding #3. - -**Files:** -- Modify: `cmd/blocks/preview.go` -- Test: `cmd/blocks/preview_test.go` - -**Interfaces:** -- Consumes: `fromStdin` from `resolveBlocksInput` (Task 2); `clients.Config.TeamFlag string`; `clients.Auth().Auths(ctx) ([]types.SlackAuth, error)`; `promptTeamSlackAuthFunc`. -- Produces: `selectTeamAuth(ctx context.Context, clients *shared.ClientFactory, fromStdin bool) (*types.SlackAuth, error)` - -- [ ] **Step 1: Write the failing tests** - -Add these two cases inside the `testutil.CommandTests{...}` map in `Test_Blocks_PreviewCommand` (in `cmd/blocks/preview_test.go`): - -```go - "errors when piping blocks with multiple teams and no --team flag": { - CmdArgs: []string{"--blocks", "-"}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) - cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ - {TeamID: "T123", TeamDomain: "team-a"}, - {TeamID: "T456", TeamDomain: "team-b"}, - }, nil) - enableExperiment(ctx, cm) - }, - ExpectedErrorStrings: []string{slackerror.ErrMissingFlag, "--team"}, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - cm.Browser.AssertNotCalled(t, "OpenURL", mock.Anything) - }, - }, - "opens the builder when piping blocks with the --team flag set": { - CmdArgs: []string{"--blocks", "-", "--team", "T123"}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("Host").Return("https://slack.com") - cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) - enableExperiment(ctx, cm) - restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) - }, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - cm.Browser.AssertCalled(t, "OpenURL", mock.MatchedBy(func(url string) bool { - return assert.Contains(t, url, "/block-kit-builder/T123/builder") - })) - }, - Teardown: func() { restore() }, - }, -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `make test testdir=cmd/blocks testname=Test_Blocks_PreviewCommand` -Expected: FAIL — the multi-team stdin case currently proceeds to the stubbed/real prompt instead of returning `ErrMissingFlag`. - -- [ ] **Step 3: Add the team-selection guard** - -In `cmd/blocks/preview.go`, add the `context` import if it is not already imported (it is needed for the new function signature), then add this function directly below `previewCommandRunE`: - -```go -// selectTeamAuth chooses the team whose Block Kit Builder to open. When blocks -// were read from stdin the interactive picker cannot prompt (stdin is spent), -// so with more than one authorization and no --team flag we return an -// actionable error instead of failing on EOF inside the prompt. -func selectTeamAuth(ctx context.Context, clients *shared.ClientFactory, fromStdin bool) (*types.SlackAuth, error) { - if fromStdin && clients.Config.TeamFlag == "" { - auths, err := clients.Auth().Auths(ctx) - if err != nil { - return nil, err - } - if 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 piping blocks", style.Highlight("--team")) - } - } - return promptTeamSlackAuthFunc(ctx, clients, "Select a team to preview blocks for", nil) -} -``` - -Then, in `previewCommandRunE`, capture the `fromStdin` return value and route team selection through `selectTeamAuth`. Change: - -```go - blocksInput, _, err := resolveBlocksInput(clients, blocksFlag, blocksFlagChanged) -``` -to: -```go - blocksInput, fromStdin, err := resolveBlocksInput(clients, blocksFlag, blocksFlagChanged) -``` - -and change: - -```go - auth, err := promptTeamSlackAuthFunc(ctx, clients, "Select a team to preview blocks for", nil) -``` -to: -```go - auth, err := selectTeamAuth(ctx, clients, fromStdin) -``` - -Add `"context"` to the import block if absent. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `make test testdir=cmd/blocks testname=Test_Blocks_PreviewCommand` -Expected: PASS. In particular the multi-team stdin case returns `ErrMissingFlag` and does not open a browser, and the single-team stdin cases from Task 2 still pass (the stub returns before the `len(auths) > 1` check matters). - -- [ ] **Step 5: Format and commit** - -```bash -gofmt -w cmd/blocks/preview.go cmd/blocks/preview_test.go -git add cmd/blocks/preview.go cmd/blocks/preview_test.go -git commit -m "$(cat <<'EOF' -fix: require --team when piping blocks with multiple auths - -Reading blocks from stdin consumes the pipe, so the interactive team -picker cannot prompt. Detect stdin input with more than one auth and no ---team flag and return an actionable error instead of failing on EOF. - -Co-Authored-By: Claude -EOF -)" -``` - ---- - -### Task 4: Use the enterprise ID only for enterprise installs - -`teamOrEnterpriseID` currently keys off `EnterpriseID != ""`, so an org-grid workspace install (enterprise ID present but not an enterprise install) opens the wrong Builder context. Key off `IsEnterpriseInstall` instead, matching `SlackAuth.AuthLevel()`. Fixes finding #2. - -**Files:** -- Modify: `cmd/blocks/preview.go:170-175` -- Test: `cmd/blocks/preview_test.go` - -**Interfaces:** -- Produces: `teamOrEnterpriseID(auth *types.SlackAuth) string` — unchanged signature, corrected behavior. - -- [ ] **Step 1: Write the failing tests** - -Add these two cases to the `testutil.CommandTests{...}` map in `Test_Blocks_PreviewCommand`: - -```go - "uses the enterprise id for enterprise installs": { - CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("Host").Return("https://slack.com") - enableExperiment(ctx, cm) - restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123", EnterpriseID: "E456", IsEnterpriseInstall: true}) - }, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - cm.Browser.AssertCalled(t, "OpenURL", mock.MatchedBy(func(url string) bool { - return assert.Contains(t, url, "/block-kit-builder/E456/builder") - })) - }, - Teardown: func() { restore() }, - }, - "uses the team id for org-grid workspace installs": { - CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("Host").Return("https://slack.com") - enableExperiment(ctx, cm) - restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123", EnterpriseID: "E456", IsEnterpriseInstall: false}) - }, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - cm.Browser.AssertCalled(t, "OpenURL", mock.MatchedBy(func(url string) bool { - return assert.Contains(t, url, "/block-kit-builder/T123/builder") - })) - }, - Teardown: func() { restore() }, - }, -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `make test testdir=cmd/blocks testname=Test_Blocks_PreviewCommand` -Expected: FAIL — the org-grid workspace case currently produces `/block-kit-builder/E456/builder` instead of `T123`. - -- [ ] **Step 3: Fix the discriminator** - -In `cmd/blocks/preview.go`, replace `teamOrEnterpriseID`: - -```go -// teamOrEnterpriseID returns the enterprise ID for enterprise installs and the -// team ID otherwise, matching the identifier used in Block Kit Builder URLs -func teamOrEnterpriseID(auth *types.SlackAuth) string { - if auth.IsEnterpriseInstall { - return auth.EnterpriseID - } - return auth.TeamID -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `make test testdir=cmd/blocks testname=Test_Blocks_PreviewCommand` -Expected: PASS (both the enterprise-install and org-grid-workspace cases). - -- [ ] **Step 5: Format and commit** - -```bash -gofmt -w cmd/blocks/preview.go cmd/blocks/preview_test.go -git add cmd/blocks/preview.go cmd/blocks/preview_test.go -git commit -m "$(cat <<'EOF' -fix: use enterprise id only for enterprise installs in blocks preview - -An org-grid workspace install has an enterprise ID but is not an -enterprise install; key off IsEnterpriseInstall so those installs open -the Builder in the workspace context. - -Co-Authored-By: Claude -EOF -)" -``` - ---- - -### Task 5: Harden `buildBlockKitBuilderURL` against bad hosts - -Guard against an empty or scheme-less host (which otherwise silently yields a malformed URL like `//app./...`) and wrap the `url.Parse` error with a structured code. Fixes findings #6 and #7. - -**Files:** -- Modify: `cmd/blocks/preview.go:180-189` -- Test: `cmd/blocks/preview_test.go` - -**Interfaces:** -- Produces: `buildBlockKitBuilderURL(apiHost string, id string, blocksJSON string) (string, error)` — unchanged signature; now errors on invalid hosts. - -- [ ] **Step 1: Write the failing tests** - -In `cmd/blocks/preview_test.go`, extend `Test_buildBlockKitBuilderURL` to cover error cases. Replace the test with: - -```go -func Test_buildBlockKitBuilderURL(t *testing.T) { - tests := map[string]struct { - apiHost string - id string - blocksJSON string - expected string - expectedErr string - }{ - "production host": { - apiHost: "https://slack.com", - id: "T123", - blocksJSON: `{"blocks":[]}`, - expected: "https://app.slack.com/block-kit-builder/T123/builder#%7B%22blocks%22:%5B%5D%7D", - }, - "developer host": { - apiHost: "https://dev1234.slack.com", - id: "E456", - blocksJSON: `{"blocks":[]}`, - expected: "https://app.dev1234.slack.com/block-kit-builder/E456/builder#%7B%22blocks%22:%5B%5D%7D", - }, - "empty host": { - apiHost: "", - id: "T123", - blocksJSON: `{"blocks":[]}`, - expectedErr: slackerror.ErrInvalidArguments, - }, - "scheme-less host": { - apiHost: "app.slack.com", - id: "T123", - blocksJSON: `{"blocks":[]}`, - expectedErr: slackerror.ErrInvalidArguments, - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - actual, err := buildBlockKitBuilderURL(tc.apiHost, tc.id, tc.blocksJSON) - if tc.expectedErr != "" { - require.Error(t, err) - assert.Equal(t, tc.expectedErr, slackerror.ToSlackError(err).Code) - return - } - require.NoError(t, err) - assert.Equal(t, tc.expected, actual) - }) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `make test testdir=cmd/blocks testname=Test_buildBlockKitBuilderURL` -Expected: FAIL — the empty and scheme-less host cases currently return no error. - -- [ ] **Step 3: Add the host guard and wrap the parse error** - -In `cmd/blocks/preview.go`, replace `buildBlockKitBuilderURL`: - -```go -// buildBlockKitBuilderURL constructs the Block Kit Builder URL for the given -// API host, team or enterprise ID, and compact blocks JSON. The blocks JSON is -// placed in the URL fragment, which url.URL.String percent-encodes. -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 -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `make test testdir=cmd/blocks testname=Test_buildBlockKitBuilderURL` -Expected: PASS (all four cases). - -- [ ] **Step 5: Format and commit** - -```bash -gofmt -w cmd/blocks/preview.go cmd/blocks/preview_test.go -git add cmd/blocks/preview.go cmd/blocks/preview_test.go -git commit -m "$(cat <<'EOF' -fix: reject invalid API hosts when building Block Kit Builder URL - -Guard against empty or scheme-less hosts that would otherwise yield a -malformed URL, and wrap the url.Parse error with a structured code. - -Co-Authored-By: Claude -EOF -)" -``` - ---- - -### Task 6: Hide the experimental command and remove its generated docs - -Hide the `blocks` command until the `block-kit-builder` experiment graduates, and remove the generated doc pages/index entry (docgen skips hidden commands via `IsAvailableCommand()`). Fixes finding #4. - -**Files:** -- Modify: `cmd/blocks/blocks.go` -- Test: `cmd/blocks/blocks_test.go` -- Delete: `docs/reference/commands/slack_blocks.md` (currently untracked) -- Delete: `docs/reference/commands/slack_blocks_preview.md` (currently untracked) -- Modify: `docs/reference/commands/slack.md` (remove the added `slack blocks` index line) - -**Interfaces:** -- Consumes: nothing new. -- Produces: `blocks` command has `Hidden: true`; example text updated to `--blocks` form. - -- [ ] **Step 1: Write the failing test** - -Replace `Test_Blocks_Command` in `cmd/blocks/blocks_test.go` with a version that asserts the command is hidden. Because a hidden command still prints its own help when invoked directly, the existing output expectations remain valid; add the hidden assertion via `ExpectedAsserts` is not suitable here (it inspects mocks, not the command), so assert on the constructed command directly in a small standalone test: - -```go -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) { - clientsMock := shared.NewClientsMock() - clients := shared.NewClientFactory(clientsMock.MockClientFactory()) - cmd := NewCommand(clients) - assert.True(t, cmd.Hidden, "blocks command should be hidden while experimental") -} -``` - -Add the import `"github.com/stretchr/testify/assert"` to `cmd/blocks/blocks_test.go`. - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `make test testdir=cmd/blocks testname=Test_Blocks_Command_Hidden` -Expected: FAIL — `cmd.Hidden` is false. - -- [ ] **Step 3: Hide the command and update the example** - -In `cmd/blocks/blocks.go`, add `Hidden: true` with an explanatory comment and update the example to the `--blocks` form: - -```go - cmd := &cobra.Command{ - Use: "blocks [flags]", - Short: "Work with Block Kit blocks", - Long: "Work with Block Kit blocks, such as previewing them in the Block Kit Builder.", - // Hidden while gated behind the block-kit-builder experiment. Remove - // when the experiment graduates. - Hidden: true, - 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() - }, - } -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `make test testdir=cmd/blocks` -Expected: PASS (both `Test_Blocks_Command` and `Test_Blocks_Command_Hidden`). - -- [ ] **Step 5: Remove the generated docs and index entry** - -The two `slack_blocks*.md` pages are untracked; remove them from disk. Edit `slack.md` to drop the index line. - -```bash -rm docs/reference/commands/slack_blocks.md docs/reference/commands/slack_blocks_preview.md -``` - -In `docs/reference/commands/slack.md`, remove this line: - -``` -* [slack blocks](slack_blocks) - Work with Block Kit blocks -``` - -Optional verification that docgen agrees (regenerates docs without the hidden command; requires a build): - -```bash -make build-ci && ./bin/slack docgen ./docs/reference && git status docs/reference/commands -``` -Expected: no `slack_blocks*.md` files reappear and `slack.md` has no `slack blocks` line. If docgen produces unrelated diffs, discard them (`git checkout -- `); this task only owns the blocks-related doc changes. - -- [ ] **Step 6: Format and commit** - -```bash -gofmt -w cmd/blocks/blocks.go cmd/blocks/blocks_test.go -git add cmd/blocks/blocks.go cmd/blocks/blocks_test.go docs/reference/commands/slack.md -git rm --ignore-unmatch docs/reference/commands/slack_blocks.md docs/reference/commands/slack_blocks_preview.md -git commit -m "$(cat <<'EOF' -chore: hide experimental blocks command from help and docs - -Hide the blocks command while gated behind the block-kit-builder -experiment and drop its generated doc pages, which docgen omits for -hidden commands. - -Co-Authored-By: Claude -EOF -)" -``` - ---- - -### Task 7: Full verification sweep - -Confirm the whole feature builds, lints, and passes tests together, plus a manual smoke test. - -**Files:** none (verification only). - -- [ ] **Step 1: Run the full affected test packages** - -Run: -```bash -make test testdir=cmd/blocks -make test testdir=internal/iostreams -make test testdir=internal/slackdeps -``` -Expected: PASS for all. - -- [ ] **Step 2: Lint** - -Run: `make lint` -Expected: no findings in `cmd/blocks/`, `internal/iostreams/`, `internal/slackdeps/`, `internal/shared/types/`. - -- [ ] **Step 3: Build** - -Run: `make build-ci` -Expected: builds `./bin/slack` with no errors. - -- [ ] **Step 4: Manual smoke test — flag path** - -Run: `./bin/slack blocks preview --experiment block-kit-builder --blocks '[{"type":"divider"}]' --team ` -Expected: prints the Block Kit Builder section with an `https://app./block-kit-builder//builder#...` URL and attempts to open the browser. - -- [ ] **Step 5: Manual smoke test — no hang on bare invocation** - -Run: `./bin/slack blocks preview --experiment block-kit-builder` in an interactive terminal (no pipe). -Expected: returns immediately with "No blocks were provided" and remediation mentioning `--blocks` — does NOT hang waiting on stdin. - -- [ ] **Step 6: Manual smoke test — piped stdin** - -Run: `echo '[{"type":"divider"}]' | ./bin/slack blocks preview --experiment block-kit-builder --team ` -Expected: same successful output as Step 4. - -- [ ] **Step 7: Confirm the command is hidden** - -Run: `./bin/slack --help` and `./bin/slack --help --verbose` -Expected: `blocks` is absent from the standard command list. - ---- - -## Notes for the implementer - -- Findings mapped to tasks: #1 → Task 2; #2 → Task 4; #3 → Task 3; #4 → Task 6; #5 → Task 2; #6 → Task 5; #7 → Task 5. The stdin-TTY infrastructure (Task 1) is the enabler for #1. -- `normalizeBlocksPayload` is intentionally unchanged — its existing tests in `Test_normalizeBlocksPayload` stay green throughout. -- Tasks 2–6 all edit `cmd/blocks/preview.go` (except Task 6, which edits `blocks.go`); implement them in order so each commit is coherent. diff --git a/docs/superpowers/specs/2026-07-20-blocks-preview-input-rework-design.md b/docs/superpowers/specs/2026-07-20-blocks-preview-input-rework-design.md deleted file mode 100644 index 3da028d2..00000000 --- a/docs/superpowers/specs/2026-07-20-blocks-preview-input-rework-design.md +++ /dev/null @@ -1,185 +0,0 @@ -# `slack blocks preview` — input rework and review fixes - -**Date:** 2026-07-20 -**Status:** Approved, pending implementation plan -**Branch:** bill-bkb-open-command - -## Background - -The new `slack blocks preview` command (in `cmd/blocks/`, gated behind the -`block-kit-builder` experiment) opens a set of Block Kit blocks in the Block Kit -Builder in a browser. A code review of the initial implementation surfaced -seven findings. This design reworks how the command accepts input and folds in -the remaining fixes. - -### Findings addressed - -1. **#1 Interactive hang** — bare `slack blocks preview` (no arg, no pipe) calls - `io.ReadAll(stdin)` with no TTY guard and blocks forever on an interactive - terminal. -2. **#2 Wrong enterprise ID** — `teamOrEnterpriseID` keys off - `EnterpriseID != ""` instead of `IsEnterpriseInstall`, so an org-grid - *workspace* install opens the Builder in the org (`E…`) context instead of the - workspace (`T…`) context. -3. **#3 Stdin/team-prompt conflict** — after consuming piped stdin for the - blocks, the interactive "Select a team" prompt reads the now-exhausted stdin - pipe and fails on EOF (only masked when the user has exactly one auth). -4. **#4 Experimental command not hidden** — the `blocks` command is listed in - `slack --help` for all users despite being experimental and non-functional - without the flag. -5. **#5 Unfriendly empty-arg error** — an empty input produces a raw - `ErrUnableToParseJSON` instead of the friendly "No blocks were provided". -6. **#6 Malformed URL for bad host** — `buildBlockKitBuilderURL` silently emits - garbage (`//app./…`) for an empty or scheme-less host. -7. **#7 Unwrapped `url.Parse` error** — the raw stdlib error is returned across a - package boundary without a structured `slackerror` code (CLAUDE.md rule). - -## Design - -### 1. Input model: `--blocks` flag with `-` stdin sentinel - -Replace the positional `[blocks]` argument with a `--blocks` string flag, -following the `slack api --json ''` precedent. The command becomes -`Args: cobra.NoArgs`. - -Behavior matrix: - -| Invocation | Source | Notes | -|---|---|---| -| `preview --blocks '[...]'` | literal flag value | stdin untouched | -| `cat f \| preview --blocks -` | stdin (explicit `-` sentinel) | | -| `cat f \| preview` | stdin (auto-detected pipe) | no flag needed | -| `preview` on a TTY, no flag | friendly `ErrMissingInput` error | **no hang** (fixes #1) | - -A new `resolveBlocksInput` function resolves the source in this order: - -1. `--blocks` set and value `!= "-"` → use the literal value. An empty string - returns a friendly `ErrMissingInput` "No blocks were provided" (fixes #5). -2. `--blocks -`, **or** flag unset but stdin is a pipe → `io.ReadAll(stdin)`. -3. Flag unset and stdin is an interactive terminal → friendly `ErrMissingInput` - with remediation. Critically, **no `ReadAll` runs against a TTY**, so the - command cannot block (fixes #1). - -`resolveBlocksInput` also returns a boolean indicating whether the blocks came -from stdin; Section 2 consumes it. - -#### Pipe detection (infrastructure) - -For `cat f | preview`, stdout is still a TTY — only *stdin* is the pipe — so the -existing `IsTTY()` (which stats stdout) cannot gate stdin reads. We generalize -the existing TTY mechanism rather than special-casing stdin: - -- Add `Stdin() types.File` to the `types.Os` interface, the `Os` implementation - (`return os.Stdin`), and `OsMock` (`AddDefaultMocks` gains - `m.On("Stdin").Return(os.Stdin)`), mirroring the existing `Stdout()`. -- Add `IsStdinTTY() bool` to the `IOStreamer` interface, implemented exactly like - `IsTTY()` but statting stdin: - `(mode & os.ModeCharDevice) == os.ModeCharDevice`. A pipe is therefore - "not a stdin TTY". - -This keeps the fix at the right altitude and remains mockable, so tests stay -hermetic. - -### 2. Team selection when stdin is consumed (fix #3) - -When blocks are read from stdin, the interactive team picker would read the -exhausted pipe and fail. We rely on what `PromptTeamSlackAuth` already does: - -- returns the sole auth directly when exactly one exists (no prompt — works even - with consumed stdin), and -- honors the `--team` flag via `SelectPromptConfig{Flag: teamFlag}` before - touching stdin. - -The only broken case is **blocks-from-stdin + ≥2 auths + no `--team`**. We detect -exactly that case *before* calling the picker and return a clean, actionable -error instead of proceeding into a survey form that fails on dead stdin: - -``` -Error: the team could not be determined -Suggestion: Select a team with the --team flag when piping blocks -``` - -When blocks come from `--blocks ''`, stdin is untouched and the picker -works interactively as before — no change. The "came from stdin" boolean from -`resolveBlocksInput` drives this; no extra plumbing. - -### 3. Remaining fixes - -**#2 — Enterprise ID selection.** Change the discriminator to -`auth.IsEnterpriseInstall`, matching `SlackAuth.AuthLevel()`: - -```go -func teamOrEnterpriseID(auth *types.SlackAuth) string { - if auth.IsEnterpriseInstall { - return auth.EnterpriseID - } - return auth.TeamID -} -``` - -An org-grid workspace install (`EnterpriseID` set, `IsEnterpriseInstall=false`) -now correctly opens the Builder in the workspace `T…` context. - -**#4 — Hide the experimental command.** Set `Hidden: true` on the `blocks` parent -command in `blocks.go`, with a comment to remove it when the `block-kit-builder` -experiment graduates. The `preview` `PreRunE` experiment gate stays as-is. - -**#6 + #7 — URL hardening in `buildBlockKitBuilderURL`.** - -- Wrap the `url.Parse` error: - `return "", slackerror.Wrap(err, slackerror.ErrInvalidArguments)`. -- After parsing, if `parsed.Host == ""` (the result for `""` or a scheme-less - `"app.slack.com"`), return `slackerror.New(slackerror.ErrInvalidArguments)` - instead of silently building `//app./…`. - -## Testing - -### `cmd/blocks/preview_test.go` - -- `--blocks '[...]'` literal → opens builder (replaces the old positional case). -- `cat | preview --blocks -` → explicit stdin sentinel. -- `cat | preview` (piped, no flag) → auto-detected stdin; mock stdin stat reports - a pipe (not `ModeCharDevice`). -- bare `preview` on a TTY, no flag → `ErrMissingInput`; assert neither - `OpenURL` nor a blocking `ReadAll` is reached (no hang). -- `--blocks ""` → friendly "No blocks were provided". -- blocks-from-stdin + ≥2 auths + no `--team` → clean error pointing to `--team` - (fix #3). -- blocks-from-stdin + `--team` set → succeeds. -- org-grid workspace install (`EnterpriseID` set, `IsEnterpriseInstall=false`) → - uses `T…` (fix #2). -- `buildBlockKitBuilderURL` empty-host and scheme-less-host → error (#6/#7). - -### `cmd/blocks/blocks_test.go` - -- assert the `blocks` command has `Hidden == true` (#4). - -### Infrastructure tests - -- `IsStdinTTY()` and `Os.Stdin()` follow the existing `IsTTY()`/`Stdout()` mock - patterns. - -## Docs - -Rerun `slack docgen ./docs/reference` to regenerate `slack_blocks.md` and -`slack_blocks_preview.md` (the `--blocks` flag, `NoArgs`, and updated examples -flow from the command definitions). - -Because `blocks` becomes `Hidden`, confirm whether docgen still emits hidden -commands. If hidden commands are excluded from generated docs, revert the -`slack.md` index line and the two generated pages so the published docs match -reality until the experiment graduates. - -## Verification - -- `make test testdir=cmd/blocks` -- `make lint` -- `gofmt -w` on all changed Go files -- Manual smoke test: - `./bin/slack blocks preview --experiment block-kit-builder --blocks '[{"type":"divider"}]'` - -## Out of scope - -- Any change to the Block Kit Builder URL format beyond the empty-host guard. -- Removing the `block-kit-builder` experiment gate (graduation is a later - change). From 53b6ee152ddf002f0cfb3d1204250f3ca1ebfa95 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 21 Jul 2026 12:00:58 -0400 Subject: [PATCH 12/17] feat: allow anyone to run blocks preview, keep it hidden from non-AI tools The blocks preview command was gated two ways on AI-coding-tool detection: visibility (parent command Hidden) and execution (preview PreRunE returning command_unavailable for non-AI callers). Drop the execution gate so anyone can run the command, while keeping the visibility gate so it stays hidden from help and docs unless an AI coding tool is detected. Remove the now-orphaned command_unavailable error code (added by the same commit that introduced the gate, used nowhere else) and regenerate errors.md. Co-Authored-By: Claude --- cmd/blocks/blocks.go | 8 +++----- cmd/blocks/preview.go | 14 -------------- cmd/blocks/preview_test.go | 16 ---------------- docs/reference/errors.md | 6 ------ internal/slackerror/errors.go | 6 ------ 5 files changed, 3 insertions(+), 47 deletions(-) diff --git a/cmd/blocks/blocks.go b/cmd/blocks/blocks.go index 638b2d52..3477e6c8 100644 --- a/cmd/blocks/blocks.go +++ b/cmd/blocks/blocks.go @@ -27,11 +27,9 @@ var aiAgentFunc = useragent.GetAIAgent func NewCommand(clients *shared.ClientFactory) *cobra.Command { cmd := &cobra.Command{ - Use: "blocks [flags]", - Short: "Work with Block Kit blocks", - Long: "Work with Block Kit blocks, such as previewing them in the Block Kit Builder.", - // The command is intended for AI coding tools, so it is hidden from help - // and docs unless one is detected. + Use: "blocks [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{ { diff --git a/cmd/blocks/preview.go b/cmd/blocks/preview.go index f4e0c655..ba00d040 100644 --- a/cmd/blocks/preview.go +++ b/cmd/blocks/preview.go @@ -59,9 +59,6 @@ func NewPreviewCommand(clients *shared.ClientFactory) *cobra.Command { }, }), Args: cobra.NoArgs, - PreRunE: func(cmd *cobra.Command, args []string) error { - return previewCommandPreRunE() - }, RunE: func(cmd *cobra.Command, args []string) error { return previewCommandRunE(clients, cmd, blocksFlag, cmd.Flags().Changed("blocks")) }, @@ -70,17 +67,6 @@ func NewPreviewCommand(clients *shared.ClientFactory) *cobra.Command { return cmd } -// previewCommandPreRunE gates the command to AI coding tools, which are the -// intended callers -func previewCommandPreRunE() error { - if aiAgentFunc() == nil { - return slackerror.New(slackerror.ErrCommandUnavailable). - WithMessage("The blocks preview command is only available to AI coding tools"). - WithRemediation("Run this command through a supported AI coding tool") - } - return nil -} - // previewCommandRunE resolves blocks from the flag or standard input and opens // them in the Block Kit Builder for the selected team func previewCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, blocksFlag string, blocksFlagChanged bool) error { diff --git a/cmd/blocks/preview_test.go b/cmd/blocks/preview_test.go index 0e6a6a44..4ed42efb 100644 --- a/cmd/blocks/preview_test.go +++ b/cmd/blocks/preview_test.go @@ -50,11 +50,6 @@ func stubTeamAuth(auth *types.SlackAuth) func() { } func Test_Blocks_PreviewCommand(t *testing.T) { - // The command is gated to AI coding tools, so detect one for every case. - // The "errors when not run by an AI coding tool" case overrides this. - restoreAIAgent := stubAIAgent(&useragent.AIAgent{Name: "claude-code"}) - defer restoreAIAgent() - var restore func() testutil.TableTestCommand(t, testutil.CommandTests{ "opens the builder with blocks from the --blocks flag": { @@ -108,17 +103,6 @@ func Test_Blocks_PreviewCommand(t *testing.T) { }, Teardown: func() { restore() }, }, - "errors when not run by an AI coding tool": { - CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - restore = stubAIAgent(nil) - }, - ExpectedErrorStrings: []string{slackerror.ErrCommandUnavailable, "AI coding tools"}, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - cm.Browser.AssertNotCalled(t, "OpenURL", mock.Anything) - }, - Teardown: func() { restore() }, - }, "errors without hanging when no blocks are provided on a terminal": { Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cm.IO.On("IsStdinTTY").Return(true) diff --git a/docs/reference/errors.md b/docs/reference/errors.md index 473e4626..53f164a5 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -371,12 +371,6 @@ Move the .slack/cli.json file to .slack/hooks.json and try again. --- -### command_unavailable {#command_unavailable} - -**Message**: The command is not available in this environment - ---- - ### comment_required {#comment_required} **Message**: Your admin is requesting a reason to approve installation of this app diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index 43ccd6f0..0c85ff12 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -76,7 +76,6 @@ const ( ErrCannotRemoveOwners = "cannot_remove_owner" ErrCannotRevokeOrgBotToken = "cannot_revoke_org_bot_token" ErrChannelNotFound = "channel_not_found" - ErrCommandUnavailable = "command_unavailable" ErrCommentRequired = "comment_required" ErrConnectedOrgDenied = "connected_org_denied" ErrConnectedTeamDenied = "connected_team_denied" @@ -589,11 +588,6 @@ Otherwise start your app for local development with: %s`, Remediation: "Try adding your app as a member to the channel.", }, - ErrCommandUnavailable: { - Code: ErrCommandUnavailable, - Message: "The command is not available in this environment", - }, - ErrCommentRequired: { Code: ErrCommentRequired, Message: "Your admin is requesting a reason to approve installation of this app", From 85309a559e8e1e3e068edb3fdb807eae4ff1bb91 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 21 Jul 2026 15:57:24 -0400 Subject: [PATCH 13/17] feat: require explicit --blocks - for stdin in blocks preview Remove the auto-detected stdin pipe from blocks preview so blocks are read from standard input only via the explicit --blocks - sentinel, matching the prevailing convention of comparable tools (kubectl -f -, gh --input -). This drops the one place the command diverged from those tools. Fix the file-input example to use the sentinel form and correct the inaccurate "piped from a file" wording (< is redirection, not a pipe), and update the Long description and remediation text to match the new behavior. Co-Authored-By: Claude --- cmd/blocks/preview.go | 22 ++++++++++------------ cmd/blocks/preview_test.go | 22 +++------------------- 2 files changed, 13 insertions(+), 31 deletions(-) diff --git a/cmd/blocks/preview.go b/cmd/blocks/preview.go index ba00d040..2b0a76c3 100644 --- a/cmd/blocks/preview.go +++ b/cmd/blocks/preview.go @@ -44,18 +44,18 @@ func NewPreviewCommand(clients *shared.ClientFactory) *cobra.Command { 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 or pipe them through standard input.", + "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 explicitly from standard input.", + "Pass - to --blocks to read from standard input.", }, "\n"), Example: style.ExampleCommandsf([]style.ExampleCommand{ { - Meaning: "Preview blocks passed with a flag", + Meaning: "Preview blocks passed as a flag value", Command: "blocks preview --blocks '[{\"type\":\"divider\"}]'", }, { - Meaning: "Preview blocks piped from a file", - Command: "blocks preview < blocks.json", + Meaning: "Preview blocks read from a file", + Command: "blocks preview --blocks - < blocks.json", }, }), Args: cobra.NoArgs, @@ -119,7 +119,7 @@ func selectTeamAuth(ctx context.Context, clients *shared.ClientFactory, fromStdi if 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 piping blocks", style.Highlight("--team")) + 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) @@ -127,9 +127,9 @@ func selectTeamAuth(ctx context.Context, clients *shared.ClientFactory, fromStdi // resolveBlocksInput returns the blocks to preview and whether they were read // from standard input. Resolution order: an explicit --blocks value, the - -// sentinel or an auto-detected stdin pipe, otherwise a friendly error. Reading -// stdin is never attempted against an interactive terminal, so a bare command -// on a TTY errors instead of blocking on io.ReadAll. +// sentinel to read from standard input, otherwise a friendly error. Standard +// input is read only when requested explicitly with -, matching tools like +// kubectl (-f -) and gh (--input -). func resolveBlocksInput(clients *shared.ClientFactory, flagValue string, flagChanged bool) (string, bool, error) { switch { case flagChanged && flagValue == "-": @@ -140,8 +140,6 @@ func resolveBlocksInput(clients *shared.ClientFactory, flagValue string, flagCha return "", false, missingBlocksError() } return input, false, nil - case !clients.IO.IsStdinTTY(): - return readStdinBlocks(clients) default: return "", false, missingBlocksError() } @@ -164,7 +162,7 @@ func readStdinBlocks(clients *shared.ClientFactory) (string, bool, error) { func missingBlocksError() error { return slackerror.New(slackerror.ErrMissingInput). WithMessage("No blocks were provided"). - WithRemediation("Provide blocks with the %s flag or pipe them through standard input", style.Highlight("--blocks")) + WithRemediation("Provide blocks with the %s flag, or pass %s to read from standard input", style.Highlight("--blocks"), style.Highlight("--blocks -")) } // normalizeBlocksPayload parses the provided input and returns a compact JSON diff --git a/cmd/blocks/preview_test.go b/cmd/blocks/preview_test.go index 4ed42efb..6e0e0f68 100644 --- a/cmd/blocks/preview_test.go +++ b/cmd/blocks/preview_test.go @@ -78,19 +78,6 @@ func Test_Blocks_PreviewCommand(t *testing.T) { }, Teardown: func() { restore() }, }, - "opens the builder with auto-detected piped stdin": { - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("Host").Return("https://slack.com") - cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) - // default IsStdinTTY() is false (piped) - restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) - }, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - expectedURL := `https://app.slack.com/block-kit-builder/T123/builder#%7B%22blocks%22:%5B%7B%22type%22:%22divider%22%7D%5D%7D` - cm.Browser.AssertCalled(t, "OpenURL", expectedURL) - }, - Teardown: func() { restore() }, - }, "accepts a blocks object payload": { CmdArgs: []string{"--blocks", `{"blocks":[{"type":"divider"}]}`}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { @@ -103,10 +90,7 @@ func Test_Blocks_PreviewCommand(t *testing.T) { }, Teardown: func() { restore() }, }, - "errors without hanging when no blocks are provided on a terminal": { - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.IO.On("IsStdinTTY").Return(true) - }, + "errors when no blocks are provided": { ExpectedErrorStrings: []string{slackerror.ErrMissingInput, "No blocks were provided"}, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { cm.Browser.AssertNotCalled(t, "OpenURL", mock.Anything) @@ -124,7 +108,7 @@ func Test_Blocks_PreviewCommand(t *testing.T) { CmdArgs: []string{"--blocks", `{"foo":"bar"}`}, ExpectedErrorStrings: []string{slackerror.ErrInvalidBlocks}, }, - "errors when piping blocks with multiple teams and no --team flag": { + "errors when reading blocks from stdin with multiple teams and no --team flag": { CmdArgs: []string{"--blocks", "-"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) @@ -138,7 +122,7 @@ func Test_Blocks_PreviewCommand(t *testing.T) { cm.Browser.AssertNotCalled(t, "OpenURL", mock.Anything) }, }, - "opens the builder when piping blocks with the --team flag set": { + "opens the builder when reading blocks from stdin with the --team flag set": { CmdArgs: []string{"--blocks", "-", "--team", "T123"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cm.API.On("Host").Return("https://slack.com") From 989cfbce50c322b37dbe4e94cbee6d2df020764e Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 21 Jul 2026 17:02:07 -0400 Subject: [PATCH 14/17] chore: trim redundant comments in blocks preview Remove comments that restate self-explanatory function names and bodies, and trim the rest to the non-obvious rationale. Comments-only change. Co-Authored-By: Claude --- cmd/blocks/blocks.go | 3 +-- cmd/blocks/preview.go | 24 +----------------------- 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/cmd/blocks/blocks.go b/cmd/blocks/blocks.go index 3477e6c8..17414044 100644 --- a/cmd/blocks/blocks.go +++ b/cmd/blocks/blocks.go @@ -21,8 +21,7 @@ import ( "github.com/spf13/cobra" ) -// aiAgentFunc detects the AI coding tool invoking the CLI. It is a package -// variable so that it can be stubbed in tests. +// aiAgentFunc is a package variable so it can be stubbed in tests. var aiAgentFunc = useragent.GetAIAgent func NewCommand(clients *shared.ClientFactory) *cobra.Command { diff --git a/cmd/blocks/preview.go b/cmd/blocks/preview.go index 2b0a76c3..06bef681 100644 --- a/cmd/blocks/preview.go +++ b/cmd/blocks/preview.go @@ -32,8 +32,7 @@ import ( "github.com/spf13/cobra" ) -// promptTeamSlackAuthFunc selects the team whose Block Kit Builder should open. -// It is a package variable so that it can be stubbed in tests. +// promptTeamSlackAuthFunc is a package variable so it can be stubbed in tests. var promptTeamSlackAuthFunc = prompts.PromptTeamSlackAuth func NewPreviewCommand(clients *shared.ClientFactory) *cobra.Command { @@ -67,8 +66,6 @@ func NewPreviewCommand(clients *shared.ClientFactory) *cobra.Command { return cmd } -// previewCommandRunE resolves blocks from the flag or standard input and opens -// them in the Block Kit Builder for the selected team func previewCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, blocksFlag string, blocksFlagChanged bool) error { ctx := cmd.Context() clients.IO.PrintTrace(ctx, slacktrace.BlocksPreviewStart) @@ -106,10 +103,6 @@ func previewCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, block return nil } -// selectTeamAuth chooses the team whose Block Kit Builder to open. When blocks -// were read from stdin the interactive picker cannot prompt (stdin is spent), -// so with more than one authorization and no --team flag we return an -// actionable error instead of failing on EOF inside the prompt. func selectTeamAuth(ctx context.Context, clients *shared.ClientFactory, fromStdin bool) (*types.SlackAuth, error) { if fromStdin && clients.Config.TeamFlag == "" { auths, err := clients.Auth().Auths(ctx) @@ -125,11 +118,6 @@ func selectTeamAuth(ctx context.Context, clients *shared.ClientFactory, fromStdi return promptTeamSlackAuthFunc(ctx, clients, "Select a team to preview blocks for", nil) } -// resolveBlocksInput returns the blocks to preview and whether they were read -// from standard input. Resolution order: an explicit --blocks value, the - -// sentinel to read from standard input, otherwise a friendly error. Standard -// input is read only when requested explicitly with -, matching tools like -// kubectl (-f -) and gh (--input -). func resolveBlocksInput(clients *shared.ClientFactory, flagValue string, flagChanged bool) (string, bool, error) { switch { case flagChanged && flagValue == "-": @@ -145,7 +133,6 @@ func resolveBlocksInput(clients *shared.ClientFactory, flagValue string, flagCha } } -// readStdinBlocks reads and trims blocks from standard input func readStdinBlocks(clients *shared.ClientFactory) (string, bool, error) { piped, err := io.ReadAll(clients.IO.ReadIn()) if err != nil { @@ -158,16 +145,12 @@ func readStdinBlocks(clients *shared.ClientFactory) (string, bool, error) { return input, true, nil } -// missingBlocksError is the friendly error returned when no blocks are supplied 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 -")) } -// normalizeBlocksPayload parses the provided input and returns a compact JSON -// string in the shape expected by the Block Kit Builder: {"blocks":[...]}. -// The input may be a bare array of blocks or an object containing a "blocks" key. func normalizeBlocksPayload(input string) (string, error) { var parsed any if err := goutils.JSONUnmarshal([]byte(input), &parsed); err != nil { @@ -194,8 +177,6 @@ func normalizeBlocksPayload(input string) (string, error) { return string(compact), nil } -// teamOrEnterpriseID returns the enterprise ID for enterprise installs and the -// team ID otherwise, matching the identifier used in Block Kit Builder URLs func teamOrEnterpriseID(auth *types.SlackAuth) string { if auth.IsEnterpriseInstall { return auth.EnterpriseID @@ -203,9 +184,6 @@ func teamOrEnterpriseID(auth *types.SlackAuth) string { return auth.TeamID } -// buildBlockKitBuilderURL constructs the Block Kit Builder URL for the given -// API host, team or enterprise ID, and compact blocks JSON. The blocks JSON is -// placed in the URL fragment, which url.URL.String percent-encodes. func buildBlockKitBuilderURL(apiHost string, id string, blocksJSON string) (string, error) { parsed, err := url.Parse(apiHost) if err != nil { From 46fa7a3afd5147e290c9c8a52e8874fe89b5bffc Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 21 Jul 2026 17:50:35 -0400 Subject: [PATCH 15/17] fix: harden blocks preview stdin, auth, and URL key order - Guard `--blocks -` with IsStdinTTY() so an interactive terminal returns a clear error instead of blocking forever on stdin. - Return ErrCredentialsNotFound when no teams are logged in, instead of showing an empty team prompt. - Preserve the user's JSON key order in the Block Kit Builder URL by compacting the original input instead of re-marshalling the parsed value. Co-Authored-By: Claude --- cmd/blocks/preview.go | 47 ++++++++++++++++++++++++-------------- cmd/blocks/preview_test.go | 34 +++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/cmd/blocks/preview.go b/cmd/blocks/preview.go index 06bef681..4d67d69c 100644 --- a/cmd/blocks/preview.go +++ b/cmd/blocks/preview.go @@ -15,6 +15,7 @@ package blocks import ( + "bytes" "context" "encoding/json" "fmt" @@ -104,16 +105,17 @@ func previewCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, block } func selectTeamAuth(ctx context.Context, clients *shared.ClientFactory, fromStdin bool) (*types.SlackAuth, error) { - if fromStdin && clients.Config.TeamFlag == "" { - auths, err := clients.Auth().Auths(ctx) - if err != nil { - return nil, err - } - if 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")) - } + 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) } @@ -134,6 +136,11 @@ func resolveBlocksInput(clients *shared.ClientFactory, flagValue string, flagCha } 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) @@ -157,24 +164,30 @@ func normalizeBlocksPayload(input string) (string, error) { return "", err } - var payload map[string]any + compacted, err := compactJSON(input) + if err != nil { + return "", slackerror.New(slackerror.ErrInvalidBlocks) + } + switch value := parsed.(type) { case []any: - payload = map[string]any{"blocks": value} + return fmt.Sprintf(`{"blocks":%s}`, compacted), nil case map[string]any: if _, ok := value["blocks"].([]any); !ok { return "", slackerror.New(slackerror.ErrInvalidBlocks) } - payload = value + return compacted, nil default: return "", slackerror.New(slackerror.ErrInvalidBlocks) } +} - compact, err := json.Marshal(payload) - if err != nil { - 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 string(compact), nil + return buf.String(), nil } func teamOrEnterpriseID(auth *types.SlackAuth) string { diff --git a/cmd/blocks/preview_test.go b/cmd/blocks/preview_test.go index 6e0e0f68..0853655e 100644 --- a/cmd/blocks/preview_test.go +++ b/cmd/blocks/preview_test.go @@ -56,6 +56,7 @@ func Test_Blocks_PreviewCommand(t *testing.T) { CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cm.API.On("Host").Return("https://slack.com") + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{{TeamID: "T123"}}, nil) restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { @@ -69,6 +70,7 @@ func Test_Blocks_PreviewCommand(t *testing.T) { CmdArgs: []string{"--blocks", "-"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cm.API.On("Host").Return("https://slack.com") + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{{TeamID: "T123"}}, nil) cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) }, @@ -82,6 +84,7 @@ func Test_Blocks_PreviewCommand(t *testing.T) { CmdArgs: []string{"--blocks", `{"blocks":[{"type":"divider"}]}`}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cm.API.On("Host").Return("https://slack.com") + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{{TeamID: "T123"}}, nil) restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { @@ -100,6 +103,26 @@ func Test_Blocks_PreviewCommand(t *testing.T) { CmdArgs: []string{"--blocks", ""}, ExpectedErrorStrings: []string{slackerror.ErrMissingInput, "No blocks were provided"}, }, + "errors when reading from stdin on an interactive terminal": { + CmdArgs: []string{"--blocks", "-"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("IsStdinTTY").Return(true) + }, + ExpectedErrorStrings: []string{slackerror.ErrMissingInput, "standard input"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertNotCalled(t, "OpenURL", mock.Anything) + }, + }, + "errors when no teams are logged in": { + CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{}, nil) + }, + ExpectedErrorStrings: []string{slackerror.ErrCredentialsNotFound}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertNotCalled(t, "OpenURL", mock.Anything) + }, + }, "errors when the blocks are not valid json": { CmdArgs: []string{"--blocks", `not json`}, ExpectedErrorStrings: []string{slackerror.ErrUnableToParseJSON}, @@ -126,6 +149,7 @@ func Test_Blocks_PreviewCommand(t *testing.T) { CmdArgs: []string{"--blocks", "-", "--team", "T123"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cm.API.On("Host").Return("https://slack.com") + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{{TeamID: "T123"}}, nil) cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) }, @@ -140,6 +164,7 @@ func Test_Blocks_PreviewCommand(t *testing.T) { CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cm.API.On("Host").Return("https://slack.com") + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{{TeamID: "T123"}}, nil) restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123", EnterpriseID: "E456", IsEnterpriseInstall: true}) }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { @@ -153,6 +178,7 @@ func Test_Blocks_PreviewCommand(t *testing.T) { CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cm.API.On("Host").Return("https://slack.com") + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{{TeamID: "T123"}}, nil) restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123", EnterpriseID: "E456", IsEnterpriseInstall: false}) }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { @@ -232,6 +258,14 @@ func Test_normalizeBlocksPayload(t *testing.T) { input: "[\n {\n \"type\": \"divider\"\n }\n]", expected: `{"blocks":[{"type":"divider"}]}`, }, + "preserves key order when wrapping a bare array": { + input: `[{"type":"section","text":{"type":"mrkdwn","text":"hi"}}]`, + expected: `{"blocks":[{"type":"section","text":{"type":"mrkdwn","text":"hi"}}]}`, + }, + "preserves key order in a blocks object": { + input: `{"blocks":[{"type":"section","text":{"type":"mrkdwn","text":"hi"}}]}`, + expected: `{"blocks":[{"type":"section","text":{"type":"mrkdwn","text":"hi"}}]}`, + }, "rejects invalid json": { input: `not json`, expectedErr: slackerror.ErrUnableToParseJSON, From 252308dd9cce8988476a2a7e6851ea455dd40605 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 21 Jul 2026 18:05:44 -0400 Subject: [PATCH 16/17] docs: use redirection example in IsStdinTTY comment Co-Authored-By: Claude --- internal/iostreams/iostreams.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/iostreams/iostreams.go b/internal/iostreams/iostreams.go index ec861df5..abe4d1ac 100644 --- a/internal/iostreams/iostreams.go +++ b/internal/iostreams/iostreams.go @@ -135,8 +135,7 @@ func (io *IOStreams) IsTTY() bool { // IsStdinTTY returns true if stdin is an interactive terminal // -// Unlike IsTTY, which inspects stdout, this inspects stdin so that piped or -// redirected input (e.g. `cat blocks.json | slack ...`) is detected even when +// Inspects stdin so that piped or redirected input (e.g. `slack ... < blocks.json`) is detected when // stdout is still attached to a terminal. func (io *IOStreams) IsStdinTTY() bool { if o, err := io.os.Stdin().Stat(); o == nil || err != nil { From 2102c7be6bc98756ed6942662397f5427370e57b Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 21 Jul 2026 18:11:26 -0400 Subject: [PATCH 17/17] simplify error message Co-Authored-By: Claude --- docs/reference/errors.md | 2 +- internal/slackerror/errors.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/errors.md b/docs/reference/errors.md index 53f164a5..2e51f362 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -824,7 +824,7 @@ Or choose a specific app with `--app ` **Message**: The provided blocks are not valid Block Kit blocks -**Remediation**: Provide a JSON array of blocks or a JSON object with a "blocks" array. Design blocks with the Block Kit Builder or see the reference: https://docs.slack.dev/reference/block-kit/blocks +**Remediation**: Provide a JSON array of blocks or a JSON object with a "blocks" array. Reference: https://docs.slack.dev/reference/block-kit/blocks --- diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index 0c85ff12..fc82ee6e 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -941,7 +941,7 @@ Otherwise start your app for local development with: %s`, ErrInvalidBlocks: { Code: ErrInvalidBlocks, Message: "The provided blocks are not valid Block Kit blocks", - Remediation: "Provide a JSON array of blocks or a JSON object with a \"blocks\" array. Design blocks with the Block Kit Builder or see the reference: https://docs.slack.dev/reference/block-kit/blocks", + Remediation: "Provide a JSON array of blocks or a JSON object with a \"blocks\" array. Reference: https://docs.slack.dev/reference/block-kit/blocks", }, ErrInvalidChallenge: {