Skip to content

Commit 09306b8

Browse files
committed
feat(ghook): add droid CLI route [project-#146]
1 parent 5c9cfe3 commit 09306b8

12 files changed

Lines changed: 223 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ All notable changes to gobby-cli are documented in this file.
77
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
88
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
99

10+
## [0.4.0] — gobby-hooks
11+
12+
### Added
13+
14+
#### gobby-hooks
15+
16+
- **Factory droid hook route**`ghook --gobby-owned --cli=droid --type=<PascalCaseHook>` now treats Factory's droid CLI as a first-class source. Droid hook stdin is passed through unchanged to the unified daemon endpoint as `{"hook_type": "<type>", "input_data": <stdin>, "source": "droid"}`, so the Gobby-side `DroidAdapter` owns the protocol translation.
17+
- **Droid diagnose support**`ghook --diagnose --cli=droid --type=SessionStart` now reports `cli_recognized: true` and `source: "droid"` so installers can probe for droid-capable ghook binaries.
18+
19+
### Changed
20+
21+
#### gobby-hooks
22+
23+
- **Droid blocking semantics** — droid daemon responses with `continue:false` now exit 2 with the daemon reason while preserving the response JSON on stdout. Other droid block JSON is forwarded on stdout with exit 0 for droid's hook protocol, and daemon transport failures surface as exit 1 stderr diagnostics.
24+
1025
## [0.3.1] — gobby-hooks
1126

1227
### Added

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ghook/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "gobby-hooks"
3-
version = "0.3.1"
3+
version = "0.4.0"
44
edition = "2024"
55
rust-version = "1.85"
66
authors = ["Josh Wilhelmi <hello@gobby.ai>"]

crates/ghook/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Sandbox-tolerant hook dispatcher for Gobby.
44

55
`ghook` is invoked by host AI CLIs (Claude Code, Codex, Gemini CLI, Qwen
6-
CLI) on lifecycle and tool-use events. It enqueues an envelope to
6+
CLI, Factory droid) on lifecycle and tool-use events. It enqueues an envelope to
77
`~/.gobby/hooks/inbox/` *before* attempting to POST to the local Gobby
88
daemon — so the daemon's drain worker replays any envelope whose POST
99
was lost to a sandbox FS-read denial, a network blip, or daemon restart.

crates/ghook/schemas/inbox-envelope.v1.schema.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
"source": {
4141
"type": "string",
4242
"minLength": 1,
43-
"description": "Source CLI identifier passed to the daemon (claude, codex, gemini, qwen)."
43+
"description": "Source CLI identifier passed to the daemon (claude, codex, gemini, qwen, droid)."
4444
},
4545
"headers": {
4646
"type": "object",

crates/ghook/src/cli_config.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ impl CliConfig {
5757
.collect(),
5858
json_error_exit_code: 2,
5959
}),
60+
"droid" => Some(Self {
61+
source: "droid",
62+
critical_hooks: HashSet::new(),
63+
terminal_context_hooks: HashSet::new(),
64+
json_error_exit_code: 1,
65+
}),
6066
_ => None,
6167
}
6268
}
@@ -111,6 +117,16 @@ mod tests {
111117
assert_eq!(c.json_error_exit_code, 2);
112118
}
113119

