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 @@ -334,6 +334,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
12 changes: 9 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,16 @@ 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 discovers the shared `~/.agents` skill directly. `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 discover the shared `~/.agents` skill directly. Those two are
rows of one table, `harness.SkillAgent` (name, id, home env var, home directory, binary), and
everything in `internal/cmd` that touches them — setup handlers, `hey skill install`, refresh,
`--remove`, the `HEY_SETUP_AGENT` values, the migration of a copy an older hey-cli made in the
agent's own skills directory — loops over `harness.SkillAgents()` rather than naming either. A new
shared-skill agent is a new row; the prose lists in `help_topics.go`, the installers and the docs
are the places to update by hand. `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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ The setup wizard connects the agents it finds. To do it yourself:
```bash
hey setup claude # skill + the hey@37signals plugin for Claude Code
hey setup codex # skill for Codex
hey setup grok # skill for Grok
claude mcp add hey -- hey mcp # HEY as MCP tools, on your signed-in account
```

Expand Down
15 changes: 8 additions & 7 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
hey-cli is built to be driven by an agent as readily as by a person: every command that returns
data answers `--json`, the exit codes are stable (`hey help exit-codes`), and `hey commands --json`
describes the whole surface. This page covers the two integrations that ship with it: an
agent skill for Claude Code and Codex, and an MCP server.
agent skill for Claude Code, Codex and Grok, and an MCP server.

## Agent skill and Claude Code plugin

Expand All @@ -15,23 +15,24 @@ 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 shared skill for Codex
hey setup grok # install the shared 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.

Codex discovers the shared `~/.agents/skills/hey` skill directly. hey-cli does
not also copy it to `~/.codex/skills/hey`, which would make Codex list the same
skill twice. Setup and version refresh remove that legacy path only when its
ownership marker proves an older hey-cli created it; user-authored files are
left untouched.
Codex and Grok discover the shared `~/.agents/skills/hey` skill directly. hey-cli does
not also copy it into their own skills directories (`~/.codex/skills/hey`, `~/.grok/skills/hey`):
Codex would list the same skill twice, and either would be a second copy to keep current.
Setup, `hey skill install` and version refresh remove such a copy only when its ownership
marker proves an older hey-cli created it; user-authored files are left untouched.

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
Expand Down
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ hey help linked-accounts # account selection and precedence
## Setup

