Skip to main content

purple_ssh/
cli_args.rs

1//! clap derive structs for the `purple` CLI.
2//!
3//! Everything related to argument parsing lives here so `main.rs` stays
4//! focused on orchestration. `Cli` is the top-level `Parser`; each
5//! `Commands` variant holds the subcommand-specific args.
6
7use clap::{Parser, Subcommand};
8use clap_complete::Shell;
9
10#[derive(Parser)]
11#[command(
12    name = "purple",
13    about = "Your SSH config is a mess. Purple fixes that.",
14    long_about = "Purple is a terminal SSH client for managing your hosts.\n\
15                  Add, edit, delete and connect without opening a text editor.\n\n\
16                  Life's too short for nano ~/.ssh/config.",
17    version
18)]
19pub struct Cli {
20    /// Connect to a host by alias, or filter the TUI
21    #[arg(value_name = "ALIAS")]
22    pub alias: Option<String>,
23
24    /// Connect directly to a host by alias (skip the TUI)
25    #[arg(short, long)]
26    pub connect: Option<String>,
27
28    /// List all configured hosts
29    #[arg(short, long)]
30    pub list: bool,
31
32    /// Path to SSH config file
33    #[arg(long, default_value = "~/.ssh/config")]
34    pub config: String,
35
36    /// Launch with demo data (no real config needed)
37    #[arg(long)]
38    pub demo: bool,
39
40    /// Generate shell completions
41    #[arg(long, value_name = "SHELL")]
42    pub completions: Option<Shell>,
43
44    /// Override theme for this session
45    #[arg(long)]
46    pub theme: Option<String>,
47
48    /// Enable verbose logging (debug level)
49    #[arg(
50        long,
51        long_help = "Enable verbose logging (debug level).\n\n\
52                     Logs are written to purple.log in the state directory,\n\
53                     ~/.purple by default (rotates at 5MB). `purple logs` prints\n\
54                     the path; tail with `purple logs --tail`.\n\n\
55                     Set PURPLE_LOG=trace|debug|info|warn|error to override the\n\
56                     level without --verbose. PURPLE_LOG takes precedence."
57    )]
58    pub verbose: bool,
59
60    #[command(subcommand)]
61    pub command: Option<Commands>,
62}
63
64#[derive(Subcommand)]
65#[allow(clippy::large_enum_variant)]
66pub enum Commands {
67    /// Quick-add a host: purple add user@host:port --alias myserver
68    Add {
69        /// Target in user@hostname:port format
70        target: String,
71
72        /// Alias for the host (default: derived from hostname)
73        #[arg(short, long)]
74        alias: Option<String>,
75
76        /// Path to identity file (SSH key)
77        #[arg(short, long)]
78        key: Option<String>,
79    },
80    /// Import hosts from a file or known_hosts
81    Import {
82        /// File with one host per line (user@host:port format)
83        file: Option<String>,
84
85        /// Import from ~/.ssh/known_hosts instead
86        #[arg(long)]
87        known_hosts: bool,
88
89        /// Group label for imported hosts
90        #[arg(short, long)]
91        group: Option<String>,
92    },
93    /// Sync hosts from cloud providers (DigitalOcean, Vultr, Linode, Hetzner, UpCloud, Proxmox VE, AWS EC2, Scaleway, GCP, Azure, Tailscale, Oracle Cloud, OVHcloud, Leaseweb, i3D.net, TransIP, Teleport, NetBox)
94    Sync {
95        /// Sync a specific provider (default: all configured)
96        provider: Option<String>,
97
98        /// Preview changes without modifying config
99        #[arg(long)]
100        dry_run: bool,
101
102        /// Remove hosts that no longer exist on the provider
103        #[arg(long)]
104        remove: bool,
105    },
106    /// Manage cloud provider configurations
107    Provider {
108        #[command(subcommand)]
109        command: ProviderCommands,
110    },
111    /// Manage SSH tunnels
112    Tunnel {
113        #[command(subcommand)]
114        command: TunnelCommands,
115    },
116    /// Manage passwords in the OS keychain for SSH hosts
117    Password {
118        #[command(subcommand)]
119        command: PasswordCommands,
120    },
121    /// Manage command snippets for quick execution on hosts
122    Snippet {
123        #[command(subcommand)]
124        command: SnippetCommands,
125    },
126    /// Update purple to the latest version
127    Update,
128    /// Start MCP server (Model Context Protocol) for AI agent integration
129    Mcp {
130        /// Restrict tools to read-only operations. Denies run_command and container_action,
131        /// and removes them from tools/list. Recommended when exposing purple to autonomous agents.
132        #[arg(long)]
133        read_only: bool,
134
135        /// Disable the MCP audit log. By default every tool call is appended to
136        /// mcp-audit.log in the state directory (~/.purple by default) as JSON Lines.
137        #[arg(long)]
138        no_audit: bool,
139
140        /// Custom path for the MCP audit log (default: mcp-audit.log in the state
141        /// directory, ~/.purple by default). Ignored when --no-audit is set.
142        #[arg(long, value_name = "PATH")]
143        audit_log: Option<String>,
144    },
145    /// Manage color themes
146    Theme {
147        #[command(subcommand)]
148        command: ThemeCommands,
149    },
150    /// HashiCorp Vault SSH secrets engine operations (signed SSH certificates)
151    Vault {
152        #[command(subcommand)]
153        command: VaultCommands,
154    },
155    /// View or manage log file
156    Logs {
157        /// Follow log output in real time
158        #[arg(long)]
159        tail: bool,
160
161        /// Delete the log file
162        #[arg(long)]
163        clear: bool,
164    },
165    /// Print release notes since a prior version
166    WhatsNew {
167        /// Only include entries newer than this version (e.g. 2.40.0)
168        #[arg(long)]
169        since: Option<String>,
170    },
171    /// Render marketing SVG screenshots from the demo TUI. Internal: drives the
172    /// release imagery pipeline so README and landing-page assets never go stale.
173    #[command(hide = true)]
174    GenAssets {
175        /// Output directory for the generated SVGs
176        #[arg(default_value = "assets/svg")]
177        out_dir: String,
178
179        /// Directory holding ui-mono-regular.woff2 and ui-mono-bold.woff2 to
180        /// embed. Pass an empty value to render with the system monospace.
181        #[arg(long, default_value = "assets/fonts")]
182        font_dir: String,
183
184        /// Also write the looping animated hero SVG (all five tabs with
185        /// detail panels, search typing itself) to this path.
186        #[arg(long)]
187        hero_out: Option<String>,
188    },
189}
190
191#[derive(Subcommand)]
192pub enum VaultCommands {
193    /// Sign an SSH certificate for a host (or --all) via the Vault SSH secrets engine
194    #[command(
195        long_about = "Sign one or more SSH certificates via the HashiCorp Vault SSH secrets engine.\n\n\
196        Prerequisites:\n\
197        - The `vault` CLI is installed and authenticated (run `vault login` or set VAULT_TOKEN)\n\
198        - VAULT_ADDR points at your Vault server\n\
199        - A role is configured on the host (Vault SSH role field in the host form) or\n  \
200          on its provider (provider-level vault_role default)\n\
201        - The SSH secrets engine is enabled on Vault and your token has `update` capability\n  \
202          on the role path\n\n\
203        Signed certificates are stored as certs/<alias>-cert.pub in the data\n\
204        directory (~/.purple by default) and `CertificateFile` is wired into the\n\
205        SSH config automatically.\n\n\
206        Distinct from the Vault KV secrets engine used as a password source (`vault:`\n\
207        askpass prefix); see `purple password` for that."
208    )]
209    Sign {
210        /// Host alias to sign (omit for --all)
211        alias: Option<String>,
212        /// Sign all hosts with a Vault SSH role configured
213        #[arg(long)]
214        all: bool,
215        /// Override VAULT_ADDR for this invocation only.
216        /// Highest precedence: flag > per-host comment > provider default > shell env.
217        #[arg(long, value_name = "URL")]
218        vault_addr: Option<String>,
219    },
220}
221
222#[derive(Subcommand)]
223pub enum ThemeCommands {
224    /// List available themes
225    List,
226    /// Set the active theme
227    Set {
228        /// Theme name
229        name: String,
230    },
231}
232
233#[derive(Subcommand)]
234#[allow(clippy::large_enum_variant)]
235pub enum ProviderCommands {
236    /// Add or update a provider configuration
237    Add {
238        /// Provider name (digitalocean, vultr, linode, hetzner, upcloud, proxmox, aws, scaleway, gcp, azure, tailscale, oracle, ovh, leaseweb, i3d, transip, teleport, netbox)
239        provider: String,
240
241        /// API token (or set PURPLE_TOKEN env var, or use --token-stdin)
242        #[arg(long)]
243        token: Option<String>,
244
245        /// Read token from stdin (e.g. from a password manager)
246        #[arg(long)]
247        token_stdin: bool,
248
249        /// Alias prefix (default: provider short label)
250        #[arg(long)]
251        prefix: Option<String>,
252
253        /// Default SSH user (default: root)
254        #[arg(long)]
255        user: Option<String>,
256
257        /// Default identity file
258        #[arg(long)]
259        key: Option<String>,
260
261        /// Base URL for self-hosted providers (required for Proxmox and NetBox)
262        #[arg(long)]
263        url: Option<String>,
264
265        /// AWS credential profile from ~/.aws/config or ~/.aws/credentials.
266        /// A profile with role_arn and source_profile is assumed via STS, so
267        /// one key pair reaches several accounts.
268        #[arg(long)]
269        profile: Option<String>,
270
271        /// Reach AWS instances through Systems Manager Session Manager:
272        /// `off` (default), `auto` to route only the nodes Session Manager
273        /// reports online or `always` to route every instance without asking
274        #[arg(long, value_name = "MODE")]
275        ssm: Option<String>,
276
277        /// Comma-separated regions, zones or subscription IDs (e.g. us-east-1,eu-west-1 for AWS, fr-par-1,nl-ams-1 for Scaleway, us-central1-a for GCP zones or subscription UUIDs for Azure)
278        #[arg(long)]
279        regions: Option<String>,
280
281        /// GCP project ID
282        #[arg(long)]
283        project: Option<String>,
284
285        /// OCI compartment OCID (Oracle)
286        #[arg(long)]
287        compartment: Option<String>,
288
289        /// NetBox filter query string (e.g. "status=active&tag=ssh")
290        #[arg(long)]
291        filter: Option<String>,
292
293        /// Skip TLS certificate verification (for self-signed certs)
294        #[arg(long, conflicts_with = "verify_tls")]
295        no_verify_tls: bool,
296
297        /// Explicitly enable TLS certificate verification (overrides stored setting)
298        #[arg(long, conflicts_with = "no_verify_tls")]
299        verify_tls: bool,
300
301        /// Enable automatic sync on startup
302        #[arg(long, conflicts_with = "no_auto_sync")]
303        auto_sync: bool,
304
305        /// Disable automatic sync on startup
306        #[arg(long, conflicts_with = "auto_sync")]
307        no_auto_sync: bool,
308
309        /// Optional label when adding a second config for the same
310        /// provider (e.g. --label work, --label personal). Required once
311        /// a provider already has a labeled config.
312        #[arg(long)]
313        label: Option<String>,
314    },
315    /// List configured providers
316    List,
317    /// Remove a provider configuration. Pass `provider` for ALL configs
318    /// of that provider, or `provider:label` for one specific config.
319    Remove {
320        /// Provider name (`digitalocean`) or `provider:label` (`digitalocean:work`)
321        provider: String,
322    },
323}
324
325#[derive(Subcommand)]
326pub enum TunnelCommands {
327    /// List configured tunnels
328    List {
329        /// Show tunnels for a specific host
330        alias: Option<String>,
331    },
332    /// Add a tunnel to a host
333    Add {
334        /// Host alias
335        alias: String,
336
337        /// Forward spec: L:port:host:port (local), R:port:host:port (remote) or D:port (SOCKS)
338        forward: String,
339    },
340    /// Remove a tunnel from a host
341    Remove {
342        /// Host alias
343        alias: String,
344
345        /// Forward spec: L:port:host:port (local), R:port:host:port (remote) or D:port (SOCKS)
346        forward: String,
347    },
348    /// Start a tunnel (foreground, Ctrl+C to stop)
349    Start {
350        /// Host alias
351        alias: String,
352    },
353}
354
355#[derive(Subcommand)]
356pub enum PasswordCommands {
357    /// Store a password in the OS keychain for a host
358    Set {
359        /// Host alias
360        alias: String,
361    },
362    /// Remove a password from the OS keychain
363    Remove {
364        /// Host alias
365        alias: String,
366    },
367}
368
369#[derive(Subcommand)]
370pub enum SnippetCommands {
371    /// List all saved snippets
372    List,
373    /// Add a new snippet
374    Add {
375        /// Snippet name
376        name: String,
377
378        /// Command to run on the remote host
379        command: String,
380
381        /// Short description
382        #[arg(long)]
383        description: Option<String>,
384
385        /// Run in an interactive shell so ~/.bashrc or ~/.zshrc is loaded (e.g. for nvm)
386        #[arg(long)]
387        interactive: bool,
388    },
389    /// Remove a snippet
390    Remove {
391        /// Snippet name
392        name: String,
393    },
394    /// Run a snippet on one or more hosts
395    Run {
396        /// Snippet name
397        name: String,
398
399        /// Host alias (run on a single host)
400        alias: Option<String>,
401
402        /// Run on all hosts matching this tag
403        #[arg(long)]
404        tag: Option<String>,
405
406        /// Run on all hosts
407        #[arg(long)]
408        all: bool,
409
410        /// Run on hosts concurrently
411        #[arg(long)]
412        parallel: bool,
413    },
414}