120+
#[test]
121+
fn droid_recognized_with_no_terminal_context_or_critical_hooks() {
122+
let c = CliConfig::for_cli("droid").unwrap();
123+
assert_eq!(c.source, "droid");
124+
assert!(c.critical_hooks.is_empty());
125+
assert!(!c.wants_terminal_context("SessionStart"));
126+
assert!(!c.wants_terminal_context("PreToolUse"));
127+
assert_eq!(c.json_error_exit_code, 1);
128+
}
129+
114130
#[test]
115131
fn unknown_cli_returns_none() {
116132
assert!(CliConfig::for_cli("cursor").is_none());
@@ -120,6 +136,7 @@ mod tests {
120136
fn cli_name_is_case_insensitive() {
121137
assert!(CliConfig::for_cli("CLAUDE").is_some());
122138
assert!(CliConfig::for_cli("Codex").is_some());
139+
assert!(CliConfig::for_cli("Droid").is_some());
123140
}
124141

125142
#[test]

crates/ghook/src/diagnose.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,16 @@ mod tests {
150150
assert!(d.terminal_context_enabled);
151151
}
152152

153+
#[test]
154+
fn droid_session_start_is_recognized_noncritical_without_terminal_context() {
155+
let d = diagnose("droid", "SessionStart");
156+
assert!(d.cli_recognized);
157+
assert_eq!(d.source.as_deref(), Some("droid"));
158+
assert!(!d.critical);
159+
assert!(!d.terminal_context_enabled);
160+
assert!(d.terminal_context_preview.is_none());
161+
}
162+
153163
fn compile_v2_schema() -> jsonschema::JSONSchema {
154164
let schema_bytes = include_bytes!("../schemas/diagnose-output.v2.schema.json");
155165
let schema: serde_json::Value = serde_json::from_slice(schema_bytes).unwrap();

crates/ghook/src/envelope.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,31 @@ mod tests {
8383
assert!(v["enqueued_at"].as_str().unwrap().contains('T'));
8484
}
8585

86+
#[test]
87+
fn droid_envelope_preserves_pascal_hook_and_source() {
88+
let env = Envelope::new(
89+
false,
90+
"PreToolUse".into(),
91+
json!({
92+
"session_id": "droid-session",
93+
"transcript_path": "/tmp/droid.jsonl",
94+
"cwd": "/tmp/project",
95+
"permission_mode": "default",
96+
"hook_event_name": "PreToolUse",
97+
"tool_name": "Read",
98+
"tool_input": {"file_path": "src/main.rs"}
99+
}),
100+
"droid".into(),
101+
BTreeMap::new(),
102+
);
103+
let v: Value = serde_json::to_value(&env).unwrap();
104+
105+
assert_eq!(v["hook_type"], "PreToolUse");
106+
assert_eq!(v["source"], "droid");
107+
assert_eq!(v["input_data"]["hook_event_name"], "PreToolUse");
108+
assert_eq!(v["input_data"]["tool_input"]["file_path"], "src/main.rs");
109+
}
110+
86111
#[test]
87112
fn empty_headers_serialize_as_empty_object() {
88113
let env = Envelope::new(

crates/ghook/src/main.rs

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ struct Args {
6060
#[arg(long)]
6161
version: bool,
6262

63-
/// Host CLI name (claude, codex, gemini, qwen).
63+
/// Host CLI name (claude, codex, gemini, qwen, droid).
6464
#[arg(long)]
6565
cli: Option<String>,
6666

@@ -303,7 +303,7 @@ fn emit_action(action: HookAction) -> ExitCode {
303303
}
304304

305305
fn action_from_success_response(
306-
_canonical_source: &str,
306+
canonical_source: &str,
307307
hook_type: &str,
308308
response_body: &str,
309309
) -> Result<HookAction, String> {
@@ -319,6 +319,10 @@ fn action_from_success_response(
319319
let result: Value = serde_json::from_str(trimmed).map_err(|e| e.to_string())?;
320320
let serialized = serde_json::to_string(&result).map_err(|e| e.to_string())?;
321321

322+
if canonical_source == "droid" {
323+
return Ok(action_from_droid_success(result, serialized));
324+
}
325+
322326
if is_blocked(&result) {
323327
if hook_type != "Stop" {
324328
return Ok(HookAction {
@@ -341,12 +345,47 @@ fn action_from_success_response(
341345
})
342346
}
343347

348+
fn action_from_droid_success(result: Value, serialized: String) -> HookAction {
349+
if result
350+
.as_object()
351+
.and_then(|map| map.get("continue"))
352+
.and_then(Value::as_bool)
353+
== Some(false)
354+
{
355+
return HookAction {
356+
exit_code: 2,
357+
stdout_json: Some(serialized),
358+
stderr_message: Some(extract_reason(&result)),
359+
};
360+
}
361+
362+
HookAction {
363+
exit_code: 0,
364+
stdout_json: json_value_is_meaningful(&result).then_some(serialized),
365+
stderr_message: None,
366+
}
367+
}
368+
344369
fn action_from_failure(
345370
hook_type: &str,
346371
cfg: &CliConfig,
347372
failure_kind: transport::DeliveryFailureKind,
348373
detail: &str,
349374
) -> HookAction {
375+
if cfg.source == "droid" {
376+
let message = match failure_kind {
377+
transport::DeliveryFailureKind::Http => format!("Daemon error: {detail}"),
378+
transport::DeliveryFailureKind::Connect => "Daemon unreachable".to_string(),
379+
transport::DeliveryFailureKind::Timeout => "Hook execution timeout".to_string(),
380+
transport::DeliveryFailureKind::Other => detail.to_string(),
381+
};
382+
return HookAction {
383+
exit_code: 1,
384+
stdout_json: None,
385+
stderr_message: Some(message),
386+
};
387+
}
388+
350389
if cfg.is_critical_hook(hook_type) {
351390
let reason = match failure_kind {
352391
transport::DeliveryFailureKind::Http => format!(
@@ -605,6 +644,42 @@ mod tests {
605644
);
606645
}
607646

647+
#[test]
648+
fn action_from_success_treats_droid_continue_false_as_exit_two_with_json() {
649+
let action = action_from_success_response(
650+
"droid",
651+
"PreToolUse",
652+
r#"{"continue":false,"stopReason":"Create a task first"}"#,
653+
)
654+
.unwrap();
655+
656+
assert_eq!(action.exit_code, 2);
657+
let stdout_json = action.stdout_json.unwrap();
658+
let parsed: Value = serde_json::from_str(&stdout_json).unwrap();
659+
assert_eq!(parsed["continue"], false);
660+
assert_eq!(
661+
action.stderr_message.as_deref(),
662+
Some("Create a task first")
663+
);
664+
}
665+
666+
#[test]
667+
fn action_from_success_preserves_droid_block_json_without_exit_two() {
668+
let action = action_from_success_response(
669+
"droid",
670+
"Stop",
671+
r#"{"decision":"block","reason":"Task still in progress"}"#,
672+
)
673+
.unwrap();
674+
675+
assert_eq!(action.exit_code, 0);
676+
let stdout_json = action.stdout_json.unwrap();
677+
let parsed: Value = serde_json::from_str(&stdout_json).unwrap();
678+
assert_eq!(parsed["decision"], "block");
679+
assert_eq!(parsed["reason"], "Task still in progress");
680+
assert_eq!(action.stderr_message, None);
681+
}
682+
608683
#[test]
609684
fn action_from_failure_blocks_critical_hooks() {
610685
let action = action_from_failure(
@@ -668,6 +743,22 @@ mod tests {
668743
);
669744
}
670745

746+
#[test]
747+
fn action_from_failure_returns_stderr_for_droid_transport_errors() {
748+
let action = action_from_failure(
749+
"PreToolUse",
750+
&CliConfig::for_dispatch("droid"),
751+
DeliveryFailureKind::Http,
752+
"Internal Server Error",
753+
);
754+
assert_eq!(action.exit_code, 1);
755+
assert!(action.stdout_json.is_none());
756+
assert_eq!(
757+
action.stderr_message.as_deref(),
758+
Some("Daemon error: Internal Server Error")
759+
);
760+
}
761+
671762
#[test]
672763
fn hooks_disabled_by_env_reads_env_var() {
673764
// Avoid racing other tests that read GOBBY_* env vars — touching the

crates/ghook/src/transport.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,51 @@ mod tests {
439439
assert!(!path.exists());
440440
}
441441

442+
#[test]
443+
fn post_and_cleanup_sends_droid_source_to_unified_hooks_endpoint() {
444+
let dir = tempdir().unwrap();
445+
let inbox = dir.path().join("inbox");
446+
let envelope = Envelope::new(
447+
false,
448+
"PreToolUse".into(),
449+
serde_json::json!({
450+
"session_id": "droid-session",
451+
"hook_event_name": "PreToolUse",
452+
"tool_name": "Read",
453+
"tool_input": {"file_path": "src/main.rs"}
454+
}),
455+
"droid".into(),
456+
BTreeMap::new(),
457+
);
458+
let path = enqueue_to(&envelope, &inbox).unwrap();
459+
460+
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
461+
let addr = listener.local_addr().unwrap();
462+
let handle = thread::spawn(move || {
463+
let (mut stream, _) = listener.accept().unwrap();
464+
let request = read_http_request(&mut stream);
465+
assert!(request.contains("POST /api/hooks/execute HTTP/1.1"));
466+
assert!(request.contains("\"hook_type\":\"PreToolUse\""));
467+
assert!(request.contains("\"source\":\"droid\""));
468+
assert!(request.contains("\"input_data\":{\"hook_event_name\":\"PreToolUse\""));
469+
assert!(request.contains("\"tool_input\":{\"file_path\":\"src/main.rs\"}"));
470+
stream
471+
.write_all(
472+
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}",
473+
)
474+
.unwrap();
475+
});
476+
477+
let report = post_and_cleanup(&envelope, &path, &format!("http://{addr}"));
478+
handle.join().unwrap();
479+
480+
assert_eq!(report.outcome, DeliveryOutcome::Delivered);
481+
assert_eq!(report.failure_kind, None);
482+
assert_eq!(report.status_code, Some(200));
483+
assert_eq!(report.response_body, Some("{}".to_string()));
484+
assert!(!path.exists());
485+
}
486+
442487
#[test]
443488
fn post_and_cleanup_captures_http_error_body() {
444489
let dir = tempdir().unwrap();

0 commit comments

Comments
 (0)