Skip to content

Commit b5ee02f

Browse files
committed
feat(harnesses): add Grok CLI harness with broker MCP injection
Expose `grok` in @agent-relay/harnesses for PTY spawn workflows, register Grok in the CLI registry, and configure Relaycast MCP via `grok mcp add` so spawned agents can message over Relay. Auto-inject `--always-approve` for unattended broker spawns.
1 parent 8f4db31 commit b5ee02f

12 files changed

Lines changed: 271 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- `@agent-relay/harnesses` adds a `grok` PTY harness for the Grok CLI, with broker MCP injection via `grok mcp add` so spawned agents can use Relaycast tools.
1213
- `@agent-relay/harnesses` is now published to npm, so SDK consumers can install the prebuilt PTY harnesses and harness-authoring helpers.
1314
- `agent-relay drive` and `agent-relay passthrough` add adaptive predictive echo so typing stays responsive when driving a high-latency or remote agent, and stays invisible on fast local links.
1415
- `@agent-relay/harness-driver` exports a reusable `PredictiveEchoEngine` so other attach UIs (CLI, Electron, browser) can share one predictive-echo implementation.

crates/broker/src/snippets.rs

Lines changed: 171 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -687,7 +687,7 @@ pub fn ensure_cursor_mcp_config(
687687
Ok(changed)
688688
}
689689

