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` loops — even when a suite tool would do the job bounded, deterministic,
10//! and self-verifying. [`analyze`] is the pure heart: it classifies a shell
11//! command string into an optional [`Steer`] naming the `ct` tool that serves
12//! it and a best-effort equivalent command. The [`hook`] submodule wraps that
13//! in the Claude Code `PreToolUse` JSON protocol (deny / ask / warn); the
14//! [`install`] submodule wires the hook into a project's `.claude/settings.json`.
15//!
16//! The matcher is deliberately **conservative**: it only fires on a fixed set
17//! of high-confidence 1:1 idioms, never re-steers a command that already
18//! invokes `ct`, and returns [`None`] (allow) whenever it is unsure. The hook
19//! is **fail-open** — any malformed input or unrecognised command is allowed —
20//! because it runs ahead of *every* shell call.
21
22/// What the hook does when a command matches a steering rule.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum Mode {
25    /// Block the call and feed the `ct` suggestion back to the agent (default).
26    #[default]
27    Deny,
28    /// Surface a confirmation prompt naming the `ct` suggestion.
29    Ask,
30    /// Allow the call, but inject the `ct` suggestion as context.
31    Warn,
32}
33
34impl Mode {
35    /// Parse the `--mode` value.
36    ///
37    /// ```
38    /// use coding_tools::steer::Mode;
39    /// assert_eq!(Mode::from_name("ask"), Some(Mode::Ask));
40    /// assert_eq!(Mode::from_name("nope"), None);
41    /// ```
42    pub fn from_name(s: &str) -> Option<Mode> {
43        match s {
44            "deny" => Some(Mode::Deny),
45            "ask" => Some(Mode::Ask),
46            "warn" => Some(Mode::Warn),
47            _ => None,
48        }
49    }
50
51    /// The canonical name, as accepted by `--mode` and written into settings.
52    pub fn name(self) -> &'static str {
53        match self {
54            Mode::Deny => "deny",
55            Mode::Ask => "ask",
56            Mode::Warn => "warn",
57        }
58    }
59}
60
61/// A steering match: a `ct` tool serves the inspected command.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct Steer {
64    /// Stable identifier for the rule that fired (e.g. `"find-grep"`).
65    pub rule_id: &'static str,
66    /// The `ct` tool that serves the idiom (e.g. `"ct search"`).
67    pub tool: &'static str,
68    /// A best-effort equivalent `ct` command line.
69    pub suggestion: String,
70    /// One line teaching why the `ct` tool is the better fit.
71    pub note: &'static str,
72}
73
74impl Steer {
75    /// The reason text shown to the agent (the `ct` command plus the lesson).
76    pub fn reason(&self) -> String {
77        format!(
78            "A `ct` tool serves this more reliably than raw shell — bounded, \
79             deterministic, and self-verifying. Use instead:\n  {}\n({})",
80            self.suggestion, self.note
81        )
82    }
83}
84
85// ----- Lexing ------------------------------------------------------------------
86
87/// A shell token: either a word (quoted regions collapse into the surrounding
88/// word, so operators inside quotes are inert) or one of the control operators
89/// we split on. Redirections and grouping are dropped to word boundaries.
90#[derive(Debug, Clone, PartialEq, Eq)]
91enum Tok {
92    Word(String),
93    Pipe,
94    And,
95    Or,
96    Semi,
97}
98
99/// Tokenise a command string. Single/double quotes and backslash escapes keep
100/// their contents inside the current word, so `echo "a | b"` is one word and
101/// the `|` does not register as a pipe.
102fn lex(cmd: &str) -> Vec<Tok> {
103    let mut toks = Vec::new();
104    let mut cur = String::new();
105    let mut have = false; // cur holds a word (possibly empty, from `""`)
106    let mut chars = cmd.chars().peekable();
107
108    fn flush(toks: &mut Vec<Tok>, cur: &mut String, have: &mut bool) {
109        if *have {
110            toks.push(Tok::Word(std::mem::take(cur)));
111            *have = false;
112        }
113    }
114
115    while let Some(c) = chars.next() {
116        match c {
117            '\'' => {
118                have = true;
119                for d in chars.by_ref() {
120                    if d == '\'' {
121                        break;
122                    }
123                    cur.push(d);
124                }
125            }
126            '"' => {
127                have = true;
128                while let Some(d) = chars.next() {
129                    if d == '"' {
130                        break;
131                    }
132                    if d == '\\' {
133                        if let Some(e) = chars.next() {
134                            cur.push(e);
135                        }
136                    } else {
137                        cur.push(d);
138                    }
139                }
140            }
141            '\\' => {
142                if let Some(d) = chars.next() {
143                    cur.push(d);
144                    have = true;
145                }
146            }
147            '|' => {
148                flush(&mut toks, &mut cur, &mut have);
149                if chars.peek() == Some(&'|') {
150                    chars.next();
151                    toks.push(Tok::Or);
152                } else {
153                    toks.push(Tok::Pipe);
154                }
155            }
156            '&' => {
157                flush(&mut toks, &mut cur, &mut have);
158                if chars.peek() == Some(&'&') {
159                    chars.next();
160                    toks.push(Tok::And);
161                } else {
162                    toks.push(Tok::Semi); // a lone `&` (background) ends a command
163                }
164            }
165            ';' => {
166                flush(&mut toks, &mut cur, &mut have);
167                toks.push(Tok::Semi);
168            }
169            // Redirections and grouping: end the current word, drop the symbol.
170            '>' | '<' | '(' | ')' | '{' | '}' | '`' => {
171                flush(&mut toks, &mut cur, &mut have);
172            }
173            c if c.is_whitespace() => flush(&mut toks, &mut cur, &mut have),
174            _ => {
175                cur.push(c);
176                have = true;
177            }
178        }
179    }
180    flush(&mut toks, &mut cur, &mut have);
181    toks
182}
183
184/// Split a token stream into control segments (on `&&` / `||` / `;`) plus the
185/// list of joiner operators between them (length = segments − 1).
186fn control_segments(toks: &[Tok]) -> (Vec<Vec<Tok>>, Vec<Tok>) {
187    let mut segs = vec![Vec::new()];
188    let mut joiners = Vec::new();
189    for t in toks {
190        match t {
191            Tok::And | Tok::Or | Tok::Semi => {
192                joiners.push(t.clone());
193                segs.push(Vec::new());
194            }
195            other => segs.last_mut().unwrap().push(other.clone()),
196        }
197    }
198    // Drop a trailing empty segment (e.g. a command ending in `;`).
199    if segs.last().is_some_and(Vec::is_empty) {
200        segs.pop();
201        joiners.pop();
202    }
203    (segs, joiners)
204}
205
206/// Split one control segment into pipeline stages (on `|`); each stage is its
207/// word list.
208fn pipe_stages(seg: &[Tok]) -> Vec<Vec<String>> {
209    let mut stages = vec![Vec::new()];
210    for t in seg {
211        match t {
212            Tok::Pipe => stages.push(Vec::new()),
213            Tok::Word(w) => stages.last_mut().unwrap().push(w.clone()),
214            _ => {}
215        }
216    }
217    stages
218}
219
220// ----- Word/flag helpers -------------------------------------------------------
221
222/// The basename of a command word (`/usr/bin/grep` → `grep`).
223fn base_name(w: &str) -> &str {
224    w.rsplit(['/', '\\']).next().unwrap_or(w)
225}
226
227/// The command name of a stage (its first word, basename-stripped).
228fn cmd_of(stage: &[String]) -> Option<&str> {
229    stage.first().map(|w| base_name(w))
230}
231
232/// Whether a stage carries a single-dash short flag containing `ch` (so `-r`,
233/// `-rn`, `-Rl` all count for `'r'`), excluding `--long` words.
234fn has_short(stage: &[String], ch: char) -> bool {
235    stage.iter().any(|w| {
236        w.starts_with('-') && !w.starts_with("--") && w[1..].chars().any(|c| c == ch)
237    })
238}
239
240/// Whether a stage carries `flag` exactly, or `flag=…`.
241fn has_flag(stage: &[String], flag: &str) -> bool {
242    stage
243        .iter()
244        .any(|w| w == flag || w.starts_with(&format!("{flag}=")))
245}
246
247/// The value of `-flag VALUE`, `--flag VALUE`, or `--flag=VALUE` in a stage.
248fn flag_value<'a>(stage: &'a [String], names: &[&str]) -> Option<&'a str> {
249    for (i, w) in stage.iter().enumerate() {
250        for n in names {
251            if w == n {
252                return stage.get(i + 1).map(String::as_str);
253            }
254            let eq = format!("{n}=");
255            if let Some(v) = w.strip_prefix(&eq) {
256                return Some(v);
257            }
258        }
259    }
260    None
261}
262
263/// The positional (non-flag) words of a stage after its command. Imperfect —
264/// a value-taking flag's value (e.g. the `40` in `head -n 40`) leaks through —
265/// so callers that care filter further.
266fn positionals(stage: &[String]) -> Vec<&str> {
267    stage
268        .iter()
269        .skip(1)
270        .filter(|w| !w.starts_with('-'))
271        .map(String::as_str)
272        .collect()
273}
274
275/// A `find` start path: the first argument, when it is not a `-option`
276/// (`find <path> -name …`; a bare `find -name …` defaults to the cwd).
277fn find_base(find: &[String]) -> Option<&str> {
278    find.get(1)
279        .filter(|w| !w.starts_with('-'))
280        .map(String::as_str)
281}
282
283/// Single-quote a value for display inside a suggested command.
284fn q(s: &str) -> String {
285    format!("'{}'", s.replace('\'', "'\\''"))
286}
287
288// ----- Rules -------------------------------------------------------------------
289
290/// Classify a shell command. [`None`] means "allow" — no `ct` tool clearly
291/// serves it. The matcher only fires on high-confidence idioms and never
292/// re-steers a command that already invokes `ct`.
293///
294/// ```
295/// use coding_tools::steer::analyze;
296/// let s = analyze("find . -name '*.rs' | xargs grep TODO").unwrap();
297/// assert_eq!(s.tool, "ct search");
298/// assert!(analyze("cargo build && cargo test").is_none());
299/// assert!(analyze("ct search --grep TODO").is_none());
300/// ```
301pub fn analyze(command: &str) -> Option<Steer> {
302    let toks = lex(command);
303    if toks.is_empty() {
304        return None;
305    }
306    let (segs, joiners) = control_segments(&toks);
307    let seg_stages: Vec<Vec<Vec<String>>> = segs.iter().map(|s| pipe_stages(s)).collect();
308
309    // Never re-steer a command that already involves `ct` / `ct-*` anywhere
310    // (as a command, or behind `xargs`/`env`/…). Erring toward allow here is
311    // safe — at worst we decline to steer a grep that merely mentions `ct-…`.
312    let touches_ct = seg_stages.iter().flatten().flatten().any(|w| {
313        let b = base_name(w);
314        b == "ct" || b.starts_with("ct-")
315    });
316    if touches_ct {
317        return None;
318    }
319
320    // Shell loops (`for`/`while`) — a control word starting the first segment.
321    if let Some(first) = seg_stages.first().and_then(|s| s.first()).and_then(|s| cmd_of(s))
322        && (first == "for" || first == "while")
323    {
324        return Some(Steer {
325            rule_id: "shell-loop",
326            tool: "ct each",
327            suggestion: "ct each --items <a> <b> -- <cmd-template-with-{ITEM}>".to_string(),
328            note: "ct each runs a command template once per item with no shell and an aggregate --expect verdict",
329        });
330    }
331
332    // A single command (possibly with pipes): the common, high-value case.
333    if segs.len() == 1 {
334        return analyze_segment(&seg_stages[0]);
335    }
336
337    // A chain (`&&` / `||`): only steer when *every* segment is itself
338    // ct-serviceable and the joiners are uniform, so `ct and`/`ct or`
339    // reproduces it faithfully. A mixed chain (e.g. `grep -r x && make`) is
340    // left alone.
341    let matches: Vec<Steer> = seg_stages.iter().filter_map(|st| analyze_segment(st)).collect();
342    if matches.len() == segs.len() && !joiners.is_empty() {
343        if joiners.iter().all(|j| *j == Tok::And) {
344            return Some(chain_steer("ct and", &matches));
345        }
346        if joiners.iter().all(|j| *j == Tok::Or) {
347            return Some(chain_steer("ct or", &matches));
348        }
349    }
350    None
351}
352
353/// Build the chain suggestion from each segment's own `ct` suggestion, joined
354/// with the suite's shell-less `:::` separator.
355fn chain_steer(head: &'static str, parts: &[Steer]) -> Steer {
356    let body = parts
357        .iter()
358        .map(|p| p.suggestion.trim_start_matches("ct ").to_string())
359        .collect::<Vec<_>>()
360        .join(" ::: ");
361    let (rule_id, note) = if head == "ct and" {
362        (
363            "and-chain",
364            "ct and runs each step in turn, stopping at the first failure — a shell-less && with no quoting",
365        )
366    } else {
367        (
368            "or-chain",
369            "ct or runs each step in turn, stopping at the first success — a shell-less || with no quoting",
370        )
371    };
372    Steer {
373        rule_id,
374        tool: head,
375        suggestion: format!("{head} {body}"),
376        note,
377    }
378}
379
380/// Classify a single control segment (its pipeline stages). Rule order encodes
381/// priority: the most specific idiom wins.
382fn analyze_segment(stages: &[Vec<String>]) -> Option<Steer> {
383    rule_find_grep(stages)
384        .or_else(|| rule_grep_recursive(stages))
385        .or_else(|| rule_sed_inplace(stages))
386        .or_else(|| rule_read_range(stages))
387        .or_else(|| rule_find_files(stages))
388        .or_else(|| rule_list_recursive(stages))
389        .or_else(|| rule_count_lines(stages))
390}
391
392/// `find … | xargs grep` / `find … -exec grep` → `ct search`.
393fn rule_find_grep(stages: &[Vec<String>]) -> Option<Steer> {
394    let find = stages.iter().find(|s| cmd_of(s) == Some("find"))?;
395    // grep appearing anywhere (its own stage, after xargs, or after -exec).
396    let grep_stage = stages
397        .iter()
398        .find(|s| s.iter().any(|w| base_name(w) == "grep"))?;
399    let glob = flag_value(find, &["-name", "-iname"]);
400    let pat = grep_pattern(grep_stage);
401    Some(Steer {
402        rule_id: "find-grep",
403        tool: "ct search",
404        suggestion: search_suggestion(find_base(find), glob, pat),
405        note: "ct search recurses, filters by name/type/size, and greps in one declarative pass — find | xargs grep in a single command",
406    })
407}
408
409/// `grep -r` / `rg` / `ag` → `ct search`.
410fn rule_grep_recursive(stages: &[Vec<String>]) -> Option<Steer> {
411    for s in stages {
412        let Some(cmd) = cmd_of(s) else { continue };
413        let recursive_grep = cmd == "grep"
414            && (has_short(s, 'r') || has_short(s, 'R') || has_flag(s, "--recursive"));
415        if recursive_grep || matches!(cmd, "rg" | "ripgrep" | "ag") {
416            let pat = grep_pattern(s);
417            // `grep -r PAT PATH` / `rg PAT PATH`: the second positional is the path.
418            let base = positionals(s).get(1).copied();
419            return Some(Steer {
420                rule_id: "grep-recursive",
421                tool: "ct search",
422                suggestion: search_suggestion(base, None, pat),
423                note: "ct search is the suite's recursive content search, with a framed --expect verdict (e.g. --expect none asserts absence)",
424            });
425        }
426    }
427    None
428}
429
430/// `find … -name` with no grep → `ct search` (name filter only).
431fn rule_find_files(stages: &[Vec<String>]) -> Option<Steer> {
432    let find = stages.iter().find(|s| cmd_of(s) == Some("find"))?;
433    let glob = flag_value(find, &["-name", "-iname"])?;
434    let base = find_base(find);
435    Some(Steer {
436        rule_id: "find-files",
437        tool: "ct search",
438        suggestion: search_suggestion(base, Some(glob), None),
439        note: "ct search selects files by --name/--type/--size and reports them, replacing a bare find",
440    })
441}
442
443/// `sed -i` / `perl -i` → `ct edit`.
444fn rule_sed_inplace(stages: &[Vec<String>]) -> Option<Steer> {
445    let stage = stages.iter().find(|s| {
446        let cmd = cmd_of(s);
447        let sed_i = cmd == Some("sed")
448            && s.iter().any(|w| w.starts_with("-i") || w == "--in-place");
449        let perl_i = cmd == Some("perl") && s.iter().any(|w| w.starts_with("-i"));
450        sed_i || perl_i
451    })?;
452    let (find, replace) = sed_subst(stage);
453    let suggestion = match (find, replace) {
454        (Some(f), Some(r)) => format!(
455            "ct edit --base . --find {} --replace {} --expect =1 --dry-run",
456            q(f),
457            q(r)
458        ),
459        _ => "ct edit --base . --find <text> --replace <text> --expect =1 --dry-run".to_string(),
460    };
461    Some(Steer {
462        rule_id: "sed-inplace",
463        tool: "ct edit",
464        suggestion,
465        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",
466    })
467}
468
469/// `head`/`tail`/`sed -n 'A,Bp'` on a file → `ct view --range`.
470fn rule_read_range(stages: &[Vec<String>]) -> Option<Steer> {
471    // sed -n 'A,Bp'
472    for s in stages {
473        if cmd_of(s) == Some("sed")
474            && has_flag(s, "-n")
475            && let Some((a, b)) = positionals(s).into_iter().find_map(parse_sed_range)
476        {
477            let file = positionals(s).into_iter().find(|&w| !is_sed_script(w));
478            return Some(view_steer(file, Some((a, b))));
479        }
480    }
481    // head / tail, reading a named file or fed by `cat FILE`.
482    for (i, s) in stages.iter().enumerate() {
483        let cmd = cmd_of(s);
484        if cmd != Some("head") && cmd != Some("tail") {
485            continue;
486        }
487        let n = head_count(s);
488        // The file is head/tail's own positional (not the numeric `-n` value),
489        // or an upstream `cat FILE`.
490        let own = positionals(s)
491            .into_iter()
492            .find(|w| w.parse::<u64>().is_err());
493        let upstream = (i > 0 && cmd_of(&stages[i - 1]) == Some("cat"))
494            .then(|| positionals(&stages[i - 1]).into_iter().next())
495            .flatten();
496        let file = own.or(upstream)?; // no concrete file → not a file read; skip
497        let range = match (cmd, n) {
498            (Some("head"), Some(n)) => Some((1, n)),
499            _ => None, // tail = last-N lines; leave the range to the agent
500        };
501        return Some(view_steer(Some(file), range));
502    }
503    None
504}
505
506/// `ls -R` / `tree` → `ct tree`.
507fn rule_list_recursive(stages: &[Vec<String>]) -> Option<Steer> {
508    let stage = stages.iter().find(|s| {
509        cmd_of(s) == Some("tree") || (cmd_of(s) == Some("ls") && has_short(s, 'R'))
510    })?;
511    let base = positionals(stage).first().copied();
512    let suggestion = match base {
513        Some(b) => format!("ct tree --base {b}"),
514        None => "ct tree".to_string(),
515    };
516    Some(Steer {
517        rule_id: "list-recursive",
518        tool: "ct tree",
519        suggestion,
520        note: "ct tree reports the file tree with per-file line/word/char counts, filtering and sorting — a richer, bounded ls -R / tree",
521    })
522}
523
524/// `wc -l` over files (not a piped stream) → `ct tree`.
525fn rule_count_lines(stages: &[Vec<String>]) -> Option<Steer> {
526    for (i, s) in stages.iter().enumerate() {
527        if cmd_of(s) != Some("wc") || !has_short(s, 'l') {
528            continue;
529        }
530        // Only when counting files: explicit file args, or fed by `find`/`ls`.
531        let has_files = !positionals(s).is_empty();
532        let from_find = i > 0 && matches!(cmd_of(&stages[i - 1]), Some("find") | Some("ls"));
533        if has_files || from_find {
534            return Some(Steer {
535                rule_id: "count-lines",
536                tool: "ct tree",
537                suggestion: "ct tree --summary".to_string(),
538                note: "ct tree reports per-file and total line/word/char counts directly, replacing wc -l over a file set",
539            });
540        }
541    }
542    None
543}
544
545// ----- Extraction helpers ------------------------------------------------------
546
547/// Assemble a `ct search` suggestion from optional base/name/grep parts.
548fn search_suggestion(base: Option<&str>, name: Option<&str>, grep: Option<&str>) -> String {
549    let mut out = String::from("ct search");
550    if let Some(b) = base {
551        out.push_str(&format!(" --base {b}"));
552    }
553    if let Some(n) = name {
554        out.push_str(&format!(" --name {}", q(n)));
555    }
556    match grep {
557        Some(g) => out.push_str(&format!(" --grep {}", q(g))),
558        None => out.push_str(" --grep <pattern>"),
559    }
560    out
561}
562
563/// Build a `ct view` suggestion for a file and optional line range.
564fn view_steer(file: Option<&str>, range: Option<(u32, u32)>) -> Steer {
565    let f = file.unwrap_or("<file>");
566    let suggestion = match range {
567        Some((a, b)) => format!("ct view {f} --range {a}:{b}"),
568        None => format!("ct view {f} --range <start>:<end>"),
569    };
570    Steer {
571        rule_id: "read-range",
572        tool: "ct view",
573        suggestion,
574        note: "ct view shows a file's lines by range (or the regions around a pattern with context) — a precise, bounded read",
575    }
576}
577
578/// The PATTERN of a grep-family stage: an explicit `-e VALUE`, else the first
579/// bare word *after the grep token* (which may follow `xargs`, `-exec`, …, so
580/// keying off the stage's own command word would pick up the wrong thing).
581fn grep_pattern(stage: &[String]) -> Option<&str> {
582    if let Some(v) = flag_value(stage, &["-e", "--regexp"]) {
583        return Some(v);
584    }
585    let start = stage
586        .iter()
587        .position(|w| matches!(base_name(w), "grep" | "egrep" | "fgrep" | "rg" | "ripgrep" | "ag"))
588        .map_or(1, |i| i + 1);
589    stage[start..]
590        .iter()
591        .find(|w| !w.starts_with('-'))
592        .map(String::as_str)
593}
594
595/// Parse `s/FIND/REPLACE/flags` (any single-char delimiter) → (find, replace).
596fn sed_subst(stage: &[String]) -> (Option<&str>, Option<&str>) {
597    for w in stage.iter().skip(1) {
598        if let Some(rest) = w.strip_prefix('s')
599            && let Some(delim) = rest.chars().next()
600            && !delim.is_alphanumeric()
601        {
602            let parts: Vec<&str> = rest[delim.len_utf8()..].split(delim).collect();
603            if parts.len() >= 2 {
604                return (Some(parts[0]), Some(parts[1]));
605            }
606        }
607    }
608    (None, None)
609}
610
611/// The N from a `head`/`tail` count flag: `-n N`, `-nN`, or `-N`.
612fn head_count(stage: &[String]) -> Option<u32> {
613    if let Some(v) = flag_value(stage, &["-n", "--lines"])
614        && let Ok(n) = v.parse::<u32>()
615    {
616        return Some(n);
617    }
618    stage
619        .iter()
620        .skip(1)
621        .find_map(|w| w.strip_prefix('-').and_then(|d| d.parse::<u32>().ok()))
622}
623
624/// Whether a word is a `sed` script (`A,Bp`, `Np`, or an `s<delim>…` subst)
625/// rather than a file. Deliberately narrow so filenames like `src/lib.rs`
626/// (which begin with `s`) are not misread as scripts.
627fn is_sed_script(w: &str) -> bool {
628    if parse_sed_range(w).is_some() {
629        return true;
630    }
631    let mut ch = w.chars();
632    ch.next() == Some('s') && ch.next().is_some_and(|d| !d.is_alphanumeric())
633}
634
635/// Parse a `sed -n` line range like `10,20p` or `10p` → `(start, end)`.
636fn parse_sed_range(w: &str) -> Option<(u32, u32)> {
637    let body = w.strip_suffix('p').unwrap_or(w);
638    match body.split_once(',') {
639        Some((a, b)) => Some((a.parse().ok()?, b.parse().ok()?)),
640        None => {
641            let n = body.parse().ok()?;
642            Some((n, n))
643        }
644    }
645}
646
647// ----- Hook protocol -----------------------------------------------------------
648
649/// The Claude Code `PreToolUse` hook protocol: turn a stdin envelope into a
650/// steering decision.
651pub mod hook {
652    use super::{Mode, Steer, analyze};
653    use serde_json::{Value, json};
654
655    /// Build the `PreToolUse` decision JSON for a [`Steer`] under `mode`.
656    pub fn decision(steer: &Steer, mode: Mode) -> Value {
657        let reason = steer.reason();
658        match mode {
659            Mode::Deny => json!({"hookSpecificOutput": {
660                "hookEventName": "PreToolUse",
661                "permissionDecision": "deny",
662                "permissionDecisionReason": reason,
663            }}),
664            Mode::Ask => json!({"hookSpecificOutput": {
665                "hookEventName": "PreToolUse",
666                "permissionDecision": "ask",
667                "permissionDecisionReason": reason,
668            }}),
669            Mode::Warn => json!({"hookSpecificOutput": {
670                "hookEventName": "PreToolUse",
671                "additionalContext": reason,
672            }}),
673        }
674    }
675
676    /// Process a raw `PreToolUse` stdin envelope. Returns the decision JSON to
677    /// print, or [`None`] to allow silently. **Fail-open:** any parse error, a
678    /// non-`Bash` tool, or a missing command all yield [`None`].
679    pub fn process(envelope: &str, mode: Mode) -> Option<Value> {
680        let v: Value = serde_json::from_str(envelope).ok()?;
681        if v.get("tool_name").and_then(Value::as_str) != Some("Bash") {
682            return None;
683        }
684        let command = v
685            .get("tool_input")
686            .and_then(|t| t.get("command"))
687            .and_then(Value::as_str)?;
688        let steer = analyze(command)?;
689        Some(decision(&steer, mode))
690    }
691}
692
693// ----- Settings install --------------------------------------------------------
694
695/// Merging the steering hook into a Claude Code settings file. The merge runs
696/// through the comment- and layout-preserving `ct-patch` engine
697/// ([`crate::patch`]): the existing file is parsed only to *decide* which edits
698/// to make, and those edits are byte-range splices against the original text,
699/// so the user's comments and formatting survive.
700pub mod install {
701    use super::Mode;
702    use crate::patch::{self, Op, parse_path};
703    use serde_json::{Value, json};
704    use std::path::{Path, PathBuf};
705
706    /// Which settings file the hook is written to.
707    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
708    pub enum Scope {
709        /// `.claude/settings.json` (shared, committed).
710        Project,
711        /// `.claude/settings.local.json` (personal, gitignored).
712        Local,
713        /// `~/.claude/settings.json` (all projects).
714        User,
715    }
716
717    impl Scope {
718        /// Parse the `--scope` value.
719        pub fn from_name(s: &str) -> Option<Scope> {
720            match s {
721                "project" => Some(Scope::Project),
722                "local" => Some(Scope::Local),
723                "user" => Some(Scope::User),
724                _ => None,
725            }
726        }
727
728        /// The settings file path. `project`/`local` are relative to `root`
729        /// (the project directory); `user` lives under `home`.
730        pub fn path(self, root: &Path, home: &Path) -> PathBuf {
731            match self {
732                Scope::Project => root.join(".claude").join("settings.json"),
733                Scope::Local => root.join(".claude").join("settings.local.json"),
734                Scope::User => home.join(".claude").join("settings.json"),
735            }
736        }
737    }
738
739    /// The hook command string written into settings for `mode`.
740    pub fn hook_command(mode: Mode) -> String {
741        match mode {
742            Mode::Deny => "ct steer hook".to_string(),
743            other => format!("ct steer hook --mode {}", other.name()),
744        }
745    }
746
747    /// Whether a settings hook command is one of ours (any mode).
748    fn is_steer_command(s: &str) -> bool {
749        s.contains("steer") && s.contains("hook")
750    }
751
752    /// Parse existing settings text (JSONC tolerated) for read-only inspection.
753    /// The actual mutation is a byte-splice on the original text via `ct-patch`,
754    /// so this serde view is only used to *decide* which edits to make.
755    fn inspect(text: &str) -> Result<Value, String> {
756        let root = jsonc_parser::parse_to_serde_value(text, &jsonc_parser::ParseOptions::default())
757            .map_err(|e| format!("parse settings: {e}"))?
758            .unwrap_or_else(|| json!({}));
759        if !root.is_object() {
760            return Err("settings root must be a JSON object".to_string());
761        }
762        Ok(root)
763    }
764
765    /// The canonical full settings document, used only when there is no existing
766    /// file to merge into (so there are no comments or layout to preserve).
767    fn canonical(command: &str) -> String {
768        let v = json!({
769            "hooks": { "PreToolUse": [
770                { "matcher": "Bash", "hooks": [ { "type": "command", "command": command } ] }
771            ] }
772        });
773        serde_json::to_string_pretty(&v).unwrap() + "\n"
774    }
775
776    fn op_set(path: &str, value: String) -> Result<Op, String> {
777        Ok(Op::Set {
778            path: parse_path(path)?,
779            raw: path.to_string(),
780            value,
781        })
782    }
783    fn op_add(path: &str, value: String) -> Result<Op, String> {
784        Ok(Op::Add {
785            path: parse_path(path)?,
786            raw: path.to_string(),
787            value,
788        })
789    }
790    fn op_delete(path: &str) -> Result<Op, String> {
791        Ok(Op::Delete {
792            path: parse_path(path)?,
793            raw: path.to_string(),
794        })
795    }
796
797    /// Apply a computed op sequence to the original text via the comment- and
798    /// layout-preserving `ct-patch` engine.
799    fn apply(text: &str, ops: &[Op]) -> Result<(String, bool), String> {
800        if ops.is_empty() {
801            return Ok((text.to_string(), false));
802        }
803        let (out, changes) =
804            patch::apply_doc(text, ops).map_err(|e| format!("settings merge: {e}"))?;
805        Ok((out, changes > 0))
806    }
807
808    /// Install the steering hook into `existing` settings text (or create a
809    /// fresh document). Returns the new text and whether it changed. Idempotent:
810    /// re-installing the same command is a no-op; a `--mode` change rewrites the
811    /// command in place. Comments and layout in `existing` are preserved.
812    pub fn install(existing: Option<&str>, command: &str) -> Result<(String, bool), String> {
813        let Some(text) = existing.filter(|t| !t.trim().is_empty()) else {
814            return Ok((canonical(command), true));
815        };
816        let root = inspect(text)?;
817        let ops = install_ops(&root, command)?;
818        apply(text, &ops)
819    }
820
821    /// Remove every steering hook from `existing` settings text, pruning emptied
822    /// matcher entries (and the `PreToolUse`/`hooks` containers when they end up
823    /// empty). Comments and layout elsewhere are preserved.
824    pub fn uninstall(existing: Option<&str>) -> Result<(String, bool), String> {
825        let Some(text) = existing.filter(|t| !t.trim().is_empty()) else {
826            return Ok((existing.unwrap_or_default().to_string(), false));
827        };
828        let root = inspect(text)?;
829        let ops = uninstall_ops(&root)?;
830        apply(text, &ops)
831    }
832
833    /// The first `hooks.PreToolUse` hook whose command is one of ours, as
834    /// `(entry_index, hook_index, command)`.
835    fn find_steer_hook(root: &Value) -> Option<(usize, usize, &str)> {
836        let pre = pre_array(root)?;
837        for (ei, entry) in pre.iter().enumerate() {
838            if let Some(list) = entry.get("hooks").and_then(Value::as_array) {
839                for (hi, h) in list.iter().enumerate() {
840                    if let Some(c) = h.get("command").and_then(Value::as_str)
841                        && is_steer_command(c)
842                    {
843                        return Some((ei, hi, c));
844                    }
845                }
846            }
847        }
848        None
849    }
850
851    /// The `hooks.PreToolUse` array, if present and well-shaped.
852    fn pre_array(root: &Value) -> Option<&Vec<Value>> {
853        root.get("hooks")
854            .and_then(|h| h.get("PreToolUse"))
855            .and_then(Value::as_array)
856    }
857
858    /// Compute the ops that install `command`, given the parsed `root`.
859    fn install_ops(root: &Value, command: &str) -> Result<Vec<Op>, String> {
860        // Already present? Keep it, or rewrite the command for a mode change.
861        if let Some((ei, hi, existing_cmd)) = find_steer_hook(root) {
862            if existing_cmd == command {
863                return Ok(vec![]);
864            }
865            let path = format!(".hooks.PreToolUse[{ei}].hooks[{hi}].command");
866            return Ok(vec![op_set(&path, json!(command).to_string())?]);
867        }
868
869        let mut ops = Vec::new();
870        let hooks = root.get("hooks");
871        match hooks {
872            None => ops.push(op_set(".hooks", "{}".to_string())?),
873            Some(h) if !h.is_object() => return Err("settings `hooks` must be an object".to_string()),
874            Some(_) => {}
875        }
876        let pre = hooks.and_then(|h| h.get("PreToolUse"));
877        match pre {
878            None => ops.push(op_set(".hooks.PreToolUse", "[]".to_string())?),
879            Some(p) if !p.is_array() => {
880                return Err("settings `hooks.PreToolUse` must be an array".to_string());
881            }
882            Some(_) => {}
883        }
884
885        let hook_obj = json!({ "type": "command", "command": command }).to_string();
886        // Append to an existing Bash matcher (with a hooks array), else add a
887        // matcher. Indices come from the original parse and stay valid because
888        // the container-creating ops above only add keys, never reorder entries.
889        let bash = pre
890            .and_then(Value::as_array)
891            .and_then(|arr| arr.iter().position(is_bash_matcher).map(|i| (i, &arr[i])));
892        match bash {
893            Some((ei, entry)) if entry.get("hooks").and_then(Value::as_array).is_some() => {
894                ops.push(op_add(&format!(".hooks.PreToolUse[{ei}].hooks"), hook_obj)?);
895            }
896            Some((ei, _)) => {
897                ops.push(op_set(
898                    &format!(".hooks.PreToolUse[{ei}].hooks"),
899                    format!("[{hook_obj}]"),
900                )?);
901            }
902            None => {
903                let matcher =
904                    json!({ "matcher": "Bash", "hooks": [{ "type": "command", "command": command }] })
905                        .to_string();
906                ops.push(op_add(".hooks.PreToolUse", matcher)?);
907            }
908        }
909        Ok(ops)
910    }
911
912    /// Whether an array element is a `"matcher": "Bash"` entry.
913    fn is_bash_matcher(entry: &Value) -> bool {
914        entry.get("matcher").and_then(Value::as_str) == Some("Bash")
915    }
916
917    /// Compute the ops that remove every steering hook, given the parsed `root`.
918    fn uninstall_ops(root: &Value) -> Result<Vec<Op>, String> {
919        let Some(pre) = pre_array(root) else {
920            return Ok(vec![]);
921        };
922        // Per matcher entry: which of its hooks are ours, and whether removing
923        // them empties the entry.
924        let mut whole_entries = Vec::new(); // entry indices to delete outright
925        let mut partial = Vec::new(); // (entry index, our hook indices)
926        for (ei, entry) in pre.iter().enumerate() {
927            let Some(list) = entry.get("hooks").and_then(Value::as_array) else {
928                continue;
929            };
930            let ours: Vec<usize> = list
931                .iter()
932                .enumerate()
933                .filter(|(_, h)| {
934                    h.get("command")
935                        .and_then(Value::as_str)
936                        .is_some_and(is_steer_command)
937                })
938                .map(|(hi, _)| hi)
939                .collect();
940            if ours.is_empty() {
941                continue;
942            }
943            if ours.len() == list.len() {
944                whole_entries.push(ei);
945            } else {
946                partial.push((ei, ours));
947            }
948        }
949        if whole_entries.is_empty() && partial.is_empty() {
950            return Ok(vec![]);
951        }
952
953        // Every entry removed outright and none surviving → the whole
954        // PreToolUse goes (or `hooks` itself if PreToolUse was its only key).
955        if partial.is_empty() && whole_entries.len() == pre.len() {
956            let hooks_solo = root
957                .get("hooks")
958                .and_then(Value::as_object)
959                .is_some_and(|o| o.len() == 1);
960            let path = if hooks_solo { ".hooks" } else { ".hooks.PreToolUse" };
961            return Ok(vec![op_delete(path)?]);
962        }
963
964        let mut ops = Vec::new();
965        // Inner-hook deletes first (descending index, so earlier indices stay
966        // valid), then whole-entry deletes (descending, likewise). Inner deletes
967        // never shift entry indices, and partial vs whole entries are disjoint.
968        for (ei, his) in &partial {
969            for hi in his.iter().rev() {
970                ops.push(op_delete(&format!(".hooks.PreToolUse[{ei}].hooks[{hi}]"))?);
971            }
972        }
973        for ei in whole_entries.iter().rev() {
974            ops.push(op_delete(&format!(".hooks.PreToolUse[{ei}]"))?);
975        }
976        Ok(ops)
977    }
978}
979
980#[cfg(test)]
981mod tests {
982    use super::install::{Scope, install, uninstall};
983    use super::*;
984    use std::path::Path;
985
986    fn tool(cmd: &str) -> Option<&'static str> {
987        analyze(cmd).map(|s| s.tool)
988    }
989    fn rule(cmd: &str) -> Option<&'static str> {
990        analyze(cmd).map(|s| s.rule_id)
991    }
992
993    #[test]
994    fn steers_high_confidence_idioms() {
995        assert_eq!(tool("find . -name '*.rs' | xargs grep TODO"), Some("ct search"));
996        assert_eq!(rule("find . -name '*.rs' | xargs grep TODO"), Some("find-grep"));
997        assert_eq!(tool("grep -rn TODO src"), Some("ct search"));
998        assert_eq!(tool("rg TODO src"), Some("ct search"));
999        assert_eq!(tool("find src -name '*.rs'"), Some("ct search"));
1000        assert_eq!(tool("sed -i 's/foo/bar/g' src/x.rs"), Some("ct edit"));
1001        assert_eq!(tool("head -n 40 src/lib.rs"), Some("ct view"));
1002        assert_eq!(tool("cat src/lib.rs | head -n 20"), Some("ct view"));
1003        assert_eq!(tool("sed -n '10,20p' src/lib.rs"), Some("ct view"));
1004        assert_eq!(tool("ls -R src"), Some("ct tree"));
1005        assert_eq!(tool("wc -l src/lib.rs"), Some("ct tree"));
1006        assert_eq!(tool("for f in a b; do grep -r x $f; done"), Some("ct each"));
1007        assert_eq!(rule("for f in a b; do grep -r x $f; done"), Some("shell-loop"));
1008    }
1009
1010    #[test]
1011    fn extracts_obvious_slots() {
1012        let s = analyze("grep -rn TODO src").unwrap();
1013        assert!(s.suggestion.contains("--grep 'TODO'"), "{}", s.suggestion);
1014        let e = analyze("sed -i 's/foo/bar/g' f.rs").unwrap();
1015        assert!(e.suggestion.contains("--find 'foo'") && e.suggestion.contains("--replace 'bar'"), "{}", e.suggestion);
1016        let v = analyze("head -n 40 src/lib.rs").unwrap();
1017        assert!(v.suggestion.contains("src/lib.rs --range 1:40"), "{}", v.suggestion);
1018        // the grep pattern is taken after the `grep` token, not after `xargs`
1019        let fg = analyze("find . -name '*.rs' | xargs grep TODO").unwrap();
1020        assert!(fg.suggestion.contains("--grep 'TODO'"), "{}", fg.suggestion);
1021        assert!(fg.suggestion.contains("--name '*.rs'"), "{}", fg.suggestion);
1022    }
1023
1024    #[test]
1025    fn chain_only_when_all_segments_serviceable() {
1026        let s = analyze("grep -r foo src && sed -i 's/a/b/' f.rs").unwrap();
1027        assert_eq!(s.tool, "ct and");
1028        assert!(s.suggestion.starts_with("ct and search"), "{}", s.suggestion);
1029        assert!(s.suggestion.contains(":::"), "{}", s.suggestion);
1030        // a mixed chain (one non-ct segment) is left alone
1031        assert!(analyze("grep -r foo src && make").is_none());
1032    }
1033
1034    #[test]
1035    fn allows_safe_and_unknown_commands() {
1036        assert!(analyze("git status").is_none());
1037        assert!(analyze("cargo build && cargo test").is_none());
1038        assert!(analyze("ls -la").is_none());
1039        assert!(analyze("cat file.txt").is_none()); // whole-file read, not a range
1040        assert!(analyze("grep TODO file.rs").is_none()); // non-recursive, single file
1041        assert!(analyze("echo 'a | b && c'").is_none()); // operators inside quotes are inert
1042        assert!(analyze("ps aux | head -n 5").is_none()); // piped stream, no file
1043        assert!(analyze("").is_none());
1044    }
1045
1046    #[test]
1047    fn never_resteers_a_ct_command() {
1048        assert!(analyze("ct search --grep TODO").is_none());
1049        assert!(analyze("ct-search --grep TODO").is_none());
1050        assert!(analyze("find . -name '*.rs' | xargs ct-edit").is_none());
1051    }
1052
1053    #[test]
1054    fn hook_decisions_respect_mode() {
1055        let envelope = r#"{"tool_name":"Bash","tool_input":{"command":"grep -r TODO src"}}"#;
1056        let deny = hook::process(envelope, Mode::Deny).unwrap();
1057        assert_eq!(deny["hookSpecificOutput"]["permissionDecision"], "deny");
1058        assert!(
1059            deny["hookSpecificOutput"]["permissionDecisionReason"]
1060                .as_str()
1061                .unwrap()
1062                .contains("ct search")
1063        );
1064        let ask = hook::process(envelope, Mode::Ask).unwrap();
1065        assert_eq!(ask["hookSpecificOutput"]["permissionDecision"], "ask");
1066        let warn = hook::process(envelope, Mode::Warn).unwrap();
1067        assert!(warn["hookSpecificOutput"]["additionalContext"].is_string());
1068        assert!(warn["hookSpecificOutput"].get("permissionDecision").is_none());
1069    }
1070
1071    #[test]
1072    fn hook_fails_open() {
1073        assert!(hook::process("not json", Mode::Deny).is_none());
1074        assert!(hook::process(r#"{"tool_name":"Read"}"#, Mode::Deny).is_none());
1075        assert!(hook::process(r#"{"tool_name":"Bash","tool_input":{}}"#, Mode::Deny).is_none());
1076        assert!(
1077            hook::process(
1078                r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#,
1079                Mode::Deny
1080            )
1081            .is_none()
1082        );
1083    }
1084
1085    #[test]
1086    fn install_is_idempotent_and_preserves_other_settings() {
1087        // fresh install
1088        let (text, changed) = install(None, "ct steer hook").unwrap();
1089        assert!(changed);
1090        assert!(text.contains("PreToolUse"));
1091        assert!(text.contains("\"matcher\": \"Bash\""));
1092        assert!(text.contains("ct steer hook"));
1093        // re-install is a no-op
1094        let (text2, changed2) = install(Some(&text), "ct steer hook").unwrap();
1095        assert!(!changed2);
1096        assert_eq!(text, text2);
1097        // a mode change rewrites in place (still one hook)
1098        let (text3, changed3) = install(Some(&text), "ct steer hook --mode ask").unwrap();
1099        assert!(changed3);
1100        assert_eq!(text3.matches("steer hook").count(), 1);
1101        // existing unrelated settings survive
1102        let existing = r#"{ "model": "opus", "hooks": { "PreToolUse": [] } }"#;
1103        let (merged, _) = install(Some(existing), "ct steer hook").unwrap();
1104        assert!(merged.contains("\"model\": \"opus\""));
1105    }
1106
1107    #[test]
1108    fn uninstall_removes_only_our_hook() {
1109        let existing = r#"{
1110            "hooks": { "PreToolUse": [
1111                { "matcher": "Bash", "hooks": [
1112                    { "type": "command", "command": "ct steer hook" },
1113                    { "type": "command", "command": "./other.sh" }
1114                ] }
1115            ] }
1116        }"#;
1117        let (text, changed) = uninstall(Some(existing)).unwrap();
1118        assert!(changed);
1119        assert!(!text.contains("steer hook"));
1120        assert!(text.contains("./other.sh")); // the unrelated hook stays
1121        // uninstall on a clean file is a no-op
1122        let (_, changed2) = uninstall(Some("{}")).unwrap();
1123        assert!(!changed2);
1124    }
1125
1126    #[test]
1127    fn install_and_uninstall_preserve_comments() {
1128        // a settings.json with comments the user cares about
1129        let existing = "{\n  \
1130            // pin the model\n  \
1131            \"model\": \"opus\", // do not change\n  \
1132            \"hooks\": {\n    \
1133            \"PreToolUse\": [\n      \
1134            { \"matcher\": \"Bash\", \"hooks\": [ { \"type\": \"command\", \"command\": \"./guard.sh\" } ] }\n    \
1135            ]\n  }\n}\n";
1136        let (installed, changed) = install(Some(existing), "ct steer hook").unwrap();
1137        assert!(changed);
1138        // comments survive the merge
1139        assert!(installed.contains("// pin the model"), "{installed}");
1140        assert!(installed.contains("// do not change"), "{installed}");
1141        // the prior hook is untouched and ours is appended to the same matcher
1142        assert!(installed.contains("./guard.sh"), "{installed}");
1143        assert!(installed.contains("ct steer hook"), "{installed}");
1144
1145        // uninstall removes only our hook, keeps the guard and the comments
1146        let (removed, changed2) = uninstall(Some(&installed)).unwrap();
1147        assert!(changed2);
1148        assert!(removed.contains("// pin the model"), "{removed}");
1149        assert!(removed.contains("./guard.sh"), "{removed}");
1150        assert!(!removed.contains("steer hook"), "{removed}");
1151    }
1152
1153    #[test]
1154    fn scope_paths() {
1155        let root = Path::new("/proj");
1156        let home = Path::new("/home/u");
1157        assert!(
1158            Scope::Project
1159                .path(root, home)
1160                .ends_with(".claude/settings.json")
1161        );
1162        assert!(
1163            Scope::Local
1164                .path(root, home)
1165                .ends_with(".claude/settings.local.json")
1166        );
1167        assert!(Scope::User.path(root, home).starts_with("/home/u"));
1168    }
1169}