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