Skip to main content

coding_tools/
steer.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Jonathan Shook
3
4//! Redirection steering: recognise the ad-hoc shell idioms a `ct` tool serves
5//! better, and (as a Claude Code `PreToolUse` hook) steer the agent to the
6//! `ct` equivalent instead.
7//!
8//! Agents reach for raw shell — `find | xargs grep`, `sed -i`, `cat | head`,
9//! `for`/`while` loops, sleep-polling waits, `wc -l` counts, and `python -c`/
10//! `jq` file reads — even when a suite tool would do the job bounded,
11//! deterministic, and self-verifying. [`analyze`] is the pure heart: it
12//! classifies a shell
13//! command string into an optional [`Steer`] naming the `ct` tool that serves
14//! it and a best-effort equivalent command. The [`hook`] submodule wraps that
15//! in the Claude Code `PreToolUse` JSON protocol (deny / ask / warn); the
16//! [`install`] submodule wires the hook into a project's `.claude/settings.json`.
17//!
18//! The matcher is deliberately **conservative**: it only fires on a fixed set
19//! of high-confidence 1:1 idioms, never re-steers a command that already
20//! invokes `ct`, and returns [`None`] (allow) whenever it is unsure. The hook
21//! is **fail-open** — any malformed input or unrecognised command is allowed —
22//! because it runs ahead of *every* shell call.
23
24/// What the hook does when a command matches a steering rule.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum Mode {
27    /// Block the call and feed the `ct` suggestion back to the agent (default).
28    #[default]
29    Deny,
30    /// Surface a confirmation prompt naming the `ct` suggestion.
31    Ask,
32    /// Allow the call, but inject the `ct` suggestion as context.
33    Warn,
34}
35
36impl Mode {
37    /// Parse the `--mode` value.
38    ///
39    /// ```
40    /// use coding_tools::steer::Mode;
41    /// assert_eq!(Mode::from_name("ask"), Some(Mode::Ask));
42    /// assert_eq!(Mode::from_name("nope"), None);
43    /// ```
44    pub fn from_name(s: &str) -> Option<Mode> {
45        match s {
46            "deny" => Some(Mode::Deny),
47            "ask" => Some(Mode::Ask),
48            "warn" => Some(Mode::Warn),
49            _ => None,
50        }
51    }
52
53    /// The canonical name, as accepted by `--mode` and written into settings.
54    pub fn name(self) -> &'static str {
55        match self {
56            Mode::Deny => "deny",
57            Mode::Ask => "ask",
58            Mode::Warn => "warn",
59        }
60    }
61}
62
63/// A steering match: a `ct` tool serves the inspected command.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct Steer {
66    /// Stable identifier for the rule that fired (e.g. `"find-grep"`).
67    pub rule_id: &'static str,
68    /// The `ct` tool that serves the idiom (e.g. `"ct search"`).
69    pub tool: &'static str,
70    /// A best-effort equivalent `ct` command line.
71    pub suggestion: String,
72    /// One line teaching why the `ct` tool is the better fit.
73    pub note: &'static str,
74}
75
76impl Steer {
77    /// The reason text shown to the agent (the `ct` command plus the lesson).
78    pub fn reason(&self) -> String {
79        format!(
80            "A `ct` tool serves this more reliably — bounded, deterministic, \
81             and self-verifying. Use instead:\n  {}\n({})",
82            self.suggestion, self.note
83        )
84    }
85}
86
87// ----- Lexing ------------------------------------------------------------------
88
89/// A shell token: either a word (quoted regions collapse into the surrounding
90/// word, so operators inside quotes are inert) or one of the control operators
91/// we split on. Redirections and grouping are dropped to word boundaries.
92#[derive(Debug, Clone, PartialEq, Eq)]
93enum Tok {
94    Word(String),
95    Pipe,
96    And,
97    Or,
98    Semi,
99}
100
101/// Tokenise a command string. Single/double quotes and backslash escapes keep
102/// their contents inside the current word, so `echo "a | b"` is one word and
103/// the `|` does not register as a pipe.
104fn lex(cmd: &str) -> Vec<Tok> {
105    let mut toks = Vec::new();
106    let mut cur = String::new();
107    let mut have = false; // cur holds a word (possibly empty, from `""`)
108    let mut chars = cmd.chars().peekable();
109
110    fn flush(toks: &mut Vec<Tok>, cur: &mut String, have: &mut bool) {
111        if *have {
112            toks.push(Tok::Word(std::mem::take(cur)));
113            *have = false;
114        }
115    }
116
117    while let Some(c) = chars.next() {
118        match c {
119            '\'' => {
120                have = true;
121                for d in chars.by_ref() {
122                    if d == '\'' {
123                        break;
124                    }
125                    cur.push(d);
126                }
127            }
128            '"' => {
129                have = true;
130                while let Some(d) = chars.next() {
131                    if d == '"' {
132                        break;
133                    }
134                    if d == '\\' {
135                        if let Some(e) = chars.next() {
136                            cur.push(e);
137                        }
138                    } else {
139                        cur.push(d);
140                    }
141                }
142            }
143            '\\' => {
144                if let Some(d) = chars.next() {
145                    cur.push(d);
146                    have = true;
147                }
148            }
149            '|' => {
150                flush(&mut toks, &mut cur, &mut have);
151                if chars.peek() == Some(&'|') {
152                    chars.next();
153                    toks.push(Tok::Or);
154                } else {
155                    toks.push(Tok::Pipe);
156                }
157            }
158            '&' => {
159                flush(&mut toks, &mut cur, &mut have);
160                if chars.peek() == Some(&'&') {
161                    chars.next();
162                    toks.push(Tok::And);
163                } else {
164                    toks.push(Tok::Semi); // a lone `&` (background) ends a command
165                }
166            }
167            ';' => {
168                flush(&mut toks, &mut cur, &mut have);
169                toks.push(Tok::Semi);
170            }
171            // Redirections and grouping: end the current word, drop the symbol.
172            '>' | '<' | '(' | ')' | '{' | '}' | '`' => {
173                flush(&mut toks, &mut cur, &mut have);
174            }
175            c if c.is_whitespace() => flush(&mut toks, &mut cur, &mut have),
176            _ => {
177                cur.push(c);
178                have = true;
179            }
180        }
181    }
182    flush(&mut toks, &mut cur, &mut have);
183    toks
184}
185
186/// Split a token stream into control segments (on `&&` / `||` / `;`) plus the
187/// list of joiner operators between them (length = segments − 1).
188fn control_segments(toks: &[Tok]) -> (Vec<Vec<Tok>>, Vec<Tok>) {
189    let mut segs = vec![Vec::new()];
190    let mut joiners = Vec::new();
191    for t in toks {
192        match t {
193            Tok::And | Tok::Or | Tok::Semi => {
194                joiners.push(t.clone());
195                segs.push(Vec::new());
196            }
197            other => segs.last_mut().unwrap().push(other.clone()),
198        }
199    }
200    // Drop a trailing empty segment (e.g. a command ending in `;`).
201    if segs.last().is_some_and(Vec::is_empty) {
202        segs.pop();
203        joiners.pop();
204    }
205    (segs, joiners)
206}
207
208/// Split one control segment into pipeline stages (on `|`); each stage is its
209/// word list.
210fn pipe_stages(seg: &[Tok]) -> Vec<Vec<String>> {
211    let mut stages = vec![Vec::new()];
212    for t in seg {
213        match t {
214            Tok::Pipe => stages.push(Vec::new()),
215            Tok::Word(w) => stages.last_mut().unwrap().push(w.clone()),
216            _ => {}
217        }
218    }
219    stages
220}
221
222// ----- Word/flag helpers -------------------------------------------------------
223
224/// The basename of a command word (`/usr/bin/grep` → `grep`).
225fn base_name(w: &str) -> &str {
226    w.rsplit(['/', '\\']).next().unwrap_or(w)
227}
228
229/// The command name of a stage (its first word, basename-stripped).
230fn cmd_of(stage: &[String]) -> Option<&str> {
231    stage.first().map(|w| base_name(w))
232}
233
234/// Whether a stage carries a single-dash short flag containing `ch` (so `-r`,
235/// `-rn`, `-Rl` all count for `'r'`), excluding `--long` words.
236fn has_short(stage: &[String], ch: char) -> bool {
237    stage
238        .iter()
239        .any(|w| w.starts_with('-') && !w.starts_with("--") && w[1..].chars().any(|c| c == ch))
240}
241
242/// Whether a stage carries `flag` exactly, or `flag=…`.
243fn has_flag(stage: &[String], flag: &str) -> bool {
244    stage
245        .iter()
246        .any(|w| w == flag || w.starts_with(&format!("{flag}=")))
247}
248
249/// The value of `-flag VALUE`, `--flag VALUE`, or `--flag=VALUE` in a stage.
250fn flag_value<'a>(stage: &'a [String], names: &[&str]) -> Option<&'a str> {
251    for (i, w) in stage.iter().enumerate() {
252        for n in names {
253            if w == n {
254                return stage.get(i + 1).map(String::as_str);
255            }
256            let eq = format!("{n}=");
257            if let Some(v) = w.strip_prefix(&eq) {
258                return Some(v);
259            }
260        }
261    }
262    None
263}
264
265/// The positional (non-flag) words of a stage after its command. Imperfect —
266/// a value-taking flag's value (e.g. the `40` in `head -n 40`) leaks through —
267/// so callers that care filter further.
268fn positionals(stage: &[String]) -> Vec<&str> {
269    stage
270        .iter()
271        .skip(1)
272        .filter(|w| !w.starts_with('-'))
273        .map(String::as_str)
274        .collect()
275}
276
277/// A `find` start path: the first argument, when it is not a `-option`
278/// (`find <path> -name …`; a bare `find -name …` defaults to the cwd).
279fn find_base(find: &[String]) -> Option<&str> {
280    find.get(1)
281        .filter(|w| !w.starts_with('-'))
282        .map(String::as_str)
283}
284
285/// Single-quote a value for display inside a suggested command.
286fn q(s: &str) -> String {
287    format!("'{}'", s.replace('\'', "'\\''"))
288}
289
290// ----- Rules -------------------------------------------------------------------
291
292/// Classify a shell command. [`None`] means "allow" — no `ct` tool clearly
293/// serves it. The matcher only fires on high-confidence idioms and never
294/// re-steers a command that already invokes `ct`.
295///
296/// ```
297/// use coding_tools::steer::analyze;
298/// let s = analyze("find . -name '*.rs' | xargs grep TODO").unwrap();
299/// assert_eq!(s.tool, "ct search");
300/// assert!(analyze("cargo build && cargo test").is_none());
301/// assert!(analyze("ct search --grep TODO").is_none());
302/// ```
303pub fn analyze(command: &str) -> Option<Steer> {
304    let toks = lex(command);
305    if toks.is_empty() {
306        return None;
307    }
308    let (segs, joiners) = control_segments(&toks);
309    let seg_stages: Vec<Vec<Vec<String>>> = segs.iter().map(|s| pipe_stages(s)).collect();
310
311    // Never re-steer a command that already involves `ct` / `ct-*` anywhere
312    // (as a command, or behind `xargs`/`env`/…). Erring toward allow here is
313    // safe — at worst we decline to steer a grep that merely mentions `ct-…`.
314    let touches_ct = seg_stages.iter().flatten().flatten().any(|w| {
315        let b = base_name(w);
316        b == "ct" || b.starts_with("ct-")
317    });
318    if touches_ct {
319        return None;
320    }
321
322    // Shell loops (`for`/`while`/`until`) — a control word starting the first
323    // segment. A loop whose body `sleep`s and re-probes is a bounded *wait*
324    // (steer to `ct await`); any other loop is a per-item map (`ct each`).
325    if let Some(first) = seg_stages
326        .first()
327        .and_then(|s| s.first())
328        .and_then(|s| cmd_of(s))
329        && matches!(first, "for" | "while" | "until")
330    {
331        let waits = seg_stages
332            .iter()
333            .flatten()
334            .flatten()
335            .any(|w| matches!(base_name(w), "sleep" | "usleep" | "Start-Sleep"));
336        return Some(if waits {
337            Steer {
338                rule_id: "wait-loop",
339                tool: "ct await",
340                suggestion: "ct await --timeout <SECS> --every <N> -- <probe-argv>".to_string(),
341                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`",
342            }
343        } else {
344            Steer {
345                rule_id: "shell-loop",
346                tool: "ct each",
347                suggestion: "ct each --items <a> <b> -- <cmd-template-with-{ITEM}>".to_string(),
348                note: "ct each runs a command template once per item with no shell and an aggregate --expect verdict",
349            }
350        });
351    }
352
353    // A single command (possibly with pipes): the common, high-value case.
354    if segs.len() == 1 {
355        return analyze_segment(&seg_stages[0]);
356    }
357
358    // A chain (`&&` / `||`): only steer when *every* segment is itself
359    // ct-serviceable and the joiners are uniform, so `ct and`/`ct or`
360    // reproduces it faithfully. A mixed chain (e.g. `grep -r x && make`) is
361    // left alone.
362    let matches: Vec<Steer> = seg_stages
363        .iter()
364        .filter_map(|st| analyze_segment(st))
365        .collect();
366    if matches.len() == segs.len() && !joiners.is_empty() {
367        if joiners.iter().all(|j| *j == Tok::And) {
368            return Some(chain_steer("ct and", &matches));
369        }
370        if joiners.iter().all(|j| *j == Tok::Or) {
371            return Some(chain_steer("ct or", &matches));
372        }
373    }
374    None
375}
376
377/// Build the chain suggestion from each segment's own `ct` suggestion, joined
378/// with the suite's shell-less `:::` separator.
379fn chain_steer(head: &'static str, parts: &[Steer]) -> Steer {
380    let body = parts
381        .iter()
382        .map(|p| p.suggestion.trim_start_matches("ct ").to_string())
383        .collect::<Vec<_>>()
384        .join(" ::: ");
385    let (rule_id, note) = if head == "ct and" {
386        (
387            "and-chain",
388            "ct and runs each step in turn, stopping at the first failure — a shell-less && with no quoting",
389        )
390    } else {
391        (
392            "or-chain",
393            "ct or runs each step in turn, stopping at the first success — a shell-less || with no quoting",
394        )
395    };
396    Steer {
397        rule_id,
398        tool: head,
399        suggestion: format!("{head} {body}"),
400        note,
401    }
402}
403
404/// Classify a single control segment (its pipeline stages). Rule order encodes
405/// priority: the most specific idiom wins.
406fn analyze_segment(stages: &[Vec<String>]) -> Option<Steer> {
407    rule_find_grep(stages)
408        .or_else(|| rule_grep_recursive(stages))
409        .or_else(|| rule_grep_count(stages))
410        .or_else(|| rule_sed_inplace(stages))
411        .or_else(|| rule_read_range(stages))
412        .or_else(|| rule_interpreter_read(stages))
413        .or_else(|| rule_find_files(stages))
414        .or_else(|| rule_list_recursive(stages))
415        .or_else(|| rule_count_lines(stages))
416}
417
418/// `find … | xargs grep` / `find … -exec grep` → `ct search`.
419fn rule_find_grep(stages: &[Vec<String>]) -> Option<Steer> {
420    let find = stages.iter().find(|s| cmd_of(s) == Some("find"))?;
421    // grep appearing anywhere (its own stage, after xargs, or after -exec).
422    let grep_stage = stages
423        .iter()
424        .find(|s| s.iter().any(|w| base_name(w) == "grep"))?;
425    let glob = flag_value(find, &["-name", "-iname"]);
426    let pat = grep_pattern(grep_stage);
427    Some(Steer {
428        rule_id: "find-grep",
429        tool: "ct search",
430        suggestion: search_suggestion(find_base(find), glob, pat),
431        note: "ct search recurses, filters by name/type/size, and greps in one declarative pass — find | xargs grep in a single command",
432    })
433}
434
435/// `grep -r` / `rg` / `ag` → `ct search`.
436fn rule_grep_recursive(stages: &[Vec<String>]) -> Option<Steer> {
437    for s in stages {
438        let Some(cmd) = cmd_of(s) else { continue };
439        let recursive_grep =
440            cmd == "grep" && (has_short(s, 'r') || has_short(s, 'R') || has_flag(s, "--recursive"));
441        if recursive_grep || matches!(cmd, "rg" | "ripgrep" | "ag") {
442            let pat = grep_pattern(s);
443            // `grep -r PAT PATH` / `rg PAT PATH`: the second positional is the path.
444            let base = positionals(s).get(1).copied();
445            return Some(Steer {
446                rule_id: "grep-recursive",
447                tool: "ct search",
448                suggestion: search_suggestion(base, None, pat),
449                note: "ct search is the suite's recursive content search, with a framed --expect verdict (e.g. --expect none asserts absence)",
450            });
451        }
452    }
453    None
454}
455
456/// `grep -c PATTERN FILE` (count matching lines) → `ct search … --summary`.
457fn rule_grep_count(stages: &[Vec<String>]) -> Option<Steer> {
458    for s in stages {
459        let Some(cmd) = cmd_of(s) else { continue };
460        if matches!(cmd, "grep" | "egrep" | "fgrep") && has_short(s, 'c') {
461            // `grep -c PATTERN FILE`: the second positional is the path.
462            let base = positionals(s).get(1).copied();
463            return Some(Steer {
464                rule_id: "grep-count",
465                tool: "ct search",
466                suggestion: format!(
467                    "{} --summary",
468                    search_suggestion(base, None, grep_pattern(s))
469                ),
470                note: "ct search --summary reports the match count directly (and --expect +N|=N turns it into a pass/fail assertion), replacing grep -c",
471            });
472        }
473    }
474    None
475}
476
477/// An interpreter one-liner that READS a file — `jq EXPR FILE`,
478/// `python -c '…open("x")…'`, `node -e`, `perl -e`, `ruby -e` — with no write
479/// signal → `ct view` / `ct search`. Pure-compute one-liners (no file read) and
480/// anything that looks like it writes are left alone.
481fn rule_interpreter_read(stages: &[Vec<String>]) -> Option<Steer> {
482    for s in stages {
483        let Some(cmd) = cmd_of(s) else { continue };
484        // `jq EXPR FILE…`: a file argument means it reads a file, not a stream.
485        if cmd == "jq" {
486            if let Some(&file) = positionals(s).get(1) {
487                return Some(interpreter_steer(Some(file)));
488            }
489            continue;
490        }
491        // `python/node/perl/ruby -c|-e '<body>'`: inspect the inline script.
492        let interp = matches!(
493            cmd,
494            "python" | "python3" | "node" | "nodejs" | "perl" | "ruby"
495        );
496        if interp
497            && let Some(body) = flag_value(s, &["-c", "-e"])
498            && reads_file(body)
499            && !writes_file(body)
500        {
501            return Some(interpreter_steer(quoted_path(body)));
502        }
503    }
504    None
505}
506
507/// `find … -name` with no grep → `ct search` (name filter only).
508fn rule_find_files(stages: &[Vec<String>]) -> Option<Steer> {
509    let find = stages.iter().find(|s| cmd_of(s) == Some("find"))?;
510    let glob = flag_value(find, &["-name", "-iname"])?;
511    let base = find_base(find);
512    Some(Steer {
513        rule_id: "find-files",
514        tool: "ct search",
515        suggestion: search_suggestion(base, Some(glob), None),
516        note: "ct search selects files by --name/--type/--size and reports them, replacing a bare find",
517    })
518}
519
520/// `sed -i` / `perl -i` → `ct edit`.
521fn rule_sed_inplace(stages: &[Vec<String>]) -> Option<Steer> {
522    let stage = stages.iter().find(|s| {
523        let cmd = cmd_of(s);
524        let sed_i =
525            cmd == Some("sed") && s.iter().any(|w| w.starts_with("-i") || w == "--in-place");
526        let perl_i = cmd == Some("perl") && s.iter().any(|w| w.starts_with("-i"));
527        sed_i || perl_i
528    })?;
529    let (find, replace) = sed_subst(stage);
530    let suggestion = match (find, replace) {
531        (Some(f), Some(r)) => format!(
532            "ct edit --base . --find {} --replace {} --expect =1 --dry-run",
533            q(f),
534            q(r)
535        ),
536        _ => "ct edit --base . --find <text> --replace <text> --expect =1 --dry-run".to_string(),
537    };
538    Some(Steer {
539        rule_id: "sed-inplace",
540        tool: "ct edit",
541        suggestion,
542        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",
543    })
544}
545
546/// `head`/`tail`/`sed -n 'A,Bp'` on a file → `ct view --range`.
547fn rule_read_range(stages: &[Vec<String>]) -> Option<Steer> {
548    // sed -n 'A,Bp'
549    for s in stages {
550        if cmd_of(s) == Some("sed")
551            && has_flag(s, "-n")
552            && let Some((a, b)) = positionals(s).into_iter().find_map(parse_sed_range)
553        {
554            let file = positionals(s).into_iter().find(|&w| !is_sed_script(w));
555            return Some(view_steer(file, Some((a, b))));
556        }
557    }
558    // head / tail, reading a named file or fed by `cat FILE`.
559    for (i, s) in stages.iter().enumerate() {
560        let cmd = cmd_of(s);
561        if cmd != Some("head") && cmd != Some("tail") {
562            continue;
563        }
564        let n = head_count(s);
565        // The file is head/tail's own positional (not the numeric `-n` value),
566        // or an upstream `cat FILE`.
567        let own = positionals(s)
568            .into_iter()
569            .find(|w| w.parse::<u64>().is_err());
570        let upstream = (i > 0 && cmd_of(&stages[i - 1]) == Some("cat"))
571            .then(|| positionals(&stages[i - 1]).into_iter().next())
572            .flatten();
573        let file = own.or(upstream)?; // no concrete file → not a file read; skip
574        let range = match (cmd, n) {
575            (Some("head"), Some(n)) => Some((1, n)),
576            _ => None, // tail = last-N lines; leave the range to the agent
577        };
578        return Some(view_steer(Some(file), range));
579    }
580    None
581}
582
583/// `ls -R` / `tree` → `ct tree`.
584fn rule_list_recursive(stages: &[Vec<String>]) -> Option<Steer> {
585    let stage = stages
586        .iter()
587        .find(|s| cmd_of(s) == Some("tree") || (cmd_of(s) == Some("ls") && has_short(s, 'R')))?;
588    let base = positionals(stage).first().copied();
589    let suggestion = match base {
590        Some(b) => format!("ct tree --base {b}"),
591        None => "ct tree".to_string(),
592    };
593    Some(Steer {
594        rule_id: "list-recursive",
595        tool: "ct tree",
596        suggestion,
597        note: "ct tree reports the file tree with per-file line/word/char counts, filtering and sorting — a richer, bounded ls -R / tree",
598    })
599}
600
601/// `wc` over files (not a bare piped stream) → `ct tree`. Counts files named
602/// directly, fed by `find`/`ls`, or read from a `cat FILE…` upstream; a stream
603/// with no file behind it (e.g. `ps aux | wc -l`) is left alone.
604fn rule_count_lines(stages: &[Vec<String>]) -> Option<Steer> {
605    for (i, s) in stages.iter().enumerate() {
606        if cmd_of(s) != Some("wc") {
607            continue;
608        }
609        let has_files = !positionals(s).is_empty();
610        let upstream = i.checked_sub(1).map(|j| &stages[j]);
611        let from_find = upstream.is_some_and(|u| matches!(cmd_of(u), Some("find") | Some("ls")));
612        let from_cat =
613            upstream.is_some_and(|u| cmd_of(u) == Some("cat") && !positionals(u).is_empty());
614        if has_files || from_find || from_cat {
615            return Some(Steer {
616                rule_id: "count-lines",
617                tool: "ct tree",
618                suggestion: "ct tree --summary".to_string(),
619                note: "ct tree reports per-file and total line/word/char counts directly, replacing wc -l over a file set",
620            });
621        }
622    }
623    None
624}
625
626// ----- Extraction helpers ------------------------------------------------------
627
628/// Assemble a `ct search` suggestion from optional base/name/grep parts.
629fn search_suggestion(base: Option<&str>, name: Option<&str>, grep: Option<&str>) -> String {
630    let mut out = String::from("ct search");
631    if let Some(b) = base {
632        out.push_str(&format!(" --base {b}"));
633    }
634    if let Some(n) = name {
635        out.push_str(&format!(" --name {}", q(n)));
636    }
637    match grep {
638        Some(g) => out.push_str(&format!(" --grep {}", q(g))),
639        None => out.push_str(" --grep <pattern>"),
640    }
641    out
642}
643
644/// Build a `ct view` suggestion for a file and optional line range.
645fn view_steer(file: Option<&str>, range: Option<(u32, u32)>) -> Steer {
646    let f = file.unwrap_or("<file>");
647    let suggestion = match range {
648        Some((a, b)) => format!("ct view {f} --range {a}:{b}"),
649        None => format!("ct view {f} --range <start>:<end>"),
650    };
651    Steer {
652        rule_id: "read-range",
653        tool: "ct view",
654        suggestion,
655        note: "ct view shows a file's lines by range (or the regions around a pattern with context) — a precise, bounded read",
656    }
657}
658
659/// Build a `ct view` suggestion for an interpreter one-liner that reads `file`.
660fn interpreter_steer(file: Option<&str>) -> Steer {
661    let f = file.unwrap_or("<file>");
662    Steer {
663        rule_id: "interpreter-read",
664        tool: "ct view",
665        suggestion: format!("ct view {f} --range <start>:<end>"),
666        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",
667    }
668}
669
670/// Whether an inline interpreter script reads a file.
671fn reads_file(body: &str) -> bool {
672    const READS: &[&str] = &[
673        "open(",
674        "json.load",
675        "readlines",
676        "read_text",
677        "readFileSync",
678        "JSON.parse",
679        "File.read",
680        "IO.read",
681        "Get-Content",
682    ];
683    READS.iter().any(|m| body.contains(m))
684}
685
686/// Whether an inline interpreter script appears to write/produce a file — used
687/// to leave read+write one-liners alone (only pure reads are steered).
688fn writes_file(body: &str) -> bool {
689    const WRITES: &[&str] = &[
690        ",'w'",
691        ", 'w'",
692        ",\"w\"",
693        ", \"w\"",
694        ",'a'",
695        ", 'a'",
696        ",\"a\"",
697        "'r+'",
698        "\"r+\"",
699        "'wb'",
700        "\"wb\"",
701        ".write(",
702        "writeFile",
703        "json.dump",
704        "to_csv",
705        "to_json(",
706        "File.write",
707    ];
708    WRITES.iter().any(|m| body.contains(m))
709}
710
711/// The first quoted token in an interpreter body that looks like a file path
712/// (contains `.` or `/`), for use in the suggested `ct view` command.
713fn quoted_path(body: &str) -> Option<&str> {
714    let bytes = body.as_bytes();
715    let mut i = 0;
716    while i < bytes.len() {
717        let c = bytes[i];
718        if (c == b'\'' || c == b'"')
719            && let Some(rel) = body[i + 1..].find(c as char)
720        {
721            let inner = &body[i + 1..i + 1 + rel];
722            if inner.contains('.') || inner.contains('/') {
723                return Some(inner);
724            }
725            i += 1 + rel + 1;
726            continue;
727        }
728        i += 1;
729    }
730    None
731}
732
733/// The PATTERN of a grep-family stage: an explicit `-e VALUE`, else the first
734/// bare word *after the grep token* (which may follow `xargs`, `-exec`, …, so
735/// keying off the stage's own command word would pick up the wrong thing).
736fn grep_pattern(stage: &[String]) -> Option<&str> {
737    if let Some(v) = flag_value(stage, &["-e", "--regexp"]) {
738        return Some(v);
739    }
740    let start = stage
741        .iter()
742        .position(|w| {
743            matches!(
744                base_name(w),
745                "grep" | "egrep" | "fgrep" | "rg" | "ripgrep" | "ag"
746            )
747        })
748        .map_or(1, |i| i + 1);
749    stage[start..]
750        .iter()
751        .find(|w| !w.starts_with('-'))
752        .map(String::as_str)
753}
754
755/// Parse `s/FIND/REPLACE/flags` (any single-char delimiter) → (find, replace).
756fn sed_subst(stage: &[String]) -> (Option<&str>, Option<&str>) {
757    for w in stage.iter().skip(1) {
758        if let Some(rest) = w.strip_prefix('s')
759            && let Some(delim) = rest.chars().next()
760            && !delim.is_alphanumeric()
761        {
762            let parts: Vec<&str> = rest[delim.len_utf8()..].split(delim).collect();
763            if parts.len() >= 2 {
764                return (Some(parts[0]), Some(parts[1]));
765            }
766        }
767    }
768    (None, None)
769}
770
771/// The N from a `head`/`tail` count flag: `-n N`, `-nN`, or `-N`.
772fn head_count(stage: &[String]) -> Option<u32> {
773    if let Some(v) = flag_value(stage, &["-n", "--lines"])
774        && let Ok(n) = v.parse::<u32>()
775    {
776        return Some(n);
777    }
778    stage
779        .iter()
780        .skip(1)
781        .find_map(|w| w.strip_prefix('-').and_then(|d| d.parse::<u32>().ok()))
782}
783
784/// Whether a word is a `sed` script (`A,Bp`, `Np`, or an `s<delim>…` subst)
785/// rather than a file. Deliberately narrow so filenames like `src/lib.rs`
786/// (which begin with `s`) are not misread as scripts.
787fn is_sed_script(w: &str) -> bool {
788    if parse_sed_range(w).is_some() {
789        return true;
790    }
791    let mut ch = w.chars();
792    ch.next() == Some('s') && ch.next().is_some_and(|d| !d.is_alphanumeric())
793}
794
795/// Parse a `sed -n` line range like `10,20p` or `10p` → `(start, end)`.
796fn parse_sed_range(w: &str) -> Option<(u32, u32)> {
797    let body = w.strip_suffix('p').unwrap_or(w);
798    match body.split_once(',') {
799        Some((a, b)) => Some((a.parse().ok()?, b.parse().ok()?)),
800        None => {
801            let n = body.parse().ok()?;
802            Some((n, n))
803        }
804    }
805}
806
807// ----- Harness tool-envelope steers --------------------------------------------
808//
809// The hook can gate not just `Bash` but the harness's own `Grep` / `Glob` /
810// `Read` tools — the *other* channel by which an agent reaches around `ct`.
811// Those calls carry structured fields (a `pattern`, a `path`, a `file_path`)
812// rather than a shell line, so each gets its own builder rather than going
813// through [`analyze`].
814
815/// Steer a harness `Grep` call to `ct search` (the suite's content search).
816pub fn grep_steer(pattern: &str, path: Option<&str>, glob: Option<&str>) -> Steer {
817    Steer {
818        rule_id: "harness-grep",
819        tool: "ct search",
820        suggestion: search_suggestion(path, glob, Some(pattern)),
821        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",
822    }
823}
824
825/// Steer a harness `Glob` call to `ct search` (name filter from a root).
826pub fn glob_steer(pattern: &str, path: Option<&str>) -> Steer {
827    let (glob_base, name) = split_glob(pattern);
828    let base = path.map(str::to_string).or(glob_base);
829    let mut out = String::from("ct search");
830    if let Some(b) = base {
831        out.push_str(&format!(" --base {b}"));
832    }
833    out.push_str(&format!(" --name {} --type f", q(&name)));
834    Steer {
835        rule_id: "harness-glob",
836        tool: "ct search",
837        suggestion: out,
838        note: "ct search selects files by --name/--type/--size from a chosen root and reports them — the suite's glob, recursive by default",
839    }
840}
841
842/// Split a glob into a literal directory prefix (its leading wildcard-free
843/// segments) and the file-name segment (its last component): `src/**/*.rs` →
844/// `(Some("src"), "*.rs")`; `**/*.rs` → `(None, "*.rs")`.
845fn split_glob(pattern: &str) -> (Option<String>, String) {
846    let segs: Vec<&str> = pattern.split('/').collect();
847    let name = segs.last().copied().unwrap_or(pattern).to_string();
848    let is_wild = |s: &str| s.contains(['*', '?', '[', '{']);
849    let literal: Vec<&str> = segs
850        .iter()
851        .take(segs.len().saturating_sub(1))
852        .take_while(|s| !is_wild(s) && !s.is_empty())
853        .copied()
854        .collect();
855    ((!literal.is_empty()).then(|| literal.join("/")), name)
856}
857
858/// Steer a harness `Read` call to `ct view` — unless the path is something
859/// `ct view` (a line reader) cannot render (an image, PDF, or notebook), where
860/// `Read` is the right tool and the call is left alone ([`None`]).
861pub fn read_steer(file_path: &str, offset: Option<i64>, limit: Option<i64>) -> Option<Steer> {
862    if is_unrenderable(file_path) {
863        return None;
864    }
865    // Read's offset is a 1-based start line and limit a line count; map both to
866    // `ct view --range`. A bare read (neither) views the whole file.
867    let range = match (offset, limit) {
868        (Some(o), Some(l)) => {
869            let start = o.max(1);
870            Some(format!("{start}:{}", (start + l - 1).max(start)))
871        }
872        (Some(o), None) => Some(format!("{}:", o.max(1))),
873        (None, Some(l)) => Some(format!("1:{}", l.max(1))),
874        (None, None) => None,
875    };
876    let suggestion = match range {
877        Some(r) => format!("ct view {file_path} --range {r}"),
878        None => format!("ct view {file_path}"),
879    };
880    Some(Steer {
881        rule_id: "harness-read",
882        tool: "ct view",
883        suggestion,
884        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)",
885    })
886}
887
888/// Whether a path's extension is a binary/rendered format `ct view` cannot
889/// usefully show as text — so a `Read` of it is left alone.
890fn is_unrenderable(path: &str) -> bool {
891    const EXTS: &[&str] = &[
892        "png", "jpg", "jpeg", "gif", "bmp", "webp", "ico", "tif", "tiff", "pdf", "ipynb",
893    ];
894    let ext = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
895    path.contains('.') && EXTS.contains(&ext.as_str())
896}
897
898// ----- Hook protocol -----------------------------------------------------------
899
900/// The Claude Code `PreToolUse` hook protocol: turn a stdin envelope into a
901/// steering decision.
902pub mod hook {
903    use super::{Mode, Steer, analyze, glob_steer, grep_steer, read_steer};
904    use serde_json::{Value, json};
905
906    /// Build the `PreToolUse` decision JSON for a [`Steer`] under `mode`.
907    pub fn decision(steer: &Steer, mode: Mode) -> Value {
908        let reason = steer.reason();
909        match mode {
910            Mode::Deny => json!({"hookSpecificOutput": {
911                "hookEventName": "PreToolUse",
912                "permissionDecision": "deny",
913                "permissionDecisionReason": reason,
914            }}),
915            Mode::Ask => json!({"hookSpecificOutput": {
916                "hookEventName": "PreToolUse",
917                "permissionDecision": "ask",
918                "permissionDecisionReason": reason,
919            }}),
920            Mode::Warn => json!({"hookSpecificOutput": {
921                "hookEventName": "PreToolUse",
922                "additionalContext": reason,
923            }}),
924        }
925    }
926
927    /// A string field of a tool-input object.
928    fn str_field<'a>(input: &'a Value, key: &str) -> Option<&'a str> {
929        input.get(key).and_then(Value::as_str)
930    }
931
932    /// An integer field of a tool-input object.
933    fn int_field(input: &Value, key: &str) -> Option<i64> {
934        input.get(key).and_then(Value::as_i64)
935    }
936
937    /// Process a raw `PreToolUse` stdin envelope. Returns the decision JSON to
938    /// print, or [`None`] to allow silently. The `Bash` command is classified by
939    /// [`analyze`]; the harness's own `Grep` / `Glob` / `Read` calls are steered
940    /// from their structured fields. **Fail-open:** any parse error, an
941    /// unhandled tool, or a missing field all yield [`None`].
942    pub fn process(envelope: &str, mode: Mode) -> Option<Value> {
943        let v: Value = serde_json::from_str(envelope).ok()?;
944        let tool = v.get("tool_name").and_then(Value::as_str)?;
945        let input = v.get("tool_input")?;
946        let steer = match tool {
947            "Bash" => analyze(str_field(input, "command")?),
948            "Grep" => Some(grep_steer(
949                str_field(input, "pattern")?,
950                str_field(input, "path"),
951                str_field(input, "glob"),
952            )),
953            "Glob" => Some(glob_steer(
954                str_field(input, "pattern")?,
955                str_field(input, "path"),
956            )),
957            "Read" => read_steer(
958                str_field(input, "file_path")?,
959                int_field(input, "offset"),
960                int_field(input, "limit"),
961            ),
962            _ => None,
963        }?;
964        Some(decision(&steer, mode))
965    }
966}
967
968// ----- Settings install --------------------------------------------------------
969
970/// Merging the steering hook into a Claude Code settings file. The merge runs
971/// through the comment- and layout-preserving `ct-patch` engine
972/// ([`crate::patch`]): the existing file is parsed only to *decide* which edits
973/// to make, and those edits are byte-range splices against the original text,
974/// so the user's comments and formatting survive.
975pub mod install {
976    use super::Mode;
977    use crate::patch::{self, Op, parse_path};
978    use serde_json::{Value, json};
979    use std::path::{Path, PathBuf};
980
981    /// Which settings file the hook is written to.
982    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
983    pub enum Scope {
984        /// `.claude/settings.json` (shared, committed).
985        Project,
986        /// `.claude/settings.local.json` (personal, gitignored).
987        Local,
988        /// `~/.claude/settings.json` (all projects).
989        User,
990    }
991
992    impl Scope {
993        /// Parse the `--scope` value.
994        pub fn from_name(s: &str) -> Option<Scope> {
995            match s {
996                "project" => Some(Scope::Project),
997                "local" => Some(Scope::Local),
998                "user" => Some(Scope::User),
999                _ => None,
1000            }
1001        }
1002
1003        /// The settings file path. `project`/`local` are relative to `root`
1004        /// (the project directory); `user` lives under `home`.
1005        pub fn path(self, root: &Path, home: &Path) -> PathBuf {
1006            match self {
1007                Scope::Project => root.join(".claude").join("settings.json"),
1008                Scope::Local => root.join(".claude").join("settings.local.json"),
1009                Scope::User => home.join(".claude").join("settings.json"),
1010            }
1011        }
1012    }
1013
1014    /// A harness tool the steering hook can be installed to gate. Each becomes
1015    /// its own `PreToolUse` matcher entry; `Bash` is the default.
1016    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1017    pub enum Tool {
1018        /// Shell commands — classified by the full shell-idiom matcher.
1019        Bash,
1020        /// The harness content search → `ct search`.
1021        Grep,
1022        /// The harness file glob → `ct search`.
1023        Glob,
1024        /// The harness file read → `ct view` (images/PDF/notebooks pass through).
1025        Read,
1026    }
1027
1028    impl Tool {
1029        /// Parse a `--tools` value.
1030        pub fn from_name(s: &str) -> Option<Tool> {
1031            match s {
1032                "Bash" => Some(Tool::Bash),
1033                "Grep" => Some(Tool::Grep),
1034                "Glob" => Some(Tool::Glob),
1035                "Read" => Some(Tool::Read),
1036                _ => None,
1037            }
1038        }
1039
1040        /// The `matcher` string this tool is written under in settings.
1041        pub fn matcher(self) -> &'static str {
1042            match self {
1043                Tool::Bash => "Bash",
1044                Tool::Grep => "Grep",
1045                Tool::Glob => "Glob",
1046                Tool::Read => "Read",
1047            }
1048        }
1049    }
1050
1051    /// The hook command string written into settings for `mode`.
1052    pub fn hook_command(mode: Mode) -> String {
1053        match mode {
1054            Mode::Deny => "ct steer hook".to_string(),
1055            other => format!("ct steer hook --mode {}", other.name()),
1056        }
1057    }
1058
1059    /// Whether a settings hook command is one of ours (any mode).
1060    fn is_steer_command(s: &str) -> bool {
1061        s.contains("steer") && s.contains("hook")
1062    }
1063
1064    /// Parse existing settings text (JSONC tolerated) for read-only inspection.
1065    /// The actual mutation is a byte-splice on the original text via `ct-patch`,
1066    /// so this serde view is only used to *decide* which edits to make.
1067    fn inspect(text: &str) -> Result<Value, String> {
1068        let root = jsonc_parser::parse_to_serde_value(text, &jsonc_parser::ParseOptions::default())
1069            .map_err(|e| format!("parse settings: {e}"))?
1070            .unwrap_or_else(|| json!({}));
1071        if !root.is_object() {
1072            return Err("settings root must be a JSON object".to_string());
1073        }
1074        Ok(root)
1075    }
1076
1077    /// The canonical full settings document, used only when there is no existing
1078    /// file to merge into (so there are no comments or layout to preserve). One
1079    /// `PreToolUse` matcher entry per requested tool.
1080    fn canonical(command: &str, tools: &[Tool]) -> String {
1081        let matchers: Vec<Value> = tools
1082            .iter()
1083            .map(|t| {
1084                json!({ "matcher": t.matcher(), "hooks": [ { "type": "command", "command": command } ] })
1085            })
1086            .collect();
1087        let v = json!({ "hooks": { "PreToolUse": matchers } });
1088        serde_json::to_string_pretty(&v).unwrap() + "\n"
1089    }
1090
1091    fn op_set(path: &str, value: String) -> Result<Op, String> {
1092        Ok(Op::Set {
1093            path: parse_path(path)?,
1094            raw: path.to_string(),
1095            value,
1096        })
1097    }
1098    fn op_add(path: &str, value: String) -> Result<Op, String> {
1099        Ok(Op::Add {
1100            path: parse_path(path)?,
1101            raw: path.to_string(),
1102            value,
1103        })
1104    }
1105    fn op_delete(path: &str) -> Result<Op, String> {
1106        Ok(Op::Delete {
1107            path: parse_path(path)?,
1108            raw: path.to_string(),
1109        })
1110    }
1111
1112    /// Apply a computed op sequence to the original text via the comment- and
1113    /// layout-preserving `ct-patch` engine.
1114    fn apply(text: &str, ops: &[Op]) -> Result<(String, bool), String> {
1115        if ops.is_empty() {
1116            return Ok((text.to_string(), false));
1117        }
1118        let (out, changes) =
1119            patch::apply_doc(text, ops).map_err(|e| format!("settings merge: {e}"))?;
1120        Ok((out, changes > 0))
1121    }
1122
1123    /// Install the steering hook into `existing` settings text (or create a
1124    /// fresh document), gating each tool in `tools`. Returns the new text and
1125    /// whether it changed. Idempotent: re-installing the same command/tools is a
1126    /// no-op; a `--mode` change rewrites the command in place; a new tool adds
1127    /// its matcher. Comments and layout in `existing` are preserved.
1128    pub fn install(
1129        existing: Option<&str>,
1130        command: &str,
1131        tools: &[Tool],
1132    ) -> Result<(String, bool), String> {
1133        let Some(text) = existing.filter(|t| !t.trim().is_empty()) else {
1134            return Ok((canonical(command, tools), true));
1135        };
1136        let root = inspect(text)?;
1137        let ops = install_ops(&root, command, tools)?;
1138        apply(text, &ops)
1139    }
1140
1141    /// Remove every steering hook from `existing` settings text, pruning emptied
1142    /// matcher entries (and the `PreToolUse`/`hooks` containers when they end up
1143    /// empty). Comments and layout elsewhere are preserved.
1144    pub fn uninstall(existing: Option<&str>) -> Result<(String, bool), String> {
1145        let Some(text) = existing.filter(|t| !t.trim().is_empty()) else {
1146            return Ok((existing.unwrap_or_default().to_string(), false));
1147        };
1148        let root = inspect(text)?;
1149        let ops = uninstall_ops(&root)?;
1150        apply(text, &ops)
1151    }
1152
1153    /// The `hooks.PreToolUse` array, if present and well-shaped.
1154    fn pre_array(root: &Value) -> Option<&Vec<Value>> {
1155        root.get("hooks")
1156            .and_then(|h| h.get("PreToolUse"))
1157            .and_then(Value::as_array)
1158    }
1159
1160    /// Whether an array element is a `"matcher": <name>` entry.
1161    fn is_matcher(entry: &Value, name: &str) -> bool {
1162        entry.get("matcher").and_then(Value::as_str) == Some(name)
1163    }
1164
1165    /// Whether a matcher entry already carries one of our steer hooks.
1166    fn entry_has_steer(entry: &Value) -> bool {
1167        entry
1168            .get("hooks")
1169            .and_then(Value::as_array)
1170            .is_some_and(|l| {
1171                l.iter().any(|h| {
1172                    h.get("command")
1173                        .and_then(Value::as_str)
1174                        .is_some_and(is_steer_command)
1175                })
1176            })
1177    }
1178
1179    /// Compute the ops that install `command` for every tool in `tools`, given
1180    /// the parsed `root`. Indices come from the original parse and stay valid
1181    /// because every op only adds keys/elements, never reorders entries.
1182    fn install_ops(root: &Value, command: &str, tools: &[Tool]) -> Result<Vec<Op>, String> {
1183        let mut ops = Vec::new();
1184
1185        // Ensure the `hooks` / `hooks.PreToolUse` containers exist.
1186        let hooks = root.get("hooks");
1187        match hooks {
1188            None => ops.push(op_set(".hooks", "{}".to_string())?),
1189            Some(h) if !h.is_object() => {
1190                return Err("settings `hooks` must be an object".to_string());
1191            }
1192            Some(_) => {}
1193        }
1194        let pre = hooks.and_then(|h| h.get("PreToolUse"));
1195        match pre {
1196            None => ops.push(op_set(".hooks.PreToolUse", "[]".to_string())?),
1197            Some(p) if !p.is_array() => {
1198                return Err("settings `hooks.PreToolUse` must be an array".to_string());
1199            }
1200            Some(_) => {}
1201        }
1202        let pre_arr = pre.and_then(Value::as_array);
1203
1204        // Mode change: rewrite the command of any existing steer hook that differs.
1205        if let Some(arr) = pre_arr {
1206            for (ei, entry) in arr.iter().enumerate() {
1207                let Some(list) = entry.get("hooks").and_then(Value::as_array) else {
1208                    continue;
1209                };
1210                for (hi, h) in list.iter().enumerate() {
1211                    if let Some(c) = h.get("command").and_then(Value::as_str)
1212                        && is_steer_command(c)
1213                        && c != command
1214                    {
1215                        ops.push(op_set(
1216                            &format!(".hooks.PreToolUse[{ei}].hooks[{hi}].command"),
1217                            json!(command).to_string(),
1218                        )?);
1219                    }
1220                }
1221            }
1222        }
1223
1224        // Per requested tool: ensure a matcher for it carries our command.
1225        let hook_obj = json!({ "type": "command", "command": command }).to_string();
1226        for tool in tools {
1227            let name = tool.matcher();
1228            // Already steered for this tool (mode change handled above)?
1229            if pre_arr.is_some_and(|arr| {
1230                arr.iter()
1231                    .any(|e| is_matcher(e, name) && entry_has_steer(e))
1232            }) {
1233                continue;
1234            }
1235            // Append to an existing matcher entry for this tool, else add one.
1236            let target =
1237                pre_arr.and_then(|arr| arr.iter().enumerate().find(|(_, e)| is_matcher(e, name)));
1238            match target {
1239                Some((ei, e)) if e.get("hooks").and_then(Value::as_array).is_some() => {
1240                    ops.push(op_add(
1241                        &format!(".hooks.PreToolUse[{ei}].hooks"),
1242                        hook_obj.clone(),
1243                    )?);
1244                }
1245                Some((ei, _)) => {
1246                    ops.push(op_set(
1247                        &format!(".hooks.PreToolUse[{ei}].hooks"),
1248                        format!("[{hook_obj}]"),
1249                    )?);
1250                }
1251                None => {
1252                    let matcher = json!({ "matcher": name, "hooks": [ { "type": "command", "command": command } ] })
1253                        .to_string();
1254                    ops.push(op_add(".hooks.PreToolUse", matcher)?);
1255                }
1256            }
1257        }
1258        Ok(ops)
1259    }
1260
1261    /// Compute the ops that remove every steering hook, given the parsed `root`.
1262    fn uninstall_ops(root: &Value) -> Result<Vec<Op>, String> {
1263        let Some(pre) = pre_array(root) else {
1264            return Ok(vec![]);
1265        };
1266        // Per matcher entry: which of its hooks are ours, and whether removing
1267        // them empties the entry.
1268        let mut whole_entries = Vec::new(); // entry indices to delete outright
1269        let mut partial = Vec::new(); // (entry index, our hook indices)
1270        for (ei, entry) in pre.iter().enumerate() {
1271            let Some(list) = entry.get("hooks").and_then(Value::as_array) else {
1272                continue;
1273            };
1274            let ours: Vec<usize> = list
1275                .iter()
1276                .enumerate()
1277                .filter(|(_, h)| {
1278                    h.get("command")
1279                        .and_then(Value::as_str)
1280                        .is_some_and(is_steer_command)
1281                })
1282                .map(|(hi, _)| hi)
1283                .collect();
1284            if ours.is_empty() {
1285                continue;
1286            }
1287            if ours.len() == list.len() {
1288                whole_entries.push(ei);
1289            } else {
1290                partial.push((ei, ours));
1291            }
1292        }
1293        if whole_entries.is_empty() && partial.is_empty() {
1294            return Ok(vec![]);
1295        }
1296
1297        // Every entry removed outright and none surviving → the whole
1298        // PreToolUse goes (or `hooks` itself if PreToolUse was its only key).
1299        if partial.is_empty() && whole_entries.len() == pre.len() {
1300            let hooks_solo = root
1301                .get("hooks")
1302                .and_then(Value::as_object)
1303                .is_some_and(|o| o.len() == 1);
1304            let path = if hooks_solo {
1305                ".hooks"
1306            } else {
1307                ".hooks.PreToolUse"
1308            };
1309            return Ok(vec![op_delete(path)?]);
1310        }
1311
1312        let mut ops = Vec::new();
1313        // Inner-hook deletes first (descending index, so earlier indices stay
1314        // valid), then whole-entry deletes (descending, likewise). Inner deletes
1315        // never shift entry indices, and partial vs whole entries are disjoint.
1316        for (ei, his) in &partial {
1317            for hi in his.iter().rev() {
1318                ops.push(op_delete(&format!(".hooks.PreToolUse[{ei}].hooks[{hi}]"))?);
1319            }
1320        }
1321        for ei in whole_entries.iter().rev() {
1322            ops.push(op_delete(&format!(".hooks.PreToolUse[{ei}]"))?);
1323        }
1324        Ok(ops)
1325    }
1326}
1327
1328#[cfg(test)]
1329mod tests {
1330    use super::install::{Scope, Tool, install, uninstall};
1331    use super::*;
1332    use std::path::Path;
1333
1334    /// Bash-only install, the common case in these tests.
1335    fn install_bash(existing: Option<&str>, command: &str) -> Result<(String, bool), String> {
1336        install(existing, command, &[Tool::Bash])
1337    }
1338
1339    fn tool(cmd: &str) -> Option<&'static str> {
1340        analyze(cmd).map(|s| s.tool)
1341    }
1342    fn rule(cmd: &str) -> Option<&'static str> {
1343        analyze(cmd).map(|s| s.rule_id)
1344    }
1345
1346    #[test]
1347    fn steers_high_confidence_idioms() {
1348        assert_eq!(
1349            tool("find . -name '*.rs' | xargs grep TODO"),
1350            Some("ct search")
1351        );
1352        assert_eq!(
1353            rule("find . -name '*.rs' | xargs grep TODO"),
1354            Some("find-grep")
1355        );
1356        assert_eq!(tool("grep -rn TODO src"), Some("ct search"));
1357        assert_eq!(tool("rg TODO src"), Some("ct search"));
1358        assert_eq!(tool("find src -name '*.rs'"), Some("ct search"));
1359        assert_eq!(tool("sed -i 's/foo/bar/g' src/x.rs"), Some("ct edit"));
1360        assert_eq!(tool("head -n 40 src/lib.rs"), Some("ct view"));
1361        assert_eq!(tool("cat src/lib.rs | head -n 20"), Some("ct view"));
1362        assert_eq!(tool("sed -n '10,20p' src/lib.rs"), Some("ct view"));
1363        assert_eq!(tool("ls -R src"), Some("ct tree"));
1364        assert_eq!(tool("wc -l src/lib.rs"), Some("ct tree"));
1365        assert_eq!(tool("for f in a b; do grep -r x $f; done"), Some("ct each"));
1366        assert_eq!(
1367            rule("for f in a b; do grep -r x $f; done"),
1368            Some("shell-loop")
1369        );
1370    }
1371
1372    #[test]
1373    fn steers_wait_loops_to_await_not_each() {
1374        // a sleep-bearing poll/wait loop is a bounded wait → ct await
1375        assert_eq!(
1376            tool("for i in $(seq 1 900); do cat f; sleep 2; done"),
1377            Some("ct await")
1378        );
1379        assert_eq!(
1380            rule("for i in $(seq 1 900); do cat f; sleep 2; done"),
1381            Some("wait-loop")
1382        );
1383        assert_eq!(
1384            tool("while true; do check; sleep 5; done"),
1385            Some("ct await")
1386        );
1387        assert_eq!(
1388            tool("until curl -sf http://x; do sleep 3; done"),
1389            Some("ct await")
1390        );
1391        // a sleep-free loop stays a per-item map → ct each
1392        assert_eq!(tool("for f in a b; do grep -r x $f; done"), Some("ct each"));
1393        assert_eq!(
1394            rule("for f in a b; do grep -r x $f; done"),
1395            Some("shell-loop")
1396        );
1397    }
1398
1399    #[test]
1400    fn steers_interpreter_file_reads() {
1401        // jq with a file argument reads a file → ct view
1402        assert_eq!(tool("jq '.note' feedback/x.jsonl"), Some("ct view"));
1403        assert_eq!(
1404            rule("jq '.note' feedback/x.jsonl"),
1405            Some("interpreter-read")
1406        );
1407        // python one-liner that opens a file and prints → ct view
1408        let s = analyze(
1409            "python -c \"rows=[json.loads(l) for l in open('feedback/x.jsonl')]; print(rows[-1])\"",
1410        )
1411        .unwrap();
1412        assert_eq!(s.tool, "ct view");
1413        assert!(
1414            s.suggestion.contains("feedback/x.jsonl"),
1415            "{}",
1416            s.suggestion
1417        );
1418        assert_eq!(
1419            tool("node -e 'const d=require(\"fs\").readFileSync(\"a.json\")'"),
1420            Some("ct view")
1421        );
1422        // pure-compute one-liner (no file read) is left alone
1423        assert!(analyze("python -c 'print(2+2)'").is_none());
1424        // a one-liner that writes is left alone
1425        assert!(analyze("python -c \"open('out.txt','w').write('hi')\"").is_none());
1426        // a jq fed by a pipe (no file) is left alone
1427        assert!(analyze("cat x | jq '.note'").is_none());
1428    }
1429
1430    #[test]
1431    fn steers_count_idioms() {
1432        // grep -c counts matching lines → ct search --summary
1433        assert_eq!(tool("grep -c TODO src/lib.rs"), Some("ct search"));
1434        assert_eq!(rule("grep -c TODO src/lib.rs"), Some("grep-count"));
1435        let s = analyze("grep -c TODO src/lib.rs").unwrap();
1436        assert!(s.suggestion.contains("--grep 'TODO'") && s.suggestion.contains("--summary"));
1437        // cat FILES | wc -l counts lines of real files → ct tree
1438        assert_eq!(tool("cat a.jsonl b.jsonl | wc -l"), Some("ct tree"));
1439        // a bare stream count has no file behind it → left alone
1440        assert!(analyze("ps aux | wc -l").is_none());
1441    }
1442
1443    #[test]
1444    fn extracts_obvious_slots() {
1445        let s = analyze("grep -rn TODO src").unwrap();
1446        assert!(s.suggestion.contains("--grep 'TODO'"), "{}", s.suggestion);
1447        let e = analyze("sed -i 's/foo/bar/g' f.rs").unwrap();
1448        assert!(
1449            e.suggestion.contains("--find 'foo'") && e.suggestion.contains("--replace 'bar'"),
1450            "{}",
1451            e.suggestion
1452        );
1453        let v = analyze("head -n 40 src/lib.rs").unwrap();
1454        assert!(
1455            v.suggestion.contains("src/lib.rs --range 1:40"),
1456            "{}",
1457            v.suggestion
1458        );
1459        // the grep pattern is taken after the `grep` token, not after `xargs`
1460        let fg = analyze("find . -name '*.rs' | xargs grep TODO").unwrap();
1461        assert!(fg.suggestion.contains("--grep 'TODO'"), "{}", fg.suggestion);
1462        assert!(fg.suggestion.contains("--name '*.rs'"), "{}", fg.suggestion);
1463    }
1464
1465    #[test]
1466    fn chain_only_when_all_segments_serviceable() {
1467        let s = analyze("grep -r foo src && sed -i 's/a/b/' f.rs").unwrap();
1468        assert_eq!(s.tool, "ct and");
1469        assert!(
1470            s.suggestion.starts_with("ct and search"),
1471            "{}",
1472            s.suggestion
1473        );
1474        assert!(s.suggestion.contains(":::"), "{}", s.suggestion);
1475        // a mixed chain (one non-ct segment) is left alone
1476        assert!(analyze("grep -r foo src && make").is_none());
1477    }
1478
1479    #[test]
1480    fn allows_safe_and_unknown_commands() {
1481        assert!(analyze("git status").is_none());
1482        assert!(analyze("cargo build && cargo test").is_none());
1483        assert!(analyze("ls -la").is_none());
1484        assert!(analyze("cat file.txt").is_none()); // whole-file read, not a range
1485        assert!(analyze("grep TODO file.rs").is_none()); // non-recursive, single file
1486        assert!(analyze("echo 'a | b && c'").is_none()); // operators inside quotes are inert
1487        assert!(analyze("ps aux | head -n 5").is_none()); // piped stream, no file
1488        assert!(analyze("").is_none());
1489    }
1490
1491    #[test]
1492    fn never_resteers_a_ct_command() {
1493        assert!(analyze("ct search --grep TODO").is_none());
1494        assert!(analyze("ct-search --grep TODO").is_none());
1495        assert!(analyze("find . -name '*.rs' | xargs ct-edit").is_none());
1496    }
1497
1498    #[test]
1499    fn hook_decisions_respect_mode() {
1500        let envelope = r#"{"tool_name":"Bash","tool_input":{"command":"grep -r TODO src"}}"#;
1501        let deny = hook::process(envelope, Mode::Deny).unwrap();
1502        assert_eq!(deny["hookSpecificOutput"]["permissionDecision"], "deny");
1503        assert!(
1504            deny["hookSpecificOutput"]["permissionDecisionReason"]
1505                .as_str()
1506                .unwrap()
1507                .contains("ct search")
1508        );
1509        let ask = hook::process(envelope, Mode::Ask).unwrap();
1510        assert_eq!(ask["hookSpecificOutput"]["permissionDecision"], "ask");
1511        let warn = hook::process(envelope, Mode::Warn).unwrap();
1512        assert!(warn["hookSpecificOutput"]["additionalContext"].is_string());
1513        assert!(
1514            warn["hookSpecificOutput"]
1515                .get("permissionDecision")
1516                .is_none()
1517        );
1518    }
1519
1520    #[test]
1521    fn hook_steers_harness_grep_glob_read() {
1522        // Grep → ct search, carrying pattern / path / glob.
1523        let grep = hook::process(
1524            r#"{"tool_name":"Grep","tool_input":{"pattern":"TODO","path":"src","glob":"*.rs"}}"#,
1525            Mode::Deny,
1526        )
1527        .unwrap();
1528        let reason = grep["hookSpecificOutput"]["permissionDecisionReason"]
1529            .as_str()
1530            .unwrap();
1531        assert!(reason.contains("ct search"), "{reason}");
1532        assert!(
1533            reason.contains("--grep 'TODO'") && reason.contains("--base src"),
1534            "{reason}"
1535        );
1536        assert!(reason.contains("--name '*.rs'"), "{reason}");
1537
1538        // Glob → ct search; a `dir/**/*.ext` glob splits into --base / --name.
1539        let s = glob_steer("src/**/*.rs", None);
1540        assert_eq!(s.tool, "ct search");
1541        assert!(s.suggestion.contains("--base src"), "{}", s.suggestion);
1542        assert!(s.suggestion.contains("--name '*.rs'"), "{}", s.suggestion);
1543
1544        // Read → ct view with a range derived from offset/limit.
1545        let read = read_steer("src/lib.rs", Some(10), Some(20)).unwrap();
1546        assert_eq!(read.tool, "ct view");
1547        assert!(
1548            read.suggestion.contains("ct view src/lib.rs --range 10:29"),
1549            "{}",
1550            read.suggestion
1551        );
1552        // a bare read views the whole file
1553        assert_eq!(
1554            read_steer("notes.md", None, None).unwrap().suggestion,
1555            "ct view notes.md"
1556        );
1557        // images / PDFs / notebooks are left for Read (None)
1558        assert!(read_steer("diagram.png", None, None).is_none());
1559        assert!(read_steer("paper.pdf", None, None).is_none());
1560        assert!(read_steer("nb.ipynb", None, None).is_none());
1561    }
1562
1563    #[test]
1564    fn install_covers_multiple_tools() {
1565        let (text, changed) =
1566            install(None, "ct steer hook", &[Tool::Bash, Tool::Grep, Tool::Read]).unwrap();
1567        assert!(changed);
1568        for m in ["\"Bash\"", "\"Grep\"", "\"Read\""] {
1569            assert!(text.contains(m), "missing matcher {m} in {text}");
1570        }
1571        // re-install with the same tools is a no-op
1572        let (_, again) = install(
1573            Some(&text),
1574            "ct steer hook",
1575            &[Tool::Bash, Tool::Grep, Tool::Read],
1576        )
1577        .unwrap();
1578        assert!(!again);
1579        // adding a tool to an existing install only appends its matcher
1580        let (grown, did) = install(Some(&text), "ct steer hook", &[Tool::Glob]).unwrap();
1581        assert!(did);
1582        assert!(grown.contains("\"Glob\""));
1583        assert_eq!(grown.matches("\"matcher\"").count(), 4);
1584        // uninstall clears every steer matcher we added
1585        let (cleared, _) = uninstall(Some(&grown)).unwrap();
1586        assert!(!cleared.contains("steer hook"));
1587    }
1588
1589    #[test]
1590    fn hook_fails_open() {
1591        assert!(hook::process("not json", Mode::Deny).is_none());
1592        assert!(hook::process(r#"{"tool_name":"Read"}"#, Mode::Deny).is_none());
1593        assert!(hook::process(r#"{"tool_name":"Bash","tool_input":{}}"#, Mode::Deny).is_none());
1594        assert!(
1595            hook::process(
1596                r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#,
1597                Mode::Deny
1598            )
1599            .is_none()
1600        );
1601    }
1602
1603    #[test]
1604    fn install_is_idempotent_and_preserves_other_settings() {
1605        // fresh install
1606        let (text, changed) = install_bash(None, "ct steer hook").unwrap();
1607        assert!(changed);
1608        assert!(text.contains("PreToolUse"));
1609        assert!(text.contains("\"matcher\": \"Bash\""));
1610        assert!(text.contains("ct steer hook"));
1611        // re-install is a no-op
1612        let (text2, changed2) = install_bash(Some(&text), "ct steer hook").unwrap();
1613        assert!(!changed2);
1614        assert_eq!(text, text2);
1615        // a mode change rewrites in place (still one hook)
1616        let (text3, changed3) = install_bash(Some(&text), "ct steer hook --mode ask").unwrap();
1617        assert!(changed3);
1618        assert_eq!(text3.matches("steer hook").count(), 1);
1619        // existing unrelated settings survive
1620        let existing = r#"{ "model": "opus", "hooks": { "PreToolUse": [] } }"#;
1621        let (merged, _) = install_bash(Some(existing), "ct steer hook").unwrap();
1622        assert!(merged.contains("\"model\": \"opus\""));
1623    }
1624
1625    #[test]
1626    fn uninstall_removes_only_our_hook() {
1627        let existing = r#"{
1628            "hooks": { "PreToolUse": [
1629                { "matcher": "Bash", "hooks": [
1630                    { "type": "command", "command": "ct steer hook" },
1631                    { "type": "command", "command": "./other.sh" }
1632                ] }
1633            ] }
1634        }"#;
1635        let (text, changed) = uninstall(Some(existing)).unwrap();
1636        assert!(changed);
1637        assert!(!text.contains("steer hook"));
1638        assert!(text.contains("./other.sh")); // the unrelated hook stays
1639        // uninstall on a clean file is a no-op
1640        let (_, changed2) = uninstall(Some("{}")).unwrap();
1641        assert!(!changed2);
1642    }
1643
1644    #[test]
1645    fn install_and_uninstall_preserve_comments() {
1646        // a settings.json with comments the user cares about
1647        let existing = "{\n  \
1648            // pin the model\n  \
1649            \"model\": \"opus\", // do not change\n  \
1650            \"hooks\": {\n    \
1651            \"PreToolUse\": [\n      \
1652            { \"matcher\": \"Bash\", \"hooks\": [ { \"type\": \"command\", \"command\": \"./guard.sh\" } ] }\n    \
1653            ]\n  }\n}\n";
1654        let (installed, changed) = install_bash(Some(existing), "ct steer hook").unwrap();
1655        assert!(changed);
1656        // comments survive the merge
1657        assert!(installed.contains("// pin the model"), "{installed}");
1658        assert!(installed.contains("// do not change"), "{installed}");
1659        // the prior hook is untouched and ours is appended to the same matcher
1660        assert!(installed.contains("./guard.sh"), "{installed}");
1661        assert!(installed.contains("ct steer hook"), "{installed}");
1662
1663        // uninstall removes only our hook, keeps the guard and the comments
1664        let (removed, changed2) = uninstall(Some(&installed)).unwrap();
1665        assert!(changed2);
1666        assert!(removed.contains("// pin the model"), "{removed}");
1667        assert!(removed.contains("./guard.sh"), "{removed}");
1668        assert!(!removed.contains("steer hook"), "{removed}");
1669    }
1670
1671    #[test]
1672    fn scope_paths() {
1673        let root = Path::new("/proj");
1674        let home = Path::new("/home/u");
1675        assert!(
1676            Scope::Project
1677                .path(root, home)
1678                .ends_with(".claude/settings.json")
1679        );
1680        assert!(
1681            Scope::Local
1682                .path(root, home)
1683                .ends_with(".claude/settings.local.json")
1684        );
1685        assert!(Scope::User.path(root, home).starts_with("/home/u"));
1686    }
1687}