Skip to main content

coding_tools/cli/
ct_steer.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Jonathan Shook
3
4//! The `ct-steer` command grammar (see [`crate::cli`]). Like `ct-okf`, this
5//! tool is **subcommand**-shaped (`ct steer hook`, `ct steer install`, …)
6//! because its surface spans the runtime hook, settings installation, and a
7//! dry-run check. The `ct-steer` bin is a parse-and-dispatch wrapper over this
8//! `Cli`.
9//!
10//! Global flags (`--json`, `--quiet`, `--timeout`, the heartbeat, `--explain`)
11//! are declared `global` so they may appear before or after the subcommand;
12//! per-verb flags live on each subcommand's args struct.
13
14use std::path::PathBuf;
15
16use clap::{Args, Parser, Subcommand};
17
18use crate::explain::Format;
19use crate::pulse::HeartbeatOpts;
20
21#[derive(Parser, Debug)]
22#[command(
23    name = "ct-steer",
24    version,
25    about = "Steer ad-hoc shell commands to the ct tool that serves them; install the PreToolUse hook.",
26    long_about = "ct-steer recognises the shell idioms a ct tool serves better (find | xargs grep, \
27                  sed -i, cat | head, for-loops, && / || chains) and, as a Claude Code PreToolUse \
28                  hook, steers the agent to the ct equivalent instead. Also reachable as `ct steer`. \
29                  Subcommands: `hook` is the runtime hook (reads a PreToolUse envelope on stdin); \
30                  `install`/`uninstall` wire it into .claude/settings.json; `check` shows what the \
31                  hook would decide for a command. See `ct-steer --explain` for agent docs."
32)]
33pub struct Cli {
34    #[command(subcommand)]
35    pub command: Option<Command>,
36
37    /// Emit a structured JSON result instead of text (where applicable).
38    #[arg(long, global = true)]
39    pub json: bool,
40
41    /// Suppress informational output (exit status still reports).
42    #[arg(long, global = true)]
43    pub quiet: bool,
44
45    /// Abort with exit 2 if the run exceeds SECS seconds (fractional allowed).
46    #[arg(long, value_name = "SECS", global = true)]
47    pub timeout: Option<f64>,
48
49    #[command(flatten)]
50    pub heartbeat: HeartbeatOpts,
51
52    /// Print agent usage docs (md or json) and exit.
53    #[arg(long, value_enum, num_args = 0..=1, default_missing_value = "md")]
54    pub explain: Option<Format>,
55}
56
57/// The `ct-steer` verbs.
58#[derive(Subcommand, Debug)]
59pub enum Command {
60    /// Runtime PreToolUse hook: read a tool-call envelope on stdin, emit a decision.
61    Hook(HookArgs),
62    /// Merge the steering hook into a Claude Code settings file.
63    Install(InstallArgs),
64    /// Remove the steering hook from a Claude Code settings file.
65    Uninstall(InstallArgs),
66    /// Show (and exit-code) what the hook would decide for a command string.
67    Check(CheckArgs),
68}
69
70/// How the hook steers a matched command.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
72pub enum Mode {
73    /// Block the call and feed the ct suggestion back to the agent (default).
74    Deny,
75    /// Surface a confirmation prompt naming the ct suggestion.
76    Ask,
77    /// Allow the call, but inject the ct suggestion as context.
78    Warn,
79}
80
81impl Mode {
82    /// Bridge to the library's mode.
83    pub fn to_lib(self) -> crate::steer::Mode {
84        match self {
85            Mode::Deny => crate::steer::Mode::Deny,
86            Mode::Ask => crate::steer::Mode::Ask,
87            Mode::Warn => crate::steer::Mode::Warn,
88        }
89    }
90}
91
92/// A harness tool the steering hook can gate (one `PreToolUse` matcher each).
93#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
94pub enum Tool {
95    /// Shell commands (the default) — the full shell-idiom matcher.
96    #[value(name = "Bash")]
97    Bash,
98    /// The harness content search → ct search.
99    #[value(name = "Grep")]
100    Grep,
101    /// The harness file glob → ct search.
102    #[value(name = "Glob")]
103    Glob,
104    /// The harness file read → ct view (images/PDF/notebooks pass through).
105    #[value(name = "Read")]
106    Read,
107    /// Every tool (a "*" matcher) — full-coverage logging; only recognised idioms are steered.
108    #[value(name = "all")]
109    All,
110}
111
112impl Tool {
113    /// Bridge to the library's tool.
114    pub fn to_lib(self) -> crate::steer::install::Tool {
115        match self {
116            Tool::Bash => crate::steer::install::Tool::Bash,
117            Tool::Grep => crate::steer::install::Tool::Grep,
118            Tool::Glob => crate::steer::install::Tool::Glob,
119            Tool::Read => crate::steer::install::Tool::Read,
120            Tool::All => crate::steer::install::Tool::All,
121        }
122    }
123}
124
125/// Which settings file `install`/`uninstall` target.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
127pub enum Scope {
128    /// .claude/settings.json (shared, committed).
129    Project,
130    /// .claude/settings.local.json (personal, gitignored).
131    Local,
132    /// ~/.claude/settings.json (all projects).
133    User,
134}
135
136impl Scope {
137    /// Bridge to the library's scope.
138    pub fn to_lib(self) -> crate::steer::install::Scope {
139        match self {
140            Scope::Project => crate::steer::install::Scope::Project,
141            Scope::Local => crate::steer::install::Scope::Local,
142            Scope::User => crate::steer::install::Scope::User,
143        }
144    }
145}
146
147#[derive(Args, Debug)]
148pub struct HookArgs {
149    /// Steering action on a match: deny (default), ask, or warn.
150    #[arg(long, value_enum, default_value_t = Mode::Deny)]
151    pub mode: Mode,
152
153    /// Directory for the daily tool-call log; defaults to .ct/tclog (nearest .ct). Also settable via CT_STEER_LOG.
154    #[arg(long, value_name = "DIR")]
155    pub log_dir: Option<PathBuf>,
156
157    /// Disable tool-call logging (it is on by default).
158    #[arg(long)]
159    pub no_log: bool,
160}
161
162#[derive(Args, Debug)]
163pub struct InstallArgs {
164    /// Which settings file to write: project (default), local, or user.
165    #[arg(long, value_enum, default_value_t = Scope::Project)]
166    pub scope: Scope,
167
168    /// The steering action baked into the installed hook command.
169    #[arg(long, value_enum, default_value_t = Mode::Deny)]
170    pub mode: Mode,
171
172    /// Harness tools to gate, comma-joined or repeated: Bash (default), Grep, Glob, Read. Grep/Glob steer to ct search, Read to ct view. Ignored when --all-tools is set.
173    #[arg(long, value_enum, value_delimiter = ',', default_value = "Bash")]
174    pub tools: Vec<Tool>,
175
176    /// Gate every tool call under a single "*" matcher (superseding --tools) — for full-coverage logging.
177    #[arg(long)]
178    pub all_tools: bool,
179
180    /// Bake a `--log-dir DIR` override into the installed hook command (logging is on by default to .ct/tclog).
181    #[arg(long, value_name = "DIR")]
182    pub log_dir: Option<PathBuf>,
183
184    /// Bake `--no-log` into the installed hook command, disabling tool-call logging.
185    #[arg(long)]
186    pub no_log: bool,
187
188    /// Show the resulting settings file without writing it.
189    #[arg(long)]
190    pub dry_run: bool,
191
192    /// Print just the hook snippet (for manual paste) and exit.
193    #[arg(long)]
194    pub print: bool,
195}
196
197#[derive(Args, Debug)]
198pub struct CheckArgs {
199    /// The shell command to classify.
200    #[arg(value_name = "COMMAND", required = true)]
201    pub command: String,
202
203    /// The steering action to report (affects the printed decision only).
204    #[arg(long, value_enum, default_value_t = Mode::Deny)]
205    pub mode: Mode,
206}