diff --git a/README.md b/README.md
index ac70e12..05d7b2a 100644
--- a/README.md
+++ b/README.md
@@ -8,9 +8,9 @@
---
-Every time Claude Code runs `git diff`, `ls`, or `cargo test`, it dumps thousands of raw tokens into the context window. Most of that is noise. Metadata, whitespace, passing tests, file permissions nobody asked for.
+Every time an AI coding agent runs `git diff`, `ls`, or `cargo test`, it dumps thousands of raw tokens into the context window. Most of that is noise. Metadata, whitespace, passing tests, file permissions nobody asked for.
-**ztk sits between Claude Code and the shell.** Today, automatic interception is built around Claude Code's `PreToolUse` hook model. You can still run any command manually through `ztk run`, but other AI tools need their own hook adapter before ztk can intercept them automatically.
+**ztk sits between your AI agent and the shell.** It supports automatic interception for **Claude Code**, **Cursor Agent**, and **Gemini CLI** — each through their native hook mechanisms. You can also run any command manually through `ztk run`.
@@ -50,10 +50,12 @@ The binary is **260KB**. No dependencies. No runtime. Just a single executable.
## Quick Start
```bash
-# One command to set up Claude Code
+# Set up all detected agents (Claude Code, Cursor, Gemini CLI)
ztk init -g
-# That's it. Every shell command Claude Code runs now goes through ztk.
+# ztk detects which agent config directories exist (.claude/, .cursor/,
+# .gemini/) and installs hooks only for those agents.
+
# Try it manually:
ztk run git diff HEAD~5
ztk run ls -la src/
@@ -135,13 +137,17 @@ Mutation commands like `git add` automatically invalidate related caches.
**SIMD text processing.** Line splitting and ANSI escape stripping use `@Vector(16, u8)` for hardware-accelerated processing on both ARM NEON and x86 SSE2.
-**231 tests.** Every filter, every edge case, every state machine. The regex engine alone has 11 tests covering catastrophic backtracking prevention.
+**269 tests.** Every filter, every edge case, every state machine. The regex engine alone has 11 tests covering catastrophic backtracking prevention.
-## Integration Status
+## Supported Agents
-ztk integrates with Claude Code via `PreToolUse` hooks. The `ztk init -g` command wires that hook into Claude Code automatically.
+| Agent | Hook mechanism | Init |
+|---|---|---|
+| **Claude Code** | `PreToolUse` hook in `.claude/settings.json` | `ztk init -g` |
+| **Cursor Agent** | `PreToolUse` hook in `.cursor/hooks.json` | `ztk init -g` |
+| **Gemini CLI** | `BeforeTool` hook in `.gemini/settings.json` | `ztk init -g` |
-It is not a generic LLM hook layer yet. The compression pipeline is tool-agnostic, and `ztk run ` works as a standalone wrapper, but automatic interception for tools such as Codex, Cursor, Gemini CLI, or Copilot requires a dedicated adapter in `src/hooks/`.
+`ztk init` detects which agents are present and installs hooks for all of them. The compression pipeline is agent-agnostic — `ztk run ` works as a standalone wrapper regardless of which agent invokes it.
## Development
diff --git a/src/cli.zig b/src/cli.zig
index 49e6491..31676fa 100644
--- a/src/cli.zig
+++ b/src/cli.zig
@@ -5,6 +5,8 @@
const std = @import("std");
const proxy = @import("proxy.zig");
const claude = @import("hooks/claude.zig");
+const cursor = @import("hooks/cursor.zig");
+const gemini = @import("hooks/gemini.zig");
const filter_cmd = @import("filter_cmd.zig");
const stats = @import("stats.zig");
const update = @import("update.zig");
@@ -26,6 +28,8 @@ pub fn run(args: []const []const u8, allocator: std.mem.Allocator) !u8 {
}
if (eq(sub, "init")) return runInitCmd(args, allocator);
if (eq(sub, "rewrite")) return claude.runRewrite(args, allocator);
+ if (eq(sub, "cursor-rewrite")) return cursor.runRewrite(allocator);
+ if (eq(sub, "gemini-rewrite")) return gemini.runRewrite(allocator);
if (eq(sub, "run")) {
if (args.len < 3) {
try compat.writeStderr("usage: ztk run [args...]\n");
@@ -51,13 +55,44 @@ fn runInitCmd(args: []const []const u8, allocator: std.mem.Allocator) !u8 {
if (eq(a, "-g") or eq(a, "--global")) global = true;
if (eq(a, "--skip-permissions")) skip_permissions = true;
}
- claude.runInit(allocator, global, skip_permissions) catch |err| switch (err) {
- error.HookFlagMismatch => return 1,
- else => return err,
- };
+ var installed: u8 = 0;
+ if (agentDirExists(allocator, global, claude.claude_dir)) {
+ claude.runInit(allocator, global, skip_permissions) catch |err| switch (err) {
+ error.HookFlagMismatch => return 1,
+ else => return err,
+ };
+ installed += 1;
+ }
+ if (agentDirExists(allocator, global, cursor.cursor_dir)) {
+ try cursor.runInit(allocator, global);
+ installed += 1;
+ }
+ if (agentDirExists(allocator, global, gemini.gemini_dir)) {
+ try gemini.runInit(allocator, global);
+ installed += 1;
+ }
+ if (installed == 0) {
+ try compat.writeStderr("ztk: no supported agent config directories found (.claude/, .cursor/, or .gemini/)\n");
+ return 1;
+ }
return 0;
}
+fn agentDirExists(allocator: std.mem.Allocator, global: bool, dir_name: []const u8) bool {
+ if (global) {
+ const home = compat.getEnvOwned(allocator, "HOME") catch return false;
+ defer allocator.free(home);
+ var buf: [512]u8 = undefined;
+ const full = std.fmt.bufPrint(&buf, "{s}/{s}", .{ home, dir_name }) catch return false;
+ var d = compat.cwd().openDir(compat.io(), full, .{}) catch return false;
+ d.close(compat.io());
+ return true;
+ }
+ var d = compat.cwd().openDir(compat.io(), dir_name, .{}) catch return false;
+ d.close(compat.io());
+ return true;
+}
+
fn usage() !void {
try compat.writeStderr(
\\usage: ztk [args...]
@@ -65,18 +100,19 @@ fn usage() !void {
\\commands:
\\ run [args...] execute command and emit compact output
\\ init [-g] [--skip-permissions]
- \\ install Claude Code PreToolUse hook.
- \\ -g writes to $HOME/.claude/settings.json,
- \\ otherwise ./.claude/settings.json.
- \\ --skip-permissions writes the hook command
- \\ as `ztk rewrite --skip-permissions` so the
- \\ hook emits "allow" instead of "ask".
+ \\ install hooks for all supported agents
+ \\ (.claude/, .cursor/, .gemini/).
+ \\ -g writes to global config directories.
+ \\ --skip-permissions (Claude only) writes the
+ \\ hook as `ztk rewrite --skip-permissions`.
\\ rewrite [--skip-permissions]
- \\ PreToolUse hook handler (reads stdin).
+ \\ Claude PreToolUse hook handler (reads stdin).
\\ --skip-permissions emits "allow" instead of
\\ "ask" so auto-mode users aren't prompted on
\\ every rewrite. permissions.deny / .ask rules
\\ still apply to the rewritten command.
+ \\ cursor-rewrite Cursor preToolUse hook handler (reads stdin)
+ \\ gemini-rewrite Gemini CLI BeforeTool hook handler (reads stdin)
\\ stats print savings stats
\\ update update this ztk executable from GitHub
\\ version print version
diff --git a/src/hooks/cursor.zig b/src/hooks/cursor.zig
new file mode 100644
index 0000000..c7d7961
--- /dev/null
+++ b/src/hooks/cursor.zig
@@ -0,0 +1,25 @@
+//! Cursor agent preToolUse hook integration.
+//!
+//! `runInit` wires ztk into Cursor's hooks.json so every Shell
+//! tool call is piped through `ztk cursor-rewrite`. `runRewrite`
+//! is the hook handler that Cursor invokes on stdin per command:
+//! it checks whether ztk has a filter, then either rewrites the
+//! command to call through `ztk run`, or passes through unchanged.
+
+const std = @import("std");
+
+pub const runInit = @import("cursor_init.zig").runInit;
+pub const runRewrite = @import("cursor_rewrite.zig").runRewrite;
+
+pub const hook_command: []const u8 = "ztk cursor-rewrite";
+
+pub const hook_matcher: []const u8 = "Shell";
+
+pub const hooks_filename: []const u8 = "hooks.json";
+
+pub const cursor_dir: []const u8 = ".cursor";
+
+test {
+ _ = @import("cursor_init.zig");
+ _ = @import("cursor_rewrite.zig");
+}
diff --git a/src/hooks/cursor_init.zig b/src/hooks/cursor_init.zig
new file mode 100644
index 0000000..6753141
--- /dev/null
+++ b/src/hooks/cursor_init.zig
@@ -0,0 +1,158 @@
+const std = @import("std");
+const cursor = @import("cursor.zig");
+const compat = @import("../compat.zig");
+
+pub fn runInit(allocator: std.mem.Allocator, global: bool) !void {
+ const path = try resolveHooksPath(allocator, global);
+ defer allocator.free(path);
+ const status = try writeInit(allocator, path);
+ switch (status) {
+ .already_installed => try compat.writeStdout("ztk Cursor hook already installed\n"),
+ .installed => {
+ var buf: [512]u8 = undefined;
+ const msg = try std.fmt.bufPrint(&buf, "Installed ztk Cursor hook in {s}\n", .{path});
+ try compat.writeStdout(msg);
+ },
+ }
+}
+
+pub const InstallStatus = enum { installed, already_installed };
+
+pub fn writeInit(allocator: std.mem.Allocator, hooks_path: []const u8) !InstallStatus {
+ if (std.fs.path.dirname(hooks_path)) |dir| {
+ compat.makePath(dir) catch |e| switch (e) {
+ error.PathAlreadyExists => {},
+ else => return e,
+ };
+ }
+ const existing = readIfExists(allocator, hooks_path) catch |e| return e;
+ defer if (existing) |b| allocator.free(b);
+ if (existing) |bytes| {
+ if (std.mem.indexOf(u8, bytes, cursor.hook_command) != null) return .already_installed;
+ }
+ const merged = try buildHooksJson(allocator, existing);
+ defer allocator.free(merged);
+ try writeAtomic(hooks_path, merged);
+ return .installed;
+}
+
+/// Build the serialized hooks.json contents. Cursor's format:
+///
+/// {"version": 1, "hooks": {"preToolUse": [...]}}
+///
+/// We merge our entry into an existing file's preToolUse array,
+/// preserving any other events and version field.
+fn buildHooksJson(allocator: std.mem.Allocator, existing: ?[]const u8) ![]u8 {
+ var arena = std.heap.ArenaAllocator.init(allocator);
+ defer arena.deinit();
+ const a = arena.allocator();
+
+ var root = try loadOrInit(a, existing);
+ try root.object.put(a, "version", .{ .integer = 1 });
+ var hooks = try ensureObject(a, &root, "hooks");
+ var pre = try ensureArray(a, hooks, "preToolUse");
+ try pre.append(.{ .object = try buildEntry(a) });
+ try hooks.put(a, "preToolUse", .{ .array = pre });
+
+ var aw: std.Io.Writer.Allocating = .init(allocator);
+ errdefer aw.deinit();
+ std.json.Stringify.value(root, .{ .whitespace = .indent_2 }, &aw.writer) catch return error.OutOfMemory;
+ return aw.toOwnedSlice();
+}
+
+fn buildEntry(a: std.mem.Allocator) !std.json.ObjectMap {
+ var entry = try emptyObject(a);
+ try entry.put(a, "matcher", .{ .string = cursor.hook_matcher });
+ try entry.put(a, "command", .{ .string = cursor.hook_command });
+ try entry.put(a, "type", .{ .string = "command" });
+ return entry;
+}
+
+fn loadOrInit(a: std.mem.Allocator, existing: ?[]const u8) !std.json.Value {
+ if (existing) |bytes| {
+ if (std.json.parseFromSliceLeaky(std.json.Value, a, bytes, .{})) |v| {
+ if (v == .object) return v;
+ } else |_| {}
+ }
+ return .{ .object = try emptyObject(a) };
+}
+
+fn ensureObject(a: std.mem.Allocator, parent: *std.json.Value, key: []const u8) !*std.json.ObjectMap {
+ if (parent.object.getPtr(key)) |p| {
+ if (p.* == .object) return &p.object;
+ }
+ const empty = try emptyObject(a);
+ try parent.object.put(a, key, .{ .object = empty });
+ return &parent.object.getPtr(key).?.object;
+}
+
+fn ensureArray(a: std.mem.Allocator, parent: *std.json.ObjectMap, key: []const u8) !std.json.Array {
+ if (parent.get(key)) |v| {
+ if (v == .array) return v.array;
+ }
+ return std.json.Array.init(a);
+}
+
+fn emptyObject(a: std.mem.Allocator) !std.json.ObjectMap {
+ return std.json.ObjectMap.init(a, &.{}, &.{});
+}
+
+fn resolveHooksPath(allocator: std.mem.Allocator, global: bool) ![]u8 {
+ if (global) {
+ const home = compat.getEnvOwned(allocator, "HOME") catch return error.HomeNotSet;
+ defer allocator.free(home);
+ return std.fs.path.join(allocator, &.{ home, cursor.cursor_dir, cursor.hooks_filename });
+ }
+ return std.fs.path.join(allocator, &.{ cursor.cursor_dir, cursor.hooks_filename });
+}
+
+fn readIfExists(allocator: std.mem.Allocator, path: []const u8) !?[]u8 {
+ const file = compat.openFile(path, .{}) catch |e| switch (e) {
+ error.FileNotFound => return null,
+ else => return e,
+ };
+ defer compat.closeFile(file);
+ return try compat.readFileToEndAlloc(file, allocator, 1 << 20);
+}
+
+fn writeAtomic(path: []const u8, bytes: []const u8) !void {
+ const file = try compat.createFile(path, .{
+ .truncate = true,
+ .permissions = compat.permissionsFromMode(0o644),
+ });
+ defer compat.closeFile(file);
+ try compat.writeFileAll(file, bytes);
+}
+
+test "buildHooksJson creates fresh hooks.json" {
+ const allocator = std.testing.allocator;
+ const out = try buildHooksJson(allocator, null);
+ defer allocator.free(out);
+ try std.testing.expect(std.mem.indexOf(u8, out, "\"preToolUse\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "ztk cursor-rewrite") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "\"Shell\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "\"version\"") != null);
+}
+
+test "buildHooksJson preserves existing hooks" {
+ const allocator = std.testing.allocator;
+ const prior =
+ \\{"version":1,"hooks":{"preToolUse":[{"matcher":"Shell","command":"other-tool","type":"command"}]}}
+ ;
+ const out = try buildHooksJson(allocator, prior);
+ defer allocator.free(out);
+ try std.testing.expect(std.mem.indexOf(u8, out, "other-tool") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "ztk cursor-rewrite") != null);
+}
+
+test "buildHooksJson preserves other event types" {
+ const allocator = std.testing.allocator;
+ const prior =
+ \\{"version":1,"hooks":{"postToolUse":[{"matcher":"Shell","command":"logger","type":"command"}]}}
+ ;
+ const out = try buildHooksJson(allocator, prior);
+ defer allocator.free(out);
+ try std.testing.expect(std.mem.indexOf(u8, out, "postToolUse") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "logger") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "ztk cursor-rewrite") != null);
+}
diff --git a/src/hooks/cursor_rewrite.zig b/src/hooks/cursor_rewrite.zig
new file mode 100644
index 0000000..a89b5e9
--- /dev/null
+++ b/src/hooks/cursor_rewrite.zig
@@ -0,0 +1,144 @@
+const std = @import("std");
+const builtin = @import("builtin");
+const claude_rewrite = @import("claude_rewrite.zig");
+const compat = @import("../compat.zig");
+
+/// Cursor agent preToolUse hook entry point.
+///
+/// Reads the JSON hook payload from stdin, extracts the shell command,
+/// and emits a rewrite directive if ztk has a filter for it. Otherwise
+/// emits nothing (passthrough).
+///
+/// Cursor preToolUse protocol: the hook should exit 0 and communicate
+/// its decision via JSON on stdout. Format:
+///
+/// {"permission": "allow",
+/// "updated_input": {"command": "ztk run "}}
+///
+/// Empty stdout means "no opinion, let Cursor decide".
+pub fn runRewrite(allocator: std.mem.Allocator) !u8 {
+ const stdin_bytes = readStdin(allocator) catch return 0;
+ defer allocator.free(stdin_bytes);
+
+ debugLog("called", stdin_bytes) catch {};
+
+ const command = extractCommand(allocator, stdin_bytes) catch {
+ debugLog("parse-fail", stdin_bytes) catch {};
+ return 0;
+ };
+ defer allocator.free(command);
+ if (command.len == 0) {
+ debugLog("empty-cmd", "") catch {};
+ return 0;
+ }
+
+ if (!claude_rewrite.hasFilterFor(command)) {
+ debugLog("passthrough", command) catch {};
+ return 0;
+ }
+
+ debugLog("rewrite", command) catch {};
+ try emitRewrite(allocator, command);
+ return 0;
+}
+
+fn debugLog(kind: []const u8, detail: []const u8) !void {
+ const allocator = std.heap.page_allocator;
+ const env_key = if (builtin.os.tag == .windows) "USERPROFILE" else "HOME";
+ const home = compat.getEnvOwned(allocator, env_key) catch return;
+ defer allocator.free(home);
+ var buf: [512]u8 = undefined;
+ const path = try std.fmt.bufPrint(&buf, "{s}/.local/share/ztk/cursor-hook-debug.log", .{home});
+ if (std.fs.path.dirname(path)) |dir| {
+ compat.makePath(dir) catch {};
+ }
+ const f = compat.createFile(path, .{
+ .truncate = false,
+ .permissions = compat.permissionsFromMode(0o644),
+ }) catch return;
+ defer compat.closeFile(f);
+ const ts = compat.unixTimestamp();
+ var line_buf: [1024]u8 = undefined;
+ const max_detail = @min(detail.len, 200);
+ const line = std.fmt.bufPrint(&line_buf, "{d}\t{s}\t{s}\n", .{ ts, kind, detail[0..max_detail] }) catch return;
+ _ = compat.appendFileAll(f, line) catch {};
+}
+
+fn readStdin(allocator: std.mem.Allocator) ![]u8 {
+ return compat.readStdinAlloc(allocator, 1 << 20);
+}
+
+/// Parse the Cursor preToolUse payload and return a dup'd copy of
+/// the `command` field. Cursor sends `{"command":"git status"}` at
+/// the top level (unlike Claude's nested `tool_input.command`).
+pub fn extractCommand(allocator: std.mem.Allocator, bytes: []const u8) ![]u8 {
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, bytes, .{});
+ defer parsed.deinit();
+ const root = parsed.value;
+ if (root != .object) return error.MissingField;
+ const cmd = root.object.get("command") orelse return error.MissingField;
+ if (cmd != .string) return error.MissingField;
+ return allocator.dupe(u8, cmd.string);
+}
+
+fn emitRewrite(allocator: std.mem.Allocator, command: []const u8) !void {
+ const rewritten = try std.fmt.allocPrint(allocator, "ztk run {s}", .{command});
+ defer allocator.free(rewritten);
+ const escaped = try jsonEscape(allocator, rewritten);
+ defer allocator.free(escaped);
+ var buf: [8192]u8 = undefined;
+ const payload = try std.fmt.bufPrint(
+ &buf,
+ "{{\"permission\":\"allow\",\"updated_input\":{{\"command\":\"{s}\"}}}}\n",
+ .{escaped},
+ );
+ try compat.writeStdout(payload);
+}
+
+fn jsonEscape(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
+ var out: std.ArrayList(u8) = .empty;
+ const w = compat.listWriter(&out, allocator);
+ for (input) |c| {
+ switch (c) {
+ '"' => try w.writeAll("\\\""),
+ '\\' => try w.writeAll("\\\\"),
+ '\n' => try w.writeAll("\\n"),
+ '\r' => try w.writeAll("\\r"),
+ '\t' => try w.writeAll("\\t"),
+ else => try w.writeByte(c),
+ }
+ }
+ return out.toOwnedSlice(allocator);
+}
+
+test "extractCommand parses top-level command" {
+ const allocator = std.testing.allocator;
+ const sample =
+ \\{"command":"git status -s"}
+ ;
+ const cmd = try extractCommand(allocator, sample);
+ defer allocator.free(cmd);
+ try std.testing.expectEqualStrings("git status -s", cmd);
+}
+
+test "extractCommand fails on missing field" {
+ const allocator = std.testing.allocator;
+ try std.testing.expectError(error.MissingField, extractCommand(allocator, "{}"));
+}
+
+test "extractCommand ignores extra fields" {
+ const allocator = std.testing.allocator;
+ const sample =
+ \\{"command":"ls -la","description":"list files","tool_name":"Shell"}
+ ;
+ const cmd = try extractCommand(allocator, sample);
+ defer allocator.free(cmd);
+ try std.testing.expectEqualStrings("ls -la", cmd);
+}
+
+test "jsonEscape handles quotes and backslashes" {
+ const allocator = std.testing.allocator;
+ const out = try jsonEscape(allocator, "cmd \"arg\"\n\\");
+ defer allocator.free(out);
+ try std.testing.expectEqualStrings("cmd \\\"arg\\\"\\n\\\\", out);
+}
diff --git a/src/hooks/cursor_test.zig b/src/hooks/cursor_test.zig
new file mode 100644
index 0000000..e9770ab
--- /dev/null
+++ b/src/hooks/cursor_test.zig
@@ -0,0 +1,134 @@
+const std = @import("std");
+const cursor_init = @import("cursor_init.zig");
+const cursor_rewrite = @import("cursor_rewrite.zig");
+const compat = @import("../compat.zig");
+
+fn tmpRealPath(allocator: std.mem.Allocator, tmp: *std.testing.TmpDir) ![]u8 {
+ var buf: [std.Io.Dir.max_path_bytes]u8 = undefined;
+ const len = try tmp.dir.realPathFile(std.testing.io, ".", &buf);
+ return allocator.dupe(u8, buf[0..len]);
+}
+
+test "writeInit writes a fresh hooks.json" {
+ const allocator = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+
+ const base = try tmpRealPath(allocator, &tmp);
+ defer allocator.free(base);
+ const hooks_path = try std.fs.path.join(allocator, &.{ base, "hooks.json" });
+ defer allocator.free(hooks_path);
+
+ const status = try cursor_init.writeInit(allocator, hooks_path);
+ try std.testing.expectEqual(cursor_init.InstallStatus.installed, status);
+
+ const file = try compat.openFile(hooks_path, .{});
+ defer compat.closeFile(file);
+ const bytes = try compat.readFileToEndAlloc(file, allocator, 1 << 20);
+ defer allocator.free(bytes);
+
+ try std.testing.expect(std.mem.indexOf(u8, bytes, "\"preToolUse\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, bytes, "\"ztk cursor-rewrite\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, bytes, "\"Shell\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, bytes, "\"version\"") != null);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, bytes, .{});
+ defer parsed.deinit();
+ try std.testing.expectEqual(std.json.Value.object, std.meta.activeTag(parsed.value));
+}
+
+test "writeInit is idempotent" {
+ const allocator = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+
+ const base = try tmpRealPath(allocator, &tmp);
+ defer allocator.free(base);
+ const hooks_path = try std.fs.path.join(allocator, &.{ base, "hooks.json" });
+ defer allocator.free(hooks_path);
+
+ _ = try cursor_init.writeInit(allocator, hooks_path);
+ const status2 = try cursor_init.writeInit(allocator, hooks_path);
+ try std.testing.expectEqual(cursor_init.InstallStatus.already_installed, status2);
+}
+
+test "writeInit preserves existing hooks" {
+ const allocator = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+
+ const base = try tmpRealPath(allocator, &tmp);
+ defer allocator.free(base);
+ const hooks_path = try std.fs.path.join(allocator, &.{ base, "hooks.json" });
+ defer allocator.free(hooks_path);
+
+ {
+ const f = try compat.createFile(hooks_path, .{ .truncate = true });
+ defer compat.closeFile(f);
+ try compat.writeFileAll(f,
+ \\{"version":1,"hooks":{"preToolUse":[{"matcher":"Shell","command":"other-tool","type":"command"}]}}
+ );
+ }
+ const status = try cursor_init.writeInit(allocator, hooks_path);
+ try std.testing.expectEqual(cursor_init.InstallStatus.installed, status);
+
+ const f = try compat.openFile(hooks_path, .{});
+ defer compat.closeFile(f);
+ const bytes = try compat.readFileToEndAlloc(f, allocator, 1 << 20);
+ defer allocator.free(bytes);
+ try std.testing.expect(std.mem.indexOf(u8, bytes, "other-tool") != null);
+ try std.testing.expect(std.mem.indexOf(u8, bytes, "ztk cursor-rewrite") != null);
+}
+
+test "writeInit preserves other event types" {
+ const allocator = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+
+ const base = try tmpRealPath(allocator, &tmp);
+ defer allocator.free(base);
+ const hooks_path = try std.fs.path.join(allocator, &.{ base, "hooks.json" });
+ defer allocator.free(hooks_path);
+
+ {
+ const f = try compat.createFile(hooks_path, .{ .truncate = true });
+ defer compat.closeFile(f);
+ try compat.writeFileAll(f,
+ \\{"version":1,"hooks":{"postToolUse":[{"matcher":"Shell","command":"logger","type":"command"}]}}
+ );
+ }
+ _ = try cursor_init.writeInit(allocator, hooks_path);
+
+ const f = try compat.openFile(hooks_path, .{});
+ defer compat.closeFile(f);
+ const bytes = try compat.readFileToEndAlloc(f, allocator, 1 << 20);
+ defer allocator.free(bytes);
+ try std.testing.expect(std.mem.indexOf(u8, bytes, "postToolUse") != null);
+ try std.testing.expect(std.mem.indexOf(u8, bytes, "logger") != null);
+ try std.testing.expect(std.mem.indexOf(u8, bytes, "ztk cursor-rewrite") != null);
+}
+
+test "extractCommand parses Cursor preToolUse input" {
+ const allocator = std.testing.allocator;
+ const payload =
+ \\{"command":"git status -s"}
+ ;
+ const cmd = try cursor_rewrite.extractCommand(allocator, payload);
+ defer allocator.free(cmd);
+ try std.testing.expectEqualStrings("git status -s", cmd);
+}
+
+test "extractCommand handles extra fields" {
+ const allocator = std.testing.allocator;
+ const payload =
+ \\{"command":"ls -la","description":"list files","tool_name":"Shell"}
+ ;
+ const cmd = try cursor_rewrite.extractCommand(allocator, payload);
+ defer allocator.free(cmd);
+ try std.testing.expectEqualStrings("ls -la", cmd);
+}
+
+test "extractCommand fails on missing command" {
+ const allocator = std.testing.allocator;
+ try std.testing.expectError(error.MissingField, cursor_rewrite.extractCommand(allocator, "{}"));
+}
diff --git a/src/hooks/gemini.zig b/src/hooks/gemini.zig
new file mode 100644
index 0000000..b509fb5
--- /dev/null
+++ b/src/hooks/gemini.zig
@@ -0,0 +1,27 @@
+//! Gemini CLI BeforeTool hook integration.
+//!
+//! `runInit` wires ztk into Gemini CLI's settings.json so every
+//! run_shell_command tool call is piped through `ztk gemini-rewrite`.
+//! `runRewrite` is the hook handler that Gemini CLI invokes on stdin
+//! per command: it checks whether ztk has a filter, then either
+//! rewrites the command to call through `ztk run`, or passes through
+//! unchanged.
+
+const std = @import("std");
+
+pub const runInit = @import("gemini_init.zig").runInit;
+pub const runRewrite = @import("gemini_rewrite.zig").runRewrite;
+
+pub const hook_command: []const u8 = "ztk gemini-rewrite";
+
+pub const hook_matcher: []const u8 = "run_shell_command";
+
+pub const settings_filename: []const u8 = "settings.json";
+
+pub const gemini_dir: []const u8 = ".gemini";
+
+test {
+ _ = @import("gemini_init.zig");
+ _ = @import("gemini_init_build.zig");
+ _ = @import("gemini_rewrite.zig");
+}
diff --git a/src/hooks/gemini_init.zig b/src/hooks/gemini_init.zig
new file mode 100644
index 0000000..512b150
--- /dev/null
+++ b/src/hooks/gemini_init.zig
@@ -0,0 +1,72 @@
+const std = @import("std");
+const gemini = @import("gemini.zig");
+const buildSettings = @import("gemini_init_build.zig").buildSettings;
+const compat = @import("../compat.zig");
+
+/// Install the ztk BeforeTool hook into Gemini CLI's settings.json.
+///
+/// When `global` is true, writes to ~/.gemini/settings.json;
+/// otherwise writes to .gemini/settings.json in the current directory.
+pub fn runInit(allocator: std.mem.Allocator, global: bool) !void {
+ const path = try resolveSettingsPath(allocator, global);
+ defer allocator.free(path);
+ const status = try writeInit(allocator, path);
+ switch (status) {
+ .already_installed => try compat.writeStdout("ztk Gemini CLI hook already installed\n"),
+ .installed => {
+ var buf: [512]u8 = undefined;
+ const msg = try std.fmt.bufPrint(&buf, "Installed ztk Gemini CLI hook in {s}\n", .{path});
+ try compat.writeStdout(msg);
+ },
+ }
+}
+
+pub const InstallStatus = enum { installed, already_installed };
+
+/// Ensure `settings_path` contains a BeforeTool hook that invokes
+/// `ztk gemini-rewrite` for run_shell_command tool calls.
+pub fn writeInit(allocator: std.mem.Allocator, settings_path: []const u8) !InstallStatus {
+ if (std.fs.path.dirname(settings_path)) |dir| {
+ compat.makePath(dir) catch |e| switch (e) {
+ error.PathAlreadyExists => {},
+ else => return e,
+ };
+ }
+ const existing = readIfExists(allocator, settings_path) catch |e| return e;
+ defer if (existing) |b| allocator.free(b);
+ if (existing) |bytes| {
+ if (std.mem.indexOf(u8, bytes, gemini.hook_command) != null) return .already_installed;
+ }
+ const merged = try buildSettings(allocator, existing);
+ defer allocator.free(merged);
+ try writeAtomic(settings_path, merged);
+ return .installed;
+}
+
+fn resolveSettingsPath(allocator: std.mem.Allocator, global: bool) ![]u8 {
+ if (global) {
+ const home = compat.getEnvOwned(allocator, "HOME") catch return error.HomeNotSet;
+ defer allocator.free(home);
+ return std.fs.path.join(allocator, &.{ home, gemini.gemini_dir, gemini.settings_filename });
+ }
+ return std.fs.path.join(allocator, &.{ gemini.gemini_dir, gemini.settings_filename });
+}
+
+fn readIfExists(allocator: std.mem.Allocator, path: []const u8) !?[]u8 {
+ const f = compat.openFile(path, .{}) catch |e| switch (e) {
+ error.FileNotFound => return null,
+ else => return e,
+ };
+ defer compat.closeFile(f);
+ const bytes = try compat.readFileToEndAlloc(f, allocator, 1 << 20);
+ return bytes;
+}
+
+fn writeAtomic(path: []const u8, data: []const u8) !void {
+ const f = try compat.createFile(path, .{
+ .truncate = true,
+ .permissions = compat.permissionsFromMode(0o644),
+ });
+ defer compat.closeFile(f);
+ try compat.writeFileAll(f, data);
+}
diff --git a/src/hooks/gemini_init_build.zig b/src/hooks/gemini_init_build.zig
new file mode 100644
index 0000000..9276eef
--- /dev/null
+++ b/src/hooks/gemini_init_build.zig
@@ -0,0 +1,95 @@
+const std = @import("std");
+const gemini = @import("gemini.zig");
+
+/// Build the serialized settings.json contents for Gemini CLI.
+/// `existing` is the prior file bytes (or null) — if present and
+/// parseable, other top-level keys are preserved and only the
+/// hooks.BeforeTool array is mutated.
+pub fn buildSettings(allocator: std.mem.Allocator, existing: ?[]const u8) ![]u8 {
+ var arena = std.heap.ArenaAllocator.init(allocator);
+ defer arena.deinit();
+ const a = arena.allocator();
+
+ var root = try loadOrInit(a, existing);
+ var hooks = try ensureObject(a, &root, "hooks");
+ var before = try ensureArray(a, hooks, "BeforeTool");
+ try before.append(try buildEntry(a));
+ try hooks.put(a, "BeforeTool", .{ .array = before });
+
+ var aw: std.Io.Writer.Allocating = .init(allocator);
+ errdefer aw.deinit();
+ std.json.Stringify.value(root, .{ .whitespace = .indent_2 }, &aw.writer) catch return error.OutOfMemory;
+ return aw.toOwnedSlice();
+}
+
+fn loadOrInit(a: std.mem.Allocator, existing: ?[]const u8) !std.json.Value {
+ if (existing) |bytes| {
+ if (std.json.parseFromSliceLeaky(std.json.Value, a, bytes, .{})) |v| {
+ if (v == .object) return v;
+ } else |_| {}
+ }
+ return .{ .object = try emptyObject(a) };
+}
+
+fn ensureObject(a: std.mem.Allocator, parent: *std.json.Value, key: []const u8) !*std.json.ObjectMap {
+ if (parent.object.getPtr(key)) |p| {
+ if (p.* == .object) return &p.object;
+ }
+ const empty = try emptyObject(a);
+ try parent.object.put(a, key, .{ .object = empty });
+ return &parent.object.getPtr(key).?.object;
+}
+
+fn ensureArray(a: std.mem.Allocator, parent: *std.json.ObjectMap, key: []const u8) !std.json.Array {
+ if (parent.get(key)) |v| {
+ if (v == .array) return v.array;
+ }
+ return std.json.Array.init(a);
+}
+
+fn buildEntry(a: std.mem.Allocator) !std.json.Value {
+ var entry = try emptyObject(a);
+ try entry.put(a, "matcher", .{ .string = gemini.hook_matcher });
+
+ var inner = try emptyObject(a);
+ try inner.put(a, "type", .{ .string = "command" });
+ try inner.put(a, "command", .{ .string = gemini.hook_command });
+
+ var hooks_arr = std.json.Array.init(a);
+ try hooks_arr.append(.{ .object = inner });
+ try entry.put(a, "hooks", .{ .array = hooks_arr });
+ return .{ .object = entry };
+}
+
+fn emptyObject(a: std.mem.Allocator) !std.json.ObjectMap {
+ return std.json.ObjectMap.init(a, &.{}, &.{});
+}
+
+test "buildSettings creates fresh JSON with hook" {
+ const allocator = std.testing.allocator;
+ const out = try buildSettings(allocator, null);
+ defer allocator.free(out);
+ try std.testing.expect(std.mem.indexOf(u8, out, "\"BeforeTool\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "ztk gemini-rewrite") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "\"run_shell_command\"") != null);
+}
+
+test "buildSettings preserves existing top-level keys" {
+ const allocator = std.testing.allocator;
+ const prior = "{\"theme\":\"dark\",\"customInstructions\":\"Be concise\"}";
+ const out = try buildSettings(allocator, prior);
+ defer allocator.free(out);
+ try std.testing.expect(std.mem.indexOf(u8, out, "\"theme\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "\"customInstructions\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "ztk gemini-rewrite") != null);
+}
+
+test "buildSettings appends to existing BeforeTool array" {
+ const allocator = std.testing.allocator;
+ const prior = "{\"hooks\":{\"BeforeTool\":[{\"matcher\":\"web_search\",\"hooks\":[]}]}}";
+ const out = try buildSettings(allocator, prior);
+ defer allocator.free(out);
+ try std.testing.expect(std.mem.indexOf(u8, out, "\"web_search\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "\"run_shell_command\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "ztk gemini-rewrite") != null);
+}
diff --git a/src/hooks/gemini_rewrite.zig b/src/hooks/gemini_rewrite.zig
new file mode 100644
index 0000000..cc0e3bf
--- /dev/null
+++ b/src/hooks/gemini_rewrite.zig
@@ -0,0 +1,107 @@
+const std = @import("std");
+const builtin = @import("builtin");
+const claude_rewrite = @import("claude_rewrite.zig");
+const compat = @import("../compat.zig");
+
+/// Gemini CLI BeforeTool hook entry point.
+///
+/// Reads the JSON hook payload from stdin, extracts the shell command,
+/// and emits a rewrite directive if ztk has a filter for it. Otherwise
+/// emits nothing (passthrough).
+///
+/// Gemini CLI BeforeTool protocol: the hook should exit 0 and communicate
+/// its decision via JSON on stdout. Format:
+///
+/// {"decision": "allow",
+/// "hookSpecificOutput": {"tool_input": {"command": "ztk run "}}}
+///
+/// Empty stdout means "no opinion, let Gemini CLI decide".
+/// Gemini's stdin format uses tool_input.command, same as Claude.
+pub fn runRewrite(allocator: std.mem.Allocator) !u8 {
+ const stdin_bytes = readStdin(allocator) catch return 0;
+ defer allocator.free(stdin_bytes);
+
+ debugLog("called", stdin_bytes) catch {};
+
+ const command = claude_rewrite.extractCommand(allocator, stdin_bytes) catch {
+ debugLog("parse-fail", stdin_bytes) catch {};
+ return 0;
+ };
+ defer allocator.free(command);
+ if (command.len == 0) {
+ debugLog("empty-cmd", "") catch {};
+ return 0;
+ }
+
+ if (!claude_rewrite.hasFilterFor(command)) {
+ debugLog("passthrough", command) catch {};
+ return 0;
+ }
+
+ debugLog("rewrite", command) catch {};
+ try emitRewrite(allocator, command);
+ return 0;
+}
+
+fn debugLog(kind: []const u8, detail: []const u8) !void {
+ const allocator = std.heap.page_allocator;
+ const env_key = if (builtin.os.tag == .windows) "USERPROFILE" else "HOME";
+ const home = compat.getEnvOwned(allocator, env_key) catch return;
+ defer allocator.free(home);
+ var buf: [512]u8 = undefined;
+ const path = try std.fmt.bufPrint(&buf, "{s}/.local/share/ztk/gemini-hook-debug.log", .{home});
+ if (std.fs.path.dirname(path)) |dir| {
+ compat.makePath(dir) catch {};
+ }
+ const f = compat.createFile(path, .{
+ .truncate = false,
+ .permissions = compat.permissionsFromMode(0o644),
+ }) catch return;
+ defer compat.closeFile(f);
+ const ts = compat.unixTimestamp();
+ var line_buf: [1024]u8 = undefined;
+ const max_detail = @min(detail.len, 200);
+ const line = std.fmt.bufPrint(&line_buf, "{d}\t{s}\t{s}\n", .{ ts, kind, detail[0..max_detail] }) catch return;
+ _ = compat.appendFileAll(f, line) catch {};
+}
+
+fn readStdin(allocator: std.mem.Allocator) ![]u8 {
+ return compat.readStdinAlloc(allocator, 1 << 20);
+}
+
+fn emitRewrite(allocator: std.mem.Allocator, command: []const u8) !void {
+ const rewritten = try std.fmt.allocPrint(allocator, "ztk run {s}", .{command});
+ defer allocator.free(rewritten);
+ const escaped = try jsonEscape(allocator, rewritten);
+ defer allocator.free(escaped);
+ var buf: [8192]u8 = undefined;
+ const payload = try std.fmt.bufPrint(
+ &buf,
+ "{{\"decision\":\"allow\",\"hookSpecificOutput\":{{\"tool_input\":{{\"command\":\"{s}\"}}}}}}\n",
+ .{escaped},
+ );
+ try compat.writeStdout(payload);
+}
+
+fn jsonEscape(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
+ var out: std.ArrayList(u8) = .empty;
+ const w = compat.listWriter(&out, allocator);
+ for (input) |c| {
+ switch (c) {
+ '"' => try w.writeAll("\\\""),
+ '\\' => try w.writeAll("\\\\"),
+ '\n' => try w.writeAll("\\n"),
+ '\r' => try w.writeAll("\\r"),
+ '\t' => try w.writeAll("\\t"),
+ else => try w.writeByte(c),
+ }
+ }
+ return out.toOwnedSlice(allocator);
+}
+
+test "jsonEscape handles quotes and backslashes" {
+ const allocator = std.testing.allocator;
+ const out = try jsonEscape(allocator, "cmd \"arg\"\n\\");
+ defer allocator.free(out);
+ try std.testing.expectEqualStrings("cmd \\\"arg\\\"\\n\\\\", out);
+}
diff --git a/src/hooks/gemini_test.zig b/src/hooks/gemini_test.zig
new file mode 100644
index 0000000..d0da8c5
--- /dev/null
+++ b/src/hooks/gemini_test.zig
@@ -0,0 +1,82 @@
+const std = @import("std");
+const gemini_init = @import("gemini_init.zig");
+const claude_rewrite = @import("claude_rewrite.zig");
+const compat = @import("../compat.zig");
+
+fn tmpRealPath(allocator: std.mem.Allocator, tmp: *std.testing.TmpDir) ![]u8 {
+ var buf: [std.Io.Dir.max_path_bytes]u8 = undefined;
+ const len = try tmp.dir.realPathFile(std.testing.io, ".", &buf);
+ return allocator.dupe(u8, buf[0..len]);
+}
+
+test "writeInit writes a fresh settings.json" {
+ const allocator = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+
+ const base = try tmpRealPath(allocator, &tmp);
+ defer allocator.free(base);
+ const settings_path = try std.fs.path.join(allocator, &.{ base, "settings.json" });
+ defer allocator.free(settings_path);
+
+ const status = try gemini_init.writeInit(allocator, settings_path);
+ try std.testing.expectEqual(gemini_init.InstallStatus.installed, status);
+
+ const file = try compat.openFile(settings_path, .{});
+ defer compat.closeFile(file);
+ const bytes = try compat.readFileToEndAlloc(file, allocator, 1 << 20);
+ defer allocator.free(bytes);
+
+ try std.testing.expect(std.mem.indexOf(u8, bytes, "\"BeforeTool\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, bytes, "\"ztk gemini-rewrite\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, bytes, "\"run_shell_command\"") != null);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, bytes, .{});
+ defer parsed.deinit();
+ try std.testing.expectEqual(std.json.Value.object, std.meta.activeTag(parsed.value));
+}
+
+test "writeInit is idempotent" {
+ const allocator = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+
+ const base = try tmpRealPath(allocator, &tmp);
+ defer allocator.free(base);
+ const settings_path = try std.fs.path.join(allocator, &.{ base, "settings.json" });
+ defer allocator.free(settings_path);
+
+ const s1 = try gemini_init.writeInit(allocator, settings_path);
+ try std.testing.expectEqual(gemini_init.InstallStatus.installed, s1);
+
+ const s2 = try gemini_init.writeInit(allocator, settings_path);
+ try std.testing.expectEqual(gemini_init.InstallStatus.already_installed, s2);
+}
+
+test "extractCommand parses Gemini BeforeTool stdin" {
+ const allocator = std.testing.allocator;
+ const input = "{\"tool_name\":\"run_shell_command\",\"tool_input\":{\"command\":\"git status\"}}";
+ const cmd = try claude_rewrite.extractCommand(allocator, input);
+ defer allocator.free(cmd);
+ try std.testing.expectEqualStrings("git status", cmd);
+}
+
+test "extractCommand handles nested JSON with extra fields" {
+ const allocator = std.testing.allocator;
+ const input = "{\"tool_name\":\"run_shell_command\",\"tool_input\":{\"command\":\"ls -la\",\"description\":\"listing files\"},\"some_meta\":123}";
+ const cmd = try claude_rewrite.extractCommand(allocator, input);
+ defer allocator.free(cmd);
+ try std.testing.expectEqualStrings("ls -la", cmd);
+}
+
+test "extractCommand fails on missing tool_input" {
+ const allocator = std.testing.allocator;
+ const input = "{\"tool_name\":\"run_shell_command\"}";
+ try std.testing.expectError(error.MissingField, claude_rewrite.extractCommand(allocator, input));
+}
+
+test "extractCommand fails on missing command field" {
+ const allocator = std.testing.allocator;
+ const input = "{\"tool_name\":\"run_shell_command\",\"tool_input\":{\"description\":\"no command\"}}";
+ try std.testing.expectError(error.MissingField, claude_rewrite.extractCommand(allocator, input));
+}
diff --git a/src/main.zig b/src/main.zig
index 32de853..b0d2826 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -103,5 +103,14 @@ test {
_ = @import("hooks/claude_init_build.zig");
_ = @import("hooks/claude_rewrite.zig");
_ = @import("hooks/claude_test.zig");
+ _ = @import("hooks/cursor.zig");
+ _ = @import("hooks/cursor_init.zig");
+ _ = @import("hooks/cursor_rewrite.zig");
+ _ = @import("hooks/cursor_test.zig");
+ _ = @import("hooks/gemini.zig");
+ _ = @import("hooks/gemini_init.zig");
+ _ = @import("hooks/gemini_init_build.zig");
+ _ = @import("hooks/gemini_rewrite.zig");
+ _ = @import("hooks/gemini_test.zig");
_ = @import("update.zig");
}