From f04445741ee66498a244b4eafcd77f86ee6bcbaf Mon Sep 17 00:00:00 2001 From: "a.key" Date: Fri, 8 May 2026 12:40:59 +0100 Subject: [PATCH 1/3] Add Cursor Agent and Gemini CLI hook integrations - Add cursor-rewrite subcommand for Cursor Agent PreToolUse hooks - Add gemini-rewrite subcommand for Gemini CLI BeforeTool hooks - Unify ztk init to auto-detect and install hooks for all supported agents (.claude/, .cursor/, .gemini/) present on the system - Add cursor.zig, cursor_init.zig, cursor_rewrite.zig, cursor_test.zig - Add gemini.zig, gemini_init.zig, gemini_init_build.zig, gemini_rewrite.zig, gemini_test.zig - 262 tests pass including 7 new Gemini-specific tests --- src/cli.zig | 58 +++++++++--- src/hooks/cursor.zig | 25 +++++ src/hooks/cursor_init.zig | 158 ++++++++++++++++++++++++++++++++ src/hooks/cursor_rewrite.zig | 144 +++++++++++++++++++++++++++++ src/hooks/cursor_test.zig | 134 +++++++++++++++++++++++++++ src/hooks/gemini.zig | 27 ++++++ src/hooks/gemini_init.zig | 69 ++++++++++++++ src/hooks/gemini_init_build.zig | 95 +++++++++++++++++++ src/hooks/gemini_rewrite.zig | 107 +++++++++++++++++++++ src/hooks/gemini_test.zig | 82 +++++++++++++++++ src/main.zig | 9 ++ 11 files changed, 897 insertions(+), 11 deletions(-) create mode 100644 src/hooks/cursor.zig create mode 100644 src/hooks/cursor_init.zig create mode 100644 src/hooks/cursor_rewrite.zig create mode 100644 src/hooks/cursor_test.zig create mode 100644 src/hooks/gemini.zig create mode 100644 src/hooks/gemini_init.zig create mode 100644 src/hooks/gemini_init_build.zig create mode 100644 src/hooks/gemini_rewrite.zig create mode 100644 src/hooks/gemini_test.zig 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..0669002 --- /dev/null +++ b/src/hooks/gemini_init.zig @@ -0,0 +1,69 @@ +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 = try compat.getEnvOwned(allocator, "HOME"); + 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 return null; + 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"); } From af7f807ffd16f9d6222a34ee8941ea2c5e41ed72 Mon Sep 17 00:00:00 2001 From: "a.key" Date: Fri, 8 May 2026 12:59:33 +0100 Subject: [PATCH 2/3] Fix gemini_init.zig error handling to match upstream patterns - resolveSettingsPath: use 'catch return error.HomeNotSet' instead of 'try' for clearer error propagation when HOME is unset - readIfExists: discriminate FileNotFound from other errors instead of swallowing all errors as null --- src/hooks/gemini_init.zig | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/hooks/gemini_init.zig b/src/hooks/gemini_init.zig index 0669002..512b150 100644 --- a/src/hooks/gemini_init.zig +++ b/src/hooks/gemini_init.zig @@ -45,7 +45,7 @@ pub fn writeInit(allocator: std.mem.Allocator, settings_path: []const u8) !Insta fn resolveSettingsPath(allocator: std.mem.Allocator, global: bool) ![]u8 { if (global) { - const home = try compat.getEnvOwned(allocator, "HOME"); + 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 }); } @@ -53,7 +53,10 @@ fn resolveSettingsPath(allocator: std.mem.Allocator, global: bool) ![]u8 { } fn readIfExists(allocator: std.mem.Allocator, path: []const u8) !?[]u8 { - const f = compat.openFile(path, .{}) catch return null; + 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; From 7b668587c8c371062405f289053faad5c86a2db6 Mon Sep 17 00:00:00 2001 From: "a.key" Date: Fri, 8 May 2026 13:08:40 +0100 Subject: [PATCH 3/3] Update README to reflect multi-agent support - Replace Claude Code-specific language with agent-agnostic framing - Document Cursor Agent and Gemini CLI as supported agents - Add supported agents table with hook mechanisms - Update test count from 231 to 269 - Clarify that ztk init detects and installs for all present agents --- README.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) 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`.

ztk stats, 256 commands, 5.8M saved, 90.6% reduction @@ -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