From 0b470bc3e1407e353db50d8406952ec6e4e41c96 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 25 Aug 2026 12:08:21 -0400 Subject: [PATCH 01/12] Support mise installation and managed upgrades --- README.md | 31 ++++- docs/omarchy.md | 2 +- internal/cmd/upgrade.go | 184 ++++++++++++++++++++++++-- internal/cmd/upgrade_test.go | 246 ++++++++++++++++++++++++++++++++++- 4 files changed, 438 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 9405251f..786e0805 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,27 @@ A CLI and TUI for [HEY](https://hey.com). ## Install +**Omarchy** + +```bash +omarchy-mise-install github:basecamp/hey-cli hey +``` + **macOS / Linux / WSL2** ```bash curl -fsSL https://hey.com/install-cli | bash ``` +Or with mise: + +```bash +MISE_MINIMUM_RELEASE_AGE=0 mise use -g github:basecamp/hey-cli +``` + +The override selects the current release immediately; mise otherwise waits for a new +release to be 24 hours old. + **Windows (PowerShell)** ```powershell @@ -60,12 +75,17 @@ Both scripts download the release for your platform, verify its SHA-256 checksum
Other installation methods +**mise:** +```bash +MISE_MINIMUM_RELEASE_AGE=0 mise use -g github:basecamp/hey-cli +``` + **Homebrew (macOS / Linux):** ```bash brew install --cask basecamp/tap/hey ``` -**Arch Linux / Omarchy (AUR):** +**Arch Linux (AUR):** ```bash yay -S hey-cli ``` @@ -138,12 +158,13 @@ hey upgrade 0.2.0-rc.1 # target a specific release, e.g. a prerelease ``` Upgrading only ever moves forward: a requested version at or below the installed one is a -no-op, and package-manager installs always follow their manager's own version (a pinned -version is refused there). +no-op. Mise and native installs can select a specific version; Homebrew and Scoop always +follow their manager's own version and refuse a pinned upgrade. What happens depends on how hey was installed: - **Installer script / tarball** (a binary under your home directory, e.g. `~/.local/bin` or `~/bin`): upgrades in place. hey downloads the release for your platform, verifies its Sigstore signature (the keyless `checksums.txt.bundle` published by the release pipeline, identity-pinned to the release workflow and tag) and SHA-256 checksum, swaps the executable transactionally, and confirms the installed binary reports the new version. On failure the previous binary is restored; in the worst case — restoration itself fails mid-swap — the error names the preserved backup file next to the binary so you can put it back by hand. +- **mise**: delegates to mise, updates the configuration that selected the running binary, then verifies normal mise resolution selects the new version. - **Homebrew / Scoop**: delegates to `brew upgrade --cask basecamp/tap/hey` / `scoop update hey`, then verifies the manager-installed binary actually reports the new version. - **System packages** (apt/dnf/apk, AUR, Nix) and **`go install` builds**: never touched. `hey upgrade` exits nonzero with upgrade guidance for that install method (the exact command where it can be known, e.g. `go install` or `yay -S hey-cli`; otherwise which package manager to use). @@ -663,8 +684,8 @@ read from `~/.local/state/omarchy/current/theme/`, and restyles live when you ru — an explicitly chosen file is trusted as written — or `NO_COLOR=1` to turn color off. ```bash -omarchy pkg aur add hey-cli # hey-cli is on the AUR -hey auth login # signing in with hey puts HEY in your bar (asked once) +omarchy-mise-install github:basecamp/hey-cli hey +hey auth login # signing in puts HEY in your bar (asked once) hey setup omarchy # the explicit path: installs in every output format, fails loudly hey setup omarchy --notify # toast new mail from the bar plugin (--no-notify turns it off) hey setup omarchy --remove # disable the bar plugin (checkout kept) and remove the desktop pieces diff --git a/docs/omarchy.md b/docs/omarchy.md index 9532c4fc..61114ba7 100644 --- a/docs/omarchy.md +++ b/docs/omarchy.md @@ -89,7 +89,7 @@ the current defaults, the layout goes too and the user is back to inheriting the [`basecamp/omarchy-hey-plugin`](https://github.com/basecamp/omarchy-hey-plugin) is the face: the HEY logo in the bar (tinted when there is unseen mail), the panel with its account switcher, `New for you` / `Previously seen` tabs, the Screener count, mark-as-seen, -and the setup flow that installs `hey-cli` from the AUR and signs you in. hey-cli is the +and the setup flow that installs `hey-cli` through mise and signs you in. hey-cli is the engine, and the plugin composes its generic commands: `hey box view imbox --json` for the Imbox, `hey watch` to know when to read it again, `hey screener list --count` for the Screener, `hey seen` for marking, `hey account list` and `hey auth status` for the rest. The diff --git a/internal/cmd/upgrade.go b/internal/cmd/upgrade.go index e91be3de..9a97a37b 100644 --- a/internal/cmd/upgrade.go +++ b/internal/cmd/upgrade.go @@ -2,6 +2,8 @@ package cmd import ( "context" + "encoding/json" + "errors" "fmt" "io" "os" @@ -20,6 +22,7 @@ import ( const ( homebrewCask = "basecamp/tap/hey" homebrewCaskroomPath = "/caskroom/hey/" + miseTool = "github:basecamp/hey-cli" scoopApp = "hey" scoopAppPath = "/scoop/apps/hey/" scoopShimPath = "/scoop/shims/" @@ -35,6 +38,11 @@ var ( scoopPrefixResolver = resolveScoopPrefix homebrewChecker = isHomebrew homebrewUpgrader = upgradeHomebrew + miseInstallResolver = resolveMiseInstall + miseBinaryResolver = resolveMiseBinary + miseActiveBinaryResolver = resolveMiseActiveBinary + miseConfigPathResolver = resolveMiseConfigPath + miseUpgrader = upgradeMise scoopChecker = isScoop scoopGlobalScopeChecker = isGlobalScoopInstall scoopUpgrader = upgradeScoop @@ -53,7 +61,7 @@ func newUpgradeCommand() *upgradeCommand { Installer-script and tarball installs under your home directory are replaced with the verified release binary (Sigstore signature and SHA-256 checksum). -Homebrew and Scoop installs are upgraded through their package manager. +Mise, Homebrew and Scoop installs are upgraded through their package manager. System packages, Nix and go install builds are never touched; the command exits nonzero with the right next step for that install method. @@ -65,7 +73,7 @@ Homebrew and Scoop installs always follow their package manager's version.`, hey upgrade --json hey upgrade 0.2.0-rc.1`, Annotations: map[string]string{ - "agent_notes": "Exits 0 only when already current or when the upgrade was applied and confirmed. An optional version argument pins the target release (self-updating installs only; never downgrades). Error codes: upgrade_required (an update exists but this install method must be upgraded another way — follow the hint), upgrade_incomplete, upgrade_unverified, upgrade_failed.", + "agent_notes": "Exits 0 only when already current or when the upgrade was applied and confirmed. An optional version argument pins the target release for native and mise installs; upgrades never downgrade. Error codes: upgrade_required (an update exists but this install method must be upgraded another way — follow the hint), upgrade_incomplete, upgrade_unverified, upgrade_failed.", }, Args: cobra.MaximumNArgs(1), RunE: upgradeCommand.run, @@ -183,7 +191,7 @@ func (c *upgradeCommand) run(cmd *cobra.Command, args []string) error { Hint: fmt.Sprintf("Run manually for detail: brew upgrade --cask %s", homebrewCask), } } - return confirmManagedUpgrade(ctx, w, "homebrew", filepath.Join(prefix, "bin", "hey"), current, latest, + return confirmManagedUpgrade(ctx, w, "homebrew", filepath.Join(prefix, "bin", "hey"), current, latest, true, fmt.Sprintf("brew reinstall --cask %s", homebrewCask)) } @@ -201,10 +209,33 @@ func (c *upgradeCommand) run(cmd *cobra.Command, args []string) error { Hint: fmt.Sprintf("Run manually for detail: scoop update%s %s", scoopGlobalFlag(global), scoopApp), } } - return confirmManagedUpgrade(ctx, w, "scoop", scoopBinaryPath(ctx, global), current, latest, + return confirmManagedUpgrade(ctx, w, "scoop", scoopBinaryPath(ctx, global), current, latest, true, fmt.Sprintf("scoop uninstall%s %s && scoop install%s %s", scoopGlobalFlag(global), scoopApp, scoopGlobalFlag(global), scoopApp)) } + if install, owned, resolveErr := miseInstallResolver(ctx, current); owned { + useCmd := miseUseCommand(install.configPath, latest) + if resolveErr != nil { + return &apierr.Error{ + Code: "upgrade_required", + Message: fmt.Sprintf("update available (%s → %s) for a mise-managed install, but mise ownership could not be resolved: %v", current, latest, resolveErr), + Hint: "Run manually: " + useCmd, + } + } + + fmt.Fprintln(w, "Upgrading via mise…") + if miseErr := miseUpgrader(ctx, install.command, install.configPath, latest, w, procErr); miseErr != nil { + return &apierr.Error{ + Code: "upgrade_failed", + Message: fmt.Sprintf("mise upgrade failed for %s: %v", miseTool, miseErr), + Hint: "Run manually for detail: " + useCmd, + } + } + + probePath, _ := miseActiveBinaryResolver(ctx, install.command) + return confirmManagedUpgrade(ctx, w, "mise", probePath, current, latest, requested == "", useCmd) + } + // A `go install` build (stable or pseudo version alike) has no release // asset lineage to swap in — the module toolchain owns it. if goInstallChecker() { @@ -270,9 +301,10 @@ func errPinnedManagedInstall(current, requested, manager, managerCmd string) *ap // confirmManagedUpgrade verifies a package-manager upgrade actually landed by // probing the manager-derived entrypoint. No success is reported without a -// confirmed version: a probe that can't run is upgrade_unverified, a probe -// that reports anything but the latest version is upgrade_incomplete. -func confirmManagedUpgrade(ctx context.Context, w io.Writer, method, probePath, current, latest, reinstallCmd string) error { +// confirmed version. Managers that follow a moving manifest may select a newer +// release published during the upgrade; an exact mise request confirms the +// requested version itself. +func confirmManagedUpgrade(ctx context.Context, w io.Writer, method, probePath, current, latest string, allowNewer bool, reinstallCmd string) error { unverified := func(detail string) error { return &apierr.Error{ Code: "upgrade_unverified", @@ -290,15 +322,17 @@ func confirmManagedUpgrade(ctx context.Context, w io.Writer, method, probePath, return unverified(fmt.Sprintf("probing %s failed: %v", probePath, err)) } - // Semantic comparison, accepting reported >= latest: a release published - // while the manager ran can legitimately install something newer than the - // snapshot fetched at the start. An unparseable probe result fails safely - // as unconfirmed rather than pretending to know either way. + // Semantic comparison accepts reported >= latest only for managers that + // follow a moving manifest: a release published while they run can + // legitimately install something newer than the snapshot fetched at the + // start. An exact mise request must resolve to that version. An unparseable + // probe result fails safely as unconfirmed rather than guessing. reportedSemver, latestSemver := normalizeSemver(reported), normalizeSemver(latest) if !semver.IsValid(reportedSemver) || !semver.IsValid(latestSemver) { return unverified(fmt.Sprintf("the installed version %q could not be interpreted (expected %s)", reported, latest)) } - if semver.Compare(reportedSemver, latestSemver) < 0 { + comparison := semver.Compare(reportedSemver, latestSemver) + if comparison < 0 || (!allowNewer && comparison != 0) { return &apierr.Error{ Code: "upgrade_incomplete", Message: fmt.Sprintf("%s exited successfully but hey still reports %s (expected %s, upgrading from %s)", method, reported, latest, current), @@ -406,6 +440,132 @@ func upgradeScoop(ctx context.Context, global bool, stdout io.Writer, stderr io. return upgrade.Run() } +// A miseInstall identifies the mise executable and configuration that own the +// running HEY binary. The same pair performs the upgrade, and normal mise +// resolution confirms the selected version afterwards. +type miseInstall struct { + command string + configPath string +} + +// resolveMiseInstall recognizes mise from its release-backend install layout +// before consulting any command on PATH. A recognized layout remains +// mise-owned when its manager cannot be resolved, protecting its files from +// native self-update. +func resolveMiseInstall(ctx context.Context, current string) (miseInstall, bool, error) { + running, ok := rawExecutablePathResolver() + if !ok || !isMiseInstallPath(running, current) { + return miseInstall{}, false, nil + } + + mise, err := exec.LookPath("mise") + if err != nil { + return miseInstall{}, true, err + } + + managed, err := miseBinaryResolver(ctx, mise, current) + if err != nil { + return miseInstall{}, true, err + } + if !sameFile(running, managed) { + return miseInstall{}, true, errors.New("mise resolves this HEY version to a different binary") + } + + configPath, err := miseConfigPathResolver(ctx, mise, current) + if err != nil { + return miseInstall{}, true, err + } + return miseInstall{command: mise, configPath: configPath}, true, nil +} + +func isMiseInstallPath(path, version string) bool { + binary := filepath.Base(path) + versionDir := filepath.Dir(path) + toolDir := filepath.Dir(versionDir) + installsDir := filepath.Dir(toolDir) + + return (strings.EqualFold(binary, "hey") || strings.EqualFold(binary, "hey.exe")) && + filepath.Base(versionDir) == version && + strings.EqualFold(filepath.Base(toolDir), "github-basecamp-hey-cli") && + strings.EqualFold(filepath.Base(installsDir), "installs") +} + +func sameFile(a, b string) bool { + aInfo, err := os.Stat(a) + if err != nil { + return false + } + bInfo, err := os.Stat(b) + return err == nil && os.SameFile(aInfo, bInfo) +} + +func resolveMiseConfigPath(ctx context.Context, mise, version string) (string, error) { + list := exec.CommandContext(ctx, mise, "ls", "--json", miseTool) //nolint:gosec // mise is independently matched to the running install layout + out, err := list.Output() + if err != nil { + return "", err + } + + return miseConfigPath(out, version) +} + +func miseConfigPath(data []byte, version string) (string, error) { + var tools []struct { + Version string `json:"version"` + Active bool `json:"active"` + Source struct { + Path string `json:"path"` + } `json:"source"` + } + if err := json.Unmarshal(data, &tools); err != nil { + return "", fmt.Errorf("could not read mise's tool configuration: %w", err) + } + for _, tool := range tools { + if tool.Active && tool.Version == version && filepath.IsAbs(tool.Source.Path) { + return tool.Source.Path, nil + } + } + return "", errors.New("mise did not report the active HEY configuration") +} + +func resolveMiseBinary(ctx context.Context, mise, version string) (string, error) { + tool := miseTool + "@" + version + resolve := exec.CommandContext(ctx, mise, "which", "hey", "--tool="+tool) //nolint:gosec // mise is independently matched to the running install layout + return miseBinaryPath(resolve) +} + +func resolveMiseActiveBinary(ctx context.Context, mise string) (string, error) { + resolve := exec.CommandContext(ctx, mise, "which", "hey") //nolint:gosec // mise is independently matched to the running install layout + return miseBinaryPath(resolve) +} + +func miseBinaryPath(resolve *exec.Cmd) (string, error) { + out, err := resolve.Output() + if err != nil { + return "", err + } + + path := strings.TrimSpace(string(out)) + if path == "" { + return "", errors.New("mise returned an empty HEY binary path") + } + return path, nil +} + +func upgradeMise(ctx context.Context, mise, configPath, version string, stdout io.Writer, stderr io.Writer) error { + upgrade := exec.CommandContext(ctx, mise, "use", "--path", configPath, miseTool+"@"+version) //nolint:gosec // mise is independently matched to the running install layout + upgrade.Stdout = stdout + upgrade.Stderr = stderr + return upgrade.Run() +} + +func miseUseCommand(configPath, version string) string { + if configPath == "" { + return "mise use -g " + miseTool + "@" + version + } + return fmt.Sprintf("mise use --path %s %s@%s", shellQuoted(configPath), miseTool, version) +} + // isHomebrew reports whether the running binary is the cask's own payload, // `/caskroom/hey//hey` — not merely something that happens to // live under a directory of that name. diff --git a/internal/cmd/upgrade_test.go b/internal/cmd/upgrade_test.go index 19b4f57c..1c33ad37 100644 --- a/internal/cmd/upgrade_test.go +++ b/internal/cmd/upgrade_test.go @@ -7,6 +7,7 @@ import ( "errors" "io" "path/filepath" + "runtime" "strings" "testing" @@ -100,13 +101,17 @@ func stubReleaseByTagFetcher(t *testing.T, fetch func(context.Context, string) ( } type upgradeCheckersStub struct { - latestVersion string - release *releaseInfo // optional richer release (assets); read at call time so tests can mutate - isBrew bool - isScoop bool - isGlobalScoop bool - homebrewUpgrade func(context.Context, string, io.Writer, io.Writer) error - scoopUpgrade func(context.Context, bool, io.Writer, io.Writer) error + latestVersion string + release *releaseInfo // optional richer release (assets); read at call time so tests can mutate + isBrew bool + isMise bool + isScoop bool + isGlobalScoop bool + homebrewUpgrade func(context.Context, string, io.Writer, io.Writer) error + miseResolveErr error + miseUpgrade func(context.Context, string, string, string, io.Writer, io.Writer) error + miseActiveBinary func(context.Context, string) (string, error) + scoopUpgrade func(context.Context, bool, io.Writer, io.Writer) error } // stubUpgradeCheckers overrides the release lookup and package manager @@ -142,6 +147,31 @@ func stubUpgradeCheckers(t *testing.T, stub upgradeCheckersStub) { stubExecutablePathResolver(t, "c:/programdata/scoop/apps/hey/current/hey.exe", true) } + origMR := miseInstallResolver + miseInstallResolver = func(context.Context, string) (miseInstall, bool, error) { + if stub.miseResolveErr != nil { + return miseInstall{}, stub.isMise, stub.miseResolveErr + } + return miseInstall{command: "/usr/bin/mise", configPath: "/home/alice/.config/mise/config.toml"}, stub.isMise, nil + } + t.Cleanup(func() { miseInstallResolver = origMR }) + + origMU := miseUpgrader + miseUpgrader = stub.miseUpgrade + if miseUpgrader == nil { + miseUpgrader = func(context.Context, string, string, string, io.Writer, io.Writer) error { return nil } + } + t.Cleanup(func() { miseUpgrader = origMU }) + + origMAB := miseActiveBinaryResolver + miseActiveBinaryResolver = stub.miseActiveBinary + if miseActiveBinaryResolver == nil { + miseActiveBinaryResolver = func(context.Context, string) (string, error) { + return "/home/alice/.local/share/mise/installs/github-basecamp-hey-cli/1.3.0/hey", nil + } + } + t.Cleanup(func() { miseActiveBinaryResolver = origMAB }) + origSC := scoopChecker scoopChecker = func(context.Context) bool { return stub.isScoop } t.Cleanup(func() { scoopChecker = origSC }) @@ -808,8 +838,210 @@ func TestUpgradeScoopExecFailureIsStructured(t *testing.T) { assertContains(t, apiErr.Hint, "scoop update -g hey") } +// --- mise --- + +func TestUpgradeMiseConfirmedSuccess(t *testing.T) { + stubVersion(t, "1.2.3") + var gotMise, gotConfig, gotVersion string + stubUpgradeCheckers(t, upgradeCheckersStub{ + latestVersion: "1.3.0", + isMise: true, + miseUpgrade: func(_ context.Context, mise, config, version string, _ io.Writer, _ io.Writer) error { + gotMise, gotConfig, gotVersion = mise, config, version + return nil + }, + miseActiveBinary: func(_ context.Context, mise string) (string, error) { + if mise != "/usr/bin/mise" { + t.Errorf("resolved with mise=%q", mise) + } + return "/home/alice/.local/share/mise/installs/github-basecamp-hey-cli/1.3.0/hey", nil + }, + }) + stubBinaryVersionProber(t, func(_ context.Context, path string) (string, error) { + if path != "/home/alice/.local/share/mise/installs/github-basecamp-hey-cli/1.3.0/hey" { + t.Errorf("probed %q, want mise's selected HEY binary", path) + } + return "1.3.0", nil + }) + + run := executeUpgradeCommand(t) + mustNoError(t, run.err) + if gotMise != "/usr/bin/mise" || gotConfig != "/home/alice/.config/mise/config.toml" || gotVersion != "1.3.0" { + t.Errorf("upgraded with mise=%q config=%q version=%q", gotMise, gotConfig, gotVersion) + } + data := run.data(t) + if data["method"] != "mise" || data["to"] != "1.3.0" { + t.Errorf("unexpected envelope data: %v", data) + } +} + +// Mise can select an exact release, so a pinned hey upgrade remains pinned +// when it delegates to the manager. +func TestUpgradeMiseSupportsPinnedVersion(t *testing.T) { + stubVersion(t, "1.2.3") + stubReleaseByTagFetcher(t, func(context.Context, string) (releaseInfo, error) { + return releaseInfo{Version: "1.4.0-rc.1"}, nil + }) + var gotVersion string + stubUpgradeCheckers(t, upgradeCheckersStub{ + isMise: true, + miseUpgrade: func(_ context.Context, _, _ string, version string, _ io.Writer, _ io.Writer) error { + gotVersion = version + return nil + }, + }) + stubBinaryVersionProber(t, func(context.Context, string) (string, error) { return "1.4.0-rc.1", nil }) + + run := executeUpgradeCommandAs(t, "--json", "1.4.0-rc.1") + mustNoError(t, run.err) + if gotVersion != "1.4.0-rc.1" { + t.Errorf("mise version = %q, want the requested prerelease", gotVersion) + } +} + +func TestUpgradeMisePinnedVersionRequiresExactResolution(t *testing.T) { + stubVersion(t, "1.2.3") + stubReleaseByTagFetcher(t, func(context.Context, string) (releaseInfo, error) { + return releaseInfo{Version: "1.4.0-rc.1"}, nil + }) + stubUpgradeCheckers(t, upgradeCheckersStub{isMise: true}) + stubBinaryVersionProber(t, func(context.Context, string) (string, error) { return "1.4.0", nil }) + + run := executeUpgradeCommandAs(t, "--json", "1.4.0-rc.1") + apiErr := requireUpgradeError(t, run.err, "upgrade_incomplete") + assertContains(t, apiErr.Message, "hey still reports 1.4.0 (expected 1.4.0-rc.1") +} + +func TestUpgradeMiseExecFailureIsStructured(t *testing.T) { + stubVersion(t, "1.2.3") + stubUpgradeCheckers(t, upgradeCheckersStub{ + latestVersion: "1.3.0", + isMise: true, + miseUpgrade: func(context.Context, string, string, string, io.Writer, io.Writer) error { + return errors.New("exit status 1") + }, + }) + + run := executeUpgradeCommand(t) + apiErr := requireUpgradeError(t, run.err, "upgrade_failed") + assertContains(t, apiErr.Message, "mise upgrade failed") + assertContains(t, apiErr.Hint, `mise use --path '/home/alice/.config/mise/config.toml' github:basecamp/hey-cli@1.3.0`) +} + +func TestUpgradeMiseUnresolvableResultIsUnverified(t *testing.T) { + stubVersion(t, "1.2.3") + stubUpgradeCheckers(t, upgradeCheckersStub{ + latestVersion: "1.3.0", + isMise: true, + miseActiveBinary: func(context.Context, string) (string, error) { + return "", errors.New("not installed") + }, + }) + + run := executeUpgradeCommand(t) + apiErr := requireUpgradeError(t, run.err, "upgrade_unverified") + assertContains(t, apiErr.Hint, `mise use --path '/home/alice/.config/mise/config.toml' github:basecamp/hey-cli@1.3.0`) +} + +func TestMiseUseCommandShellQuotesConfigPath(t *testing.T) { + configPath := "/home/alice/$HOME/it's `mine`.toml" + got := miseUseCommand(configPath, "1.3.0") + want := "mise use --path '/home/alice/$HOME/it'\\''s `mine`.toml' github:basecamp/hey-cli@1.3.0" + if got != want { + t.Errorf("mise command = %q, want %q", got, want) + } +} + +func TestUpgradeMiseResolutionFailureDoesNotSelfUpdate(t *testing.T) { + stubVersion(t, "1.2.3") + stubUpgradeCheckers(t, upgradeCheckersStub{ + latestVersion: "1.3.0", + isMise: true, + miseResolveErr: errors.New("mise is unavailable"), + }) + stubSelfUpdateTarget(t, "", errors.New("native self-update must not be reached")) + + run := executeUpgradeCommand(t) + apiErr := requireUpgradeError(t, run.err, "upgrade_required") + assertContains(t, apiErr.Message, "mise-managed install") + assertContains(t, apiErr.Hint, "mise use -g github:basecamp/hey-cli@1.3.0") +} + // --- install-source detection --- +func TestResolveMiseInstallRequiresMiseOwnedBinary(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the fake mise executable is a Unix shell script") + } + + dir := t.TempDir() + misePath := filepath.Join(dir, "mise") + managed := filepath.Join(dir, "data", "installs", "github-basecamp-hey-cli", "1.2.3", "hey") + other := filepath.Join(dir, "bin", "hey") + configPath := filepath.Join(dir, "config", "mise", "config.toml") + mustMkdirAll(t, filepath.Dir(managed)) + mustMkdirAll(t, filepath.Dir(other)) + mustMkdirAll(t, filepath.Dir(configPath)) + mustWriteFile(t, misePath, []byte("#!/bin/sh\nexit 0\n"), 0o755) + mustWriteFile(t, managed, []byte("managed"), 0o755) + mustWriteFile(t, other, []byte("other"), 0o755) + mustWriteFile(t, configPath, []byte("[tools]\n"), 0o644) + t.Setenv("PATH", dir) + + binaryResolutions := 0 + origMB := miseBinaryResolver + miseBinaryResolver = func(_ context.Context, mise, version string) (string, error) { + binaryResolutions++ + if mise != misePath || version != "1.2.3" { + t.Errorf("resolved with mise=%q version=%q", mise, version) + } + return managed, nil + } + t.Cleanup(func() { miseBinaryResolver = origMB }) + + origMC := miseConfigPathResolver + miseConfigPathResolver = func(_ context.Context, mise, version string) (string, error) { + if mise != misePath || version != "1.2.3" { + t.Errorf("found config with mise=%q version=%q", mise, version) + } + return configPath, nil + } + t.Cleanup(func() { miseConfigPathResolver = origMC }) + + running := managed + origRaw := rawExecutablePathResolver + rawExecutablePathResolver = func() (string, bool) { return running, true } + t.Cleanup(func() { rawExecutablePathResolver = origRaw }) + + install, owned, err := resolveMiseInstall(context.Background(), "1.2.3") + mustNoError(t, err) + if !owned || install.command != misePath || install.configPath != configPath { + t.Fatalf("resolveMiseInstall = (%q, %q, %v), want the owning mise executable and config", install.command, install.configPath, owned) + } + + binaryResolutions = 0 + running = other + if _, owned, err := resolveMiseInstall(context.Background(), "1.2.3"); err != nil || owned { + t.Errorf("native path resolved as mise: owned=%v err=%v", owned, err) + } + if binaryResolutions != 0 { + t.Error("a PATH command must not establish mise ownership for a native install") + } +} + +func TestMiseConfigPathUsesActiveVersionSource(t *testing.T) { + data := []byte(`[ + {"version":"1.2.3","active":false,"source":{"path":"/home/alice/old.toml"}}, + {"version":"1.2.3","active":true,"source":{"path":"/home/alice/project/mise.toml"}} + ]`) + + got, err := miseConfigPath(data, "1.2.3") + mustNoError(t, err) + if got != "/home/alice/project/mise.toml" { + t.Errorf("miseConfigPath = %q, want the active configuration", got) + } +} + func TestIsHomebrewAnchorsOnCaskPayload(t *testing.T) { tests := []struct { path string From 9ec3edecce0c51552aecf4d0e39e5f3e7a475507 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 25 Aug 2026 13:02:36 -0400 Subject: [PATCH 02/12] Keep installer agent setup output concise --- scripts/install.ps1 | 7 ++++++- scripts/install.sh | 9 ++++++++- tests/e2e/installer.bats | 19 ++++++++++++++++--- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 858306dc..701094ec 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -322,7 +322,12 @@ function Invoke-PostInstallSetup([string]$Binary) { try { $help = & $Binary setup --help 2>$null } catch { $help = '' } if ($help -match '(?m)^\s+agents\s') { - try { & $Binary setup agents } catch { } + try { + $summary = & $Binary setup agents --jq '.summary' 2>$null + if ($summary) { + Info "$summary" + } + } catch { } } } finally { if ($null -eq $savedNoKeyring) { diff --git a/scripts/install.sh b/scripts/install.sh index 5aa3210b..fa7de829 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -500,6 +500,8 @@ binary_supports_setup_agents() { # post_install_setup connects coding agents without prompting, but only when # the installed binary has the ownership-aware `setup agents` (it honors # HEY_SETUP_AGENT itself: claude|codex|all|none, unset = auto-detect one). +# The jq selector keeps the machine-readable command's envelope out of the +# human-facing installer while preserving its concise outcome. # # Releases that predate `setup agents` also predate the ownership gate on # `skill install` — their legacy implementation overwrites whatever occupies @@ -514,9 +516,14 @@ binary_supports_setup_agents() { post_install_setup() { local binary_name="$1" local bin="$BIN_DIR/$binary_name" + local summary if binary_supports_setup_agents "$bin"; then - HEY_NO_KEYRING=1 "$bin" setup agents || true + if summary=$(HEY_NO_KEYRING=1 "$bin" setup agents --jq '.summary' 2>/dev/null); then + if [[ -n "$summary" ]]; then + info "$summary" + fi + fi fi } diff --git a/tests/e2e/installer.bats b/tests/e2e/installer.bats index d2055669..1e33656a 100644 --- a/tests/e2e/installer.bats +++ b/tests/e2e/installer.bats @@ -62,6 +62,9 @@ write_stub() { # `setup `: a real old parent would swallow it as a stray arg and # launch the interactive wizard -- the exact bug the installer must avoid. echo 'if [[ "$1" == "setup" && "$2" != "claude" && "$2" != "--help" ]]; then echo "unknown command \"$2\"" >&2; exit 1; fi' + else + echo 'if [[ "$1 $2 $3 $4" == "setup agents --jq .summary" ]]; then echo "Installed baseline skill"; exit 0; fi' + echo 'if [[ "$1 $2" == "setup agents" ]]; then echo '\''{"ok":true,"data":{"skill_installed":true}}'\''; exit 0; fi' fi echo 'exit 0' } > "$STUB_DIR/hey" @@ -104,10 +107,12 @@ run_post_install_setup() { # `setup claude`; old binaries (no `setup agents`) must fall back without # guessing an agent. -@test "new binary: post_install_setup dispatches to 'setup agents', never 'setup claude'" { +@test "new binary: post_install_setup prints the summary without the JSON envelope" { run_post_install_setup [[ "$status" -eq 0 ]] - [[ "$output" == *"setup agents"* ]] + [[ "$output" == *"Installed baseline skill"* ]] + [[ "$output" == *"setup agents --jq .summary"* ]] + [[ "$output" != *'"ok":true'* ]] [[ "$output" != *"setup claude"* ]] } @@ -231,6 +236,10 @@ $nk = if ($null -eq $env:HEY_NO_KEYRING) { 'unset' } else { $env:HEY_NO_KEYRING Add-Content -LiteralPath $env:PS_LOG "nk=$nk $rest" if ($rest -eq 'setup --help') { ' agents Install the HEY skill and connect detected coding agents' +} elseif ($rest -eq 'setup agents --jq .summary') { + 'Installed baseline skill' +} elseif ($rest -eq 'setup agents') { + '{"ok":true,"data":{"skill_installed":true}}' } EOF @@ -243,6 +252,8 @@ $fn = $ast.Find({ param($n) $n -is [System.Management.Automation.Language.Functi if (-not $fn) { throw 'Invoke-PostInstallSetup not found in install.ps1' } # Evaluate only the function definition -- the installer's Main never runs. . ([scriptblock]::Create($fn.Extent.Text)) +# Supply the installer's output helper used by Invoke-PostInstallSetup. +function Info([string]$Message) { $Message } Invoke-PostInstallSetup $env:PS_STUB if ($null -ne $env:HEY_NO_KEYRING) { throw "HEY_NO_KEYRING not restored: '$env:HEY_NO_KEYRING'" } 'restored-ok' @@ -263,7 +274,9 @@ EOF [[ "$status" -eq 0 ]] [[ "$output" == *"restored-ok"* ]] [[ "$output" == *"restored-value-ok"* ]] - [[ "$output" == *"nk=1 setup agents"* ]] + [[ "$output" == *"Installed baseline skill"* ]] + [[ "$output" == *"nk=1 setup agents --jq .summary"* ]] + [[ "$output" != *'"ok":true'* ]] } # The release builds darwin_amd64 and darwin_arm64, never a universal From 7ac91b3d24f91cecb41304a27955660cf66de651 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 25 Aug 2026 14:30:48 -0400 Subject: [PATCH 03/12] Make setup complete, concise, and configurable --- .surface | 4 + AGENTS.md | 31 +- README.md | 42 +-- docs/omarchy.md | 5 +- internal/cmd/auth.go | 44 +-- internal/cmd/auth_commands_test.go | 38 --- internal/cmd/formatting.go | 13 +- internal/cmd/help_test.go | 2 +- internal/cmd/omarchy.go | 4 +- internal/cmd/omarchy_plugin.go | 2 +- internal/cmd/omarchy_plugin_test.go | 109 ++++++- internal/cmd/setup.go | 464 +++++++++++++++++----------- internal/cmd/setup_agent.go | 26 +- internal/cmd/setup_agents.go | 15 +- internal/cmd/setup_agents_remove.go | 116 +++++++ internal/cmd/setup_agents_test.go | 117 +++++++ internal/cmd/setup_test.go | 371 ++++++++++++++++++---- 17 files changed, 1013 insertions(+), 390 deletions(-) create mode 100644 internal/cmd/setup_agents_remove.go diff --git a/.surface b/.surface index 8e8585c2..bf681bab 100644 --- a/.surface +++ b/.surface @@ -289,7 +289,11 @@ hey search --to hey search filters hey seen hey setup +hey setup --silent-success +hey setup --skip-agents +hey setup --skip-omarchy hey setup agents +hey setup agents --remove hey setup claude hey setup codex hey setup omarchy diff --git a/AGENTS.md b/AGENTS.md index 05524313..4717608e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,27 +53,30 @@ login`, `hey auth logout`, `hey auth status`), the `hey login`/`hey logout` shor Bare `hey` at an interactive terminal runs the setup wizard when logged out (`runSetupWizard` in `internal/cmd/setup.go` — welcome, OAuth sign-in, linked-account -greeting, coding-agent setup, summary) and stops at the summary; in every other case it +greeting, coding-agent setup, summary) and stops at the summary; `hey setup --skip-agents` +and `--skip-omarchy` leave those integrations unchanged. In every other case bare `hey` prints help. The TUI lives at `hey tui` (plus the hidden `hey hey`). `config.json`'s `onboarded` flag only trims a later logged-out run to the sign-in step. `HEY_NONINTERACTIVE=1` -disables every prompt regardless of TTY detection: the wizard skips OAuth and answers its -own confirmations with their defaults. - -On Omarchy, an interactive OAuth sign-in — `hey auth login`/`hey login`, the wizard, or -`requireAuth`'s prompt — offers to install the `37signals.hey` bar plugin -(`internal/cmd/omarchy_plugin.go`): asked once, remembered in -`StateDir()/omarchy/bar-plugin.json`, serialized by a flock next to it. The hook never -runs for machine output, `HEY_NONINTERACTIVE`, a non-TTY, or `--token`/`--cookie` logins, -and it never re-enables a plugin that is off the bar — only explicit `hey setup omarchy` -does, which installs in every output format and fails loudly (`setup_failed`) on any -incomplete outcome; `--remove` writes its tombstone first, disables, and keeps the -checkout. Details and the state model are in docs/omarchy.md. +disables interactive sign-in regardless of TTY detection; detected agent setup continues +without prompting. + +On Omarchy, the full `hey setup` wizard installs and enables the `37signals.hey` bar plugin +without prompting. Other interactive OAuth sign-ins — `hey auth login`/`hey login`, the +lite wizard, or `requireAuth`'s prompt — offer it once and remember the answer in +`StateDir()/omarchy/bar-plugin.json`, serialized by a flock next to it. The sign-in hook +never runs for machine output, `HEY_NONINTERACTIVE`, a non-TTY, or `--token`/`--cookie` +logins, and it never re-enables a plugin that is off the bar. The full wizard and explicit +`hey setup omarchy` do re-enable it; the explicit command installs in every output format +and fails loudly (`setup_failed`) on any incomplete outcome. `--remove` writes its +tombstone first, disables, and keeps the checkout. Details and the state model are in +docs/omarchy.md. Coding-agent integration lives in `internal/harness` (agent registry, Claude Code / Codex detection, plugin and skill health checks) and `internal/cmd/setup_agent*.go` (`hey setup claude|codex|agents`). Claude Code gets the `hey@37signals` plugin from `basecamp/claude-plugins` plus a skill link; Codex gets the skill only until a `.codex-plugin` ships. `HEY_SETUP_AGENT` -selects the target for `hey setup agents`. `hey doctor` reports per-agent diagnostics, and a +selects the target for `hey setup agents`; `hey setup agents --remove` uninstalls the +Claude plugin and removes only hey-cli-managed skill files. `hey doctor` reports per-agent diagnostics, and a `PersistentPostRunE` hook (`skill_refresh.go`) re-syncs installed skill copies once per release version change. Ownership is explicit: every skill write goes through `claimSkillDir`, which marks a directory it creates (`.managed-by-hey-cli`) and refuses a populated one without the diff --git a/README.md b/README.md index 786e0805..c1ce8fc1 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,12 @@ hey ``` The first time you run `hey` at a terminal it walks you through setup: it signs you in -(browser-based OAuth), shows the mail accounts linked to your HEY identity, and offers to -connect your coding agents (Claude Code, Codex). After that, `hey tui` opens the app and +(browser-based OAuth), shows the mail accounts linked to your HEY identity, and connects +your detected coding agents (Claude Code, Codex). Run `hey setup --skip-agents` to leave +agent integrations unchanged, or add `--skip-omarchy` to leave Omarchy unchanged. +`hey setup --silent-success` keeps required authentication visible, shows an installation +spinner, and ends with `SETUP COMPLETE`; failure guidance remains visible. After that, +`hey tui` opens the app and bare `hey` prints the help. `hey setup` reruns the wizard any time; `hey login` and `hey logout` are shortcuts for `hey auth login` and `hey auth logout`. @@ -685,8 +689,8 @@ read from `~/.local/state/omarchy/current/theme/`, and restyles live when you ru ```bash omarchy-mise-install github:basecamp/hey-cli hey -hey auth login # signing in puts HEY in your bar (asked once) -hey setup omarchy # the explicit path: installs in every output format, fails loudly +hey setup # sign in, connect detected agents, and put HEY in your bar +hey setup omarchy # the explicit desktop-only path: installs in every output format, fails loudly hey setup omarchy --notify # toast new mail from the bar plugin (--no-notify turns it off) hey setup omarchy --remove # disable the bar plugin (checkout kept) and remove the desktop pieces ``` @@ -697,13 +701,14 @@ its engine — the plugin reads the Imbox with `hey box view imbox` and runs `he bar is live: a thread you archive in the TUI, on your phone or in the web app leaves the panel within a second, and after a disconnect the watch catches up from where it left off. -Signing in interactively — `hey auth login`, the setup wizard, or any command's sign-in -prompt — offers to install that plugin, asks once, and remembers your answer. The offer -never fires in scripts: machine output (`--json` and friends), `HEY_NONINTERACTIVE`, a -non-TTY, and `--token`/`--cookie` logins all skip it, and `hey setup omarchy` is how a -script installs it — explicitly, in every output format, verified against the running -shell, with a loud failure when it cannot. A plugin you disable stays disabled until you -run `hey setup omarchy` again; `--remove` disables it and keeps the checkout. +The full `hey setup` wizard installs and enables the plugin automatically. Other interactive +sign-ins — `hey auth login` or any command's sign-in prompt — offer it once and remember +the answer. Those sign-in hooks never fire in scripts: machine output (`--json` and friends), +`HEY_NONINTERACTIVE`, a non-TTY, and `--token`/`--cookie` logins all skip it. +`hey setup omarchy` is the desktop-only path for scripts: it works in every output format, +verifies the running shell, and fails loudly when installation is incomplete. A plugin you +disable stays disabled until `hey setup` or `hey setup omarchy` enables it again; `--remove` +disables it and keeps the checkout. Setup installs a `HEY TUI` launcher entry, a `HEY` row in the SUPER+SPACE menu, and a `hey.toml.tpl` theme template so theme authors can tune the overlay. It prints the @@ -722,22 +727,23 @@ other app. See [docs/omarchy.md](docs/omarchy.md) for the details and what is pl hey-cli ships with an embedded agent skill so your coding agent can work with HEY on your behalf, and a Claude Code plugin (`hey@37signals` from the `basecamp/claude-plugins` -marketplace). The setup wizard offers to connect detected agents; these commands do it on -their own: +marketplace). The setup wizard connects every detected agent automatically; these commands +manage the integrations on their own: ```bash hey setup claude # install the skill and the hey@37signals plugin for Claude Code hey setup codex # install the skill for Codex hey skill install # install the skill only (~/.agents/skills/hey, linked for detected agents) -hey setup agents # non-interactive: skill + a single detected agent (the installer uses this) -hey doctor # check skill and plugin health per detected agent +hey setup agents # non-interactive: skill + a single detected agent (the installer uses this) +hey setup agents --remove # remove HEY's managed skills and Claude Code plugin +hey doctor # check skill and plugin health per detected agent ``` `hey setup agents` never prompts and never guesses: with several agents detected it installs the skill only and lists the `hey setup ` choices. `HEY_SETUP_AGENT=claude|codex|all|none` -picks explicitly. `HEY_NONINTERACTIVE=1` disables every prompt (the sign-in offer, the -wizard's confirmations) for harnesses that run hey under a pseudo-terminal. The installed -skill is refreshed automatically the first time a new hey release runs. +picks explicitly. `HEY_NONINTERACTIVE=1` disables interactive sign-in for harnesses that +run hey under a pseudo-terminal. The installed skill is refreshed automatically the first +time a new hey release runs. hey only ever writes skill directories it owns: each one it creates carries a `.managed-by-hey-cli` marker, and install, replacement and automatic refresh all refuse a diff --git a/docs/omarchy.md b/docs/omarchy.md index 61114ba7..53906631 100644 --- a/docs/omarchy.md +++ b/docs/omarchy.md @@ -69,7 +69,7 @@ step does not stop the others. |---|---|---| | Desktop entry | `~/.local/share/applications/HEY TUI.desktop` | Distinct from Omarchy's shipped `HEY.desktop` web app. Launches under app-id `org.omarchy.hey` | | Menu row | marker block in `~/.config/omarchy/extensions/omarchy-menu.jsonc` | one root `HEY` row that focuses or launches the TUI; its guard is a PATH lookup, never network or `hey` itself. Becomes a submenu once there is more than one thing to open | -| Bar plugin | clone under `~/.config/omarchy/plugins/37signals.hey`, entry in `~/.config/omarchy/shell.json`'s bar layout | installed and enabled by **signing in with hey** (asked once — see below) or by this command, which also finishes an interrupted install and re-enables a plugin you disabled, verified against the running shell. `--notify` / `--no-notify` set or delete the entry's `notify` key, which the shell hot-reloads and the plugin reads to decide whether to toast. `--remove` disables the plugin and keeps its checkout. An earlier inline `hey-unread` module is removed on sight, its notify choice carried over | +| Bar plugin | clone under `~/.config/omarchy/plugins/37signals.hey`, entry in `~/.config/omarchy/shell.json`'s bar layout | installed and enabled automatically by the full **`hey setup`** wizard; other interactive sign-ins ask once (see below). This command also finishes an interrupted install and re-enables a plugin you disabled, verified against the running shell. `--notify` / `--no-notify` set or delete the entry's `notify` key, which the shell hot-reloads and the plugin reads to decide whether to toast. `--remove` disables the plugin and keeps its checkout. An earlier inline `hey-unread` module is removed on sight, its notify choice carried over | | Theme template | `~/.config/omarchy/themed/hey.toml.tpl` | renders `hey.toml` into every theme so theme authors can override the overlay; triggers `omarchy-theme-refresh` | | Keybinding | printed, never written | `o.bind("SUPER + SHIFT + ALT + H", "HEY TUI", "omarchy-launch-or-focus-tui --app-id=org.omarchy.hey hey tui")`; SUPER+SHIFT+E keeps opening the web app unless you `hl.unbind` it. Spelled out rather than `{ tui = "hey tui" }` because the lua helper quotes that into one word and the app-id derived from it would never match | @@ -136,7 +136,8 @@ file, one lock: - **Entry points.** An interactive OAuth sign-in — `hey auth login` / `hey login`, `requireAuth`'s "Sign in now?" (how `hey tui` and every data command sign in), the lite wizard — runs the routine in *ensure* mode and prints at most one stderr line; the full - wizard runs it as Step 3; `hey setup omarchy` runs it in *force* mode. The automatic + wizard runs Step 3 in *force* mode without asking; `hey setup omarchy` also runs in + *force* mode. The automatic hooks never run for machine output, `HEY_NONINTERACTIVE`, a non-TTY, or `--token`/`--cookie` logins: a script installs with `hey setup omarchy`, which works in every output format and exits `setup_failed` on any incomplete outcome — an incomplete diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index 182d1157..139de065 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -3,9 +3,7 @@ package cmd import ( "context" "fmt" - "io" "os" - "sort" "strings" "time" @@ -13,7 +11,6 @@ import ( "github.com/basecamp/hey-cli/internal/apierr" "github.com/basecamp/hey-cli/internal/auth" - "github.com/basecamp/hey-cli/internal/harness" "github.com/basecamp/hey-cli/internal/output" ) @@ -97,7 +94,8 @@ Use --token or --cookie for non-interactive login.`, if identity, err := rootSDK.Identity().GetIdentity(cmd.Context()); err == nil && identity != nil { fmt.Fprintln(w, identityGreeting(identity)) } - printAgentNudge(w) + // Agent integration is configured through `hey setup`; sign-in reports + // the authenticated identity and completes its Omarchy integration. ensureOmarchyBarPluginAfterLogin(cmd.ErrOrStderr()) return nil } @@ -313,41 +311,3 @@ func refuseSessionCookieAsToken() error { } return nil } - -// printAgentNudge prints a hint about coding agent setup after login. -// -// Detection proves presence, not intent: with a single detected-unhealthy -// agent it points at that agent; with several, it never guesses — it prints -// every `hey setup ` choice so the user picks. It never suggests -// `hey setup agents`, which is the installer's non-interactive path. -func printAgentNudge(w io.Writer) { - type nudgeAgent struct{ id, name string } - var unhealthy []nudgeAgent - for _, agent := range harness.DetectedAgents() { - if agent.Checks == nil { - continue - } - for _, c := range agent.Checks() { - if c.Status != "pass" { - unhealthy = append(unhealthy, nudgeAgent{id: agent.ID, name: agent.Name}) - break - } - } - } - sort.Slice(unhealthy, func(i, j int) bool { return unhealthy[i].id < unhealthy[j].id }) - - switch len(unhealthy) { - case 0: - return - case 1: - fmt.Fprintln(w) - fmt.Fprintln(w, muted.format(fmt.Sprintf(" %s detected. Connect it to HEY:", unhealthy[0].name))) - fmt.Fprintln(w, bold.format(" hey setup "+unhealthy[0].id)) - default: - fmt.Fprintln(w) - fmt.Fprintln(w, muted.format(" Multiple coding agents detected. Choose one:")) - for _, a := range unhealthy { - fmt.Fprintln(w, bold.format(" hey setup "+a.id)) - } - } -} diff --git a/internal/cmd/auth_commands_test.go b/internal/cmd/auth_commands_test.go index f0441fd8..52835bd0 100644 --- a/internal/cmd/auth_commands_test.go +++ b/internal/cmd/auth_commands_test.go @@ -328,41 +328,3 @@ func TestLoginLogoutShortcutsMirrorAuthCommands(t *testing.T) { t.Errorf("hey login example = %q", login2.Example) } } - -func TestPrintAgentNudge(t *testing.T) { - isolateAgents(t) - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("USERPROFILE", home) - origColor := colorDisabled - colorDisabled = true - t.Cleanup(func() { colorDisabled = origColor }) - - var out bytes.Buffer - printAgentNudge(&out) - if out.Len() != 0 { - t.Errorf("no agents: nudge should be silent, got %q", out.String()) - } - - if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { - t.Fatal(err) - } - out.Reset() - printAgentNudge(&out) - if !strings.Contains(out.String(), "Claude Code detected. Connect it to HEY:") || !strings.Contains(out.String(), "hey setup claude") { - t.Errorf("single agent nudge = %q", out.String()) - } - if strings.Contains(out.String(), "setup agents") { - t.Errorf("nudge must never suggest setup agents: %q", out.String()) - } - - if err := os.MkdirAll(filepath.Join(home, ".codex"), 0o755); err != nil { - t.Fatal(err) - } - out.Reset() - printAgentNudge(&out) - text := out.String() - if !strings.Contains(text, "Multiple coding agents detected. Choose one:") || !strings.Contains(text, "hey setup claude") || !strings.Contains(text, "hey setup codex") { - t.Errorf("multiple agent nudge = %q", text) - } -} diff --git a/internal/cmd/formatting.go b/internal/cmd/formatting.go index d7fc97c7..e5ef2bdb 100644 --- a/internal/cmd/formatting.go +++ b/internal/cmd/formatting.go @@ -82,12 +82,13 @@ func (t *table) updateColumnWidths(row []string) { type style string const ( - plain style = "" - bold style = "1;34" - italic style = "3;94" - success style = "32" - warning style = "33" - muted style = "90" + plain style = "" + bold style = "1;34" + italic style = "3;94" + italicMuted style = "3;90" + success style = "32" + warning style = "33" + muted style = "90" ) func (s style) format(value string) string { diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index 2dc020ea..62415e90 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -16,7 +16,7 @@ func TestCuratedCommandHelpUsesUserFacingLanguage(t *testing.T) { root := newRootCmd() tests := map[string]string{ "auth": "Sign in to HEY, sign out, or check your login status.", - "setup": "Sign in and connect your coding agents. On Omarchy, a styled interactive run also\ninstalls hey into the desktop and offers the HEY bar plugin (asked once); machine\nand non-interactive runs never touch the desktop — hey setup omarchy does that\nexplicitly.", + "setup": "Sign in, connect detected coding agents, and install the Omarchy integration when\napplicable. Use --skip-agents or --skip-omarchy to leave either integration unchanged.\n--silent-success keeps authentication visible, shows an installation spinner, and ends\nwith SETUP COMPLETE. Machine and non-interactive runs never touch the desktop —\nhey setup omarchy does that explicitly. Set HEY_SETUP_VERBOSE=1 to show the detailed setup checklist, agent\ninstallation progress, Omarchy status, and keybinding hint.", } for name, expected := range tests { diff --git a/internal/cmd/omarchy.go b/internal/cmd/omarchy.go index 423e98a0..18bde92d 100644 --- a/internal/cmd/omarchy.go +++ b/internal/cmd/omarchy.go @@ -145,8 +145,8 @@ type omarchyStep struct { type omarchySetup struct { env omarchyEnv notify *bool // nil keeps the bar plugin's notify setting as it is - // forcePlugin is `hey setup omarchy`: install the bar plugin in every - // output format, finish a pending install, re-enable a disabled one, and + // forcePlugin is the full setup wizard and `hey setup omarchy`: install + // the bar plugin, finish a pending install, re-enable a disabled one, and // carry a failure for every incomplete outcome. forcePlugin bool } diff --git a/internal/cmd/omarchy_plugin.go b/internal/cmd/omarchy_plugin.go index ca9ff816..949a753a 100644 --- a/internal/cmd/omarchy_plugin.go +++ b/internal/cmd/omarchy_plugin.go @@ -90,7 +90,7 @@ var ensureOmarchyBarPluginAfterLogin = func(w io.Writer) { // confirmOmarchyPanel is the one question the sign-in hook may ask, once ever. // A seam so tests can answer it. var confirmOmarchyPanel = func() (bool, error) { - return tui.Confirm("Put HEY in your Omarchy bar?", true) + return tui.Confirm(" Put HEY in your Omarchy bar?", true) } // installBarPlugin puts the 37signals.hey plugin in the bar, in ensure or diff --git a/internal/cmd/omarchy_plugin_test.go b/internal/cmd/omarchy_plugin_test.go index 07251d15..a30421ea 100644 --- a/internal/cmd/omarchy_plugin_test.go +++ b/internal/cmd/omarchy_plugin_test.go @@ -1220,7 +1220,7 @@ func TestRequireAuthRunsTheOmarchyHookAfterSignIn(t *testing.T) { func TestLiteWizardRunsTheOmarchyHookOnceAndFullDoesNot(t *testing.T) { isolateAgents(t) stubInteractive(t, true) - stubStdinTerminal(t, true) + stubStdinTerminal(t) logins := stubLoginInteractively(t, nil) hookCalls := stubOmarchyAfterLogin(t) server := quietServer(t) @@ -1260,7 +1260,9 @@ func TestSetupWizardStep3InstallsThePlugin(t *testing.T) { t.Cleanup(func() { colorDisabled = origColor }) enabled := false + omarchyCalls := 0 stubOmarchyRun(t, func(name string, args ...string) (string, error) { + omarchyCalls++ command := strings.Join(append([]string{name}, args...), " ") switch { case name != "omarchy": @@ -1288,13 +1290,106 @@ func TestSetupWizardStep3InstallsThePlugin(t *testing.T) { t.Fatalf("setup: %v\n%s", err, stdout.String()) } text := stdout.String() - for _, want := range []string{"Step 3: Omarchy desktop", "HEY is in your Omarchy bar", "✓ Omarchy desktop", "Setup complete!"} { + for _, want := range []string{"Step 1: Omarchy desktop", "HEY is in your Omarchy bar", "Step 2: Try it out!", "hey hey", "Open TUI"} { if !strings.Contains(text, want) { t.Errorf("missing %q:\n%s", want, text) } } - if *confirms != 1 { - t.Errorf("consent asked %d times, want 1", *confirms) + if *confirms != 0 { + t.Errorf("setup asked for Omarchy consent %d times", *confirms) + } + if strings.Contains(text, "Put HEY in your Omarchy bar?") { + t.Errorf("setup asked an Omarchy question:\n%s", text) + } + for _, hidden := range []string{"✓ Omarchy desktop", "Bar plugin:", "Desktop:", "Add a keybinding yourself"} { + if strings.Contains(text, hidden) { + t.Errorf("the concise wizard printed %q:\n%s", hidden, text) + } + } + + t.Setenv(setupVerboseEnv, "1") + root = newRootCmd() + stdout.Reset() + root.SetOut(&stdout) + root.SetErr(&stdout) + root.SetArgs([]string{"--base-url", server.URL, "setup", "--styled"}) + if err := root.Execute(); err != nil { + t.Fatalf("verbose setup: %v\n%s", err, stdout.String()) + } + for _, want := range []string{"✓ Omarchy desktop", "Bar plugin:", "Desktop:", "Add a keybinding yourself"} { + if !strings.Contains(stdout.String(), want) { + t.Errorf("verbose setup missing %q:\n%s", want, stdout.String()) + } + } + + // A later concise run keeps the detected integration visible and reports + // that it is already connected. + t.Setenv(setupVerboseEnv, "") + root = newRootCmd() + stdout.Reset() + root.SetOut(&stdout) + root.SetErr(&stdout) + root.SetArgs([]string{"--base-url", server.URL, "setup", "--styled"}) + if err := root.Execute(); err != nil { + t.Fatalf("repeat setup: %v\n%s", err, stdout.String()) + } + for _, want := range []string{"Step 1: Omarchy desktop", "✓ Omarchy desktop connected", "Step 2: Try it out!"} { + if !strings.Contains(stdout.String(), want) { + t.Errorf("repeat setup missing %q:\n%s", want, stdout.String()) + } + } + + beforeSilent := omarchyCalls + root = newRootCmd() + stdout.Reset() + root.SetOut(&stdout) + root.SetErr(&stdout) + root.SetArgs([]string{"--base-url", server.URL, "setup", "--styled", "--silent-success"}) + if err := root.Execute(); err != nil { + t.Fatalf("silent setup: %v\n%s", err, stdout.String()) + } + if !strings.Contains(stdout.String(), "Installing HEY…") || !strings.HasSuffix(stdout.String(), "\r\x1b[2KSETUP COMPLETE\n") { + t.Errorf("silent setup output = %q", stdout.String()) + } + if omarchyCalls == beforeSilent { + t.Error("silent setup did not verify the Omarchy integration") + } +} + +func TestSetupWizardSkipOmarchyLeavesIntegrationUnchanged(t *testing.T) { + isolateAgents(t) + stubInteractive(t, true) + server := identityServer(t) + configHome := t.TempDir() + if _, _, err := runAuthCommand(t, configHome, server.URL, "", true, "auth", "login", "--cookie", "session-cookie"); err != nil { + t.Fatalf("auth login: %v", err) + } + ran := stubOmarchyRun(t, omarchyUnavailable) + t.Setenv("HEY_TOKEN", "") + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + t.Setenv("HOME", configHome) + t.Setenv("XDG_CONFIG_HOME", configHome) + t.Setenv("XDG_STATE_HOME", configHome) + t.Setenv("XDG_CACHE_HOME", configHome) + t.Setenv("OMARCHY_PATH", t.TempDir()) + origColor := colorDisabled + colorDisabled = true + t.Cleanup(func() { colorDisabled = origColor }) + + root := newRootCmd() + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stdout) + root.SetArgs([]string{"--base-url", server.URL, "setup", "--styled", "--skip-omarchy"}) + if err := root.Execute(); err != nil { + t.Fatalf("setup --skip-omarchy: %v\n%s", err, stdout.String()) + } + if len(*ran) != 0 { + t.Errorf("Omarchy commands ran: %v", *ran) + } + if strings.Contains(stdout.String(), "Omarchy setup skipped") || strings.Contains(stdout.String(), "Omarchy desktop") || strings.Contains(stdout.String(), "Step 1:") || !strings.Contains(stdout.String(), "Try it out!") { + t.Errorf("unexpected setup output:\n%s", stdout.String()) } } @@ -1384,11 +1479,11 @@ func TestSetupWizardStep3CloneFailureIsIncomplete(t *testing.T) { t.Fatalf("setup: %v\n%s", err, stdout.String()) } text := stdout.String() - if !strings.Contains(text, "needs attention") || !strings.Contains(text, "hey setup omarchy") { + if !strings.Contains(text, "need attention") || !strings.Contains(text, "hey setup omarchy") { t.Errorf("a clone failure must leave the wizard incomplete with the remediation:\n%s", text) } - if !strings.Contains(text, "✗ Omarchy desktop") { - t.Errorf("the checklist must not contradict the issue list:\n%s", text) + if strings.Contains(text, "✗ Omarchy desktop") { + t.Errorf("the concise summary printed the checklist:\n%s", text) } } diff --git a/internal/cmd/setup.go b/internal/cmd/setup.go index f84b3a07..8804bf45 100644 --- a/internal/cmd/setup.go +++ b/internal/cmd/setup.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" "github.com/spf13/cobra" @@ -25,7 +26,10 @@ import ( ) type setupCommand struct { - cmd *cobra.Command + cmd *cobra.Command + skipAgents bool + skipOmarchy bool + silentSuccess bool } func newSetupCommand() *setupCommand { @@ -33,13 +37,15 @@ func newSetupCommand() *setupCommand { setupCommand.cmd = &cobra.Command{ Use: "setup", Short: "Set up HEY for first use", - Long: `Sign in and connect your coding agents. On Omarchy, a styled interactive run also -installs hey into the desktop and offers the HEY bar plugin (asked once); machine -and non-interactive runs never touch the desktop — hey setup omarchy does that -explicitly.`, + Long: `Sign in, connect detected coding agents, and install the Omarchy integration when +applicable. Use --skip-agents or --skip-omarchy to leave either integration unchanged. +--silent-success keeps authentication visible, shows an installation spinner, and ends +with SETUP COMPLETE. Machine and non-interactive runs never touch the desktop — +hey setup omarchy does that explicitly. Set HEY_SETUP_VERBOSE=1 to show the detailed setup checklist, agent +installation progress, Omarchy status, and keybinding hint.`, Args: cobra.NoArgs, Annotations: map[string]string{ - "agent_notes": "Runs the first-run wizard: OAuth sign-in, a look at the linked accounts, and coding-agent setup. HEY_NONINTERACTIVE=1 suppresses OAuth and every prompt, but the wizard still connects detected agents (writing skill files) and persists the onboarded flag — there is no read-only mode; use `hey doctor` to inspect without changing anything. --json changes only the output format: a terminal on stdin (an allocated PTY included) still starts browser OAuth and waits for it. Logged out and non-interactive reports status incomplete with a remediation breadcrumb.", + "agent_notes": "Runs the first-run wizard: OAuth sign-in, a look at the linked accounts, coding-agent setup, and the Omarchy integration when detected. --skip-agents and --skip-omarchy leave those integrations unchanged. --silent-success keeps required authentication and failure output, shows an installation spinner, and ends with SETUP COMPLETE. HEY_NONINTERACTIVE=1 suppresses interactive sign-in, but unskipped agent setup still writes skill files and the wizard persists the onboarded flag — there is no read-only mode; use `hey doctor` to inspect without changing anything. --json changes only the output format: a terminal on stdin (an allocated PTY included) still starts browser OAuth and waits for it. Logged out and non-interactive reports status incomplete with a remediation breadcrumb.", }, RunE: setupCommand.run, } @@ -49,6 +55,9 @@ explicitly.`, } setupCommand.cmd.AddCommand(newSetupAgentsCommand()) setupCommand.cmd.AddCommand(newSetupOmarchyCommand().cmd) + setupCommand.cmd.Flags().BoolVar(&setupCommand.skipAgents, "skip-agents", false, "Leave coding-agent integrations unchanged") + setupCommand.cmd.Flags().BoolVar(&setupCommand.skipOmarchy, "skip-omarchy", false, "Leave the Omarchy integration unchanged") + setupCommand.cmd.Flags().BoolVar(&setupCommand.silentSuccess, "silent-success", false, "Show setup activity and end a successful run with SETUP COMPLETE") return setupCommand } @@ -57,7 +66,15 @@ func (c *setupCommand) run(cmd *cobra.Command, _ []string) error { if err := rejectListOnlyFormats("the setup wizard"); err != nil { return err } - return runSetupWizard(cmd, wizardOptions{full: true}) + if c.silentSuccess && (!writer.IsStyled() || !interactiveStdio()) { + return apierr.ErrUsage("--silent-success requires an interactive terminal with styled output") + } + return runSetupWizard(cmd, wizardOptions{ + full: true, + skipAgents: c.skipAgents, + skipOmarchy: c.skipOmarchy, + silentSuccess: c.silentSuccess, + }) } // rejectListOnlyFormats fails fast on --ids-only and --count: setup results @@ -75,10 +92,18 @@ func rejectListOnlyFormats(command string) error { } } -// wizardOptions tunes the first-run wizard. full runs every step; a lite run -// (a later logged-out bare `hey` once onboarded) only signs in. +const setupVerboseEnv = "HEY_SETUP_VERBOSE" + +var setupSpinnerInterval = 80 * time.Millisecond + +// wizardOptions tunes the first-run wizard. A full run performs every +// unskipped step; a lite run (a later logged-out bare `hey` once onboarded) +// only signs in. type wizardOptions struct { - full bool + full bool + skipAgents bool + skipOmarchy bool + silentSuccess bool } // wizardIdentity is the signed-in HEY identity reported in the envelope. @@ -95,6 +120,10 @@ type wizardResult struct { Identity *wizardIdentity `json:"identity,omitempty"` Accounts []accountListItem `json:"accounts,omitempty"` SkillInstalled bool `json:"skill_installed"` + // AgentsSkipped reports that --skip-agents left coding-agent integrations unchanged. + AgentsSkipped bool `json:"agents_skipped,omitempty"` + // OmarchySkipped reports that --skip-omarchy left the desktop integration unchanged. + OmarchySkipped bool `json:"omarchy_skipped,omitempty"` // CompletionsInstalled reports the shell completion step, which asks // nothing and fails quietly — a shell hey cannot write to is not a reason // for setup to be incomplete. @@ -115,31 +144,30 @@ type omarchyOutcome struct { // setupWizard carries one wizard run: the command it prints through and what // it has learned so far. type setupWizard struct { - cmd *cobra.Command - opts wizardOptions - styled bool - result wizardResult - outcome agentSetupOutcome -} - -// confirmAgentSetup is the wizard's one prompt, a seam so tests can answer it. -var confirmAgentSetup = func() (bool, error) { - return tui.Confirm(" Set up HEY for your coding agents?", true) + cmd *cobra.Command + opts wizardOptions + styled bool + verbose bool + nextStep int + result wizardResult + outcome agentSetupOutcome } // runSetupWizard is the entry point shared by `hey setup` and bare `hey`. func runSetupWizard(cmd *cobra.Command, opts wizardOptions) error { wizard := &setupWizard{ - cmd: cmd, - opts: opts, - styled: writer.IsStyled(), - result: wizardResult{Version: version.Version, Status: "complete"}, + cmd: cmd, + opts: opts, + styled: writer.IsStyled(), + verbose: os.Getenv(setupVerboseEnv) == "1", + nextStep: 1, + result: wizardResult{Version: version.Version, Status: "complete"}, } return wizard.run() } func (s *setupWizard) run() error { - if s.styled { + if s.narrates() { s.welcome(s.cmd.OutOrStdout()) } @@ -154,9 +182,19 @@ func (s *setupWizard) run() error { s.result.Issues = append(s.result.Issues, agentIssue{Check: "Not logged in", Hint: "Run: hey auth login"}) } + stopSilentSpinner := func() {} + if s.opts.silentSuccess { + stopSilentSpinner = startSetupSpinner(s.cmd.OutOrStdout(), "Installing HEY…", true) + } + defer stopSilentSpinner() + if s.opts.full { s.result.CompletionsInstalled = s.installCompletions() - s.outcome = s.setupAgents() + if s.opts.skipAgents { + s.result.AgentsSkipped = true + } else { + s.outcome = s.setupAgents() + } } else if signedIn { // A lite wizard that just signed in gets the same one-line Omarchy // hook as `hey auth login`; the full wizard runs the real step below. @@ -169,9 +207,14 @@ func (s *setupWizard) run() error { s.result.Status = "incomplete" } if s.opts.full { - s.setupOmarchy(signedIn) + if s.opts.skipOmarchy { + s.result.OmarchySkipped = true + } else { + s.setupOmarchy(signedIn) + } } + stopSilentSpinner() s.persistOnboarded() return s.summary() } @@ -187,6 +230,16 @@ func (s *setupWizard) welcome(w io.Writer) { fmt.Fprintln(w) } +// printStep gives each displayed setup stage the next consecutive number. +func (s *setupWizard) printStep(w io.Writer, label string) { + fmt.Fprintln(w, bold.format(fmt.Sprintf("Step %d: %s", s.nextStep, label))) + s.nextStep++ +} + +func (s *setupWizard) narrates() bool { + return s.styled && !s.opts.silentSuccess +} + // signIn makes sure we are authenticated. Reports whether we are. Sign-in // runs only when somebody can see it through: stdin is a terminal and // HEY_NONINTERACTIVE is not engaged. Otherwise — a piped `hey setup --json`, @@ -200,9 +253,9 @@ func (s *setupWizard) signIn() (bool, error) { return false, nil } - if s.styled { + if s.narrates() { w := s.cmd.OutOrStdout() - fmt.Fprintln(w, bold.format(" Step 1: Sign in")) + s.printStep(w, "Sign in") fmt.Fprintln(w) if err := loginInteractively(w); err != nil { return false, err @@ -237,7 +290,7 @@ var loginInteractively = func(out io.Writer) error { if line == "" { fmt.Fprintln(out) } else { - fmt.Fprintln(out, " "+line) + fmt.Fprintln(out, line) } } } @@ -267,16 +320,16 @@ func (s *setupWizard) greet() { if os.Getenv("HEY_TOKEN") != "" { issue = agentIssue{Check: "HEY_TOKEN rejected", Hint: "Update or unset HEY_TOKEN"} } - if s.styled { - fmt.Fprintln(w, warning.format(" "+issue.Check+" by HEY — "+issue.Hint)) + if s.narrates() { + fmt.Fprintln(w, warning.format(issue.Check+" by HEY — "+issue.Hint)) fmt.Fprintln(w) } s.result.Status = "incomplete" s.result.Issues = append(s.result.Issues, issue) return } - if s.styled { - fmt.Fprintln(w, success.format(" Signed in.")) + if s.narrates() { + fmt.Fprintln(w, success.format("Signed in.")) fmt.Fprintln(w) } return @@ -285,10 +338,10 @@ func (s *setupWizard) greet() { s.result.Identity = &wizardIdentity{Name: identity.Name, Email: identity.PrimaryContact.EmailAddress} s.result.Accounts = linkedAccountList(identity, cfg.AccountID) - if !s.styled { + if !s.narrates() { return } - fmt.Fprintln(w, success.format(" "+identityGreeting(identity))) + fmt.Fprintln(w, success.format(identityGreeting(identity))) // accounts[0] is the "All Accounts" filter; a single linked account // needs no list. if len(s.result.Accounts) > 2 { @@ -297,12 +350,12 @@ func (s *setupWizard) greet() { if account.Email != "" { label += " (" + terminal.SanitizeLine(account.Email) + ")" } - fmt.Fprintln(w, muted.format(" • "+label)) + fmt.Fprintln(w, muted.format("• "+label)) } if cfg.AccountID == config.AllAccounts { - fmt.Fprintln(w, muted.format(" Using All Accounts — hey account use to default to one")) + fmt.Fprintln(w, muted.format("Using All Accounts — hey account use to default to one")) } else { - fmt.Fprintln(w, muted.format(" Default mail account: "+cfg.AccountID)) + fmt.Fprintln(w, muted.format("Default mail account: "+cfg.AccountID)) } } fmt.Fprintln(w) @@ -360,19 +413,19 @@ func (s *setupWizard) installCompletions() bool { return false } - if s.styled { + if s.narrates() { w := s.cmd.OutOrStdout() fmt.Fprintln(w, statusLine(true, "Shell completions installed for "+shell)) if target.Hint != "" { - fmt.Fprintln(w, muted.format(" "+target.Hint)) + fmt.Fprintln(w, muted.format(target.Hint)) } fmt.Fprintln(w) } return true } -// setupAgents offers to connect detected coding agents. In a styled run the -// user confirms first; a machine run just does it — there is nobody to ask. +// setupAgents connects every detected coding agent. Styled runs narrate the +// work while machine runs report the same outcome in their envelope. func (s *setupWizard) setupAgents() agentSetupOutcome { agents := harness.DetectedAgents() if len(agents) == 0 { @@ -380,12 +433,16 @@ func (s *setupWizard) setupAgents() agentSetupOutcome { } w := s.cmd.OutOrStdout() + if s.narrates() { + s.printStep(w, "Coding agents") + fmt.Fprintln(w) + } // One pre-setup snapshot drives both the all-good gate and the checklist // rendered in the summary for the paths that do not run setup. preChecks := snapshotAgentChecks(agents) if baselineSkillInstalled() && len(issuesFromChecks(preChecks)) == 0 { - if s.styled { + if s.narrates() { for _, a := range agents { fmt.Fprintln(w, statusLine(true, a.Name+" connected")) } @@ -394,62 +451,40 @@ func (s *setupWizard) setupAgents() agentSetupOutcome { return agentSetupOutcome{Checks: preChecks} } - if s.styled { - fmt.Fprintln(w, bold.format(" Step 2: Coding agents")) - fmt.Fprintln(w) - + if s.narrates() { var names []string for _, a := range agents { names = append(names, a.Name) } - fmt.Fprintf(w, " Detected: %s\n", joinNames(names)) + fmt.Fprintf(w, "Detected: %s\n", joinNames(names)) fmt.Fprintln(w) - fmt.Fprintln(w, " This will:") - step := 1 - fmt.Fprintln(w, muted.format(fmt.Sprintf(" %d. Install the HEY agent skill to ~/.agents/skills/hey/", step))) - step++ - for _, a := range agents { - handler, ok := agentSetupHandlers[a.ID] - if !ok { - continue - } - for _, label := range handler.Labels { - fmt.Fprintln(w, muted.format(fmt.Sprintf(" %d. %s", step, label))) - step++ - } - } - fmt.Fprintln(w) - - // The prompt runs only when it can be answered: styled output alone - // does not prove a human (HEY_NONINTERACTIVE on a PTY, --styled while - // piped). Without one, proceed with the prompt's default answer — - // exactly what the machine-mode wizard does. - if interactiveStdio() { - install, confirmErr := confirmAgentSetup() - if confirmErr != nil || !install { - fmt.Fprintln(w, muted.format(" You can set up agents later:")) - for _, a := range agents { - if _, ok := agentSetupHandlers[a.ID]; ok { - fmt.Fprintln(w, bold.format(" hey setup "+a.ID)) - } + if s.verbose { + fmt.Fprintln(w, "This will:") + step := 1 + fmt.Fprintln(w, muted.format(fmt.Sprintf("%d. Install the HEY agent skill to ~/.agents/skills/hey/", step))) + step++ + for _, a := range agents { + handler, ok := agentSetupHandlers[a.ID] + if !ok { + continue + } + for _, label := range handler.Labels { + fmt.Fprintln(w, muted.format(fmt.Sprintf("%d. %s", step, label))) + step++ } - fmt.Fprintln(w) - // Skipped carries the snapshot for the checklist but records no - // issues, so a deliberate skip stays "complete". - return agentSetupOutcome{Skipped: true, Checks: preChecks} } + fmt.Fprintln(w) } - fmt.Fprintln(w) } + stopSpinner := startSetupSpinner(w, "Installing agent skill…", s.narrates() && !s.verbose && interactiveStdio()) + _, skillErr := installSkillFiles() var issues []agentIssue - if _, err := installSkillFiles(); err != nil { - if s.styled { - fmt.Fprintln(w, warning.format(fmt.Sprintf(" Skill install failed: %s", err))) - } + if skillErr != nil { issues = append(issues, agentIssue{Check: "Agent skill", Hint: "Run: hey skill install"}) - } else if s.styled { - fmt.Fprintln(w, statusLine(true, "Agent skill installed")) + } + if s.narrates() && s.verbose { + printSkillInstallResult(w, skillErr) } for _, a := range agents { @@ -458,7 +493,7 @@ func (s *setupWizard) setupAgents() agentSetupOutcome { continue } var handlerErr error - if s.styled { + if s.narrates() && s.verbose { if handler.Run != nil { handlerErr = handler.Run(s.cmd) // interactive handlers warn and continue } @@ -471,6 +506,10 @@ func (s *setupWizard) setupAgents() agentSetupOutcome { issues = append(issues, agentIssue{Agent: a.Name, Check: a.Name + " setup failed", Hint: handlerErr.Error()}) } } + stopSpinner() + if s.narrates() && !s.verbose { + printSkillInstallResult(w, skillErr) + } // Re-snapshot after setup ran so failed installs surface as issues rather // than a silent "complete". The same snapshot renders the checklist, so @@ -478,17 +517,61 @@ func (s *setupWizard) setupAgents() agentSetupOutcome { postChecks := snapshotAgentChecks(agents) issues = append(issues, issuesFromChecks(postChecks)...) - if s.styled { + if s.narrates() { fmt.Fprintln(w) } return agentSetupOutcome{Checks: postChecks, Issues: issues} } -// setupOmarchy is Step 3: on Omarchy, a full, styled, interactive, signed-in -// wizard installs the desktop pieces and offers the bar plugin in ensure -// mode — consent asked once and remembered, and a plugin the user disabled -// stays disabled. Machine and non-interactive runs skip it entirely; the -// envelope points at hey setup omarchy instead (wizardBreadcrumbs). +func printSkillInstallResult(w io.Writer, err error) { + if err != nil { + fmt.Fprintln(w, warning.format(fmt.Sprintf("Skill install failed: %s", err))) + return + } + fmt.Fprintln(w, statusLine(true, "Agent skill installed")) +} + +// startSetupSpinner animates one terminal line while concise agent setup runs. +// stop clears that line so the durable success or failure status replaces it. +func startSetupSpinner(w io.Writer, label string, enabled bool) func() { + if !enabled { + return func() {} + } + + frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + done := make(chan struct{}) + stopped := make(chan struct{}) + fmt.Fprintf(w, "\r%s %s", frames[0], label) + go func() { + defer close(stopped) + ticker := time.NewTicker(setupSpinnerInterval) + defer ticker.Stop() + frame := 1 + for { + select { + case <-ticker.C: + fmt.Fprintf(w, "\r%s %s", frames[frame%len(frames)], label) + frame++ + case <-done: + return + } + } + }() + + var once sync.Once + return func() { + once.Do(func() { + close(done) + <-stopped + fmt.Fprint(w, "\r\x1b[2K") + }) + } +} + +// setupOmarchy installs the Omarchy desktop pieces and enables the bar plugin +// in a full, styled, interactive, signed-in wizard. Machine and +// non-interactive runs skip it entirely; the envelope points at +// hey setup omarchy instead (wizardBreadcrumbs). func (s *setupWizard) setupOmarchy(signedIn bool) { // Stored-but-rejected credentials are not a login: greet recorded the // auth issue, and the desktop step waits for a sign-in that works. @@ -500,26 +583,39 @@ func (s *setupWizard) setupOmarchy(signedIn bool) { return } w := s.cmd.OutOrStdout() - fmt.Fprintln(w, bold.format(" Step 3: Omarchy desktop")) - fmt.Fprintln(w) - steps := omarchySetup{env: env}.apply() + steps := omarchySetup{env: env, forcePlugin: true}.apply() s.result.Omarchy = &omarchyOutcome{Steps: steps} - bar := stepNamed(steps, "bar plugin") - switch { - case bar.Status == "installed": - fmt.Fprintln(w, statusLine(true, "HEY is in your Omarchy bar")) - case bar.Status == "failed" || (bar.Status == "skipped" && bar.attempted): - fmt.Fprintln(w, warning.format(" Bar plugin: "+bar.Detail)) + if s.narrates() { + s.printStep(w, "Omarchy desktop") + fmt.Fprintln(w) } + + failed := false for _, step := range steps { if step.Status == "failed" || (step.Status == "skipped" && step.attempted) { + failed = true s.result.Status = "incomplete" s.result.Issues = append(s.result.Issues, agentIssue{Check: "Omarchy " + step.Name, Hint: "Run: hey setup omarchy"}) } } - fmt.Fprintln(w) - fmt.Fprintln(w, muted.format(" "+strings.ReplaceAll(strings.TrimRight(omarchyKeybindHint, "\n"), "\n", "\n "))) - fmt.Fprintln(w) + if s.narrates() { + bar := stepNamed(steps, "bar plugin") + switch { + case bar.Status == "failed" || (bar.Status == "skipped" && bar.attempted): + fmt.Fprintln(w, warning.format("Bar plugin: "+bar.Detail)) + case failed: + fmt.Fprintln(w, statusLine(false, "Omarchy desktop setup needs attention")) + case bar.Status == "installed": + fmt.Fprintln(w, statusLine(true, "HEY is in your Omarchy bar")) + default: + fmt.Fprintln(w, statusLine(true, "Omarchy desktop connected")) + } + if s.verbose { + fmt.Fprintln(w) + fmt.Fprintln(w, muted.format(strings.TrimRight(omarchyKeybindHint, "\n"))) + } + fmt.Fprintln(w) + } } // persistOnboarded records that the wizard ran, so a later logged-out bare @@ -537,8 +633,19 @@ func (s *setupWizard) persistOnboarded() { // summary closes the wizard: a checklist and next steps in a terminal, the // envelope otherwise. func (s *setupWizard) summary() error { + if s.opts.silentSuccess { + w := s.cmd.OutOrStdout() + if s.result.Status == "complete" { + fmt.Fprintln(w, success.format("SETUP COMPLETE")) + return nil + } + fmt.Fprintln(w, warning.format("SETUP INCOMPLETE")) + fmt.Fprintln(w) + printWizardIssues(w, s.result.Issues) + return nil + } if s.styled { - showWizardSuccess(s.cmd.OutOrStdout(), s.result, s.outcome) + showWizardSuccess(s.cmd.OutOrStdout(), s.result, s.outcome, s.verbose, s.nextStep) return nil } if s.result.Agents == nil { @@ -553,90 +660,59 @@ func (s *setupWizard) summary() error { ) } -// successHeadline returns the completion banner. When a step left unresolved -// issues the banner is honest about it rather than claiming "Setup complete!". -func successHeadline(status string, issueCount int) string { - if status != "incomplete" { - return "Setup complete!" - } - if issueCount == 1 { - return "Setup finished — 1 step needs attention" - } - return fmt.Sprintf("Setup finished — %d steps need attention", issueCount) -} - -// showWizardSuccess renders the completion checklist, remediation for -// anything that did not complete, and example commands. -func showWizardSuccess(w io.Writer, result wizardResult, outcome agentSetupOutcome) { - divider := muted.format("─────────────────────────────────") - - headline := success - if result.Status == "incomplete" { - headline = warning - } - - fmt.Fprintln(w, divider) - fmt.Fprintln(w, headline.format(" "+successHeadline(result.Status, len(result.Issues)))) - fmt.Fprintln(w, divider) - fmt.Fprintln(w) - - fmt.Fprintln(w, statusLine(!hasAuthIssue(result.Issues), "Signed in")) - if outcome.Skipped { - fmt.Fprintln(w, muted.format(" Coding agent setup skipped — run: hey setup")) - } else { - for _, check := range outcome.Checks { - fmt.Fprintln(w, statusLine(check.Status == "pass", check.Name)) - } - } - if result.Omarchy != nil { - ok := true - for _, step := range result.Omarchy.Steps { - // The same predicate that files the issue: a checkmark beside a - // step listed under "needs attention" would contradict itself. - if step.Status == "failed" || step.failure != nil || (step.Status == "skipped" && step.attempted) { - ok = false +// showWizardSuccess renders an optional detailed checklist, remediation for +// incomplete steps, and the commands that finish the setup flow. +func showWizardSuccess(w io.Writer, result wizardResult, outcome agentSetupOutcome, verbose bool, nextStep int) { + if verbose { + fmt.Fprintln(w, statusLine(!hasAuthIssue(result.Issues), "Signed in")) + if result.AgentsSkipped { + fmt.Fprintln(w, muted.format("Coding agent setup skipped")) + } else if outcome.Skipped { + fmt.Fprintln(w, muted.format("Coding agent setup skipped — run: hey setup")) + } else { + for _, check := range outcome.Checks { + fmt.Fprintln(w, statusLine(check.Status == "pass", check.Name)) } } - fmt.Fprintln(w, statusLine(ok, "Omarchy desktop")) - bar := stepNamed(result.Omarchy.Steps, "bar plugin") - barLine := bar.Status - if bar.Detail != "" { - barLine += " — " + bar.Detail - } - fmt.Fprintln(w, muted.format(" Bar plugin: "+barLine)) - desktop := "launcher entry, menu row and theme template in place" - for _, step := range result.Omarchy.Steps { - if step.Name != "bar plugin" && step.Status == "failed" { - desktop = step.Name + " failed: " + step.Detail + if result.OmarchySkipped { + fmt.Fprintln(w, muted.format("Omarchy setup skipped")) + } else if result.Omarchy != nil { + ok := true + for _, step := range result.Omarchy.Steps { + // The same predicate that files the issue: a checkmark beside a + // step listed under "needs attention" would contradict itself. + if step.Status == "failed" || step.failure != nil || (step.Status == "skipped" && step.attempted) { + ok = false + } } - } - fmt.Fprintln(w, muted.format(" Desktop: "+desktop)) - } - fmt.Fprintln(w) - - if len(result.Issues) > 0 { - fmt.Fprintln(w, " Some steps need attention:") - for _, issue := range result.Issues { - // Check names usually already carry the agent (e.g. "Claude Code - // Plugin"); only prefix when they don't. - label := issue.Check - if issue.Agent != "" && !strings.HasPrefix(issue.Check, issue.Agent) { - label = issue.Agent + " — " + issue.Check + fmt.Fprintln(w, statusLine(ok, "Omarchy desktop")) + bar := stepNamed(result.Omarchy.Steps, "bar plugin") + barLine := bar.Status + if bar.Detail != "" { + barLine += " — " + bar.Detail } - line := " " + label - if issue.Hint != "" { - line += ": " + issue.Hint + fmt.Fprintln(w, muted.format("Bar plugin: "+barLine)) + desktop := "launcher entry, menu row and theme template in place" + for _, step := range result.Omarchy.Steps { + if step.Name != "bar plugin" && step.Status == "failed" { + desktop = step.Name + " failed: " + step.Detail + } } - fmt.Fprintln(w, warning.format(line)) + fmt.Fprintln(w, muted.format("Desktop: "+desktop)) } - fmt.Fprintln(w, muted.format(" Then verify with: hey doctor")) fmt.Fprintln(w) } - fmt.Fprintln(w, " Try these commands:") + printWizardIssues(w, result.Issues) + + title := "Try it out!" + if nextStep > 1 { + title = fmt.Sprintf("Step %d: Try it out!", nextStep) + } + fmt.Fprintln(w, bold.format(title)) fmt.Fprintln(w) examples := []struct{ cmd, desc string }{ - {"hey tui", "Open the app"}, + {"hey hey", "Open TUI"}, {"hey box list", "List your boxes"}, {"hey box view imbox", "Read your Imbox"}, {`hey search "quarterly planning"`, "Search your mail"}, @@ -646,8 +722,30 @@ func showWizardSuccess(w io.Writer, result wizardResult, outcome agentSetupOutco width = max(width, len(ex.cmd)) } for _, ex := range examples { - fmt.Fprintf(w, " %s%s %s\n", bold.format(ex.cmd), strings.Repeat(" ", width-len(ex.cmd)), muted.format(ex.desc)) + fmt.Fprintf(w, "%s%s %s\n", muted.format(ex.cmd), strings.Repeat(" ", width-len(ex.cmd)), italicMuted.format(ex.desc)) + } + fmt.Fprintln(w) +} + +func printWizardIssues(w io.Writer, issues []agentIssue) { + if len(issues) == 0 { + return + } + fmt.Fprintln(w, "Some steps need attention:") + for _, issue := range issues { + // Check names usually already carry the agent (e.g. "Claude Code + // Plugin"); only prefix when they don't. + label := issue.Check + if issue.Agent != "" && !strings.HasPrefix(issue.Check, issue.Agent) { + label = issue.Agent + " — " + issue.Check + } + line := label + if issue.Hint != "" { + line += ": " + issue.Hint + } + fmt.Fprintln(w, warning.format(line)) } + fmt.Fprintln(w, muted.format("Then verify with: hey doctor")) fmt.Fprintln(w) } @@ -707,7 +805,7 @@ func wizardBreadcrumbs(result wizardResult) []output.Breadcrumb { // A machine or non-interactive run on Omarchy never touches the desktop // itself — logged out included, where the automatic hook can never run — // so the explicit command rides along in every branch. - if result.Omarchy == nil && liveOmarchyEnv().detected() { + if result.Omarchy == nil && !result.OmarchySkipped && liveOmarchyEnv().detected() { crumbs = append(crumbs, output.Breadcrumb{Action: "omarchy", Command: "hey setup omarchy", Description: "Put HEY in your Omarchy bar"}) } return crumbs diff --git a/internal/cmd/setup_agent.go b/internal/cmd/setup_agent.go index aef9e5f3..bcf0f82c 100644 --- a/internal/cmd/setup_agent.go +++ b/internal/cmd/setup_agent.go @@ -135,7 +135,7 @@ func runSetupAgent(cmd *cobra.Command, agent harness.AgentInfo, handler agentSet if writer.IsStyled() { w := cmd.OutOrStdout() if skillErr != nil { - fmt.Fprintln(w, warning.format(fmt.Sprintf(" Skill install failed: %s", skillErr))) + fmt.Fprintln(w, warning.format(fmt.Sprintf("Skill install failed: %s", skillErr))) } else { fmt.Fprintln(w, statusLine(true, "Agent skill installed")) } @@ -149,7 +149,7 @@ func runSetupAgent(cmd *cobra.Command, agent harness.AgentInfo, handler agentSet // handler's return value: never tell the user to start a session // against an integration that is not connected. if skillErr == nil && agent.Detect != nil && agent.Detect() && agentChecksPass(agent) { - fmt.Fprintln(w, muted.format(" Start a new "+agent.Name+" session to use HEY commands.")) + fmt.Fprintln(w, muted.format("Start a new "+agent.Name+" session to use HEY commands.")) return nil } return &apierr.Error{ @@ -232,27 +232,27 @@ func runSetupAgent(cmd *cobra.Command, agent harness.AgentInfo, handler agentSet func runClaudeSetup(cmd *cobra.Command) error { w := cmd.OutOrStdout() progress := func(message string) { - fmt.Fprintln(w, muted.format(" "+message)) + fmt.Fprintln(w, muted.format(message)) } if err := installClaudePlugin(cmd.Context(), progress); err != nil { - fmt.Fprintln(w, warning.format(" Claude Code setup failed: "+err.Error())) + fmt.Fprintln(w, warning.format("Claude Code setup failed: "+err.Error())) var setupErr *agentSetupError if errors.As(err, &setupErr) && len(setupErr.Manual) > 0 { - fmt.Fprintln(w, muted.format(" Try manually:")) + fmt.Fprintln(w, muted.format("Try manually:")) for _, manual := range setupErr.Manual { - fmt.Fprintln(w, bold.format(" "+manual)) + fmt.Fprintln(w, bold.format(manual)) } } - fmt.Fprintln(w, muted.format(" Then verify with: hey doctor")) + fmt.Fprintln(w, muted.format("Then verify with: hey doctor")) return nil } fmt.Fprintln(w, statusLine(true, "Claude Code plugin installed")) fmt.Fprintln(w, statusLine(true, "Claude Code skill linked")) fmt.Fprintln(w) - fmt.Fprintln(w, muted.format(" Tip: enable auto-update to stay current with new CLI releases:")) - fmt.Fprintln(w, muted.format(" "+harness.AutoUpdateHint)) + fmt.Fprintln(w, muted.format("Tip: enable auto-update to stay current with new CLI releases:")) + fmt.Fprintln(w, muted.format(harness.AutoUpdateHint)) return nil } @@ -349,8 +349,8 @@ func runCodexSetup(cmd *cobra.Command) error { w := cmd.OutOrStdout() path, err := installCodexSkill() if err != nil { - fmt.Fprintln(w, warning.format(" Codex skill install failed: "+err.Error())) - fmt.Fprintln(w, muted.format(" Then verify with: hey doctor")) + fmt.Fprintln(w, warning.format("Codex skill install failed: "+err.Error())) + fmt.Fprintln(w, muted.format("Then verify with: hey doctor")) return nil //nolint:nilerr // warn and continue; the post-setup snapshot reports the failure } fmt.Fprintln(w, statusLine(true, "Codex skill installed ("+path+")")) @@ -381,9 +381,9 @@ func installCodexSkill() (string, error) { // statusLine renders a ✓/✗ checklist line. func statusLine(ok bool, label string) string { if ok { - return success.format(" ✓ " + label) + return success.format("✓ " + label) } - return warning.format(" ✗ " + label) + return warning.format("✗ " + label) } // snapshotAgentChecks captures every check across the given agents in one pass. diff --git a/internal/cmd/setup_agents.go b/internal/cmd/setup_agents.go index 04e4c83d..d30a8336 100644 --- a/internal/cmd/setup_agents.go +++ b/internal/cmd/setup_agents.go @@ -23,23 +23,30 @@ const agentSetupEnv = "HEY_SETUP_AGENT" // envelope. It never prompts, so it is safe for the piped installer and // coding-agent shells. func newSetupAgentsCommand() *cobra.Command { - return &cobra.Command{ + var remove bool + cmd := &cobra.Command{ Use: "agents", - Short: "Install the HEY skill and connect detected coding agents", + Short: "Install or remove HEY coding-agent integrations", Long: "Install the baseline HEY agent skill and attempt to connect coding agents.\n\n" + "Selection is controlled by " + agentSetupEnv + ": claude, codex, all, or none. When\n" + "unset, a single detected agent is connected; when several are detected none is\n" + - "guessed — the per-agent `hey setup ` commands are surfaced instead.", + "guessed — the per-agent `hey setup ` commands are surfaced instead. Use\n" + + "--remove to uninstall the HEY integrations and managed skill files.", // Selection is env-driven; positional args are always a mistake (typo, // or confusion with `setup `). Reject them rather than silently ignore. Args: cobra.NoArgs, Annotations: map[string]string{ - "agent_notes": "Never prompts. Set " + agentSetupEnv + "=claude|codex|all|none to choose; unset auto-detects a single agent.", + "agent_notes": "Never prompts. Set " + agentSetupEnv + "=claude|codex|all|none to choose; unset auto-detects a single agent. --remove uninstalls HEY's managed agent integrations.", }, RunE: func(cmd *cobra.Command, _ []string) error { + if remove { + return runRemoveAgentSetup(cmd) + } return runNonInteractiveAgentSetup(cmd) }, } + cmd.Flags().BoolVar(&remove, "remove", false, "Remove HEY coding-agent integrations") + return cmd } // agentSetupRecord is the per-agent outcome captured while running handlers. diff --git a/internal/cmd/setup_agents_remove.go b/internal/cmd/setup_agents_remove.go new file mode 100644 index 00000000..a3e249a3 --- /dev/null +++ b/internal/cmd/setup_agents_remove.go @@ -0,0 +1,116 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/harness" +) + +// runRemoveAgentSetup removes the coding-agent integrations written by hey. +// Canonical paths without hey's ownership marker remain untouched. +func runRemoveAgentSetup(cmd *cobra.Command) error { + home, err := os.UserHomeDir() + if err != nil { + return apierr.ErrAPI(0, fmt.Sprintf("getting home directory: %v", err)) + } + + var removed []string + var failures []string + + if harness.InstalledPluginVersion() != "" { + claudePath := harness.FindClaudeBinary() + if claudePath == "" { + failures = append(failures, "Claude Code plugin: claude binary not found") + } else if out, uninstallErr := runClaudeStep(cmd.Context(), claudeInstallTimeout, claudePath, "plugin", "uninstall", harness.ClaudeExpectedPluginKey); uninstallErr != nil { + failures = append(failures, "Claude Code plugin: "+agentCommandFailure(out, uninstallErr)) + } else { + removed = append(removed, "Claude Code plugin") + } + } + + claudeSkill := filepath.Join(home, ".claude", "skills", harness.ClaudePluginName) + if exists(claudeSkill) { + if removeErr := removeExistingSkillLink(claudeSkill); removeErr != nil { + var unmanaged *unmanagedSkillDirError + if !errors.As(removeErr, &unmanaged) { + failures = append(failures, "Claude Code skill: "+removeErr.Error()) + } + } else { + removed = append(removed, "Claude Code skill") + } + } + + if codexSkill := harness.CodexSkillPath(); codexSkill != "" { + if didRemove, removeErr := removeOwnedSkillFiles(filepath.Dir(codexSkill)); removeErr != nil { + failures = append(failures, "Codex skill: "+removeErr.Error()) + } else if didRemove { + removed = append(removed, "Codex skill") + } + } + + baseline := filepath.Join(home, ".agents", "skills", "hey") + if didRemove, removeErr := removeOwnedSkillFiles(baseline); removeErr != nil { + failures = append(failures, "agent skill: "+removeErr.Error()) + } else if didRemove { + removed = append(removed, "agent skill") + } + + if len(failures) > 0 { + return &apierr.Error{ + Code: "setup_remove_failed", + Message: "coding-agent integration removal incomplete: " + strings.Join(failures, "; "), + Hint: "Resolve the reported item, then run: hey setup agents --remove", + } + } + + if removed == nil { + removed = []string{} + } + return writeMutation(cmd, "Coding-agent integrations removed", map[string]any{"removed": removed}) +} + +// removeOwnedSkillFiles removes only files hey writes from a marked skill +// directory. Additional files remain in place and make the directory +// user-owned once the marker is gone. +func removeOwnedSkillFiles(dir string) (bool, error) { + if !ownedSkillDir(dir) { + return false, nil + } + + paths := []string{ + filepath.Join(dir, skillFilename), + filepath.Join(dir, installedVersionFile), + filepath.Join(dir, ownershipMarkerFile), + } + for _, path := range paths { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + continue + } + if err != nil { + return false, fmt.Errorf("inspecting %s: %w", path, err) + } + if !info.Mode().IsRegular() { + return false, fmt.Errorf("%s is not a regular file", path) + } + } + for _, path := range paths { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return false, fmt.Errorf("removing %s: %w", path, err) + } + } + _ = os.Remove(dir) // Additional user files keep the now-unmanaged directory in place. + return true, nil +} + +func exists(path string) bool { + _, err := os.Lstat(path) + return err == nil +} diff --git a/internal/cmd/setup_agents_test.go b/internal/cmd/setup_agents_test.go index 7797d1d9..92483bbd 100644 --- a/internal/cmd/setup_agents_test.go +++ b/internal/cmd/setup_agents_test.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "encoding/json" "errors" "net/http" @@ -575,6 +576,122 @@ func TestMigrationRetriesScrubWhenStoreAlreadyPopulated(t *testing.T) { } } +func TestSetupAgentsRemoveDeletesManagedSkillsAndPreservesUserFiles(t *testing.T) { + isolateAgents(t) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + for _, dir := range []string{".claude", ".codex"} { + if err := os.MkdirAll(filepath.Join(home, dir), 0o755); err != nil { + t.Fatal(err) + } + } + if _, err := installSkillFiles(); err != nil { + t.Fatal(err) + } + if _, err := linkSkillToClaude(); err != nil { + t.Fatal(err) + } + if _, err := installSkillToCodex(); err != nil { + t.Fatal(err) + } + baseline := filepath.Join(home, ".agents", "skills", "hey") + if err := os.WriteFile(filepath.Join(baseline, "notes.txt"), []byte("keep me"), 0o600); err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + _, response, err := runAuthCommand(t, home, server.URL, "", true, "setup", "agents", "--remove") + if err != nil { + t.Fatalf("setup agents --remove: %v", err) + } + if response.Summary != "Coding-agent integrations removed" { + t.Errorf("summary = %q", response.Summary) + } + for _, path := range []string{ + filepath.Join(home, ".claude", "skills", "hey"), + filepath.Join(home, ".codex", "skills", "hey"), + filepath.Join(baseline, skillFilename), + filepath.Join(baseline, ownershipMarkerFile), + } { + if _, statErr := os.Lstat(path); !os.IsNotExist(statErr) { + t.Errorf("managed path remains: %s", path) + } + } + if data, readErr := os.ReadFile(filepath.Join(baseline, "notes.txt")); readErr != nil || string(data) != "keep me" { + t.Errorf("user file = %q, %v", data, readErr) + } +} + +func TestSetupAgentsRemovePreservesUnmanagedSkills(t *testing.T) { + isolateAgents(t) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + paths := []string{ + filepath.Join(home, ".agents", "skills", "hey"), + filepath.Join(home, ".claude", "skills", "hey"), + filepath.Join(home, ".codex", "skills", "hey"), + } + for _, path := range paths { + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, skillFilename), []byte("user skill"), 0o600); err != nil { + t.Fatal(err) + } + } + + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + if _, _, err := runAuthCommand(t, home, server.URL, "", true, "setup", "agents", "--remove"); err != nil { + t.Fatalf("setup agents --remove: %v", err) + } + for _, path := range paths { + data, readErr := os.ReadFile(filepath.Join(path, skillFilename)) + if readErr != nil || string(data) != "user skill" { + t.Errorf("unmanaged skill %s = %q, %v", path, data, readErr) + } + } +} + +func TestSetupAgentsRemoveUninstallsClaudePlugin(t *testing.T) { + isolateAgents(t) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + pluginsDir := filepath.Join(home, ".claude", "plugins") + if err := os.MkdirAll(pluginsDir, 0o755); err != nil { + t.Fatal(err) + } + registry := `{"version":2,"plugins":{"hey@37signals":[{"version":"1.1.0"}]}}` + if err := os.WriteFile(filepath.Join(pluginsDir, "installed_plugins.json"), []byte(registry), 0o600); err != nil { + t.Fatal(err) + } + binDir := t.TempDir() + claudePath := filepath.Join(binDir, "claude") + if err := os.WriteFile(claudePath, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", binDir) + var gotName string + var gotArgs []string + stubRunAgentCommand(t, func(_ context.Context, name string, args ...string) ([]byte, error) { + gotName, gotArgs = name, args + return nil, nil + }) + + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + if _, _, err := runAuthCommand(t, home, server.URL, "", true, "setup", "agents", "--remove"); err != nil { + t.Fatalf("setup agents --remove: %v", err) + } + if gotName != claudePath || strings.Join(gotArgs, " ") != "plugin uninstall hey@37signals" { + t.Errorf("claude command = %q %v", gotName, gotArgs) + } +} + // Legacy credentials belong to the server recorded beside them. With the // effective base URL pointed elsewhere, migration must neither misfile them // under the wrong store key nor scrub the only copy for their real origin. diff --git a/internal/cmd/setup_test.go b/internal/cmd/setup_test.go index 397c73c5..9cc48385 100644 --- a/internal/cmd/setup_test.go +++ b/internal/cmd/setup_test.go @@ -5,13 +5,17 @@ import ( "context" "encoding/json" "errors" + "fmt" + "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" + "time" + "github.com/basecamp/hey-cli/internal/auth" "github.com/basecamp/hey-cli/internal/output" ) @@ -91,6 +95,130 @@ func TestSetupCommandRegistersAgentSubcommands(t *testing.T) { } } +func TestSetupSkipAgentsLeavesAgentIntegrationsUnchanged(t *testing.T) { + isolateAgents(t) + server := identityServer(t) + configHome := t.TempDir() + if err := os.MkdirAll(filepath.Join(configHome, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + if _, _, err := runAuthCommand(t, configHome, server.URL, "", true, "auth", "login", "--cookie", "session-cookie"); err != nil { + t.Fatalf("auth login: %v", err) + } + + _, response, err := runAuthCommand(t, configHome, server.URL, "", true, "setup", "--skip-agents") + if err != nil { + t.Fatalf("setup --skip-agents: %v", err) + } + data := wizardData(t, response) + if data["agents_skipped"] != true { + t.Errorf("agents_skipped = %v", data["agents_skipped"]) + } + if agents, ok := data["agents"].([]any); !ok || len(agents) != 0 { + t.Errorf("agents = %v", data["agents"]) + } + if _, statErr := os.Stat(filepath.Join(configHome, ".agents", "skills", "hey", skillFilename)); !os.IsNotExist(statErr) { + t.Errorf("agent skill was written: %v", statErr) + } +} + +func TestSetupSilentSuccessShowsSpinnerAndCompletion(t *testing.T) { + isolateAgents(t) + stubInteractive(t, true) + server := identityServer(t) + configHome := t.TempDir() + if _, _, err := runAuthCommand(t, configHome, server.URL, "", true, "auth", "login", "--cookie", "session-cookie"); err != nil { + t.Fatalf("auth login: %v", err) + } + origColor := colorDisabled + colorDisabled = true + t.Cleanup(func() { colorDisabled = origColor }) + + stdout, _, err := runAuthCommand(t, configHome, server.URL, "", false, "setup", "--styled", "--silent-success") + if err != nil { + t.Fatalf("setup --silent-success: %v", err) + } + if !strings.Contains(stdout, "Installing HEY…") { + t.Errorf("silent setup did not show installation activity: %q", stdout) + } + if !strings.HasSuffix(stdout, "\r\x1b[2KSETUP COMPLETE\n") { + t.Errorf("silent setup did not clear the spinner into SETUP COMPLETE: %q", stdout) + } + for _, hidden := range []string{"Welcome to HEY", "Coding agents", "Try it out!"} { + if strings.Contains(stdout, hidden) { + t.Errorf("silent success contains narration %q: %q", hidden, stdout) + } + } +} + +func TestSetupSilentSuccessKeepsSignInInstructions(t *testing.T) { + stubStdinTerminal(t) + t.Setenv("HEY_NONINTERACTIVE", "") + previousAuthMgr := authMgr + authMgr = auth.NewManager("http://app.hey.localhost:3003", http.DefaultClient, t.TempDir()) + t.Cleanup(func() { authMgr = previousAuthMgr }) + previousLogin := loginInteractively + loginInteractively = func(out io.Writer) error { + fmt.Fprintln(out, "Opening browser for authentication...") + fmt.Fprintln(out, "If the browser doesn't open, visit: https://example.com/oauth") + fmt.Fprintln(out, "Waiting for authentication...") + return nil + } + t.Cleanup(func() { loginInteractively = previousLogin }) + + cmd := newSetupCommand().cmd + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stdout) + wizard := &setupWizard{cmd: cmd, opts: wizardOptions{silentSuccess: true}, styled: true, nextStep: 1} + signedIn, err := wizard.signIn() + if err != nil || !signedIn { + t.Fatalf("signIn = %v, %v", signedIn, err) + } + for _, want := range []string{"Opening browser", "https://example.com/oauth", "Waiting for authentication"} { + if !strings.Contains(stdout.String(), want) { + t.Errorf("silent sign-in missing %q:\n%s", want, stdout.String()) + } + } + if strings.Contains(stdout.String(), "Step 1") { + t.Errorf("silent sign-in included setup narration:\n%s", stdout.String()) + } +} + +func TestSetupSilentSuccessKeepsFailureGuidance(t *testing.T) { + isolateAgents(t) + stubInteractive(t, true) + t.Setenv("HEY_NONINTERACTIVE", "1") + server := quietServer(t) + origColor := colorDisabled + colorDisabled = true + t.Cleanup(func() { colorDisabled = origColor }) + + stdout, _, err := runAuthCommand(t, t.TempDir(), server.URL, "", false, "setup", "--styled", "--silent-success") + if err != nil { + t.Fatalf("setup --silent-success: %v", err) + } + for _, want := range []string{"SETUP INCOMPLETE", "Not logged in", "hey auth login"} { + if !strings.Contains(stdout, want) { + t.Errorf("failure output missing %q:\n%s", want, stdout) + } + } + for _, hidden := range []string{"Welcome to HEY", "Try it out!"} { + if strings.Contains(stdout, hidden) { + t.Errorf("failure output contains success narration %q:\n%s", hidden, stdout) + } + } +} + +func TestSetupSilentSuccessRejectsMachineOutput(t *testing.T) { + isolateAgents(t) + stubInteractive(t, true) + _, _, err := runAuthCommand(t, t.TempDir(), "http://app.hey.localhost:3003", "", true, "setup", "--silent-success") + if err == nil || !strings.Contains(err.Error(), "requires an interactive terminal with styled output") { + t.Fatalf("error = %v", err) + } +} + func TestSetupRejectsJQ(t *testing.T) { isolateAgents(t) _, _, err := runAuthCommand(t, t.TempDir(), "http://app.hey.localhost:3003", "", false, "setup", "--jq", ".") @@ -271,23 +399,6 @@ func contains(items []string, want string) bool { return false } -func TestSuccessHeadline(t *testing.T) { - tests := []struct { - status string - issues int - want string - }{ - {"complete", 0, "Setup complete!"}, - {"incomplete", 1, "Setup finished — 1 step needs attention"}, - {"incomplete", 3, "Setup finished — 3 steps need attention"}, - } - for _, tt := range tests { - if got := successHeadline(tt.status, tt.issues); got != tt.want { - t.Errorf("successHeadline(%q, %d) = %q, want %q", tt.status, tt.issues, got, tt.want) - } - } -} - func TestStatusFromOutcome(t *testing.T) { if got := statusFromOutcome(agentSetupOutcome{}); got != "complete" { t.Errorf("empty outcome = %q", got) @@ -308,9 +419,9 @@ func TestShowWizardSuccessText(t *testing.T) { var out bytes.Buffer showWizardSuccess(&out, wizardResult{Status: "complete", Identity: &wizardIdentity{Email: "jane@example.com"}}, agentSetupOutcome{ Checks: []agentCheck{{Agent: "Claude Code", Name: "Claude Code Plugin", Status: "pass"}}, - }) + }, true, 3) text := out.String() - for _, want := range []string{"Setup complete!", "✓ Signed in", "✓ Claude Code Plugin", "Try these commands:", "hey box list", `hey search "quarterly planning"`} { + for _, want := range []string{"✓ Signed in", "✓ Claude Code Plugin", "Step 3: Try it out!", "hey hey", "Open TUI", "hey box list", `hey search "quarterly planning"`} { if !strings.Contains(text, want) { t.Errorf("complete summary missing %q:\n%s", want, text) } @@ -324,38 +435,75 @@ func TestShowWizardSuccessText(t *testing.T) { showWizardSuccess(&out, wizardResult{Status: "incomplete", Issues: issues}, agentSetupOutcome{ Checks: []agentCheck{{Agent: "Claude Code", Name: "Claude Code Plugin", Status: "fail"}}, Issues: issues, - }) + }, true, 2) text = out.String() - for _, want := range []string{"Setup finished — 1 step needs attention", "✗ Claude Code Plugin", "Some steps need attention:", "Claude Code Plugin: Run: hey setup claude", "Then verify with: hey doctor"} { + for _, want := range []string{"✗ Claude Code Plugin", "Some steps need attention:", "Claude Code Plugin: Run: hey setup claude", "Then verify with: hey doctor", "Step 2: Try it out!"} { if !strings.Contains(text, want) { t.Errorf("incomplete summary missing %q:\n%s", want, text) } } out.Reset() - showWizardSuccess(&out, wizardResult{Status: "complete"}, agentSetupOutcome{Skipped: true}) + showWizardSuccess(&out, wizardResult{Status: "complete"}, agentSetupOutcome{Skipped: true}, true, 1) if !strings.Contains(out.String(), "Coding agent setup skipped — run: hey setup") { t.Errorf("skipped summary:\n%s", out.String()) } } -func stubStdinTerminal(t *testing.T, isTerminal bool) { - t.Helper() - orig := stdinIsTerminal - stdinIsTerminal = func() bool { return isTerminal } - t.Cleanup(func() { stdinIsTerminal = orig }) +func TestShowWizardSuccessConciseHidesChecklist(t *testing.T) { + origColor := colorDisabled + colorDisabled = true + t.Cleanup(func() { colorDisabled = origColor }) + + result := wizardResult{ + Status: "complete", + Omarchy: &omarchyOutcome{Steps: []omarchyStep{ + {Name: "bar plugin", Status: "installed", Detail: "installed and enabled; notifications off"}, + {Name: "desktop entry", Status: "installed"}, + }}, + } + outcome := agentSetupOutcome{Checks: []agentCheck{ + {Name: "Claude Code Plugin", Status: "pass"}, + {Name: "Claude Code Skill", Status: "pass"}, + {Name: "Codex Skill", Status: "pass"}, + }} + var out bytes.Buffer + showWizardSuccess(&out, result, outcome, false, 1) + for _, hidden := range []string{"Signed in", "Claude Code Plugin", "Claude Code Skill", "Codex Skill", "Omarchy desktop", "Bar plugin:", "Desktop:", "Setup complete!", "────────────────", "Try these commands:", "Step 1:"} { + if strings.Contains(out.String(), hidden) { + t.Errorf("concise summary contains %q:\n%s", hidden, out.String()) + } + } + for _, want := range []string{"Try it out!", "hey hey", "Open TUI"} { + if !strings.Contains(out.String(), want) { + t.Errorf("concise summary missing %q:\n%s", want, out.String()) + } + } + for _, line := range strings.Split(out.String(), "\n") { + if (strings.Contains(line, "Try it out!") || strings.HasPrefix(strings.TrimLeft(line, " "), "hey ")) && strings.HasPrefix(line, " ") { + t.Errorf("summary line is indented: %q", line) + } + } + + colorDisabled = false + out.Reset() + showWizardSuccess(&out, result, outcome, false, 1) + if !strings.Contains(out.String(), muted.format("hey hey")) { + t.Errorf("summary command is not muted:\n%s", out.String()) + } + if strings.Contains(out.String(), bold.format("hey hey")) { + t.Errorf("summary command competes with the step style:\n%s", out.String()) + } + if !strings.Contains(out.String(), italicMuted.format("Open TUI")) { + t.Errorf("summary hint is not differentiated from its command:\n%s", out.String()) + } } -func stubConfirmAgentSetup(t *testing.T, answer bool, err error) *int { +func stubStdinTerminal(t *testing.T) { t.Helper() - calls := 0 - orig := confirmAgentSetup - confirmAgentSetup = func() (bool, error) { - calls++ - return answer, err - } - t.Cleanup(func() { confirmAgentSetup = orig }) - return &calls + orig := stdinIsTerminal + stdinIsTerminal = func() bool { return true } + t.Cleanup(func() { stdinIsTerminal = orig }) } // The wizard installs shell completions on its own. An install through mise, @@ -384,13 +532,12 @@ func TestSetupInstallsShellCompletions(t *testing.T) { } } -// HEY_NONINTERACTIVE must disable every wizard interaction even on a real -// PTY: no agent-setup prompt (the default answer applies) and no OAuth wait. +// HEY_NONINTERACTIVE disables interactive sign-in even on a real PTY while +// detected agent setup continues without prompting. func TestSetupStyledNonInteractiveNeverPromptsNorSignsIn(t *testing.T) { isolateAgents(t) - stubStdinTerminal(t, true) // a PTY — but HEY_NONINTERACTIVE wins + stubStdinTerminal(t) // a PTY — but HEY_NONINTERACTIVE wins t.Setenv("HEY_NONINTERACTIVE", "1") - confirms := stubConfirmAgentSetup(t, false, nil) server := quietServer(t) configHome := t.TempDir() if err := os.MkdirAll(filepath.Join(configHome, ".claude"), 0o755); err != nil { @@ -405,24 +552,104 @@ func TestSetupStyledNonInteractiveNeverPromptsNorSignsIn(t *testing.T) { if err != nil { t.Fatalf("setup --styled: %v", err) } - if *confirms != 0 { - t.Errorf("the agent-setup prompt ran %d times with HEY_NONINTERACTIVE=1", *confirms) - } // No OAuth wait: the run completed and reported the missing login. - if !strings.Contains(stdout, "Setup finished") || !strings.Contains(stdout, "Not logged in") { - t.Errorf("expected an incomplete summary, got:\n%s", stdout) + if !strings.Contains(stdout, "Some steps need attention") || !strings.Contains(stdout, "Not logged in") { + t.Errorf("expected incomplete-step guidance, got:\n%s", stdout) + } + for _, want := range []string{"Step 1: Coding agents", "Step 2: Try it out!"} { + if !strings.Contains(stdout, want) { + t.Errorf("dynamic steps missing %q:\n%s", want, stdout) + } + } + if strings.Contains(stdout, "Step 3:") { + t.Errorf("setup left a gap in its step numbering:\n%s", stdout) + } + for _, line := range strings.Split(stdout, "\n") { + if strings.HasPrefix(line, " ") { + t.Errorf("setup line is indented: %q", line) + } } - // The agent step proceeded with the prompt's default answer. if _, err := os.Stat(filepath.Join(configHome, ".agents", "skills", "hey", "SKILL.md")); err != nil { t.Errorf("agent step should auto-proceed without a prompt: %v", err) } } -// The interactive path still prompts, and declining skips the agent step. -func TestSetupStyledInteractiveDeclineSkipsAgents(t *testing.T) { +func TestSetupSpinnerAnimatesAndClears(t *testing.T) { + originalInterval := setupSpinnerInterval + setupSpinnerInterval = time.Millisecond + t.Cleanup(func() { setupSpinnerInterval = originalInterval }) + + var out bytes.Buffer + stop := startSetupSpinner(&out, "Installing agent skill…", true) + time.Sleep(5 * time.Millisecond) + stop() + text := out.String() + if !strings.Contains(text, "Installing agent skill…") { + t.Errorf("spinner output = %q", text) + } + if !strings.HasSuffix(text, "\r\x1b[2K") { + t.Errorf("spinner did not clear its line: %q", text) + } + + out.Reset() + startSetupSpinner(&out, "Installing agent skill…", false)() + if out.Len() != 0 { + t.Errorf("disabled spinner output = %q", out.String()) + } +} + +func TestSetupStyledDetailsRequireVerboseEnv(t *testing.T) { + for _, tt := range []struct { + name string + verbose string + want bool + }{ + {name: "concise by default"}, + {name: "verbose", verbose: "1", want: true}, + } { + t.Run(tt.name, func(t *testing.T) { + isolateAgents(t) + t.Setenv("HEY_NONINTERACTIVE", "1") + t.Setenv(setupVerboseEnv, tt.verbose) + binDir := t.TempDir() + claudePath := filepath.Join(binDir, "claude") + if err := os.WriteFile(claudePath, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", binDir) + stubRunAgentCommand(t, func(context.Context, string, ...string) ([]byte, error) { + return nil, nil + }) + + configHome := t.TempDir() + if err := os.MkdirAll(filepath.Join(configHome, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + origColor := colorDisabled + colorDisabled = true + t.Cleanup(func() { colorDisabled = origColor }) + + server := quietServer(t) + defer server.Close() + stdout, _, err := runAuthCommand(t, configHome, server.URL, "", false, "setup", "--styled") + if err != nil { + t.Fatalf("setup --styled: %v", err) + } + for _, detail := range []string{"This will:", "Registering 37signals marketplace", "Refreshing 37signals marketplace", "Installing hey@37signals plugin"} { + if got := strings.Contains(stdout, detail); got != tt.want { + t.Errorf("contains %q = %v, want %v:\n%s", detail, got, tt.want, stdout) + } + } + if strings.Contains(stdout, "Detected: Claude Code\n\n\n") { + t.Errorf("extra blank line after detected agents:\n%s", stdout) + } + }) + } +} + +func TestSetupStyledInteractiveInstallsDetectedAgentsWithoutPrompt(t *testing.T) { isolateAgents(t) stubInteractive(t, true) - confirms := stubConfirmAgentSetup(t, false, nil) server := identityServer(t) configHome := t.TempDir() if err := os.MkdirAll(filepath.Join(configHome, ".claude"), 0o755); err != nil { @@ -440,21 +667,47 @@ func TestSetupStyledInteractiveDeclineSkipsAgents(t *testing.T) { if err != nil { t.Fatalf("setup --styled: %v", err) } - if *confirms != 1 { - t.Errorf("prompt ran %d times, want 1", *confirms) + if strings.Contains(stdout, "Set up HEY for your coding agents?") { + t.Errorf("setup asked for agent confirmation:\n%s", stdout) } - if !strings.Contains(stdout, "You can set up agents later:") || !strings.Contains(stdout, "Coding agent setup skipped") { - t.Errorf("declined setup should skip:\n%s", stdout) + if _, err := os.Stat(filepath.Join(configHome, ".agents", "skills", "hey", "SKILL.md")); err != nil { + t.Errorf("setup did not install the detected agent skill: %v", err) } - if _, err := os.Stat(filepath.Join(configHome, ".agents", "skills", "hey", "SKILL.md")); !os.IsNotExist(err) { - t.Error("declined setup must not install the skill") +} + +func TestSetupRepeatKeepsDetectedConnectedAgentsVisible(t *testing.T) { + isolateAgents(t) + stubInteractive(t, true) + server := identityServer(t) + configHome := t.TempDir() + if err := os.MkdirAll(filepath.Join(configHome, ".codex"), 0o755); err != nil { + t.Fatal(err) + } + if _, _, err := runAuthCommand(t, configHome, server.URL, "", true, "auth", "login", "--cookie", "session-cookie"); err != nil { + t.Fatalf("auth login: %v", err) + } + origColor := colorDisabled + colorDisabled = true + t.Cleanup(func() { colorDisabled = origColor }) + + if _, _, err := runAuthCommand(t, configHome, server.URL, "", false, "setup", "--styled"); err != nil { + t.Fatalf("initial setup: %v", err) + } + stdout, _, err := runAuthCommand(t, configHome, server.URL, "", false, "setup", "--styled") + if err != nil { + t.Fatalf("repeat setup: %v", err) + } + for _, want := range []string{"Step 1: Coding agents", "✓ Codex connected", "Step 2: Try it out!"} { + if !strings.Contains(stdout, want) { + t.Errorf("repeat setup missing %q:\n%s", want, stdout) + } } } // Machine output plus HEY_NONINTERACTIVE on a terminal must not start OAuth. func TestSetupJSONNonInteractiveEnvSkipsSignIn(t *testing.T) { isolateAgents(t) - stubStdinTerminal(t, true) + stubStdinTerminal(t) t.Setenv("HEY_NONINTERACTIVE", "1") server := quietServer(t) @@ -503,8 +756,8 @@ func TestSetupJSONStaleCredentialsReportIncomplete(t *testing.T) { } // The wizard records a handler refusal as an issue even when the health -// snapshot alone would miss it, so "Setup complete!" can never follow an -// installation warning. +// snapshot alone would miss it, so the warning remains visible in the +// finished setup flow. func TestSetupWizardRecordsHandlerFailures(t *testing.T) { isolateAgents(t) server := identityServer(t) @@ -551,7 +804,7 @@ func TestShowWizardSuccessRejectedCredentialsChecklist(t *testing.T) { var out bytes.Buffer issues := []agentIssue{{Check: "Stored sign-in rejected", Hint: "Run: hey auth login"}} - showWizardSuccess(&out, wizardResult{Status: "incomplete", Issues: issues}, agentSetupOutcome{}) + showWizardSuccess(&out, wizardResult{Status: "incomplete", Issues: issues}, agentSetupOutcome{}, true, 1) if !strings.Contains(out.String(), "✗ Signed in") { t.Errorf("rejected credentials must render as not signed in:\n%s", out.String()) } From a2a9fee348f6c476f4e6549aa4cc9fae9a630444 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 25 Aug 2026 16:01:40 -0400 Subject: [PATCH 04/12] Keep Claude plugin installs on HTTPS --- internal/cmd/setup_agent.go | 47 +++++++++++++++++++++++++++++-- internal/cmd/setup_agents_test.go | 28 ++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/internal/cmd/setup_agent.go b/internal/cmd/setup_agent.go index bcf0f82c..21e9dd34 100644 --- a/internal/cmd/setup_agent.go +++ b/internal/cmd/setup_agent.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "os" "os/exec" "strings" "time" @@ -83,12 +84,44 @@ var agentSetupHandlers = map[string]agentSetupHandler{ // prints its own status lines and surfaces the tool's output only on failure. var runAgentCommand = func(ctx context.Context, name string, args ...string) ([]byte, error) { command := exec.CommandContext(ctx, name, args...) // #nosec G204 -- name comes from harness.Find*Binary + command.Env = commandEnvironment(os.Environ(), agentCommandEnvironment(ctx)) // Bound Wait, not just the process: a wrapper script's grandchild can hold // the output pipes open after the timeout kills the child. command.WaitDelay = time.Second return command.CombinedOutput() } +type agentCommandEnvironmentKey struct{} + +func agentCommandEnvironment(ctx context.Context) []string { + env, _ := ctx.Value(agentCommandEnvironmentKey{}).([]string) + return env +} + +// commandEnvironment applies command-scoped overrides without changing the +// process environment shared by concurrent commands. +func commandEnvironment(base, overrides []string) []string { + env := append([]string(nil), base...) + for _, override := range overrides { + key, _, ok := strings.Cut(override, "=") + if !ok { + continue + } + replaced := false + for i, value := range env { + existingKey, _, hasValue := strings.Cut(value, "=") + if hasValue && strings.EqualFold(existingKey, key) { + env[i] = override + replaced = true + } + } + if !replaced { + env = append(env, override) + } + } + return env +} + const ( // claudeMarketplaceTimeout bounds marketplace add/update so a hung clone // can't stall setup indefinitely. @@ -292,12 +325,12 @@ func installClaudePlugin(parent context.Context, progress func(string)) error { progress("Registering " + harness.ClaudeMarketplaceName + " marketplace…") // Best-effort: already registered is the common case and not an error. - _, _ = runClaudeStep(parent, claudeMarketplaceTimeout, claudePath, "plugin", "marketplace", "add", harness.ClaudeMarketplaceSource) + _, _ = runClaudePluginStep(parent, claudeMarketplaceTimeout, claudePath, "plugin", "marketplace", "add", harness.ClaudeMarketplaceSource) progress("Refreshing " + harness.ClaudeMarketplaceName + " marketplace…") - _, _ = runClaudeStep(parent, claudeMarketplaceTimeout, claudePath, "plugin", "marketplace", "update", harness.ClaudeMarketplaceName) + _, _ = runClaudePluginStep(parent, claudeMarketplaceTimeout, claudePath, "plugin", "marketplace", "update", harness.ClaudeMarketplaceName) progress("Installing " + harness.ClaudeExpectedPluginKey + " plugin…") - out, err := runClaudeStep(parent, claudeInstallTimeout, claudePath, "plugin", "install", harness.ClaudeExpectedPluginKey) + out, err := runClaudePluginStep(parent, claudeInstallTimeout, claudePath, "plugin", "install", harness.ClaudeExpectedPluginKey) if err != nil { return claudeSetupError("plugin install failed: " + agentCommandFailure(out, err)) } @@ -315,6 +348,14 @@ func runClaudeStep(parent context.Context, timeout time.Duration, path string, a return runAgentCommand(ctx, path, args...) } +// runClaudePluginStep clones the public Claude plugin repositories over +// HTTPS independently of global Git URL rewrites. Plugin setup therefore +// works before the user configures GitHub SSH keys or host verification. +func runClaudePluginStep(parent context.Context, timeout time.Duration, path string, args ...string) ([]byte, error) { + ctx := context.WithValue(parent, agentCommandEnvironmentKey{}, []string{"GIT_CONFIG_GLOBAL=" + os.DevNull}) + return runClaudeStep(ctx, timeout, path, args...) +} + func claudeSetupError(summary string) *agentSetupError { return &agentSetupError{Summary: summary, Manual: claudeManualCommands()} } diff --git a/internal/cmd/setup_agents_test.go b/internal/cmd/setup_agents_test.go index 92483bbd..213f91f7 100644 --- a/internal/cmd/setup_agents_test.go +++ b/internal/cmd/setup_agents_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/basecamp/hey-cli/internal/apierr" "github.com/basecamp/hey-cli/internal/output" @@ -656,6 +657,33 @@ func TestSetupAgentsRemovePreservesUnmanagedSkills(t *testing.T) { } } +func TestClaudePluginStepsIgnoreGlobalGitURLRewrites(t *testing.T) { + isolateAgents(t) + var gotEnv []string + stubRunAgentCommand(t, func(ctx context.Context, _ string, _ ...string) ([]byte, error) { + gotEnv = agentCommandEnvironment(ctx) + return nil, nil + }) + + if _, err := runClaudePluginStep(context.Background(), time.Second, "claude", "plugin", "install", "hey@37signals"); err != nil { + t.Fatal(err) + } + want := "GIT_CONFIG_GLOBAL=" + os.DevNull + if len(gotEnv) != 1 || gotEnv[0] != want { + t.Errorf("command environment = %v, want %q", gotEnv, want) + } +} + +func TestCommandEnvironmentReplacesExistingValues(t *testing.T) { + got := commandEnvironment( + []string{"PATH=/usr/bin", "GIT_CONFIG_GLOBAL=/home/jane/.gitconfig"}, + []string{"GIT_CONFIG_GLOBAL=" + os.DevNull}, + ) + if strings.Join(got, "|") != "PATH=/usr/bin|GIT_CONFIG_GLOBAL="+os.DevNull { + t.Errorf("environment = %v", got) + } +} + func TestSetupAgentsRemoveUninstallsClaudePlugin(t *testing.T) { isolateAgents(t) home := t.TempDir() From 0612a0313a6fb9aefbaec78e4925510a2daccc81 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 25 Aug 2026 17:40:10 -0400 Subject: [PATCH 05/12] Decline symlinked skill directories on removal Review fixes for hey setup agents --remove and its docs: - removeOwnedSkillFiles now Lstats the directory and declines a symlink or non-directory the way every other skill write path does (claimSkillDir, writeSkillFile), so removal can never delete managed files through a user's symlink target. A real Lstat failure surfaces as an error instead of a silent skip. - Cover the setup_remove_failed failure aggregation: a failing `claude plugin uninstall` and a missing claude binary both name the failing component in the error, and the symlink refusal keeps the target's files intact. - docs/omarchy.md no longer promises a literal "Step 3" now that wizard step numbers are assigned dynamically to the steps that actually run. --- docs/omarchy.md | 3 +- internal/cmd/setup_agents_remove.go | 13 ++++ internal/cmd/setup_agents_test.go | 97 +++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/docs/omarchy.md b/docs/omarchy.md index 53906631..ae24af66 100644 --- a/docs/omarchy.md +++ b/docs/omarchy.md @@ -136,7 +136,8 @@ file, one lock: - **Entry points.** An interactive OAuth sign-in — `hey auth login` / `hey login`, `requireAuth`'s "Sign in now?" (how `hey tui` and every data command sign in), the lite wizard — runs the routine in *ensure* mode and prints at most one stderr line; the full - wizard runs Step 3 in *force* mode without asking; `hey setup omarchy` also runs in + wizard runs its Omarchy step in *force* mode without asking (step numbers are + assigned dynamically to whichever steps actually run); `hey setup omarchy` also runs in *force* mode. The automatic hooks never run for machine output, `HEY_NONINTERACTIVE`, a non-TTY, or `--token`/`--cookie` logins: a script installs with `hey setup omarchy`, which works in diff --git a/internal/cmd/setup_agents_remove.go b/internal/cmd/setup_agents_remove.go index a3e249a3..d84bd1f4 100644 --- a/internal/cmd/setup_agents_remove.go +++ b/internal/cmd/setup_agents_remove.go @@ -80,6 +80,19 @@ func runRemoveAgentSetup(cmd *cobra.Command) error { // directory. Additional files remain in place and make the directory // user-owned once the marker is gone. func removeOwnedSkillFiles(dir string) (bool, error) { + // A symlinked directory is a user's arrangement: its target was never + // inspected by the ownership gate, so removal declines it the way every + // other write path does (claimSkillDir, writeSkillFile). + info, err := os.Lstat(dir) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("inspecting %s: %w", dir, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return false, nil + } if !ownedSkillDir(dir) { return false, nil } diff --git a/internal/cmd/setup_agents_test.go b/internal/cmd/setup_agents_test.go index 213f91f7..12d0dd11 100644 --- a/internal/cmd/setup_agents_test.go +++ b/internal/cmd/setup_agents_test.go @@ -720,6 +720,103 @@ func TestSetupAgentsRemoveUninstallsClaudePlugin(t *testing.T) { } } +func TestSetupAgentsRemoveReportsFailedPluginUninstall(t *testing.T) { + isolateAgents(t) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + pluginsDir := filepath.Join(home, ".claude", "plugins") + if err := os.MkdirAll(pluginsDir, 0o755); err != nil { + t.Fatal(err) + } + registry := `{"version":2,"plugins":{"hey@37signals":[{"version":"1.1.0"}]}}` + if err := os.WriteFile(filepath.Join(pluginsDir, "installed_plugins.json"), []byte(registry), 0o600); err != nil { + t.Fatal(err) + } + binDir := t.TempDir() + if err := os.WriteFile(filepath.Join(binDir, "claude"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", binDir) + stubRunAgentCommand(t, func(_ context.Context, _ string, _ ...string) ([]byte, error) { + return []byte("network unreachable"), errors.New("exit status 1") + }) + + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + _, _, err := runAuthCommand(t, home, server.URL, "", true, "setup", "agents", "--remove") + var apiErr *apierr.Error + if !errors.As(err, &apiErr) { + t.Fatalf("error = %v, want *apierr.Error", err) + } + if apiErr.Code != "setup_remove_failed" { + t.Errorf("code = %q, want setup_remove_failed", apiErr.Code) + } + if !strings.Contains(apiErr.Message, "Claude Code plugin: network unreachable") { + t.Errorf("message = %q, want the failing component and its output named", apiErr.Message) + } +} + +func TestSetupAgentsRemoveReportsMissingClaudeBinary(t *testing.T) { + isolateAgents(t) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + pluginsDir := filepath.Join(home, ".claude", "plugins") + if err := os.MkdirAll(pluginsDir, 0o755); err != nil { + t.Fatal(err) + } + registry := `{"version":2,"plugins":{"hey@37signals":[{"version":"1.1.0"}]}}` + if err := os.WriteFile(filepath.Join(pluginsDir, "installed_plugins.json"), []byte(registry), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", t.TempDir()) + stubRunAgentCommand(t, func(_ context.Context, _ string, _ ...string) ([]byte, error) { + t.Error("no claude command should run when the binary is missing") + return nil, nil + }) + + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + _, _, err := runAuthCommand(t, home, server.URL, "", true, "setup", "agents", "--remove") + var apiErr *apierr.Error + if !errors.As(err, &apiErr) { + t.Fatalf("error = %v, want *apierr.Error", err) + } + if apiErr.Code != "setup_remove_failed" { + t.Errorf("code = %q, want setup_remove_failed", apiErr.Code) + } + if !strings.Contains(apiErr.Message, "Claude Code plugin: claude binary not found") { + t.Errorf("message = %q, want the missing binary named", apiErr.Message) + } +} + +func TestRemoveOwnedSkillFilesDeclinesSymlinkedDirectory(t *testing.T) { + target := t.TempDir() + for _, name := range []string{skillFilename, installedVersionFile, ownershipMarkerFile} { + if err := os.WriteFile(filepath.Join(target, name), []byte("managed"), 0o600); err != nil { + t.Fatal(err) + } + } + link := filepath.Join(t.TempDir(), "hey") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + removed, err := removeOwnedSkillFiles(link) + if err != nil || removed { + t.Fatalf("removeOwnedSkillFiles = %v, %v; want false, nil", removed, err) + } + for _, name := range []string{skillFilename, installedVersionFile, ownershipMarkerFile} { + if _, statErr := os.Stat(filepath.Join(target, name)); statErr != nil { + t.Errorf("symlink target lost %s: %v", name, statErr) + } + } + if _, statErr := os.Lstat(link); statErr != nil { + t.Errorf("symlink itself removed: %v", statErr) + } +} + // Legacy credentials belong to the server recorded beside them. With the // effective base URL pointed elsewhere, migration must neither misfile them // under the wrong store key nor scrub the only copy for their real origin. From 5e97fc6e3cd987ee21775b5b0dabfbe5f2a6bb6b Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 25 Aug 2026 17:17:55 -0500 Subject: [PATCH 06/12] No need for muted colors --- internal/cmd/completion_install.go | 4 ++-- internal/cmd/formatting.go | 3 +-- internal/cmd/setup.go | 28 ++++++++++++++-------------- internal/cmd/setup_agent.go | 14 +++++++------- internal/cmd/setup_test.go | 9 ++++++--- 5 files changed, 30 insertions(+), 28 deletions(-) diff --git a/internal/cmd/completion_install.go b/internal/cmd/completion_install.go index 084d17a9..eb2ec13a 100644 --- a/internal/cmd/completion_install.go +++ b/internal/cmd/completion_install.go @@ -526,7 +526,7 @@ func (c *completionInstallCommand) reportPackaged(cmd *cobra.Command, shell, pat if writer.IsStyled() { w := cmd.OutOrStdout() for _, notice := range notices { - fmt.Fprintln(w, muted.format(notice)) + fmt.Fprintln(w, notice) } } return nil @@ -559,7 +559,7 @@ func (c *completionInstallCommand) report(cmd *cobra.Command, env completionEnv, if writer.IsStyled() { w := cmd.OutOrStdout() for _, notice := range notices { - fmt.Fprintln(w, muted.format(notice)) + fmt.Fprintln(w, notice) } } return nil diff --git a/internal/cmd/formatting.go b/internal/cmd/formatting.go index e5ef2bdb..bb61a8b4 100644 --- a/internal/cmd/formatting.go +++ b/internal/cmd/formatting.go @@ -85,10 +85,9 @@ const ( plain style = "" bold style = "1;34" italic style = "3;94" - italicMuted style = "3;90" + italicPlain style = "3" success style = "32" warning style = "33" - muted style = "90" ) func (s style) format(value string) string { diff --git a/internal/cmd/setup.go b/internal/cmd/setup.go index 8804bf45..6b4d22f3 100644 --- a/internal/cmd/setup.go +++ b/internal/cmd/setup.go @@ -350,12 +350,12 @@ func (s *setupWizard) greet() { if account.Email != "" { label += " (" + terminal.SanitizeLine(account.Email) + ")" } - fmt.Fprintln(w, muted.format("• "+label)) + fmt.Fprintln(w, "• "+label) } if cfg.AccountID == config.AllAccounts { - fmt.Fprintln(w, muted.format("Using All Accounts — hey account use to default to one")) + fmt.Fprintln(w, "Using All Accounts — hey account use to default to one") } else { - fmt.Fprintln(w, muted.format("Default mail account: "+cfg.AccountID)) + fmt.Fprintln(w, "Default mail account: "+cfg.AccountID) } } fmt.Fprintln(w) @@ -417,7 +417,7 @@ func (s *setupWizard) installCompletions() bool { w := s.cmd.OutOrStdout() fmt.Fprintln(w, statusLine(true, "Shell completions installed for "+shell)) if target.Hint != "" { - fmt.Fprintln(w, muted.format(target.Hint)) + fmt.Fprintln(w, target.Hint) } fmt.Fprintln(w) } @@ -461,7 +461,7 @@ func (s *setupWizard) setupAgents() agentSetupOutcome { if s.verbose { fmt.Fprintln(w, "This will:") step := 1 - fmt.Fprintln(w, muted.format(fmt.Sprintf("%d. Install the HEY agent skill to ~/.agents/skills/hey/", step))) + fmt.Fprintf(w, "%d. Install the HEY agent skill to ~/.agents/skills/hey/\n", step) step++ for _, a := range agents { handler, ok := agentSetupHandlers[a.ID] @@ -469,7 +469,7 @@ func (s *setupWizard) setupAgents() agentSetupOutcome { continue } for _, label := range handler.Labels { - fmt.Fprintln(w, muted.format(fmt.Sprintf("%d. %s", step, label))) + fmt.Fprintf(w, "%d. %s\n", step, label) step++ } } @@ -612,7 +612,7 @@ func (s *setupWizard) setupOmarchy(signedIn bool) { } if s.verbose { fmt.Fprintln(w) - fmt.Fprintln(w, muted.format(strings.TrimRight(omarchyKeybindHint, "\n"))) + fmt.Fprintln(w, strings.TrimRight(omarchyKeybindHint, "\n")) } fmt.Fprintln(w) } @@ -666,16 +666,16 @@ func showWizardSuccess(w io.Writer, result wizardResult, outcome agentSetupOutco if verbose { fmt.Fprintln(w, statusLine(!hasAuthIssue(result.Issues), "Signed in")) if result.AgentsSkipped { - fmt.Fprintln(w, muted.format("Coding agent setup skipped")) + fmt.Fprintln(w, "Coding agent setup skipped") } else if outcome.Skipped { - fmt.Fprintln(w, muted.format("Coding agent setup skipped — run: hey setup")) + fmt.Fprintln(w, "Coding agent setup skipped — run: hey setup") } else { for _, check := range outcome.Checks { fmt.Fprintln(w, statusLine(check.Status == "pass", check.Name)) } } if result.OmarchySkipped { - fmt.Fprintln(w, muted.format("Omarchy setup skipped")) + fmt.Fprintln(w, "Omarchy setup skipped") } else if result.Omarchy != nil { ok := true for _, step := range result.Omarchy.Steps { @@ -691,14 +691,14 @@ func showWizardSuccess(w io.Writer, result wizardResult, outcome agentSetupOutco if bar.Detail != "" { barLine += " — " + bar.Detail } - fmt.Fprintln(w, muted.format("Bar plugin: "+barLine)) + fmt.Fprintln(w, "Bar plugin: "+barLine) desktop := "launcher entry, menu row and theme template in place" for _, step := range result.Omarchy.Steps { if step.Name != "bar plugin" && step.Status == "failed" { desktop = step.Name + " failed: " + step.Detail } } - fmt.Fprintln(w, muted.format("Desktop: "+desktop)) + fmt.Fprintln(w, "Desktop: "+desktop) } fmt.Fprintln(w) } @@ -722,7 +722,7 @@ func showWizardSuccess(w io.Writer, result wizardResult, outcome agentSetupOutco width = max(width, len(ex.cmd)) } for _, ex := range examples { - fmt.Fprintf(w, "%s%s %s\n", muted.format(ex.cmd), strings.Repeat(" ", width-len(ex.cmd)), italicMuted.format(ex.desc)) + fmt.Fprintf(w, "%s%s %s\n", ex.cmd, strings.Repeat(" ", width-len(ex.cmd)), italicPlain.format(ex.desc)) } fmt.Fprintln(w) } @@ -745,7 +745,7 @@ func printWizardIssues(w io.Writer, issues []agentIssue) { } fmt.Fprintln(w, warning.format(line)) } - fmt.Fprintln(w, muted.format("Then verify with: hey doctor")) + fmt.Fprintln(w, "Then verify with: hey doctor") fmt.Fprintln(w) } diff --git a/internal/cmd/setup_agent.go b/internal/cmd/setup_agent.go index 21e9dd34..1850b9c1 100644 --- a/internal/cmd/setup_agent.go +++ b/internal/cmd/setup_agent.go @@ -182,7 +182,7 @@ func runSetupAgent(cmd *cobra.Command, agent harness.AgentInfo, handler agentSet // handler's return value: never tell the user to start a session // against an integration that is not connected. if skillErr == nil && agent.Detect != nil && agent.Detect() && agentChecksPass(agent) { - fmt.Fprintln(w, muted.format("Start a new "+agent.Name+" session to use HEY commands.")) + fmt.Fprintln(w, "Start a new "+agent.Name+" session to use HEY commands.") return nil } return &apierr.Error{ @@ -265,27 +265,27 @@ func runSetupAgent(cmd *cobra.Command, agent harness.AgentInfo, handler agentSet func runClaudeSetup(cmd *cobra.Command) error { w := cmd.OutOrStdout() progress := func(message string) { - fmt.Fprintln(w, muted.format(message)) + fmt.Fprintln(w, message) } if err := installClaudePlugin(cmd.Context(), progress); err != nil { fmt.Fprintln(w, warning.format("Claude Code setup failed: "+err.Error())) var setupErr *agentSetupError if errors.As(err, &setupErr) && len(setupErr.Manual) > 0 { - fmt.Fprintln(w, muted.format("Try manually:")) + fmt.Fprintln(w, "Try manually:") for _, manual := range setupErr.Manual { fmt.Fprintln(w, bold.format(manual)) } } - fmt.Fprintln(w, muted.format("Then verify with: hey doctor")) + fmt.Fprintln(w, "Then verify with: hey doctor") return nil } fmt.Fprintln(w, statusLine(true, "Claude Code plugin installed")) fmt.Fprintln(w, statusLine(true, "Claude Code skill linked")) fmt.Fprintln(w) - fmt.Fprintln(w, muted.format("Tip: enable auto-update to stay current with new CLI releases:")) - fmt.Fprintln(w, muted.format(harness.AutoUpdateHint)) + fmt.Fprintln(w, "Tip: enable auto-update to stay current with new CLI releases:") + fmt.Fprintln(w, harness.AutoUpdateHint) return nil } @@ -391,7 +391,7 @@ func runCodexSetup(cmd *cobra.Command) error { path, err := installCodexSkill() if err != nil { fmt.Fprintln(w, warning.format("Codex skill install failed: "+err.Error())) - fmt.Fprintln(w, muted.format("Then verify with: hey doctor")) + fmt.Fprintln(w, "Then verify with: hey doctor") return nil //nolint:nilerr // warn and continue; the post-setup snapshot reports the failure } fmt.Fprintln(w, statusLine(true, "Codex skill installed ("+path+")")) diff --git a/internal/cmd/setup_test.go b/internal/cmd/setup_test.go index 9cc48385..bad02e74 100644 --- a/internal/cmd/setup_test.go +++ b/internal/cmd/setup_test.go @@ -488,13 +488,16 @@ func TestShowWizardSuccessConciseHidesChecklist(t *testing.T) { colorDisabled = false out.Reset() showWizardSuccess(&out, result, outcome, false, 1) - if !strings.Contains(out.String(), muted.format("hey hey")) { - t.Errorf("summary command is not muted:\n%s", out.String()) + if strings.Contains(out.String(), "\033[90m") || strings.Contains(out.String(), "\033[2m") { + t.Errorf("summary text is dimmed:\n%s", out.String()) + } + if !strings.Contains(out.String(), "\nhey hey ") { + t.Errorf("summary command is not in the default color:\n%s", out.String()) } if strings.Contains(out.String(), bold.format("hey hey")) { t.Errorf("summary command competes with the step style:\n%s", out.String()) } - if !strings.Contains(out.String(), italicMuted.format("Open TUI")) { + if !strings.Contains(out.String(), italicPlain.format("Open TUI")) { t.Errorf("summary hint is not differentiated from its command:\n%s", out.String()) } } From 62536955138d6bdf17c4d755164ae3987ef20071 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 25 Aug 2026 18:33:23 -0400 Subject: [PATCH 07/12] Simplify setup welcome copy --- AGENTS.md | 4 ++-- README.md | 4 ++-- internal/cmd/setup.go | 27 ++++----------------------- internal/cmd/setup_test.go | 30 ++++++++++++++++++++++++++++++ 4 files changed, 38 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4717608e..ca3da7d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,8 +52,8 @@ login`, `hey auth logout`, `hey auth status`), the `hey login`/`hey logout` shor `hey setup` and `hey doctor` work without authentication. Bare `hey` at an interactive terminal runs the setup wizard when logged out -(`runSetupWizard` in `internal/cmd/setup.go` — welcome, OAuth sign-in, linked-account -greeting, coding-agent setup, summary) and stops at the summary; `hey setup --skip-agents` +(`runSetupWizard` in `internal/cmd/setup.go` — welcome, OAuth sign-in, signed-in identity, +coding-agent setup, summary) and stops at the summary; `hey setup --skip-agents` and `--skip-omarchy` leave those integrations unchanged. In every other case bare `hey` prints help. The TUI lives at `hey tui` (plus the hidden `hey hey`). `config.json`'s `onboarded` flag only trims a later logged-out run to the sign-in step. `HEY_NONINTERACTIVE=1` diff --git a/README.md b/README.md index c1ce8fc1..191dc0a8 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ hey ``` The first time you run `hey` at a terminal it walks you through setup: it signs you in -(browser-based OAuth), shows the mail accounts linked to your HEY identity, and connects -your detected coding agents (Claude Code, Codex). Run `hey setup --skip-agents` to leave +with browser-based OAuth, confirms your identity, and connects your detected coding agents +(Claude Code, Codex). Run `hey setup --skip-agents` to leave agent integrations unchanged, or add `--skip-omarchy` to leave Omarchy unchanged. `hey setup --silent-success` keeps required authentication visible, shows an installation spinner, and ends with `SETUP COMPLETE`; failure guidance remains visible. After that, diff --git a/internal/cmd/setup.go b/internal/cmd/setup.go index 6b4d22f3..0ded0c80 100644 --- a/internal/cmd/setup.go +++ b/internal/cmd/setup.go @@ -223,10 +223,8 @@ func (s *setupWizard) run() error { func (s *setupWizard) welcome(w io.Writer) { fmt.Fprintln(w, tui.RenderWordmark(!colorDisabled)) fmt.Fprintln(w) - fmt.Fprintln(w, bold.format("Welcome to HEY")) - fmt.Fprintln(w) - fmt.Fprintf(w, "The command-line interface for HEY (v%s).\n", version.Version) - fmt.Fprintln(w, "Let's get you set up. This will only take a moment.") + fmt.Fprintln(w, bold.format("Welcome to the command-line interface for HEY.")) + fmt.Fprintln(w, "Let's get you set up. It'll only take a moment.") fmt.Fprintln(w) } @@ -300,9 +298,8 @@ var loginInteractively = func(out io.Writer) error { return selectConfiguredAccount(context.Background()) } -// greet looks up who signed in and shows the linked accounts. Read-only: -// HEY is natively multi-account and "all" is the default, so nothing is -// chosen or persisted here. +// greet records the signed-in identity and linked accounts, then confirms +// the identity in styled output. Nothing is chosen or persisted here. func (s *setupWizard) greet() { w := s.cmd.OutOrStdout() @@ -342,22 +339,6 @@ func (s *setupWizard) greet() { return } fmt.Fprintln(w, success.format(identityGreeting(identity))) - // accounts[0] is the "All Accounts" filter; a single linked account - // needs no list. - if len(s.result.Accounts) > 2 { - for _, account := range s.result.Accounts[1:] { - label := terminal.SanitizeLine(account.Name) - if account.Email != "" { - label += " (" + terminal.SanitizeLine(account.Email) + ")" - } - fmt.Fprintln(w, "• "+label) - } - if cfg.AccountID == config.AllAccounts { - fmt.Fprintln(w, "Using All Accounts — hey account use to default to one") - } else { - fmt.Fprintln(w, "Default mail account: "+cfg.AccountID) - } - } fmt.Fprintln(w) } diff --git a/internal/cmd/setup_test.go b/internal/cmd/setup_test.go index bad02e74..8bea1d62 100644 --- a/internal/cmd/setup_test.go +++ b/internal/cmd/setup_test.go @@ -95,6 +95,28 @@ func TestSetupCommandRegistersAgentSubcommands(t *testing.T) { } } +func TestSetupWelcomeCopy(t *testing.T) { + origColor := colorDisabled + colorDisabled = true + t.Cleanup(func() { colorDisabled = origColor }) + var out bytes.Buffer + wizard := setupWizard{} + wizard.welcome(&out) + for _, want := range []string{ + "Welcome to the command-line interface for HEY.", + "Let's get you set up. It'll only take a moment.", + } { + if !strings.Contains(out.String(), want) { + t.Errorf("welcome missing %q:\n%s", want, out.String()) + } + } + for _, removed := range []string{"Welcome to HEY", "This will only take a moment", "The command-line interface for HEY (v"} { + if strings.Contains(out.String(), removed) { + t.Errorf("welcome retained %q:\n%s", removed, out.String()) + } + } +} + func TestSetupSkipAgentsLeavesAgentIntegrationsUnchanged(t *testing.T) { isolateAgents(t) server := identityServer(t) @@ -673,6 +695,14 @@ func TestSetupStyledInteractiveInstallsDetectedAgentsWithoutPrompt(t *testing.T) if strings.Contains(stdout, "Set up HEY for your coding agents?") { t.Errorf("setup asked for agent confirmation:\n%s", stdout) } + if !strings.Contains(stdout, "Signed in as Jane Doe (jane@example.com)") { + t.Errorf("setup omitted the signed-in identity:\n%s", stdout) + } + for _, hidden := range []string{"• Personal", "• Work", "Using All Accounts", "Default mail account"} { + if strings.Contains(stdout, hidden) { + t.Errorf("setup listed account detail %q:\n%s", hidden, stdout) + } + } if _, err := os.Stat(filepath.Join(configHome, ".agents", "skills", "hey", "SKILL.md")); err != nil { t.Errorf("setup did not install the detected agent skill: %v", err) } From 8abdbe3a8b243c9b4a258230b698500a83d1f365 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 25 Aug 2026 18:36:10 -0400 Subject: [PATCH 08/12] Restore the setup welcome heading --- internal/cmd/setup.go | 4 +++- internal/cmd/setup_test.go | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/cmd/setup.go b/internal/cmd/setup.go index 0ded0c80..b98f006f 100644 --- a/internal/cmd/setup.go +++ b/internal/cmd/setup.go @@ -223,7 +223,9 @@ func (s *setupWizard) run() error { func (s *setupWizard) welcome(w io.Writer) { fmt.Fprintln(w, tui.RenderWordmark(!colorDisabled)) fmt.Fprintln(w) - fmt.Fprintln(w, bold.format("Welcome to the command-line interface for HEY.")) + fmt.Fprintln(w, bold.format("Welcome to HEY")) + fmt.Fprintln(w) + fmt.Fprintln(w, "Welcome to the command-line interface for HEY.") fmt.Fprintln(w, "Let's get you set up. It'll only take a moment.") fmt.Fprintln(w) } diff --git a/internal/cmd/setup_test.go b/internal/cmd/setup_test.go index 8bea1d62..21aa6d85 100644 --- a/internal/cmd/setup_test.go +++ b/internal/cmd/setup_test.go @@ -103,14 +103,14 @@ func TestSetupWelcomeCopy(t *testing.T) { wizard := setupWizard{} wizard.welcome(&out) for _, want := range []string{ - "Welcome to the command-line interface for HEY.", + "Welcome to HEY\n\nWelcome to the command-line interface for HEY.", "Let's get you set up. It'll only take a moment.", } { if !strings.Contains(out.String(), want) { t.Errorf("welcome missing %q:\n%s", want, out.String()) } } - for _, removed := range []string{"Welcome to HEY", "This will only take a moment", "The command-line interface for HEY (v"} { + for _, removed := range []string{"This will only take a moment", "The command-line interface for HEY (v"} { if strings.Contains(out.String(), removed) { t.Errorf("welcome retained %q:\n%s", removed, out.String()) } From 5350f02f1ce15cc4a422f39bc7d81d5d01ddc92b Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 25 Aug 2026 18:39:11 -0400 Subject: [PATCH 09/12] Refine the setup welcome copy --- internal/cmd/setup.go | 3 +-- internal/cmd/setup_test.go | 10 +++------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/internal/cmd/setup.go b/internal/cmd/setup.go index b98f006f..595b0eb5 100644 --- a/internal/cmd/setup.go +++ b/internal/cmd/setup.go @@ -223,9 +223,8 @@ func (s *setupWizard) run() error { func (s *setupWizard) welcome(w io.Writer) { fmt.Fprintln(w, tui.RenderWordmark(!colorDisabled)) fmt.Fprintln(w) - fmt.Fprintln(w, bold.format("Welcome to HEY")) + fmt.Fprintln(w, bold.format("Welcome to the command-line interface for HEY.")) fmt.Fprintln(w) - fmt.Fprintln(w, "Welcome to the command-line interface for HEY.") fmt.Fprintln(w, "Let's get you set up. It'll only take a moment.") fmt.Fprintln(w) } diff --git a/internal/cmd/setup_test.go b/internal/cmd/setup_test.go index 21aa6d85..a059c15c 100644 --- a/internal/cmd/setup_test.go +++ b/internal/cmd/setup_test.go @@ -102,13 +102,9 @@ func TestSetupWelcomeCopy(t *testing.T) { var out bytes.Buffer wizard := setupWizard{} wizard.welcome(&out) - for _, want := range []string{ - "Welcome to HEY\n\nWelcome to the command-line interface for HEY.", - "Let's get you set up. It'll only take a moment.", - } { - if !strings.Contains(out.String(), want) { - t.Errorf("welcome missing %q:\n%s", want, out.String()) - } + want := "Welcome to the command-line interface for HEY.\n\nLet's get you set up. It'll only take a moment." + if !strings.Contains(out.String(), want) { + t.Errorf("welcome missing %q:\n%s", want, out.String()) } for _, removed := range []string{"This will only take a moment", "The command-line interface for HEY (v"} { if strings.Contains(out.String(), removed) { From cc480f2e9ff87e40fa44060f2651070abb161bc1 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 25 Aug 2026 18:47:36 -0400 Subject: [PATCH 10/12] Polish the setup greeting --- internal/cmd/setup.go | 4 ++-- internal/cmd/setup_test.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/cmd/setup.go b/internal/cmd/setup.go index 595b0eb5..42df0e21 100644 --- a/internal/cmd/setup.go +++ b/internal/cmd/setup.go @@ -223,9 +223,9 @@ func (s *setupWizard) run() error { func (s *setupWizard) welcome(w io.Writer) { fmt.Fprintln(w, tui.RenderWordmark(!colorDisabled)) fmt.Fprintln(w) - fmt.Fprintln(w, bold.format("Welcome to the command-line interface for HEY.")) + fmt.Fprintln(w, bold.format("HEY! It's a command-line interface!")) fmt.Fprintln(w) - fmt.Fprintln(w, "Let's get you set up. It'll only take a moment.") + fmt.Fprintln(w, "Let's get you set up. It'll only take a moment") fmt.Fprintln(w) } diff --git a/internal/cmd/setup_test.go b/internal/cmd/setup_test.go index a059c15c..1394267b 100644 --- a/internal/cmd/setup_test.go +++ b/internal/cmd/setup_test.go @@ -102,11 +102,11 @@ func TestSetupWelcomeCopy(t *testing.T) { var out bytes.Buffer wizard := setupWizard{} wizard.welcome(&out) - want := "Welcome to the command-line interface for HEY.\n\nLet's get you set up. It'll only take a moment." + want := "HEY! It's a command-line interface!\n\nLet's get you set up. It'll only take a moment" if !strings.Contains(out.String(), want) { t.Errorf("welcome missing %q:\n%s", want, out.String()) } - for _, removed := range []string{"This will only take a moment", "The command-line interface for HEY (v"} { + for _, removed := range []string{"This will only take a moment", "The command-line interface for HEY (v", "take a moment."} { if strings.Contains(out.String(), removed) { t.Errorf("welcome retained %q:\n%s", removed, out.String()) } From 3ea6a7c57458b671e1a66642c75a2941fafb9d7b Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 25 Aug 2026 19:07:03 -0400 Subject: [PATCH 11/12] Correct the setup greeting --- internal/cmd/setup.go | 2 +- internal/cmd/setup_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cmd/setup.go b/internal/cmd/setup.go index 42df0e21..f21fa311 100644 --- a/internal/cmd/setup.go +++ b/internal/cmd/setup.go @@ -223,7 +223,7 @@ func (s *setupWizard) run() error { func (s *setupWizard) welcome(w io.Writer) { fmt.Fprintln(w, tui.RenderWordmark(!colorDisabled)) fmt.Fprintln(w) - fmt.Fprintln(w, bold.format("HEY! It's a command-line interface!")) + fmt.Fprintln(w, bold.format("HEY! It's the command-line interface!")) fmt.Fprintln(w) fmt.Fprintln(w, "Let's get you set up. It'll only take a moment") fmt.Fprintln(w) diff --git a/internal/cmd/setup_test.go b/internal/cmd/setup_test.go index 1394267b..510e1291 100644 --- a/internal/cmd/setup_test.go +++ b/internal/cmd/setup_test.go @@ -102,7 +102,7 @@ func TestSetupWelcomeCopy(t *testing.T) { var out bytes.Buffer wizard := setupWizard{} wizard.welcome(&out) - want := "HEY! It's a command-line interface!\n\nLet's get you set up. It'll only take a moment" + want := "HEY! It's the command-line interface!\n\nLet's get you set up. It'll only take a moment" if !strings.Contains(out.String(), want) { t.Errorf("welcome missing %q:\n%s", want, out.String()) } From b9790e65163bca82c16f52c0dcb68059384db937 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 25 Aug 2026 19:21:03 -0400 Subject: [PATCH 12/12] Force Claude plugin clones onto HTTPS --- internal/cmd/setup_agent.go | 15 ++++++++++++--- internal/cmd/setup_agents_test.go | 15 +++++++++++---- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/internal/cmd/setup_agent.go b/internal/cmd/setup_agent.go index 1850b9c1..c7d4cd57 100644 --- a/internal/cmd/setup_agent.go +++ b/internal/cmd/setup_agent.go @@ -349,10 +349,19 @@ func runClaudeStep(parent context.Context, timeout time.Duration, path string, a } // runClaudePluginStep clones the public Claude plugin repositories over -// HTTPS independently of global Git URL rewrites. Plugin setup therefore -// works before the user configures GitHub SSH keys or host verification. +// HTTPS independently of global Git URL rewrites. Claude accepts HTTPS +// marketplace sources and also emits SSH-shaped GitHub URLs for plugins, so +// this command-scoped Git configuration maps both forms onto HTTPS. func runClaudePluginStep(parent context.Context, timeout time.Duration, path string, args ...string) ([]byte, error) { - ctx := context.WithValue(parent, agentCommandEnvironmentKey{}, []string{"GIT_CONFIG_GLOBAL=" + os.DevNull}) + env := []string{ + "GIT_CONFIG_GLOBAL=" + os.DevNull, + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=url.https://github.com/.insteadOf", + "GIT_CONFIG_VALUE_0=git@github.com:", + "GIT_CONFIG_KEY_1=url.https://github.com/.insteadOf", + "GIT_CONFIG_VALUE_1=ssh://git@github.com/", + } + ctx := context.WithValue(parent, agentCommandEnvironmentKey{}, env) return runClaudeStep(ctx, timeout, path, args...) } diff --git a/internal/cmd/setup_agents_test.go b/internal/cmd/setup_agents_test.go index 12d0dd11..0f0125ae 100644 --- a/internal/cmd/setup_agents_test.go +++ b/internal/cmd/setup_agents_test.go @@ -657,7 +657,7 @@ func TestSetupAgentsRemovePreservesUnmanagedSkills(t *testing.T) { } } -func TestClaudePluginStepsIgnoreGlobalGitURLRewrites(t *testing.T) { +func TestClaudePluginStepsForceGitHubHTTPS(t *testing.T) { isolateAgents(t) var gotEnv []string stubRunAgentCommand(t, func(ctx context.Context, _ string, _ ...string) ([]byte, error) { @@ -668,9 +668,16 @@ func TestClaudePluginStepsIgnoreGlobalGitURLRewrites(t *testing.T) { if _, err := runClaudePluginStep(context.Background(), time.Second, "claude", "plugin", "install", "hey@37signals"); err != nil { t.Fatal(err) } - want := "GIT_CONFIG_GLOBAL=" + os.DevNull - if len(gotEnv) != 1 || gotEnv[0] != want { - t.Errorf("command environment = %v, want %q", gotEnv, want) + want := []string{ + "GIT_CONFIG_GLOBAL=" + os.DevNull, + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=url.https://github.com/.insteadOf", + "GIT_CONFIG_VALUE_0=git@github.com:", + "GIT_CONFIG_KEY_1=url.https://github.com/.insteadOf", + "GIT_CONFIG_VALUE_1=ssh://git@github.com/", + } + if strings.Join(gotEnv, "|") != strings.Join(want, "|") { + t.Errorf("command environment = %v, want %v", gotEnv, want) } }