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 clap::{Args, Parser, Subcommand};
15
16use crate::explain::Format;
17use crate::pulse::HeartbeatOpts;
18
19#[derive(Parser, Debug)]
20#[command(
21    name = "ct-steer",
22    version,
23    about = "Steer ad-hoc shell commands to the ct tool that serves them; install the PreToolUse hook.",
24    long_about = "ct-steer recognises the shell idioms a ct tool serves better (find | xargs grep, \
25                  sed -i, cat | head, for-loops, && / || chains) and, as a Claude Code PreToolUse \
26                  hook, steers the agent to the ct equivalent instead. Also reachable as `ct steer`. \
27                  Subcommands: `hook` is the runtime hook (reads a PreToolUse envelope on stdin); \
28                  `install`/`uninstall` wire it into .claude/settings.json; `check` shows what the \
29                  hook would decide for a command. See `ct-steer --explain` for agent docs."
30)]
31pub struct Cli {
32    #[command(subcommand)]
33    pub command: Option<Command>,
34
35    /// Emit a structured JSON result instead of text (where applicable).
36    #[arg(long, global = true)]
37    pub json: bool,
38
39    /// Suppress informational output (exit status still reports).
40    #[arg(long, global = true)]
41    pub quiet: bool,
42
43    /// Abort with exit 2 if the run exceeds SECS seconds (fractional allowed).
44    #[arg(long, value_name = "SECS", global = true)]
45    pub timeout: Option<f64>,
46
47    #[command(flatten)]
48    pub heartbeat: HeartbeatOpts,
49
50    /// Print agent usage docs (md or json) and exit.
51    #[arg(long, value_enum, num_args = 0..=1, default_missing_value = "md")]
52    pub explain: Option<Format>,
53}
54
55/// The `ct-steer` verbs.
56#[derive(Subcommand, Debug)]
57pub enum Command {
58    /// Runtime PreToolUse hook: read a tool-call envelope on stdin, emit a decision.
59    Hook(HookArgs),
60    /// Merge the steering hook into a Claude Code settings file.
61    Install(InstallArgs),
62    /// Remove the steering hook from a Claude Code settings file.
63    Uninstall(InstallArgs),
64    /// Show (and exit-code) what the hook would decide for a command string.
65    Check(CheckArgs),
66}
67
68/// How the hook steers a matched command.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
70pub enum Mode {
71    /// Block the call and feed the ct suggestion back to the agent (default).
72    Deny,
73    /// Surface a confirmation prompt naming the ct suggestion.
74    Ask,
75    /// Allow the call, but inject the ct suggestion as context.
76    Warn,
77}
78
79impl Mode {
80    /// Bridge to the library's mode.
81    pub fn to_lib(self) -> crate::steer::Mode {
82        match self {
83            Mode::Deny => crate::steer::Mode::Deny,
84            Mode::Ask => crate::steer::Mode::Ask,
85            Mode::Warn => crate::steer::Mode::Warn,
86        }
87    }
88}
89
90/// A harness tool the steering hook can gate (one `PreToolUse` matcher each).
91#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
92pub enum Tool {
93    /// Shell commands (the default) — the full shell-idiom matcher.
94    #[value(name = "Bash")]
95    Bash,
96    /// The harness content search → ct search.
97    #[value(name = "Grep")]
98    Grep,
99    /// The harness file glob → ct search.
100    #[value(name = "Glob")]
101    Glob,
102    /// The harness file read → ct view (images/PDF/notebooks pass through).
103    #[value(name = "Read")]
104    Read,
105}
106
107impl Tool {
108    /// Bridge to the library's tool.
109    pub fn to_lib(self) -> crate::steer::install::Tool {
110        match self {
111            Tool::Bash => crate::steer::install::Tool::Bash,
112            Tool::Grep => crate::steer::install::Tool::Grep,
113            Tool::Glob => crate::steer::install::Tool::Glob,
114            Tool::Read => crate::steer::install::Tool::Read,
115        }
116    }
117}
118
119/// Which settings file `install`/`uninstall` target.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
121pub enum Scope {
122    /// .claude/settings.json (shared, committed).
123    Project,
124    /// .claude/settings.local.json (personal, gitignored).
125    Local,
126    /// ~/.claude/settings.json (all projects).
127    User,
128}
129
130impl Scope {
131    /// Bridge to the library's scope.
132    pub fn to_lib(self) -> crate::steer::install::Scope {
133        match self {
134            Scope::Project => crate::steer::install::Scope::Project,
135            Scope::Local => crate::steer::install::Scope::Local,
136            Scope::User => crate::steer::install::Scope::User,
137        }
138    }
139}
140
141#[derive(Args, Debug)]
142pub struct HookArgs {
143    /// Steering action on a match: deny (default), ask, or warn.
144    #[arg(long, value_enum, default_value_t = Mode::Deny)]
145    pub mode: Mode,
146}
147
148#[derive(Args, Debug)]
149pub struct InstallArgs {
150    /// Which settings file to write: project (default), local, or user.
151    #[arg(long, value_enum, default_value_t = Scope::Project)]
152    pub scope: Scope,
153
154    /// The steering action baked into the installed hook command.
155    #[arg(long, value_enum, default_value_t = Mode::Deny)]
156    pub mode: Mode,
157
158    /// Harness tools to gate, comma-joined or repeated: Bash (default), Grep, Glob, Read. Grep/Glob steer to ct search, Read to ct view.
159    #[arg(long, value_enum, value_delimiter = ',', default_value = "Bash")]
160    pub tools: Vec<Tool>,
161
162    /// Show the resulting settings file without writing it.
163    #[arg(long)]
164    pub dry_run: bool,
165
166    /// Print just the hook snippet (for manual paste) and exit.
167    #[arg(long)]
168    pub print: bool,
169}
170
171#[derive(Args, Debug)]
172pub struct CheckArgs {
173    /// The shell command to classify.
174    #[arg(value_name = "COMMAND", required = true)]
175    pub command: String,
176
177    /// The steering action to report (affects the printed decision only).
178    #[arg(long, value_enum, default_value_t = Mode::Deny)]
179    pub mode: Mode,
180}