diff --git a/README.md b/README.md
index 51c53a9a..5b76d33c 100644
--- a/README.md
+++ b/README.md
@@ -47,7 +47,7 @@ curl -fsSL https://berthd.app/install | sh
It downloads `berthd` from the [latest release](https://github.com/cosscom/shipyard/releases/latest),
checks it against the release's checksums, installs it for that user (no
root), starts it as a systemd user service (launchd on macOS), installs the
-hooks that let Claude Code, Codex and Cursor report their state
+hooks that let Claude Code, Codex, Cursor, Grok and others report their state
(`--no-integrations` skips them), and prints a pairing link. The link works
once, for ten minutes. Run it again to upgrade in place; `sh -s -- --help`
lists the options.
diff --git a/app/src/components/agent-glyph.tsx b/app/src/components/agent-glyph.tsx
index 6dcdd35a..095c874b 100644
--- a/app/src/components/agent-glyph.tsx
+++ b/app/src/components/agent-glyph.tsx
@@ -1,4 +1,4 @@
-import { AsteriskIcon, CheckIcon, HexagonIcon, SparkleIcon, SquareTerminalIcon, TerminalIcon } from "lucide-react";
+import { AsteriskIcon, CheckIcon, HexagonIcon, SparkleIcon, SquareTerminalIcon, TerminalIcon, ZapIcon } from "lucide-react";
import { Tip } from "@/components/tip";
import type { SessionState } from "@/lib/derive";
@@ -26,6 +26,8 @@ export function AgentIcon({ agent, className, muted }: { agent?: string; classNa
return π;
case "opencode":
return ;
+ case "grok":
+ return ;
default:
return ;
}
diff --git a/app/src/lib/agent-hooks.ts b/app/src/lib/agent-hooks.ts
index 6b69d3b7..8db168ea 100644
--- a/app/src/lib/agent-hooks.ts
+++ b/app/src/lib/agent-hooks.ts
@@ -23,7 +23,7 @@ interface IntegrationsReport {
}
// The commands Shipyard has hooks for, by the tool they install for.
-const TOOLS: Record = { claude: "claude", codex: "codex", "cursor-agent": "cursor", cursor: "cursor" };
+const TOOLS: Record = { claude: "claude", codex: "codex", "cursor-agent": "cursor", cursor: "cursor", grok: "grok" };
// hookToolFor is the tool an agent preset ID or command line needs hooks for.
export function hookToolFor(agentOrCommand: string): string | undefined {
diff --git a/app/src/lib/derive.test.ts b/app/src/lib/derive.test.ts
index 2312f8c8..045df3a3 100644
--- a/app/src/lib/derive.test.ts
+++ b/app/src/lib/derive.test.ts
@@ -11,6 +11,7 @@ test("a preset session's first prompt is read back from its command", () => {
assert.equal(firstPrompt({ preset: "claude", command: `claude 'it'\\''s "done"
next line'` }), `it's "done"\nnext line`);
assert.equal(firstPrompt({ preset: "opencode", command: "opencode --prompt 'add a health check'" }), "add a health check");
+ assert.equal(firstPrompt({ preset: "grok", command: "grok --model grok-build 'fix the bug'" }), "fix the bug");
assert.equal(firstPrompt({ preset: "claude", command: "claude --resume 0d1e 'go on'" }), "go on");
// No prompt, or a command berth didn't write.
assert.equal(firstPrompt({ preset: "claude", command: "claude --model haiku" }), undefined);
diff --git a/app/src/lib/derive.ts b/app/src/lib/derive.ts
index da792167..dea892af 100644
--- a/app/src/lib/derive.ts
+++ b/app/src/lib/derive.ts
@@ -7,7 +7,7 @@ import type { Location, Session, Stats, Worktree } from "@/lib/api";
// read as parked until a reply or a later time as ready (Idle).
export type SessionState = "ready" | "running" | "waiting" | "finished" | "exited" | "idle";
-export const AGENTS = ["claude", "codex", "opencode", "gemini", "pi", "omp", "cursor-agent"];
+export const AGENTS = ["claude", "codex", "opencode", "gemini", "pi", "omp", "cursor-agent", "grok"];
export function agentOf(s: Session): string | undefined {
// A service's terminal runs no agent, whatever its command.
@@ -161,7 +161,7 @@ export function sortedWorktrees(loc: Location): Worktree[] {
return [...(loc.worktrees ?? [])].sort((a, b) => Number(!!b.main) - Number(!!a.main) || a.name.localeCompare(b.name));
}
-const labels: Record = { claude: "Claude Code", codex: "Codex", opencode: "OpenCode", gemini: "Gemini", pi: "Pi", omp: "oh-my-pi", "cursor-agent": "Cursor Agent", cursor: "Cursor Agent" };
+const labels: Record = { claude: "Claude Code", codex: "Codex", opencode: "OpenCode", gemini: "Gemini", pi: "Pi", omp: "oh-my-pi", "cursor-agent": "Cursor Agent", cursor: "Cursor Agent", grok: "Grok CLI" };
// agentLabel is an agent's product name: "claude" → "Claude Code".
export const agentLabel = (a: string) => labels[a] ?? a.charAt(0).toUpperCase() + a.slice(1);
diff --git a/app/src/lib/mock-install.ts b/app/src/lib/mock-install.ts
index e1cb5292..1f97972d 100644
--- a/app/src/lib/mock-install.ts
+++ b/app/src/lib/mock-install.ts
@@ -24,6 +24,7 @@ export const MOCK_AGENTS: AgentChoice[] = [
{ id: "codex", name: "Codex", command: "codex", offered: true, install: "codex 0.160.1 from github.com/openai/codex/releases → ~/.local/bin/codex", verified: "pinned to 0.160.1 and checked against its sha256" },
{ id: "cursor", name: "Cursor Agent", command: "cursor-agent", offered: true, install: "curl -fsS https://cursor.com/install | bash", verified: "Cursor's installer, over HTTPS; Cursor publishes no checksums for it" },
{ id: "opencode", name: "OpenCode", command: "opencode", offered: true, install: "curl -fsSL https://opencode.ai/install | bash -s -- --no-modify-path", verified: "OpenCode's installer, over HTTPS; it publishes no checksums for it" },
+ { id: "grok", name: "Grok CLI", command: "grok", offered: true, install: "curl -fsSL https://x.ai/cli/install.sh | bash", verified: "xAI's installer, over HTTPS; xAI publishes no checksums for it" },
{
id: "gemini",
name: "Gemini CLI",
diff --git a/app/src/lib/mock-team.ts b/app/src/lib/mock-team.ts
index 9db9f388..c2e51e34 100644
--- a/app/src/lib/mock-team.ts
+++ b/app/src/lib/mock-team.ts
@@ -137,7 +137,7 @@ const GITHUB_STEP: PlanStep = {
commands: ["gh auth status || gh auth login --hostname github.com --git-protocol https --web", "gh auth setup-git", "# a device code: you enter it at github.com/login/device; this computer's sign-in is never copied"],
};
-const AGENT_NAMES: Record = { claude: "Claude Code", codex: "Codex", cursor: "Cursor Agent", opencode: "OpenCode" };
+const AGENT_NAMES: Record = { claude: "Claude Code", codex: "Codex", cursor: "Cursor Agent", opencode: "OpenCode", grok: "Grok CLI" };
const agentsStep = (ids: string[]): PlanStep => {
const names = ids.map((id) => AGENT_NAMES[id] ?? id);
return {
diff --git a/app/src/lib/session-open.ts b/app/src/lib/session-open.ts
index 66fa9e59..8aef80fb 100644
--- a/app/src/lib/session-open.ts
+++ b/app/src/lib/session-open.ts
@@ -11,7 +11,7 @@ import { activateTab, findSession, focusPane, openTab, refOf, removePane, select
// that worktree, and never takes the keyboard from them; otherwise a
// notification offers to open it.
-const agentNames: Record = { claude: "Claude Code", codex: "Codex", opencode: "OpenCode", gemini: "Gemini", omp: "oh-my-pi", cursor: "Cursor" };
+const agentNames: Record = { claude: "Claude Code", codex: "Codex", opencode: "OpenCode", gemini: "Gemini", omp: "oh-my-pi", cursor: "Cursor", grok: "Grok CLI" };
function agentLabel(box: string, agent?: string): string {
if (!agent) return "A terminal";
diff --git a/app/src/views/automations/flows/model.ts b/app/src/views/automations/flows/model.ts
index 63bd3787..8565d45b 100644
--- a/app/src/views/automations/flows/model.ts
+++ b/app/src/views/automations/flows/model.ts
@@ -56,7 +56,7 @@ const TRIGGER_PHRASE: Record = {
"box.upgraded": "the box is upgraded",
};
-const AGENT_NAME: Record = { claude: "Claude", codex: "Codex", opencode: "OpenCode", gemini: "Gemini", omp: "oh-my-pi", cursor: "Cursor" };
+const AGENT_NAME: Record = { claude: "Claude", codex: "Codex", opencode: "OpenCode", gemini: "Gemini", omp: "oh-my-pi", cursor: "Cursor", grok: "Grok" };
export const agentName = (id?: string) => (id ? (AGENT_NAME[id] ?? id) : "an agent");
export const GITHUB_ONS: { on: GitHubOn; label: string; phrase: string; fields: string[] }[] = [
diff --git a/app/src/views/onboarding/guided-install.tsx b/app/src/views/onboarding/guided-install.tsx
index 1a915624..0c33dcd6 100644
--- a/app/src/views/onboarding/guided-install.tsx
+++ b/app/src/views/onboarding/guided-install.tsx
@@ -308,7 +308,7 @@ function AgentPicker({ choices, value, onChange, installed = [] }: { choices: Ag
);
}
-const ORDER = ["claude", "codex", "cursor", "opencode", "gemini"];
+const ORDER = ["claude", "codex", "cursor", "opencode", "grok", "gemini"];
const order = (id: string) => (ORDER.indexOf(id) + 1 || 99) as number;
function PlanRow({ step, n, bundledTmux }: { step: InstallPlanStep; n: number; bundledTmux: boolean }) {
@@ -1031,7 +1031,7 @@ function Banner({ run, ready, readyLabel, onReady, onBack, agents }: { run: Inst
}
export function agentNames(ids: string[]) {
- const n = ids.map((id) => ({ claude: "Claude Code", codex: "Codex", cursor: "Cursor Agent", opencode: "OpenCode" })[id] ?? id);
+ const n = ids.map((id) => ({ claude: "Claude Code", codex: "Codex", cursor: "Cursor Agent", opencode: "OpenCode", grok: "Grok CLI" })[id] ?? id);
return n.length <= 1 ? (n[0] ?? "") : `${n.slice(0, -1).join(", ")} and ${n[n.length - 1]}`;
}
diff --git a/app/src/views/onboarding/quick-install.tsx b/app/src/views/onboarding/quick-install.tsx
index 616732db..6de596ff 100644
--- a/app/src/views/onboarding/quick-install.tsx
+++ b/app/src/views/onboarding/quick-install.tsx
@@ -236,7 +236,7 @@ export function InlineAgents({ value, onChange, disabled }: { value: string[]; o
);
}
-const ORDER = ["claude", "codex", "cursor", "opencode"];
+const ORDER = ["claude", "codex", "cursor", "opencode", "grok", "gemini"];
const order = (id: string) => (ORDER.indexOf(id) + 1 || 99) as number;
const busyHint = (v: string[]) => (v.length === 0 ? none: add them later in Settings : null);
diff --git a/app/src/views/project/agents-section.tsx b/app/src/views/project/agents-section.tsx
index 139063db..9b8b1b5f 100644
--- a/app/src/views/project/agents-section.tsx
+++ b/app/src/views/project/agents-section.tsx
@@ -21,7 +21,7 @@ export function AgentsSection({ repo, draft, setDraft, box }: { repo: RepoConfig
setOwn([...own, { id: ids.includes("claude") ? `agent-${own.length + 1}` : "claude", name: "", command: "" }])}>
diff --git a/app/src/views/settings/agents-section.tsx b/app/src/views/settings/agents-section.tsx
index 5c13311b..f02f7351 100644
--- a/app/src/views/settings/agents-section.tsx
+++ b/app/src/views/settings/agents-section.tsx
@@ -33,6 +33,7 @@ const INSTALL: Record = {
gemini: "npm install -g @google/gemini-cli",
cursor: "curl https://cursor.com/install -fsS | bash",
omp: "bun install -g @oh-my-pi/pi-coding-agent",
+ grok: "curl -fsSL https://x.ai/cli/install.sh | bash",
};
const BUILTIN: Pick[] = [
@@ -42,6 +43,7 @@ const BUILTIN: Pick[] = [
{ id: "gemini", name: "Gemini CLI" },
{ id: "cursor", name: "Cursor Agent" },
{ id: "omp", name: "oh-my-pi" },
+ { id: "grok", name: "Grok CLI" },
];
export function AgentsSection() {
diff --git a/cmd/berth/addssh.go b/cmd/berth/addssh.go
index 12799ef3..9bc422f1 100644
--- a/cmd/berth/addssh.go
+++ b/cmd/berth/addssh.go
@@ -83,7 +83,7 @@ func addSSHSteps(l laptop, args []string) error {
identity := fs.String("identity", "", "an SSH private key file to log in with (as ssh -i)")
trustKey := fs.String("trust-host-key", "", "trust the box's host key if its fingerprint is this SHA256:… (a new box only; a changed key is never trusted)")
noIntegrations := fs.Bool("no-integrations", false, "don't install hooks and skills for the agent CLIs on the box")
- agentList := fs.String("agents", strings.Join(agentcli.Defaults(), ","), "agent CLIs to install on the box: claude, codex, cursor, opencode, or none")
+ agentList := fs.String("agents", strings.Join(agentcli.Defaults(), ","), "agent CLIs to install on the box: claude, codex, cursor, opencode, grok, or none")
yes := fs.Bool("yes", false, "ask nothing: don't wait for Enter, and stop with the command to run where sudo would ask for a password")
guidedFlag := fs.Bool("guided", false, "show the whole plan first, wait for Enter, and run every step in one terminal; without it, steps run on their own and only one that needs sudo's password asks")
from := fs.String("from", "", "start from this step (connect, berthd, linger, tools, agents, integrations, pair); the ones before it are kept")
diff --git a/cmd/berthd/agents.go b/cmd/berthd/agents.go
index 61939797..e8a2ab30 100644
--- a/cmd/berthd/agents.go
+++ b/cmd/berthd/agents.go
@@ -18,7 +18,7 @@ import (
"github.com/cosscom/shipyard/internal/service"
)
-const agentsUsage = "usage: berthd agents install [--integrations] [--markers] claude|codex|cursor|opencode ... | berthd agents list [--json]"
+const agentsUsage = "usage: berthd agents install [--integrations] [--markers] claude|codex|cursor|opencode|grok ... | berthd agents list [--json]"
// agents installs agent CLIs here, as this user, into ~/.local/bin, with no
// sudo: `berthd agents install claude codex`. The guided install runs it on
diff --git a/cmd/berthd/install_test.go b/cmd/berthd/install_test.go
index 1e8c50cd..ff1786a0 100644
--- a/cmd/berthd/install_test.go
+++ b/cmd/berthd/install_test.go
@@ -126,7 +126,7 @@ func TestLogTail(t *testing.T) {
// finds, and the advice for the others, are integrations' tests.)
func TestInstallSetsUpIntegrationsForAgentsOnThisBox(t *testing.T) {
home, path := t.TempDir(), t.TempDir()
- for _, tool := range []string{"claude", "codex", "cursor-agent"} {
+ for _, tool := range []string{"claude", "codex", "cursor-agent", "grok"} {
os.WriteFile(filepath.Join(path, tool), []byte("#!/bin/sh\n"), 0o755)
}
t.Setenv("HOME", home)
@@ -137,7 +137,7 @@ func TestInstallSetsUpIntegrationsForAgentsOnThisBox(t *testing.T) {
if err != nil || !strings.Contains(string(settings), "/home/alex/.local/bin/berthd hook claude Stop") {
t.Fatalf("settings.json = %s, %v", settings, err)
}
- for _, want := range []string{"Claude Code: hooks and 7 skills in ~/.claude", "hooks added in", "Codex: hooks in ~/.codex; 7 skills in", "notify added in", "Cursor: hooks added"} {
+ for _, want := range []string{"Claude Code: hooks and 7 skills in ~/.claude", "hooks added in", "Codex: hooks in ~/.codex; 7 skills in", "notify added in", "Cursor: hooks added", "Grok CLI: hooks added"} {
if !strings.Contains(out.String(), want) {
t.Errorf("output missing %q:\n%s", want, out.String())
}
diff --git a/cmd/berthd/main.go b/cmd/berthd/main.go
index 21194a01..4dfd2c09 100644
--- a/cmd/berthd/main.go
+++ b/cmd/berthd/main.go
@@ -68,7 +68,7 @@ const usage = `berthd — the berth daemon for a development box
(--check: only say whether it can read now)
berthd mcp A stdio MCP server of berth's tools for agents on this box
(integrations install adds it to Claude, Codex and Gemini)
- berthd agents install [--integrations] [--markers] claude|codex|cursor|opencode ...
+ berthd agents install [--integrations] [--markers] claude|codex|cursor|opencode|grok ...
Install agent CLIs into ~/.local/bin, without sudo (each is
skipped when it is already here)
berthd agents list [--json] Which agent CLIs are here, and how to add the others
diff --git a/docs/changelog.mdx b/docs/changelog.mdx
index 0ba054b7..0553c15a 100644
--- a/docs/changelog.mdx
+++ b/docs/changelog.mdx
@@ -11,6 +11,16 @@ again.
## Unreleased
+- **Grok CLI.** `grok` (xAI Grok Build) is a built-in agent preset: first
+ prompt as the last argument, `--model` and `--effort` as on the CLI.
+ `berthd integrations install grok` writes session-state hooks to
+ `~/.grok/hooks/berth.json`. Skills and MCP are not installed for Grok
+ yet. The guided install and `berthd agents install grok` run xAI's own
+ installer (`curl -fsSL https://x.ai/cli/install.sh | bash`) and link
+ `~/.grok/bin/grok` into `~/.local/bin`. See
+ [First agent](/getting-started/first-agent) and
+ [agent integrations](/guides/agent-integrations).
+
- **A box with a full hostname is named by its first label.** A cloud VM
whose hostname is `devbox.europe-north1-a.c.example.internal` called itself
by all of it: the phone and notifications showed the whole name, and it
diff --git a/docs/concepts/sessions-and-agents.mdx b/docs/concepts/sessions-and-agents.mdx
index 70517078..0beb1c0f 100644
--- a/docs/concepts/sessions-and-agents.mdx
+++ b/docs/concepts/sessions-and-agents.mdx
@@ -109,7 +109,7 @@ announces `task.created`.
An agent is a preset: an id, a name and a command. The box offers each
built-in whose command it finds on its `PATH` (ids `claude`, `codex`,
-`opencode`, `gemini`, `omp` (oh-my-pi), and `cursor`, which runs
+`opencode`, `gemini`, `omp` (oh-my-pi), `grok`, and `cursor`, which runs
`cursor-agent`), plus any a
repository's config adds or replaces. `berth agents BOX` lists the
built-ins the box has; a repository's own presets come with its location
@@ -122,7 +122,7 @@ A first prompt is passed as the command's last argument, or after its
Shipyard knows what an agent is doing from the agent's own hooks, which
`berthd install` sets up for the agent CLIs it finds on the box (later,
`berthd integrations install claude`, or `codex`, `cursor`, `gemini`,
-`opencode`, `omp`, `all`; an upgrade brings hooks Shipyard installed before up to
+`opencode`, `omp`, `grok`, `all`; an upgrade brings hooks Shipyard installed before up to
date):
| State | In the app | Meaning | From |
@@ -244,6 +244,7 @@ screen shows.
| Gemini CLI | ✓ | ✓ | ✓ | ✓ | hooks in `~/.gemini/settings.json` |
| OpenCode | | ✓ | ✓ | ✓ | a plugin in `~/.config/opencode/plugin/berth.js` |
| oh-my-pi | ✓ | ✓ | ✓ | ✓ | an extension in `~/.omp/agent/extensions/berth.ts` |
+| Grok CLI | ✓ | ✓ | ✓ | ✓ | hooks in `~/.grok/hooks/berth.json` |
| anything else | screen | screen | screen | screen | the terminal |
The box lists these as `adapters` in `GET /v1/info`, with `capabilities`
diff --git a/docs/getting-started/add-a-box.mdx b/docs/getting-started/add-a-box.mdx
index 7c8251c3..8951e5bc 100644
--- a/docs/getting-started/add-a-box.mdx
+++ b/docs/getting-started/add-a-box.mdx
@@ -221,8 +221,9 @@ that truly needs `sudo`'s password on the box, and only for that step.
### In the app
**Add a box › Or let Shipyard set it up over SSH** takes `user@host`, with
-**Agents** right below it: Claude Code is ticked; Codex, Cursor Agent and
-OpenCode are there to add, and your choice is remembered for the next box.
+**Agents** right below it: Claude Code is ticked; Codex, Cursor Agent,
+OpenCode and Grok CLI are there to add, and your choice is remembered for
+the next box.
**Install and pair** on a machine [on your tailnet](#on-your-tailnet) has the
same choice. **Set up** opens a compact dialog with a short checklist:
Connect, Install berthd, tmux and git, the agents, Agent integrations, Pair.
@@ -351,8 +352,10 @@ What each step does:
- Codex, its release binary from
[github.com/openai/codex](https://github.com/openai/codex/releases),
pinned to a version and checked against its sha256;
- - Cursor Agent and OpenCode with their own installers (over HTTPS; neither
- publishes checksums for them).
+ - Cursor Agent, OpenCode and Grok CLI with their own installers (over
+ HTTPS; none of them publish checksums). Grok's is xAI's
+ (`curl -fsSL https://x.ai/cli/install.sh | bash`), which puts the
+ binary in `~/.grok/bin`; Shipyard links it into `~/.local/bin`.
Gemini CLI isn't offered: it installs with npm and needs Node.js 20 or
newer, which Shipyard doesn't install. Install Node, then
@@ -399,7 +402,7 @@ Options:
| Flag | What it does |
| --- | --- |
| `--name N` | What to call the box in Shipyard (default: its hostname). It becomes part of URLs like `3000.N.localhost`. |
-| `--agents LIST` | The agent CLIs to install: `claude`, `codex`, `cursor`, `opencode`, comma-separated, or `none` (default `claude`). |
+| `--agents LIST` | The agent CLIs to install: `claude`, `codex`, `cursor`, `opencode`, `grok`, comma-separated, or `none` (default `claude`). |
| `--guided` | Show the whole plan first, wait for Enter, and run every step in one terminal (what Team setup does). Without it, steps run on their own and only one that needs `sudo`'s password asks. |
| `--yes` | Ask nothing, and stop with the command to run where `sudo` would ask for a password. |
| `--from STEP` | Start from a step (`berthd`, `linger`, `tools`, `agents`, `integrations`, `pair`), keeping the ones before it. It still connects first. |
diff --git a/docs/getting-started/first-agent.mdx b/docs/getting-started/first-agent.mdx
index 701d9f2d..365e80bb 100644
--- a/docs/getting-started/first-agent.mdx
+++ b/docs/getting-started/first-agent.mdx
@@ -24,7 +24,7 @@ my-box**). Or run, from the laptop:
ssh me@my-box '~/.local/bin/berthd integrations install claude'
```
-Use `codex`, `cursor` or `all` for other agents. For Codex this sets
+Use `codex`, `cursor`, `grok` or `all` for other agents. For Codex this sets
`notify` in `~/.codex/config.toml`, unless you already use it for something
else; then it prints the line to change yourself. The full path matters: a
command over SSH may not have `~/.local/bin`, where berthd lives, on its
@@ -63,6 +63,7 @@ The built-in agents, offered when their command is installed on the box:
| `opencode` | OpenCode | `opencode --prompt …` |
| `gemini` | Gemini CLI | `gemini -i …` |
| `cursor` | Cursor Agent | `cursor-agent` |
+| `grok` | Grok CLI | `grok` |
A repository can add its own or replace these in
[`.berth/config.json`](/reference/config#agents).
diff --git a/docs/guides/agent-integrations.mdx b/docs/guides/agent-integrations.mdx
index efb31e96..d35f2b57 100644
--- a/docs/guides/agent-integrations.mdx
+++ b/docs/guides/agent-integrations.mdx
@@ -1,6 +1,6 @@
---
title: Agent integrations
-description: Claude Code, Codex, Cursor, Gemini CLI, OpenCode and oh-my-pi hooks, the skills Shipyard teaches agents, and announcing events from any tool.
+description: Claude Code, Codex, Cursor, Gemini CLI, OpenCode, oh-my-pi and Grok CLI hooks, the skills Shipyard teaches agents, and announcing events from any tool.
---
Shipyard works with the tools around it in both directions: tools drive
@@ -10,7 +10,7 @@ between two tools.
## Agent tools → Shipyard
-### Claude Code, Codex, Cursor, Gemini CLI, OpenCode
+### Claude Code, Codex, Cursor, Gemini CLI, OpenCode, oh-my-pi, Grok CLI
```sh
berth integrations install claude # on a laptop
@@ -53,6 +53,12 @@ with the command that installs them.
for approval. Subagents have no UI and report nothing. On a box it also
adds `berthd mcp` to `mcpServers` in `~/.omp/agent/mcp.json`, next to any
servers already there.
+- **Grok CLI**: `SessionStart` / `UserPromptSubmit` / `PostToolUse` /
+ `Notification` / `Stop` / `StopFailure` / `StopCancelled` / `SessionEnd`
+ hooks in `~/.grok/hooks/berth.json`. Only `permission_prompt` (and
+ Claude-compatible question types) mean "needs you"; `idle_prompt` and
+ `task_complete` settle a turn that never reported `Stop`, and are not
+ questions. Grok Build has no skill or MCP install path in Shipyard yet.
### Every account
diff --git a/docs/guides/hooks.mdx b/docs/guides/hooks.mdx
index 4f658768..52cccc1a 100644
--- a/docs/guides/hooks.mdx
+++ b/docs/guides/hooks.mdx
@@ -86,7 +86,7 @@ events, with `box` set, so a laptop hook on `agent.waiting` hears about every
agent on every box.
Agent events come from the agents' own hooks. `berthd integrations install
-claude` (or `codex`, `cursor`, `all`) on a box sets them up; the same command
+claude` (or `codex`, `cursor`, `gemini`, `opencode`, `omp`, `grok`, `all`) on a box sets them up; the same command
with `berth` does it on a laptop.
Anything can announce its own events:
diff --git a/docs/guides/project-config.mdx b/docs/guides/project-config.mdx
index f45e5693..f14cb997 100644
--- a/docs/guides/project-config.mdx
+++ b/docs/guides/project-config.mdx
@@ -155,7 +155,7 @@ config for the project.
`agents` adds ways to start agents in this repository, or replaces a
built-in one with the same `id` (`claude`, `codex`, `opencode`, `gemini`,
-`cursor`). A first prompt goes at the end of `command`, or after
+`cursor`, `grok`). A first prompt goes at the end of `command`, or after
`prompt_flag` if the agent needs one.
### Models and effort
@@ -170,6 +170,7 @@ Shipyard passes them with each CLI's own flags, checked against its `--help`:
| `codex` | `--model` | `-c model_reasoning_effort=` (minimal, low, medium, high) | none: Codex's default |
| `opencode`, `cursor` | `--model` | none | none |
| `omp` | `--model` | `--thinking` (low, medium, high, xhigh, max) | none: omp's default |
+| `grok` | `--model` | `--effort` (none, minimal, low, medium, high, xhigh, max) | none: Grok's default |
Unset means the CLI's default. A value must be a plain name (letters,
digits, `.` `_` `:` `/` `-`); anything else is refused, so nothing reaches a
diff --git a/docs/guides/team-setup.mdx b/docs/guides/team-setup.mdx
index a0bfa819..a9cecd4e 100644
--- a/docs/guides/team-setup.mdx
+++ b/docs/guides/team-setup.mdx
@@ -170,11 +170,11 @@ after the team's steps and before GitHub, like the GitHub and 1Password
steps: **Claude Code and Codex on devbox**, which runs `berthd agents
install --integrations claude codex` in the same terminal. They go in
`~/.local/bin` without sudo (Claude Code with its native installer, Codex
-from its pinned GitHub release with its sha256 checked, Cursor Agent and
-OpenCode with their own installers), each skipped when it is already there,
-and then their hooks and skills. Signing in stays the engineer's: each agent
-asks the first time it starts. `gemini` is refused: it needs Node.js and npm,
-which a team's own step can install.
+from its pinned GitHub release with its sha256 checked, Cursor Agent,
+OpenCode and Grok CLI with their own installers), each skipped when it is
+already there, and then their hooks and skills. Signing in stays the
+engineer's: each agent asks the first time it starts. `gemini` is refused:
+it needs Node.js and npm, which a team's own step can install.
### What the box needs first
@@ -468,7 +468,7 @@ branch is offered as an update, like the org's own.
| `box.os` | the systems the script supports; shown, not enforced |
| `box.script` | a path inside `.berth`; see below |
| `box.steps[]` | `id` (a subcommand of the script), `title`, `detail`, `sudo` (it asks for the password). `all`, `check`, `plan`, `agents`, `github` and `1password` are reserved |
-| `agents` | optional: the agent CLIs Shipyard installs on the box as its own step, `claude`, `codex`, `cursor` and `opencode` ([above](#agent-clis-on-the-box)) |
+| `agents` | optional: the agent CLIs Shipyard installs on the box as its own step, `claude`, `codex`, `cursor`, `opencode` and `grok` ([above](#agent-clis-on-the-box)) |
| `box.settings` | values the team keeps in one place for its script (versions, ports), name → string. Each reaches the script, and each project's `init`, as `BERTH_SETTING_`. Names are letters, digits and `_`; values are one line, at most 4096 bytes; at most 64 settings |
| `projects[]` | `id` (the project's name on the box), `repo` (`owner/name`), `path` (default `~/code/`), `required`, `kit`, `init`, `init_detail`, `first_task`, `review_button` |
| `projects[].id` | lowercase letters, digits and dashes, not starting or ending with a dash. It is part of every worktree's URL (`...localhost`), so an id with a dot is refused: for `acme/acme.com`, use `"id": "site"` with `"path": "~/code/acme.com"` to keep the folder. (An org without a `.berth` names each picked repo this way itself: `acme.com` becomes `acme-com`.) |
diff --git a/docs/reference/berthd.mdx b/docs/reference/berthd.mdx
index 7ed1cb93..100978e7 100644
--- a/docs/reference/berthd.mdx
+++ b/docs/reference/berthd.mdx
@@ -22,7 +22,7 @@ flows, skills) can drive it directly. `berthd help` prints this list.
| `berthd serve [--listen ADDR]` | Serve paired laptops (default: the tailnet address only, port 7444) |
| `berthd install [--listen ADDR] [--keep-listen] [--no-integrations] [--no-tools] [--dry-run]` | Run `serve` as a user service (systemd, or launchd on macOS), and install hooks and skills for the agent CLIs found on the box (`--no-integrations` skips that). First it installs tmux, and git if missing, when that needs no password (as root, with sudo that needs none, or with Homebrew), and otherwise stops with the command to run (`--no-tools` skips this). `--keep-listen` keeps an installed service's non-tailnet address; `--dry-run` checks the box and prints what it would do |
| `berthd uninstall` | Remove that service |
-| `berthd agents install [--integrations] [--markers] claude\|codex\|cursor\|opencode ...` | Install agent CLIs for this user into `~/.local/bin`, without sudo, each skipped when it is already there: Claude Code with its native installer, Codex from its pinned GitHub release (sha256 checked), Cursor Agent and OpenCode with their own installers. Adds `~/.local/bin` to the login shell's `PATH` if it lacks it. `--integrations` then installs hooks and skills for every agent CLI here; `--markers` prints a `::berth-step` line as each starts and ends. The guided install over SSH, a team setup's `agents` and **Add agents…** in the app run it |
+| `berthd agents install [--integrations] [--markers] claude\|codex\|cursor\|opencode\|grok ...` | Install agent CLIs for this user into `~/.local/bin`, without sudo, each skipped when it is already there: Claude Code with its native installer, Codex from its pinned GitHub release (sha256 checked), Cursor Agent, OpenCode and Grok CLI with their own installers. Adds `~/.local/bin` to the login shell's `PATH` if it lacks it. `--integrations` then installs hooks and skills for every agent CLI here; `--markers` prints a `::berth-step` line as each starts and ends. The guided install over SSH, a team setup's `agents` and **Add agents…** in the app run it |
| `berthd agents list [--json]` | Which agent CLIs are here, and how to add the others. An agent counts wherever your interactive shell finds it, an npm install under nvm too ([agent CLIs you installed yourself](/getting-started/add-a-box#agent-clis-you-installed-yourself)), and `agents install` skips it |
| `berthd pair [--address HOST[:PORT]] [--ttl 10m] [--json]` | Print a single-use pairing link; `--json` prints `{ link, address, fingerprint, expires }`, which the laptop agent reads to pair with this computer's own berthd ([Use this Mac](/getting-started/add-a-box#this-mac)) |
| `berthd clients` | List paired laptops |
@@ -237,7 +237,7 @@ refuse a helper, and a `before:helper.attach` gate a look in a worktree's
| Command | What it does |
| --- | --- |
| `berthd integrations [status]` | Where Shipyard's hooks and skills are for each agent CLI on this box, per Claude Code and Codex account (`Claude Code: hooks and 7 skills in ~/.claude, ~/.berth/accounts/claude/personal`), and the command that adds them where they're missing |
-| `berthd integrations install claude\|cursor\|codex\|gemini\|opencode\|omp\|all\|present` | Install Shipyard's skills and agent hooks for a tool (`present`: every agent CLI on this box, quietly skipping the rest), and `berthd mcp` as an MCP server for Claude Code, Codex, Gemini CLI and oh-my-pi. Claude Code and Codex get them in [every account folder](/guides/agent-integrations#every-account) |
+| `berthd integrations install claude\|cursor\|codex\|gemini\|opencode\|omp\|grok\|all\|present` | Install Shipyard's skills and agent hooks for a tool (`present`: every agent CLI on this box, quietly skipping the rest), and `berthd mcp` as an MCP server for Claude Code, Codex, Gemini CLI and oh-my-pi. Claude Code and Codex get them in [every account folder](/guides/agent-integrations#every-account) |
| `berthd hook TOOL EVENT [PAYLOAD]` | What those hooks run: turns a tool's hook into a Shipyard event (`agent.finished`, `agent.waiting`), naming the session from `$BERTH_SESSION`. While berthd is down it keeps the event in `~/.berth/box/spool/`, published when berthd starts |
See [agent integrations](/guides/agent-integrations) for what the hooks and
diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx
index 302ea2ed..19bc3497 100644
--- a/docs/reference/cli.mdx
+++ b/docs/reference/cli.mdx
@@ -214,7 +214,7 @@ See [orchestration](/guides/orchestration) and
| Command | What it does |
| --- | --- |
| `berth integrations [status]` | Where Shipyard's hooks and skills are for each agent CLI on this laptop, per Claude Code and Codex account |
-| `berth integrations install claude\|cursor\|codex\|gemini\|opencode\|omp\|all` | Install Shipyard's skills and agent hooks for a tool, on this laptop, in every account folder it has |
+| `berth integrations install claude\|cursor\|codex\|gemini\|opencode\|omp\|grok\|all` | Install Shipyard's skills and agent hooks for a tool, on this laptop, in every account folder it has |
| `berth hook TOOL EVENT [PAYLOAD]` | What those hooks run: turns a tool's hook into a Shipyard event |
See [agent integrations](/guides/agent-integrations).
diff --git a/docs/reference/config.mdx b/docs/reference/config.mdx
index cfa6c273..574c473d 100644
--- a/docs/reference/config.mdx
+++ b/docs/reference/config.mdx
@@ -38,15 +38,15 @@ guide is in [project config](/guides/project-config).
| Field | Type | Meaning |
| --- | --- | --- |
-| `id` | string | The preset's id. The same id as a built-in (`claude`, `codex`, `opencode`, `gemini`, `cursor`) replaces it. |
+| `id` | string | The preset's id. The same id as a built-in (`claude`, `codex`, `opencode`, `gemini`, `cursor`, `grok`) replaces it. |
| `name` | string | Shown in the app. |
| `command` | string | What to run. |
| `prompt_flag` | string | How the agent takes a first prompt, when it needs a flag (`--prompt`). Empty: the prompt is the last argument. |
| `acp` | string | What follows `command` to start the agent's Agent Client Protocol server (`acp`, `--acp`). With it the agent can run as a [structured agent](/guides/labs#structured-agents). Given with an `id` and no `command`, it adds that to a built-in agent. |
The built-ins are offered when their command is installed on the box:
-`claude`, `codex`, `opencode` (`--prompt`), `gemini` (`-i`), `omp` (oh-my-pi) and
-`cursor` (`cursor-agent`).
+`claude`, `codex`, `opencode` (`--prompt`), `gemini` (`-i`), `omp` (oh-my-pi),
+`cursor` (`cursor-agent`) and `grok`.
### `hooks`
diff --git a/internal/agentcli/agentcli.go b/internal/agentcli/agentcli.go
index da923bdb..2fd12c8c 100644
--- a/internal/agentcli/agentcli.go
+++ b/internal/agentcli/agentcli.go
@@ -1,8 +1,8 @@
// Package agentcli installs the agent CLIs Shipyard runs (Claude Code, Codex,
-// Cursor Agent, OpenCode) on a box, as the box's user, without sudo, into
-// ~/.local/bin. Each install checks first and does nothing when the agent
-// is already there, so running it again is safe. Signing in stays the
-// person's own: nothing here touches an agent's login.
+// Cursor Agent, OpenCode, Grok CLI) on a box, as the box's user, without
+// sudo, into ~/.local/bin. Each install checks first and does nothing when
+// the agent is already there, so running it again is safe. Signing in stays
+// the person's own: nothing here touches an agent's login.
package agentcli
import (
@@ -59,6 +59,7 @@ const (
claudeInstaller = "https://claude.ai/install.sh"
cursorInstaller = "https://cursor.com/install"
opencodeInstaller = "https://opencode.ai/install"
+ grokInstaller = "https://x.ai/cli/install.sh"
)
// Catalog is every agent CLI the add-a-box screen lists, in its order.
@@ -75,6 +76,9 @@ var Catalog = []Agent{
{ID: "opencode", Name: "OpenCode", Command: "opencode", Offered: true,
Install: "curl -fsSL " + opencodeInstaller + " | bash -s -- --no-modify-path",
Verified: "OpenCode's installer, over HTTPS; it publishes no checksums for it"},
+ {ID: "grok", Name: "Grok CLI", Command: "grok", Offered: true,
+ Install: "curl -fsSL " + grokInstaller + " | bash",
+ Verified: "xAI's installer, over HTTPS; xAI publishes no checksums for it"},
{ID: "gemini", Name: "Gemini CLI", Command: "gemini",
Install: "npm install -g @google/gemini-cli",
Why: "it installs with npm and needs Node.js 20 or newer, which Shipyard doesn't install; install Node, then run the command"},
@@ -221,6 +225,9 @@ func (in *Installer) findInstalled(a Agent) (string, bool) {
if a.ID == "opencode" {
dirs = append(dirs, filepath.Join(in.Home, ".opencode", "bin"))
}
+ if a.ID == "grok" {
+ dirs = append(dirs, filepath.Join(in.Home, ".grok", "bin"))
+ }
if a.ID == "claude" {
dirs = append(dirs, filepath.Join(in.Home, ".claude", "local"))
}
@@ -267,6 +274,11 @@ func (in *Installer) Install(ctx context.Context, id string) (Result, error) {
if err == nil {
err = in.linkOpencode()
}
+ case "grok":
+ err = in.script(ctx, a, grokInstaller)
+ if err == nil {
+ err = in.linkGrok()
+ }
}
if err != nil {
return Result{}, fmt.Errorf("%s did not install: %w", a.Name, err)
@@ -410,11 +422,22 @@ func (in *Installer) codex(ctx context.Context) error {
// linkOpencode puts opencode, which its installer keeps in ~/.opencode/bin,
// in ~/.local/bin beside the others, so one PATH entry finds every agent.
func (in *Installer) linkOpencode() error {
- src := filepath.Join(in.Home, ".opencode", "bin", "opencode")
+ return in.linkHomeBin("opencode", filepath.Join(".opencode", "bin", "opencode"))
+}
+
+// linkGrok puts grok, which xAI's installer keeps in ~/.grok/bin, in
+// ~/.local/bin beside the others. The installer may already have linked it
+// there when ~/.local/bin was on PATH; this covers the rest.
+func (in *Installer) linkGrok() error {
+ return in.linkHomeBin("grok", filepath.Join(".grok", "bin", "grok"))
+}
+
+func (in *Installer) linkHomeBin(command, rel string) error {
+ src := filepath.Join(in.Home, rel)
if !executable(src) {
return nil
}
- dest := filepath.Join(in.BinDir(), "opencode")
+ dest := filepath.Join(in.BinDir(), command)
if _, err := os.Lstat(dest); err == nil {
return nil
}
diff --git a/internal/agentcli/agentcli_test.go b/internal/agentcli/agentcli_test.go
index 70232365..1c58c7cd 100644
--- a/internal/agentcli/agentcli_test.go
+++ b/internal/agentcli/agentcli_test.go
@@ -51,6 +51,8 @@ func newFake(t *testing.T) *fake {
exe(t, filepath.Join(home, ".local", "bin", "cursor-agent"))
case strings.Contains(string(script), "opencode"):
exe(t, filepath.Join(home, ".opencode", "bin", "opencode"))
+ case strings.Contains(string(script), "grok"):
+ exe(t, filepath.Join(home, ".grok", "bin", "grok"))
}
return nil
},
@@ -64,6 +66,7 @@ func newFake(t *testing.T) *fake {
f.files[claudeInstaller] = []byte("echo claude installer")
f.files[cursorInstaller] = []byte("echo cursor installer")
f.files[opencodeInstaller] = []byte("echo opencode installer")
+ f.files[grokInstaller] = []byte("echo grok installer")
return f
}
@@ -108,6 +111,28 @@ func TestOpencodeIsLinkedIntoLocalBin(t *testing.T) {
}
}
+func TestGrokIsLinkedIntoLocalBin(t *testing.T) {
+ f := newFake(t)
+ res, err := f.Install(context.Background(), "grok")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Already || res.Path != filepath.Join(f.Home, ".local", "bin", "grok") {
+ t.Errorf("result = %+v", res)
+ }
+ if !reflect.DeepEqual(f.fetched, []string{"https://x.ai/cli/install.sh"}) || len(f.ran) != 1 || !strings.HasPrefix(f.ran[0], "bash echo grok installer") {
+ t.Errorf("fetched %v, ran %v", f.fetched, f.ran)
+ }
+ link, err := os.Readlink(filepath.Join(f.Home, ".local", "bin", "grok"))
+ if err != nil || link != filepath.Join(f.Home, ".grok", "bin", "grok") {
+ t.Errorf("link = %q %v", link, err)
+ }
+ res, err = f.Install(context.Background(), "grok")
+ if err != nil || !res.Already || len(f.fetched) != 1 || len(f.ran) != 1 {
+ t.Errorf("second install: %+v %v; fetched %v ran %v", res, err, f.fetched, f.ran)
+ }
+}
+
func codexArchive(t *testing.T, name string, body []byte) []byte {
t.Helper()
var buf bytes.Buffer
@@ -180,6 +205,9 @@ func TestParseList(t *testing.T) {
if Names([]string{"claude", "codex", "cursor"}) != "Claude Code, Codex and Cursor Agent" {
t.Errorf("names = %q", Names([]string{"claude", "codex", "cursor"}))
}
+ if got, err := ParseList("grok"); err != nil || !reflect.DeepEqual(got, []string{"grok"}) {
+ t.Errorf("grok: %v %v", got, err)
+ }
}
func TestEnsureLoginPATH(t *testing.T) {
diff --git a/internal/agentpath/agentpath.go b/internal/agentpath/agentpath.go
index 0e4b4aef..ba3b23c2 100644
--- a/internal/agentpath/agentpath.go
+++ b/internal/agentpath/agentpath.go
@@ -43,7 +43,7 @@ import (
)
// Commands are the agent CLIs Shipyard starts, looked for together.
-var Commands = []string{"claude", "codex", "opencode", "gemini", "cursor-agent", "pi", "omp"}
+var Commands = []string{"claude", "codex", "opencode", "gemini", "cursor-agent", "pi", "omp", "grok"}
// Found is where an agent's command is.
type Found struct {
@@ -411,6 +411,8 @@ func agentDirs(home, name string) []string {
return []string{filepath.Join(home, ".claude", "local")}
case "opencode":
return []string{filepath.Join(home, ".opencode", "bin")}
+ case "grok":
+ return []string{filepath.Join(home, ".grok", "bin")}
}
return nil
}
diff --git a/internal/box/agentpaths.go b/internal/box/agentpaths.go
index a8a6ea21..80708e71 100644
--- a/internal/box/agentpaths.go
+++ b/internal/box/agentpaths.go
@@ -154,7 +154,7 @@ func agentChecks() []doctor.Check {
}
if found == 0 {
checks = append(checks, doctor.Check{Area: "Agents", Name: "agent CLIs", Status: doctor.Info,
- Detail: "none found (claude, codex, opencode, gemini, cursor-agent, omp), through your shell or where installers put them",
+ Detail: "none found (claude, codex, opencode, gemini, cursor-agent, omp, grok), through your shell or where installers put them",
Fix: "Add agents from Shipyard (Settings → Boxes), or: berthd agents install claude"})
}
st := agentFinder().Status()
diff --git a/internal/box/integrations.go b/internal/box/integrations.go
index c0d827e5..e88a2f42 100644
--- a/internal/box/integrations.go
+++ b/internal/box/integrations.go
@@ -68,7 +68,7 @@ func (b *Box) installIntegrations(w http.ResponseWriter, r *http.Request) error
return err
}
if _, ok := integrations.ToolByID(req.Tool); !ok {
- return badRequest("unknown tool %q; use claude, codex or cursor", req.Tool)
+ return badRequest("unknown tool %q; use %s or all", req.Tool, strings.Join(integrations.AllTools, ", "))
}
home, err := os.UserHomeDir()
if err != nil {
diff --git a/internal/box/services.go b/internal/box/services.go
index 7fb173fd..46263f82 100644
--- a/internal/box/services.go
+++ b/internal/box/services.go
@@ -99,7 +99,7 @@ func (b *Box) handleServices(w http.ResponseWriter, r *http.Request) error {
// Tools reports which agent CLIs this box has.
func Tools() []string {
var have []string
- for _, t := range []string{"claude", "codex", "opencode", "gemini", "pi", "omp"} {
+ for _, t := range []string{"claude", "codex", "opencode", "gemini", "pi", "omp", "grok"} {
if _, ok := agentFound(t); ok {
have = append(have, t)
}
diff --git a/internal/box/stats.go b/internal/box/stats.go
index 932fcd23..95f3843b 100644
--- a/internal/box/stats.go
+++ b/internal/box/stats.go
@@ -55,7 +55,7 @@ type Agent struct {
}
// agentTools are the process names counted as agents.
-var agentTools = map[string]string{"claude": "claude", "codex": "codex", "cursor-agent": "cursor"}
+var agentTools = map[string]string{"claude": "claude", "codex": "codex", "cursor-agent": "cursor", "grok": "grok"}
func (b *Box) handleStats(w http.ResponseWriter, r *http.Request) error {
s := collectStats("/proc")
diff --git a/internal/box/tasks.go b/internal/box/tasks.go
index c4b8f821..4e2b01af 100644
--- a/internal/box/tasks.go
+++ b/internal/box/tasks.go
@@ -45,7 +45,9 @@ type AgentPreset struct {
// Code's --model takes aliases (opus, sonnet, haiku) and --effort low to max;
// Codex takes -m/--model and its reasoning effort as the config key
// model_reasoning_effort; OpenCode and Cursor Agent take --model; oh-my-pi
-// takes --model and --thinking.
+// takes --model and --thinking; Grok CLI takes --model and --effort (none
+// through max, only the levels the model advertises). Grok's first prompt
+// is the last argument, like Claude.
var builtinAgents = []AgentPreset{
{ID: "claude", Name: "Claude Code", Command: "claude", ModelFlag: "--model", EffortFlag: "--effort",
Models: []string{"opus", "sonnet", "haiku"}, Efforts: []string{"low", "medium", "high", "xhigh", "max"}},
@@ -56,6 +58,8 @@ var builtinAgents = []AgentPreset{
{ID: "cursor", Name: "Cursor Agent", Command: "cursor-agent", ModelFlag: "--model", ACP: "acp"},
{ID: "omp", Name: "oh-my-pi", Command: "omp", ModelFlag: "--model", EffortFlag: "--thinking",
Efforts: []string{"low", "medium", "high", "xhigh", "max"}, ACP: "acp"},
+ {ID: "grok", Name: "Grok CLI", Command: "grok", ModelFlag: "--model", EffortFlag: "--effort",
+ Efforts: []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"}},
}
// Presets are the built-in agents this box has, then the location's own from
diff --git a/internal/box/tasks_test.go b/internal/box/tasks_test.go
index 992ce959..99f19669 100644
--- a/internal/box/tasks_test.go
+++ b/internal/box/tasks_test.go
@@ -65,7 +65,7 @@ func TestAgentCommandsQuoteThePrompt(t *testing.T) {
if got := AgentCommand(gemini, "hi"); got != "gemini -i 'hi'" {
t.Fatalf("gemini = %s", got)
}
- if agentOf("/usr/local/bin/claude --resume") != "claude" || agentOf("cat") != "" {
+ if agentOf("/usr/local/bin/claude --resume") != "claude" || agentOf("cat") != "" || agentOf("grok --model grok-build") != "grok" {
t.Fatal("agentOf misread a command")
}
}
@@ -128,6 +128,16 @@ func TestAgentCommandsPassAModelAndAnEffort(t *testing.T) {
if _, err := AgentCommandWith(gemini, "", "pro", ""); err == nil {
t.Fatal("a model for an agent with no model flag was let through")
}
+ grok, _ := presetFor(nil, "grok")
+ if grok.Command != "grok" || grok.Name != "Grok CLI" || grok.PromptFlag != "" || grok.ModelFlag != "--model" || grok.EffortFlag != "--effort" {
+ t.Fatalf("grok preset = %+v", grok)
+ }
+ if got, err := AgentCommandWith(grok, "fix the bug", "grok-build", "high"); err != nil || got != "grok --model grok-build --effort high 'fix the bug'" {
+ t.Fatalf("grok = %q, %v", got, err)
+ }
+ if got, _ := AgentCommandWith(grok, "fix the bug", "", ""); got != "grok 'fix the bug'" {
+ t.Fatalf("grok prompt-only = %q", got)
+ }
}
func TestARepositoryCanListModelsForABuiltInAgent(t *testing.T) {
diff --git a/internal/box/tmux.go b/internal/box/tmux.go
index 819d1836..47f0f6a1 100644
--- a/internal/box/tmux.go
+++ b/internal/box/tmux.go
@@ -177,6 +177,7 @@ var agentInstalls = map[string]string{
"gemini": "npm install -g @google/gemini-cli",
"cursor": "curl https://cursor.com/install -fsS | bash",
"omp": "bun install -g @oh-my-pi/pi-coding-agent",
+ "grok": "curl -fsSL https://x.ai/cli/install.sh | bash",
}
// tmuxRequirement is whether tmux is here and, when it isn't, how to get it.
diff --git a/internal/boxcmd/boxcmd.go b/internal/boxcmd/boxcmd.go
index b47c1e95..cd6c03b6 100644
--- a/internal/boxcmd/boxcmd.go
+++ b/internal/boxcmd/boxcmd.go
@@ -548,7 +548,7 @@ func Run(ctx context.Context, c *box.Client, args []string, out io.Writer) error
}
return show(out, *asJSON, i.Agents, func() {
if len(i.Agents) == 0 {
- fmt.Fprintln(out, "No agent CLIs found on this box (claude, codex, opencode, gemini, cursor-agent, omp).")
+ fmt.Fprintln(out, "No agent CLIs found on this box (claude, codex, opencode, gemini, cursor-agent, omp, grok).")
return
}
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
diff --git a/internal/guided/guided_test.go b/internal/guided/guided_test.go
index 59c5423d..d8ce50f4 100644
--- a/internal/guided/guided_test.go
+++ b/internal/guided/guided_test.go
@@ -145,6 +145,10 @@ func TestParseProbe(t *testing.T) {
if p.OS != "linux" || p.Arch != "arm64" || p.UID != 1000 || p.Tmux || p.Git || p.Manager != "apt-get" || p.Linger != "no" || !p.Sudo || p.SudoNoPassword || !p.Agents["claude"] || p.Agents["codex"] || !p.Tailnet || p.Listen != "100.64.0.2:7444" {
t.Errorf("probe = %+v", p)
}
+ g, err := ParseProbe("os Linux\narch x86_64\nuid 1000\nuser demo\nhome /home/demo\nagent grok\n")
+ if err != nil || !g.Agents["grok"] {
+ t.Errorf("grok probe = %+v %v", g, err)
+ }
for _, bad := range []string{"", "os Plan9\narch x86_64\nuid 1\n", "os Linux\narch riscv64\nuid 1\n"} {
if _, err := ParseProbe(bad); err == nil {
t.Errorf("%q parsed", bad)
diff --git a/internal/guided/plan.go b/internal/guided/plan.go
index b7a3fa47..9e6011e8 100644
--- a/internal/guided/plan.go
+++ b/internal/guided/plan.go
@@ -105,8 +105,8 @@ if command -v sudo >/dev/null 2>&1; then
fi
# An agent counts wherever an installer puts it, npm's under nvm, fnm,
# volta or bun too: SSH's shell reads no ~/.bashrc, where nvm lives.
-for a in claude codex cursor-agent opencode gemini; do
- if command -v "$a" >/dev/null 2>&1 || [ -x "$B/$a" ] || { [ "$a" = opencode ] && [ -x "$HOME/.opencode/bin/opencode" ]; }; then echo "agent $a"; continue; fi
+for a in claude codex cursor-agent opencode grok gemini; do
+ if command -v "$a" >/dev/null 2>&1 || [ -x "$B/$a" ] || { [ "$a" = opencode ] && [ -x "$HOME/.opencode/bin/opencode" ]; } || { [ "$a" = grok ] && [ -x "$HOME/.grok/bin/grok" ]; }; then echo "agent $a"; continue; fi
for p in "$HOME"/.nvm/versions/node/*/bin/"$a" "$HOME"/.local/share/fnm/aliases/default/bin/"$a" "$HOME/.volta/bin/$a" "$HOME/.bun/bin/$a" "$HOME/.npm-global/bin/$a" "/usr/local/bin/$a" "/opt/homebrew/bin/$a"; do
if [ -x "$p" ]; then echo "agent $a"; break; fi
done
diff --git a/internal/integrations/adapters/adapters.go b/internal/integrations/adapters/adapters.go
index b58af025..3108310c 100644
--- a/internal/integrations/adapters/adapters.go
+++ b/internal/integrations/adapters/adapters.go
@@ -63,7 +63,7 @@ func For(name string) (*Adapter, bool) {
// All lists the adapters, for the API and docs.
func All() []*Adapter {
- return []*Adapter{Claude, Codex, Cursor, Gemini, OpenCode, Omp, Screen}
+ return []*Adapter{Claude, Codex, Cursor, Gemini, OpenCode, Omp, Grok, Screen}
}
// CapsFor is an agent's capabilities: the screen's for anything unknown.
diff --git a/internal/integrations/adapters/grok.go b/internal/integrations/adapters/grok.go
new file mode 100644
index 00000000..70c75f1c
--- /dev/null
+++ b/internal/integrations/adapters/grok.go
@@ -0,0 +1,72 @@
+package adapters
+
+// Grok is Grok CLI (xAI Grok Build), through hooks in ~/.grok/hooks/.
+// Its event names match Claude Code's; the JSON on stdin uses camelCase
+// (sessionId, notificationType) rather than Claude's snake_case.
+var Grok = register(&Adapter{
+ Name: "grok",
+ Caps: Caps{Ready: true, Started: true, Waiting: true, Finished: true, Via: "hooks"},
+ Translate: func(hook string, in Payload) (string, map[string]any, bool) {
+ sid := firstOf(in, "sessionId", "session_id")
+ d := map[string]any{"path": in.Str("cwd"), "agent_session_id": sid, "session_id": sid}
+ switch hook {
+ case "SessionStart":
+ return Ready, d, true
+ case "UserPromptSubmit":
+ d["signal"] = "prompt"
+ // Only its short title: the box names an untitled session
+ // after it, and drops it before the event is published.
+ d["title"] = Title(firstOf(in, "prompt"))
+ return Started, d, true
+ case "PostToolUse":
+ // No hook fires when a permission is granted, but the tool it
+ // allowed then runs: the agent is working again.
+ d["signal"] = "tool"
+ return Started, d, true
+ case "Notification":
+ reason, typ, ok := grokNotification(in)
+ if !ok {
+ return "", nil, false
+ }
+ if typ == Finished {
+ return Finished, d, true
+ }
+ d["reason"] = reason
+ // The message goes with the ask, never into the event.
+ if msg := clip(in.Str("message")); msg != "" {
+ d[AskKey] = map[string]any{"message": msg}
+ }
+ return Waiting, d, true
+ case "Stop":
+ return Finished, d, true
+ case "StopFailure":
+ d["status"] = "error"
+ return Finished, d, true
+ case "StopCancelled":
+ // A turn that was interrupted or refused, instead of Stop.
+ d["status"] = "cancelled"
+ return Finished, d, true
+ case "SessionEnd":
+ return Exited, d, true
+ }
+ return "", nil, false
+ },
+})
+
+// grokNotification says whether a Notification is a question, a finished
+// turn, or noise. permission_prompt (and Claude-compatible names) need
+// someone; idle_prompt and task_complete settle a turn that never reported
+// Stop, so they are finished, not waiting. auth_success is a login.
+func grokNotification(in Payload) (reason, typ string, ok bool) {
+ switch firstOf(in, "notificationType", "notification_type") {
+ case "permission_prompt":
+ return "permission", Waiting, true
+ case "elicitation_dialog", "agent_needs_input":
+ return "question", Waiting, true
+ case "idle_prompt", "task_complete":
+ return "", Finished, true
+ case "auth_success":
+ return "", "", false
+ }
+ return "", "", false
+}
diff --git a/internal/integrations/command.go b/internal/integrations/command.go
index 3de70c68..3dc37098 100644
--- a/internal/integrations/command.go
+++ b/internal/integrations/command.go
@@ -19,7 +19,7 @@ const Usage = `Integrations
%[1]s integrations [status]
Where Shipyard's hooks and skills are, per agent
and per Claude Code or Codex account
- %[1]s integrations install claude|cursor|codex|gemini|opencode|omp|all|present
+ %[1]s integrations install claude|cursor|codex|gemini|opencode|omp|grok|all|present
Install Shipyard's skills and agent hooks for a
tool, in every account folder it has
%[1]s hook TOOL EVENT [PAYLOAD]
@@ -79,7 +79,7 @@ func Install(args []string, bin string, out io.Writer) error {
return nil
}
if len(args) < 2 || args[0] != "install" {
- return errors.New("usage: integrations [status] | integrations install claude|cursor|codex|gemini|opencode|omp|all|present")
+ return errors.New("usage: integrations [status] | integrations install claude|cursor|codex|gemini|opencode|omp|grok|all|present")
}
tools := args[1:]
if len(tools) == 1 && tools[0] == "all" {
@@ -151,8 +151,15 @@ func InstallTool(home, tool, bin string, out io.Writer) error {
}
fmt.Fprintf(out, "oh-my-pi: MCP server %s in %s\n", verb(changed), mcp)
}
+ case "grok":
+ hooks := filepath.Join(home, ".grok", "hooks", "berth.json")
+ changed, err := InstallGrokHooks(hooks, bin)
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(out, "Grok CLI: hooks %s in %s\n", verb(changed), hooks)
default:
- return fmt.Errorf("unknown tool %q; use claude, cursor, codex, gemini, opencode, omp, or all", tool)
+ return fmt.Errorf("unknown tool %q; use claude, cursor, codex, gemini, opencode, omp, grok, all, or present", tool)
}
return nil
}
@@ -206,7 +213,7 @@ func verb(changed bool) string {
}
// AllTools are the agents `integrations install all` covers.
-var AllTools = []string{"claude", "cursor", "codex", "gemini", "opencode", "omp"}
+var AllTools = []string{"claude", "cursor", "codex", "gemini", "opencode", "omp", "grok"}
var validSession = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$`)
diff --git a/internal/integrations/detect.go b/internal/integrations/detect.go
index 0b099094..2d66948a 100644
--- a/internal/integrations/detect.go
+++ b/internal/integrations/detect.go
@@ -42,6 +42,9 @@ var Tools = []Tool{
hookFile: filepath.Join(".config", "opencode", "plugin", "berth.js"), hookMarker: "hook opencode"},
{ID: "omp", Name: "oh-my-pi", Command: "omp", configDir: filepath.Join(".omp", "agent"),
hookFile: filepath.Join(".omp", "agent", "extensions", "berth.ts"), hookMarker: "hook omp"},
+ {ID: "grok", Name: "Grok CLI", Command: "grok", configDir: ".grok",
+ hookFile: filepath.Join(".grok", "hooks", "berth.json"), hookMarker: "hook grok",
+ currentMarker: "hook grok StopCancelled"},
}
// ToolByID finds a tool by its ID.
diff --git a/internal/integrations/detect_test.go b/internal/integrations/detect_test.go
index 02c35c87..2165aa04 100644
--- a/internal/integrations/detect_test.go
+++ b/internal/integrations/detect_test.go
@@ -40,7 +40,7 @@ func TestDetectFindsToolsOnPathAndInHome(t *testing.T) {
os.MkdirAll(filepath.Join(home, ".cursor"), 0o755)
present, missing := Detect(home)
- if ids(present) != "claude codex" || ids(missing) != "cursor gemini opencode omp" {
+ if ids(present) != "claude codex" || ids(missing) != "cursor gemini opencode omp grok" {
t.Fatalf("present %q, missing %q", ids(present), ids(missing))
}
}
@@ -66,7 +66,7 @@ func TestInstallDetectedInstallsOnlyWhatIsThereAndOnlyOnce(t *testing.T) {
"Claude Code: hooks and 7 skills in ~/.claude\n hooks added in ~/.claude/settings.json",
"Codex: hooks in ~/.codex; 7 skills in ~/.agents/skills",
"notify added in ~/.codex/config.toml",
- "Not found: Cursor Agent, Gemini CLI, OpenCode, oh-my-pi. After installing one, run: berthd integrations install cursor|gemini|opencode|omp",
+ "Not found: Cursor Agent, Gemini CLI, OpenCode, oh-my-pi, Grok CLI. After installing one, run: berthd integrations install cursor|gemini|opencode|omp|grok",
} {
if !strings.Contains(first, want) {
t.Errorf("output missing %q:\n%s", want, first)
@@ -107,8 +107,8 @@ func TestInstallDetectedWithNoAgentsSaysHowToAddThemLater(t *testing.T) {
if err := InstallDetected(home, "/usr/local/bin/berthd", "/usr/local/bin/berthd", &out); err != nil {
t.Fatal(err)
}
- if !strings.Contains(out.String(), "No agent CLIs found (claude, codex, cursor-agent, gemini, opencode, omp)") ||
- !strings.Contains(out.String(), "/usr/local/bin/berthd integrations install claude|codex|cursor|gemini|opencode|omp") {
+ if !strings.Contains(out.String(), "No agent CLIs found (claude, codex, cursor-agent, gemini, opencode, omp, grok)") ||
+ !strings.Contains(out.String(), "/usr/local/bin/berthd integrations install claude|codex|cursor|gemini|opencode|omp|grok") {
t.Fatalf("output:\n%s", out.String())
}
if entries, _ := os.ReadDir(home); len(entries) != 0 {
diff --git a/internal/integrations/install.go b/internal/integrations/install.go
index e8c3c0b1..8714ca43 100644
--- a/internal/integrations/install.go
+++ b/internal/integrations/install.go
@@ -91,6 +91,27 @@ func InstallGeminiHooks(path, bin string) (bool, error) {
})
}
+// GrokHookEvents are the Grok CLI hooks berth installs. They are Claude
+// Code's names plus StopCancelled, which Grok fires instead of Stop when a
+// turn is interrupted or a permission is refused.
+var GrokHookEvents = []string{"SessionStart", "UserPromptSubmit", "PostToolUse", "Notification", "Stop", "StopFailure", "StopCancelled", "SessionEnd"}
+
+// InstallGrokHooks adds berth's hooks to a Grok CLI hook file
+// (~/.grok/hooks/berth.json), keeping every existing setting and hook.
+func InstallGrokHooks(path, bin string) (bool, error) {
+ f, rel := fileAt(path)
+ return editJSON(f, rel, func(root map[string]any) bool {
+ hooks := object(root, "hooks")
+ changed := false
+ for _, event := range GrokHookEvents {
+ if addNested(hooks, event, hookCommand(bin, "grok", event)) {
+ changed = true
+ }
+ }
+ return changed
+ })
+}
+
// InstallOpenCodePlugin writes berth's OpenCode plugin. It is berth's own
// file, so it is replaced when it differs.
func InstallOpenCodePlugin(path, bin string) (bool, error) {
diff --git a/internal/integrations/install_test.go b/internal/integrations/install_test.go
index 1dd6fb4a..6421fa8c 100644
--- a/internal/integrations/install_test.go
+++ b/internal/integrations/install_test.go
@@ -163,6 +163,19 @@ func TestNewAgentHooksInstallOnceAndKeepOtherSettings(t *testing.T) {
if !strings.Contains(string(b), `"/opt/berthd"} hook opencode`) {
t.Fatalf("plugin = %s", b)
}
+ grok := filepath.Join(dir, "grok.json")
+ if changed, err := InstallGrokHooks(grok, "/opt/berthd"); !changed || err != nil {
+ t.Fatalf("grok: %v %v", changed, err)
+ }
+ if changed, _ := InstallGrokHooks(grok, "/opt/berthd"); changed {
+ t.Fatal("grok hooks were added twice")
+ }
+ b, _ = os.ReadFile(grok)
+ for _, ev := range GrokHookEvents {
+ if !strings.Contains(string(b), "hook grok "+ev) {
+ t.Errorf("grok settings lack %s", ev)
+ }
+ }
claude := filepath.Join(dir, "claude.json")
InstallClaudeHooks(claude, "/opt/berthd")
b, _ = os.ReadFile(claude)
diff --git a/internal/integrations/toolhook_test.go b/internal/integrations/toolhook_test.go
index 049a9318..4c289999 100644
--- a/internal/integrations/toolhook_test.go
+++ b/internal/integrations/toolhook_test.go
@@ -64,6 +64,8 @@ func TestTranslateNeverCopiesContent(t *testing.T) {
{"codex", "PreToolUse", `{"cwd":"/w","tool_name":"Bash","tool_input":{"command":"SECRET-TEXT"}}`},
{"codex", "PostToolUse", `{"cwd":"/w","tool_name":"request_user_input","tool_input":{"questions":["SECRET-TEXT"]},"tool_response":"SECRET-TEXT"}`},
{"cursor", "stop", `{"prompt":"SECRET-TEXT","workspace_roots":["/w"]}`},
+ {"grok", "UserPromptSubmit", `{"prompt":"first line\nSECRET-TEXT","cwd":"/w","sessionId":"g"}`},
+ {"grok", "Notification", `{"message":"SECRET-TEXT","cwd":"/w","notificationType":"permission_prompt"}`},
} {
e, _ := Translate(tc[0], tc[1], []byte(tc[2]))
delete(e.Data, adapters.AskKey) // never published: see TestPermissionRequestAsk
@@ -78,6 +80,7 @@ func TestTranslateNeverCopiesContent(t *testing.T) {
func TestUninterestingHooksAreIgnored(t *testing.T) {
for _, tc := range [][3]string{
{"claude", "PreToolUse", `{}`},
+ {"grok", "PreToolUse", `{}`},
{"cursor", "beforeShellExecution", `{}`},
{"codex", "notify", `{"type":"something-else"}`},
{"orca", "not-an-event-type", `{}`},
@@ -160,6 +163,14 @@ func TestEveryAgentAdapterMapsItsTurn(t *testing.T) {
{"omp", "tool_approval_resolved", `{"cwd":"/w"}`, "agent.started"},
{"omp", "agent_end", `{"cwd":"/w"}`, "agent.finished"},
{"omp", "session_shutdown", `{"cwd":"/w"}`, "agent.exited"},
+ {"grok", "SessionStart", `{"cwd":"/w","sessionId":"g"}`, "agent.ready"},
+ {"grok", "UserPromptSubmit", `{"cwd":"/w","sessionId":"g","prompt":"Fix it"}`, "agent.started"},
+ {"grok", "PostToolUse", `{"cwd":"/w","sessionId":"g"}`, "agent.started"},
+ {"grok", "Notification", `{"cwd":"/w","sessionId":"g","notificationType":"permission_prompt"}`, "agent.waiting"},
+ {"grok", "Stop", `{"cwd":"/w","sessionId":"g"}`, "agent.finished"},
+ {"grok", "StopFailure", `{"cwd":"/w","sessionId":"g"}`, "agent.finished"},
+ {"grok", "StopCancelled", `{"cwd":"/w","sessionId":"g"}`, "agent.finished"},
+ {"grok", "SessionEnd", `{"cwd":"/w","sessionId":"g"}`, "agent.exited"},
} {
e, ok := Translate(tc[0], tc[1], []byte(tc[2]))
if !ok || e.Type != tc[3] || e.Data["path"] != "/w" {
@@ -187,3 +198,34 @@ func TestCodexAsyncQuestionStaysAWait(t *testing.T) {
t.Fatalf("answered question: %+v ok=%v", e, ok)
}
}
+
+func TestGrokNotificationsAndCamelCaseFields(t *testing.T) {
+ e, ok := Translate("grok", "Stop", []byte(`{"sessionId":"g1","cwd":"/w/fix"}`))
+ if !ok || e.Type != "agent.finished" || e.Data["session_id"] != "g1" || e.Data["agent_session_id"] != "g1" {
+ t.Fatalf("grok Stop camelCase = %+v %v", e, ok)
+ }
+ e, ok = Translate("grok", "UserPromptSubmit", []byte(`{"session_id":"g2","cwd":"/w","prompt":"Fix the cart\nSECRET"}`))
+ if !ok || e.Type != "agent.started" || e.Data["title"] != "Fix the cart" || e.Data["session_id"] != "g2" {
+ t.Fatalf("grok UserPromptSubmit snake_case = %+v %v", e, ok)
+ }
+ for _, tc := range []struct {
+ payload, typ, reason string
+ ok bool
+ }{
+ {`{"cwd":"/w","notificationType":"permission_prompt","message":"Allow bash?"}`, "agent.waiting", "permission", true},
+ {`{"cwd":"/w","notification_type":"elicitation_dialog"}`, "agent.waiting", "question", true},
+ {`{"cwd":"/w","notificationType":"idle_prompt"}`, "agent.finished", "", true},
+ {`{"cwd":"/w","notificationType":"task_complete"}`, "agent.finished", "", true},
+ {`{"cwd":"/w","notificationType":"auth_success"}`, "", "", false},
+ {`{"cwd":"/w","notificationType":"something_new"}`, "", "", false},
+ } {
+ e, ok := Translate("grok", "Notification", []byte(tc.payload))
+ delete(e.Data, adapters.AskKey)
+ switch {
+ case !tc.ok && ok:
+ t.Errorf("%s became %+v", tc.payload, e)
+ case tc.ok && (!ok || e.Type != tc.typ || e.Data["reason"] != tc.reason && tc.reason != ""):
+ t.Errorf("%s: %+v ok=%v, want %s (%s)", tc.payload, e, ok, tc.typ, tc.reason)
+ }
+ }
+}
diff --git a/internal/team/team.go b/internal/team/team.go
index f0a4de2a..01f7cf9b 100644
--- a/internal/team/team.go
+++ b/internal/team/team.go
@@ -64,8 +64,8 @@ type Setup struct {
Docs string `json:"docs,omitempty"`
Box Box `json:"box"`
// Agents are the agent CLIs every engineer's box gets ("claude",
- // "codex", "cursor", "opencode"), installed by Shipyard as a step of its
- // own. Signing in to each stays the engineer's.
+ // "codex", "cursor", "opencode", "grok"), installed by Shipyard as a
+ // step of its own. Signing in to each stays the engineer's.
Agents []string `json:"agents,omitempty"`
Projects []Project `json:"projects"`
Keys KeySet `json:"keys,omitempty"`
@@ -385,7 +385,7 @@ func (s *Setup) Validate() error {
}
for _, a := range s.Agents {
if a == "none" || strings.ContainsAny(a, ", ") {
- return fmt.Errorf("agents: %q is not an agent id (claude, codex, cursor, opencode)", a)
+ return fmt.Errorf("agents: %q is not an agent id (claude, codex, cursor, opencode, grok)", a)
}
}
ids, repos := map[string]bool{}, map[string]bool{}
diff --git a/internal/team/team_test.go b/internal/team/team_test.go
index 77293c76..4001fe8d 100644
--- a/internal/team/team_test.go
+++ b/internal/team/team_test.go
@@ -334,6 +334,11 @@ func TestAgents(t *testing.T) {
if err != nil || len(warnings) != 0 || strings.Join(s.Agents, ",") != "claude,codex" {
t.Fatalf("agents: %v %v %v", s, warnings, err)
}
+ doc = strings.Replace(minimal, `"name":"Acme"`, `"name":"Acme","agents":["grok"]`, 1)
+ s, warnings, err = Parse([]byte(doc))
+ if err != nil || len(warnings) != 0 || strings.Join(s.Agents, ",") != "grok" {
+ t.Fatalf("grok: %v %v %v", s, warnings, err)
+ }
for in, want := range map[string]string{
`["gemini"]`: "Node.js",
`["vim"]`: "not an agent",
diff --git a/plugins/activity/src/index.tsx b/plugins/activity/src/index.tsx
index 94dc7e60..d6b165c2 100644
--- a/plugins/activity/src/index.tsx
+++ b/plugins/activity/src/index.tsx
@@ -62,7 +62,7 @@ function where(e: BerthEvent, names?: Names): string {
return path ? path.split("/").filter(Boolean).slice(-1)[0] : "";
}
-const AGENTS: Record = { claude: "Claude", codex: "Codex", opencode: "OpenCode", gemini: "Gemini", omp: "oh-my-pi", cursor: "Cursor" };
+const AGENTS: Record = { claude: "Claude", codex: "Codex", opencode: "OpenCode", gemini: "Gemini", omp: "oh-my-pi", cursor: "Cursor", grok: "Grok" };
const agentName = (a: string) => AGENTS[a] ?? (a || "An agent");
interface Line {