From 74bc6c76423a577d40ec04de3dd536156a4cfacc Mon Sep 17 00:00:00 2001 From: Shawn Yeager Date: Wed, 26 Aug 2026 11:40:22 -0500 Subject: [PATCH 1/2] Connect Grok Build as a first-class coding agent Register Grok in the agent harness, add hey setup grok, and copy the HEY skill into $GROK_HOME/skills/hey. Include Grok in skill install, refresh, doctor, setup agents, and --remove. No Grok plugin yet. --- .surface | 1 + AGENTS.md | 6 +- README.md | 5 +- internal/cmd/help_topics.go | 2 +- internal/cmd/setup_agent.go | 44 +++++++++- internal/cmd/setup_agents.go | 12 +-- internal/cmd/setup_agents_remove.go | 8 ++ internal/cmd/setup_agents_test.go | 85 ++++++++++++++++-- internal/cmd/setup_test.go | 11 +-- internal/cmd/skill_install.go | 33 ++++++- internal/cmd/skill_install_test.go | 20 +++-- internal/cmd/skill_refresh.go | 13 +-- internal/cmd/skill_refresh_test.go | 39 ++++++++- internal/harness/agent.go | 2 +- internal/harness/agent_test.go | 7 +- internal/harness/grok.go | 130 ++++++++++++++++++++++++++++ internal/harness/grok_test.go | 71 +++++++++++++++ scripts/install.ps1 | 2 +- scripts/install.sh | 4 +- tests/e2e/installer.bats | 3 +- 20 files changed, 455 insertions(+), 43 deletions(-) create mode 100644 internal/harness/grok.go create mode 100644 internal/harness/grok_test.go diff --git a/.surface b/.surface index bf681bab..547739f5 100644 --- a/.surface +++ b/.surface @@ -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 diff --git a/AGENTS.md b/AGENTS.md index ca3da7d7..a05f937c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 191dc0a8..7fdb8caf 100644 --- a/README.md +++ b/README.md @@ -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, @@ -733,6 +733,7 @@ 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 @@ -740,7 +741,7 @@ 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` +the skill only and lists the `hey setup ` 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. diff --git a/internal/cmd/help_topics.go b/internal/cmd/help_topics.go index 3f70731a..dcf0f782 100644 --- a/internal/cmd/help_topics.go +++ b/internal/cmd/help_topics.go @@ -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.`, }, diff --git a/internal/cmd/setup_agent.go b/internal/cmd/setup_agent.go index c7d4cd57..6c282642 100644 --- a/internal/cmd/setup_agent.go +++ b/internal/cmd/setup_agent.go @@ -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) { @@ -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. diff --git a/internal/cmd/setup_agents.go b/internal/cmd/setup_agents.go index d30a8336..3feeec6e 100644 --- a/internal/cmd/setup_agents.go +++ b/internal/cmd/setup_agents.go @@ -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 @@ -28,7 +28,7 @@ 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 ` commands are surfaced instead. Use\n" + "--remove to uninstall the HEY integrations and managed skill files.", @@ -36,7 +36,7 @@ func newSetupAgentsCommand() *cobra.Command { // 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. --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 { @@ -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. @@ -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 } diff --git a/internal/cmd/setup_agents_remove.go b/internal/cmd/setup_agents_remove.go index d84bd1f4..c0dfa84a 100644 --- a/internal/cmd/setup_agents_remove.go +++ b/internal/cmd/setup_agents_remove.go @@ -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()) diff --git a/internal/cmd/setup_agents_test.go b/internal/cmd/setup_agents_test.go index 0f0125ae..03eb485f 100644 --- a/internal/cmd/setup_agents_test.go +++ b/internal/cmd/setup_agents_test.go @@ -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 { @@ -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) } } @@ -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) { @@ -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") @@ -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 ` that did not connect must say so and exit // nonzero — never "start a new session" over a failed integration. func TestSetupAgentStyledReportsNotConnected(t *testing.T) { @@ -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) } @@ -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) @@ -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), } { @@ -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 { diff --git a/internal/cmd/setup_test.go b/internal/cmd/setup_test.go index 898d4f59..55750b56 100644 --- a/internal/cmd/setup_test.go +++ b/internal/cmd/setup_test.go @@ -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")) @@ -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) @@ -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") { diff --git a/internal/cmd/skill_install.go b/internal/cmd/skill_install.go index 37480cd1..4e5d3e58 100644 --- a/internal/cmd/skill_install.go +++ b/internal/cmd/skill_install.go @@ -100,7 +100,7 @@ func newSkillInstallCommand() *cobra.Command { return &cobra.Command{ Use: "install", Short: "Install the hey skill globally for your coding agents", - Long: "Copies the embedded SKILL.md to ~/.agents/skills/hey/, links it into ~/.claude/skills/hey when Claude Code is installed, and copies it for Codex when Codex is installed.", + Long: "Copies the embedded SKILL.md to ~/.agents/skills/hey/, links it into ~/.claude/skills/hey when Claude Code is installed, and copies it for Codex and Grok when those agents are installed.", RunE: runSkillInstall, } } @@ -138,6 +138,15 @@ func runSkillInstall(cmd *cobra.Command, args []string) error { lines = append(lines, "Copied skill to "+codexPath) } + if harness.DetectGrok() { + grokPath, grokErr := installSkillToGrok() + if grokErr != nil { + return apierr.ErrAPI(0, grokErr.Error()) + } + result["grok_skill_path"] = grokPath + lines = append(lines, "Copied skill to "+grokPath) + } + if writer.IsStyled() { for _, line := range lines { fmt.Fprintln(cmd.OutOrStdout(), line) @@ -311,6 +320,28 @@ func installSkillToCodex() (string, error) { return skillPath, nil } +// installSkillToGrok copies the embedded SKILL.md into Grok's skills +// directory ($GROK_HOME or ~/.grok). GROK_HOME can sit anywhere, so this is +// a copy rather than a link — the same reason Codex copies. +func installSkillToGrok() (string, error) { + skillPath := harness.GrokSkillPath() + if skillPath == "" { + return "", fmt.Errorf("cannot determine Grok home directory") + } + + data, err := skills.FS.ReadFile("hey/SKILL.md") + if err != nil { + return "", fmt.Errorf("reading embedded skill: %w", err) + } + if err := claimSkillDir(filepath.Dir(skillPath)); err != nil { + return "", err + } + if err := writeSkillFile(skillPath, data); err != nil { + return "", fmt.Errorf("writing Grok skill file: %w", err) + } + return skillPath, nil +} + // baselineSkillInstalled reports whether ~/.agents/skills/hey/SKILL.md is a // healthy install: a regular file in a directory hey-cli owns. These are the // final-component rules the write paths enforce (a symlinked SKILL.md or an diff --git a/internal/cmd/skill_install_test.go b/internal/cmd/skill_install_test.go index 18c30d3d..f6cc5baf 100644 --- a/internal/cmd/skill_install_test.go +++ b/internal/cmd/skill_install_test.go @@ -78,6 +78,7 @@ func TestSkillInstallCopyFallbackIsIdempotent(t *testing.T) { t.Setenv("USERPROFILE", home) t.Setenv("PATH", t.TempDir()) t.Setenv("CODEX_HOME", "") + t.Setenv("GROK_HOME", "") if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { t.Fatal(err) } @@ -155,6 +156,7 @@ func agentHome(t *testing.T, dirs ...string) string { t.Setenv("USERPROFILE", home) t.Setenv("PATH", t.TempDir()) t.Setenv("CODEX_HOME", "") + t.Setenv("GROK_HOME", "") for _, dir := range dirs { if err := os.MkdirAll(filepath.Join(home, dir), 0o755); err != nil { t.Fatal(err) @@ -199,12 +201,13 @@ func TestSkillInstallRefusesForeignLinkOrFile(t *testing.T) { // hand-authored skill can legitimately live there. Every install path — the // explicit command and the installer's automatic `setup agents` alike — must // refuse an unmarked one rather than overwrite it and then claim it. -func TestSkillInstallRefusesUnmarkedBaselineAndCodexSkills(t *testing.T) { - home := agentHome(t, ".codex") +func TestSkillInstallRefusesUnmarkedBaselineAndAgentSkills(t *testing.T) { + home := agentHome(t, ".codex", ".grok") custom := "# my own hey skill\n" baseline := filepath.Join(home, ".agents", "skills", "hey") codex := filepath.Join(home, ".codex", "skills", "hey") - for _, dir := range []string{baseline, codex} { + grok := filepath.Join(home, ".grok", "skills", "hey") + for _, dir := range []string{baseline, codex, grok} { if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } @@ -220,7 +223,10 @@ func TestSkillInstallRefusesUnmarkedBaselineAndCodexSkills(t *testing.T) { if _, err := installSkillToCodex(); !errors.As(err, &unmanaged) { t.Fatalf("installSkillToCodex error = %v, want unmanaged refusal", err) } - for _, dir := range []string{baseline, codex} { + if _, err := installSkillToGrok(); !errors.As(err, &unmanaged) { + t.Fatalf("installSkillToGrok error = %v, want unmanaged refusal", err) + } + for _, dir := range []string{baseline, codex, grok} { data, err := os.ReadFile(filepath.Join(dir, "SKILL.md")) if err != nil || string(data) != custom { t.Errorf("%s changed: %q, %v", dir, data, err) @@ -254,7 +260,7 @@ func TestClaimSkillDirAcceptsEmptyAndMarkedDirectories(t *testing.T) { // follow a symlink planted inside it — a link's target was never inspected, // and truncating it would destroy a file we do not own. func TestSkillInstallRefusesSymlinkedSkillFileInManagedDir(t *testing.T) { - home := agentHome(t, ".codex") + home := agentHome(t, ".codex", ".grok") precious := filepath.Join(home, "precious.md") if err := os.WriteFile(precious, []byte("# do not truncate"), 0o644); err != nil { t.Fatal(err) @@ -262,6 +268,7 @@ func TestSkillInstallRefusesSymlinkedSkillFileInManagedDir(t *testing.T) { for _, dir := range []string{ filepath.Join(home, ".agents", "skills", "hey"), filepath.Join(home, ".codex", "skills", "hey"), + filepath.Join(home, ".grok", "skills", "hey"), } { if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) @@ -278,6 +285,9 @@ func TestSkillInstallRefusesSymlinkedSkillFileInManagedDir(t *testing.T) { if _, err := installSkillToCodex(); err == nil { t.Error("Codex install wrote through a symlinked SKILL.md") } + if _, err := installSkillToGrok(); err == nil { + t.Error("Grok install wrote through a symlinked SKILL.md") + } if data, _ := os.ReadFile(precious); string(data) != "# do not truncate" { t.Errorf("symlink target was truncated: %q", data) } diff --git a/internal/cmd/skill_refresh.go b/internal/cmd/skill_refresh.go index 41d74320..aa166e6e 100644 --- a/internal/cmd/skill_refresh.go +++ b/internal/cmd/skill_refresh.go @@ -48,11 +48,11 @@ func refreshSkillsIfVersionChanged() bool { } sentinelPath := filepath.Join(configDir, ".last-run-version") - // The sentinel records the active Codex home alongside the version: - // skill locations depend on CODEX_HOME, so switching homes mid-release - // triggers one rescan instead of leaving the other home's marked skill - // stale until the next release. - sentinelState := version.Version + "\n" + harness.CodexHome() + "\n" + // The sentinel records the active Codex and Grok homes alongside the + // version: skill locations depend on CODEX_HOME and GROK_HOME, so + // switching homes mid-release triggers one rescan instead of leaving + // the other home's marked skill stale until the next release. + sentinelState := version.Version + "\n" + harness.CodexHome() + "\n" + harness.GrokHome() + "\n" data, err := os.ReadFile(sentinelPath) // #nosec G304 -- fixed path under the user config dir if err == nil && string(data) == sentinelState { return false @@ -94,6 +94,9 @@ func skillRefreshLocations() []string { if codexPath := harness.CodexSkillPath(); codexPath != "" { locations = append(locations, codexPath) } + if grokPath := harness.GrokSkillPath(); grokPath != "" { + locations = append(locations, grokPath) + } return locations } diff --git a/internal/cmd/skill_refresh_test.go b/internal/cmd/skill_refresh_test.go index a893a89d..a9c726ac 100644 --- a/internal/cmd/skill_refresh_test.go +++ b/internal/cmd/skill_refresh_test.go @@ -16,6 +16,7 @@ func refreshFixture(t *testing.T) (home string) { t.Setenv("USERPROFILE", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) t.Setenv("CODEX_HOME", "") + t.Setenv("GROK_HOME", "") return home } @@ -56,8 +57,9 @@ func TestRefreshSkillsUpdatesInstalledCopiesOnce(t *testing.T) { stubVersion(t, "1.2.3") skillPath := installStaleSkill(t, home) - // A Codex copy must be refreshed too. + // A Codex copy and a Grok copy must be refreshed too. codexSkill := writeSkillFixture(t, filepath.Join(home, ".codex", "skills", "hey"), "# stale skill", true) + grokSkill := writeSkillFixture(t, filepath.Join(home, ".grok", "skills", "hey"), "# stale skill", true) if !refreshSkillsIfVersionChanged() { t.Fatal("first run on a new version should refresh") @@ -67,7 +69,7 @@ func TestRefreshSkillsUpdatesInstalledCopiesOnce(t *testing.T) { if err != nil { t.Fatal(err) } - for _, path := range []string{skillPath, codexSkill} { + for _, path := range []string{skillPath, codexSkill, grokSkill} { data, err := os.ReadFile(path) if err != nil { t.Fatal(err) @@ -174,6 +176,7 @@ func TestRefreshSkillsPreservesUnmanagedSkills(t *testing.T) { writeSkillFixture(t, filepath.Join(home, ".agents", "skills", "hey"), custom, false), writeSkillFixture(t, filepath.Join(home, ".claude", "skills", "hey"), custom, false), writeSkillFixture(t, filepath.Join(home, ".codex", "skills", "hey"), custom, false), + writeSkillFixture(t, filepath.Join(home, ".grok", "skills", "hey"), custom, false), } if refreshSkillsIfVersionChanged() { @@ -287,6 +290,38 @@ func TestRefreshSkillsRescansWhenCodexHomeChanges(t *testing.T) { } } +// The sentinel tracks the active Grok home: a marked, stale skill in a +// Grok home that was inactive during the first post-upgrade run is +// refreshed as soon as that home becomes active, not at the next release. +func TestRefreshSkillsRescansWhenGrokHomeChanges(t *testing.T) { + home := refreshFixture(t) + stubVersion(t, "9.9.9") + installStaleSkill(t, home) + + homeA := t.TempDir() + t.Setenv("GROK_HOME", homeA) + if !refreshSkillsIfVersionChanged() { + t.Fatal("first run should refresh") + } + if refreshSkillsIfVersionChanged() { + t.Fatal("same home: second run is a no-op") + } + + homeB := t.TempDir() + staleB := writeSkillFixture(t, filepath.Join(homeB, "skills", "hey"), "# stale skill", true) + t.Setenv("GROK_HOME", homeB) + if !refreshSkillsIfVersionChanged() { + t.Fatal("switching Grok homes should rescan") + } + data, err := os.ReadFile(staleB) + if err != nil || string(data) == "# stale skill" { + t.Errorf("skill in the newly active Grok home was not refreshed: %v", err) + } + if refreshSkillsIfVersionChanged() { + t.Error("stable again: refresh must be a no-op") + } +} + // The sentinel gets the same no-follow rule as every other file this feature // writes: a symlink planted at its path is never truncated. func TestRefreshSkillsNeverWritesThroughSymlinkedSentinel(t *testing.T) { diff --git a/internal/harness/agent.go b/internal/harness/agent.go index f02bc809..553c9da5 100644 --- a/internal/harness/agent.go +++ b/internal/harness/agent.go @@ -7,7 +7,7 @@ import ( // AgentInfo describes a coding agent integration. type AgentInfo struct { - Name string // "Claude Code" + Name string // "Claude Code", "Codex", "Grok" ID string // "claude" Detect func() bool // reports whether the agent is installed Checks func() []*StatusCheck // cheap health checks gating setup wizard behavior diff --git a/internal/harness/agent_test.go b/internal/harness/agent_test.go index aadae6f9..852e5b0f 100644 --- a/internal/harness/agent_test.go +++ b/internal/harness/agent_test.go @@ -3,7 +3,7 @@ package harness import "testing" // withCleanRegistry empties the registry for a test and restores the real -// claude/codex registrations from init() afterwards. +// claude/codex/grok registrations from init() afterwards. func withCleanRegistry(t *testing.T) { t.Helper() registryMu.Lock() @@ -76,11 +76,14 @@ func TestRegisterAgentPanicsOnBadIDs(t *testing.T) { assertPanics("duplicate ID", func() { RegisterAgent(AgentInfo{ID: "dup", Name: "Second"}) }) } -func TestDefaultRegistryHasClaudeAndCodex(t *testing.T) { +func TestDefaultRegistryHasClaudeCodexAndGrok(t *testing.T) { if FindAgent("claude") == nil { t.Error("claude agent not registered") } if FindAgent("codex") == nil { t.Error("codex agent not registered") } + if FindAgent("grok") == nil { + t.Error("grok agent not registered") + } } diff --git a/internal/harness/grok.go b/internal/harness/grok.go new file mode 100644 index 00000000..0500cd3f --- /dev/null +++ b/internal/harness/grok.go @@ -0,0 +1,130 @@ +package harness + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func init() { + RegisterAgent(AgentInfo{ + Name: "Grok", + ID: "grok", + Detect: DetectGrok, + // hey has no Grok plugin yet (no .grok-plugin manifest ships in + // this repo), so Grok health is skill-presence only. When a native + // plugin lands, this grows the plugin/version checks Claude has. + Checks: func() []*StatusCheck { + return []*StatusCheck{CheckGrokSkill()} + }, + Diagnostics: func(_ context.Context) []*StatusCheck { + return []*StatusCheck{CheckGrokSkill()} + }, + }) +} + +// DetectGrok returns true when Grok has a home directory or executable. +func DetectGrok() bool { + if info, err := os.Stat(GrokHome()); err == nil && info.IsDir() { + return true + } + return FindGrokBinary() != "" +} + +// FindGrokBinary returns the Grok executable path, or an empty string. +func FindGrokBinary() string { + if path, err := exec.LookPath("grok"); err == nil { + return path + } + if home := GrokHome(); home != "" { + candidate := filepath.Join(home, "bin", "grok") + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "" + } + candidate := filepath.Join(filepath.Clean(home), ".local", "bin", "grok") + if _, err := os.Stat(candidate); err == nil { + return candidate + } + return "" +} + +// GrokHome returns Grok's home directory: $GROK_HOME or ~/.grok. +func GrokHome() string { + if grokHome := strings.TrimSpace(os.Getenv("GROK_HOME")); grokHome != "" { + return grokHome + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "" + } + return filepath.Join(filepath.Clean(home), ".grok") +} + +// GrokSkillPath returns where Grok reads the hey skill from. +func GrokSkillPath() string { + grokHome := GrokHome() + if grokHome == "" { + return "" + } + return filepath.Join(grokHome, "skills", "hey", "SKILL.md") +} + +// CheckGrokSkill checks whether the hey skill is installed for Grok. +func CheckGrokSkill() *StatusCheck { + skillPath := GrokSkillPath() + if skillPath == "" { + return &StatusCheck{ + Name: "Grok Skill", + Status: "warn", + Message: "Cannot determine Grok home directory", + } + } + if _, err := os.Stat(skillPath); err != nil { + if os.IsNotExist(err) { + return &StatusCheck{ + Name: "Grok Skill", + Status: "fail", + Message: "Skill not installed", + Hint: "Run: hey setup grok", + } + } + return &StatusCheck{ + Name: "Grok Skill", + Status: "warn", + Message: "Cannot check Grok skill", + Hint: "Unable to stat " + skillPath, + } + } + // Presence is not health: the file must be a regular file (a symlinked + // SKILL.md points somewhere never inspected)... + if !RegularSkillFile(skillPath) { + return &StatusCheck{ + Name: "Grok Skill", + Status: "fail", + Message: "SKILL.md at " + filepath.Dir(skillPath) + " is not a regular file", + Hint: "Move it aside, then run: hey setup grok", + } + } + // ...written by hey-cli — anything else is somebody's work occupying + // the path, not a connected integration. + if skillDir := filepath.Dir(skillPath); !SkillDirOwned(skillDir) { + return &StatusCheck{ + Name: "Grok Skill", + Status: "fail", + Message: "A skill not written by hey-cli occupies " + skillDir, + Hint: "Move it aside, then run: hey setup grok", + } + } + return &StatusCheck{ + Name: "Grok Skill", + Status: "pass", + Message: "Installed", + } +} diff --git a/internal/harness/grok_test.go b/internal/harness/grok_test.go new file mode 100644 index 00000000..effaad95 --- /dev/null +++ b/internal/harness/grok_test.go @@ -0,0 +1,71 @@ +package harness + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetectGrokByHomeDirectory(t *testing.T) { + home := tempHome(t) + t.Setenv("PATH", t.TempDir()) + t.Setenv("GROK_HOME", "") + + if DetectGrok() { + t.Error("no ~/.grok and no binary should not detect Grok") + } + if err := os.MkdirAll(filepath.Join(home, ".grok"), 0o755); err != nil { + t.Fatal(err) + } + if !DetectGrok() { + t.Error("~/.grok directory should detect Grok") + } +} + +func TestGrokHomeHonorsEnvOverride(t *testing.T) { + home := tempHome(t) + + t.Setenv("GROK_HOME", "") + if got, want := GrokHome(), filepath.Join(home, ".grok"); got != want { + t.Errorf("GrokHome() = %q, want %q", got, want) + } + + override := t.TempDir() + t.Setenv("GROK_HOME", override) + if got := GrokHome(); got != override { + t.Errorf("GrokHome() = %q, want %q", got, override) + } + if got, want := GrokSkillPath(), filepath.Join(override, "skills", "hey", "SKILL.md"); got != want { + t.Errorf("GrokSkillPath() = %q, want %q", got, want) + } +} + +func TestCheckGrokSkill(t *testing.T) { + home := tempHome(t) + t.Setenv("GROK_HOME", "") + + check := CheckGrokSkill() + if check.Status != "fail" || check.Hint != "Run: hey setup grok" { + t.Errorf("missing skill: %+v", check) + } + + skillDir := filepath.Join(home, ".grok", "skills", "hey") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# hey"), 0o644); err != nil { + t.Fatal(err) + } + // Present but unmarked is somebody else's skill occupying the path — + // never reported as a connected integration. + if check := CheckGrokSkill(); check.Status != "fail" || check.Hint != "Move it aside, then run: hey setup grok" { + t.Errorf("unmanaged skill: %+v", check) + } + + if err := os.WriteFile(filepath.Join(skillDir, SkillOwnershipMarker), []byte("hey-cli"), 0o644); err != nil { + t.Fatal(err) + } + if check := CheckGrokSkill(); check.Status != "pass" { + t.Errorf("managed skill: %+v", check) + } +} diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 701094ec..3448735d 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -20,7 +20,7 @@ try { # `hey setup agents` to install the agent skill and connect # coding agents without prompting) # HEY_SETUP_AGENT Which coding agent(s) `setup agents` connects: -# claude | codex | all | none (default: auto-detect) +# claude | codex | grok | all | none (default: auto-detect) # # This file must stay pure ASCII: the release pipeline stages and # Authenticode-signs a CRLF copy of it, and Windows PowerShell 5.1 decodes a diff --git a/scripts/install.sh b/scripts/install.sh index fa7de829..f9f95c23 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -14,7 +14,7 @@ # and connect coding agents without prompting) # HEY_SETUP_AGENT # Which coding agent(s) `setup agents` connects: -# claude | codex | all | none (default: auto-detect a single +# claude | codex | grok | all | none (default: auto-detect a single # agent; several detected connects none and lists them) # # Verification: the SHA-256 checksum is always verified against the release's @@ -499,7 +499,7 @@ 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). +# HEY_SETUP_AGENT itself: claude|codex|grok|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. # diff --git a/tests/e2e/installer.bats b/tests/e2e/installer.bats index 1e33656a..c490671a 100644 --- a/tests/e2e/installer.bats +++ b/tests/e2e/installer.bats @@ -130,7 +130,7 @@ run_post_install_setup() { # printed next steps. @test "old binary: nothing is invoked beyond the capability probe" { write_stub old - for selector in "" claude codex all none; do + for selector in "" claude codex grok all none; do : > "$LOG" if [[ -n "$selector" ]]; then run_post_install_setup "export HEY_SETUP_AGENT=$selector" @@ -142,6 +142,7 @@ run_post_install_setup() { [[ "$output" != *"skill install"* ]] [[ "$output" != *"setup claude"* ]] [[ "$output" != *"setup codex"* ]] + [[ "$output" != *"setup grok"* ]] [[ "$output" != *"setup agents"$'\n'* ]] done } From b5b11029b5196c46503ea93607184a30c53121ee Mon Sep 17 00:00:00 2001 From: Shawn Yeager Date: Wed, 26 Aug 2026 20:09:44 -0500 Subject: [PATCH 2/2] Cover DetectGrok when only a grok binary is on PATH --- internal/harness/grok_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/internal/harness/grok_test.go b/internal/harness/grok_test.go index effaad95..a2277479 100644 --- a/internal/harness/grok_test.go +++ b/internal/harness/grok_test.go @@ -22,6 +22,21 @@ func TestDetectGrokByHomeDirectory(t *testing.T) { } } +func TestDetectGrokByBinary(t *testing.T) { + tempHome(t) + t.Setenv("GROK_HOME", "") + bin := t.TempDir() + stub := filepath.Join(bin, "grok") + if err := os.WriteFile(stub, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin) + + if !DetectGrok() { + t.Error("grok executable on PATH should detect Grok without a home directory") + } +} + func TestGrokHomeHonorsEnvOverride(t *testing.T) { home := tempHome(t)