1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
//! clap derive structs for the `purple` CLI.
//!
//! Everything related to argument parsing lives here so `main.rs` stays
//! focused on orchestration. `Cli` is the top-level `Parser`; each
//! `Commands` variant holds the subcommand-specific args.
use clap::{Parser, Subcommand};
use clap_complete::Shell;
#[derive(Parser)]
#[command(
name = "purple",
about = "Your SSH config is a mess. Purple fixes that.",
long_about = "Purple is a terminal SSH client for managing your hosts.\n\
Add, edit, delete and connect without opening a text editor.\n\n\
Life's too short for nano ~/.ssh/config.",
version
)]
pub struct Cli {
/// Connect to a host by alias, or filter the TUI
#[arg(value_name = "ALIAS")]
pub alias: Option<String>,
/// Connect directly to a host by alias (skip the TUI)
#[arg(short, long)]
pub connect: Option<String>,
/// List all configured hosts
#[arg(short, long)]
pub list: bool,
/// Path to SSH config file
#[arg(long, default_value = "~/.ssh/config")]
pub config: String,
/// Launch with demo data (no real config needed)
#[arg(long)]
pub demo: bool,
/// Generate shell completions
#[arg(long, value_name = "SHELL")]
pub completions: Option<Shell>,
/// Override theme for this session
#[arg(long)]
pub theme: Option<String>,
/// Enable verbose logging (debug level)
#[arg(long)]
pub verbose: bool,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand)]
pub enum Commands {
/// Quick-add a host: purple add user@host:port --alias myserver
Add {
/// Target in user@hostname:port format
target: String,
/// Alias for the host (default: derived from hostname)
#[arg(short, long)]
alias: Option<String>,
/// Path to identity file (SSH key)
#[arg(short, long)]
key: Option<String>,
},
/// Import hosts from a file or known_hosts
Import {
/// File with one host per line (user@host:port format)
file: Option<String>,
/// Import from ~/.ssh/known_hosts instead
#[arg(long)]
known_hosts: bool,
/// Group label for imported hosts
#[arg(short, long)]
group: Option<String>,
},
/// 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)
Sync {
/// Sync a specific provider (default: all configured)
provider: Option<String>,
/// Preview changes without modifying config
#[arg(long)]
dry_run: bool,
/// Remove hosts that no longer exist on the provider
#[arg(long)]
remove: bool,
},
/// Manage cloud provider configurations
Provider {
#[command(subcommand)]
command: ProviderCommands,
},
/// Manage SSH tunnels
Tunnel {
#[command(subcommand)]
command: TunnelCommands,
},
/// Manage passwords in the OS keychain for SSH hosts
Password {
#[command(subcommand)]
command: PasswordCommands,
},
/// Manage command snippets for quick execution on hosts
Snippet {
#[command(subcommand)]
command: SnippetCommands,
},
/// Update purple to the latest version
Update,
/// Start MCP server (Model Context Protocol) for AI agent integration
Mcp,
/// Manage color themes
Theme {
#[command(subcommand)]
command: ThemeCommands,
},
/// HashiCorp Vault SSH secrets engine operations (signed SSH certificates)
Vault {
#[command(subcommand)]
command: VaultCommands,
},
/// View or manage log file
Logs {
/// Follow log output in real time
#[arg(long)]
tail: bool,
/// Delete the log file
#[arg(long)]
clear: bool,
},
/// Print release notes since a prior version
WhatsNew {
/// Only include entries newer than this version (e.g. 2.40.0)
#[arg(long)]
since: Option<String>,
},
}
#[derive(Subcommand)]
pub enum VaultCommands {
/// Sign an SSH certificate for a host (or --all) via the Vault SSH secrets engine
#[command(
long_about = "Sign one or more SSH certificates via the HashiCorp Vault SSH secrets engine.\n\n\
Prerequisites:\n\
- The `vault` CLI is installed and authenticated (run `vault login` or set VAULT_TOKEN)\n\
- VAULT_ADDR points at your Vault server\n\
- A role is configured on the host (Vault SSH role field in the host form) or\n \
on its provider (provider-level vault_role default)\n\
- The SSH secrets engine is enabled on Vault and your token has `update` capability\n \
on the role path\n\n\
Signed certificates are cached under ~/.purple/certs/<alias>-cert.pub and\n\
`CertificateFile` is wired into the SSH config automatically.\n\n\
Distinct from the Vault KV secrets engine used as a password source (`vault:`\n\
askpass prefix); see `purple password` for that."
)]
Sign {
/// Host alias to sign (omit for --all)
alias: Option<String>,
/// Sign all hosts with a Vault SSH role configured
#[arg(long)]
all: bool,
/// Override VAULT_ADDR for this invocation only.
/// Highest precedence: flag > per-host comment > provider default > shell env.
#[arg(long, value_name = "URL")]
vault_addr: Option<String>,
},
}
#[derive(Subcommand)]
pub enum ThemeCommands {
/// List available themes
List,
/// Set the active theme
Set {
/// Theme name
name: String,
},
}
#[derive(Subcommand)]
#[allow(clippy::large_enum_variant)]
pub enum ProviderCommands {
/// Add or update a provider configuration
Add {
/// Provider name (digitalocean, vultr, linode, hetzner, upcloud, proxmox, aws, scaleway, gcp, azure, tailscale, oracle, ovh, leaseweb, i3d, transip)
provider: String,
/// API token (or set PURPLE_TOKEN env var, or use --token-stdin)
#[arg(long)]
token: Option<String>,
/// Read token from stdin (e.g. from a password manager)
#[arg(long)]
token_stdin: bool,
/// Alias prefix (default: provider short label)
#[arg(long)]
prefix: Option<String>,
/// Default SSH user (default: root)
#[arg(long)]
user: Option<String>,
/// Default identity file
#[arg(long)]
key: Option<String>,
/// Base URL for self-hosted providers (required for Proxmox)
#[arg(long)]
url: Option<String>,
/// AWS credential profile from ~/.aws/credentials
#[arg(long)]
profile: Option<String>,
/// 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)
#[arg(long)]
regions: Option<String>,
/// GCP project ID
#[arg(long)]
project: Option<String>,
/// OCI compartment OCID (Oracle)
#[arg(long)]
compartment: Option<String>,
/// Skip TLS certificate verification (for self-signed certs)
#[arg(long, conflicts_with = "verify_tls")]
no_verify_tls: bool,
/// Explicitly enable TLS certificate verification (overrides stored setting)
#[arg(long, conflicts_with = "no_verify_tls")]
verify_tls: bool,
/// Enable automatic sync on startup
#[arg(long, conflicts_with = "no_auto_sync")]
auto_sync: bool,
/// Disable automatic sync on startup
#[arg(long, conflicts_with = "auto_sync")]
no_auto_sync: bool,
},
/// List configured providers
List,
/// Remove a provider configuration
Remove {
/// Provider name to remove
provider: String,
},
}
#[derive(Subcommand)]
pub enum TunnelCommands {
/// List configured tunnels
List {
/// Show tunnels for a specific host
alias: Option<String>,
},
/// Add a tunnel to a host
Add {
/// Host alias
alias: String,
/// Forward spec: L:port:host:port (local), R:port:host:port (remote) or D:port (SOCKS)
forward: String,
},
/// Remove a tunnel from a host
Remove {
/// Host alias
alias: String,
/// Forward spec: L:port:host:port (local), R:port:host:port (remote) or D:port (SOCKS)
forward: String,
},
/// Start a tunnel (foreground, Ctrl+C to stop)
Start {
/// Host alias
alias: String,
},
}
#[derive(Subcommand)]
pub enum PasswordCommands {
/// Store a password in the OS keychain for a host
Set {
/// Host alias
alias: String,
},
/// Remove a password from the OS keychain
Remove {
/// Host alias
alias: String,
},
}
#[derive(Subcommand)]
pub enum SnippetCommands {
/// List all saved snippets
List,
/// Add a new snippet
Add {
/// Snippet name
name: String,
/// Command to run on the remote host
command: String,
/// Short description
#[arg(long)]
description: Option<String>,
},
/// Remove a snippet
Remove {
/// Snippet name
name: String,
},
/// Run a snippet on one or more hosts
Run {
/// Snippet name
name: String,
/// Host alias (run on a single host)
alias: Option<String>,
/// Run on all hosts matching this tag
#[arg(long)]
tag: Option<String>,
/// Run on all hosts
#[arg(long)]
all: bool,
/// Run on hosts concurrently
#[arg(long)]
parallel: bool,
},
}