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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ hey setup agents
hey setup agents --remove
hey setup claude
hey setup codex
hey setup grok
hey setup omarchy
hey setup omarchy --no-notify
hey setup omarchy --notify
Expand Down
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,10 @@ and fails loudly (`setup_failed`) on any incomplete outcome. `--remove` writes i
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
Coding-agent integration lives in `internal/harness` (agent registry, Claude Code / Codex / Grok
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`
claude|codex|grok|agents`). Claude Code gets the `hey@37signals` plugin from `basecamp/claude-plugins`
plus a skill link; Codex and Grok get the skill only until a `.codex-plugin` / `.grok-plugin` ships. `HEY_SETUP_AGENT`
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
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ hey

The first time you run `hey` at a terminal it walks you through setup: it signs you in
with browser-based OAuth, confirms your identity, and connects your detected coding agents
(Claude Code, Codex). Run `hey setup --skip-agents` to leave
(Claude Code, Codex, Grok). 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,
Expand Down Expand Up @@ -733,14 +733,15 @@ 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 setup grok # install the skill for Grok
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 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 <agent>` choices. `HEY_SETUP_AGENT=claude|codex|all|none`
the skill only and lists the `hey setup <agent>` choices. `HEY_SETUP_AGENT=claude|codex|grok|all|none`
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.
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/help_topics.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ INTERACTION & DIAGNOSTICS
TUI & SETUP
HEY_THEME Load a TUI theme overlay from a TOML file.
HEY_CABLE_URL Override the Action Cable websocket URL.
HEY_SETUP_AGENT Select claude, codex, all, or none during agent setup.
HEY_SETUP_AGENT Select claude, codex, grok, all, or none during agent setup.

Command-line flags take precedence over environment values.`,
},
Expand Down
44 changes: 43 additions & 1 deletion internal/cmd/setup_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,16 @@ var agentSetupHandlers = map[string]agentSetupHandler{
Run: runCodexSetup,
RunNonInteractive: runCodexSetupNonInteractive,
},
"grok": {
Labels: []string{
"Copy the HEY skill into Grok's skills directory",
},
Run: runGrokSetup,
RunNonInteractive: runGrokSetupNonInteractive,
},
}

// runAgentCommand is the subprocess seam for agent CLIs (claude, codex) so
// runAgentCommand is the subprocess seam for agent CLIs (claude, codex, grok) so
// tests never spawn a real one. Output is captured, not streamed: the wizard
// 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) {
Expand Down Expand Up @@ -426,6 +433,41 @@ func installCodexSkill() (string, error) {
return installSkillToCodex()
}

// --- Grok ---

// runGrokSetup copies the skill for Grok in the interactive wizard.
// hey has no Grok plugin yet, so the skill is the whole integration.
func runGrokSetup(cmd *cobra.Command) error {
w := cmd.OutOrStdout()
path, err := installGrokSkill()
if err != nil {
fmt.Fprintln(w, warning.format("Grok skill install failed: "+err.Error()))
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, "Grok skill installed ("+path+")"))
return nil
}

func runGrokSetupNonInteractive(*cobra.Command) error {
_, err := installGrokSkill()
return err
}

// installGrokSkill is the Grok handler's one step. Like Claude, it never
// fabricates the agent: creating ~/.grok on a machine without Grok would
// make every later detection — and this command's own verdict — report it
// installed.
func installGrokSkill() (string, error) {
if !harness.DetectGrok() {
return "", &agentSetupError{
Summary: "Grok not detected — install Grok, then run: hey setup grok",
Manual: []string{"hey setup grok"},
}
}
return installSkillToGrok()
}

// --- Shared helpers ---

// statusLine renders a ✓/✗ checklist line.
Expand Down
12 changes: 7 additions & 5 deletions internal/cmd/setup_agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
)

// agentSetupEnv selects which coding agents `setup agents` targets.
// Values: claude | codex | all | none. Empty (unset) means auto-detect.
// Values: claude | codex | grok | all | none. Empty (unset) means auto-detect.
const agentSetupEnv = "HEY_SETUP_AGENT"

