Skip to main content

par_term/cli/
install.rs

1//! CLI install, uninstall, and self-update procedure implementations.
2//!
3//! These functions are invoked by [`super::process_cli`] when the user runs
4//! install/uninstall/self-update subcommands. Each follows a
5//! prompt-confirm-execute-report pattern; this module and [`super::plugin`]
6//! are the only places in the codebase that use blocking stdin reads.
7
8use std::io::{self, Write};
9
10use crate::config::ShellType;
11use crate::shader_installer;
12use crate::shell_integration_installer;
13
14/// Install shaders from the latest GitHub release (CLI version with prompts and output)
15pub fn install_shaders_cli(skip_prompt: bool) -> anyhow::Result<()> {
16    let shaders_dir = crate::config::Config::shaders_dir();
17
18    println!("=============================================");
19    println!("  par-term Shader Installer");
20    println!("=============================================");
21    println!();
22    println!("Target directory: {}", shaders_dir.display());
23    println!();
24
25    // Check if directory has existing shaders
26    if shaders_dir.exists() && shader_installer::has_shader_files(&shaders_dir) && !skip_prompt {
27        println!("WARNING: This will overwrite existing shaders in:");
28        println!("  {}", shaders_dir.display());
29        println!();
30        print!("Do you want to continue? [y/N] ");
31        io::stdout().flush()?;
32
33        let mut response = String::new();
34        io::stdin().read_line(&mut response)?;
35        let response = response.trim().to_lowercase();
36
37        if response != "y" && response != "yes" {
38            println!("Installation cancelled.");
39            return Ok(());
40        }
41        println!();
42    }
43
44    // Fetch latest release info
45    println!("Fetching latest release information...");
46
47    const REPO: &str = "paulrobello/par-term";
48    let api_url = format!("https://api.github.com/repos/{}/releases/latest", REPO);
49    let (zip_url, checksum_url) = shader_installer::get_shaders_download_url(&api_url, REPO)
50        .map_err(|e| anyhow::anyhow!(e))?;
51
52    println!("Downloading shaders from: {}", zip_url);
53    println!();
54
55    // Download the zip file (with optional checksum verification)
56    let zip_data = shader_installer::download_and_verify(&zip_url, checksum_url.as_deref())
57        .map_err(|e| anyhow::anyhow!(e))?;
58
59    // Create shaders directory if it doesn't exist
60    std::fs::create_dir_all(&shaders_dir)?;
61
62    // Extract shaders
63    println!("Extracting shaders to {}...", shaders_dir.display());
64    shader_installer::extract_shaders(&zip_data, &shaders_dir).map_err(|e| anyhow::anyhow!(e))?;
65
66    // Count installed shaders
67    let shader_count = shader_installer::count_shader_files(&shaders_dir);
68
69    println!();
70    println!("=============================================");
71    println!("  Installation complete!");
72    println!("=============================================");
73    println!();
74    println!("Installed {} shaders to:", shader_count);
75    println!("  {}", shaders_dir.display());
76    println!();
77    println!("To use a shader, add to your config.yaml:");
78    println!("  custom_shader: \"shader_name.glsl\"");
79    println!("  custom_shader_enabled: true");
80    println!();
81    println!("For cursor shaders:");
82    println!("  cursor_shader: \"cursor_glow.glsl\"");
83    println!("  cursor_shader_enabled: true");
84    println!();
85    println!("See docs/SHADERS.md for the full shader gallery.");
86
87    Ok(())
88}
89
90/// Install shell integration for the specified or detected shell (CLI version)
91pub fn install_shell_integration_cli(shell: Option<ShellType>) -> anyhow::Result<()> {
92    let detected = shell_integration_installer::detected_shell();
93    let target_shell = shell.unwrap_or(detected);
94
95    println!("=============================================");
96    println!("  par-term Shell Integration Installer");
97    println!("=============================================");
98    println!();
99
100    if target_shell == ShellType::Unknown {
101        eprintln!("Error: Could not detect shell type.");
102        eprintln!("Please specify your shell with --shell bash|zsh|fish");
103        return Err(anyhow::anyhow!("Unknown shell type"));
104    }
105
106    println!("Detected shell: {:?}", target_shell);
107
108    // Check if already installed
109    if shell_integration_installer::is_installed() {
110        println!("Shell integration is already installed.");
111        print!("Do you want to reinstall? [y/N] ");
112        io::stdout().flush()?;
113
114        let mut response = String::new();
115        io::stdin().read_line(&mut response)?;
116        let response = response.trim().to_lowercase();
117
118        if response != "y" && response != "yes" {
119            println!("Installation cancelled.");
120            return Ok(());
121        }
122        println!();
123    }
124
125    println!("Installing shell integration...");
126
127    match shell_integration_installer::install(Some(target_shell)) {
128        Ok(result) => {
129            println!();
130            println!("=============================================");
131            println!("  Installation complete!");
132            println!("=============================================");
133            println!();
134            println!("Script installed to:");
135            println!("  {}", result.script_path.display());
136            println!();
137            println!("Added source line to:");
138            println!("  {}", result.rc_file.display());
139            println!();
140            if result.needs_restart {
141                println!("Please restart your shell or run:");
142                println!("  source {}", result.rc_file.display());
143            }
144            Ok(())
145        }
146        Err(e) => {
147            eprintln!("Error: {}", e);
148            Err(anyhow::anyhow!(e))
149        }
150    }
151}
152
153/// Uninstall shell integration (CLI version)
154pub fn uninstall_shell_integration_cli() -> anyhow::Result<()> {
155    println!("=============================================");
156    println!("  par-term Shell Integration Uninstaller");
157    println!("=============================================");
158    println!();
159
160    if !shell_integration_installer::is_installed() {
161        println!("Shell integration is not installed.");
162        return Ok(());
163    }
164
165    println!("Uninstalling shell integration...");
166
167    match shell_integration_installer::uninstall() {
168        Ok(result) => {
169            println!();
170            println!("=============================================");
171            println!("  Uninstallation complete!");
172            println!("=============================================");
173            println!();
174
175            if !result.cleaned.is_empty() {
176                println!("Cleaned RC files:");
177                for path in &result.cleaned {
178                    println!("  {}", path.display());
179                }
180                println!();
181            }
182
183            if !result.scripts_removed.is_empty() {
184                println!("Removed integration scripts:");
185                for path in &result.scripts_removed {
186                    println!("  {}", path.display());
187                }
188                println!();
189            }
190
191            if !result.needs_manual.is_empty() {
192                println!("WARNING: Some files need manual cleanup:");
193                for path in &result.needs_manual {
194                    println!("  {}", path.display());
195                }
196                println!();
197            }
198
199            Ok(())
200        }
201        Err(e) => {
202            eprintln!("Error: {}", e);
203            Err(anyhow::anyhow!(e))
204        }
205    }
206}
207
208/// Uninstall shaders using manifest (CLI version)
209pub fn uninstall_shaders_cli(force: bool) -> anyhow::Result<()> {
210    let shaders_dir = crate::config::Config::shaders_dir();
211
212    println!("=============================================");
213    println!("  par-term Shader Uninstaller");
214    println!("=============================================");
215    println!();
216    println!("Shaders directory: {}", shaders_dir.display());
217    println!();
218
219    if !shaders_dir.exists() {
220        println!("No shaders installed.");
221        return Ok(());
222    }
223
224    // Check for manifest
225    let manifest_path = shaders_dir.join("manifest.json");
226    if !manifest_path.exists() {
227        println!("No manifest.json found. Cannot determine which files are bundled.");
228        println!("Only files installed with the installer can be safely uninstalled.");
229        return Err(anyhow::anyhow!("No manifest found"));
230    }
231
232    if !force {
233        println!("This will remove bundled shader files.");
234        println!("User-created and modified files will be preserved.");
235        println!();
236        print!("Do you want to continue? [y/N] ");
237        io::stdout().flush()?;
238
239        let mut response = String::new();
240        io::stdin().read_line(&mut response)?;
241        let response = response.trim().to_lowercase();
242
243        if response != "y" && response != "yes" {
244            println!("Uninstallation cancelled.");
245            return Ok(());
246        }
247        println!();
248    }
249
250    println!("Uninstalling shaders...");
251
252    match shader_installer::uninstall_shaders(force) {
253        Ok(result) => {
254            println!();
255            println!("=============================================");
256            println!("  Uninstallation complete!");
257            println!("=============================================");
258            println!();
259            println!("Removed {} bundled files.", result.removed);
260
261            if result.kept > 0 {
262                println!("Preserved {} user files.", result.kept);
263            }
264
265            if !result.needs_confirmation.is_empty() {
266                println!();
267                println!("Modified files that were preserved:");
268                for path in &result.needs_confirmation {
269                    println!("  {}", path);
270                }
271            }
272
273            Ok(())
274        }
275        Err(e) => {
276            eprintln!("Error: {}", e);
277            Err(anyhow::anyhow!(e))
278        }
279    }
280}
281
282/// Self-update par-term to the latest version (CLI version)
283pub fn self_update_cli(skip_prompt: bool) -> anyhow::Result<()> {
284    use par_term_update::self_updater;
285    use par_term_update::update_checker;
286
287    println!("=============================================");
288    println!("  par-term Self-Updater");
289    println!("=============================================");
290    println!();
291
292    let current_version = env!("CARGO_PKG_VERSION");
293    println!("Current version: {}", current_version);
294
295    // Detect installation type
296    let installation = self_updater::detect_installation().map_err(|e| anyhow::anyhow!(e))?;
297    println!("Installation type: {}", installation.description());
298    println!();
299
300    // Check for managed installations early
301    match &installation {
302        self_updater::InstallationType::Homebrew => {
303            println!("par-term is installed via Homebrew.");
304            println!("Please update with:");
305            println!("  brew upgrade --cask par-term");
306            return Err(anyhow::anyhow!("Cannot self-update Homebrew installation"));
307        }
308        self_updater::InstallationType::CargoInstall => {
309            println!("par-term is installed via cargo.");
310            println!("Please update with:");
311            println!("  cargo install par-term");
312            return Err(anyhow::anyhow!("Cannot self-update cargo installation"));
313        }
314        _ => {}
315    }
316
317    // Check for updates
318    println!("Checking for updates...");
319    let release_info = update_checker::fetch_latest_release().map_err(|e| anyhow::anyhow!(e))?;
320
321    let latest_version = release_info
322        .version
323        .strip_prefix('v')
324        .unwrap_or(&release_info.version);
325
326    let current = semver::Version::parse(current_version)?;
327    let latest = semver::Version::parse(latest_version)?;
328
329    if latest <= current {
330        println!();
331        println!(
332            "You are already running the latest version ({}).",
333            current_version
334        );
335        return Ok(());
336    }
337
338    println!();
339    println!(
340        "New version available: {} -> {}",
341        current_version, latest_version
342    );
343    if let Some(ref notes) = release_info.release_notes {
344        println!();
345        println!("Release notes:");
346        // Show first few lines of release notes
347        for line in notes.lines().take(10) {
348            println!("  {}", line);
349        }
350        if notes.lines().count() > 10 {
351            println!("  ...");
352        }
353    }
354    println!();
355
356    // Confirm unless --yes
357    if !skip_prompt {
358        print!("Do you want to update? [y/N] ");
359        io::stdout().flush()?;
360
361        let mut response = String::new();
362        io::stdin().read_line(&mut response)?;
363        let response = response.trim().to_lowercase();
364
365        if response != "y" && response != "yes" {
366            println!("Update cancelled.");
367            return Ok(());
368        }
369        println!();
370    }
371
372    println!("Downloading and installing update...");
373
374    match self_updater::perform_update(latest_version, crate::VERSION) {
375        Ok(result) => {
376            println!();
377            println!("=============================================");
378            println!("  Update complete!");
379            println!("=============================================");
380            println!();
381            println!("Updated: {} -> {}", result.old_version, result.new_version);
382            println!("Location: {}", result.install_path.display());
383            if result.needs_restart {
384                println!();
385                println!("Please restart par-term to use the new version.");
386            }
387            Ok(())
388        }
389        Err(e) => {
390            eprintln!("Update failed: {}", e);
391            Err(anyhow::anyhow!(e))
392        }
393    }
394}
395
396/// Install both shaders and shell integration (CLI version)
397pub fn install_integrations_cli(skip_prompt: bool) -> anyhow::Result<()> {
398    println!("=============================================");
399    println!("  par-term Integrations Installer");
400    println!("=============================================");
401    println!();
402    println!("This will install:");
403    println!("  1. Shader collection from latest release");
404    println!("  2. Shell integration for your current shell");
405    println!();
406
407    if !skip_prompt {
408        print!("Do you want to continue? [y/N] ");
409        io::stdout().flush()?;
410
411        let mut response = String::new();
412        io::stdin().read_line(&mut response)?;
413        let response = response.trim().to_lowercase();
414
415        if response != "y" && response != "yes" {
416            println!("Installation cancelled.");
417            return Ok(());
418        }
419        println!();
420    }
421
422    // Install shaders
423    println!("Step 1: Installing shaders...");
424    println!("---------------------------------------------");
425
426    let shader_result = install_shaders_cli(true);
427    if shader_result.is_err() {
428        println!();
429        println!("WARNING: Shader installation failed.");
430        println!("Continuing with shell integration...");
431    }
432
433    println!();
434    println!("Step 2: Installing shell integration...");
435    println!("---------------------------------------------");
436
437    let shell_result = install_shell_integration_cli(None);
438
439    println!();
440    println!("=============================================");
441    println!("  Integrations Installation Summary");
442    println!("=============================================");
443    println!();
444
445    match (&shader_result, &shell_result) {
446        (Ok(()), Ok(())) => {
447            println!("All integrations installed successfully!");
448        }
449        (Err(_), Ok(())) => {
450            println!("Shell integration: INSTALLED");
451            println!("Shaders: FAILED (see above for errors)");
452        }
453        (Ok(()), Err(_)) => {
454            println!("Shaders: INSTALLED");
455            println!("Shell integration: FAILED (see above for errors)");
456        }
457        (Err(_), Err(_)) => {
458            println!("Both installations failed. See above for errors.");
459        }
460    }
461
462    // Return success if at least one succeeded
463    if shader_result.is_ok() || shell_result.is_ok() {
464        Ok(())
465    } else {
466        Err(anyhow::anyhow!("Both installations failed"))
467    }
468}
469
470/// Install the par-mux agent-state extensions for pi and omp (CLI version)
471pub fn install_mux_extensions_cli(skip_prompt: bool) -> anyhow::Result<()> {
472    use crate::mux_extension_installer;
473
474    println!("=============================================");
475    println!("  par-term par-mux Extension Installer");
476    println!("=============================================");
477    println!();
478    println!("This installs the par-mux agent-state extension into each");
479    println!("installed agent's extension directory:");
480    println!(
481        "  pi:  {}",
482        dir_display(mux_extension_installer::pi_extension_dir())
483    );
484    println!(
485        "  omp: {}",
486        dir_display(mux_extension_installer::omp_extension_dir())
487    );
488    println!();
489    println!("An agent that is not installed is reported and skipped.");
490
491    if !skip_prompt {
492        println!();
493        print!("Do you want to continue? [y/N] ");
494        io::stdout().flush()?;
495
496        let mut response = String::new();
497        io::stdin().read_line(&mut response)?;
498        let response = response.trim().to_lowercase();
499
500        if response != "y" && response != "yes" {
501            println!("Installation cancelled.");
502            return Ok(());
503        }
504    }
505    println!();
506
507    let pi_result = mux_extension_installer::install_pi_extension();
508    report_install("pi", &pi_result);
509    let omp_result = mux_extension_installer::install_omp_extension();
510    report_install("omp", &omp_result);
511
512    if pi_result.is_ok() || omp_result.is_ok() {
513        println!();
514        println!("At least one extension installed successfully.");
515        Ok(())
516    } else {
517        println!();
518        Err(anyhow::anyhow!("No extension installed (see errors above)"))
519    }
520}
521
522/// Uninstall the par-mux agent-state extensions (CLI version)
523pub fn uninstall_mux_extensions_cli() -> anyhow::Result<()> {
524    use crate::mux_extension_installer;
525
526    println!("=============================================");
527    println!("  par-term par-mux Extension Uninstaller");
528    println!("=============================================");
529    println!();
530
531    let pi_result = mux_extension_installer::uninstall_pi_extension();
532    report_removal("pi", &pi_result);
533    let omp_result = mux_extension_installer::uninstall_omp_extension();
534    report_removal("omp", &omp_result);
535
536    println!();
537    println!("Done.");
538    Ok(())
539}
540
541/// Install the par-mux session hooks for config-entry agents (CLI version)
542pub fn install_mux_hooks_cli(skip_prompt: bool) -> anyhow::Result<()> {
543    use crate::mux_hook_installer;
544
545    println!("=============================================");
546    println!("  par-term par-mux Hook Installer");
547    println!("=============================================");
548    println!();
549    println!("This installs the par-mux session hooks. Unlike the pi/omp");
550    println!("extensions (a file drop into the agent's directory), these agents");
551    println!("have no extension directory: the hook is an ENTRY inside your");
552    println!("existing agent config, merged without touching your other settings,");
553    println!("comments, or formatting. Reinstalling is a no-op; uninstalling");
554    println!("removes only par-term's entries.");
555    println!();
556    println!("Agents:");
557    println!(
558        "  claude: {}",
559        dir_display(mux_hook_installer::claude_settings_path())
560    );
561    println!(
562        "  codex:  {}",
563        dir_display(mux_hook_installer::codex_config_dir())
564    );
565    println!(
566        "  grok:  {}",
567        dir_display(mux_hook_installer::grok_config_dir().map(|dir| dir.join("hooks")))
568    );
569    println!();
570
571    if !skip_prompt {
572        println!();
573        print!("Do you want to continue? [y/N] ");
574        io::stdout().flush()?;
575
576        let mut response = String::new();
577        io::stdin().read_line(&mut response)?;
578        let response = response.trim().to_lowercase();
579
580        if response != "y" && response != "yes" {
581            println!("Installation cancelled.");
582            return Ok(());
583        }
584    }
585    println!();
586
587    let claude_result = mux_hook_installer::install_claude_hook();
588    match &claude_result {
589        Ok(result) if result.settings_changed => {
590            println!(
591                "claude: hook entry added to {} (script at {})",
592                result.settings_path.display(),
593                result.hook_path.display()
594            );
595        }
596        Ok(result) => {
597            println!(
598                "claude: already installed ({} unchanged, script refreshed)",
599                result.settings_path.display()
600            );
601        }
602        Err(err) => println!("claude: FAILED — {err}"),
603    }
604
605    // codex reads claude-format hooks from hooks.json gated by a
606    // `[features] hooks = true` flag in config.toml.
607    let codex_result = mux_hook_installer::install_codex_hook();
608    match &codex_result {
609        Ok(result) => {
610            let summary = match (result.hooks_changed, result.config_changed) {
611                (true, true) => format!(
612                    "hook entry added to {} and features.hooks enabled in {} (script at {})",
613                    result.hooks_path.display(),
614                    result.config_path.display(),
615                    result.hook_path.display()
616                ),
617                (true, false) => format!(
618                    "hook entry added to {} (features.hooks already enabled; script at {})",
619                    result.hooks_path.display(),
620                    result.hook_path.display()
621                ),
622                (false, true) => format!(
623                    "features.hooks enabled in {} (hook entry already present; script at {})",
624                    result.config_path.display(),
625                    result.hook_path.display()
626                ),
627                (false, false) => format!(
628                    "already installed ({} unchanged, script refreshed)",
629                    result.hooks_path.display()
630                ),
631            };
632            println!("codex:  {summary}");
633        }
634        Err(err) => println!("codex:  FAILED — {err}"),
635    }
636
637    // grok merges every <config>/hooks/*.json, so its arm writes an owned
638    // config file instead of merging.
639    let grok_result = mux_hook_installer::install_grok_hook();
640    match &grok_result {
641        Ok(result) => println!(
642            "grok:  hook config written to {} (script at {})",
643            result.config_path.display(),
644            result.hook_path.display()
645        ),
646        Err(err) => println!("grok:  FAILED — {err}"),
647    }
648
649    if claude_result.is_ok() || codex_result.is_ok() || grok_result.is_ok() {
650        println!();
651        println!("At least one hook installed successfully.");
652        Ok(())
653    } else {
654        println!();
655        Err(anyhow::anyhow!("No hook installed (see errors above)"))
656    }
657}
658
659/// Uninstall the par-mux session hooks (CLI version)
660pub fn uninstall_mux_hooks_cli() -> anyhow::Result<()> {
661    use crate::mux_hook_installer;
662
663    println!("=============================================");
664    println!("  par-term par-mux Hook Uninstaller");
665    println!("=============================================");
666    println!();
667
668    let claude_result = mux_hook_installer::uninstall_claude_hook();
669    match &claude_result {
670        Ok(result) => {
671            if result.settings_changed {
672                println!(
673                    "claude: entries removed from {}",
674                    result.settings_path.display()
675                );
676            }
677            if result.hook_removed {
678                println!("claude: removed {}", result.hook_path.display());
679            }
680            if !result.settings_changed && !result.hook_removed {
681                if result.hook_path.is_file() {
682                    println!(
683                        "claude: {} exists but is not par-term's (left untouched)",
684                        result.hook_path.display()
685                    );
686                } else {
687                    println!("claude: not installed");
688                }
689            }
690        }
691        Err(err) => println!("claude: FAILED — {err}"),
692    }
693
694    let codex_result = mux_hook_installer::uninstall_codex_hook();
695    match &codex_result {
696        Ok(result) => {
697            if result.hooks_changed {
698                println!(
699                    "codex:  entries removed from {}",
700                    result.hooks_path.display()
701                );
702            }
703            if result.hook_removed {
704                println!("codex:  removed {}", result.hook_path.display());
705            }
706            if !result.hooks_changed && !result.hook_removed {
707                if result.hook_path.is_file() {
708                    println!(
709                        "codex:  {} exists but is not par-term's (left untouched)",
710                        result.hook_path.display()
711                    );
712                } else {
713                    println!("codex:  not installed");
714                }
715            }
716            if result.hooks_changed || result.hook_removed {
717                println!(
718                    "codex:  note: [features] hooks = true left in {} — inert while no hooks remain; remove it manually if unwanted",
719                    result.config_path.display()
720                );
721            }
722        }
723        Err(err) => println!("codex:  FAILED — {err}"),
724    }
725
726    let grok_result = mux_hook_installer::uninstall_grok_hook();
727    match &grok_result {
728        Ok(result) => {
729            if result.config_removed {
730                println!("grok:  removed {}", result.config_path.display());
731            }
732            if result.hook_removed {
733                println!("grok:  removed {}", result.hook_path.display());
734            }
735            if !result.config_removed && !result.hook_removed {
736                if result.hook_path.is_file() {
737                    println!(
738                        "grok:  {} exists but is not par-term's (left untouched)",
739                        result.hook_path.display()
740                    );
741                } else {
742                    println!("grok:  not installed");
743                }
744            }
745        }
746        Err(err) => println!("grok:  FAILED — {err}"),
747    }
748
749    println!();
750    println!("Done.");
751    Ok(())
752}
753
754fn dir_display(dir: std::io::Result<std::path::PathBuf>) -> String {
755    match dir {
756        Ok(path) => path.display().to_string(),
757        Err(err) => format!("<unresolved: {err}>"),
758    }
759}
760
761fn report_install(agent: &str, result: &std::io::Result<std::path::PathBuf>) {
762    match result {
763        Ok(path) => println!("{agent}: installed at {}", path.display()),
764        Err(err) => println!("{agent}: FAILED — {err}"),
765    }
766}
767
768fn report_removal(
769    agent: &str,
770    result: &std::io::Result<crate::mux_extension_installer::ExtensionRemoval>,
771) {
772    match result {
773        Ok(removal) if removal.removed => {
774            println!("{agent}: removed {}", removal.path.display());
775        }
776        Ok(removal) if removal.path.is_file() => println!(
777            "{agent}: {} exists but is not par-term's (left untouched)",
778            removal.path.display()
779        ),
780        Ok(_) => println!("{agent}: not installed"),
781        Err(err) => println!("{agent}: FAILED — {err}"),
782    }
783}