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.iter().any(|w| {
236 w.starts_with('-') && !w.starts_with("--") && w[1..].chars().any(|c| c == ch)
237 })
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.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 if segs.len() == 1 {
334 return analyze_segment(&seg_stages[0]);
335 }
336
337 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
353fn 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
380fn 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
392fn rule_find_grep(stages: &[Vec<String>]) -> Option<Steer> {
394 let find = stages.iter().find(|s| cmd_of(s) == Some("find"))?;
395 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
409fn 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 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
430fn 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
443fn 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
469fn rule_read_range(stages: &[Vec<String>]) -> Option<Steer> {
471 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 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 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)?; let range = match (cmd, n) {
498 (Some("head"), Some(n)) => Some((1, n)),
499 _ => None, };
501 return Some(view_steer(Some(file), range));
502 }
503 None
504}
505
506fn 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
524fn 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 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
545fn 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
563fn 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
578fn 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
595fn 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
611fn 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
624fn 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
635fn 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
647pub mod hook {
652 use super::{Mode, Steer, analyze};
653 use serde_json::{Value, json};
654
655 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 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
693pub 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 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
708 pub enum Scope {
709 Project,
711 Local,
713 User,
715 }
716
717 impl Scope {
718 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 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 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 fn is_steer_command(s: &str) -> bool {
749 s.contains("steer") && s.contains("hook")
750 }
751
752 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 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 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 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 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 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 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 fn install_ops(root: &Value, command: &str) -> Result<Vec<Op>, String> {
860 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 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 fn is_bash_matcher(entry: &Value) -> bool {
914 entry.get("matcher").and_then(Value::as_str) == Some("Bash")
915 }
916
917 fn uninstall_ops(root: &Value) -> Result<Vec<Op>, String> {
919 let Some(pre) = pre_array(root) else {
920 return Ok(vec![]);
921 };
922 let mut whole_entries = Vec::new(); let mut partial = Vec::new(); 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 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 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 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 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()); 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());
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 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 let (text2, changed2) = install(Some(&text), "ct steer hook").unwrap();
1095 assert!(!changed2);
1096 assert_eq!(text, text2);
1097 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 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")); let (_, changed2) = uninstall(Some("{}")).unwrap();
1123 assert!(!changed2);
1124 }
1125
1126 #[test]
1127 fn install_and_uninstall_preserve_comments() {
1128 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 assert!(installed.contains("// pin the model"), "{installed}");
1140 assert!(installed.contains("// do not change"), "{installed}");
1141 assert!(installed.contains("./guard.sh"), "{installed}");
1143 assert!(installed.contains("ct steer hook"), "{installed}");
1144
1145 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}