#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Mode {
#[default]
Deny,
Ask,
Warn,
}
impl Mode {
pub fn from_name(s: &str) -> Option<Mode> {
match s {
"deny" => Some(Mode::Deny),
"ask" => Some(Mode::Ask),
"warn" => Some(Mode::Warn),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
Mode::Deny => "deny",
Mode::Ask => "ask",
Mode::Warn => "warn",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Steer {
pub rule_id: &'static str,
pub tool: &'static str,
pub suggestion: String,
pub note: &'static str,
}
impl Steer {
pub fn reason(&self) -> String {
format!(
"A `ct` tool serves this more reliably — bounded, deterministic, \
and self-verifying. Use instead:\n {}\n({})",
self.suggestion, self.note
)
}
}
pub fn date_stem(epoch_secs: i64) -> String {
let (y, m, d) = civil_from_days(epoch_secs.div_euclid(86_400));
format!("{y:04}-{m:02}-{d:02}")
}
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d)
}
pub const LOG_IGNORE_RULE: &str = "*log";
pub fn gitignore_with_log_rule(existing: Option<&str>) -> Option<String> {
match existing {
None => Some(format!("{LOG_IGNORE_RULE}\n")),
Some(text) if text.lines().any(|l| l.trim() == LOG_IGNORE_RULE) => None,
Some(text) => {
let mut out = text.to_string();
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str(LOG_IGNORE_RULE);
out.push('\n');
Some(out)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Tok {
Word(String),
Pipe,
And,
Or,
Semi,
}
fn lex(cmd: &str) -> Vec<Tok> {
let mut toks = Vec::new();
let mut cur = String::new();
let mut have = false; let mut chars = cmd.chars().peekable();
fn flush(toks: &mut Vec<Tok>, cur: &mut String, have: &mut bool) {
if *have {
toks.push(Tok::Word(std::mem::take(cur)));
*have = false;
}
}
while let Some(c) = chars.next() {
match c {
'\'' => {
have = true;
for d in chars.by_ref() {
if d == '\'' {
break;
}
cur.push(d);
}
}
'"' => {
have = true;
while let Some(d) = chars.next() {
if d == '"' {
break;
}
if d == '\\' {
if let Some(e) = chars.next() {
cur.push(e);
}
} else {
cur.push(d);
}
}
}
'\\' => {
if let Some(d) = chars.next() {
cur.push(d);
have = true;
}
}
'|' => {
flush(&mut toks, &mut cur, &mut have);
if chars.peek() == Some(&'|') {
chars.next();
toks.push(Tok::Or);
} else {
toks.push(Tok::Pipe);
}
}
'&' => {
flush(&mut toks, &mut cur, &mut have);
if chars.peek() == Some(&'&') {
chars.next();
toks.push(Tok::And);
} else {
toks.push(Tok::Semi); }
}
';' => {
flush(&mut toks, &mut cur, &mut have);
toks.push(Tok::Semi);
}
'>' | '<' | '(' | ')' | '{' | '}' | '`' => {
flush(&mut toks, &mut cur, &mut have);
}
c if c.is_whitespace() => flush(&mut toks, &mut cur, &mut have),
_ => {
cur.push(c);
have = true;
}
}
}
flush(&mut toks, &mut cur, &mut have);
toks
}
fn control_segments(toks: &[Tok]) -> (Vec<Vec<Tok>>, Vec<Tok>) {
let mut segs = vec![Vec::new()];
let mut joiners = Vec::new();
for t in toks {
match t {
Tok::And | Tok::Or | Tok::Semi => {
joiners.push(t.clone());
segs.push(Vec::new());
}
other => segs.last_mut().unwrap().push(other.clone()),
}
}
if segs.last().is_some_and(Vec::is_empty) {
segs.pop();
joiners.pop();
}
(segs, joiners)
}
fn pipe_stages(seg: &[Tok]) -> Vec<Vec<String>> {
let mut stages = vec![Vec::new()];
for t in seg {
match t {
Tok::Pipe => stages.push(Vec::new()),
Tok::Word(w) => stages.last_mut().unwrap().push(w.clone()),
_ => {}
}
}
stages
}
fn base_name(w: &str) -> &str {
w.rsplit(['/', '\\']).next().unwrap_or(w)
}
fn cmd_of(stage: &[String]) -> Option<&str> {
stage.first().map(|w| base_name(w))
}
fn has_short(stage: &[String], ch: char) -> bool {
stage
.iter()
.any(|w| w.starts_with('-') && !w.starts_with("--") && w[1..].chars().any(|c| c == ch))
}
fn has_flag(stage: &[String], flag: &str) -> bool {
stage
.iter()
.any(|w| w == flag || w.starts_with(&format!("{flag}=")))
}
fn flag_value<'a>(stage: &'a [String], names: &[&str]) -> Option<&'a str> {
for (i, w) in stage.iter().enumerate() {
for n in names {
if w == n {
return stage.get(i + 1).map(String::as_str);
}
let eq = format!("{n}=");
if let Some(v) = w.strip_prefix(&eq) {
return Some(v);
}
}
}
None
}
fn positionals(stage: &[String]) -> Vec<&str> {
stage
.iter()
.skip(1)
.filter(|w| !w.starts_with('-'))
.map(String::as_str)
.collect()
}
fn find_base(find: &[String]) -> Option<&str> {
find.get(1)
.filter(|w| !w.starts_with('-'))
.map(String::as_str)
}
fn q(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
pub fn analyze(command: &str) -> Option<Steer> {
let toks = lex(command);
if toks.is_empty() {
return None;
}
let (segs, joiners) = control_segments(&toks);
let seg_stages: Vec<Vec<Vec<String>>> = segs.iter().map(|s| pipe_stages(s)).collect();
let touches_ct = seg_stages.iter().flatten().flatten().any(|w| {
let b = base_name(w);
b == "ct" || b.starts_with("ct-")
});
if touches_ct {
return None;
}
if let Some(first) = seg_stages
.first()
.and_then(|s| s.first())
.and_then(|s| cmd_of(s))
&& matches!(first, "for" | "while" | "until")
{
let waits = seg_stages
.iter()
.flatten()
.flatten()
.any(|w| matches!(base_name(w), "sleep" | "usleep" | "Start-Sleep"));
return Some(if waits {
Steer {
rule_id: "wait-loop",
tool: "ct await",
suggestion: "ct await --timeout <SECS> --every <N> -- <probe-argv>".to_string(),
note: "ct await polls a read-only probe until it passes (or a timeout/abort fires) with no shell loop — and being the wait itself, it should be launched in the background, never wrapped in `for/while … sleep`",
}
} else {
Steer {
rule_id: "shell-loop",
tool: "ct each",
suggestion: "ct each --items <a> <b> -- <cmd-template-with-{ITEM}>".to_string(),
note: "ct each runs a command template once per item with no shell and an aggregate --expect verdict",
}
});
}
if segs.len() == 1 {
return analyze_segment(&seg_stages[0]);
}
let matches: Vec<Steer> = seg_stages
.iter()
.filter_map(|st| analyze_segment(st))
.collect();
if matches.len() == segs.len() && !joiners.is_empty() {
if joiners.iter().all(|j| *j == Tok::And) {
return Some(chain_steer("ct and", &matches));
}
if joiners.iter().all(|j| *j == Tok::Or) {
return Some(chain_steer("ct or", &matches));
}
}
None
}
fn chain_steer(head: &'static str, parts: &[Steer]) -> Steer {
let body = parts
.iter()
.map(|p| p.suggestion.trim_start_matches("ct ").to_string())
.collect::<Vec<_>>()
.join(" ::: ");
let (rule_id, note) = if head == "ct and" {
(
"and-chain",
"ct and runs each step in turn, stopping at the first failure — a shell-less && with no quoting",
)
} else {
(
"or-chain",
"ct or runs each step in turn, stopping at the first success — a shell-less || with no quoting",
)
};
Steer {
rule_id,
tool: head,
suggestion: format!("{head} {body}"),
note,
}
}
fn analyze_segment(stages: &[Vec<String>]) -> Option<Steer> {
rule_find_grep(stages)
.or_else(|| rule_grep_recursive(stages))
.or_else(|| rule_grep_count(stages))
.or_else(|| rule_sed_inplace(stages))
.or_else(|| rule_read_range(stages))
.or_else(|| rule_interpreter_read(stages))
.or_else(|| rule_find_files(stages))
.or_else(|| rule_list_recursive(stages))
.or_else(|| rule_count_lines(stages))
}
fn rule_find_grep(stages: &[Vec<String>]) -> Option<Steer> {
let find = stages.iter().find(|s| cmd_of(s) == Some("find"))?;
let grep_stage = stages
.iter()
.find(|s| s.iter().any(|w| base_name(w) == "grep"))?;
let glob = flag_value(find, &["-name", "-iname"]);
let pat = grep_pattern(grep_stage);
Some(Steer {
rule_id: "find-grep",
tool: "ct search",
suggestion: search_suggestion(find_base(find), glob, pat),
note: "ct search recurses, filters by name/type/size, and greps in one declarative pass — find | xargs grep in a single command",
})
}
fn rule_grep_recursive(stages: &[Vec<String>]) -> Option<Steer> {
for s in stages {
let Some(cmd) = cmd_of(s) else { continue };
let recursive_grep =
cmd == "grep" && (has_short(s, 'r') || has_short(s, 'R') || has_flag(s, "--recursive"));
if recursive_grep || matches!(cmd, "rg" | "ripgrep" | "ag") {
let pat = grep_pattern(s);
let base = positionals(s).get(1).copied();
return Some(Steer {
rule_id: "grep-recursive",
tool: "ct search",
suggestion: search_suggestion(base, None, pat),
note: "ct search is the suite's recursive content search, with a framed --expect verdict (e.g. --expect none asserts absence)",
});
}
}
None
}
fn rule_grep_count(stages: &[Vec<String>]) -> Option<Steer> {
for s in stages {
let Some(cmd) = cmd_of(s) else { continue };
if matches!(cmd, "grep" | "egrep" | "fgrep") && has_short(s, 'c') {
let base = positionals(s).get(1).copied();
return Some(Steer {
rule_id: "grep-count",
tool: "ct search",
suggestion: format!(
"{} --summary",
search_suggestion(base, None, grep_pattern(s))
),
note: "ct search --summary reports the match count directly (and --expect +N|=N turns it into a pass/fail assertion), replacing grep -c",
});
}
}
None
}
fn rule_interpreter_read(stages: &[Vec<String>]) -> Option<Steer> {
for s in stages {
let Some(cmd) = cmd_of(s) else { continue };
if cmd == "jq" {
if let Some(&file) = positionals(s).get(1) {
return Some(interpreter_steer(Some(file)));
}
continue;
}
let interp = matches!(
cmd,
"python" | "python3" | "node" | "nodejs" | "perl" | "ruby"
);
if interp
&& let Some(body) = flag_value(s, &["-c", "-e"])
&& reads_file(body)
&& !writes_file(body)
{
return Some(interpreter_steer(quoted_path(body)));
}
}
None
}
fn rule_find_files(stages: &[Vec<String>]) -> Option<Steer> {
let find = stages.iter().find(|s| cmd_of(s) == Some("find"))?;
let glob = flag_value(find, &["-name", "-iname"])?;
let base = find_base(find);
Some(Steer {
rule_id: "find-files",
tool: "ct search",
suggestion: search_suggestion(base, Some(glob), None),
note: "ct search selects files by --name/--type/--size and reports them, replacing a bare find",
})
}
fn rule_sed_inplace(stages: &[Vec<String>]) -> Option<Steer> {
let stage = stages.iter().find(|s| {
let cmd = cmd_of(s);
let sed_i =
cmd == Some("sed") && s.iter().any(|w| w.starts_with("-i") || w == "--in-place");
let perl_i = cmd == Some("perl") && s.iter().any(|w| w.starts_with("-i"));
sed_i || perl_i
})?;
let (find, replace) = sed_subst(stage);
let suggestion = match (find, replace) {
(Some(f), Some(r)) => format!(
"ct edit --base . --find {} --replace {} --expect =1 --dry-run",
q(f),
q(r)
),
_ => "ct edit --base . --find <text> --replace <text> --expect =1 --dry-run".to_string(),
};
Some(Steer {
rule_id: "sed-inplace",
tool: "ct edit",
suggestion,
note: "ct edit previews the diff (--dry-run) and writes only when the match count matches --expect, so a wrong-sized in-place edit fails loudly instead of applying silently",
})
}
fn rule_read_range(stages: &[Vec<String>]) -> Option<Steer> {
for s in stages {
if cmd_of(s) == Some("sed")
&& has_flag(s, "-n")
&& let Some((a, b)) = positionals(s).into_iter().find_map(parse_sed_range)
{
let file = positionals(s).into_iter().find(|&w| !is_sed_script(w));
return Some(view_steer(file, Some((a, b))));
}
}
for (i, s) in stages.iter().enumerate() {
let cmd = cmd_of(s);
if cmd != Some("head") && cmd != Some("tail") {
continue;
}
let n = head_count(s);
let own = positionals(s)
.into_iter()
.find(|w| w.parse::<u64>().is_err());
let upstream = (i > 0 && cmd_of(&stages[i - 1]) == Some("cat"))
.then(|| positionals(&stages[i - 1]).into_iter().next())
.flatten();
let file = own.or(upstream)?; let range = match (cmd, n) {
(Some("head"), Some(n)) => Some((1, n)),
_ => None, };
return Some(view_steer(Some(file), range));
}
None
}
fn rule_list_recursive(stages: &[Vec<String>]) -> Option<Steer> {
let stage = stages
.iter()
.find(|s| cmd_of(s) == Some("tree") || (cmd_of(s) == Some("ls") && has_short(s, 'R')))?;
let base = positionals(stage).first().copied();
let suggestion = match base {
Some(b) => format!("ct tree --base {b}"),
None => "ct tree".to_string(),
};
Some(Steer {
rule_id: "list-recursive",
tool: "ct tree",
suggestion,
note: "ct tree reports the file tree with per-file line/word/char counts, filtering and sorting — a richer, bounded ls -R / tree",
})
}
fn rule_count_lines(stages: &[Vec<String>]) -> Option<Steer> {
for (i, s) in stages.iter().enumerate() {
if cmd_of(s) != Some("wc") {
continue;
}
let has_files = !positionals(s).is_empty();
let upstream = i.checked_sub(1).map(|j| &stages[j]);
let from_find = upstream.is_some_and(|u| matches!(cmd_of(u), Some("find") | Some("ls")));
let from_cat =
upstream.is_some_and(|u| cmd_of(u) == Some("cat") && !positionals(u).is_empty());
if has_files || from_find || from_cat {
return Some(Steer {
rule_id: "count-lines",
tool: "ct tree",
suggestion: "ct tree --summary".to_string(),
note: "ct tree reports per-file and total line/word/char counts directly, replacing wc -l over a file set",
});
}
}
None
}
fn search_suggestion(base: Option<&str>, name: Option<&str>, grep: Option<&str>) -> String {
let mut out = String::from("ct search");
if let Some(b) = base {
out.push_str(&format!(" --base {b}"));
}
if let Some(n) = name {
out.push_str(&format!(" --name {}", q(n)));
}
match grep {
Some(g) => out.push_str(&format!(" --grep {}", q(g))),
None => out.push_str(" --grep <pattern>"),
}
out
}
fn view_steer(file: Option<&str>, range: Option<(u32, u32)>) -> Steer {
let f = file.unwrap_or("<file>");
let suggestion = match range {
Some((a, b)) => format!("ct view {f} --range {a}:{b}"),
None => format!("ct view {f} --range <start>:<end>"),
};
Steer {
rule_id: "read-range",
tool: "ct view",
suggestion,
note: "ct view shows a file's lines by range (or the regions around a pattern with context) — a precise, bounded read",
}
}
fn interpreter_steer(file: Option<&str>) -> Steer {
let f = file.unwrap_or("<file>");
Steer {
rule_id: "interpreter-read",
tool: "ct view",
suggestion: format!("ct view {f} --range <start>:<end>"),
note: "an interpreter one-liner that reads a file is a bounded read — `ct view` shows a line range (or `--match <pat> --context N`), and `ct search <file> --grep <pat> --detail` finds the matching record, both without a hand-rolled parser",
}
}
fn reads_file(body: &str) -> bool {
const READS: &[&str] = &[
"open(",
"json.load",
"readlines",
"read_text",
"readFileSync",
"JSON.parse",
"File.read",
"IO.read",
"Get-Content",
];
READS.iter().any(|m| body.contains(m))
}
fn writes_file(body: &str) -> bool {
const WRITES: &[&str] = &[
",'w'",
", 'w'",
",\"w\"",
", \"w\"",
",'a'",
", 'a'",
",\"a\"",
"'r+'",
"\"r+\"",
"'wb'",
"\"wb\"",
".write(",
"writeFile",
"json.dump",
"to_csv",
"to_json(",
"File.write",
];
WRITES.iter().any(|m| body.contains(m))
}
fn quoted_path(body: &str) -> Option<&str> {
let bytes = body.as_bytes();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if (c == b'\'' || c == b'"')
&& let Some(rel) = body[i + 1..].find(c as char)
{
let inner = &body[i + 1..i + 1 + rel];
if inner.contains('.') || inner.contains('/') {
return Some(inner);
}
i += 1 + rel + 1;
continue;
}
i += 1;
}
None
}
fn grep_pattern(stage: &[String]) -> Option<&str> {
if let Some(v) = flag_value(stage, &["-e", "--regexp"]) {
return Some(v);
}
let start = stage
.iter()
.position(|w| {
matches!(
base_name(w),
"grep" | "egrep" | "fgrep" | "rg" | "ripgrep" | "ag"
)
})
.map_or(1, |i| i + 1);
stage[start..]
.iter()
.find(|w| !w.starts_with('-'))
.map(String::as_str)
}
fn sed_subst(stage: &[String]) -> (Option<&str>, Option<&str>) {
for w in stage.iter().skip(1) {
if let Some(rest) = w.strip_prefix('s')
&& let Some(delim) = rest.chars().next()
&& !delim.is_alphanumeric()
{
let parts: Vec<&str> = rest[delim.len_utf8()..].split(delim).collect();
if parts.len() >= 2 {
return (Some(parts[0]), Some(parts[1]));
}
}
}
(None, None)
}
fn head_count(stage: &[String]) -> Option<u32> {
if let Some(v) = flag_value(stage, &["-n", "--lines"])
&& let Ok(n) = v.parse::<u32>()
{
return Some(n);
}
stage
.iter()
.skip(1)
.find_map(|w| w.strip_prefix('-').and_then(|d| d.parse::<u32>().ok()))
}
fn is_sed_script(w: &str) -> bool {
if parse_sed_range(w).is_some() {
return true;
}
let mut ch = w.chars();
ch.next() == Some('s') && ch.next().is_some_and(|d| !d.is_alphanumeric())
}
fn parse_sed_range(w: &str) -> Option<(u32, u32)> {
let body = w.strip_suffix('p').unwrap_or(w);
match body.split_once(',') {
Some((a, b)) => Some((a.parse().ok()?, b.parse().ok()?)),
None => {
let n = body.parse().ok()?;
Some((n, n))
}
}
}
pub fn grep_steer(pattern: &str, path: Option<&str>, glob: Option<&str>) -> Steer {
Steer {
rule_id: "harness-grep",
tool: "ct search",
suggestion: search_suggestion(path, glob, Some(pattern)),
note: "ct search is the suite's content search — recursive, filtered by name/type/size, with a framed --expect verdict; ct outline maps a file's symbols when you are after a definition",
}
}
pub fn glob_steer(pattern: &str, path: Option<&str>) -> Steer {
let (glob_base, name) = split_glob(pattern);
let base = path.map(str::to_string).or(glob_base);
let mut out = String::from("ct search");
if let Some(b) = base {
out.push_str(&format!(" --base {b}"));
}
out.push_str(&format!(" --name {} --type f", q(&name)));
Steer {
rule_id: "harness-glob",
tool: "ct search",
suggestion: out,
note: "ct search selects files by --name/--type/--size from a chosen root and reports them — the suite's glob, recursive by default",
}
}
fn split_glob(pattern: &str) -> (Option<String>, String) {
let segs: Vec<&str> = pattern.split('/').collect();
let name = segs.last().copied().unwrap_or(pattern).to_string();
let is_wild = |s: &str| s.contains(['*', '?', '[', '{']);
let literal: Vec<&str> = segs
.iter()
.take(segs.len().saturating_sub(1))
.take_while(|s| !is_wild(s) && !s.is_empty())
.copied()
.collect();
((!literal.is_empty()).then(|| literal.join("/")), name)
}
pub fn read_steer(file_path: &str, offset: Option<i64>, limit: Option<i64>) -> Option<Steer> {
if is_unrenderable(file_path) {
return None;
}
let range = match (offset, limit) {
(Some(o), Some(l)) => {
let start = o.max(1);
Some(format!("{start}:{}", (start + l - 1).max(start)))
}
(Some(o), None) => Some(format!("{}:", o.max(1))),
(None, Some(l)) => Some(format!("1:{}", l.max(1))),
(None, None) => None,
};
let suggestion = match range {
Some(r) => format!("ct view {file_path} --range {r}"),
None => format!("ct view {file_path}"),
};
Some(Steer {
rule_id: "harness-read",
tool: "ct view",
suggestion,
note: "ct view is the suite's bounded file reader — a line range, or --match with context (Read stays the tool for images, PDFs, and notebooks ct view cannot render)",
})
}
fn is_unrenderable(path: &str) -> bool {
const EXTS: &[&str] = &[
"png", "jpg", "jpeg", "gif", "bmp", "webp", "ico", "tif", "tiff", "pdf", "ipynb",
];
let ext = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
path.contains('.') && EXTS.contains(&ext.as_str())
}
pub mod hook {
use super::{Mode, Steer, analyze, glob_steer, grep_steer, read_steer};
use serde_json::{Value, json};
pub fn decision(steer: &Steer, mode: Mode) -> Value {
let reason = steer.reason();
match mode {
Mode::Deny => json!({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}}),
Mode::Ask => json!({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": reason,
}}),
Mode::Warn => json!({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": reason,
}}),
}
}
fn str_field<'a>(input: &'a Value, key: &str) -> Option<&'a str> {
input.get(key).and_then(Value::as_str)
}
fn int_field(input: &Value, key: &str) -> Option<i64> {
input.get(key).and_then(Value::as_i64)
}
pub fn classify(tool: &str, input: &Value) -> Option<Steer> {
match tool {
"Bash" => analyze(str_field(input, "command")?),
"Grep" => Some(grep_steer(
str_field(input, "pattern")?,
str_field(input, "path"),
str_field(input, "glob"),
)),
"Glob" => Some(glob_steer(
str_field(input, "pattern")?,
str_field(input, "path"),
)),
"Read" => read_steer(
str_field(input, "file_path")?,
int_field(input, "offset"),
int_field(input, "limit"),
),
_ => None,
}
}
pub fn process(envelope: &str, mode: Mode) -> Option<Value> {
let v: Value = serde_json::from_str(envelope).ok()?;
let tool = v.get("tool_name").and_then(Value::as_str)?;
let input = v.get("tool_input")?;
let steer = classify(tool, input)?;
Some(decision(&steer, mode))
}
pub fn log_record(envelope: &str, mode: Mode) -> Value {
let v: Value = serde_json::from_str(envelope).unwrap_or(Value::Null);
let tool = v.get("tool_name").and_then(Value::as_str).unwrap_or("");
let input = v.get("tool_input").cloned().unwrap_or(Value::Null);
let (decision, rule_id, ct_tool) = match classify(tool, &input) {
Some(s) => (mode.name(), Some(s.rule_id), Some(s.tool)),
None => ("allow", None, None),
};
json!({
"tool": tool,
"command": input.get("command").and_then(Value::as_str),
"cwd": v.get("cwd").and_then(Value::as_str),
"session_id": v.get("session_id").and_then(Value::as_str),
"decision": decision,
"rule_id": rule_id,
"ct_tool": ct_tool,
})
}
}
pub mod install {
use super::Mode;
use crate::patch::{self, Op, parse_path};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
Project,
Local,
User,
}
impl Scope {
pub fn from_name(s: &str) -> Option<Scope> {
match s {
"project" => Some(Scope::Project),
"local" => Some(Scope::Local),
"user" => Some(Scope::User),
_ => None,
}
}
pub fn path(self, root: &Path, home: &Path) -> PathBuf {
match self {
Scope::Project => root.join(".claude").join("settings.json"),
Scope::Local => root.join(".claude").join("settings.local.json"),
Scope::User => home.join(".claude").join("settings.json"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tool {
Bash,
Grep,
Glob,
Read,
All,
}
impl Tool {
pub fn from_name(s: &str) -> Option<Tool> {
match s {
"Bash" => Some(Tool::Bash),
"Grep" => Some(Tool::Grep),
"Glob" => Some(Tool::Glob),
"Read" => Some(Tool::Read),
"all" | "*" => Some(Tool::All),
_ => None,
}
}
pub fn matcher(self) -> &'static str {
match self {
Tool::Bash => "Bash",
Tool::Grep => "Grep",
Tool::Glob => "Glob",
Tool::Read => "Read",
Tool::All => "*",
}
}
}
pub fn hook_command(mode: Mode, log_dir: Option<&str>, no_log: bool) -> String {
let mut cmd = match mode {
Mode::Deny => "ct steer hook".to_string(),
other => format!("ct steer hook --mode {}", other.name()),
};
if no_log {
cmd.push_str(" --no-log");
} else if let Some(path) = log_dir {
let quoted = if path.chars().any(char::is_whitespace) {
format!("\"{path}\"")
} else {
path.to_string()
};
cmd.push_str(&format!(" --log-dir {quoted}"));
}
cmd
}
fn is_steer_command(s: &str) -> bool {
s.contains("steer") && s.contains("hook")
}
fn inspect(text: &str) -> Result<Value, String> {
let root = jsonc_parser::parse_to_serde_value(text, &jsonc_parser::ParseOptions::default())
.map_err(|e| format!("parse settings: {e}"))?
.unwrap_or_else(|| json!({}));
if !root.is_object() {
return Err("settings root must be a JSON object".to_string());
}
Ok(root)
}
fn canonical(command: &str, tools: &[Tool]) -> String {
let matchers: Vec<Value> = tools
.iter()
.map(|t| {
json!({ "matcher": t.matcher(), "hooks": [ { "type": "command", "command": command } ] })
})
.collect();
let v = json!({ "hooks": { "PreToolUse": matchers } });
serde_json::to_string_pretty(&v).unwrap() + "\n"
}
fn op_set(path: &str, value: String) -> Result<Op, String> {
Ok(Op::Set {
path: parse_path(path)?,
raw: path.to_string(),
value,
})
}
fn op_add(path: &str, value: String) -> Result<Op, String> {
Ok(Op::Add {
path: parse_path(path)?,
raw: path.to_string(),
value,
})
}
fn op_delete(path: &str) -> Result<Op, String> {
Ok(Op::Delete {
path: parse_path(path)?,
raw: path.to_string(),
})
}
fn apply(text: &str, ops: &[Op]) -> Result<(String, bool), String> {
if ops.is_empty() {
return Ok((text.to_string(), false));
}
let (out, changes) =
patch::apply_doc(text, ops).map_err(|e| format!("settings merge: {e}"))?;
Ok((out, changes > 0))
}
pub fn install(
existing: Option<&str>,
command: &str,
tools: &[Tool],
) -> Result<(String, bool), String> {
let Some(text) = existing.filter(|t| !t.trim().is_empty()) else {
return Ok((canonical(command, tools), true));
};
let root = inspect(text)?;
let ops = install_ops(&root, command, tools)?;
apply(text, &ops)
}
pub fn uninstall(existing: Option<&str>) -> Result<(String, bool), String> {
let Some(text) = existing.filter(|t| !t.trim().is_empty()) else {
return Ok((existing.unwrap_or_default().to_string(), false));
};
let root = inspect(text)?;
let ops = uninstall_ops(&root)?;
apply(text, &ops)
}
fn pre_array(root: &Value) -> Option<&Vec<Value>> {
root.get("hooks")
.and_then(|h| h.get("PreToolUse"))
.and_then(Value::as_array)
}
fn is_matcher(entry: &Value, name: &str) -> bool {
entry.get("matcher").and_then(Value::as_str) == Some(name)
}
fn entry_has_steer(entry: &Value) -> bool {
entry
.get("hooks")
.and_then(Value::as_array)
.is_some_and(|l| {
l.iter().any(|h| {
h.get("command")
.and_then(Value::as_str)
.is_some_and(is_steer_command)
})
})
}
fn install_ops(root: &Value, command: &str, tools: &[Tool]) -> Result<Vec<Op>, String> {
let mut ops = Vec::new();
let hooks = root.get("hooks");
match hooks {
None => ops.push(op_set(".hooks", "{}".to_string())?),
Some(h) if !h.is_object() => {
return Err("settings `hooks` must be an object".to_string());
}
Some(_) => {}
}
let pre = hooks.and_then(|h| h.get("PreToolUse"));
match pre {
None => ops.push(op_set(".hooks.PreToolUse", "[]".to_string())?),
Some(p) if !p.is_array() => {
return Err("settings `hooks.PreToolUse` must be an array".to_string());
}
Some(_) => {}
}
let pre_arr = pre.and_then(Value::as_array);
if let Some(arr) = pre_arr {
for (ei, entry) in arr.iter().enumerate() {
let Some(list) = entry.get("hooks").and_then(Value::as_array) else {
continue;
};
for (hi, h) in list.iter().enumerate() {
if let Some(c) = h.get("command").and_then(Value::as_str)
&& is_steer_command(c)
&& c != command
{
ops.push(op_set(
&format!(".hooks.PreToolUse[{ei}].hooks[{hi}].command"),
json!(command).to_string(),
)?);
}
}
}
}
let hook_obj = json!({ "type": "command", "command": command }).to_string();
for tool in tools {
let name = tool.matcher();
if pre_arr.is_some_and(|arr| {
arr.iter()
.any(|e| is_matcher(e, name) && entry_has_steer(e))
}) {
continue;
}
let target =
pre_arr.and_then(|arr| arr.iter().enumerate().find(|(_, e)| is_matcher(e, name)));
match target {
Some((ei, e)) if e.get("hooks").and_then(Value::as_array).is_some() => {
ops.push(op_add(
&format!(".hooks.PreToolUse[{ei}].hooks"),
hook_obj.clone(),
)?);
}
Some((ei, _)) => {
ops.push(op_set(
&format!(".hooks.PreToolUse[{ei}].hooks"),
format!("[{hook_obj}]"),
)?);
}
None => {
let matcher = json!({ "matcher": name, "hooks": [ { "type": "command", "command": command } ] })
.to_string();
ops.push(op_add(".hooks.PreToolUse", matcher)?);
}
}
}
Ok(ops)
}
fn uninstall_ops(root: &Value) -> Result<Vec<Op>, String> {
let Some(pre) = pre_array(root) else {
return Ok(vec![]);
};
let mut whole_entries = Vec::new(); let mut partial = Vec::new(); for (ei, entry) in pre.iter().enumerate() {
let Some(list) = entry.get("hooks").and_then(Value::as_array) else {
continue;
};
let ours: Vec<usize> = list
.iter()
.enumerate()
.filter(|(_, h)| {
h.get("command")
.and_then(Value::as_str)
.is_some_and(is_steer_command)
})
.map(|(hi, _)| hi)
.collect();
if ours.is_empty() {
continue;
}
if ours.len() == list.len() {
whole_entries.push(ei);
} else {
partial.push((ei, ours));
}
}
if whole_entries.is_empty() && partial.is_empty() {
return Ok(vec![]);
}
if partial.is_empty() && whole_entries.len() == pre.len() {
let hooks_solo = root
.get("hooks")
.and_then(Value::as_object)
.is_some_and(|o| o.len() == 1);
let path = if hooks_solo {
".hooks"
} else {
".hooks.PreToolUse"
};
return Ok(vec![op_delete(path)?]);
}
let mut ops = Vec::new();
for (ei, his) in &partial {
for hi in his.iter().rev() {
ops.push(op_delete(&format!(".hooks.PreToolUse[{ei}].hooks[{hi}]"))?);
}
}
for ei in whole_entries.iter().rev() {
ops.push(op_delete(&format!(".hooks.PreToolUse[{ei}]"))?);
}
Ok(ops)
}
}
#[cfg(test)]
mod tests {
use super::install::{Scope, Tool, install, uninstall};
use super::*;
use std::path::Path;
fn install_bash(existing: Option<&str>, command: &str) -> Result<(String, bool), String> {
install(existing, command, &[Tool::Bash])
}
fn tool(cmd: &str) -> Option<&'static str> {
analyze(cmd).map(|s| s.tool)
}
fn rule(cmd: &str) -> Option<&'static str> {
analyze(cmd).map(|s| s.rule_id)
}
#[test]
fn steers_high_confidence_idioms() {
assert_eq!(
tool("find . -name '*.rs' | xargs grep TODO"),
Some("ct search")
);
assert_eq!(
rule("find . -name '*.rs' | xargs grep TODO"),
Some("find-grep")
);
assert_eq!(tool("grep -rn TODO src"), Some("ct search"));
assert_eq!(tool("rg TODO src"), Some("ct search"));
assert_eq!(tool("find src -name '*.rs'"), Some("ct search"));
assert_eq!(tool("sed -i 's/foo/bar/g' src/x.rs"), Some("ct edit"));
assert_eq!(tool("head -n 40 src/lib.rs"), Some("ct view"));
assert_eq!(tool("cat src/lib.rs | head -n 20"), Some("ct view"));
assert_eq!(tool("sed -n '10,20p' src/lib.rs"), Some("ct view"));
assert_eq!(tool("ls -R src"), Some("ct tree"));
assert_eq!(tool("wc -l src/lib.rs"), Some("ct tree"));
assert_eq!(tool("for f in a b; do grep -r x $f; done"), Some("ct each"));
assert_eq!(
rule("for f in a b; do grep -r x $f; done"),
Some("shell-loop")
);
}
#[test]
fn steers_wait_loops_to_await_not_each() {
assert_eq!(
tool("for i in $(seq 1 900); do cat f; sleep 2; done"),
Some("ct await")
);
assert_eq!(
rule("for i in $(seq 1 900); do cat f; sleep 2; done"),
Some("wait-loop")
);
assert_eq!(
tool("while true; do check; sleep 5; done"),
Some("ct await")
);
assert_eq!(
tool("until curl -sf http://x; do sleep 3; done"),
Some("ct await")
);
assert_eq!(tool("for f in a b; do grep -r x $f; done"), Some("ct each"));
assert_eq!(
rule("for f in a b; do grep -r x $f; done"),
Some("shell-loop")
);
}
#[test]
fn steers_interpreter_file_reads() {
assert_eq!(tool("jq '.note' feedback/x.jsonl"), Some("ct view"));
assert_eq!(
rule("jq '.note' feedback/x.jsonl"),
Some("interpreter-read")
);
let s = analyze(
"python -c \"rows=[json.loads(l) for l in open('feedback/x.jsonl')]; print(rows[-1])\"",
)
.unwrap();
assert_eq!(s.tool, "ct view");
assert!(
s.suggestion.contains("feedback/x.jsonl"),
"{}",
s.suggestion
);
assert_eq!(
tool("node -e 'const d=require(\"fs\").readFileSync(\"a.json\")'"),
Some("ct view")
);
assert!(analyze("python -c 'print(2+2)'").is_none());
assert!(analyze("python -c \"open('out.txt','w').write('hi')\"").is_none());
assert!(analyze("cat x | jq '.note'").is_none());
}
#[test]
fn steers_count_idioms() {
assert_eq!(tool("grep -c TODO src/lib.rs"), Some("ct search"));
assert_eq!(rule("grep -c TODO src/lib.rs"), Some("grep-count"));
let s = analyze("grep -c TODO src/lib.rs").unwrap();
assert!(s.suggestion.contains("--grep 'TODO'") && s.suggestion.contains("--summary"));
assert_eq!(tool("cat a.jsonl b.jsonl | wc -l"), Some("ct tree"));
assert!(analyze("ps aux | wc -l").is_none());
}
#[test]
fn extracts_obvious_slots() {
let s = analyze("grep -rn TODO src").unwrap();
assert!(s.suggestion.contains("--grep 'TODO'"), "{}", s.suggestion);
let e = analyze("sed -i 's/foo/bar/g' f.rs").unwrap();
assert!(
e.suggestion.contains("--find 'foo'") && e.suggestion.contains("--replace 'bar'"),
"{}",
e.suggestion
);
let v = analyze("head -n 40 src/lib.rs").unwrap();
assert!(
v.suggestion.contains("src/lib.rs --range 1:40"),
"{}",
v.suggestion
);
let fg = analyze("find . -name '*.rs' | xargs grep TODO").unwrap();
assert!(fg.suggestion.contains("--grep 'TODO'"), "{}", fg.suggestion);
assert!(fg.suggestion.contains("--name '*.rs'"), "{}", fg.suggestion);
}
#[test]
fn chain_only_when_all_segments_serviceable() {
let s = analyze("grep -r foo src && sed -i 's/a/b/' f.rs").unwrap();
assert_eq!(s.tool, "ct and");
assert!(
s.suggestion.starts_with("ct and search"),
"{}",
s.suggestion
);
assert!(s.suggestion.contains(":::"), "{}", s.suggestion);
assert!(analyze("grep -r foo src && make").is_none());
}
#[test]
fn allows_safe_and_unknown_commands() {
assert!(analyze("git status").is_none());
assert!(analyze("cargo build && cargo test").is_none());
assert!(analyze("ls -la").is_none());
assert!(analyze("cat file.txt").is_none()); assert!(analyze("grep TODO file.rs").is_none()); assert!(analyze("echo 'a | b && c'").is_none()); assert!(analyze("ps aux | head -n 5").is_none()); assert!(analyze("").is_none());
}
#[test]
fn never_resteers_a_ct_command() {
assert!(analyze("ct search --grep TODO").is_none());
assert!(analyze("ct-search --grep TODO").is_none());
assert!(analyze("find . -name '*.rs' | xargs ct-edit").is_none());
}
#[test]
fn hook_decisions_respect_mode() {
let envelope = r#"{"tool_name":"Bash","tool_input":{"command":"grep -r TODO src"}}"#;
let deny = hook::process(envelope, Mode::Deny).unwrap();
assert_eq!(deny["hookSpecificOutput"]["permissionDecision"], "deny");
assert!(
deny["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.unwrap()
.contains("ct search")
);
let ask = hook::process(envelope, Mode::Ask).unwrap();
assert_eq!(ask["hookSpecificOutput"]["permissionDecision"], "ask");
let warn = hook::process(envelope, Mode::Warn).unwrap();
assert!(warn["hookSpecificOutput"]["additionalContext"].is_string());
assert!(
warn["hookSpecificOutput"]
.get("permissionDecision")
.is_none()
);
}
#[test]
fn hook_steers_harness_grep_glob_read() {
let grep = hook::process(
r#"{"tool_name":"Grep","tool_input":{"pattern":"TODO","path":"src","glob":"*.rs"}}"#,
Mode::Deny,
)
.unwrap();
let reason = grep["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.unwrap();
assert!(reason.contains("ct search"), "{reason}");
assert!(
reason.contains("--grep 'TODO'") && reason.contains("--base src"),
"{reason}"
);
assert!(reason.contains("--name '*.rs'"), "{reason}");
let s = glob_steer("src/**/*.rs", None);
assert_eq!(s.tool, "ct search");
assert!(s.suggestion.contains("--base src"), "{}", s.suggestion);
assert!(s.suggestion.contains("--name '*.rs'"), "{}", s.suggestion);
let read = read_steer("src/lib.rs", Some(10), Some(20)).unwrap();
assert_eq!(read.tool, "ct view");
assert!(
read.suggestion.contains("ct view src/lib.rs --range 10:29"),
"{}",
read.suggestion
);
assert_eq!(
read_steer("notes.md", None, None).unwrap().suggestion,
"ct view notes.md"
);
assert!(read_steer("diagram.png", None, None).is_none());
assert!(read_steer("paper.pdf", None, None).is_none());
assert!(read_steer("nb.ipynb", None, None).is_none());
}
#[test]
fn install_covers_multiple_tools() {
let (text, changed) =
install(None, "ct steer hook", &[Tool::Bash, Tool::Grep, Tool::Read]).unwrap();
assert!(changed);
for m in ["\"Bash\"", "\"Grep\"", "\"Read\""] {
assert!(text.contains(m), "missing matcher {m} in {text}");
}
let (_, again) = install(
Some(&text),
"ct steer hook",
&[Tool::Bash, Tool::Grep, Tool::Read],
)
.unwrap();
assert!(!again);
let (grown, did) = install(Some(&text), "ct steer hook", &[Tool::Glob]).unwrap();
assert!(did);
assert!(grown.contains("\"Glob\""));
assert_eq!(grown.matches("\"matcher\"").count(), 4);
let (cleared, _) = uninstall(Some(&grown)).unwrap();
assert!(!cleared.contains("steer hook"));
}
#[test]
fn hook_fails_open() {
assert!(hook::process("not json", Mode::Deny).is_none());
assert!(hook::process(r#"{"tool_name":"Read"}"#, Mode::Deny).is_none());
assert!(hook::process(r#"{"tool_name":"Bash","tool_input":{}}"#, Mode::Deny).is_none());
assert!(
hook::process(
r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#,
Mode::Deny
)
.is_none()
);
}
#[test]
fn log_record_captures_steered_and_allowed_calls() {
let steered = hook::log_record(
r#"{"tool_name":"Bash","tool_input":{"command":"grep -r TODO src"},"cwd":"/work","session_id":"s1"}"#,
Mode::Deny,
);
assert_eq!(steered["tool"], "Bash");
assert_eq!(steered["command"], "grep -r TODO src");
assert_eq!(steered["decision"], "deny");
assert_eq!(steered["ct_tool"], "ct search");
assert!(steered["rule_id"].is_string());
assert_eq!(steered["cwd"], "/work");
assert_eq!(steered["session_id"], "s1");
let allowed = hook::log_record(
r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#,
Mode::Deny,
);
assert_eq!(allowed["decision"], "allow");
assert!(allowed["rule_id"].is_null());
assert!(allowed["ct_tool"].is_null());
assert_eq!(allowed["command"], "git status");
let other = hook::log_record(
r#"{"tool_name":"Edit","tool_input":{"file_path":"a.rs"}}"#,
Mode::Warn,
);
assert_eq!(other["tool"], "Edit");
assert_eq!(other["decision"], "allow");
let warned = hook::log_record(
r#"{"tool_name":"Grep","tool_input":{"pattern":"TODO"}}"#,
Mode::Warn,
);
assert_eq!(warned["decision"], "warn");
}
#[test]
fn log_record_is_lenient_on_malformed_envelopes() {
let bad = hook::log_record("not json", Mode::Deny);
assert_eq!(bad["tool"], "");
assert_eq!(bad["decision"], "allow");
assert!(bad["command"].is_null());
}
#[test]
fn hook_command_bakes_logging_flags() {
assert_eq!(
install::hook_command(Mode::Deny, None, false),
"ct steer hook"
);
assert_eq!(
install::hook_command(Mode::Warn, Some("/x"), true),
"ct steer hook --mode warn --no-log"
);
assert_eq!(
install::hook_command(Mode::Deny, Some("/var/log/tc"), false),
"ct steer hook --log-dir /var/log/tc"
);
assert_eq!(
install::hook_command(Mode::Deny, Some("/my logs/tc"), false),
"ct steer hook --log-dir \"/my logs/tc\""
);
}
#[test]
fn date_stem_is_utc_civil_date() {
assert_eq!(date_stem(0), "1970-01-01");
assert_eq!(date_stem(86_399), "1970-01-01"); assert_eq!(date_stem(86_400), "1970-01-02"); assert_eq!(date_stem(1_600_000_000), "2020-09-13");
assert_eq!(date_stem(1_582_934_400), "2020-02-29");
}
#[test]
fn gitignore_rule_is_added_once() {
assert_eq!(gitignore_with_log_rule(None).as_deref(), Some("*log\n"));
assert!(gitignore_with_log_rule(Some("*log\n")).is_none());
assert_eq!(
gitignore_with_log_rule(Some("target")).as_deref(),
Some("target\n*log\n")
);
}
#[test]
fn install_all_tools_writes_a_wildcard_matcher() {
let (text, changed) = install(None, "ct steer hook", &[install::Tool::All]).unwrap();
assert!(changed);
assert!(text.contains("\"matcher\": \"*\""), "{text}");
let (cleared, _) = uninstall(Some(&text)).unwrap();
assert!(!cleared.contains("steer hook"));
}
#[test]
fn install_is_idempotent_and_preserves_other_settings() {
let (text, changed) = install_bash(None, "ct steer hook").unwrap();
assert!(changed);
assert!(text.contains("PreToolUse"));
assert!(text.contains("\"matcher\": \"Bash\""));
assert!(text.contains("ct steer hook"));
let (text2, changed2) = install_bash(Some(&text), "ct steer hook").unwrap();
assert!(!changed2);
assert_eq!(text, text2);
let (text3, changed3) = install_bash(Some(&text), "ct steer hook --mode ask").unwrap();
assert!(changed3);
assert_eq!(text3.matches("steer hook").count(), 1);
let existing = r#"{ "model": "opus", "hooks": { "PreToolUse": [] } }"#;
let (merged, _) = install_bash(Some(existing), "ct steer hook").unwrap();
assert!(merged.contains("\"model\": \"opus\""));
}
#[test]
fn uninstall_removes_only_our_hook() {
let existing = r#"{
"hooks": { "PreToolUse": [
{ "matcher": "Bash", "hooks": [
{ "type": "command", "command": "ct steer hook" },
{ "type": "command", "command": "./other.sh" }
] }
] }
}"#;
let (text, changed) = uninstall(Some(existing)).unwrap();
assert!(changed);
assert!(!text.contains("steer hook"));
assert!(text.contains("./other.sh")); let (_, changed2) = uninstall(Some("{}")).unwrap();
assert!(!changed2);
}
#[test]
fn install_and_uninstall_preserve_comments() {
let existing = "{\n \
// pin the model\n \
\"model\": \"opus\", // do not change\n \
\"hooks\": {\n \
\"PreToolUse\": [\n \
{ \"matcher\": \"Bash\", \"hooks\": [ { \"type\": \"command\", \"command\": \"./guard.sh\" } ] }\n \
]\n }\n}\n";
let (installed, changed) = install_bash(Some(existing), "ct steer hook").unwrap();
assert!(changed);
assert!(installed.contains("// pin the model"), "{installed}");
assert!(installed.contains("// do not change"), "{installed}");
assert!(installed.contains("./guard.sh"), "{installed}");
assert!(installed.contains("ct steer hook"), "{installed}");
let (removed, changed2) = uninstall(Some(&installed)).unwrap();
assert!(changed2);
assert!(removed.contains("// pin the model"), "{removed}");
assert!(removed.contains("./guard.sh"), "{removed}");
assert!(!removed.contains("steer hook"), "{removed}");
}
#[test]
fn scope_paths() {
let root = Path::new("/proj");
let home = Path::new("/home/u");
assert!(
Scope::Project
.path(root, home)
.ends_with(".claude/settings.json")
);
assert!(
Scope::Local
.path(root, home)
.ends_with(".claude/settings.local.json")
);
assert!(Scope::User.path(root, home).starts_with("/home/u"));
}
}