// newSetupAgentsCommand builds `hey setup agents`. It always runs
Expand All @@ -28,15 +28,15 @@ func newSetupAgentsCommand() *cobra.Command {
Use: "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" +
"Selection is controlled by " + agentSetupEnv + ": claude, codex, grok, 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 <id>` 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 <id>`). 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. --remove uninstalls HEY's managed agent integrations.",
"agent_notes": "Never prompts. Set " + agentSetupEnv + "=claude|codex|grok|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 {
Expand Down Expand Up @@ -102,13 +102,13 @@ func runNonInteractiveAgentSetup(cmd *cobra.Command) error {
targets = harness.AllAgents()
case "none":
// baseline skill only
case "claude", "codex":
case "claude", "codex", "grok":
if a := harness.FindAgent(selector); a != nil {
targets = []harness.AgentInfo{*a}
}
default:
selector = "invalid"
warnings = append(warnings, fmt.Sprintf("Unknown %s value %q; installed the baseline skill only (expected claude, codex, all, or none)", agentSetupEnv, selectorRaw))
warnings = append(warnings, fmt.Sprintf("Unknown %s value %q; installed the baseline skill only (expected claude, codex, grok, all, or none)", agentSetupEnv, selectorRaw))
}

// Run handlers in id order so aggregation is deterministic.
Expand Down Expand Up @@ -243,6 +243,8 @@ func agentBinaryPresent(id string) bool {
return harness.FindClaudeBinary() != ""
case "codex":
return harness.FindCodexBinary() != ""
case "grok":
return harness.FindGrokBinary() != ""
default:
return true
}
Expand Down
8 changes: 8 additions & 0 deletions internal/cmd/setup_agents_remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ func runRemoveAgentSetup(cmd *cobra.Command) error {
}
}

if grokSkill := harness.GrokSkillPath(); grokSkill != "" {
if didRemove, removeErr := removeOwnedSkillFiles(filepath.Dir(grokSkill)); removeErr != nil {
failures = append(failures, "Grok skill: "+removeErr.Error())
} else if didRemove {
removed = append(removed, "Grok skill")
}
}

baseline := filepath.Join(home, ".agents", "skills", "hey")
if didRemove, removeErr := removeOwnedSkillFiles(baseline); removeErr != nil {
failures = append(failures, "agent skill: "+removeErr.Error())
Expand Down
85 changes: 79 additions & 6 deletions internal/cmd/setup_agents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,23 @@ func TestSetupAgentsSingleDetectedAgentIsConnected(t *testing.T) {
}
}

func TestSetupAgentsSingleDetectedGrokIsConnected(t *testing.T) {
data, response := runSetupAgents(t, "", ".grok")
if got := stringList(t, data["attempted_agents"]); len(got) != 1 || got[0] != "grok" {
t.Errorf("attempted = %v", got)
}
if got := stringList(t, data["errors"]); len(got) != 0 {
t.Errorf("errors = %v", got)
}
agents := data["agents"].([]any)
if len(agents) != 1 || agents[0].(map[string]any)["plugin_installed"] != true {
t.Errorf("agents = %v", agents)
}
if response.Summary != "Installed baseline skill; connected Grok" {
t.Errorf("summary = %q", response.Summary)
}
}