690-
/// - `cli`: CLI tool name (e.g. "claude", "codex", "gemini", "droid", "opencode", "cursor")
690+
/// - `cli`: CLI tool name (e.g. "claude", "codex", "gemini", "droid", "grok", "opencode", "cursor")
691691
/// - `agent_name`: the name of the agent being spawned
692692
/// - `api_key`: optional relay API key (empty or `None` means omit)
693693
/// - `base_url`: optional relay base URL (empty or `None` means omit)
@@ -765,6 +765,7 @@ pub async fn configure_agent_relay_mcp_with_result(
765765
let is_gemini = cli_lower == "gemini";
766766
let is_droid = cli_lower == "droid";
767767
let is_opencode = cli_lower == "opencode";
768+
let is_grok = cli_lower == "grok";
768769
let is_cursor = cli_lower == "cursor" || cli_lower == "cursor-agent" || cli_lower == "agent"; // "agent" is cursor-agent's binary name
769770

770771
let api_key = api_key.map(str::trim).filter(|s| !s.is_empty());
@@ -920,6 +921,17 @@ pub async fn configure_agent_relay_mcp_with_result(
920921
None,
921922
)
922923
.await?;
924+
} else if is_grok {
925+
configure_grok_mcp(
926+
cli,
927+
api_key,
928+
base_url,
929+
Some(agent_name),
930+
agent_token,
931+
workspaces_json,
932+
default_workspace,
933+
)
934+
.await?;
923935
} else if is_opencode && !existing_args.iter().any(|a| a == "--agent") {
924936
ensure_opencode_config_with_result(
925937
cwd,
@@ -1110,6 +1122,138 @@ fn gemini_droid_mcp_add_args_with_result(
11101122
args
11111123
}
11121124

1125+
fn grok_mcp_add_args(
1126+
api_key: Option<&str>,
1127+
base_url: Option<&str>,
1128+
agent_name: Option<&str>,
1129+
agent_token: Option<&str>,
1130+
workspaces_json: Option<&str>,
1131+
default_workspace: Option<&str>,
1132+
) -> Vec<String> {
1133+
let mut args = vec!["mcp".to_string(), "add".to_string()];
1134+
if let Some(key) = api_key {
1135+
args.push("--env".to_string());
1136+
args.push(format!("RELAY_API_KEY={key}"));
1137+
}
1138+
if let Some(url) = base_url {
1139+
args.push("--env".to_string());
1140+
args.push(format!("RELAY_BASE_URL={url}"));
1141+
}
1142+
if let Some(name) = agent_name.map(str::trim).filter(|s| !s.is_empty()) {
1143+
args.push("--env".to_string());
1144+
args.push(format!("RELAY_AGENT_NAME={name}"));
1145+
args.push("--env".to_string());
1146+
args.push("RELAY_AGENT_TYPE=agent".to_string());
1147+
args.push("--env".to_string());
1148+
args.push("RELAY_STRICT_AGENT_NAME=1".to_string());
1149+
}
1150+
if let Some(token) = agent_token.map(str::trim).filter(|s| !s.is_empty()) {
1151+
args.push("--env".to_string());
1152+
args.push(format!("RELAY_AGENT_TOKEN={token}"));
1153+
args.push("--env".to_string());
1154+
args.push("RELAY_SKIP_BOOTSTRAP=1".to_string());
1155+
}
1156+
if let Some(wj) = workspaces_json.map(str::trim).filter(|s| !s.is_empty()) {
1157+
args.push("--env".to_string());
1158+
args.push(format!("RELAY_WORKSPACES_JSON={wj}"));
1159+
}
1160+
if let Some(dw) = default_workspace.map(str::trim).filter(|s| !s.is_empty()) {
1161+
args.push("--env".to_string());
1162+
args.push(format!("RELAY_DEFAULT_WORKSPACE={dw}"));
1163+
}
1164+
args.push(AGENT_RELAY_MCP_SERVER.to_string());
1165+
let mcp_command = agent_relay_mcp_command();
1166+
args.push("--command".to_string());
1167+
args.push(mcp_command.command);
1168+
args.push("--args".to_string());
1169+
args.extend(mcp_command.args);
1170+
args
1171+
}
1172+
1173+
fn grok_manual_mcp_add_cmd(cli: &str) -> String {
1174+
let mcp_command = agent_relay_mcp_command();
1175+
let mut rendered_parts = vec![mcp_command.command];
1176+
rendered_parts.extend(mcp_command.args);
1177+
let rendered_mcp_command = rendered_parts.join(" ");
1178+
format!(
1179+
"{cli} mcp add --env RELAY_API_KEY=<key> --env RELAY_BASE_URL=<url> {AGENT_RELAY_MCP_SERVER} --command {rendered_mcp_command}"
1180+
)
1181+
}
1182+
1183+
#[allow(clippy::too_many_arguments)]
1184+
async fn configure_grok_mcp(
1185+
cli: &str,
1186+
api_key: Option<&str>,
1187+
base_url: Option<&str>,
1188+
agent_name: Option<&str>,
1189+
agent_token: Option<&str>,
1190+
workspaces_json: Option<&str>,
1191+
default_workspace: Option<&str>,
1192+
) -> Result<()> {
1193+
let exe = shlex::split(cli)
1194+
.and_then(|parts| parts.first().cloned())
1195+
.unwrap_or_else(|| cli.trim().to_string());
1196+
let manual_cmd = grok_manual_mcp_add_cmd(&exe);
1197+
1198+
for server_name in [AGENT_RELAY_MCP_SERVER, LEGACY_RELAYCAST_SERVER] {
1199+
let _ = std::process::Command::new(&exe)
1200+
.args(["mcp", "remove", server_name])
1201+
.stdin(Stdio::null())
1202+
.stdout(Stdio::null())
1203+
.stderr(Stdio::null())
1204+
.spawn()
1205+
.and_then(|mut c| c.wait());
1206+
}
1207+
1208+
let mut mcp_cmd = Command::new(&exe);
1209+
mcp_cmd.args(grok_mcp_add_args(
1210+
api_key,
1211+
base_url,
1212+
agent_name,
1213+
agent_token,
1214+
workspaces_json,
1215+
default_workspace,
1216+
));
1217+
mcp_cmd
1218+
.stdin(Stdio::null())
1219+
.stdout(Stdio::null())
1220+
.stderr(Stdio::piped());
1221+
1222+
match mcp_cmd.spawn() {
1223+
Ok(mut child) => match tokio::time::timeout(Duration::from_secs(15), child.wait()).await {
1224+
Ok(Ok(status)) if !status.success() => {
1225+
anyhow::bail!(
1226+
"failed to configure Agent Relay MCP for {cli}: `{cli} mcp add` exited with code {:?}. \
1227+
Please configure the Agent Relay MCP server manually:\n {manual_cmd}",
1228+
status.code()
1229+
);
1230+
}
1231+
Ok(Err(error)) => {
1232+
anyhow::bail!(
1233+
"failed to configure Agent Relay MCP for {cli}: {error}. \
1234+
Please configure the Agent Relay MCP server manually:\n {manual_cmd}"
1235+
);
1236+
}
1237+
Err(_) => {
1238+
let _ = child.kill().await;
1239+
anyhow::bail!(
1240+
"failed to configure Agent Relay MCP for {cli}: `{cli} mcp add` timed out after 15s. \
1241+
Please configure the Agent Relay MCP server manually:\n {manual_cmd}"
1242+
);
1243+
}
1244+
_ => {}
1245+
},
1246+
Err(error) => {
1247+
anyhow::bail!(
1248+
"failed to configure Agent Relay MCP for {cli}: {error}. \
1249+
Please configure the Agent Relay MCP server manually:\n {manual_cmd}"
1250+
);
1251+
}
1252+
}
1253+
1254+
Ok(())
1255+
}
1256+
11131257
#[allow(clippy::too_many_arguments)]
11141258
async fn configure_gemini_droid_mcp(
11151259
cli: &str,
@@ -1584,6 +1728,32 @@ mod tests {
15841728
assert_eq!(args[0], "--mcp-config");
15851729
}
15861730

1731+
#[test]
1732+
fn grok_mcp_add_args_use_command_and_args_flags() {
1733+
let args = super::grok_mcp_add_args(
1734+
Some("rk_live_xyz"),
1735+
Some("https://api.relaycast.dev"),
1736+
Some("GrokWorker"),
1737+
Some("tok_grok_123"),
1738+
None,
1739+
None,
1740+
);
1741+
1742+
assert!(args.starts_with(&["mcp".to_string(), "add".to_string()]));
1743+
assert!(args.contains(&"--env".to_string()));
1744+
assert!(args.contains(&"RELAY_API_KEY=rk_live_xyz".to_string()));
1745+
assert!(args.contains(&"RELAY_AGENT_NAME=GrokWorker".to_string()));
1746+
assert!(args.contains(&"RELAY_AGENT_TOKEN=tok_grok_123".to_string()));
1747+
let server_idx = args
1748+
.iter()
1749+
.position(|arg| arg == "agent-relay")
1750+
.expect("agent-relay arg");
1751+
assert_eq!(args[server_idx + 1], "--command");
1752+
assert_eq!(args[server_idx + 2], "npx");
1753+
assert_eq!(args[server_idx + 3], "--args");
1754+
assert_eq!(args[server_idx + 4], "-y");
1755+
}
1756+
15871757
#[test]
15881758
fn droid_mcp_add_args_include_option_separator() {
15891759
let args = super::gemini_droid_mcp_add_args(

crates/broker/src/worker.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ impl WorkerRegistry {
280280
let is_claude = cli_lower == "claude" || cli_lower.starts_with("claude:");
281281
let is_codex = cli_lower == "codex";
282282
let is_gemini = cli_lower == "gemini";
283+
let is_grok = cli_lower == "grok";
283284
if let Some(model) = apply_codex_model_arg_fallback(
284285
&resolved_cli,
285286
&cli_lower,
@@ -356,6 +357,10 @@ impl WorkerRegistry {
356357
Some("--dangerously-bypass-approvals-and-sandbox")
357358
} else if is_gemini && !effective_args.iter().any(|a| a == "--yolo" || a == "-y") {
358359
Some("--yolo")
360+
} else if is_grok
361+
&& !effective_args.iter().any(|a| a == "--always-approve")
362+
{
363+
Some("--always-approve")
359364
} else {
360365
None
361366
};
@@ -481,6 +486,7 @@ impl WorkerRegistry {
481486
let is_claude = cli_lower == "claude" || cli_lower.starts_with("claude:");
482487
let is_codex = cli_lower == "codex";
483488
let is_gemini = cli_lower == "gemini";
489+
let is_grok = cli_lower == "grok";
484490
if let Some(model) = apply_codex_model_arg_fallback(
485491
&resolved_cli,
486492
&cli_lower,
@@ -559,6 +565,10 @@ impl WorkerRegistry {
559565
&& !effective_args.iter().any(|a| a == "--yolo" || a == "-y")
560566
{
561567
Some("--yolo")
568+
} else if is_grok
569+
&& !effective_args.iter().any(|a| a == "--always-approve")
570+
{
571+
Some("--always-approve")
562572
} else {
563573
None
564574
};

packages/cli/src/cli/telemetry/orchestrator-harness.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ describe('orchestrator harness detection', () => {
1818
it('maps common process command names to harness identifiers', () => {
1919
expect(inferHarnessFromCommand('/usr/local/bin/claude')).toBe('claude-code');
2020
expect(inferHarnessFromCommand('/opt/homebrew/bin/codex')).toBe('codex');
21+
expect(inferHarnessFromCommand('/Users/will/.grok/bin/grok')).toBe('grok');
2122
expect(inferHarnessFromCommand('/Applications/Cursor.app/Contents/MacOS/Cursor')).toBe('cursor');
2223
expect(inferHarnessFromCommand('/usr/local/bin/gemini-cli')).toBe('gemini-cli');
2324
expect(inferHarnessFromCommand(String.raw`C:\Users\will\AppData\Roaming\npm\gemini.cmd`)).toBe(

packages/cli/src/cli/telemetry/orchestrator-harness.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export function inferHarnessFromCommand(command: string | undefined): string | u
4747
if (base === 'opencode' || lower.includes('opencode')) return 'opencode';
4848
if (base === 'goose' || lower.includes('goose')) return 'goose';
4949
if (base === 'droid' || lower.includes('droid')) return 'droid';
50+
if (base === 'grok' || lower.includes('/grok')) return 'grok';
5051
if (base === 'amp' || normalized.includes('/amp')) return 'amp';
5152
if (lower.includes('copilot')) return 'github-copilot';
5253
if (base === 'zed' || lower.includes('zed')) return 'zed';

packages/cloud/src/permissions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type AgentCli =
1414
| 'gemini'
1515
| 'aider'
1616
| 'goose'
17+
| 'grok'
1718
| 'opencode'
1819
| 'droid'
1920
| 'cursor'

packages/config/src/cli-registry.generated.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export const CLIVersions = {
2424
DROID: '0.1.0',
2525
/** OpenCode v1.2.24 */
2626
OPENCODE: '1.2.24',
27+
/** Grok v0.1.0 */
28+
GROK: '0.1.0',
2729
/** Aider v0.72.1 */
2830
AIDER: '0.72.1',
2931
/** Goose v1.0.16 */
@@ -40,6 +42,7 @@ export const CLIs = {
4042
CURSOR: 'cursor',
4143
DROID: 'droid',
4244
OPENCODE: 'opencode',
45+
GROK: 'grok',
4346
AIDER: 'aider',
4447
GOOSE: 'goose',
4548
} as const;
@@ -420,6 +423,18 @@ export const OpencodeModels = {
420423

421424
export type OpencodeModel = (typeof OpencodeModels)[keyof typeof OpencodeModels];
422425

426+
/**
427+
* Grok model identifiers.
428+
*/
429+
export const GrokModels = {
430+
/** Grok Build (default) */
431+
GROK_BUILD: 'grok-build',
432+
/** Grok Composer 2.5 Fast */
433+
GROK_COMPOSER_2_5_FAST: 'grok-composer-2.5-fast',
434+
} as const;
435+
436+
export type GrokModel = (typeof GrokModels)[keyof typeof GrokModels];
437+
423438
/** Reasoning effort levels supported by model providers. */
424439
export const ReasoningEfforts = {
425440
LOW: 'low',
@@ -637,6 +652,14 @@ export const OPENCODE_MODEL_OPTIONS: ModelOption[] = [
637652
{ value: 'openai/o4-mini-deep-research', label: 'O4 Mini Deep Research' },
638653
];
639654

655+
/**
656+
* Grok model options for UI dropdowns.
657+
*/
658+
export const GROK_MODEL_OPTIONS: ModelOption[] = [
659+
{ value: 'grok-build', label: 'Grok Build' },
660+
{ value: 'grok-composer-2.5-fast', label: 'Grok Composer 2.5 Fast' },
661+
];
662+
640663
/**
641664
* Claude Code model metadata keyed by model id.
642665
*/
@@ -836,6 +859,14 @@ export const OPENCODE_MODEL_METADATA: Record<OpencodeModel, ModelOption> = {
836859
'openai/o4-mini-deep-research': { value: 'openai/o4-mini-deep-research', label: 'O4 Mini Deep Research' },
837860
};
838861

862+
/**
863+
* Grok model metadata keyed by model id.
864+
*/
865+
export const GROK_MODEL_METADATA: Record<GrokModel, ModelOption> = {
866+
'grok-build': { value: 'grok-build', label: 'Grok Build' },
867+
'grok-composer-2.5-fast': { value: 'grok-composer-2.5-fast', label: 'Grok Composer 2.5 Fast' },
868+
};
869+
839870
/**
840871
* All models grouped by CLI tool.
841872
*
@@ -854,6 +885,7 @@ export const Models = {
854885
Cursor: CursorModels,
855886
Droid: DroidModels,
856887
Opencode: OpencodeModels,
888+
Grok: GrokModels,
857889
} as const;
858890

859891
/**
@@ -875,6 +907,7 @@ export const ModelOptions = {
875907
Cursor: CURSOR_MODEL_OPTIONS,
876908
Droid: DROID_MODEL_OPTIONS,
877909
Opencode: OPENCODE_MODEL_OPTIONS,
910+
Grok: GROK_MODEL_OPTIONS,
878911
} as const;
879912

880913
/**
@@ -887,6 +920,7 @@ export const ModelMetadata = {
887920
Cursor: CURSOR_MODEL_METADATA,
888921
Droid: DROID_MODEL_METADATA,
889922
Opencode: OPENCODE_MODEL_METADATA,
923+
Grok: GROK_MODEL_METADATA,
890924
} as const;
891925

892926
const MODEL_METADATA_BY_CLI: Record<CLI, Record<string, ModelOption>> = {
@@ -896,6 +930,7 @@ const MODEL_METADATA_BY_CLI: Record<CLI, Record<string, ModelOption>> = {
896930
cursor: CURSOR_MODEL_METADATA,
897931
droid: DROID_MODEL_METADATA,
898932
opencode: OPENCODE_MODEL_METADATA,
933+
grok: GROK_MODEL_METADATA,
899934
aider: {},
900935
goose: {},
901936
};
@@ -1001,6 +1036,13 @@ export const CLIRegistry = {
10011036
install: 'npm install -g opencode-ai',
10021037
npmLink: 'https://www.npmjs.com/package/opencode-ai',
10031038
},
1039+
grok: {
1040+
name: 'Grok',
1041+
package: 'grok',
1042+
version: '0.1.0',
1043+
install: 'Install from x.ai (Grok CLI)',
1044+
npmLink: undefined,
1045+
},
10041046
aider: {
10051047
name: 'Aider',
10061048
package: 'aider-chat',
@@ -1027,4 +1069,5 @@ export const DefaultModels = {
10271069
cursor: 'composer-2-fast',
10281070
droid: 'opus-4.6-fast',
10291071
opencode: 'openai/gpt-5.2',
1072+
grok: 'grok-build',
10301073
} as const;

0 commit comments

Comments
 (0)