`hey setup` runs the first-run wizard again at any time: browser sign-in, a check of who
you are signed in as, and connecting the coding agents it detects (Claude Code, Codex).
you are signed in as, and connecting the coding agents it detects (Claude Code, Codex, Grok).
`--skip-agents` leaves agent integrations unchanged and `--skip-omarchy` leaves the
Omarchy integration unchanged. `--silent-success` keeps any required sign-in visible,
shows an installation spinner, and ends a successful run with `SETUP COMPLETE`; failure
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
4 changes: 2 additions & 2 deletions internal/cmd/local_config_trust.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func commandIgnoresLocalConfig(cmd *cobra.Command) bool {
return true
case "setup":
// The wizard itself uses the effective server; its subcommands
// (agents, claude, codex, omarchy) touch only local files and must
// (agents, claude, codex, grok, omarchy) touch only local files and must
// work from any directory — the installer pipes curl from wherever
// the user happens to be, malformed .hey/config.json included.
return len(parts) >= 3
Expand All @@ -85,7 +85,7 @@ func commandUsesRuntimeConfig(cmd *cobra.Command) bool {
return false
case "setup":
// `hey setup` itself signs in against the effective server, but its
// subcommands (agents, claude, codex) only touch local agent files.
// subcommands (agents, claude, codex, grok) only touch local agent files.
// The installer's non-TTY handoff runs `setup agents` from whatever
// directory the user piped curl in — possibly a repository with an
// untrusted .hey/config.json — and must not be blocked by it.
Expand Down
100 changes: 58 additions & 42 deletions internal/cmd/setup_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,27 +59,29 @@ type agentSetupOutcome struct {
Issues []agentIssue
}

// agentSetupHandlers maps agent ID → setup handler.
var agentSetupHandlers = map[string]agentSetupHandler{
"claude": {
Labels: []string{
"Add the " + harness.ClaudeMarketplaceSource + " marketplace to Claude Code",
"Install the " + harness.ClaudeExpectedPluginKey + " plugin for Claude Code",
"Link the skill into ~/.claude/skills/hey",
},
Run: runClaudeSetup,
RunNonInteractive: runClaudeSetupNonInteractive,
},
"codex": {
Labels: []string{
"Install the shared HEY skill for Codex",
// agentSetupHandlers maps agent ID → setup handler. Claude Code's is written
// out; every shared-skill agent's comes from the harness table.
var agentSetupHandlers = agentSetupHandlersFor(harness.SkillAgents())

func agentSetupHandlersFor(skillAgents []harness.SkillAgent) map[string]agentSetupHandler {
handlers := map[string]agentSetupHandler{
"claude": {
Labels: []string{
"Add the " + harness.ClaudeMarketplaceSource + " marketplace to Claude Code",
"Install the " + harness.ClaudeExpectedPluginKey + " plugin for Claude Code",
"Link the skill into ~/.claude/skills/hey",
},
Run: runClaudeSetup,
RunNonInteractive: runClaudeSetupNonInteractive,
},
Run: runCodexSetup,
RunNonInteractive: runCodexSetupNonInteractive,
},
}
for _, agent := range skillAgents {
handlers[agent.ID] = skillAgentSetupHandler(agent)
}
return handlers
}

// runAgentCommand is the subprocess seam for agent CLIs (claude, codex) so
// runAgentCommand is the subprocess seam for agent CLIs (claude, codex, …) 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 @@ -391,34 +393,48 @@ func agentCommandFailure(out []byte, err error) string {
return message
}

// --- Codex ---
// --- Shared-skill agents (Codex, Grok) ---

// runCodexSetup connects Codex to the shared agent skill.
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, "Then verify with: hey doctor")
return nil //nolint:nilerr // warn and continue; the post-setup snapshot reports the failure
// skillAgentSetupHandler builds the handler for an agent that reads the
// shared skill directly: hey has no plugin for it, so the one step is
// confirming the shared skill and clearing any copy it once made.
func skillAgentSetupHandler(agent harness.SkillAgent) agentSetupHandler {
return agentSetupHandler{
Labels: []string{
"Install the shared HEY skill for " + agent.Name,
},
// Interactive: print progress, warn and continue.
Run: func(cmd *cobra.Command) error {
w := cmd.OutOrStdout()
path, err := installSkillAgentSkill(agent)
if err != nil {
fmt.Fprintln(w, warning.format(agent.Name+" 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, agent.Name+" skill installed ("+path+")"))
return nil
},
RunNonInteractive: func(*cobra.Command) error {
_, err := installSkillAgentSkill(agent)
return err
},
}
fmt.Fprintln(w, statusLine(true, "Codex skill installed ("+path+")"))
return nil
}

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

// installCodexSkill is the Codex handler's one step. The caller installs the
// shared baseline first; this removes any older hey-cli-managed Codex copy so
// Codex discovers only one skill. Like Claude, it never fabricates the agent.
func installCodexSkill() (string, error) {
if !harness.DetectCodex() {
// installSkillAgentSkill is a shared-skill agent's one step. The caller
// installs the shared baseline first; this confirms it is healthy and
// removes any older hey-cli-managed copy in the agent's own skills
// directory so the agent discovers only one skill. Like Claude, it never
// fabricates the agent: creating its home on a machine without it would
// make every later detection — and this command's own verdict — report it
// installed.
func installSkillAgentSkill(agent harness.SkillAgent) (string, error) {
if !agent.Detect() {
setup := "hey setup " + agent.ID
return "", &agentSetupError{
Summary: "Codex not detected — install Codex, then run: hey setup codex",
Manual: []string{"hey setup codex"},
Summary: agent.Name + " not detected — install " + agent.Name + ", then run: " + setup,
Manual: []string{setup},
}
}
path := harness.AgentSkillPath()
Expand All @@ -428,7 +444,7 @@ func installCodexSkill() (string, error) {
if !baselineSkillInstalled() {
return "", fmt.Errorf("shared HEY skill is not installed")
}
if _, err := migrateLegacyCodexSkill(); err != nil {
if _, err := migrateLegacySkill(agent); err != nil {
return "", err
}
return path, nil
Expand Down
49 changes: 30 additions & 19 deletions internal/cmd/setup_agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,26 @@ import (
)

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

// agentSelectorValues lists what agentSetupEnv accepts, for help and
// diagnostics: every registered agent id, then all and none.
func agentSelectorValues() []string {
var values []string
for _, agent := range harness.AllAgents() {
values = append(values, agent.ID)
}
return append(values, "all", "none")
}

// agentSelectorProse renders agentSelectorValues as "claude, codex, grok, all, or none".
func agentSelectorProse() string {
values := agentSelectorValues()
return strings.Join(values[:len(values)-1], ", ") + ", or " + values[len(values)-1]
}

// newSetupAgentsCommand builds `hey setup agents`. It always runs
// non-interactively: it installs the baseline skill, connects agents per the
// HEY_SETUP_AGENT selector (or auto-detection), and emits a structured
Expand All @@ -28,15 +45,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 + ": " + agentSelectorProse() + ". 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 + "=" + strings.Join(agentSelectorValues(), "|") + " 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 +119,13 @@ func runNonInteractiveAgentSetup(cmd *cobra.Command) error {
targets = harness.AllAgents()
case "none":
// baseline skill only
case "claude", "codex":
default:
if a := harness.FindAgent(selector); a != nil {
targets = []harness.AgentInfo{*a}
} else {
selector = "invalid"
warnings = append(warnings, fmt.Sprintf("Unknown %s value %q; installed the baseline skill only (expected %s)", agentSetupEnv, selectorRaw, agentSelectorProse()))
}
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))
}

// Run handlers in id order so aggregation is deterministic.
Expand Down Expand Up @@ -213,7 +230,7 @@ func runAgentSetupHandler(cmd *cobra.Command, agent harness.AgentInfo) agentSetu
id: agent.ID,
name: agent.Name,
detectedBefore: agent.Detect != nil && agent.Detect(),
binaryAbsent: !agentBinaryPresent(agent.ID),
binaryAbsent: !agentBinaryPresent(agent),
}

if handler, ok := agentSetupHandlers[agent.ID]; ok && handler.RunNonInteractive != nil {
Expand All @@ -235,17 +252,11 @@ func runAgentSetupHandler(cmd *cobra.Command, agent harness.AgentInfo) agentSetu
return rec
}

// agentBinaryPresent reports whether the agent's executable is on disk.
// Unknown agents are assumed present so no bogus remediation is synthesized.
func agentBinaryPresent(id string) bool {
switch id {
case "claude":
return harness.FindClaudeBinary() != ""
case "codex":
return harness.FindCodexBinary() != ""
default:
return true
}
// agentBinaryPresent reports whether the agent's executable is on disk. An
// agent with no executable to look for is assumed present so no bogus
// remediation is synthesized.
func agentBinaryPresent(agent harness.AgentInfo) bool {
return agent.FindBinary == nil || agent.FindBinary() != ""
}

// detectedAgentIDs returns the ids of currently detected agents, sorted.
Expand Down
8 changes: 4 additions & 4 deletions internal/cmd/setup_agents_remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,11 @@ func runRemoveAgentSetup(cmd *cobra.Command) error {
}
}

if codexSkill := harness.LegacyCodexSkillPath(); codexSkill != "" {
if didRemove, removeErr := removeOwnedSkillFiles(filepath.Dir(codexSkill)); removeErr != nil {
failures = append(failures, "Codex skill: "+removeErr.Error())
for _, agent := range harness.SkillAgents() {
if didRemove, removeErr := removeLegacySkill(agent); removeErr != nil {
failures = append(failures, agent.Name+" skill: "+removeErr.Error())
} else if didRemove {
removed = append(removed, "Codex skill")
removed = append(removed, agent.Name+" skill")
}
}

Expand Down
Loading
Loading