func TestSetupAgentsAmbiguousDetectionNeverGuesses(t *testing.T) {
data, response := runSetupAgents(t, "", ".claude", ".codex")
if data["ambiguous"] != true {
Expand All @@ -120,24 +137,34 @@ func TestSetupAgentsAmbiguousDetectionNeverGuesses(t *testing.T) {

func TestSetupAgentsAllAttemptsEveryAgent(t *testing.T) {
data, response := runSetupAgents(t, "all", ".claude", ".codex")
if got := stringList(t, data["attempted_agents"]); len(got) != 2 || got[0] != "claude" || got[1] != "codex" {
if got := stringList(t, data["attempted_agents"]); len(got) != 3 || got[0] != "claude" || got[1] != "codex" || got[2] != "grok" {
t.Errorf("attempted = %v", got)
}
// Claude cannot be connected without its binary: an error, a warning and
// manual remediation, never a silent success.
// manual remediation, never a silent success. Grok is not detected here
// (no ~/.grok), so its handler also fails closed.
errs := stringList(t, data["errors"])
if len(errs) == 0 || !strings.HasPrefix(errs[0], "claude: ") {
if len(errs) < 2 || !strings.HasPrefix(errs[0], "claude: ") {
t.Errorf("errors = %v", errs)
}
var sawGrok bool
for _, e := range errs {
if strings.HasPrefix(e, "grok: ") {
sawGrok = true
}
}
if !sawGrok {
t.Errorf("errors = %v, want a grok: failure", errs)
}
warnings := stringList(t, data["warnings"])
if len(warnings) == 0 || !strings.Contains(warnings[0], "Claude Code binary not found") {
t.Errorf("warnings = %v", warnings)
}
manual := stringList(t, data["manual_commands"])
if !contains(manual, "claude plugin install hey@37signals") || !contains(manual, "hey setup claude") {
if !contains(manual, "claude plugin install hey@37signals") || !contains(manual, "hey setup claude") || !contains(manual, "hey setup grok") {
t.Errorf("manual_commands = %v", manual)
}
if response.Summary != "Installed baseline skill; attempted Claude Code and Codex" {
if response.Summary != "Installed baseline skill; attempted Claude Code, Codex, and Grok" {
t.Errorf("summary = %q", response.Summary)
}
}
Expand All @@ -160,6 +187,14 @@ func TestSetupAgentsExplicitSelectorTargetsThatAgent(t *testing.T) {
if got := stringList(t, data["attempted_agents"]); len(got) != 1 || got[0] != "codex" {
t.Errorf("attempted = %v", got)
}

data, _ = runSetupAgents(t, "Grok", ".claude", ".grok")
if data["selector"] != "grok" {
t.Errorf("selector = %v", data["selector"])
}
if got := stringList(t, data["attempted_agents"]); len(got) != 1 || got[0] != "grok" {
t.Errorf("attempted = %v", got)
}
}

func TestSetupAgentsInvalidSelectorWarns(t *testing.T) {
Expand Down Expand Up @@ -200,6 +235,21 @@ func TestSetupAgentCommandEnvelope(t *testing.T) {
t.Errorf("summary = %q", response.Summary)
}

if err := os.MkdirAll(filepath.Join(home, ".grok"), 0o755); err != nil {
t.Fatal(err)
}
_, response, err = runAuthCommand(t, home, server.URL, "", true, "setup", "grok")
if err != nil {
t.Fatalf("setup grok: %v", err)
}
data = response.Data.(map[string]any)
if data["agent_detected"] != true || data["plugin_installed"] != true {
t.Errorf("grok data = %v", data)
}
if response.Summary != "Grok connected" {
t.Errorf("summary = %q", response.Summary)
}

// An explicitly requested integration that is not detected is a failed
// command: error envelope, nonzero exit.
_, _, err = runAuthCommand(t, home, server.URL, "", true, "setup", "claude")
Expand Down Expand Up @@ -281,6 +331,24 @@ func TestSetupCodexDoesNotFabricateCodex(t *testing.T) {
}
}

// `hey setup grok` on a machine without Grok must not create ~/.grok and
// then count its own creation as detection.
func TestSetupGrokDoesNotFabricateGrok(t *testing.T) {
isolateAgents(t)
home := t.TempDir()
server := httptest.NewServer(http.NotFoundHandler())
defer server.Close()

_, _, err := runAuthCommand(t, home, server.URL, "", true, "setup", "grok")
var cliErr *apierr.Error
if !errors.As(err, &cliErr) || cliErr.Code != "setup_incomplete" || cliErr.Message != "Grok not detected" {
t.Fatalf("error = %v, want setup_incomplete/Grok not detected", err)
}
if _, err := os.Stat(filepath.Join(home, ".grok")); !os.IsNotExist(err) {
t.Error("~/.grok was fabricated")
}
}

// A styled `hey setup <agent>` that did not connect must say so and exit
// nonzero — never "start a new session" over a failed integration.
func TestSetupAgentStyledReportsNotConnected(t *testing.T) {
Expand Down Expand Up @@ -582,7 +650,7 @@ func TestSetupAgentsRemoveDeletesManagedSkillsAndPreservesUserFiles(t *testing.T
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
for _, dir := range []string{".claude", ".codex"} {
for _, dir := range []string{".claude", ".codex", ".grok"} {
if err := os.MkdirAll(filepath.Join(home, dir), 0o755); err != nil {
t.Fatal(err)
}
Expand All @@ -596,6 +664,9 @@ func TestSetupAgentsRemoveDeletesManagedSkillsAndPreservesUserFiles(t *testing.T
if _, err := installSkillToCodex(); err != nil {
t.Fatal(err)
}
if _, err := installSkillToGrok(); 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)
Expand All @@ -613,6 +684,7 @@ func TestSetupAgentsRemoveDeletesManagedSkillsAndPreservesUserFiles(t *testing.T
for _, path := range []string{
filepath.Join(home, ".claude", "skills", "hey"),
filepath.Join(home, ".codex", "skills", "hey"),
filepath.Join(home, ".grok", "skills", "hey"),
filepath.Join(baseline, skillFilename),
filepath.Join(baseline, ownershipMarkerFile),
} {
Expand All @@ -634,6 +706,7 @@ func TestSetupAgentsRemovePreservesUnmanagedSkills(t *testing.T) {
filepath.Join(home, ".agents", "skills", "hey"),
filepath.Join(home, ".claude", "skills", "hey"),
filepath.Join(home, ".codex", "skills", "hey"),
filepath.Join(home, ".grok", "skills", "hey"),
}
for _, path := range paths {
if err := os.MkdirAll(path, 0o755); err != nil {
Expand Down
11 changes: 6 additions & 5 deletions internal/cmd/setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ import (
"github.com/basecamp/hey-cli/internal/output"
)

// isolateAgents makes agent detection deterministic: no claude/codex binary
// on PATH and no ~/.local/bin, so only the ~/.claude and ~/.codex directories
// a test creates count. Agent CLIs are never spawned.
// isolateAgents makes agent detection deterministic: no claude/codex/grok
// binary on PATH and no ~/.local/bin, so only the ~/.claude, ~/.codex and
// ~/.grok directories a test creates count. Agent CLIs are never spawned.
func isolateAgents(t *testing.T) {
t.Helper()
t.Setenv("PATH", t.TempDir())
t.Setenv("CODEX_HOME", "")
t.Setenv("GROK_HOME", "")
// The wizard installs shell completions too; without this it would read
// the shell of whoever runs the tests.
stubCompletionEnv(t, testCompletionEnv(t, "bash"))
Expand Down Expand Up @@ -87,7 +88,7 @@ func wizardData(t *testing.T, response output.Response) map[string]any {

func TestSetupCommandRegistersAgentSubcommands(t *testing.T) {
root := newRootCmd()
for _, path := range [][]string{{"setup", "agents"}, {"setup", "claude"}, {"setup", "codex"}} {
for _, path := range [][]string{{"setup", "agents"}, {"setup", "claude"}, {"setup", "codex"}, {"setup", "grok"}} {
command, _, err := root.Find(path)
if err != nil || command.Name() != path[1] {
t.Errorf("%v not registered: %v", path, err)
Expand Down Expand Up @@ -870,7 +871,7 @@ func TestSetupRejectsListOnlyFormatsBeforeSideEffects(t *testing.T) {
isolateAgents(t)
server := quietServer(t)
for _, flag := range []string{"--ids-only", "--count"} {
for _, args := range [][]string{{"setup"}, {"setup", "agents"}, {"setup", "codex"}} {
for _, args := range [][]string{{"setup"}, {"setup", "agents"}, {"setup", "codex"}, {"setup", "grok"}} {
configHome := t.TempDir()
_, _, err := runAuthCommand(t, configHome, server.URL, "", false, append(args, flag)...)
if err == nil || !strings.Contains(err.Error(), flag+" is not supported") {
Expand Down
Loading
Loading