Skip to main content

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