Skip to main content

rippy_cli/
cli.rs

1use std::path::PathBuf;
2
3use clap::{Args, Parser, Subcommand, ValueEnum};
4
5use crate::mode::Mode;
6
7/// Mode selection for which AI tool is calling rippy.
8#[derive(Debug, Clone, Copy, ValueEnum)]
9pub enum ModeArg {
10    Claude,
11    Gemini,
12    Cursor,
13    Codex,
14}
15
16impl ModeArg {
17    const fn to_mode(self) -> Mode {
18        match self {
19            Self::Claude => Mode::Claude,
20            Self::Gemini => Mode::Gemini,
21            Self::Cursor => Mode::Cursor,
22            Self::Codex => Mode::Codex,
23        }
24    }
25}
26
27/// A shell command safety hook for AI coding tools.
28#[derive(Parser, Debug)]
29#[command(
30    name = "rippy",
31    version,
32    about,
33    after_help = "\
34Reads a JSON hook payload from stdin and writes a verdict to stdout.\n\n\
35Exit codes: 0 = allow, 2 = ask/deny, 1 = error\n\n\
36Get started with a safety package:\n  \
37rippy init                          # interactive package selection\n  \
38rippy init --package develop        # skip the prompt\n  \
39rippy profile list                  # see available packages\n\n\
40Packages: review (full supervision), develop (balanced), autopilot (maximum autonomy)\n\n\
41Example hook usage:\n  \
42echo '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"git status\"}}' | rippy --mode claude"
43)]
44pub struct Cli {
45    #[command(subcommand)]
46    pub command: Option<Command>,
47
48    #[command(flatten)]
49    pub hook_args: HookArgs,
50}
51
52#[derive(Subcommand, Debug)]
53pub enum Command {
54    /// Configure rippy as the permission engine for another tool
55    Setup(SetupArgs),
56    /// Convert a .rippy config file to .rippy.toml format
57    Migrate(MigrateArgs),
58    /// Show configured rules and trace command decisions
59    Inspect(InspectArgs),
60    /// Show aggregate decision tracking statistics
61    Stats(StatsArgs),
62    /// Add an allow rule to the config
63    Allow(RuleArgs),
64    /// Add a deny rule to the config
65    Deny(RuleArgs),
66    /// Add an ask rule to the config
67    Ask(RuleArgs),
68    /// Analyze tracking data and suggest config rules
69    Suggest(SuggestArgs),
70    /// Initialize config with a safety package (review, develop, or autopilot)
71    Init(InitArgs),
72    /// Discover flag aliases from command --help output
73    Discover(DiscoverArgs),
74    /// Manage trust for project-level config files
75    Trust(TrustArgs),
76    /// Trace the full decision path for a command
77    Debug(DebugArgs),
78    /// List safe commands, handlers, or effective rules
79    List(ListArgs),
80    /// Manage safety packages (review, develop, autopilot)
81    Profile(ProfileArgs),
82}
83
84#[derive(Args, Debug)]
85pub struct ListArgs {
86    #[command(subcommand)]
87    pub target: ListTarget,
88}
89
90#[derive(Args, Debug)]
91pub struct ProfileArgs {
92    #[command(subcommand)]
93    pub target: ProfileTarget,
94}
95
96#[derive(Subcommand, Debug)]
97pub enum ProfileTarget {
98    /// List available safety packages
99    List {
100        /// Output as JSON
101        #[arg(long)]
102        json: bool,
103    },
104    /// Show details of a safety package
105    Show {
106        /// Package name (review, develop, autopilot)
107        name: String,
108        /// Output as JSON
109        #[arg(long)]
110        json: bool,
111    },
112    /// Activate a safety package
113    Set {
114        /// Package name (review, develop, autopilot)
115        name: String,
116        /// Write to project config instead of global
117        #[arg(long)]
118        project: bool,
119    },
120}
121
122#[derive(Subcommand, Debug)]
123pub enum ListTarget {
124    /// Show all auto-approved safe commands
125    Safe,
126    /// Show all commands with dedicated handlers
127    Handlers,
128    /// Show effective rules from all config sources
129    Rules(ListRulesArgs),
130}
131
132#[derive(Args, Debug)]
133pub struct ListRulesArgs {
134    /// Filter rules by pattern
135    #[arg(long)]
136    pub filter: Option<String>,
137}
138
139#[derive(Args, Debug)]
140pub struct DiscoverArgs {
141    /// Command and optional subcommand (e.g. "git push")
142    pub args: Vec<String>,
143
144    /// Re-discover all previously cached commands
145    #[arg(long)]
146    pub all: bool,
147
148    /// Output in JSON format
149    #[arg(long)]
150    pub json: bool,
151}
152
153#[derive(Args, Debug)]
154pub struct InitArgs {
155    /// Write to global config (~/.rippy/config.toml) instead of project .rippy.toml
156    #[arg(long)]
157    pub global: bool,
158
159    /// Print stdlib to stdout instead of writing to file
160    #[arg(long)]
161    pub stdout: bool,
162
163    /// Safety package to activate (review, develop, autopilot).
164    /// Prompts interactively if omitted.
165    #[arg(long)]
166    pub package: Option<String>,
167}
168
169#[derive(Args, Debug)]
170pub struct StatsArgs {
171    /// Time filter, e.g. "7d", "30d", "1h", "30m"
172    #[arg(long)]
173    pub since: Option<String>,
174
175    /// Output in JSON format
176    #[arg(long)]
177    pub json: bool,
178
179    /// Override tracking database path
180    #[arg(long)]
181    pub db: Option<PathBuf>,
182}
183
184#[derive(Args, Debug)]
185pub struct RuleArgs {
186    /// Pattern to match (e.g. "git push *")
187    pub pattern: String,
188    /// Optional rejection/guidance message
189    pub message: Option<String>,
190    /// Write to global config (~/.rippy/config.toml) instead of project .rippy.toml
191    #[arg(long)]
192    pub global: bool,
193}
194
195#[derive(Args, Debug)]
196#[allow(clippy::struct_excessive_bools)]
197pub struct SuggestArgs {
198    /// Generate patterns from a command string instead of analyzing the DB
199    #[arg(long)]
200    pub from_command: Option<String>,
201
202    /// Time filter, e.g. "7d", "30d", "1h", "30m"
203    #[arg(long)]
204    pub since: Option<String>,
205
206    /// Output in JSON format
207    #[arg(long)]
208    pub json: bool,
209
210    /// Override tracking database path
211    #[arg(long)]
212    pub db: Option<PathBuf>,
213
214    /// Apply all suggestions to config
215    #[arg(long)]
216    pub apply: bool,
217
218    /// Write to global config (~/.rippy/config.toml) instead of project .rippy.toml
219    #[arg(long)]
220    pub global: bool,
221
222    /// Minimum number of occurrences to generate a suggestion
223    #[arg(long, default_value = "3")]
224    pub min_count: i64,
225
226    /// Use Claude Code session files (default if sessions exist, use --db to override)
227    #[arg(long)]
228    pub sessions: bool,
229
230    /// Analyze a specific session JSONL file
231    #[arg(long)]
232    pub session_file: Option<PathBuf>,
233
234    /// Audit mode: classify commands against current config
235    #[arg(long)]
236    pub audit: bool,
237}
238
239#[derive(Args, Debug)]
240pub struct InspectArgs {
241    /// Command to trace through the decision pipeline (omit to list all rules)
242    pub command: Option<String>,
243
244    /// Output in JSON format
245    #[arg(long)]
246    pub json: bool,
247
248    /// Override config file path
249    #[arg(long, env = "RIPPY_CONFIG")]
250    pub config: Option<PathBuf>,
251}
252
253/// Arguments for `rippy debug` — trace the decision path for a command.
254#[derive(Args, Debug)]
255pub struct DebugArgs {
256    /// The shell command to trace (e.g. "git push --force")
257    pub command: String,
258
259    /// Output in JSON format
260    #[arg(long)]
261    pub json: bool,
262
263    /// Override config file path
264    #[arg(long, env = "RIPPY_CONFIG")]
265    pub config: Option<PathBuf>,
266}
267
268#[derive(Args, Debug)]
269pub struct MigrateArgs {
270    /// Path to the config file to convert (defaults to .rippy in current directory)
271    pub path: Option<PathBuf>,
272
273    /// Write to stdout instead of creating .rippy.toml
274    #[arg(long)]
275    pub stdout: bool,
276}
277
278#[derive(Args, Debug)]
279pub struct SetupArgs {
280    #[command(subcommand)]
281    pub target: SetupTarget,
282}
283
284#[derive(Subcommand, Debug)]
285pub enum SetupTarget {
286    /// Configure tokf to use rippy as its external permission engine
287    Tokf(TokfSetupArgs),
288    /// Install rippy as a direct hook for Claude Code
289    ClaudeCode(DirectHookArgs),
290    /// Install rippy as a direct hook for Gemini CLI
291    Gemini(DirectHookArgs),
292    /// Install rippy as a direct hook for Cursor
293    Cursor(DirectHookArgs),
294}
295
296#[derive(Args, Debug)]
297pub struct DirectHookArgs {
298    /// Install at user level (~/.claude/ etc.) instead of project level (.claude/ etc.)
299    #[arg(long)]
300    pub global: bool,
301}
302
303#[derive(Args, Debug)]
304pub struct TokfSetupArgs {
305    /// Install at user level (~/.config/tokf/) instead of project level (.tokf/)
306    #[arg(long)]
307    pub global: bool,
308
309    /// Also install tokf hooks for these AI tools (comma-separated).
310    /// Supported: claude-code, opencode, codex, gemini-cli, cursor, cline,
311    /// windsurf, copilot, aider
312    #[arg(long, value_delimiter = ',')]
313    pub install_hooks: Vec<String>,
314
315    /// Install tokf hooks for all supported AI tools
316    #[arg(long)]
317    pub all_hooks: bool,
318}
319
320/// Arguments for `rippy trust` — manage project config trust.
321#[derive(Args, Debug)]
322#[allow(clippy::struct_excessive_bools)]
323pub struct TrustArgs {
324    /// Remove trust for the current project config
325    #[arg(long)]
326    pub revoke: bool,
327
328    /// Show trust status without modifying
329    #[arg(long)]
330    pub status: bool,
331
332    /// List all trusted project configs
333    #[arg(long)]
334    pub list: bool,
335
336    /// Trust without interactive confirmation
337    #[arg(long, short = 'y')]
338    pub yes: bool,
339}
340
341/// Hook-mode arguments (the original rippy behavior).
342#[derive(Args, Debug)]
343pub struct HookArgs {
344    /// Force a specific AI tool mode
345    #[arg(long, value_enum)]
346    pub mode: Option<ModeArg>,
347
348    /// Override config file path (also reads `RIPPY_CONFIG` / `DIPPY_CONFIG` env vars)
349    #[arg(long, env = "RIPPY_CONFIG")]
350    pub config: Option<PathBuf>,
351
352    /// Remote mode (container/SSH context — skip local path validation)
353    #[arg(long)]
354    pub remote: bool,
355
356    /// Print decision trace to stderr for debugging
357    #[arg(long, short = 'v')]
358    pub verbose: bool,
359}
360
361impl HookArgs {
362    /// Return the explicitly forced mode, if any.
363    #[must_use]
364    pub fn forced_mode(&self) -> Option<Mode> {
365        self.mode.map(ModeArg::to_mode)
366    }
367
368    /// Resolve the config path: CLI flag > `RIPPY_CONFIG` > `DIPPY_CONFIG` env var.
369    #[must_use]
370    pub fn config_path(&self) -> Option<PathBuf> {
371        self.config
372            .clone()
373            .or_else(|| std::env::var_os("DIPPY_CONFIG").map(PathBuf::from))
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn forced_mode_claude() {
383        let args = HookArgs {
384            mode: Some(ModeArg::Claude),
385            config: None,
386            remote: false,
387            verbose: false,
388        };
389        assert_eq!(args.forced_mode(), Some(Mode::Claude));
390    }
391
392    #[test]
393    fn no_forced_mode() {
394        let args = HookArgs {
395            mode: None,
396            config: None,
397            remote: false,
398            verbose: false,
399        };
400        assert_eq!(args.forced_mode(), None);
401    }
402}