Skip to main content

par_term/cli/
mod.rs

1//! Command-line interface for par-term.
2//!
3//! This module handles CLI argument parsing and subcommands like shader installation.
4//! Install/uninstall procedure implementations live in the [`install`] submodule;
5//! plugin add/update/remove in [`plugin`].
6
7pub mod install;
8pub mod plugin;
9
10use crate::config::ShellType;
11use clap::{Parser, Subcommand};
12use std::path::PathBuf;
13
14/// Shell type argument for CLI
15#[derive(Debug, Clone, Copy, clap::ValueEnum)]
16pub enum ShellTypeArg {
17    Bash,
18    Zsh,
19    Fish,
20}
21
22impl From<ShellTypeArg> for ShellType {
23    fn from(arg: ShellTypeArg) -> Self {
24        match arg {
25            ShellTypeArg::Bash => ShellType::Bash,
26            ShellTypeArg::Zsh => ShellType::Zsh,
27            ShellTypeArg::Fish => ShellType::Fish,
28        }
29    }
30}
31
32/// par-term - A GPU-accelerated terminal emulator
33#[derive(Parser)]
34#[command(name = "par-term")]
35#[command(author, version, about, long_about = None)]
36pub struct Cli {
37    #[command(subcommand)]
38    pub command: Option<Commands>,
39
40    /// Background shader to use (filename from shaders directory)
41    #[arg(long, value_name = "SHADER")]
42    pub shader: Option<String>,
43
44    /// Exit after the specified number of seconds
45    #[arg(long, value_name = "SECONDS")]
46    pub exit_after: Option<f64>,
47
48    /// Take a screenshot and save to the specified path (default: timestamped PNG in current dir)
49    #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
50    pub screenshot: Option<PathBuf>,
51
52    /// Send a command to the shell after 1 second delay
53    #[arg(long, value_name = "COMMAND")]
54    pub command_to_send: Option<String>,
55
56    /// Run a scripted UI test (JSON script; see
57    /// docs/guides/AGENT_UI_VERIFICATION.md), then write a report and exit
58    #[arg(long, value_name = "SCRIPT")]
59    pub ui_test: Option<PathBuf>,
60
61    /// Where the --ui-test JSON report is written (default: beside the script)
62    #[arg(long, value_name = "PATH", requires = "ui_test")]
63    pub ui_test_report: Option<PathBuf>,
64
65    /// Enable session logging (overrides config setting)
66    #[arg(long)]
67    pub log_session: bool,
68
69    /// Set debug log level (overrides config and RUST_LOG)
70    #[arg(long, value_enum, value_name = "LEVEL")]
71    pub log_level: Option<LogLevelArg>,
72}
73
74/// Log level argument for CLI
75#[derive(Debug, Clone, Copy, clap::ValueEnum)]
76pub enum LogLevelArg {
77    Off,
78    Error,
79    Warn,
80    Info,
81    Debug,
82    Trace,
83}
84
85impl LogLevelArg {
86    /// Convert to `log::LevelFilter`
87    pub fn to_level_filter(self) -> log::LevelFilter {
88        match self {
89            LogLevelArg::Off => log::LevelFilter::Off,
90            LogLevelArg::Error => log::LevelFilter::Error,
91            LogLevelArg::Warn => log::LevelFilter::Warn,
92            LogLevelArg::Info => log::LevelFilter::Info,
93            LogLevelArg::Debug => log::LevelFilter::Debug,
94            LogLevelArg::Trace => log::LevelFilter::Trace,
95        }
96    }
97}
98
99#[derive(Subcommand)]
100pub enum Commands {
101    /// Install shaders from the latest GitHub release
102    InstallShaders {
103        /// Skip confirmation prompt
104        #[arg(short = 'y', long)]
105        yes: bool,
106
107        /// Force overwrite without prompting
108        #[arg(short, long)]
109        force: bool,
110    },
111
112    /// Install shell integration for your shell
113    InstallShellIntegration {
114        /// Specify shell type (auto-detected if not provided)
115        #[arg(long, value_enum)]
116        shell: Option<ShellTypeArg>,
117    },
118
119    /// Uninstall shell integration
120    UninstallShellIntegration,
121
122    /// Uninstall shaders (removes bundled files, keeps user files)
123    UninstallShaders {
124        /// Force removal without prompting
125        #[arg(short, long)]
126        force: bool,
127    },
128
129    /// Install par-mux agent-state extensions for pi and omp
130    InstallMuxExtensions {
131        /// Skip confirmation prompt
132        #[arg(short = 'y', long)]
133        yes: bool,
134    },
135
136    /// Uninstall par-mux agent-state extensions
137    UninstallMuxExtensions,
138
139    /// Install par-mux session hooks for config-entry agents (claude)
140    InstallMuxHooks {
141        /// Skip confirmation prompt
142        #[arg(short = 'y', long)]
143        yes: bool,
144    },
145
146    /// Uninstall par-mux session hooks
147    UninstallMuxHooks,
148
149    /// Lint a custom background shader file
150    ShaderLint {
151        /// Path to the .glsl shader file to lint
152        path: PathBuf,
153
154        /// Include source-level readability scoring and suggested readability defaults
155        #[arg(long)]
156        readability: bool,
157
158        /// Apply suggested readability defaults to the shader metadata without prompting
159        #[arg(long)]
160        apply: bool,
161
162        /// Do not prompt to apply suggested readability defaults
163        #[arg(long)]
164        no_prompt: bool,
165    },
166
167    /// Install both shaders and shell integration
168    InstallIntegrations {
169        /// Skip confirmation prompts
170        #[arg(short = 'y', long)]
171        yes: bool,
172    },
173
174    /// Update par-term to the latest version
175    SelfUpdate {
176        /// Skip confirmation prompt
177        #[arg(short = 'y', long)]
178        yes: bool,
179    },
180
181    /// Manage plugins: install from a git URL, update, remove
182    Plugin {
183        #[command(subcommand)]
184        command: plugin::PluginCommands,
185    },
186
187    /// Run as an MCP server (used by ACP agents for config updates)
188    McpServer,
189
190    /// Agent/user command fallthrough: `par-term <id> [args...]` runs the
191    /// command file `<config_dir>/commands/<id>.yaml` when `<id>` matches no
192    /// built-in subcommand above. Real subcommands always win (clap-native).
193    #[command(external_subcommand)]
194    External(Vec<String>),
195}
196
197/// Runtime options passed from CLI to the application
198#[derive(Clone, Debug, Default)]
199pub struct RuntimeOptions {
200    /// Background shader to use
201    pub shader: Option<String>,
202    /// Exit after this many seconds
203    pub exit_after: Option<f64>,
204    /// Take a screenshot (Some(empty path) = auto-name, Some(path) = specific path, None = no screenshot)
205    pub screenshot: Option<PathBuf>,
206    /// Command to send to shell after delay
207    pub command_to_send: Option<String>,
208    /// Scripted UI test to run (see docs/guides/AGENT_UI_VERIFICATION.md)
209    pub ui_test: Option<PathBuf>,
210    /// Where the --ui-test JSON report is written
211    pub ui_test_report: Option<PathBuf>,
212    /// Enable session logging (overrides config)
213    pub log_session: bool,
214    /// Log level override from CLI
215    pub log_level: Option<log::LevelFilter>,
216}
217
218/// Result of CLI processing
219pub enum CliResult {
220    /// Continue with normal application startup, with optional runtime options
221    Continue(RuntimeOptions),
222    /// Exit with the given code (subcommand completed)
223    Exit(i32),
224}
225
226/// Process CLI arguments and handle subcommands
227pub fn process_cli() -> CliResult {
228    use install::{
229        install_integrations_cli, install_mux_extensions_cli, install_mux_hooks_cli,
230        install_shaders_cli, install_shell_integration_cli, self_update_cli,
231        uninstall_mux_extensions_cli, uninstall_mux_hooks_cli, uninstall_shaders_cli,
232        uninstall_shell_integration_cli,
233    };
234
235    let cli = Cli::parse();
236
237    match cli.command {
238        Some(Commands::InstallShaders { yes, force }) => {
239            let result = install_shaders_cli(yes || force);
240            CliResult::Exit(if result.is_ok() { 0 } else { 1 })
241        }
242        Some(Commands::InstallShellIntegration { shell }) => {
243            let result = install_shell_integration_cli(shell.map(Into::into));
244            CliResult::Exit(if result.is_ok() { 0 } else { 1 })
245        }
246        Some(Commands::UninstallShellIntegration) => {
247            let result = uninstall_shell_integration_cli();
248            CliResult::Exit(if result.is_ok() { 0 } else { 1 })
249        }
250        Some(Commands::UninstallShaders { force }) => {
251            let result = uninstall_shaders_cli(force);
252            CliResult::Exit(if result.is_ok() { 0 } else { 1 })
253        }
254        Some(Commands::InstallMuxExtensions { yes }) => {
255            let result = install_mux_extensions_cli(yes);
256            CliResult::Exit(if result.is_ok() { 0 } else { 1 })
257        }
258        Some(Commands::UninstallMuxExtensions) => {
259            let result = uninstall_mux_extensions_cli();
260            CliResult::Exit(if result.is_ok() { 0 } else { 1 })
261        }
262        Some(Commands::InstallMuxHooks { yes }) => {
263            let result = install_mux_hooks_cli(yes);
264            CliResult::Exit(if result.is_ok() { 0 } else { 1 })
265        }
266        Some(Commands::UninstallMuxHooks) => {
267            let result = uninstall_mux_hooks_cli();
268            CliResult::Exit(if result.is_ok() { 0 } else { 1 })
269        }
270        Some(Commands::ShaderLint {
271            path,
272            readability,
273            apply,
274            no_prompt,
275        }) => {
276            let result = crate::shader_lint::shader_lint_cli(&path, readability, apply, !no_prompt);
277            CliResult::Exit(if result.is_ok() { 0 } else { 1 })
278        }
279        Some(Commands::InstallIntegrations { yes }) => {
280            let result = install_integrations_cli(yes);
281            CliResult::Exit(if result.is_ok() { 0 } else { 1 })
282        }
283        Some(Commands::SelfUpdate { yes }) => {
284            let result = self_update_cli(yes);
285            CliResult::Exit(if result.is_ok() { 0 } else { 1 })
286        }
287        Some(Commands::Plugin { command }) => {
288            use plugin::PluginCommands;
289            let result = match command {
290                PluginCommands::Add { url, yes } => plugin::plugin_add_cli(&url, yes),
291                PluginCommands::Update { id, yes } => plugin::plugin_update_cli(id.as_deref(), yes),
292                PluginCommands::Remove { id, yes } => plugin::plugin_remove_cli(&id, yes),
293            };
294            CliResult::Exit(if result.is_ok() { 0 } else { 1 })
295        }
296        Some(Commands::McpServer) => {
297            crate::mcp_server::set_app_version(crate::VERSION);
298            crate::mcp_server::run_mcp_server();
299            CliResult::Exit(0)
300        }
301        Some(Commands::External(parts)) => {
302            // External-subcommand fallthrough: an id matching no built-in
303            // subcommand resolves against the commands directory
304            // (design 2026-09-24, CLI section).
305            run_external_command(&parts)
306        }
307        None => {
308            // Extract runtime options from CLI flags
309            let options = RuntimeOptions {
310                shader: cli.shader,
311                exit_after: cli.exit_after,
312                screenshot: cli.screenshot,
313                command_to_send: cli.command_to_send,
314                ui_test: cli.ui_test,
315                ui_test_report: cli.ui_test_report,
316                log_session: cli.log_session,
317                log_level: cli.log_level.map(|l| l.to_level_filter()),
318            };
319            CliResult::Continue(options)
320        }
321    }
322}
323
324/// Run an external-subcommand fallthrough: `par-term <id> [args...]`.
325///
326/// `<id>` resolves against the commands directory. Script commands run as a
327/// foreground subprocess (extra args appended after the stored ones,
328/// inheriting stdio so the user sees live output) with the same
329/// first-run-confirmation rule as the palette: an unconfirmed body prints
330/// itself and exits non-zero unless `--yes` is the first extra arg. Macro
331/// commands cannot run without the app's window and are refused with a
332/// pointer at the palette.
333fn run_external_command(parts: &[String]) -> CliResult {
334    use par_term_config::CustomActionConfig;
335    use par_term_config::agent_commands as cmd_fmt;
336
337    let id = &parts[0];
338    let mut extra: Vec<String> = parts[1..].to_vec();
339    let mut assume_yes = false;
340    if extra.first().is_some_and(|a| a == "--yes") {
341        assume_yes = true;
342        extra.remove(0);
343    }
344
345    let dir = cmd_fmt::commands_dir();
346    let path = dir.join(format!("{id}.yaml"));
347    if !path.exists() {
348        eprintln!(
349            "Unknown subcommand or command id {id:?}. No such file: {}. Run \
350             `par-term --help` for built-in subcommands.",
351            path.display()
352        );
353        return CliResult::Exit(1);
354    }
355
356    let file = match cmd_fmt::load_command_file(&path) {
357        Ok(f) => f,
358        Err(e) => {
359            eprintln!("Command {id:?} is invalid: {e:#}");
360            return CliResult::Exit(1);
361        }
362    };
363
364    match file.action.clone() {
365        CustomActionConfig::ShellCommand {
366            command,
367            mut args,
368            timeout_secs,
369            ..
370        } => {
371            let mut ledger = cmd_fmt::load_confirmation_ledger(&dir);
372            let hash = file.body_hash();
373            if ledger.get(file.id()) != Some(&hash) {
374                if !assume_yes {
375                    println!(
376                        "Unconfirmed agent script command {}. Run `par-term {id} --yes` \
377                         to execute it, or approve it once in the app's palette dialog.",
378                        file.title()
379                    );
380                    println!("--- body ---");
381                    println!("{command} {}", args.join(" "));
382                    return CliResult::Exit(2);
383                }
384                // `--yes` is an explicit approval of this exact body —
385                // persist it so later runs need neither the flag nor the
386                // prompt, matching the palette dialog's Run.
387                ledger.insert(file.id().to_string(), hash);
388                if let Err(e) = cmd_fmt::save_confirmation_ledger(&ledger, &dir) {
389                    eprintln!("Warning: could not persist confirmation ({e:#})");
390                }
391            }
392            args.extend(extra);
393            let env = [
394                ("PAR_TERM_COMMAND_ID", Some(file.id().to_string())),
395                ("PAR_TERM_COMMAND_SOURCE_AGENT", file.source_agent.clone()),
396            ];
397            let mut cmd = std::process::Command::new(&command);
398            cmd.args(&args);
399            for (k, v) in env.iter().filter_map(|(k, v)| v.clone().map(|v| (k, v))) {
400                cmd.env(k, v);
401            }
402            // Spawn + poll so stdio stays inherited (live output) while the
403            // stored timeout_secs is still honored.
404            let status = match cmd.spawn() {
405                Ok(mut child) => {
406                    let deadline =
407                        std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
408                    loop {
409                        match child.try_wait() {
410                            Ok(Some(s)) => break Some(Ok(s)),
411                            Ok(None) => {
412                                if std::time::Instant::now() > deadline {
413                                    let _ = child.kill();
414                                    let _ = child.wait();
415                                    break None;
416                                }
417                                std::thread::sleep(std::time::Duration::from_millis(50));
418                            }
419                            Err(e) => break Some(Err(e)),
420                        }
421                    }
422                }
423                Err(e) => Some(Err(e)),
424            };
425            match status {
426                Some(Ok(s)) => CliResult::Exit(s.code().unwrap_or(1)),
427                Some(Err(e)) => {
428                    eprintln!("Failed to run {command:?}: {e}");
429                    CliResult::Exit(1)
430                }
431                None => {
432                    eprintln!("Command {id:?} timed out after {timeout_secs}s");
433                    CliResult::Exit(1)
434                }
435            }
436        }
437        macro_action => {
438            let _ = &macro_action;
439            eprintln!(
440                "Command {id:?} is a macro ({}); macros drive the par-term UI \
441                 and run from the command palette (open it and search \"{}\"), \
442                 not from the CLI.",
443                file.title(),
444                file.title()
445            );
446            CliResult::Exit(1)
447        }
448    }
449}