Repository navigation
feat: add CodeBuddy Code support #1967
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| # CodeBuddy Code Hooks | ||
|
|
||
| > Part of [`hooks/`](../README.md) — see also [`src/hooks/`](../../src/hooks/README.md) for installation code | ||
|
|
||
| ## Specifics | ||
|
|
||
| - Uses the `rtk hook codebuddy` Rust binary — no `jq` dependency | ||
| - Same `PreToolUse` JSON protocol as Claude Code (`tool_name` + `tool_input.command`), with two differences: requires top-level `"continue": true` and a `"permissionDecision"` field (`"allow"`, `"ask"`, or `"deny"`) | ||
| - Emits `updatedInput` (not `modifiedInput` — CodeBuddy upstream has converged on `updatedInput`) | ||
| - `CODEBUDDY.md` is generated at install time from the shared `hooks/claude/rtk-awareness.md` template (no per-agent copy shipped in this directory) | ||
| - Installed globally via `rtk init -g --agent codebuddy`; there is no project-scoped variant | ||
|
|
||
| ## Notes | ||
|
|
||
| - CodeBuddy is an independent product, not a Claude Code fork. The `PreToolUse` JSON shape is the only shared contract; if either agent evolves its schema, RTK tracks them independently |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -598,13 +598,7 @@ fn process_claude_payload(v: &Value) -> PayloadAction { | |
| HookDecision::AskRewrite(r) => (r, false), | ||
| }; | ||
|
|
||
| let updated_input = { | ||
| let mut ti = v.get("tool_input").cloned().unwrap_or_else(|| json!({})); | ||
| if let Some(obj) = ti.as_object_mut() { | ||
| obj.insert("command".into(), Value::String(rewritten.clone())); | ||
| } | ||
| ti | ||
| }; | ||
| let updated_input = build_updated_input(v, rewritten.clone()); | ||
|
|
||
| let mut hook_output = json!({ | ||
| "hookEventName": PRE_TOOL_USE_KEY, | ||
|
|
@@ -626,6 +620,17 @@ fn process_claude_payload(v: &Value) -> PayloadAction { | |
| } | ||
| } | ||
|
|
||
| /// Build an `updatedInput` object by cloning the original `tool_input` | ||
| /// and overriding the `command` field. Preserves sibling fields like | ||
| /// `timeout` and `description` that may be present in the hook payload. | ||
| fn build_updated_input(v: &Value, rewritten: String) -> Value { | ||
| let mut ti = v.get("tool_input").cloned().unwrap_or_else(|| json!({})); | ||
| if let Some(obj) = ti.as_object_mut() { | ||
| obj.insert("command".into(), Value::String(rewritten)); | ||
| } | ||
| ti | ||
| } | ||
|
|
||
| /// Run the Claude Code PreToolUse hook natively. | ||
| pub fn run_claude() -> Result<()> { | ||
| let input = read_stdin_limited()?; | ||
|
|
@@ -670,6 +675,109 @@ fn run_claude_inner(input: &str) -> Option<String> { | |
| } | ||
| } | ||
|
|
||
| // ── CodeBuddy native hook ───────────────────────────────── | ||
| // | ||
| // CodeBuddy (Tencent Cloud AI code editor) uses a hook protocol compatible | ||
| // with Claude Code's specification, with these differences in the output JSON: | ||
| // 1. Requires top-level `"continue": true` | ||
| // 2. Reads `"updatedInput"` (CodeBuddy's older IDE builds used `modifiedInput`; | ||
| // upstream has converged on `updatedInput`, so RTK emits `updatedInput`) | ||
| // 3. Mirrors RTK's verdict: AllowRewrite -> `"allow"`, AskRewrite -> `"ask"`. | ||
| // Earlier IDE builds ignored the rewritten input when `permissionDecision` | ||
| // was `"ask"` (an upstream Bug fixed in a later release), so RTK briefly | ||
| // collapsed both to `"allow"`. With the fix in place the distinction is | ||
| // honored again. | ||
|
|
||
| /// Run the CodeBuddy PreToolUse hook natively. | ||
| pub fn run_codebuddy() -> Result<()> { | ||
| let input = read_stdin_limited()?; | ||
|
|
||
| let input = input.trim(); | ||
| if input.is_empty() { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| let v: Value = match serde_json::from_str(input) { | ||
| Ok(v) => v, | ||
| Err(e) => { | ||
| let _ = writeln!(io::stderr(), "[rtk hook] Failed to parse JSON input: {e}"); | ||
| return Ok(()); | ||
| } | ||
| }; | ||
|
|
||
| let cmd = match codebuddy_execute_command(&v) { | ||
| Some(c) => c.to_string(), | ||
| None => return Ok(()), | ||
| }; | ||
|
|
||
| // Use CodeBuddy's own permission settings, not Claude's. | ||
| let decision = decide_hook_action(&cmd, permissions::Host::CodeBuddy); | ||
|
|
||
| if let Some(response) = codebuddy_response_from_decision(&v, &cmd, decision) { | ||
| let _ = writeln!(io::stdout(), "{response}"); | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
| /// Extract the shell command when the payload targets CodeBuddy's shell tool. | ||
| /// | ||
| /// CodeBuddy uses `Bash` in CLI mode and `execute_command` in IDE mode (the | ||
| /// installed matcher is `Bash|execute_command`). Tolerate a missing `tool_name` | ||
| /// defensively, mirroring `droid_execute_command`. | ||
| fn codebuddy_execute_command(v: &Value) -> Option<&str> { | ||
| let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or(""); | ||
| if !matches!(tool_name, "Bash" | "execute_command" | "") { | ||
| return None; | ||
| } | ||
|
Comment on lines
+727
to
+731
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This gating has no test coverage. The function it mirrors is tested for exactly this behaviour:
The four CodeBuddy tests ( Two small tests mirroring the Droid pair would close it — a payload with |
||
|
|
||
| v.pointer("/tool_input/command") | ||
| .and_then(|c| c.as_str()) | ||
| .filter(|c| !c.is_empty()) | ||
| } | ||
|
|
||
| /// Build the CodeBuddy `PreToolUse` hook response for a verdict, mirroring RTK's | ||
| /// own ask/allow distinction. `Deny`/`AllowRewrite`/`AskRewrite` return a | ||
| /// response; `Defer` returns `None` (no output, command untouched). | ||
| /// | ||
| /// CodeBuddy differs from Claude Code by requiring top-level `"continue": true`. | ||
| /// It reads the same `updatedInput` field as Claude Code (CodeBuddy's older IDE | ||
| /// builds used `modifiedInput`, but upstream has converged on `updatedInput`, so | ||
| /// RTK emits `updatedInput` for both). Earlier IDE builds ignored the rewritten | ||
| /// input when `permissionDecision` was `"ask"` — an upstream Bug fixed in a | ||
| /// later release — so RTK briefly collapsed both rewrites to `"allow"`. With the | ||
| /// fix in place the ask/allow distinction is honored again. | ||
| fn codebuddy_response_from_decision(v: &Value, cmd: &str, decision: HookDecision) -> Option<Value> { | ||
| let (permission, reason, rewritten) = match decision { | ||
| HookDecision::Defer => return None, | ||
| HookDecision::Deny => { | ||
| audit_log("deny", cmd, ""); | ||
| ("deny", "Blocked by RTK permission rule", None) | ||
| } | ||
| HookDecision::AllowRewrite(r) => { | ||
| audit_log("rewrite", cmd, &r); | ||
| ("allow", "RTK auto-rewrite", Some(r)) | ||
| } | ||
| HookDecision::AskRewrite(r) => { | ||
| audit_log("ask", cmd, &r); | ||
| ("ask", "RTK auto-rewrite", Some(r)) | ||
| } | ||
|
Comment on lines
+760
to
+763
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This makes CodeBuddy prompt on every rewritten command.
// hook_cmd.rs:266-270
match get_rewritten(cmd) {
Some(r) if verdict == PermissionVerdict::Allow => HookDecision::AllowRewrite(r),
Some(r) => HookDecision::AskRewrite(r), // <- Default lands here too
None => HookDecision::Defer,
}So this arm asserts This is the regression already documented in this file at
Both sibling paths follow that rule and insert the field only when the verdict is an explicit Allow — I realise honouring the ask/allow distinction was deliberate, and now that the IDE bug is fixed the rewrite does survive the prompt. But #3037 was about prompt fatigue, not correctness — and that argument applies here unchanged. Two ways out:
The second preserves your intent while keeping the default install quiet. |
||
| }; | ||
|
|
||
| let mut hook_output = json!({ | ||
| "hookEventName": PRE_TOOL_USE_KEY, | ||
| "permissionDecision": permission, | ||
| "permissionDecisionReason": reason, | ||
| }); | ||
| if let Some(rewritten) = rewritten { | ||
| hook_output["updatedInput"] = build_updated_input(v, rewritten); | ||
| } | ||
| Some(json!({ | ||
| "continue": true, | ||
| "permissionDecision": permission, | ||
| "hookSpecificOutput": hook_output, | ||
| })) | ||
| } | ||
|
studyzy marked this conversation as resolved.
|
||
|
|
||
| // ── Cursor native hook ───────────────────────────────────────── | ||
|
|
||
| /// Cursor on Windows ships hook payloads with one or more leading | ||
|
|
@@ -833,13 +941,7 @@ fn droid_response_from_decision(v: &Value, cmd: &str, decision: HookDecision) -> | |
|
|
||
| audit_log("rewrite", cmd, &rewritten); | ||
|
|
||
| let updated_input = { | ||
| let mut ti = v.get("tool_input").cloned().unwrap_or_else(|| json!({})); | ||
| if let Some(obj) = ti.as_object_mut() { | ||
| obj.insert("command".into(), Value::String(rewritten)); | ||
| } | ||
| ti | ||
| }; | ||
| let updated_input = build_updated_input(v, rewritten); | ||
|
|
||
| Some(json!({ | ||
| "hookSpecificOutput": { | ||
|
|
@@ -1565,6 +1667,66 @@ mod tests { | |
| assert_eq!(v["continue"], true); | ||
| } | ||
|
|
||
| // ── CodeBuddy hook ────────────────────────────────────────── | ||
|
|
||
| fn codebuddy_payload(cmd: &str) -> Value { | ||
| json!({ "tool_name": "Bash", "tool_input": { "command": cmd } }) | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_codebuddy_allow_rewrite_sets_allow_with_updated_input() { | ||
| let v = codebuddy_payload("git status"); | ||
| let r = codebuddy_response_from_decision( | ||
| &v, | ||
| "git status", | ||
| HookDecision::AllowRewrite("rtk git status".into()), | ||
| ) | ||
| .unwrap(); | ||
| assert_eq!(r["continue"], true); | ||
| assert_eq!(r["permissionDecision"], "allow"); | ||
| assert_eq!(r["hookSpecificOutput"]["permissionDecision"], "allow"); | ||
| assert_eq!( | ||
| r["hookSpecificOutput"]["updatedInput"]["command"], | ||
| "rtk git status" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_codebuddy_ask_rewrite_sets_ask_with_updated_input() { | ||
| let v = codebuddy_payload("git status"); | ||
| let r = codebuddy_response_from_decision( | ||
| &v, | ||
| "git status", | ||
| HookDecision::AskRewrite("rtk git status".into()), | ||
| ) | ||
| .unwrap(); | ||
| assert_eq!(r["continue"], true); | ||
| assert_eq!(r["permissionDecision"], "ask"); | ||
| assert_eq!(r["hookSpecificOutput"]["permissionDecision"], "ask"); | ||
| assert_eq!( | ||
| r["hookSpecificOutput"]["updatedInput"]["command"], | ||
| "rtk git status" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_codebuddy_deny_omits_updated_input() { | ||
| let v = codebuddy_payload("git status"); | ||
| let r = codebuddy_response_from_decision(&v, "git status", HookDecision::Deny).unwrap(); | ||
| assert_eq!(r["permissionDecision"], "deny"); | ||
| assert_eq!( | ||
| r["hookSpecificOutput"]["permissionDecisionReason"], | ||
| "Blocked by RTK permission rule" | ||
| ); | ||
| assert!(r["hookSpecificOutput"].get("updatedInput").is_none()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_codebuddy_defer_returns_none() { | ||
| let v = codebuddy_payload("git status"); | ||
| assert!(codebuddy_response_from_decision(&v, "git status", HookDecision::Defer).is_none()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_cursor_substitution_defers_even_when_allowed() { | ||
| assert_eq!( | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.