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