Skip to content

Commit 08a47d4

Browse files
mpecanclaude
andauthored
fix(hook): use bare tokf in generated hook scripts (#208)
## Summary - Replace hardcoded absolute binary path (`std::env::current_exe()`) with bare `tokf` in generated hook shim and OpenCode plugin scripts - Remove `escape_for_js_string` helper and `{{TOKF_PATH}}` template substitution from opencode.rs (no longer needed) - Add `--path <binary>` flag to `tokf hook install` so users can opt into a specific binary path when bare `tokf` isn't on PATH (e.g. Linuxbrew, `cargo install` without PATH in `/bin/sh`) - Custom paths are shell-escaped in the hook shim and JS-escaped (via `serde_json`) in the OpenCode plugin template - Update tests to assert on bare `tokf` instead of quoted full paths, plus new tests for custom path and path-with-spaces escaping - Add "Set up automatic filtering" section to getting-started docs so users discover `tokf hook install --global` during onboarding - Document the `--path` flag in integrations docs Closes #191 ## Test plan - [x] `cargo test -p tokf -- hook` — all 81 tests pass - [x] `cargo clippy --workspace --all-targets -- -D warnings` — clean - [x] `cargo fmt -- --check` — clean - [x] README.md regenerated via `scripts/generate-readme.sh` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
1 parent e9bbb61 commit 08a47d4

9 files changed

Lines changed: 232 additions & 82 deletions

File tree

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,25 @@ tokf looks up a filter for `git push`, runs the command, and applies the filter.
124124

125125
---
126126

127+
## Set up automatic filtering
128+
129+
If you use an AI coding tool, install the hook so every command is filtered automatically — no `tokf run` prefix needed:
130+
131+
```sh
132+
# Claude Code (recommended: --global so it works in every project)
133+
tokf hook install --global
134+
135+
# OpenCode
136+
tokf hook install --tool opencode --global
137+
138+
# OpenAI Codex CLI
139+
tokf hook install --tool codex --global
140+
```
141+
142+
Drop `--global` to install for the current project only. See [Claude Code hook](#claude-code-hook) for details on each tool, the `--path` flag, and optional extras like the filter-authoring skill.
143+
144+
---
145+
127146
## Usage
128147

129148
### Run a command with filtering
@@ -709,6 +728,15 @@ tokf hook install --global # user-level (~/.config/tokf/)
709728

710729
Once installed, every command Claude runs through the Bash tool is filtered transparently. Track cumulative savings with `tokf gain`.
711730

731+
### Custom binary path
732+
733+
By default the generated hook script calls bare `tokf`, relying on PATH at runtime. If `tokf` isn't on PATH in the hook's execution environment (common with Linuxbrew or `cargo install` when PATH is only set in interactive shell profiles), pass `--path` to embed a specific binary location:
734+
735+
```sh
736+
tokf hook install --global --path ~/.cargo/bin/tokf
737+
tokf hook install --tool opencode --path /home/linuxbrew/.linuxbrew/bin/tokf
738+
```
739+
712740
tokf also ships a filter-authoring skill that teaches Claude the complete filter schema:
713741

714742
```sh

crates/tokf-cli/src/commands.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -361,11 +361,12 @@ pub fn cmd_hook_handle() -> i32 {
361361
0
362362
}
363363

364-
pub fn cmd_hook_install(global: bool, tool: &HookTool) -> i32 {
364+
pub fn cmd_hook_install(global: bool, tool: &HookTool, path: Option<&Path>) -> i32 {
365+
let tokf_bin = path.map_or_else(|| "tokf".to_string(), |p| p.display().to_string());
365366
let result = match tool {
366-
HookTool::ClaudeCode => hook::install(global),
367+
HookTool::ClaudeCode => hook::install(global, &tokf_bin),
367368
// R6: Updated variant name from Opencode to OpenCode.
368-
HookTool::OpenCode => hook::opencode::install(global),
369+
HookTool::OpenCode => hook::opencode::install(global, &tokf_bin),
369370
HookTool::Codex => hook::codex::install(global),
370371
};
371372
match result {

crates/tokf-cli/src/hook/mod.rs

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ pub(crate) fn handle_json_with_config(
7878
/// # Errors
7979
///
8080
/// Returns an error if file I/O fails.
81-
pub fn install(global: bool) -> anyhow::Result<()> {
81+
pub fn install(global: bool, tokf_bin: &str) -> anyhow::Result<()> {
8282
let (hook_dir, settings_path) = if global {
8383
let user = crate::paths::user_dir()
8484
.ok_or_else(|| anyhow::anyhow!("could not determine config directory"))?;
@@ -94,13 +94,17 @@ pub fn install(global: bool) -> anyhow::Result<()> {
9494
(hook_dir, settings_path)
9595
};
9696

97-
install_to(&hook_dir, &settings_path)
97+
install_to(&hook_dir, &settings_path, tokf_bin)
9898
}
9999

100100
/// Core install logic with explicit paths (testable).
101-
pub(crate) fn install_to(hook_dir: &Path, settings_path: &Path) -> anyhow::Result<()> {
101+
pub(crate) fn install_to(
102+
hook_dir: &Path,
103+
settings_path: &Path,
104+
tokf_bin: &str,
105+
) -> anyhow::Result<()> {
102106
let hook_script = hook_dir.join("pre-tool-use.sh");
103-
write_hook_shim(hook_dir, &hook_script)?;
107+
write_hook_shim(hook_dir, &hook_script, tokf_bin)?;
104108
patch_settings(settings_path, &hook_script)?;
105109

106110
eprintln!("[tokf] hook installed");
@@ -111,13 +115,16 @@ pub(crate) fn install_to(hook_dir: &Path, settings_path: &Path) -> anyhow::Resul
111115
}
112116

113117
/// Write the hook shim script.
114-
fn write_hook_shim(hook_dir: &Path, hook_script: &Path) -> anyhow::Result<()> {
118+
fn write_hook_shim(hook_dir: &Path, hook_script: &Path, tokf_bin: &str) -> anyhow::Result<()> {
115119
std::fs::create_dir_all(hook_dir)?;
116120

117-
let tokf_path = std::env::current_exe()?;
118-
let quoted = runner::shell_escape(&tokf_path.to_string_lossy());
119-
let content = format!("#!/bin/sh\nexec {quoted} hook handle\n");
120-
std::fs::write(hook_script, &content)?;
121+
let escaped_bin = if tokf_bin == "tokf" {
122+
tokf_bin.to_string()
123+
} else {
124+
runner::shell_escape(tokf_bin)
125+
};
126+
let content = format!("#!/bin/sh\nexec {escaped_bin} hook handle\n");
127+
std::fs::write(hook_script, content)?;
121128

122129
// Make executable on Unix
123130
#[cfg(unix)]

crates/tokf-cli/src/hook/opencode.rs

Lines changed: 54 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ use anyhow::Context;
66
// R7: Add 5000ms timeout to Bun.spawnSync.
77
// R8: Check proc.stdout for null before decoding.
88
// R9: Log errors in catch block with [tokf] prefix.
9+
// {{TOKF_BIN}} is replaced at install time with the configured tokf binary (command or path).
910
const PLUGIN_TEMPLATE: &str = r#"// tokf-filter plugin for OpenCode
1011
// Generated by: tokf hook install --tool opencode
1112
// Docs: https://github.com/mpecan/tokf
12-
const TOKF_BIN = "{{TOKF_PATH}}";
13+
const TOKF_BIN = "{{TOKF_BIN}}";
1314
export default {
1415
"tool.execute.before": async (
1516
input: { tool: string },
@@ -39,17 +40,17 @@ export default {
3940
/// # Errors
4041
///
4142
/// Returns an error if the plugin directory cannot be created or the plugin file cannot be written.
42-
pub fn install(global: bool) -> anyhow::Result<()> {
43+
pub fn install(global: bool, tokf_bin: &str) -> anyhow::Result<()> {
4344
let plugin_dir = if global {
4445
global_plugin_dir()?
4546
} else {
4647
PathBuf::from(".opencode/plugins")
4748
};
48-
install_to(&plugin_dir)
49+
install_to(&plugin_dir, tokf_bin)
4950
}
5051

51-
pub(crate) fn install_to(plugin_dir: &Path) -> anyhow::Result<()> {
52-
write_plugin_file(plugin_dir)?;
52+
pub(crate) fn install_to(plugin_dir: &Path, tokf_bin: &str) -> anyhow::Result<()> {
53+
write_plugin_file(plugin_dir, tokf_bin)?;
5354
// R5: Standardize eprintln prefix to [tokf]
5455
eprintln!(
5556
"[tokf] OpenCode plugin installed to {}",
@@ -64,26 +65,19 @@ fn global_plugin_dir() -> anyhow::Result<PathBuf> {
6465
Ok(home.join(".config/opencode/plugins"))
6566
}
6667

67-
// R10: Extract escaping logic into a small helper for testability.
68-
fn escape_for_js_string(s: &str) -> anyhow::Result<String> {
69-
// serde_json::to_string gives us a JSON string with surrounding quotes; strip them.
70-
let json = serde_json::to_string(s).context("failed to serialize tokf path")?;
71-
// Strip surrounding quotes that to_string adds.
72-
Ok(json[1..json.len() - 1].to_string())
73-
}
74-
75-
fn write_plugin_file(plugin_dir: &Path) -> anyhow::Result<()> {
68+
fn write_plugin_file(plugin_dir: &Path, tokf_bin: &str) -> anyhow::Result<()> {
7669
std::fs::create_dir_all(plugin_dir)
7770
.with_context(|| format!("failed to create plugin dir: {}", plugin_dir.display()))?;
7871

79-
let tokf_path = std::env::current_exe().context("could not determine tokf executable path")?;
80-
let tokf_path_str = tokf_path.to_string_lossy();
81-
// R4: Use serde_json for proper JSON string escaping instead of manual replacement.
82-
let escaped = escape_for_js_string(tokf_path_str.as_ref())?;
83-
let content = PLUGIN_TEMPLATE.replace("{{TOKF_PATH}}", &escaped);
72+
// Use serde_json for proper JS/JSON string escaping (handles \n, \r, \t, etc.).
73+
// serde_json::to_string wraps in quotes — strip them for template substitution.
74+
let json_str = serde_json::to_string(tokf_bin)
75+
.with_context(|| format!("failed to JSON-escape tokf binary: {tokf_bin}"))?;
76+
let js_escaped = &json_str[1..json_str.len() - 1];
77+
let content = PLUGIN_TEMPLATE.replace("{{TOKF_BIN}}", js_escaped);
8478

8579
let plugin_file = plugin_dir.join("tokf.ts");
86-
std::fs::write(&plugin_file, &content)
80+
std::fs::write(&plugin_file, content)
8781
.with_context(|| format!("failed to write plugin file: {}", plugin_file.display()))?;
8882

8983
Ok(())
@@ -100,7 +94,7 @@ mod tests {
10094
let dir = TempDir::new().unwrap();
10195
let plugin_dir = dir.path().join("plugins");
10296

103-
install_to(&plugin_dir).unwrap();
97+
install_to(&plugin_dir, "tokf").unwrap();
10498

10599
assert!(plugin_dir.join("tokf.ts").exists());
106100
}
@@ -110,34 +104,62 @@ mod tests {
110104
let dir = TempDir::new().unwrap();
111105
let plugin_dir = dir.path().join("plugins");
112106

113-
install_to(&plugin_dir).unwrap();
114-
install_to(&plugin_dir).unwrap();
107+
install_to(&plugin_dir, "tokf").unwrap();
108+
install_to(&plugin_dir, "tokf").unwrap();
115109

116110
// Only one file exists, no errors
117111
let entries: Vec<_> = std::fs::read_dir(&plugin_dir).unwrap().collect();
118112
assert_eq!(entries.len(), 1);
119113
}
120114

121115
#[test]
122-
fn write_plugin_embeds_tokf_path() {
116+
fn write_plugin_uses_bare_tokf() {
123117
let dir = TempDir::new().unwrap();
124118
let plugin_dir = dir.path().join("plugins");
125119

126-
write_plugin_file(&plugin_dir).unwrap();
120+
write_plugin_file(&plugin_dir, "tokf").unwrap();
127121

128122
let content = std::fs::read_to_string(plugin_dir.join("tokf.ts")).unwrap();
129-
// Should not contain the template placeholder
130-
assert!(!content.contains("{{TOKF_PATH}}"));
131-
// Should contain something that looks like an executable path
132-
assert!(content.contains("const TOKF_BIN"));
123+
assert!(
124+
content.contains(r#"const TOKF_BIN = "tokf";"#),
125+
"should use bare tokf, got: {content}"
126+
);
127+
}
128+
129+
#[test]
130+
fn write_plugin_custom_path() {
131+
let dir = TempDir::new().unwrap();
132+
let plugin_dir = dir.path().join("plugins");
133+
134+
write_plugin_file(&plugin_dir, "/opt/bin/tokf").unwrap();
135+
136+
let content = std::fs::read_to_string(plugin_dir.join("tokf.ts")).unwrap();
137+
assert!(
138+
content.contains(r#"const TOKF_BIN = "/opt/bin/tokf";"#),
139+
"should use custom path, got: {content}"
140+
);
141+
}
142+
143+
#[test]
144+
fn write_plugin_escapes_backslash_in_path() {
145+
let dir = TempDir::new().unwrap();
146+
let plugin_dir = dir.path().join("plugins");
147+
148+
write_plugin_file(&plugin_dir, r"C:\Users\me\tokf.exe").unwrap();
149+
150+
let content = std::fs::read_to_string(plugin_dir.join("tokf.ts")).unwrap();
151+
assert!(
152+
content.contains(r#"const TOKF_BIN = "C:\\Users\\me\\tokf.exe";"#),
153+
"backslashes should be escaped for JS, got: {content}"
154+
);
133155
}
134156

135157
#[test]
136158
fn write_plugin_rewrites_bash_tool_name() {
137159
let dir = TempDir::new().unwrap();
138160
let plugin_dir = dir.path().join("plugins");
139161

140-
write_plugin_file(&plugin_dir).unwrap();
162+
write_plugin_file(&plugin_dir, "tokf").unwrap();
141163

142164
let content = std::fs::read_to_string(plugin_dir.join("tokf.ts")).unwrap();
143165
assert!(content.contains(r#"input.tool !== "bash""#));
@@ -169,41 +191,15 @@ mod tests {
169191
assert!(PLUGIN_TEMPLATE.contains("[tokf] plugin error:"));
170192
}
171193

172-
// R10: Test the path escaping helper handles special characters.
173-
#[test]
174-
fn escape_for_js_string_handles_special_chars() {
175-
// Test that backslashes get escaped
176-
assert!(
177-
escape_for_js_string("C:\\Users\\foo\\tokf")
178-
.unwrap()
179-
.contains("\\\\")
180-
);
181-
// Test that quotes are escaped as \" (i.e. the result contains \" not a bare ").
182-
// serde_json produces the JS-safe escape sequence.
183-
let escaped = escape_for_js_string("path\"with\"quotes").unwrap();
184-
assert!(
185-
escaped.contains("\\\""),
186-
"quotes should be backslash-escaped"
187-
);
188-
// The result must be safe to embed inside a JS double-quoted string:
189-
// it should not contain an unescaped double quote (any " must be preceded by \).
190-
let unescaped_quote = escaped
191-
.chars()
192-
.zip(std::iter::once(' ').chain(escaped.chars()))
193-
.any(|(ch, prev)| ch == '"' && prev != '\\');
194-
assert!(!unescaped_quote, "must not contain unescaped double quote");
195-
}
196-
197-
// R10: Verify no placeholder remains after write.
198194
#[test]
199195
fn write_plugin_no_placeholder_remains() {
200196
let dir = TempDir::new().unwrap();
201197
let plugin_dir = dir.path().join("plugins");
202198

203-
write_plugin_file(&plugin_dir).unwrap();
199+
write_plugin_file(&plugin_dir, "tokf").unwrap();
204200

205201
let content = std::fs::read_to_string(plugin_dir.join("tokf.ts")).unwrap();
206-
assert!(!content.contains("{{TOKF_PATH}}"));
202+
assert!(!content.contains("{{TOKF_BIN}}"));
207203
assert!(content.contains("const TOKF_BIN"));
208204
}
209205
}

crates/tokf-cli/src/hook/tests.rs

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ fn write_hook_shim_creates_executable_script() {
262262
let hook_dir = dir.path().join("hooks");
263263
let hook_script = hook_dir.join("pre-tool-use.sh");
264264

265-
write_hook_shim(&hook_dir, &hook_script).unwrap();
265+
write_hook_shim(&hook_dir, &hook_script, "tokf").unwrap();
266266

267267
let content = std::fs::read_to_string(&hook_script).unwrap();
268268
assert!(content.starts_with("#!/bin/sh\n"));
@@ -280,18 +280,47 @@ fn write_hook_shim_creates_executable_script() {
280280
}
281281

282282
#[test]
283-
fn write_hook_shim_quotes_path() {
283+
fn write_hook_shim_uses_bare_tokf() {
284284
let dir = tempfile::TempDir::new().unwrap();
285285
let hook_dir = dir.path().join("hooks");
286286
let hook_script = hook_dir.join("pre-tool-use.sh");
287287

288-
write_hook_shim(&hook_dir, &hook_script).unwrap();
288+
write_hook_shim(&hook_dir, &hook_script, "tokf").unwrap();
289289

290290
let content = std::fs::read_to_string(&hook_script).unwrap();
291-
// The exec line should contain single quotes around the path
292291
assert!(
293-
content.contains("exec '"),
294-
"expected quoted path in script, got: {content}"
292+
content.contains("exec tokf hook handle"),
293+
"expected bare tokf in script, got: {content}"
294+
);
295+
}
296+
297+
#[test]
298+
fn write_hook_shim_custom_path() {
299+
let dir = tempfile::TempDir::new().unwrap();
300+
let hook_dir = dir.path().join("hooks");
301+
let hook_script = hook_dir.join("pre-tool-use.sh");
302+
303+
write_hook_shim(&hook_dir, &hook_script, "/opt/bin/tokf").unwrap();
304+
305+
let content = std::fs::read_to_string(&hook_script).unwrap();
306+
assert!(
307+
content.contains("exec '/opt/bin/tokf' hook handle"),
308+
"expected shell-escaped custom path, got: {content}"
309+
);
310+
}
311+
312+
#[test]
313+
fn write_hook_shim_path_with_spaces() {
314+
let dir = tempfile::TempDir::new().unwrap();
315+
let hook_dir = dir.path().join("hooks");
316+
let hook_script = hook_dir.join("pre-tool-use.sh");
317+
318+
write_hook_shim(&hook_dir, &hook_script, "/home/my user/bin/tokf").unwrap();
319+
320+
let content = std::fs::read_to_string(&hook_script).unwrap();
321+
assert!(
322+
content.contains("exec '/home/my user/bin/tokf' hook handle"),
323+
"path with spaces should be shell-escaped, got: {content}"
295324
);
296325
}
297326

@@ -303,7 +332,7 @@ fn install_to_creates_files() {
303332
let hook_dir = dir.path().join("global/tokf/hooks");
304333
let settings_path = dir.path().join("global/.claude/settings.json");
305334

306-
install_to(&hook_dir, &settings_path).unwrap();
335+
install_to(&hook_dir, &settings_path, "tokf").unwrap();
307336

308337
let hook_script = hook_dir.join("pre-tool-use.sh");
309338
assert!(hook_script.exists(), "hook script should exist");
@@ -321,8 +350,8 @@ fn install_to_idempotent() {
321350
let hook_dir = dir.path().join(".tokf/hooks");
322351
let settings_path = dir.path().join("settings.json");
323352

324-
install_to(&hook_dir, &settings_path).unwrap();
325-
install_to(&hook_dir, &settings_path).unwrap();
353+
install_to(&hook_dir, &settings_path, "tokf").unwrap();
354+
install_to(&hook_dir, &settings_path, "tokf").unwrap();
326355

327356
let content = std::fs::read_to_string(&settings_path).unwrap();
328357
let value: serde_json::Value = serde_json::from_str(&content).unwrap();

0 commit comments

Comments
 (0)