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
11 changes: 11 additions & 0 deletions hooks/qoder/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Qoder IDE Hooks

> Part of [`hooks/`](../README.md) — see also [`src/hooks/`](../../src/hooks/README.md) for installation code

## Specifics

- Shell-based `PreToolUse` hook (powered natively by `rtk hook qoder`)
- Returns `updatedInput` JSON for transparent command rewrite (IDE doesn't know RTK is involved)
- Protocol is identical to Claude Code.
- Exits silently (exit 0) on any failure or JSON parse error.
- `rtk-awareness.md` is embedded into `~/.qoder/RTK.md` by `rtk init -g --agent qoder`.
25 changes: 25 additions & 0 deletions hooks/qoder/rtk-awareness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# RTK - Rust Token Killer

**Usage**: Token-optimized CLI proxy (60-90% savings on dev operations)

## Meta Commands (always use rtk directly)

```bash
rtk gain # Show token savings analytics
rtk gain --history # Show command usage history with savings
rtk proxy <cmd> # Execute raw command without filtering (for debugging)
```

## Installation Verification

```bash
rtk --version # Should show: rtk X.Y.Z
rtk gain # Should work (not "command not found")
```

⚠️ **Name collision**: If `rtk gain` fails, you may have reachingforthejack/rtk (Rust Type Kit) installed instead.

## Hook-Based Usage

All other commands are automatically rewritten by the Qoder PreToolUse hook.
Example: `git status` → `rtk git status` (transparent, 0 tokens overhead)
4 changes: 4 additions & 0 deletions src/hooks/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@ pub const HERMES_PLUGINS_SUBDIR: &str = "plugins";
pub const HERMES_PLUGIN_NAME: &str = "rtk-rewrite";
pub const HERMES_PLUGIN_INIT_FILE: &str = "__init__.py";
pub const HERMES_PLUGIN_MANIFEST_FILE: &str = "plugin.yaml";

pub const QODER_DIR: &str = ".qoder";
pub const QODER_HOOK_COMMAND: &str = "rtk hook qoder";
pub const QODER_SETTINGS_JSON: &str = "settings.json";
12 changes: 11 additions & 1 deletion src/hooks/hook_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ mod tests {
use crate::hooks::constants::{
CODEX_DIR, CONFIG_DIR, CURSOR_DIR, GEMINI_DIR, GEMINI_HOOK_FILE, HERMES_DIR,
HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME,
OPENCODE_PLUGIN_FILE, OPENCODE_SUBDIR, PLUGIN_SUBDIR,
OPENCODE_PLUGIN_FILE, OPENCODE_SUBDIR, PLUGIN_SUBDIR, QODER_DIR, QODER_SETTINGS_JSON,
};

fn other_integration_installed(home: &std::path::Path) -> bool {
Expand All @@ -177,6 +177,7 @@ mod tests {
.join(HERMES_PLUGINS_SUBDIR)
.join(HERMES_PLUGIN_NAME)
.join(HERMES_PLUGIN_MANIFEST_FILE),
home.join(QODER_DIR).join(QODER_SETTINGS_JSON),
];
paths.iter().any(|p| p.exists())
}
Expand Down Expand Up @@ -284,6 +285,15 @@ mod tests {
assert!(other_integration_installed(tmp.path()));
}

#[test]
fn test_other_integration_qoder() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join(QODER_DIR).join(QODER_SETTINGS_JSON);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"settings").unwrap();
assert!(other_integration_installed(tmp.path()));
}

#[test]
fn test_other_integration_empty_dirs_not_enough() {
let tmp = tempfile::tempdir().expect("tempdir");
Expand Down
81 changes: 81 additions & 0 deletions src/hooks/hook_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,44 @@ fn run_claude_inner(input: &str) -> Option<String> {
}
}

// ── Qoder native hook ──────────────────────────────────────────

/// Run the Qoder IDE PreToolUse hook natively.
/// Protocol is identical to Claude Code; reuses process_claude_payload().
pub fn run_qoder() -> 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(());
}
};

match process_claude_payload(&v) {
PayloadAction::Rewrite {
cmd,
rewritten,
output,
} => {
audit_log("rewrite", &cmd, &rewritten);
let _ = writeln!(io::stdout(), "{output}");
}
PayloadAction::Skip { reason, cmd } => {
audit_log(reason, &cmd, "");
}
PayloadAction::Ignore => {}
}

Ok(())
}

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

/// Cursor on Windows ships hook payloads with one or more leading
Expand Down Expand Up @@ -780,6 +818,49 @@ mod tests {
assert!(run_claude_inner(&input).is_none());
}

// --- Qoder handler ---

#[cfg(test)]
fn run_qoder_inner(input: &str) -> Option<String> {
let v: Value = serde_json::from_str(input).ok()?;
match process_claude_payload(&v) {
PayloadAction::Rewrite { output, .. } => Some(output.to_string()),
_ => None,
}
}

fn qoder_input(cmd: &str) -> String {
serde_json::json!({
"session_id": "test-session",
"cwd": "/tmp/project",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": cmd }
})
.to_string()
}

#[test]
fn test_qoder_rewrite_git_status() {
let input = qoder_input("git status");
let output = run_qoder_inner(&input).unwrap();
assert!(output.contains("\"updatedInput\":{\"command\":\"rtk git status\"}"));
}

#[test]
fn test_qoder_passthrough_no_output() {
let input = qoder_input("htop");
let output = run_qoder_inner(&input);
assert!(output.is_none());
}

#[test]
fn test_qoder_already_rtk_passthrough() {
let input = qoder_input("rtk git status");
let output = run_qoder_inner(&input);
assert!(output.is_none());
}

// --- Cursor handler ---

fn cursor_input(cmd: &str) -> String {
Expand Down
Loading