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
22 changes: 14 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

<p align="center">
<img src="assets/stats-screenshot.svg" alt="ztk stats, 256 commands, 5.8M saved, 90.6% reduction" width="700">
Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -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 <command>` 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 <command>` works as a standalone wrapper regardless of which agent invokes it.

## Development

Expand Down
58 changes: 47 additions & 11 deletions src/cli.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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 <cmd> [args...]\n");
Expand All @@ -51,32 +55,64 @@ 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 <command> [args...]
\\
\\commands:
\\ run <cmd> [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
Expand Down
25 changes: 25 additions & 0 deletions src/hooks/cursor.zig
Original file line number Diff line number Diff line change
@@ -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");
}
158 changes: 158 additions & 0 deletions src/hooks/cursor_init.zig
Original file line number Diff line number Diff line change
@@ -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);
}
Loading