Skip to main content

par_term/
cli.rs

1//! Command-line interface for par-term.
2//!
3//! This module handles CLI argument parsing and subcommands like shader installation.
4
5use crate::config::ShellType;
6use crate::shader_installer;
7use crate::shell_integration_installer;
8use clap::{Parser, Subcommand};
9use std::io::{self, Write};
10use std::path::PathBuf;
11
12/// Shell type argument for CLI
13#[derive(Debug, Clone, Copy, clap::ValueEnum)]
14pub enum ShellTypeArg {
15    Bash,
16    Zsh,
17    Fish,
18}
19
20impl From<ShellTypeArg> for ShellType {
21    fn from(arg: ShellTypeArg) -> Self {
22        match arg {
23            ShellTypeArg::Bash => ShellType::Bash,
24            ShellTypeArg::Zsh => ShellType::Zsh,
25            ShellTypeArg::Fish => ShellType::Fish,
26        }
27    }
28}
29
30/// par-term - A GPU-accelerated terminal emulator
31#[derive(Parser)]
32#[command(name = "par-term")]
33#[command(author, version, about, long_about = None)]
34pub struct Cli {
35    #[command(subcommand)]
36    pub command: Option<Commands>,
37
38    /// Background shader to use (filename from shaders directory)
39    #[arg(long, value_name = "SHADER")]
40    pub shader: Option<String>,
41
42    /// Exit after the specified number of seconds
43    #[arg(long, value_name = "SECONDS")]
44    pub exit_after: Option<f64>,
45
46    /// Take a screenshot and save to the specified path (default: timestamped PNG in current dir)
47    #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
48    pub screenshot: Option<PathBuf>,
49
50    /// Send a command to the shell after 1 second delay
51    #[arg(long, value_name = "COMMAND")]
52    pub command_to_send: Option<String>,
53
54    /// Enable session logging (overrides config setting)
55    #[arg(long)]
56    pub log_session: bool,
57
58    /// Set debug log level (overrides config and RUST_LOG)
59    #[arg(long, value_enum, value_name = "LEVEL")]
60    pub log_level: Option<LogLevelArg>,
61}
62
63/// Log level argument for CLI
64#[derive(Debug, Clone, Copy, clap::ValueEnum)]
65pub enum LogLevelArg {
66    Off,
67    Error,
68    Warn,
69    Info,
70    Debug,
71    Trace,
72}
73
74impl LogLevelArg {
75    /// Convert to `log::LevelFilter`
76    pub fn to_level_filter(self) -> log::LevelFilter {
77        match self {
78            LogLevelArg::Off => log::LevelFilter::Off,
79            LogLevelArg::Error => log::LevelFilter::Error,
80            LogLevelArg::Warn => log::LevelFilter::Warn,
81            LogLevelArg::Info => log::LevelFilter::Info,
82            LogLevelArg::Debug => log::LevelFilter::Debug,
83            LogLevelArg::Trace => log::LevelFilter::Trace,
84        }
85    }
86}
87
88#[derive(Subcommand)]
89pub enum Commands {
90    /// Install shaders from the latest GitHub release
91    InstallShaders {
92        /// Skip confirmation prompt
93        #[arg(short = 'y', long)]
94        yes: bool,
95
96        /// Force overwrite without prompting
97        #[arg(short, long)]
98        force: bool,
99    },
100
101    /// Install shell integration for your shell
102    InstallShellIntegration {
103        /// Specify shell type (auto-detected if not provided)
104        #[arg(long, value_enum)]
105        shell: Option<ShellTypeArg>,
106    },
107
108    /// Uninstall shell integration
109    UninstallShellIntegration,
110
111    /// Uninstall shaders (removes bundled files, keeps user files)
112    UninstallShaders {
113        /// Force removal without prompting
114        #[arg(short, long)]
115        force: bool,
116    },
117
118    /// Install both shaders and shell integration
119    InstallIntegrations {
120        /// Skip confirmation prompts
121        #[arg(short = 'y', long)]
122        yes: bool,
123    },
124
125    /// Update par-term to the latest version
126    SelfUpdate {
127        /// Skip confirmation prompt
128        #[arg(short = 'y', long)]
129        yes: bool,
130    },
131
132    /// Run as an MCP server (used by ACP agents for config updates)
133    McpServer,
134}
135
136/// Runtime options passed from CLI to the application
137#[derive(Clone, Debug, Default)]
138pub struct RuntimeOptions {
139    /// Background shader to use
140    pub shader: Option<String>,
141    /// Exit after this many seconds
142    pub exit_after: Option<f64>,
143    /// Take a screenshot (Some(empty path) = auto-name, Some(path) = specific path, None = no screenshot)
144    pub screenshot: Option<PathBuf>,
145    /// Command to send to shell after delay
146    pub command_to_send: Option<String>,
147    /// Enable session logging (overrides config)
148    pub log_session: bool,
149    /// Log level override from CLI
150    pub log_level: Option<log::LevelFilter>,
151}
152
153/// Result of CLI processing
154pub enum CliResult {
155    /// Continue with normal application startup, with optional runtime options
156    Continue(RuntimeOptions),
157    /// Exit with the given code (subcommand completed)
158    Exit(i32),
159}
160
161/// Process CLI arguments and handle subcommands
162pub fn process_cli() -> CliResult {
163    let cli = Cli::parse();
164
165    match cli.command {
166        Some(Commands::InstallShaders { yes, force }) => {
167            let result = install_shaders_cli(yes || force);
168            CliResult::Exit(if result.is_ok() { 0 } else { 1 })
169        }
170        Some(Commands::InstallShellIntegration { shell }) => {
171            let result = install_shell_integration_cli(shell.map(Into::into));
172            CliResult::Exit(if result.is_ok() { 0 } else { 1 })
173        }
174        Some(Commands::UninstallShellIntegration) => {
175            let result = uninstall_shell_integration_cli();
176            CliResult::Exit(if result.is_ok() { 0 } else { 1 })
177        }
178        Some(Commands::UninstallShaders { force }) => {
179            let result = uninstall_shaders_cli(force);
180            CliResult::Exit(if result.is_ok() { 0 } else { 1 })
181        }
182        Some(Commands::InstallIntegrations { yes }) => {
183            let result = install_integrations_cli(yes);
184            CliResult::Exit(if result.is_ok() { 0 } else { 1 })
185        }
186        Some(Commands::SelfUpdate { yes }) => {
187            let result = self_update_cli(yes);
188            CliResult::Exit(if result.is_ok() { 0 } else { 1 })
189        }
190        Some(Commands::McpServer) => {
191            crate::mcp_server::run_mcp_server();
192        }
193        None => {
194            // Extract runtime options from CLI flags
195            let options = RuntimeOptions {
196                shader: cli.shader,
197                exit_after: cli.exit_after,
198                screenshot: cli.screenshot,
199                command_to_send: cli.command_to_send,
200                log_session: cli.log_session,
201                log_level: cli.log_level.map(|l| l.to_level_filter()),
202            };
203            CliResult::Continue(options)
204        }
205    }
206}
207
208/// Install shaders from the latest GitHub release (CLI version with prompts and output)
209fn install_shaders_cli(skip_prompt: bool) -> anyhow::Result<()> {
210    let shaders_dir = crate::config::Config::shaders_dir();
211
212    println!("=============================================");
213    println!("  par-term Shader Installer");
214    println!("=============================================");
215    println!();
216    println!("Target directory: {}", shaders_dir.display());
217    println!();
218
219    // Check if directory has existing shaders
220    if shaders_dir.exists() && shader_installer::has_shader_files(&shaders_dir) && !skip_prompt {
221        println!("WARNING: This will overwrite existing shaders in:");
222        println!("  {}", shaders_dir.display());
223        println!();
224        print!("Do you want to continue? [y/N] ");
225        io::stdout().flush()?;
226
227        let mut response = String::new();
228        io::stdin().read_line(&mut response)?;
229        let response = response.trim().to_lowercase();
230
231        if response != "y" && response != "yes" {
232            println!("Installation cancelled.");
233            return Ok(());
234        }
235        println!();
236    }
237
238    // Fetch latest release info
239    println!("Fetching latest release information...");
240
241    const REPO: &str = "paulrobello/par-term";
242    let api_url = format!("https://api.github.com/repos/{}/releases/latest", REPO);
243    let download_url = shader_installer::get_shaders_download_url(&api_url, REPO)
244        .map_err(|e| anyhow::anyhow!(e))?;
245
246    println!("Downloading shaders from: {}", download_url);
247    println!();
248
249    // Download the zip file
250    let zip_data =
251        shader_installer::download_file(&download_url).map_err(|e| anyhow::anyhow!(e))?;
252
253    // Create shaders directory if it doesn't exist
254    std::fs::create_dir_all(&shaders_dir)?;
255
256    // Extract shaders
257    println!("Extracting shaders to {}...", shaders_dir.display());
258    shader_installer::extract_shaders(&zip_data, &shaders_dir).map_err(|e| anyhow::anyhow!(e))?;
259
260    // Count installed shaders
261    let shader_count = shader_installer::count_shader_files(&shaders_dir);
262
263    println!();
264    println!("=============================================");
265    println!("  Installation complete!");
266    println!("=============================================");
267    println!();
268    println!("Installed {} shaders to:", shader_count);
269    println!("  {}", shaders_dir.display());
270    println!();
271    println!("To use a shader, add to your config.yaml:");
272    println!("  custom_shader: \"shader_name.glsl\"");
273    println!("  custom_shader_enabled: true");
274    println!();
275    println!("For cursor shaders:");
276    println!("  cursor_shader: \"cursor_glow.glsl\"");
277    println!("  cursor_shader_enabled: true");
278    println!();
279    println!("See docs/SHADERS.md for the full shader gallery.");
280
281    Ok(())
282}
283
284/// Install shell integration for the specified or detected shell (CLI version)
285fn install_shell_integration_cli(shell: Option<ShellType>) -> anyhow::Result<()> {
286    let detected = shell_integration_installer::detected_shell();
287    let target_shell = shell.unwrap_or(detected);
288
289    println!("=============================================");
290    println!("  par-term Shell Integration Installer");
291    println!("=============================================");
292    println!();
293
294    if target_shell == ShellType::Unknown {
295        eprintln!("Error: Could not detect shell type.");
296        eprintln!("Please specify your shell with --shell bash|zsh|fish");
297        return Err(anyhow::anyhow!("Unknown shell type"));
298    }
299
300    println!("Detected shell: {:?}", target_shell);
301
302    // Check if already installed
303    if shell_integration_installer::is_installed() {
304        println!("Shell integration is already installed.");
305        print!("Do you want to reinstall? [y/N] ");
306        io::stdout().flush()?;
307
308        let mut response = String::new();
309        io::stdin().read_line(&mut response)?;
310        let response = response.trim().to_lowercase();
311
312        if response != "y" && response != "yes" {
313            println!("Installation cancelled.");
314            return Ok(());
315        }
316        println!();
317    }
318
319    println!("Installing shell integration...");
320
321    match shell_integration_installer::install(Some(target_shell)) {
322        Ok(result) => {
323            println!();
324            println!("=============================================");
325            println!("  Installation complete!");
326            println!("=============================================");
327            println!();
328            println!("Script installed to:");
329            println!("  {}", result.script_path.display());
330            println!();
331            println!("Added source line to:");
332            println!("  {}", result.rc_file.display());
333            println!();
334            if result.needs_restart {
335                println!("Please restart your shell or run:");
336                println!("  source {}", result.rc_file.display());
337            }
338            Ok(())
339        }
340        Err(e) => {
341            eprintln!("Error: {}", e);
342            Err(anyhow::anyhow!(e))
343        }
344    }
345}
346
347/// Uninstall shell integration (CLI version)
348fn uninstall_shell_integration_cli() -> anyhow::Result<()> {
349    println!("=============================================");
350    println!("  par-term Shell Integration Uninstaller");
351    println!("=============================================");
352    println!();
353
354    if !shell_integration_installer::is_installed() {
355        println!("Shell integration is not installed.");
356        return Ok(());
357    }
358
359    println!("Uninstalling shell integration...");
360
361    match shell_integration_installer::uninstall() {
362        Ok(result) => {
363            println!();
364            println!("=============================================");
365            println!("  Uninstallation complete!");
366            println!("=============================================");
367            println!();
368
369            if !result.cleaned.is_empty() {
370                println!("Cleaned RC files:");
371                for path in &result.cleaned {
372                    println!("  {}", path.display());
373                }
374                println!();
375            }
376
377            if !result.scripts_removed.is_empty() {
378                println!("Removed integration scripts:");
379                for path in &result.scripts_removed {
380                    println!("  {}", path.display());
381                }
382                println!();
383            }
384
385            if !result.needs_manual.is_empty() {
386                println!("WARNING: Some files need manual cleanup:");
387                for path in &result.needs_manual {
388                    println!("  {}", path.display());
389                }
390                println!();
391            }
392
393            Ok(())
394        }
395        Err(e) => {
396            eprintln!("Error: {}", e);
397            Err(anyhow::anyhow!(e))
398        }
399    }
400}
401
402/// Uninstall shaders using manifest (CLI version)
403fn uninstall_shaders_cli(force: bool) -> anyhow::Result<()> {
404    let shaders_dir = crate::config::Config::shaders_dir();
405
406    println!("=============================================");
407    println!("  par-term Shader Uninstaller");
408    println!("=============================================");
409    println!();
410    println!("Shaders directory: {}", shaders_dir.display());
411    println!();
412
413    if !shaders_dir.exists() {
414        println!("No shaders installed.");
415        return Ok(());
416    }
417
418    // Check for manifest
419    let manifest_path = shaders_dir.join("manifest.json");
420    if !manifest_path.exists() {
421        println!("No manifest.json found. Cannot determine which files are bundled.");
422        println!("Only files installed with the installer can be safely uninstalled.");
423        return Err(anyhow::anyhow!("No manifest found"));
424    }
425
426    if !force {
427        println!("This will remove bundled shader files.");
428        println!("User-created and modified files will be preserved.");
429        println!();
430        print!("Do you want to continue? [y/N] ");
431        io::stdout().flush()?;
432
433        let mut response = String::new();
434        io::stdin().read_line(&mut response)?;
435        let response = response.trim().to_lowercase();
436
437        if response != "y" && response != "yes" {
438            println!("Uninstallation cancelled.");
439            return Ok(());
440        }
441        println!();
442    }
443
444    println!("Uninstalling shaders...");
445
446    match shader_installer::uninstall_shaders(force) {
447        Ok(result) => {
448            println!();
449            println!("=============================================");
450            println!("  Uninstallation complete!");
451            println!("=============================================");
452            println!();
453            println!("Removed {} bundled files.", result.removed);
454
455            if result.kept > 0 {
456                println!("Preserved {} user files.", result.kept);
457            }
458
459            if !result.needs_confirmation.is_empty() {
460                println!();
461                println!("Modified files that were preserved:");
462                for path in &result.needs_confirmation {
463                    println!("  {}", path);
464                }
465            }
466
467            Ok(())
468        }
469        Err(e) => {
470            eprintln!("Error: {}", e);
471            Err(anyhow::anyhow!(e))
472        }
473    }
474}
475
476/// Self-update par-term to the latest version (CLI version)
477fn self_update_cli(skip_prompt: bool) -> anyhow::Result<()> {
478    use crate::self_updater;
479    use crate::update_checker;
480
481    println!("=============================================");
482    println!("  par-term Self-Updater");
483    println!("=============================================");
484    println!();
485
486    let current_version = env!("CARGO_PKG_VERSION");
487    println!("Current version: {}", current_version);
488
489    // Detect installation type
490    let installation = self_updater::detect_installation();
491    println!("Installation type: {}", installation.description());
492    println!();
493
494    // Check for managed installations early
495    match &installation {
496        self_updater::InstallationType::Homebrew => {
497            println!("par-term is installed via Homebrew.");
498            println!("Please update with:");
499            println!("  brew upgrade --cask par-term");
500            return Err(anyhow::anyhow!("Cannot self-update Homebrew installation"));
501        }
502        self_updater::InstallationType::CargoInstall => {
503            println!("par-term is installed via cargo.");
504            println!("Please update with:");
505            println!("  cargo install par-term");
506            return Err(anyhow::anyhow!("Cannot self-update cargo installation"));
507        }
508        _ => {}
509    }
510
511    // Check for updates
512    println!("Checking for updates...");
513    let release_info = update_checker::fetch_latest_release().map_err(|e| anyhow::anyhow!(e))?;
514
515    let latest_version = release_info
516        .version
517        .strip_prefix('v')
518        .unwrap_or(&release_info.version);
519
520    let current = semver::Version::parse(current_version)?;
521    let latest = semver::Version::parse(latest_version)?;
522
523    if latest <= current {
524        println!();
525        println!(
526            "You are already running the latest version ({}).",
527            current_version
528        );
529        return Ok(());
530    }
531
532    println!();
533    println!(
534        "New version available: {} -> {}",
535        current_version, latest_version
536    );
537    if let Some(ref notes) = release_info.release_notes {
538        println!();
539        println!("Release notes:");
540        // Show first few lines of release notes
541        for line in notes.lines().take(10) {
542            println!("  {}", line);
543        }
544        if notes.lines().count() > 10 {
545            println!("  ...");
546        }
547    }
548    println!();
549
550    // Confirm unless --yes
551    if !skip_prompt {
552        print!("Do you want to update? [y/N] ");
553        io::stdout().flush()?;
554
555        let mut response = String::new();
556        io::stdin().read_line(&mut response)?;
557        let response = response.trim().to_lowercase();
558
559        if response != "y" && response != "yes" {
560            println!("Update cancelled.");
561            return Ok(());
562        }
563        println!();
564    }
565
566    println!("Downloading and installing update...");
567
568    match self_updater::perform_update(latest_version) {
569        Ok(result) => {
570            println!();
571            println!("=============================================");
572            println!("  Update complete!");
573            println!("=============================================");
574            println!();
575            println!("Updated: {} -> {}", result.old_version, result.new_version);
576            println!("Location: {}", result.install_path.display());
577            if result.needs_restart {
578                println!();
579                println!("Please restart par-term to use the new version.");
580            }
581            Ok(())
582        }
583        Err(e) => {
584            eprintln!("Update failed: {}", e);
585            Err(anyhow::anyhow!(e))
586        }
587    }
588}
589
590/// Install both shaders and shell integration (CLI version)
591fn install_integrations_cli(skip_prompt: bool) -> anyhow::Result<()> {
592    println!("=============================================");
593    println!("  par-term Integrations Installer");
594    println!("=============================================");
595    println!();
596    println!("This will install:");
597    println!("  1. Shader collection from latest release");
598    println!("  2. Shell integration for your current shell");
599    println!();
600
601    if !skip_prompt {
602        print!("Do you want to continue? [y/N] ");
603        io::stdout().flush()?;
604
605        let mut response = String::new();
606        io::stdin().read_line(&mut response)?;
607        let response = response.trim().to_lowercase();
608
609        if response != "y" && response != "yes" {
610            println!("Installation cancelled.");
611            return Ok(());
612        }
613        println!();
614    }
615
616    // Install shaders
617    println!("Step 1: Installing shaders...");
618    println!("---------------------------------------------");
619
620    let shader_result = install_shaders_cli(true);
621    if shader_result.is_err() {
622        println!();
623        println!("WARNING: Shader installation failed.");
624        println!("Continuing with shell integration...");
625    }
626
627    println!();
628    println!("Step 2: Installing shell integration...");
629    println!("---------------------------------------------");
630
631    let shell_result = install_shell_integration_cli(None);
632
633    println!();
634    println!("=============================================");
635    println!("  Integrations Installation Summary");
636    println!("=============================================");
637    println!();
638
639    match (&shader_result, &shell_result) {
640        (Ok(()), Ok(())) => {
641            println!("All integrations installed successfully!");
642        }
643        (Err(_), Ok(())) => {
644            println!("Shell integration: INSTALLED");
645            println!("Shaders: FAILED (see above for errors)");
646        }
647        (Ok(()), Err(_)) => {
648            println!("Shaders: INSTALLED");
649            println!("Shell integration: FAILED (see above for errors)");
650        }
651        (Err(_), Err(_)) => {
652            println!("Both installations failed. See above for errors.");
653        }
654    }
655
656    // Return success if at least one succeeded
657    if shader_result.is_ok() || shell_result.is_ok() {
658        Ok(())
659    } else {
660        Err(anyhow::anyhow!("Both installations failed"))
661    }
662}