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
236        .iter()
237        .any(|w| w.starts_with('-') && !w.starts_with("--") && w[1..].chars().any(|c| c == ch))
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
322        .first()
323        .and_then(|s| s.first())
324        .and_then(|s| cmd_of(s))
325        && (first == "for" || first == "while")
326    {
327        return Some(Steer {
328            rule_id: "shell-loop",
329            tool: "ct each",
330            suggestion: "ct each --items <a> <b> -- <cmd-template-with-{ITEM}>".to_string(),
331            note: "ct each runs a command template once per item with no shell and an aggregate --expect verdict",
332        });
333    }
334
335    // A single command (possibly with pipes): the common, high-value case.
336    if segs.len() == 1 {
337        return analyze_segment(&seg_stages[0]);
338    }
339
340    // A chain (`&&` / `||`): only steer when *every* segment is itself
341    // ct-serviceable and the joiners are uniform, so `ct and`/`ct or`
342    // reproduces it faithfully. A mixed chain (e.g. `grep -r x && make`) is
343    // left alone.
344    let matches: Vec<Steer> = seg_stages
345        .iter()
346        .filter_map(|st| analyze_segment(st))
347        .collect();
348    if matches.len() == segs.len() && !joiners.is_empty() {
349        if joiners.iter().all(|j| *j == Tok::And) {
350            return Some(chain_steer("ct and", &matches));
351        }
352        if joiners.iter().all(|j| *j == Tok::Or) {
353            return Some(chain_steer("ct or", &matches));
354        }
355    }
356    None
357}
358
359/// Build the chain suggestion from each segment's own `ct` suggestion, joined
360/// with the suite's shell-less `:::` separator.
361fn chain_steer(head: &'static str, parts: &[Steer]) -> Steer {
362    let body = parts
363        .iter()
364        .map(|p| p.suggestion.trim_start_matches("ct ").to_string())
365        .collect::<Vec<_>>()
366        .join(" ::: ");
367    let (rule_id, note) = if head == "ct and" {
368        (
369            "and-chain",
370            "ct and runs each step in turn, stopping at the first failure — a shell-less && with no quoting",
371        )
372    } else {
373        (
374            "or-chain",
375            "ct or runs each step in turn, stopping at the first success — a shell-less || with no quoting",
376        )
377    };
378    Steer {
379        rule_id,
380        tool: head,
381        suggestion: format!("{head} {body}"),
382        note,
383    }
384}
385
386/// Classify a single control segment (its pipeline stages). Rule order encodes
387/// priority: the most specific idiom wins.
388fn analyze_segment(stages: &[Vec<String>]) -> Option<Steer> {
389    rule_find_grep(stages)
390        .or_else(|| rule_grep_recursive(stages))
391        .or_else(|| rule_sed_inplace(stages))
392        .or_else(|| rule_read_range(stages))
393        .or_else(|| rule_find_files(stages))
394        .or_else(|| rule_list_recursive(stages))
395        .or_else(|| rule_count_lines(stages))
396}
397
398/// `find … | xargs grep` / `find … -exec grep` → `ct search`.
399fn rule_find_grep(stages: &[Vec<String>]) -> Option<Steer> {
400    let find = stages.iter().find(|s| cmd_of(s) == Some("find"))?;
401    // grep appearing anywhere (its own stage, after xargs, or after -exec).
402    let grep_stage = stages
403        .iter()
404        .find(|s| s.iter().any(|w| base_name(w) == "grep"))?;
405    let glob = flag_value(find, &["-name", "-iname"]);
406    let pat = grep_pattern(grep_stage);
407    Some(Steer {
408        rule_id: "find-grep",
409        tool: "ct search",
410        suggestion: search_suggestion(find_base(find), glob, pat),
411        note: "ct search recurses, filters by name/type/size, and greps in one declarative pass — find | xargs grep in a single command",
412    })
413}
414
415/// `grep -r` / `rg` / `ag` → `ct search`.
416fn rule_grep_recursive(stages: &[Vec<String>]) -> Option<Steer> {
417    for s in stages {
418        let Some(cmd) = cmd_of(s) else { continue };
419        let recursive_grep =
420            cmd == "grep" && (has_short(s, 'r') || has_short(s, 'R') || has_flag(s, "--recursive"));
421        if recursive_grep || matches!(cmd, "rg" | "ripgrep" | "ag") {
422            let pat = grep_pattern(s);
423            // `grep -r PAT PATH` / `rg PAT PATH`: the second positional is the path.
424            let base = positionals(s).get(1).copied();
425            return Some(Steer {
426                rule_id: "grep-recursive",
427                tool: "ct search",
428                suggestion: search_suggestion(base, None, pat),
429                note: "ct search is the suite's recursive content search, with a framed --expect verdict (e.g. --expect none asserts absence)",
430            });
431        }
432    }
433    None
434}
435
436/// `find … -name` with no grep → `ct search` (name filter only).
437fn rule_find_files(stages: &[Vec<String>]) -> Option<Steer> {
438    let find = stages.iter().find(|s| cmd_of(s) == Some("find"))?;
439    let glob = flag_value(find, &["-name", "-iname"])?;
440    let base = find_base(find);
441    Some(Steer {
442        rule_id: "find-files",
443        tool: "ct search",
444        suggestion: search_suggestion(base, Some(glob), None),
445        note: "ct search selects files by --name/--type/--size and reports them, replacing a bare find",
446    })
447}
448
449/// `sed -i` / `perl -i` → `ct edit`.
450fn rule_sed_inplace(stages: &[Vec<String>]) -> Option<Steer> {
451    let stage = stages.iter().find(|s| {
452        let cmd = cmd_of(s);
453        let sed_i =
454            cmd == Some("sed") && s.iter().any(|w| w.starts_with("-i") || w == "--in-place");
455        let perl_i = cmd == Some("perl") && s.iter().any(|w| w.starts_with("-i"));
456        sed_i || perl_i
457    })?;
458    let (find, replace) = sed_subst(stage);
459    let suggestion = match (find, replace) {
460        (Some(f), Some(r)) => format!(
461            "ct edit --base . --find {} --replace {} --expect =1 --dry-run",
462            q(f),
463            q(r)
464        ),
465        _ => "ct edit --base . --find <text> --replace <text> --expect =1 --dry-run".to_string(),
466    };
467    Some(Steer {
468        rule_id: "sed-inplace",
469        tool: "ct edit",
470        suggestion,
471        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",
472    })
473}
474
475/// `head`/`tail`/`sed -n 'A,Bp'` on a file → `ct view --range`.
476fn rule_read_range(stages: &[Vec<String>]) -> Option<Steer> {
477    // sed -n 'A,Bp'
478    for s in stages {
479        if cmd_of(s) == Some("sed")
480            && has_flag(s, "-n")
481            && let Some((a, b)) = positionals(s).into_iter().find_map(parse_sed_range)
482        {
483            let file = positionals(s).into_iter().find(|&w| !is_sed_script(w));
484            return Some(view_steer(file, Some((a, b))));
485        }
486    }
487    // head / tail, reading a named file or fed by `cat FILE`.
488    for (i, s) in stages.iter().enumerate() {
489        let cmd = cmd_of(s);
490        if cmd != Some("head") && cmd != Some("tail") {
491            continue;
492        }
493        let n = head_count(s);
494        // The file is head/tail's own positional (not the numeric `-n` value),
495        // or an upstream `cat FILE`.
496        let own = positionals(s)
497            .into_iter()
498            .find(|w| w.parse::<u64>().is_err());
499        let upstream = (i > 0 && cmd_of(&stages[i - 1]) == Some("cat"))
500            .then(|| positionals(&stages[i - 1]).into_iter().next())
501            .flatten();
502        let file = own.or(upstream)?; // no concrete file → not a file read; skip
503        let range = match (cmd, n) {
504            (Some("head"), Some(n)) => Some((1, n)),
505            _ => None, // tail = last-N lines; leave the range to the agent
506        };
507        return Some(view_steer(Some(file), range));
508    }
509    None
510}
511
512/// `ls -R` / `tree` → `ct tree`.
513fn rule_list_recursive(stages: &[Vec<String>]) -> Option<Steer> {
514    let stage = stages
515        .iter()
516        .find(|s| cmd_of(s) == Some("tree") || (cmd_of(s) == Some("ls") && has_short(s, 'R')))?;
517    let base = positionals(stage).first().copied();
518    let suggestion = match base {
519        Some(b) => format!("ct tree --base {b}"),
520        None => "ct tree".to_string(),
521    };
522    Some(Steer {
523        rule_id: "list-recursive",
524        tool: "ct tree",
525        suggestion,
526        note: "ct tree reports the file tree with per-file line/word/char counts, filtering and sorting — a richer, bounded ls -R / tree",
527    })
528}
529
530/// `wc -l` over files (not a piped stream) → `ct tree`.
531fn rule_count_lines(stages: &[Vec<String>]) -> Option<Steer> {
532    for (i, s) in stages.iter().enumerate() {
533        if cmd_of(s) != Some("wc") || !has_short(s, 'l') {
534            continue;
535        }
536        // Only when counting files: explicit file args, or fed by `find`/`ls`.
537        let has_files = !positionals(s).is_empty();
538        let from_find = i > 0 && matches!(cmd_of(&stages[i - 1]), Some("find") | Some("ls"));
539        if has_files || from_find {
540            return Some(Steer {
541                rule_id: "count-lines",
542                tool: "ct tree",
543                suggestion: "ct tree --summary".to_string(),
544                note: "ct tree reports per-file and total line/word/char counts directly, replacing wc -l over a file set",
545            });
546        }
547    }
548    None
549}
550
551// ----- Extraction helpers ------------------------------------------------------
552
553/// Assemble a `ct search` suggestion from optional base/name/grep parts.
554fn search_suggestion(base: Option<&str>, name: Option<&str>, grep: Option<&str>) -> String {
555    let mut out = String::from("ct search");
556    if let Some(b) = base {
557        out.push_str(&format!(" --base {b}"));
558    }
559    if let Some(n) = name {
560        out.push_str(&format!(" --name {}", q(n)));
561    }
562    match grep {
563        Some(g) => out.push_str(&format!(" --grep {}", q(g))),
564        None => out.push_str(" --grep <pattern>"),
565    }
566    out
567}
568
569/// Build a `ct view` suggestion for a file and optional line range.
570fn view_steer(file: Option<&str>, range: Option<(u32, u32)>) -> Steer {
571    let f = file.unwrap_or("<file>");
572    let suggestion = match range {
573        Some((a, b)) => format!("ct view {f} --range {a}:{b}"),
574        None => format!("ct view {f} --range <start>:<end>"),
575    };
576    Steer {
577        rule_id: "read-range",
578        tool: "ct view",
579        suggestion,
580        note: "ct view shows a file's lines by range (or the regions around a pattern with context) — a precise, bounded read",
581    }
582}
583
584/// The PATTERN of a grep-family stage: an explicit `-e VALUE`, else the first
585/// bare word *after the grep token* (which may follow `xargs`, `-exec`, …, so
586/// keying off the stage's own command word would pick up the wrong thing).
587fn grep_pattern(stage: &[String]) -> Option<&str> {
588    if let Some(v) = flag_value(stage, &["-e", "--regexp"]) {
589        return Some(v);
590    }
591    let start = stage
592        .iter()
593        .position(|w| {
594            matches!(
595                base_name(w),
596                "grep" | "egrep" | "fgrep" | "rg" | "ripgrep" | "ag"
597            )
598        })
599        .map_or(1, |i| i + 1);
600    stage[start..]
601        .iter()
602        .find(|w| !w.starts_with('-'))
603        .map(String::as_str)
604}
605
606/// Parse `s/FIND/REPLACE/flags` (any single-char delimiter) → (find, replace).
607fn sed_subst(stage: &[String]) -> (Option<&str>, Option<&str>) {
608    for w in stage.iter().skip(1) {
609        if let Some(rest) = w.strip_prefix('s')
610            && let Some(delim) = rest.chars().next()
611            && !delim.is_alphanumeric()
612        {
613            let parts: Vec<&str> = rest[delim.len_utf8()..].split(delim).collect();
614            if parts.len() >= 2 {
615                return (Some(parts[0]), Some(parts[1]));
616            }
617        }
618    }
619    (None, None)
620}
621
622/// The N from a `head`/`tail` count flag: `-n N`, `-nN`, or `-N`.
623fn head_count(stage: &[String]) -> Option<u32> {
624    if let Some(v) = flag_value(stage, &["-n", "--lines"])
625        && let Ok(n) = v.parse::<u32>()
626    {
627        return Some(n);
628    }
629    stage
630        .iter()
631        .skip(1)
632        .find_map(|w| w.strip_prefix('-').and_then(|d| d.parse::<u32>().ok()))
633}
634
635/// Whether a word is a `sed` script (`A,Bp`, `Np`, or an `s<delim>…` subst)
636/// rather than a file. Deliberately narrow so filenames like `src/lib.rs`
637/// (which begin with `s`) are not misread as scripts.
638fn is_sed_script(w: &str) -> bool {
639    if parse_sed_range(w).is_some() {
640        return true;
641    }
642    let mut ch = w.chars();
643    ch.next() == Some('s') && ch.next().is_some_and(|d| !d.is_alphanumeric())
644}
645
646/// Parse a `sed -n` line range like `10,20p` or `10p` → `(start, end)`.
647fn parse_sed_range(w: &str) -> Option<(u32, u32)> {
648    let body = w.strip_suffix('p').unwrap_or(w);
649    match body.split_once(',') {
650        Some((a, b)) => Some((a.parse().ok()?, b.parse().ok()?)),
651        None => {
652            let n = body.parse().ok()?;
653            Some((n, n))
654        }
655    }
656}
657
658// ----- Hook protocol -----------------------------------------------------------
659
660/// The Claude Code `PreToolUse` hook protocol: turn a stdin envelope into a
661/// steering decision.
662pub mod hook {
663    use super::{Mode, Steer, analyze};
664    use serde_json::{Value, json};
665
666    /// Build the `PreToolUse` decision JSON for a [`Steer`] under `mode`.
667    pub fn decision(steer: &Steer, mode: Mode) -> Value {
668        let reason = steer.reason();
669        match mode {
670            Mode::Deny => json!({"hookSpecificOutput": {
671                "hookEventName": "PreToolUse",
672                "permissionDecision": "deny",
673                "permissionDecisionReason": reason,
674            }}),
675            Mode::Ask => json!({"hookSpecificOutput": {
676                "hookEventName": "PreToolUse",
677                "permissionDecision": "ask",
678                "permissionDecisionReason": reason,
679            }}),
680            Mode::Warn => json!({"hookSpecificOutput": {
681                "hookEventName": "PreToolUse",
682                "additionalContext": reason,
683            }}),
684        }
685    }
686
687    /// Process a raw `PreToolUse` stdin envelope. Returns the decision JSON to
688    /// print, or [`None`] to allow silently. **Fail-open:** any parse error, a
689    /// non-`Bash` tool, or a missing command all yield [`None`].
690    pub fn process(envelope: &str, mode: Mode) -> Option<Value> {
691        let v: Value = serde_json::from_str(envelope).ok()?;
692        if v.get("tool_name").and_then(Value::as_str) != Some("Bash") {
693            return None;
694        }
695        let command = v
696            .get("tool_input")
697            .and_then(|t| t.get("command"))
698            .and_then(Value::as_str)?;
699        let steer = analyze(command)?;
700        Some(decision(&steer, mode))
701    }
702}
703
704// ----- Settings install --------------------------------------------------------
705
706/// Merging the steering hook into a Claude Code settings file. The merge runs
707/// through the comment- and layout-preserving `ct-patch` engine
708/// ([`crate::patch`]): the existing file is parsed only to *decide* which edits
709/// to make, and those edits are byte-range splices against the original text,
710/// so the user's comments and formatting survive.
711pub mod install {
712    use super::Mode;
713    use crate::patch::{self, Op, parse_path};
714    use serde_json::{Value, json};
715    use std::path::{Path, PathBuf};
716
717    /// Which settings file the hook is written to.
718    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
719    pub enum Scope {
720        /// `.claude/settings.json` (shared, committed).
721        Project,
722        /// `.claude/settings.local.json` (personal, gitignored).
723        Local,
724        /// `~/.claude/settings.json` (all projects).
725        User,
726    }
727
728    impl Scope {
729        /// Parse the `--scope` value.
730        pub fn from_name(s: &str) -> Option<Scope> {
731            match s {
732                "project" => Some(Scope::Project),
733                "local" => Some(Scope::Local),
734                "user" => Some(Scope::User),
735                _ => None,
736            }
737        }
738
739        /// The settings file path. `project`/`local` are relative to `root`
740        /// (the project directory); `user` lives under `home`.
741        pub fn path(self, root: &Path, home: &Path) -> PathBuf {
742            match self {
743                Scope::Project => root.join(".claude").join("settings.json"),
744                Scope::Local => root.join(".claude").join("settings.local.json"),
745                Scope::User => home.join(".claude").join("settings.json"),
746            }
747        }
748    }
749
750    /// The hook command string written into settings for `mode`.
751    pub fn hook_command(mode: Mode) -> String {
752        match mode {
753            Mode::Deny => "ct steer hook".to_string(),
754            other => format!("ct steer hook --mode {}", other.name()),
755        }
756    }
757
758    /// Whether a settings hook command is one of ours (any mode).
759    fn is_steer_command(s: &str) -> bool {
760        s.contains("steer") && s.contains("hook")
761    }
762
763    /// Parse existing settings text (JSONC tolerated) for read-only inspection.
764    /// The actual mutation is a byte-splice on the original text via `ct-patch`,
765    /// so this serde view is only used to *decide* which edits to make.
766    fn inspect(text: &str) -> Result<Value, String> {
767        let root = jsonc_parser::parse_to_serde_value(text, &jsonc_parser::ParseOptions::default())
768            .map_err(|e| format!("parse settings: {e}"))?
769            .unwrap_or_else(|| json!({}));
770        if !root.is_object() {
771            return Err("settings root must be a JSON object".to_string());
772        }
773        Ok(root)
774    }
775
776    /// The canonical full settings document, used only when there is no existing
777    /// file to merge into (so there are no comments or layout to preserve).
778    fn canonical(command: &str) -> String {
779        let v = json!({
780            "hooks": { "PreToolUse": [
781                { "matcher": "Bash", "hooks": [ { "type": "command", "command": command } ] }
782            ] }
783        });
784        serde_json::to_string_pretty(&v).unwrap() + "\n"
785    }
786
787    fn op_set(path: &str, value: String) -> Result<Op, String> {
788        Ok(Op::Set {
789            path: parse_path(path)?,
790            raw: path.to_string(),
791            value,
792        })
793    }
794    fn op_add(path: &str, value: String) -> Result<Op, String> {
795        Ok(Op::Add {
796            path: parse_path(path)?,
797            raw: path.to_string(),
798            value,
799        })
800    }
801    fn op_delete(path: &str) -> Result<Op, String> {
802        Ok(Op::Delete {
803            path: parse_path(path)?,
804            raw: path.to_string(),
805        })
806    }
807
808    /// Apply a computed op sequence to the original text via the comment- and
809    /// layout-preserving `ct-patch` engine.
810    fn apply(text: &str, ops: &[Op]) -> Result<(String, bool), String> {
811        if ops.is_empty() {
812            return Ok((text.to_string(), false));
813        }
814        let (out, changes) =
815            patch::apply_doc(text, ops).map_err(|e| format!("settings merge: {e}"))?;
816        Ok((out, changes > 0))
817    }
818
819    /// Install the steering hook into `existing` settings text (or create a
820    /// fresh document). Returns the new text and whether it changed. Idempotent:
821    /// re-installing the same command is a no-op; a `--mode` change rewrites the
822    /// command in place. Comments and layout in `existing` are preserved.
823    pub fn install(existing: Option<&str>, command: &str) -> Result<(String, bool), String> {
824        let Some(text) = existing.filter(|t| !t.trim().is_empty()) else {
825            return Ok((canonical(command), true));
826        };
827        let root = inspect(text)?;
828        let ops = install_ops(&root, command)?;
829        apply(text, &ops)
830    }
831
832    /// Remove every steering hook from `existing` settings text, pruning emptied
833    /// matcher entries (and the `PreToolUse`/`hooks` containers when they end up
834    /// empty). Comments and layout elsewhere are preserved.
835    pub fn uninstall(existing: Option<&str>) -> Result<(String, bool), String> {
836        let Some(text) = existing.filter(|t| !t.trim().is_empty()) else {
837            return Ok((existing.unwrap_or_default().to_string(), false));
838        };
839        let root = inspect(text)?;
840        let ops = uninstall_ops(&root)?;
841        apply(text, &ops)
842    }
843
844    /// The first `hooks.PreToolUse` hook whose command is one of ours, as
845    /// `(entry_index, hook_index, command)`.
846    fn find_steer_hook(root: &Value) -> Option<(usize, usize, &str)> {
847        let pre = pre_array(root)?;
848        for (ei, entry) in pre.iter().enumerate() {
849            if let Some(list) = entry.get("hooks").and_then(Value::as_array) {
850                for (hi, h) in list.iter().enumerate() {
851                    if let Some(c) = h.get("command").and_then(Value::as_str)
852                        && is_steer_command(c)
853                    {
854                        return Some((ei, hi, c));
855                    }
856                }
857            }
858        }
859        None
860    }
861
862    /// The `hooks.PreToolUse` array, if present and well-shaped.
863    fn pre_array(root: &Value) -> Option<&Vec<Value>> {
864        root.get("hooks")
865            .and_then(|h| h.get("PreToolUse"))
866            .and_then(Value::as_array)
867    }
868
869    /// Compute the ops that install `command`, given the parsed `root`.
870    fn install_ops(root: &Value, command: &str) -> Result<Vec<Op>, String> {
871        // Already present? Keep it, or rewrite the command for a mode change.
872        if let Some((ei, hi, existing_cmd)) = find_steer_hook(root) {
873            if existing_cmd == command {
874                return Ok(vec![]);
875            }
876            let path = format!(".hooks.PreToolUse[{ei}].hooks[{hi}].command");
877            return Ok(vec![op_set(&path, json!(command).to_string())?]);
878        }
879
880        let mut ops = Vec::new();
881        let hooks = root.get("hooks");
882        match hooks {
883            None => ops.push(op_set(".hooks", "{}".to_string())?),
884            Some(h) if !h.is_object() => {
885                return Err("settings `hooks` must be an object".to_string());
886            }
887            Some(_) => {}
888        }
889        let pre = hooks.and_then(|h| h.get("PreToolUse"));
890        match pre {
891            None => ops.push(op_set(".hooks.PreToolUse", "[]".to_string())?),
892            Some(p) if !p.is_array() => {
893                return Err("settings `hooks.PreToolUse` must be an array".to_string());
894            }
895            Some(_) => {}
896        }
897
898        let hook_obj = json!({ "type": "command", "command": command }).to_string();
899        // Append to an existing Bash matcher (with a hooks array), else add a
900        // matcher. Indices come from the original parse and stay valid because
901        // the container-creating ops above only add keys, never reorder entries.
902        let bash = pre
903            .and_then(Value::as_array)
904            .and_then(|arr| arr.iter().position(is_bash_matcher).map(|i| (i, &arr[i])));
905        match bash {
906            Some((ei, entry)) if entry.get("hooks").and_then(Value::as_array).is_some() => {
907                ops.push(op_add(&format!(".hooks.PreToolUse[{ei}].hooks"), hook_obj)?);
908            }
909            Some((ei, _)) => {
910                ops.push(op_set(
911                    &format!(".hooks.PreToolUse[{ei}].hooks"),
912                    format!("[{hook_obj}]"),
913                )?);
914            }
915            None => {
916                let matcher =
917                    json!({ "matcher": "Bash", "hooks": [{ "type": "command", "command": command }] })
918                        .to_string();
919                ops.push(op_add(".hooks.PreToolUse", matcher)?);
920            }
921        }
922        Ok(ops)
923    }
924
925    /// Whether an array element is a `"matcher": "Bash"` entry.
926    fn is_bash_matcher(entry: &Value) -> bool {
927        entry.get("matcher").and_then(Value::as_str) == Some("Bash")
928    }
929
930    /// Compute the ops that remove every steering hook, given the parsed `root`.
931    fn uninstall_ops(root: &Value) -> Result<Vec<Op>, String> {
932        let Some(pre) = pre_array(root) else {
933            return Ok(vec![]);
934        };
935        // Per matcher entry: which of its hooks are ours, and whether removing
936        // them empties the entry.
937        let mut whole_entries = Vec::new(); // entry indices to delete outright
938        let mut partial = Vec::new(); // (entry index, our hook indices)
939        for (ei, entry) in pre.iter().enumerate() {
940            let Some(list) = entry.get("hooks").and_then(Value::as_array) else {
941                continue;
942            };
943            let ours: Vec<usize> = list
944                .iter()
945                .enumerate()
946                .filter(|(_, h)| {
947                    h.get("command")
948                        .and_then(Value::as_str)
949                        .is_some_and(is_steer_command)
950                })
951                .map(|(hi, _)| hi)
952                .collect();
953            if ours.is_empty() {
954                continue;
955            }
956            if ours.len() == list.len() {
957                whole_entries.push(ei);
958            } else {
959                partial.push((ei, ours));
960            }
961        }
962        if whole_entries.is_empty() && partial.is_empty() {
963            return Ok(vec![]);
964        }
965
966        // Every entry removed outright and none surviving → the whole
967        // PreToolUse goes (or `hooks` itself if PreToolUse was its only key).
968        if partial.is_empty() && whole_entries.len() == pre.len() {
969            let hooks_solo = root
970                .get("hooks")
971                .and_then(Value::as_object)
972                .is_some_and(|o| o.len() == 1);
973            let path = if hooks_solo {
974                ".hooks"
975            } else {
976                ".hooks.PreToolUse"
977            };
978            return Ok(vec![op_delete(path)?]);
979        }
980
981        let mut ops = Vec::new();
982        // Inner-hook deletes first (descending index, so earlier indices stay
983        // valid), then whole-entry deletes (descending, likewise). Inner deletes
984        // never shift entry indices, and partial vs whole entries are disjoint.
985        for (ei, his) in &partial {
986            for hi in his.iter().rev() {
987                ops.push(op_delete(&format!(".hooks.PreToolUse[{ei}].hooks[{hi}]"))?);
988            }
989        }
990        for ei in whole_entries.iter().rev() {
991            ops.push(op_delete(&format!(".hooks.PreToolUse[{ei}]"))?);
992        }
993        Ok(ops)
994    }
995}
996
997#[cfg(test)]
998mod tests {
999    use super::install::{Scope, install, uninstall};
1000    use super::*;
1001    use std::path::Path;
1002
1003    fn tool(cmd: &str) -> Option<&'static str> {
1004        analyze(cmd).map(|s| s.tool)
1005    }
1006    fn rule(cmd: &str) -> Option<&'static str> {
1007        analyze(cmd).map(|s| s.rule_id)
1008    }
1009
1010    #[test]
1011    fn steers_high_confidence_idioms() {
1012        assert_eq!(
1013            tool("find . -name '*.rs' | xargs grep TODO"),
1014            Some("ct search")
1015        );
1016        assert_eq!(
1017            rule("find . -name '*.rs' | xargs grep TODO"),
1018            Some("find-grep")
1019        );
1020        assert_eq!(tool("grep -rn TODO src"), Some("ct search"));
1021        assert_eq!(tool("rg TODO src"), Some("ct search"));
1022        assert_eq!(tool("find src -name '*.rs'"), Some("ct search"));
1023        assert_eq!(tool("sed -i 's/foo/bar/g' src/x.rs"), Some("ct edit"));
1024        assert_eq!(tool("head -n 40 src/lib.rs"), Some("ct view"));
1025        assert_eq!(tool("cat src/lib.rs | head -n 20"), Some("ct view"));
1026        assert_eq!(tool("sed -n '10,20p' src/lib.rs"), Some("ct view"));
1027        assert_eq!(tool("ls -R src"), Some("ct tree"));
1028        assert_eq!(tool("wc -l src/lib.rs"), Some("ct tree"));
1029        assert_eq!(tool("for f in a b; do grep -r x $f; done"), Some("ct each"));
1030        assert_eq!(
1031            rule("for f in a b; do grep -r x $f; done"),
1032            Some("shell-loop")
1033        );
1034    }
1035
1036    #[test]
1037    fn extracts_obvious_slots() {
1038        let s = analyze("grep -rn TODO src").unwrap();
1039        assert!(s.suggestion.contains("--grep 'TODO'"), "{}", s.suggestion);
1040        let e = analyze("sed -i 's/foo/bar/g' f.rs").unwrap();
1041        assert!(
1042            e.suggestion.contains("--find 'foo'") && e.suggestion.contains("--replace 'bar'"),
1043            "{}",
1044            e.suggestion
1045        );
1046        let v = analyze("head -n 40 src/lib.rs").unwrap();
1047        assert!(
1048            v.suggestion.contains("src/lib.rs --range 1:40"),
1049            "{}",
1050            v.suggestion
1051        );
1052        // the grep pattern is taken after the `grep` token, not after `xargs`
1053        let fg = analyze("find . -name '*.rs' | xargs grep TODO").unwrap();
1054        assert!(fg.suggestion.contains("--grep 'TODO'"), "{}", fg.suggestion);
1055        assert!(fg.suggestion.contains("--name '*.rs'"), "{}", fg.suggestion);
1056    }
1057
1058    #[test]
1059    fn chain_only_when_all_segments_serviceable() {
1060        let s = analyze("grep -r foo src && sed -i 's/a/b/' f.rs").unwrap();
1061        assert_eq!(s.tool, "ct and");
1062        assert!(
1063            s.suggestion.starts_with("ct and search"),
1064            "{}",
1065            s.suggestion
1066        );
1067        assert!(s.suggestion.contains(":::"), "{}", s.suggestion);
1068        // a mixed chain (one non-ct segment) is left alone
1069        assert!(analyze("grep -r foo src && make").is_none());
1070    }
1071
1072    #[test]
1073    fn allows_safe_and_unknown_commands() {
1074        assert!(analyze("git status").is_none());
1075        assert!(analyze("cargo build && cargo test").is_none());
1076        assert!(analyze("ls -la").is_none());
1077        assert!(analyze("cat file.txt").is_none()); // whole-file read, not a range
1078        assert!(analyze("grep TODO file.rs").is_none()); // non-recursive, single file
1079        assert!(analyze("echo 'a | b && c'").is_none()); // operators inside quotes are inert
1080        assert!(analyze("ps aux | head -n 5").is_none()); // piped stream, no file
1081        assert!(analyze("").is_none());
1082    }
1083
1084    #[test]
1085    fn never_resteers_a_ct_command() {
1086        assert!(analyze("ct search --grep TODO").is_none());
1087        assert!(analyze("ct-search --grep TODO").is_none());
1088        assert!(analyze("find . -name '*.rs' | xargs ct-edit").is_none());
1089    }
1090
1091    #[test]
1092    fn hook_decisions_respect_mode() {
1093        let envelope = r#"{"tool_name":"Bash","tool_input":{"command":"grep -r TODO src"}}"#;
1094        let deny = hook::process(envelope, Mode::Deny).unwrap();
1095        assert_eq!(deny["hookSpecificOutput"]["permissionDecision"], "deny");
1096        assert!(
1097            deny["hookSpecificOutput"]["permissionDecisionReason"]
1098                .as_str()
1099                .unwrap()
1100                .contains("ct search")
1101        );
1102        let ask = hook::process(envelope, Mode::Ask).unwrap();
1103        assert_eq!(ask["hookSpecificOutput"]["permissionDecision"], "ask");
1104        let warn = hook::process(envelope, Mode::Warn).unwrap();
1105        assert!(warn["hookSpecificOutput"]["additionalContext"].is_string());
1106        assert!(
1107            warn["hookSpecificOutput"]
1108                .get("permissionDecision")
1109                .is_none()
1110        );
1111    }
1112
1113    #[test]
1114    fn hook_fails_open() {
1115        assert!(hook::process("not json", Mode::Deny).is_none());
1116        assert!(hook::process(r#"{"tool_name":"Read"}"#, Mode::Deny).is_none());
1117        assert!(hook::process(r#"{"tool_name":"Bash","tool_input":{}}"#, Mode::Deny).is_none());
1118        assert!(
1119            hook::process(
1120                r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#,
1121                Mode::Deny
1122            )
1123            .is_none()
1124        );
1125    }
1126
1127    #[test]
1128    fn install_is_idempotent_and_preserves_other_settings() {
1129        // fresh install
1130        let (text, changed) = install(None, "ct steer hook").unwrap();
1131        assert!(changed);
1132        assert!(text.contains("PreToolUse"));
1133        assert!(text.contains("\"matcher\": \"Bash\""));
1134        assert!(text.contains("ct steer hook"));
1135        // re-install is a no-op
1136        let (text2, changed2) = install(Some(&text), "ct steer hook").unwrap();
1137        assert!(!changed2);
1138        assert_eq!(text, text2);
1139        // a mode change rewrites in place (still one hook)
1140        let (text3, changed3) = install(Some(&text), "ct steer hook --mode ask").unwrap();
1141        assert!(changed3);
1142        assert_eq!(text3.matches("steer hook").count(), 1);
1143        // existing unrelated settings survive
1144        let existing = r#"{ "model": "opus", "hooks": { "PreToolUse": [] } }"#;
1145        let (merged, _) = install(Some(existing), "ct steer hook").unwrap();
1146        assert!(merged.contains("\"model\": \"opus\""));
1147    }
1148
1149    #[test]
1150    fn uninstall_removes_only_our_hook() {
1151        let existing = r#"{
1152            "hooks": { "PreToolUse": [
1153                { "matcher": "Bash", "hooks": [
1154                    { "type": "command", "command": "ct steer hook" },
1155                    { "type": "command", "command": "./other.sh" }
1156                ] }
1157            ] }
1158        }"#;
1159        let (text, changed) = uninstall(Some(existing)).unwrap();
1160        assert!(changed);
1161        assert!(!text.contains("steer hook"));
1162        assert!(text.contains("./other.sh")); // the unrelated hook stays
1163        // uninstall on a clean file is a no-op
1164        let (_, changed2) = uninstall(Some("{}")).unwrap();
1165        assert!(!changed2);
1166    }
1167
1168    #[test]
1169    fn install_and_uninstall_preserve_comments() {
1170        // a settings.json with comments the user cares about
1171        let existing = "{\n  \
1172            // pin the model\n  \
1173            \"model\": \"opus\", // do not change\n  \
1174            \"hooks\": {\n    \
1175            \"PreToolUse\": [\n      \
1176            { \"matcher\": \"Bash\", \"hooks\": [ { \"type\": \"command\", \"command\": \"./guard.sh\" } ] }\n    \
1177            ]\n  }\n}\n";
1178        let (installed, changed) = install(Some(existing), "ct steer hook").unwrap();
1179        assert!(changed);
1180        // comments survive the merge
1181        assert!(installed.contains("// pin the model"), "{installed}");
1182        assert!(installed.contains("// do not change"), "{installed}");
1183        // the prior hook is untouched and ours is appended to the same matcher
1184        assert!(installed.contains("./guard.sh"), "{installed}");
1185        assert!(installed.contains("ct steer hook"), "{installed}");
1186
1187        // uninstall removes only our hook, keeps the guard and the comments
1188        let (removed, changed2) = uninstall(Some(&installed)).unwrap();
1189        assert!(changed2);
1190        assert!(removed.contains("// pin the model"), "{removed}");
1191        assert!(removed.contains("./guard.sh"), "{removed}");
1192        assert!(!removed.contains("steer hook"), "{removed}");
1193    }
1194
1195    #[test]
1196    fn scope_paths() {
1197        let root = Path::new("/proj");
1198        let home = Path::new("/home/u");
1199        assert!(
1200            Scope::Project
1201                .path(root, home)
1202                .ends_with(".claude/settings.json")
1203        );
1204        assert!(
1205            Scope::Local
1206                .path(root, home)
1207                .ends_with(".claude/settings.local.json")
1208        );
1209        assert!(Scope::User.path(root, home).starts_with("/home/u"));
1210    }
1211}