Skip to main content

par_term/cli/
plugin.rs

1//! CLI plugin management: `par-term plugin add|update|remove`.
2//!
3//! Prompt-confirm-execute-report wrappers around the headless git layer in
4//! [`par_term_scripting::plugin_git`], which owns every git invocation. The
5//! prompts are the procedural half of the plugin trust model: add warns
6//! before cloning anything, and update shows a diff before fast-forwarding,
7//! so no upstream code lands without the user seeing it happen. Like
8//! [`super::install`], these subcommands run and exit before the application
9//! starts, which is why blocking stdin reads are safe here.
10
11use std::io::{self, Write};
12use std::path::Path;
13
14use clap::Subcommand;
15
16use par_term_scripting::manifest::discover_plugins;
17use par_term_scripting::plugin_git;
18
19/// `par-term plugin <command>` — see `Commands::Plugin` in [`super`].
20#[derive(Subcommand)]
21pub enum PluginCommands {
22    /// Install a plugin from a git URL; it lands DISABLED
23    Add {
24        /// Git URL to clone from (https://, ssh://, git://, file://, scp-like)
25        url: String,
26
27        /// Skip the confirmation prompt
28        #[arg(short = 'y', long)]
29        yes: bool,
30    },
31
32    /// Fetch upstream and fast-forward installed plugins after showing a diff
33    Update {
34        /// Plugin id; updates every git-installed plugin when omitted
35        id: Option<String>,
36
37        /// Skip the per-plugin confirmation prompt
38        #[arg(short = 'y', long)]
39        yes: bool,
40    },
41
42    /// Remove a plugin that was installed by `plugin add`
43    Remove {
44        /// Plugin id to remove
45        id: String,
46
47        /// Skip the confirmation prompt
48        #[arg(short = 'y', long)]
49        yes: bool,
50    },
51}
52
53/// `par-term plugin add <url>`
54pub fn plugin_add_cli(url: &str, yes: bool) -> anyhow::Result<()> {
55    let root = crate::config::Config::config_dir().join("plugins");
56    println!("Plugins directory: {}", root.display());
57    println!();
58    println!("About to clone a plugin from:");
59    println!("  {url}");
60    println!();
61    println!("Plugins run as supervised subprocesses of par-term. This one is");
62    println!("installed DISABLED — read its code before enabling it in");
63    println!("Settings > Automation > Plugins.");
64    if !yes && !confirm("Clone and install? [y/N] ")? {
65        println!("Cancelled.");
66        return Ok(());
67    }
68    println!();
69    match plugin_git::add(&root, url) {
70        Ok(plugin) => {
71            let manifest = &plugin.manifest;
72            println!(
73                "Installed {} {} ({})",
74                manifest.name, manifest.version, manifest.id
75            );
76            println!("  {}", root.join(&manifest.id).display());
77            println!();
78            println!("It is DISABLED. Read the code, then enable it in");
79            println!("Settings > Automation > Plugins.");
80            Ok(())
81        }
82        Err(reason) => {
83            eprintln!("Error: {reason}");
84            Err(anyhow::anyhow!(reason))
85        }
86    }
87}
88
89/// `par-term plugin update [id]`
90pub fn plugin_update_cli(id: Option<&str>, yes: bool) -> anyhow::Result<()> {
91    let root = crate::config::Config::config_dir().join("plugins");
92    let targets: Vec<String> = match id {
93        Some(one) => vec![one.to_string()],
94        None => {
95            let (discovered, _) = discover_plugins(&root);
96            let mut ids: Vec<String> = discovered
97                .into_iter()
98                .map(|plugin| plugin.manifest.id)
99                .filter(|pid| plugin_git::is_git_installed(&root.join(pid)))
100                .collect();
101            ids.sort();
102            if ids.is_empty() {
103                println!("No git-installed plugins under {}.", root.display());
104                println!("Plugins installed by copying a directory by hand are updated");
105                println!("the same way — `plugin update` only manages what it installed.");
106                return Ok(());
107            }
108            ids
109        }
110    };
111
112    let mut failures = 0usize;
113    for pid in targets {
114        println!("=== {pid} ===");
115        if let Err(reason) = update_one(&root, &pid, yes) {
116            eprintln!("Error: {reason}");
117            failures += 1;
118        }
119        println!();
120    }
121    if failures > 0 {
122        return Err(anyhow::anyhow!("{failures} plugin update(s) failed"));
123    }
124    Ok(())
125}
126
127fn update_one(root: &Path, pid: &str, yes: bool) -> anyhow::Result<()> {
128    let dir = root.join(pid);
129    let preview = plugin_git::update_fetch(&dir).map_err(|reason| anyhow::anyhow!(reason))?;
130    if preview.up_to_date {
131        println!("Already up to date ({}).", preview.current);
132        return Ok(());
133    }
134    println!("{} -> {}", preview.current, preview.incoming);
135    println!();
136    println!("{}", preview.diff);
137    println!();
138    if !yes && !confirm("Apply this update? [y/N] ")? {
139        println!("Skipped.");
140        return Ok(());
141    }
142    let new_head = plugin_git::update_apply(&dir).map_err(|reason| anyhow::anyhow!(reason))?;
143    println!("Updated to {new_head}.");
144    Ok(())
145}
146
147/// `par-term plugin remove <id>`
148pub fn plugin_remove_cli(id: &str, yes: bool) -> anyhow::Result<()> {
149    let root = crate::config::Config::config_dir().join("plugins");
150    println!("About to remove:");
151    println!("  {}", root.join(id).display());
152    println!();
153    if !yes && !confirm("Remove this plugin? [y/N] ")? {
154        println!("Cancelled.");
155        return Ok(());
156    }
157    match plugin_git::remove(&root, id) {
158        Ok(()) => {
159            println!("Removed {id}.");
160            Ok(())
161        }
162        Err(reason) => {
163            eprintln!("Error: {reason}");
164            Err(anyhow::anyhow!(reason))
165        }
166    }
167}
168
169fn confirm(prompt: &str) -> anyhow::Result<bool> {
170    print!("{prompt}");
171    io::stdout().flush()?;
172    let mut response = String::new();
173    io::stdin().read_line(&mut response)?;
174    let response = response.trim().to_lowercase();
175    Ok(response == "y" || response == "yes")
176}