1#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum Mode {
25 #[default]
27 Deny,
28 Ask,
30 Warn,
32}
33
34impl Mode {
35 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 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#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct Steer {
64 pub rule_id: &'static str,
66 pub tool: &'static str,
68 pub suggestion: String,
70 pub note: &'static str,
72}
73
74impl Steer {
75 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#[derive(Debug, Clone, PartialEq, Eq)]
91enum Tok {
92 Word(String),
93 Pipe,
94 And,
95 Or,
96 Semi,
97}
98
99fn lex(cmd: &str) -> Vec<Tok> {
103 let mut toks = Vec::new();
104 let mut cur = String::new();
105 let mut have = false; 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); }
164 }
165 ';' => {
166 flush(&mut toks, &mut cur, &mut have);
167 toks.push(Tok::Semi);
168 }
169 '>' | '<' | '(' | ')' | '{' | '}' | '`' => {
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
184fn 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 if segs.last().is_some_and(Vec::is_empty) {
200 segs.pop();
201 joiners.pop();
202 }
203 (segs, joiners)
204}
205
206fn 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
220fn base_name(w: &str) -> &str {
224 w.rsplit(['/', '\\']).next().unwrap_or(w)
225}
226
227fn cmd_of(stage: &[String]) -> Option<&str> {
229 stage.first().map(|w| base_name(w))
230}
231
232fn 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
240fn has_flag(stage: &[String], flag: &str) -> bool {
242 stage
243 .iter()
244 .any(|w| w == flag || w.starts_with(&format!("{flag}=")))
245}
246
247fn 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
263fn 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
275fn find_base(find: &[String]) -> Option<&str> {
278 find.get(1)
279 .filter(|w| !w.starts_with('-'))
280 .map(String::as_str)
281}
282
283fn q(s: &str) -> String {
285 format!("'{}'", s.replace('\'', "'\\''"))
286}
287
288pub 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 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 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 if segs.len() == 1 {
337 return analyze_segment(&seg_stages[0]);
338 }
339
340 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
359fn 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
386fn 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
398fn rule_find_grep(stages: &[Vec<String>]) -> Option<Steer> {
400 let find = stages.iter().find(|s| cmd_of(s) == Some("find"))?;
401 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
415fn 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 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
436fn 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
449fn 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
475fn rule_read_range(stages: &[Vec<String>]) -> Option<Steer> {
477 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 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 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)?; let range = match (cmd, n) {
504 (Some("head"), Some(n)) => Some((1, n)),
505 _ => None, };
507 return Some(view_steer(Some(file), range));
508 }
509 None
510}
511
512fn 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
530fn 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 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
551fn 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
569fn 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
584fn 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
606fn 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
622fn 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
635fn 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
646fn 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
658pub mod hook {
663 use super::{Mode, Steer, analyze};
664 use serde_json::{Value, json};
665
666 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 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
704pub 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 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
719 pub enum Scope {
720 Project,
722 Local,
724 User,
726 }
727
728 impl Scope {
729 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 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 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 fn is_steer_command(s: &str) -> bool {
760 s.contains("steer") && s.contains("hook")
761 }
762
763 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 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 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 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 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 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 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 fn install_ops(root: &Value, command: &str) -> Result<Vec<Op>, String> {
871 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 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 fn is_bash_matcher(entry: &Value) -> bool {
927 entry.get("matcher").and_then(Value::as_str) == Some("Bash")
928 }
929
930 fn uninstall_ops(root: &Value) -> Result<Vec<Op>, String> {
932 let Some(pre) = pre_array(root) else {
933 return Ok(vec![]);
934 };
935 let mut whole_entries = Vec::new(); let mut partial = Vec::new(); 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 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 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 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 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()); assert!(analyze("grep TODO file.rs").is_none()); assert!(analyze("echo 'a | b && c'").is_none()); assert!(analyze("ps aux | head -n 5").is_none()); assert!(analyze("").is_none());
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 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 let (text2, changed2) = install(Some(&text), "ct steer hook").unwrap();
1137 assert!(!changed2);
1138 assert_eq!(text, text2);
1139 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 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")); let (_, changed2) = uninstall(Some("{}")).unwrap();
1165 assert!(!changed2);
1166 }
1167
1168 #[test]
1169 fn install_and_uninstall_preserve_comments() {
1170 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 assert!(installed.contains("// pin the model"), "{installed}");
1182 assert!(installed.contains("// do not change"), "{installed}");
1183 assert!(installed.contains("./guard.sh"), "{installed}");
1185 assert!(installed.contains("ct steer hook"), "{installed}");
1186
1187 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}