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 /// Runtime PostToolUse recorder: log the executed call (for effectiveness analysis).
63 Post(PostArgs),
64 /// Merge the steering hook into a Claude Code settings file.
65 Install(InstallArgs),
66 /// Remove the steering hook from a Claude Code settings file.
67 Uninstall(InstallArgs),
68 /// Show (and exit-code) what the hook would decide for a command string.
69 Check(CheckArgs),
70}
71
72/// How the hook steers a matched command.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
74pub enum Mode {
75 /// Block the call and feed the ct suggestion back to the agent (default).
76 Deny,
77 /// Surface a confirmation prompt naming the ct suggestion.
78 Ask,
79 /// Allow the call, but inject the ct suggestion as context.
80 Warn,
81}
82
83impl Mode {
84 /// Bridge to the library's mode.
85 pub fn to_lib(self) -> crate::steer::Mode {
86 match self {
87 Mode::Deny => crate::steer::Mode::Deny,
88 Mode::Ask => crate::steer::Mode::Ask,
89 Mode::Warn => crate::steer::Mode::Warn,
90 }
91 }
92}
93
94/// A harness tool the steering hook can gate (one `PreToolUse` matcher each).
95#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
96pub enum Tool {
97 /// Shell commands (the default) — the full shell-idiom matcher.
98 #[value(name = "Bash")]
99 Bash,
100 /// The harness content search → ct search.
101 #[value(name = "Grep")]
102 Grep,
103 /// The harness file glob → ct search.
104 #[value(name = "Glob")]
105 Glob,
106 /// The harness file read → ct view (images/PDF/notebooks pass through).
107 #[value(name = "Read")]
108 Read,
109 /// Every tool (a "*" matcher) — full-coverage logging; only recognised idioms are steered.
110 #[value(name = "all")]
111 All,
112}
113
114impl Tool {
115 /// Bridge to the library's tool.
116 pub fn to_lib(self) -> crate::steer::install::Tool {
117 match self {
118 Tool::Bash => crate::steer::install::Tool::Bash,
119 Tool::Grep => crate::steer::install::Tool::Grep,
120 Tool::Glob => crate::steer::install::Tool::Glob,
121 Tool::Read => crate::steer::install::Tool::Read,
122 Tool::All => crate::steer::install::Tool::All,
123 }
124 }
125}
126
127/// Which settings file `install`/`uninstall` target.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
129pub enum Scope {
130 /// .claude/settings.json (shared, committed).
131 Project,
132 /// .claude/settings.local.json (personal, gitignored).
133 Local,
134 /// ~/.claude/settings.json (all projects).
135 User,
136}
137
138impl Scope {
139 /// Bridge to the library's scope.
140 pub fn to_lib(self) -> crate::steer::install::Scope {
141 match self {
142 Scope::Project => crate::steer::install::Scope::Project,
143 Scope::Local => crate::steer::install::Scope::Local,
144 Scope::User => crate::steer::install::Scope::User,
145 }
146 }
147}
148
149#[derive(Args, Debug)]
150pub struct HookArgs {
151 /// Steering action on a match: deny (default), ask, or warn.
152 #[arg(long, value_enum, default_value_t = Mode::Deny)]
153 pub mode: Mode,
154
155 /// Directory for the daily tool-call log; defaults to .ct/tclog (nearest .ct). Also settable via CT_STEER_LOG.
156 #[arg(long, value_name = "DIR")]
157 pub log_dir: Option<PathBuf>,
158
159 /// Disable tool-call logging (it is on by default).
160 #[arg(long)]
161 pub no_log: bool,
162
163 /// Also nudge (warn-only, never deny) against ANY shell pipeline the specific rules did not steer, prompting harder use of ct.
164 #[arg(long)]
165 pub nudge_pipelines: bool,
166}
167
168#[derive(Args, Debug)]
169pub struct PostArgs {
170 /// Directory for the daily log; defaults to .ct/tclog (nearest .ct). Also settable via CT_STEER_LOG.
171 #[arg(long, value_name = "DIR")]
172 pub log_dir: Option<PathBuf>,
173
174 /// Disable logging (the recorder does nothing).
175 #[arg(long)]
176 pub no_log: bool,
177}
178
179#[derive(Args, Debug)]
180pub struct InstallArgs {
181 /// Which settings file to write: project (default), local, or user.
182 #[arg(long, value_enum, default_value_t = Scope::Project)]
183 pub scope: Scope,
184
185 /// The steering action baked into the installed hook command.
186 #[arg(long, value_enum, default_value_t = Mode::Deny)]
187 pub mode: Mode,
188
189 /// 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.
190 #[arg(long, value_enum, value_delimiter = ',', default_value = "Bash")]
191 pub tools: Vec<Tool>,
192
193 /// Gate every tool call under a single "*" matcher (superseding --tools) — for full-coverage logging.
194 #[arg(long)]
195 pub all_tools: bool,
196
197 /// Bake a `--log-dir DIR` override into the installed hook command (logging is on by default to .ct/tclog).
198 #[arg(long, value_name = "DIR")]
199 pub log_dir: Option<PathBuf>,
200
201 /// Bake `--no-log` into the installed hook command, disabling tool-call logging.
202 #[arg(long)]
203 pub no_log: bool,
204
205 /// Bake `--nudge-pipelines` into the installed hook (warn-only nudge against any un-steered shell pipeline).
206 #[arg(long)]
207 pub nudge_pipelines: bool,
208
209 /// Also install a PostToolUse recorder (a `*` matcher running `ct steer post`) to measure whether steer guidance was followed.
210 #[arg(long)]
211 pub measure: bool,
212
213 /// Bake the absolute path of THIS ct-steer binary into the hook (instead of resolving `ct` on PATH), so a version-skewed or missing `ct` can't break the hook.
214 #[arg(long)]
215 pub pin: bool,
216
217 /// Skip the preflight that verifies the resolving `ct` can parse the hook command; install even if it looks incompatible.
218 #[arg(long)]
219 pub force: bool,
220
221 /// Show the resulting settings file without writing it.
222 #[arg(long)]
223 pub dry_run: bool,
224
225 /// Print just the hook snippet (for manual paste) and exit.
226 #[arg(long)]
227 pub print: bool,
228}
229
230#[derive(Args, Debug)]
231pub struct CheckArgs {
232 /// The shell command to classify.
233 #[arg(value_name = "COMMAND", required = true)]
234 pub command: String,
235
236 /// The steering action to report (affects the printed decision only).
237 #[arg(long, value_enum, default_value_t = Mode::Deny)]
238 pub mode: Mode,
239}