Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ rtk init --agent kimi # Kimi AI
rtk init -g --agent pi # Pi
rtk init --agent hermes # Hermes
rtk init -g --agent droid # Factory Droid
rtk init -g --agent codebuddy # CodeBuddy

# 2. Restart your AI tool, then test
git status # Automatically rewritten to rtk git status
Expand Down Expand Up @@ -381,7 +382,7 @@ rtk init -g

## Supported AI Tools

RTK supports 16 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents, reducing the bash output the agent reads where the agent supports command interception.
RTK supports 17 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents, reducing the bash output the agent reads where the agent supports command interception.

| Tool | Install | Method |
|------|---------|--------|
Expand All @@ -402,6 +403,7 @@ RTK supports 16 AI coding tools. Each integration rewrites shell commands to `rt
| **Google Antigravity** | `rtk init --agent antigravity` | .agents/rules/antigravity-rtk-rules.md (project-scoped) |
| **Kimi AI** | `rtk init --agent kimi` | AGENTS.md (project-scoped) |
| **Factory Droid** | `rtk init -g --agent droid` (or per-project) | PreToolUse hook in `~/.factory/hooks.json` (matcher `Execute`) |
| **CodeBuddy** | `rtk init -g --agent codebuddy` | PreToolUse hook (bash) + CODEBUDDY.md |

For per-agent setup details, override controls, and graceful degradation, see the [Supported Agents guide](https://www.rtk-ai.app/guide/getting-started/supported-agents). The Hermes plugin source and tests live in `hooks/hermes/`; installed Hermes runtime files still live under `~/.hermes/plugins/rtk-rewrite/`.

Expand Down
19 changes: 17 additions & 2 deletions docs/guide/getting-started/supported-agents.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Supported Agents
description: How to integrate RTK with Claude Code, Cursor, Copilot, Cline, Windsurf, Codex, OpenCode, Hermes, Kilo Code, Antigravity, Factory Droid, and Mistral Vibe
description: How to integrate RTK with Claude Code, Cursor, Copilot, Cline, Windsurf, Codex, OpenCode, Hermes, Kilo Code, Antigravity, Factory Droid, Mistral Vibe, and CodeBuddy
sidebar:
order: 3
---
Expand Down Expand Up @@ -44,6 +44,7 @@ Agent runs "cargo test"
| Kilo Code | Rules file (prompt-level) | N/A |
| Google Antigravity | Rules file (prompt-level) | N/A |
| Mistral Vibe | Rust binary (`pre_tool`) | Yes |
| CodeBuddy | Rust binary (`PreToolUse`) | Yes |

## Installation by agent

Expand Down Expand Up @@ -220,6 +221,20 @@ rtk init -g --agent vibe --uninstall

Strips only RTK's `[[hooks]]` block and the `~/.vibe/prompts/rtk.md` file. Any other user-declared hooks in `hooks.toml` are preserved byte-for-byte. `hooks.toml` is removed only when the RTK entry was the sole content.

### CodeBuddy

```bash
rtk init -g --agent codebuddy # installs hook + patches ~/.codebuddy/settings.json
```

CodeBuddy uses the same `PreToolUse` hook mechanism as Claude Code. The hook transparently rewrites Bash commands before execution. A `CODEBUDDY.md` file with RTK awareness instructions is also installed to `~/.codebuddy/CODEBUDDY.md`.

Uninstall:

```bash
rtk init --uninstall -g --agent codebuddy
```

## Integration tiers explained

| Tier | Mechanism | How rewrites work |
Expand All @@ -228,7 +243,7 @@ Strips only RTK's `[[hooks]]` block and the `~/.vibe/prompts/rtk.md` file. Any o
| **Plugin** | TypeScript, JavaScript, or Python in agent's plugin system | Transparent, in-place mutation when the agent allows it |
| **Rules file** | Prompt-level instructions | Guidance only — agent is told to prefer `rtk <cmd>` |

Rules file integrations (Cline, Windsurf, Codex, Kilo Code, Antigravity) rely on the model following instructions. Full hook integrations (Claude Code, Cursor, Gemini) are guaranteed — the command is rewritten before the agent sees it. Plugin integrations (OpenCode, Pi) use in-place mutation via the agent's TypeScript extension API.
Rules file integrations (Cline, Windsurf, Codex, Kilo Code, Antigravity) rely on the model following instructions. Full hook integrations (Claude Code, CodeBuddy, Cursor, Gemini) are guaranteed — the command is rewritten before the agent sees it. Plugin integrations (OpenCode, Pi) use in-place mutation via the agent's TypeScript extension API.

## Windows support

Expand Down
27 changes: 25 additions & 2 deletions hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

**Deployed hook artifacts** — the actual files installed on user machines by `rtk init`. These are shell scripts, TypeScript plugins, and rules files that run outside the Rust binary. They are **thin delegates**: parse agent-specific JSON, call `rtk rewrite` as a subprocess, format agent-specific response. Zero filtering logic lives here.

Owns: per-agent hook scripts and configuration files for 10 supported agents (Claude Code, Copilot, Cursor, Cline, Windsurf, Codex, OpenCode, Hermes, Pi, Mistral Vibe).
Owns: per-agent hook scripts and configuration files for 11 supported agents (Claude Code, Copilot, Cursor, Cline, Windsurf, Codex, OpenCode, Hermes, Pi, Mistral Vibe, CodeBuddy).

Does **not** own: hook installation/uninstallation (that's `src/hooks/init.rs`), the rewrite pattern registry (that's `discover/registry`), or integrity verification (that's `src/hooks/integrity.rs`).

Expand Down Expand Up @@ -43,6 +43,7 @@ Each agent subdirectory has its own README with hook-specific details:
- **[`pi/`](pi/README.md)** — TypeScript extension, `tool_call` event, `isToolCallEventType` guard, in-place mutation, `~/.pi/agent/extensions/`
- **[`hermes/`](hermes/README.md)** — Python plugin, `pre_tool_call` hook, in-place terminal command mutation
- **[`vibe/`](vibe/README.md)** — Rust binary hook (`rtk hook vibe`), `pre_tool` entry in `~/.vibe/hooks.toml`, `hook_specific_output.tool_input` rewrite plus `system_message` for UI visibility
- **[`codebuddy/`](codebuddy/README.md)** — Rust binary hook (`rtk hook codebuddy`), same `PreToolUse` protocol as Claude Code with `"continue": true` and `"permissionDecision"` added

## Supported Agents

Expand All @@ -60,6 +61,7 @@ Each agent subdirectory has its own README with hook-specific details:
| Pi | TypeScript extension (`tool_call` event) | In-place mutation | Yes |
| Hermes | Python plugin (`pre_tool_call`) | In-place mutation | Yes |
| Mistral Vibe | Rust binary (`rtk hook vibe`) | Transparent rewrite | Yes (`hook_specific_output.tool_input`) |
| CodeBuddy | Rust binary (`rtk hook codebuddy`) | Transparent rewrite | Yes (`updatedInput`) |

## JSON Formats by Agent

Expand Down Expand Up @@ -185,6 +187,27 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths).

**No rewrite**: exit 0 with empty stdout (Vibe's contract for "no opinion" from a `pre_tool` hook).

### CodeBuddy (Rust Binary)

**Input**: Same as Claude Code (`tool_name: "Bash"`, `tool_input.command`).

**Output** (when rewritten):

```json
{
"continue": true,
"permissionDecision": "allow",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "RTK auto-rewrite",
"updatedInput": { "command": "rtk git status" }
}
}
```

Differs from Claude Code: requires top-level `"continue": true` and `"permissionDecision"` (`"allow"`, `"ask"`, or `"deny"`).

### OpenCode (TypeScript Plugin)

Mutates `args.command` in-place via the zx library:
Expand Down Expand Up @@ -269,7 +292,7 @@ New integrations must follow the [Exit Code Contract](#exit-code-contract) and [

| Tier | Mechanism | Maintenance | Examples |
|------|-----------|-------------|----------|
| **Full hook** | Shell script or Rust binary, intercepts commands via agent's hook API | High — must track agent API changes | Claude Code, Cursor, Copilot, Gemini |
| **Full hook** | Shell script or Rust binary, intercepts commands via agent's hook API | High — must track agent API changes | Claude Code, Cursor, Copilot, Gemini, CodeBuddy |
| **Plugin** | TypeScript/JS/Python plugin in agent's plugin system | Medium — agent manages loading | OpenCode, Hermes, Pi |
| **Rules file** | Prompt-level instructions the agent reads | Low — no code to break | Cline, Windsurf, Codex |

Expand Down
15 changes: 15 additions & 0 deletions hooks/codebuddy/README.md
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
1 change: 1 addition & 0 deletions src/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ Rules are loaded from all Claude Code `settings.json` files (project + global, i
| Copilot CLI (rtk hook copilot) | No updatedInput | deny-with-suggestion (unchanged) |
| Codex | ask parsed but no-op | allow (limitation — fails open) |
| Mistral Vibe (rtk hook vibe) | No native ask surface | passthrough — Vibe's own approval prompt fires on the rewritten command |
| CodeBuddy (rtk hook codebuddy) | Yes | `permissionDecision: "ask"` — user prompted |

### Implementation

Expand Down
5 changes: 5 additions & 0 deletions src/hooks/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ pub const CURSOR_HOOK_COMMAND: &str = "rtk hook cursor";
pub const DROID_HOOK_COMMAND: &str = "rtk hook droid";
/// Native Rust hook command for Mistral Vibe.
pub const VIBE_HOOK_COMMAND: &str = "rtk hook vibe";
/// Native Rust hook command for CodeBuddy (replaces rtk-rewrite.sh).
pub const CODEBUDDY_HOOK_COMMAND: &str = "rtk hook codebuddy";
/// CodeBuddy PreToolUse matcher: CLI mode (Bash) and IDE mode (execute_command).
pub const CODEBUDDY_MATCHER: &str = "Bash|execute_command";
Comment thread
studyzy marked this conversation as resolved.

pub const CONFIG_DIR: &str = ".config";
pub const OPENCODE_SUBDIR: &str = "opencode";
Expand Down Expand Up @@ -67,3 +71,4 @@ pub const VIBE_PROMPTS_SUBDIR: &str = "prompts";
pub const VIBE_PROMPT_FILE: &str = "rtk.md";
pub const VIBE_HOOK_NAME: &str = "rtk-rewrite";
pub const VIBE_BASH_MATCH: &str = "bash";
pub const CODEBUDDY_DIR: &str = ".codebuddy";
190 changes: 176 additions & 14 deletions src/hooks/hook_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()?;
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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:

  • test_droid_ignores_non_execute_tool (hook_cmd.rs:2194)
  • test_droid_bash_tool_name_accepted_defensively (hook_cmd.rs:2205)

The four CodeBuddy tests (:1677-:1728) all construct their payload with codebuddy_payload() and call codebuddy_response_from_decision directly, so none of them ever reach codebuddy_execute_command. If a later refactor drops or inverts the matches! guard, nothing fails and RTK silently starts rewriting commands for non-shell tools again.

Two small tests mirroring the Droid pair would close it — a payload with tool_name: "Read" returning None, and one with execute_command / missing tool_name returning the command.


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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes CodeBuddy prompt on every rewritten command.

decide_from_verdict maps both an explicit Ask rule and the Default "no rule matched" verdict to HookDecision::AskRewrite:

// 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 permissionDecision: "ask" for the default case. And the default case is the normal case: patch_settings_json_at writes a settings.json containing only a hooks key — no permissions block — so after a fresh rtk init -g --agent codebuddy, load_codebuddy_rules() returns empty vectors and every command RTK rewrites gets "ask". The agent runs git status, RTK rewrites it, CodeBuddy shows a blocking approval dialog. Every time.

This is the regression already documented in this file at hook_cmd.rs:288-295:

permissionDecision: "allow" is only ever asserted for an explicit, user-configured Allow rule. Every other rewrite (Default verdict or an explicit Ask rule) omits the field entirely, leaving the host's own native prompt/allowlist flow in control — see #3037, where asserting "ask" here made Copilot CLI 1.0.66+ force a blocking dialog with no "remember" option on every rewritten command.

Both sibling paths follow that rule and insert the field only when the verdict is an explicit Allow — process_claude_payload (:598/:613) and vscode_response_from_decision (:304/:314). CodeBuddy is currently the only host that asserts "ask".

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:

  • Omit permissionDecision unless the verdict is an explicit Allow (mirrors Claude/VS Code exactly), or
  • Split Default from Ask in HookDecision so only a user-configured Ask rule produces "ask".

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,
}))
}
Comment thread
studyzy marked this conversation as resolved.

// ── Cursor native hook ─────────────────────────────────────────

/// Cursor on Windows ships hook payloads with one or more leading
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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!(
Expand Down
Loading