intelli_shell/
cli.rs

1use std::str::FromStr;
2
3use clap::{
4    Args, Command, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum,
5    builder::{ValueParser, styling::Style},
6};
7use clap_stdin::{FileOrStdin, MaybeStdin};
8use color_eyre::{Result, eyre::eyre};
9use itertools::Itertools;
10use regex::Regex;
11use reqwest::{
12    Method,
13    header::{HeaderName, HeaderValue},
14};
15use tracing::instrument;
16
17use crate::model::SearchMode;
18
19/// Like IntelliSense, but for shells
20///
21/// Interactive commands are best used with the default shell bindings:
22/// - `ctrl + space` to search for commands
23/// - `ctrl + b` to bookmark a new command
24/// - `ctrl + l` to replace variables from a command
25/// - `ctrl + x` to fix a command that is failing
26#[derive(Parser)]
27#[cfg_attr(debug_assertions, derive(Debug))]
28#[command(
29    author,
30    version,
31    verbatim_doc_comment,
32    infer_subcommands = true,
33    after_long_help = include_str!("_examples/cli.txt")
34)]
35pub struct Cli {
36    /// Whether to skip the execution of the command
37    ///
38    /// Primarily used by shell integrations capable of running the command themselves
39    #[arg(long, hide = true)]
40    pub skip_execution: bool,
41
42    /// Whether to add an extra line when rendering the inline TUI
43    ///
44    /// Primarily used by shell integrations (e.g., Readline keybindings) to skip and preserve the shell prompt
45    #[arg(long, hide = true)]
46    pub extra_line: bool,
47
48    /// Path of the file to write the final textual output to (defaults to stdout)
49    ///
50    /// Primarily used by shell integrations (e.g., Readline/PSReadLine keybindings) to capture the result of an
51    /// interactive TUI session
52    #[arg(long, hide = true)]
53    pub file_output: Option<String>,
54
55    /// Command to be executed
56    #[command(name = "command", subcommand)]
57    pub process: CliProcess,
58}
59
60#[derive(Subcommand)]
61#[cfg_attr(debug_assertions, derive(Debug))]
62pub enum CliProcess {
63    #[cfg(debug_assertions)]
64    /// (debug) Runs an sql query against the database
65    Query(QueryProcess),
66
67    /// Generates the shell integration script
68    #[command(after_long_help = include_str!("_examples/init.txt"))]
69    Init(InitProcess),
70
71    /// Bookmarks a new command
72    #[command(after_long_help = include_str!("_examples/new.txt"))]
73    New(Interactive<BookmarkCommandProcess>),
74
75    /// Search stored commands
76    #[command(after_long_help = include_str!("_examples/search.txt"))]
77    Search(Interactive<SearchCommandsProcess>),
78
79    /// Replace the variables of a command
80    ///
81    /// Anything enclosed in double brackets is considered a variable: echo {{message}}
82    ///
83    /// This command also supports an alternative <variable> syntax, to improve compatibility
84    #[command(after_long_help = include_str!("_examples/replace.txt"))]
85    Replace(Interactive<VariableReplaceProcess>),
86
87    /// Fix a command that is failing
88    ///
89    /// The command will be run in order to capture its output and exit code, only non-interactive commands are
90    /// supported
91    #[command(after_long_help = include_str!("_examples/fix.txt"))]
92    Fix(CommandFixProcess),
93
94    /// Exports stored user commands to an external location
95    ///
96    /// Commands fetched from tldr are not exported
97    #[command(after_long_help = include_str!("_examples/export.txt"))]
98    Export(Interactive<ExportCommandsProcess>),
99
100    /// Imports user commands from an external location
101    #[command(after_long_help = include_str!("_examples/import.txt"))]
102    Import(Interactive<ImportCommandsProcess>),
103
104    /// Manages tldr integration
105    #[command(name = "tldr", subcommand)]
106    Tldr(TldrProcess),
107}
108
109#[derive(Subcommand)]
110#[cfg_attr(debug_assertions, derive(Debug))]
111pub enum TldrProcess {
112    /// Fetches command examples from tldr pages and imports them
113    ///
114    /// Imported commands will reside on a dfferent category and can be excluded when querying
115    #[command(after_long_help = include_str!("_examples/tldr_fetch.txt"))]
116    Fetch(TldrFetchProcess),
117
118    /// Clear command examples imported from tldr pages
119    #[command(after_long_help = include_str!("_examples/tldr_clear.txt"))]
120    Clear(TldrClearProcess),
121}
122
123/// A generic struct that combines process-specific arguments with common interactive mode options.
124///
125/// This struct is used to wrap processes that can be run in both interactive and non-interactive modes.
126#[derive(Args, Debug)]
127pub struct Interactive<T: FromArgMatches + Args> {
128    /// Options for the process
129    #[command(flatten)]
130    pub process: T,
131
132    /// Options for interactive display mode
133    #[command(flatten)]
134    pub opts: InteractiveOptions,
135}
136
137/// Options common to interactive processes
138#[derive(Args, Debug)]
139pub struct InteractiveOptions {
140    /// Open an interactive interface
141    #[arg(short = 'i', long)]
142    pub interactive: bool,
143
144    /// Force the interactive interface to render inline (takes less space)
145    #[arg(short = 'l', long, requires = "interactive", conflicts_with = "full_screen")]
146    pub inline: bool,
147
148    /// Force the interactive interface to render in full screen
149    #[arg(short = 'f', long, requires = "interactive", conflicts_with = "inline")]
150    pub full_screen: bool,
151}
152
153#[cfg(debug_assertions)]
154/// Runs an SQL query against the database
155#[derive(Args, Debug)]
156pub struct QueryProcess {
157    /// The query to run (reads from stdin if '-')
158    #[arg(default_value = "-")]
159    pub sql: FileOrStdin,
160}
161
162/// Generates the integration shell script
163#[derive(Args, Debug)]
164pub struct InitProcess {
165    /// The shell to generate the script for
166    #[arg(value_enum)]
167    pub shell: Shell,
168}
169
170#[derive(ValueEnum, Copy, Clone, PartialEq, Eq, Debug)]
171pub enum Shell {
172    Bash,
173    Zsh,
174    Fish,
175    Powershell,
176}
177
178/// Bookmarks a new command
179#[derive(Args, Debug)]
180pub struct BookmarkCommandProcess {
181    /// Command to be stored (mandatory when non-interactive)
182    ///
183    /// Take into consideration shell expansion and quote any special character intended to be stored
184    #[arg(required_unless_present = "interactive")]
185    pub command: Option<String>,
186
187    /// Alias for the command
188    #[arg(short = 'a', long)]
189    pub alias: Option<String>,
190
191    /// Description of the command
192    #[arg(short = 'd', long)]
193    pub description: Option<String>,
194
195    /// Use AI to suggest the command and description
196    #[arg(long)]
197    pub ai: bool,
198}
199
200/// Search stored commands
201#[derive(Args, Debug)]
202pub struct SearchCommandsProcess {
203    /// Initial search query to filter commands
204    pub query: Option<String>,
205
206    /// Search mode, overwriting the default one on the config
207    #[arg(short = 'm', long)]
208    pub mode: Option<SearchMode>,
209
210    /// Whether to search for user commands only (ignoring tldr), overwriting the config
211    #[arg(short = 'u', long)]
212    pub user_only: bool,
213
214    /// Use AI to suggest commands instead of searching for them on the database
215    #[arg(long, requires = "query")]
216    pub ai: bool,
217}
218
219/// Replace the variables of a command
220#[derive(Args, Debug)]
221pub struct VariableReplaceProcess {
222    /// Command to replace variables from (reads from stdin if '-')
223    ///
224    /// Take into consideration shell expansion and quote any special character that must be kept
225    #[arg(default_value = "-")]
226    pub command: MaybeStdin<String>,
227
228    /// Values for the variables, can be specified multiple times
229    ///
230    /// If only `KEY` is given (e.g., `--env api-token`), its value is read from the `API_TOKEN` environment variable
231    #[arg(short = 'e', long = "env", value_name = "KEY[=VALUE]", value_parser = ValueParser::new(parse_env_var))]
232    pub values: Vec<(String, Option<String>)>,
233
234    /// Automatically populates remaining unspecified variables from environment variables
235    ///
236    /// Unlike `--env` this flag will provide access to any environment variable, not only those explicitly listed
237    ///
238    /// Variable names are converted to SCREAMING_SNAKE_CASE to find matching variables (e.g., `{{http-header}}` checks
239    /// env var `HTTP_HEADER`)
240    ///
241    /// When run in interactive mode, env variables will be always suggested if found
242    #[arg(short = 'E', long)]
243    pub use_env: bool,
244}
245
246/// Fix a command that is failing
247#[derive(Args, Debug)]
248pub struct CommandFixProcess {
249    /// The non-interactive failing command
250    pub command: String,
251
252    /// Recent history from the shell, to be used as additional context in the prompt
253    ///
254    /// It has to contain recent commands, separated by a newline, from oldest to newest
255    #[arg(long, value_name = "HISTORY")]
256    pub history: Option<String>,
257}
258
259/// Exports stored user commands
260#[derive(Args, Clone, Debug)]
261pub struct ExportCommandsProcess {
262    /// Location to export commands to (writes to stdout if '-')
263    ///
264    /// The location type will be auto detected based on the content, if no type is specified
265    #[arg(default_value = "-")]
266    pub location: String,
267    /// Treat the location as a file path
268    #[arg(long, group = "location_type")]
269    pub file: bool,
270    /// Treat the location as a generic http(s) URL
271    #[arg(long, group = "location_type")]
272    pub http: bool,
273    /// Treat the location as a GitHub Gist URL or ID
274    #[arg(long, group = "location_type")]
275    pub gist: bool,
276    /// Export commands matching the given regular expression only
277    ///
278    /// The regular expression will be checked against both the command and the description
279    #[arg(long, value_name = "REGEX")]
280    pub filter: Option<Regex>,
281    /// Custom headers to include in the request
282    ///
283    /// This argument can be specified multiple times to add more than one header, but it will be only used for HTTP
284    /// locations
285    #[arg(short = 'H', long = "header", value_name = "KEY: VALUE", value_parser = ValueParser::new(parse_header))]
286    pub headers: Vec<(HeaderName, HeaderValue)>,
287    /// HTTP method to use for the request
288    ///
289    /// It will be only used for HTTP locations
290    #[arg(short = 'X', long = "request", value_enum, default_value_t = HttpMethod::PUT)]
291    pub method: HttpMethod,
292}
293
294/// Imports user commands
295#[derive(Args, Clone, Debug)]
296pub struct ImportCommandsProcess {
297    /// Location to import commands from (reads from stdin if '-')
298    ///
299    /// The location type will be auto detected based on the content, if no type is specified
300    #[arg(default_value = "-", required_unless_present = "history")]
301    pub location: String,
302    /// Use AI to parse and extract commands
303    #[arg(long)]
304    pub ai: bool,
305    /// Do not import the commands, just output them
306    ///
307    /// This is useful when we're not sure about the format of the location we're importing
308    #[arg(long)]
309    pub dry_run: bool,
310    /// Treat the location as a file path
311    #[arg(long, group = "location_type")]
312    pub file: bool,
313    /// Treat the location as a generic http(s) URL
314    #[arg(long, group = "location_type")]
315    pub http: bool,
316    /// Treat the location as a GitHub Gist URL or ID
317    #[arg(long, group = "location_type")]
318    pub gist: bool,
319    /// Treat the location as a shell history (requires --ai)
320    #[arg(long, value_enum, group = "location_type", requires = "ai")]
321    pub history: Option<HistorySource>,
322    /// Import commands matching the given regular expression only
323    ///
324    /// The regular expression will be checked against both the command and the description
325    #[arg(long, value_name = "REGEX")]
326    pub filter: Option<Regex>,
327    /// Add hastags to imported commands
328    ///
329    /// This argument can be specified multiple times to add more than one, hastags will be included at the end of the
330    /// description
331    #[arg(short = 't', long = "add-tag", value_name = "TAG")]
332    pub tags: Vec<String>,
333    /// Custom headers to include in the request
334    ///
335    /// This argument can be specified multiple times to add more than one header, but it will be only used for http
336    /// locations
337    #[arg(short = 'H', long = "header", value_name = "KEY: VALUE", value_parser = ValueParser::new(parse_header))]
338    pub headers: Vec<(HeaderName, HeaderValue)>,
339    /// HTTP method to use for the request
340    ///
341    /// It will be only used for http locations
342    #[arg(short = 'X', long = "request", value_enum, default_value_t = HttpMethod::GET)]
343    pub method: HttpMethod,
344}
345
346#[derive(ValueEnum, Copy, Clone, PartialEq, Eq, Debug)]
347pub enum HistorySource {
348    Bash,
349    Zsh,
350    Fish,
351    Powershell,
352    Atuin,
353}
354
355#[derive(ValueEnum, Copy, Clone, PartialEq, Eq, Debug)]
356pub enum HttpMethod {
357    GET,
358    POST,
359    PUT,
360    PATCH,
361}
362impl From<HttpMethod> for Method {
363    fn from(value: HttpMethod) -> Self {
364        match value {
365            HttpMethod::GET => Method::GET,
366            HttpMethod::POST => Method::POST,
367            HttpMethod::PUT => Method::PUT,
368            HttpMethod::PATCH => Method::PATCH,
369        }
370    }
371}
372
373/// Fetches command examples from tldr pages and imports them
374#[derive(Args, Debug)]
375pub struct TldrFetchProcess {
376    /// Category to fetch, skip to fetch for current platform (e.g., `common`, `linux`, `osx`, `windows`)
377    ///
378    /// For a full list of available categories, see: https://github.com/tldr-pages/tldr/tree/main/pages
379    pub category: Option<String>,
380
381    /// Fetches examples only for the specified command(s) (e.g., `git`, `docker`, `tar`)
382    ///
383    /// Command names should match their corresponding filenames (without the `.md` extension)
384    /// as found in the tldr pages repository
385    #[arg(short = 'c', long = "command", value_name = "COMMAND_NAME")]
386    pub commands: Vec<String>,
387
388    /// Fetches examples only for the command(s) from the file specified (reads from stdin if '-')
389    ///
390    /// The file or stdin must contain the command names as found in the tldr pages repository separated by newlines
391    #[arg(short = 'C', long, value_name = "FILE_OR_STDIN", num_args = 0..=1, default_missing_value = "-")]
392    pub filter_commands: Option<FileOrStdin>,
393}
394
395/// Clear command examples from tldr pages
396#[derive(Args, Debug)]
397pub struct TldrClearProcess {
398    /// Category to clear, skip to clear all categories
399    ///
400    /// For a full list of available categories, see: https://github.com/tldr-pages/tldr/tree/main/pages
401    pub category: Option<String>,
402}
403
404impl Cli {
405    /// Parses the [Cli] command, with any runtime extension required
406    #[instrument]
407    pub fn parse_extended() -> Self {
408        // Command definition
409        let mut cmd = Self::command_for_update();
410
411        // Update after_long_help to match the style, if present
412        let style = cmd.get_styles().clone();
413        let dimmed = style.get_placeholder().dimmed();
414        let plain_examples_header = "Examples:";
415        let styled_examples_header = format!(
416            "{}Examples:{}",
417            style.get_usage().render(),
418            style.get_usage().render_reset()
419        );
420        style_after_long_help(&mut cmd, &dimmed, plain_examples_header, &styled_examples_header);
421
422        // Parse the arguments
423        let matches = cmd.get_matches();
424
425        // Convert the argument matches back into the strongly typed `Cli` struct
426        match Cli::from_arg_matches(&matches) {
427            Ok(args) => args,
428            Err(err) => err.exit(),
429        }
430    }
431}
432
433fn style_after_long_help(
434    command_ref: &mut Command,
435    dimmed: &Style,
436    plain_examples_header: &str,
437    styled_examples_header: &str,
438) {
439    let mut command = std::mem::take(command_ref);
440    if let Some(after_long_help) = command.get_after_long_help() {
441        let current_help_text = after_long_help.to_string();
442        let modified_help_text = current_help_text
443            // Replace the examples header to match the same usage style
444            .replace(plain_examples_header, styled_examples_header)
445            // Style the comment lines to be dimmed
446            .lines()
447            .map(|line| {
448                if line.trim_start().starts_with('#') {
449                    format!("{}{}{}", dimmed.render(), line, dimmed.render_reset())
450                } else {
451                    line.to_string()
452                }
453            }).join("\n");
454        command = command.after_long_help(modified_help_text);
455    }
456    for subcommand_ref in command.get_subcommands_mut() {
457        style_after_long_help(subcommand_ref, dimmed, plain_examples_header, styled_examples_header);
458    }
459    *command_ref = command;
460}
461
462fn parse_env_var(env: &str) -> Result<(String, Option<String>)> {
463    if let Some((var, value)) = env.split_once('=') {
464        Ok((var.to_owned(), Some(value.to_owned())))
465    } else {
466        Ok((env.to_owned(), None))
467    }
468}
469
470fn parse_header(env: &str) -> Result<(HeaderName, HeaderValue)> {
471    if let Some((name, value)) = env.split_once(':') {
472        Ok((HeaderName::from_str(name)?, HeaderValue::from_str(value.trim_start())?))
473    } else {
474        Err(eyre!("Missing a colon between the header name and value"))
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    #[test]
483    fn test_cli_asserts() {
484        Cli::command().debug_assert()
485    }
486}