purple-ssh 2.41.1

Open-source terminal SSH manager and SSH config editor. Search hundreds of hosts, sync from 16 clouds, transfer files, manage Docker and Podman over SSH, sign short-lived Vault SSH certs and expose an MCP server for AI agents. Rust TUI, MIT licensed.
Documentation
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
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
//! Centralized user-facing messages.
//!
//! Every string the user can see (toasts, CLI output, error messages) lives
//! here. Handler, CLI and UI code reference these constants and functions
//! instead of inlining string literals. This makes copy consistent, auditable
//! and future-proof for i18n.

// ── General / shared ────────────────────────────────────────────────

pub const FAILED_TO_SAVE: &str = "Failed to save";
pub fn failed_to_save(e: &impl std::fmt::Display) -> String {
    format!("{}: {}", FAILED_TO_SAVE, e)
}

pub const CONFIG_CHANGED_EXTERNALLY: &str =
    "Config changed externally. Press Esc and re-open to pick up changes.";

// ── Demo mode ───────────────────────────────────────────────────────

pub const DEMO_CONNECTION_DISABLED: &str = "Demo mode. Connection disabled.";
pub const DEMO_SYNC_DISABLED: &str = "Demo mode. Sync disabled.";
pub const DEMO_TUNNELS_DISABLED: &str = "Demo mode. Tunnels disabled.";
pub const DEMO_VAULT_SIGNING_DISABLED: &str = "Demo mode. Vault SSH signing disabled.";
pub const DEMO_FILE_BROWSER_DISABLED: &str = "Demo mode. File browser disabled.";
pub const DEMO_CONTAINER_REFRESH_DISABLED: &str = "Demo mode. Container refresh disabled.";
pub const DEMO_CONTAINER_ACTIONS_DISABLED: &str = "Demo mode. Container actions disabled.";
pub const DEMO_EXECUTION_DISABLED: &str = "Demo mode. Execution disabled.";
pub const DEMO_PROVIDER_CHANGES_DISABLED: &str = "Demo mode. Provider config changes disabled.";

// ── Stale host ──────────────────────────────────────────────────────

pub fn stale_host(hint: &str) -> String {
    format!("Stale host.{}", hint)
}

// ── Host list ───────────────────────────────────────────────────────

pub fn copied_ssh_command(alias: &str) -> String {
    format!("Copied SSH command for {}.", alias)
}

pub fn copied_config_block(alias: &str) -> String {
    format!("Copied config block for {}.", alias)
}

pub fn showing_unreachable(count: usize) -> String {
    format!(
        "Showing {} unreachable host{}.",
        count,
        if count == 1 { "" } else { "s" }
    )
}

pub fn sorted_by(label: &str) -> String {
    format!("Sorted by {}.", label)
}

pub fn sorted_by_save_failed(label: &str, e: &impl std::fmt::Display) -> String {
    format!("Sorted by {}. (save failed: {})", label, e)
}

pub fn grouped_by(label: &str) -> String {
    format!("Grouped by {}.", label)
}

pub fn grouped_by_save_failed(label: &str, e: &impl std::fmt::Display) -> String {
    format!("Grouped by {}. (save failed: {})", label, e)
}

pub const UNGROUPED: &str = "Ungrouped.";

pub fn ungrouped_save_failed(e: &impl std::fmt::Display) -> String {
    format!("Ungrouped. (save failed: {})", e)
}

pub const GROUPED_BY_TAG: &str = "Grouped by tag.";

pub fn grouped_by_tag_save_failed(e: &impl std::fmt::Display) -> String {
    format!("Grouped by tag. (save failed: {})", e)
}

pub fn host_restored(alias: &str) -> String {
    format!("{} is back from the dead.", alias)
}

pub fn restored_tags(count: usize) -> String {
    format!(
        "Restored tags on {} host{}.",
        count,
        if count == 1 { "" } else { "s" }
    )
}

pub const NOTHING_TO_UNDO: &str = "Nothing to undo.";
pub const NO_IMPORTABLE_HOSTS: &str = "No importable hosts in known_hosts.";
pub const NO_STALE_HOSTS: &str = "No stale hosts.";
pub const NO_HOST_SELECTED: &str = "No host selected.";
pub const NO_HOSTS_TO_RUN: &str = "No hosts to run on.";
pub const NO_HOSTS_TO_TAG: &str = "No hosts to tag.";
pub const PING_FIRST: &str = "Ping first (p/P), then filter with !.";
pub const PINGING_ALL: &str = "Pinging all the things...";

pub fn included_file_edit(name: &str) -> String {
    format!("{} is in an included file. Edit it there.", name)
}

pub fn included_file_delete(name: &str) -> String {
    format!("{} is in an included file. Delete it there.", name)
}

pub fn included_file_clone(name: &str) -> String {
    format!("{} is in an included file. Clone it there.", name)
}

pub fn included_host_lives_in(alias: &str, path: &impl std::fmt::Display) -> String {
    format!("{} lives in {}. Edit it there.", alias, path)
}

pub fn included_host_clone_there(alias: &str, path: &impl std::fmt::Display) -> String {
    format!("{} lives in {}. Clone it there.", alias, path)
}

pub fn included_host_tag_there(alias: &str, path: &impl std::fmt::Display) -> String {
    format!("{} is included from {}. Tag it there.", alias, path)
}

pub const HOST_NOT_FOUND_IN_CONFIG: &str = "Host not found in config.";

// ── Host form ───────────────────────────────────────────────────────

pub const SMART_PARSED: &str = "Smart-parsed that for you. Check the fields.";
pub const LOOKS_LIKE_ADDRESS: &str = "Looks like an address. Suggested as Host.";

// ── Confirm delete ──────────────────────────────────────────────────

pub fn goodbye_host(alias: &str) -> String {
    format!("Goodbye, {}. We barely knew ye. (u to undo)", alias)
}

pub fn host_not_found(alias: &str) -> String {
    format!("Host '{}' not found.", alias)
}

pub fn cert_cleanup_warning(path: &impl std::fmt::Display, e: &impl std::fmt::Display) -> String {
    format!("Warning: failed to clean up Vault SSH cert {}: {}", path, e)
}

// ── Clone ───────────────────────────────────────────────────────────

pub const CLONED_VAULT_CLEARED: &str = "Cloned. Vault SSH role cleared on copy.";

// ── Tunnels ─────────────────────────────────────────────────────────

pub const TUNNEL_REMOVED: &str = "Tunnel removed.";
pub const TUNNEL_SAVED: &str = "Tunnel saved.";
pub const TUNNEL_NOT_FOUND: &str = "Tunnel not found in config.";
pub const TUNNEL_INCLUDED_READ_ONLY: &str = "Included host. Tunnels are read-only.";
pub const TUNNEL_ORIGINAL_NOT_FOUND: &str = "Original tunnel not found in config.";
pub const TUNNEL_LIST_CHANGED: &str = "Tunnel list changed externally. Press Esc and re-open.";
pub const TUNNEL_DUPLICATE: &str = "Duplicate tunnel already configured.";

pub fn tunnel_stopped(alias: &str) -> String {
    format!("Tunnel for {} stopped.", alias)
}

pub fn tunnel_started(alias: &str) -> String {
    format!("Tunnel for {} started.", alias)
}

pub fn tunnel_start_failed(e: &impl std::fmt::Display) -> String {
    format!("Failed to start tunnel: {}", e)
}

// ── Ping ────────────────────────────────────────────────────────────

pub fn pinging_host(alias: &str, show_hint: bool) -> String {
    if show_hint {
        format!("Pinging {}... (Shift+P pings all)", alias)
    } else {
        format!("Pinging {}...", alias)
    }
}

pub fn bastion_not_found(alias: &str) -> String {
    format!("Bastion {} not found in config.", alias)
}

// ── Providers ───────────────────────────────────────────────────────

pub fn provider_removed(display_name: &str) -> String {
    format!(
        "Removed {} configuration. Synced hosts remain in your SSH config.",
        display_name
    )
}

pub fn provider_not_configured(display_name: &str) -> String {
    format!("{} is not configured. Nothing to remove.", display_name)
}

pub fn provider_configure_first(display_name: &str) -> String {
    format!("Configure {} first. Press Enter to set up.", display_name)
}

pub fn provider_saved_syncing(display_name: &str) -> String {
    format!("Saved {} configuration. Syncing...", display_name)
}

pub fn provider_saved(display_name: &str) -> String {
    format!("Saved {} configuration.", display_name)
}

pub fn syncing_provider(display_name: &str) -> String {
    format!("Syncing {}...", display_name)
}

pub fn no_stale_hosts_for(display_name: &str) -> String {
    format!("No stale hosts for {}.", display_name)
}

pub fn contains_control_chars(name: &str) -> String {
    format!("{} contains control characters.", name)
}

pub const TOKEN_FORMAT_AWS: &str = "Token format: AccessKeyId:SecretAccessKey";
pub const URL_REQUIRED_PROXMOX: &str = "URL is required for Proxmox VE.";
pub const PROJECT_REQUIRED_GCP: &str = "Project ID can't be empty. Set your GCP project ID.";
pub const COMPARTMENT_REQUIRED_OCI: &str =
    "Compartment can't be empty. Set your OCI compartment OCID.";
pub const REGIONS_REQUIRED_AWS: &str = "Select at least one AWS region.";
pub const ZONES_REQUIRED_SCALEWAY: &str = "Select at least one Scaleway zone.";
pub const SUBSCRIPTIONS_REQUIRED_AZURE: &str = "Enter at least one Azure subscription ID.";
pub const ALIAS_PREFIX_INVALID: &str =
    "Alias prefix can't contain spaces or pattern characters (*, ?, [, !).";
pub const USER_NO_WHITESPACE: &str = "User can't contain whitespace.";
pub const VAULT_ROLE_FORMAT: &str = "Vault SSH role must be in the form <mount>/sign/<role>.";

// ── Vault SSH ───────────────────────────────────────────────────────

pub const VAULT_SIGNING_CANCELLED: &str = "Vault SSH signing cancelled.";
pub const VAULT_NO_ROLE_CONFIGURED: &str = "No Vault SSH role configured. Set one in the host form \
     (Vault SSH role field) or on a provider for shared defaults.";
pub const VAULT_NO_HOSTS_WITH_ROLE: &str = "No hosts with a Vault SSH role configured.";
pub const VAULT_ALL_CERTS_VALID: &str = "All Vault SSH certificates are still valid.";
pub const VAULT_NO_ADDRESS: &str = "No Vault address set. Edit the host (e) or provider \
     and fill in the Vault SSH Address field.";

pub fn vault_error(msg: &str) -> String {
    format!("Vault SSH: {}", msg)
}

pub fn vault_signed(alias: &str) -> String {
    format!("Signed Vault SSH cert for {}", alias)
}

pub fn vault_sign_failed(alias: &str, message: &str) -> String {
    format!("Vault SSH: failed to sign {}: {}", alias, message)
}

pub fn vault_signing_progress(spinner: &str, done: usize, total: usize, alias: &str) -> String {
    format!(
        "{} Signing {}/{}: {} (V to cancel)",
        spinner, done, total, alias
    )
}

pub fn vault_cert_saved_host_gone(alias: &str) -> String {
    format!(
        "Vault SSH cert saved for {} but host no longer in config \
         (renamed or deleted). CertificateFile NOT written.",
        alias
    )
}

pub fn vault_spawn_failed(e: &impl std::fmt::Display) -> String {
    format!("Vault SSH: failed to spawn signing thread: {}", e)
}

pub fn vault_cert_check_failed(alias: &str, message: &str) -> String {
    format!("Cert check failed for {}: {}", alias, message)
}

pub fn vault_role_set(role: &str) -> String {
    format!("Vault SSH role set to {}.", role)
}

// ── Snippets ────────────────────────────────────────────────────────

pub fn snippet_removed(name: &str) -> String {
    format!("Removed snippet '{}'.", name)
}

pub fn snippet_added(name: &str) -> String {
    format!("Added snippet '{}'.", name)
}

pub fn snippet_updated(name: &str) -> String {
    format!("Updated snippet '{}'.", name)
}

pub fn snippet_exists(name: &str) -> String {
    format!("'{}' already exists.", name)
}

pub const OUTPUT_COPIED: &str = "Output copied.";

pub fn copy_failed(e: &impl std::fmt::Display) -> String {
    format!("Copy failed: {}", e)
}

// ── Picker (password source, key, proxy) ────────────────────────────

pub const GLOBAL_DEFAULT_CLEARED: &str = "Global default cleared.";
pub const PASSWORD_SOURCE_CLEARED: &str = "Password source cleared.";

pub fn global_default_set(label: &str) -> String {
    format!("Global default set to {}.", label)
}

pub fn password_source_set(label: &str) -> String {
    format!("Password source set to {}.", label)
}

pub fn complete_path(label: &str) -> String {
    format!("Complete the {} path.", label)
}

pub fn key_selected(name: &str) -> String {
    format!("Locked and loaded with {}.", name)
}

pub fn proxy_jump_set(alias: &str) -> String {
    format!("Jumping through {}.", alias)
}

pub fn save_default_failed(e: &impl std::fmt::Display) -> String {
    format!("Failed to save default: {}", e)
}

// ── Containers ──────────────────────────────────────────────────────

pub fn container_action_complete(action: &str) -> String {
    format!("Container {} complete.", action)
}

// ── Import ──────────────────────────────────────────────────────────

pub fn imported_hosts(imported: usize, skipped: usize) -> String {
    format!(
        "Imported {} host{}, skipped {} duplicate{}.",
        imported,
        if imported == 1 { "" } else { "s" },
        skipped,
        if skipped == 1 { "" } else { "s" }
    )
}

pub fn all_hosts_exist(skipped: usize) -> String {
    if skipped == 1 {
        "Host already exists.".to_string()
    } else {
        format!("All {} hosts already exist.", skipped)
    }
}

// ── SSH config repair ───────────────────────────────────────────────

pub fn config_repaired(groups: usize, orphaned: usize) -> String {
    format!(
        "Repaired SSH config ({} absorbed, {} orphaned group headers).",
        groups, orphaned
    )
}

pub fn no_exact_match(alias: &str) -> String {
    format!("No exact match for '{}'. Here's what we found.", alias)
}

pub fn group_pref_reset_failed(e: &impl std::fmt::Display) -> String {
    format!("Group preference reset. (save failed: {})", e)
}

// ── Connection ──────────────────────────────────────────────────────

pub fn opened_in_tmux(alias: &str) -> String {
    format!("Opened {} in new tmux window.", alias)
}

pub fn tmux_error(e: &impl std::fmt::Display) -> String {
    format!("tmux: {}", e)
}

pub fn connection_failed(alias: &str) -> String {
    format!("Connection to {} failed.", alias)
}

// ── Host key reset ──────────────────────────────────────────────────

pub fn host_key_remove_failed(stderr: &str) -> String {
    format!("Failed to remove host key: {}", stderr)
}

pub fn ssh_keygen_failed(e: &impl std::fmt::Display) -> String {
    format!("Failed to run ssh-keygen: {}", e)
}

// ── Transfer ────────────────────────────────────────────────────────

pub const TRANSFER_COMPLETE: &str = "Transfer complete.";

// ── Background / event loop ─────────────────────────────────────────

pub const PING_EXPIRED: &str = "Ping expired. Press P to refresh.";

pub fn provider_event(name: &str, message: &str) -> String {
    format!("{}: {}", name, message)
}

// ── Vault SSH bulk signing summaries (event_loop.rs) ────────────────

pub fn vault_config_reapply_failed(signed: usize, e: &impl std::fmt::Display) -> String {
    format!(
        "External edits detected; signed {} certs but failed to re-apply CertificateFile: {}",
        signed, e
    )
}

pub fn vault_external_edits_merged(summary: &str, reapplied: usize) -> String {
    format!(
        "{} External ssh config edits detected, merged {} CertificateFile directives.",
        summary, reapplied
    )
}

pub fn vault_external_edits_no_write(summary: &str) -> String {
    format!(
        "{} External ssh config edits detected; certs on disk, no CertificateFile written.",
        summary
    )
}

pub fn vault_reparse_failed(signed: usize, e: &impl std::fmt::Display) -> String {
    format!(
        "Signed {} certs but cannot re-parse ssh config after external edit: {}. \
         Certs are on disk under ~/.purple/certs/.",
        signed, e
    )
}

pub fn vault_config_update_failed(signed: usize, e: &impl std::fmt::Display) -> String {
    format!(
        "Signed {} certs but failed to update SSH config: {}",
        signed, e
    )
}

pub fn vault_config_write_after_sign(e: &impl std::fmt::Display) -> String {
    format!("Failed to update config after vault signing: {}", e)
}

// ── File browser ────────────────────────────────────────────────────

// ── Confirm / host key ──────────────────────────────────────────────

pub fn removed_host_key(hostname: &str) -> String {
    format!("Removed host key for {}. Reconnecting...", hostname)
}

// ── Host detail (tags) ──────────────────────────────────────────────

pub fn tagged_host(alias: &str, count: usize) -> String {
    format!(
        "Tagged {} with {} label{}.",
        alias,
        count,
        if count == 1 { "" } else { "s" }
    )
}

// ── Config reload ───────────────────────────────────────────────────

pub fn config_reloaded(count: usize) -> String {
    format!("Config reloaded. {} hosts.", count)
}

// ── Sync background ─────────────────────────────────────────────────

pub fn synced_progress(names: &str) -> String {
    format!("Synced: {}...", names)
}

pub fn synced_done(names: &str) -> String {
    format!("Synced: {}", names)
}

// ── Vault signing cancelled summary ─────────────────────────────────

pub fn vault_signing_cancelled_summary(
    signed: u32,
    failed: u32,
    first_error: Option<&str>,
) -> String {
    let mut msg = format!(
        "Vault SSH signing cancelled ({} signed, {} failed)",
        signed, failed
    );
    if let Some(err) = first_error {
        msg.push_str(": ");
        msg.push_str(err);
    }
    msg
}

// ── Region picker ───────────────────────────────────────────────────

pub fn regions_selected_count(count: usize, label: &str) -> String {
    let s = if count == 1 { "" } else { "s" };
    format!("{} {}{} selected.", count, label, s)
}

// ── Purge stale ─────────────────────────────────────────────────────

// ── Clipboard ───────────────────────────────────────────────────────

pub const NO_CLIPBOARD_TOOL: &str =
    "No clipboard tool found. Install pbcopy (macOS), wl-copy (Wayland), or xclip/xsel (X11).";

// ── CLI messages ────────────────────────────────────────────────────

pub mod cli {
    // ── Add host validation ─────────────────────────────────────────

    pub const ALIAS_EMPTY: &str = "Alias can't be empty. Use --alias to specify one.";
    pub const ALIAS_WHITESPACE: &str =
        "Alias can't contain whitespace. Use --alias to pick a simpler name.";
    pub const ALIAS_PATTERN_CHARS: &str =
        "Alias can't contain pattern characters. Use --alias to pick a different name.";
    pub const HOSTNAME_WHITESPACE: &str = "Hostname can't contain whitespace.";
    pub const USER_WHITESPACE: &str = "User can't contain whitespace.";
    pub const PASSWORD_EMPTY: &str = "Password can't be empty.";
    pub const CANCELLED: &str = "Cancelled.";
    pub const DESCRIPTION_CONTROL_CHARS: &str = "Description contains control characters.";

    pub use super::contains_control_chars as control_chars;

    pub fn welcome(alias: &str) -> String {
        format!("Welcome aboard, {}!", alias)
    }

    // ── Import ──────────────────────────────────────────────────────

    pub const IMPORT_NO_FILE: &str =
        "Provide a file or use --known-hosts. Run 'purple import --help' for details.";

    // ── Provider CLI ────────────────────────────────────────────────

    pub const NO_PROVIDERS: &str =
        "No providers configured. Run 'purple provider add' to set one up.";

    pub fn no_config_for(provider: &str) -> String {
        format!(
            "No configuration for {}. Run 'purple provider add {}' first.",
            provider, provider
        )
    }

    pub fn saved_config(provider: &str) -> String {
        format!("Saved {} configuration.", provider)
    }

    pub fn no_config_to_remove(provider: &str) -> String {
        format!("No configuration for '{}'. Nothing to remove.", provider)
    }

    pub fn removed_config(provider: &str) -> String {
        format!("Removed {} configuration.", provider)
    }

    // ── Tunnel CLI ──────────────────────────────────────────────────

    pub fn no_tunnels_for(alias: &str) -> String {
        format!("No tunnels configured for {}.", alias)
    }

    pub fn tunnels_for(alias: &str) -> String {
        format!("Tunnels for {}:", alias)
    }

    pub const NO_TUNNELS: &str = "No tunnels configured.";

    pub fn starting_tunnel(alias: &str) -> String {
        format!("Starting tunnel for {}... (Ctrl+C to stop)", alias)
    }

    pub fn host_not_found(alias: &str) -> String {
        format!("No host '{}' found.", alias)
    }

    pub fn added_forward(forward: &str, alias: &str) -> String {
        format!("Added {} to {}.", forward, alias)
    }

    pub fn forward_exists(forward: &str, alias: &str) -> String {
        format!("Forward {} already exists on {}.", forward, alias)
    }

    pub fn forward_not_found(forward: &str, alias: &str) -> String {
        format!("No matching forward {} found on {}.", forward, alias)
    }

    pub fn removed_forward(forward: &str, alias: &str) -> String {
        format!("Removed {} from {}.", forward, alias)
    }

    pub fn no_forwards(alias: &str) -> String {
        format!("No forwarding directives configured for '{}'.", alias)
    }

    // ── Snippet CLI ─────────────────────────────────────────────────

    pub const NO_SNIPPETS: &str = "No snippets configured. Use 'purple snippet add' to create one.";

    pub use super::snippet_added;
    pub use super::snippet_removed;
    pub use super::snippet_updated;

    pub fn snippet_not_found(name: &str) -> String {
        format!("No snippet '{}' found.", name)
    }

    pub fn no_hosts_with_tag(tag: &str) -> String {
        format!("No hosts found with tag '{}'.", tag)
    }

    pub const SPECIFY_TARGET: &str = "Specify a host alias, --tag or --all.";

    // ── Run/exec output ─────────────────────────────────────────────

    pub fn beaming_up(alias: &str) -> String {
        format!("Beaming you up to {}...\n", alias)
    }

    pub fn running_snippet_on(name: &str, alias: &str) -> String {
        format!("Running '{}' on {}...\n", name, alias)
    }

    pub fn host_separator(alias: &str) -> String {
        format!("── {} ──", alias)
    }

    pub fn exited_with_code(code: i32) -> String {
        format!("Exited with code {}.", code)
    }

    pub const DONE: &str = "Done.";

    pub fn done_multi(name: &str, count: usize) -> String {
        format!("Done. Ran '{}' on {} hosts.", name, count)
    }

    pub const PRESS_ENTER: &str = "Press Enter to continue...";

    pub fn host_failed(alias: &str, e: &impl std::fmt::Display) -> String {
        format!("[{}] Failed: {}", alias, e)
    }

    pub fn skipping_host(alias: &str, e: &impl std::fmt::Display) -> String {
        format!("Skipping {}: {}", alias, e)
    }

    // ── Password CLI ────────────────────────────────────────────────

    pub fn password_removed(alias: &str) -> String {
        format!("Password removed for {}.", alias)
    }

    // ── Log CLI ─────────────────────────────────────────────────────

    pub fn log_deleted(path: &impl std::fmt::Display) -> String {
        format!("Log file deleted: {}", path)
    }

    pub fn no_log_file(path: &impl std::fmt::Display) -> String {
        format!("No log file found at {}", path)
    }

    // ── Theme CLI ───────────────────────────────────────────────────

    pub const BUILTIN_THEMES: &str = "Built-in themes:";
    pub const CUSTOM_THEMES: &str = "\nCustom themes:";

    pub fn theme_set(name: &str) -> String {
        format!("Theme set to: {}", name)
    }

    // ── Sync output ─────────────────────────────────────────────────

    pub fn syncing(name: &str, summary: &str) -> String {
        format!("\x1b[2K\rSyncing {}... {}", name, summary)
    }

    pub fn servers_found_with_failures(count: usize, failures: usize, total: usize) -> String {
        format!(
            "{} servers found ({} of {} failed to fetch).",
            count, failures, total
        )
    }

    pub fn servers_found(count: usize) -> String {
        format!("{} servers found.", count)
    }

    pub fn sync_result(prefix: &str, added: usize, updated: usize, unchanged: usize) -> String {
        format!(
            "{}Added {}, updated {}, unchanged {}.",
            prefix, added, updated, unchanged
        )
    }

    pub fn sync_removed(count: usize) -> String {
        format!("  Removed {}.", count)
    }

    pub fn sync_stale(count: usize) -> String {
        format!("  Marked {} stale.", count)
    }

    pub fn sync_skip_remove(display_name: &str) -> String {
        format!(
            "! {}: skipping --remove due to partial failures.",
            display_name
        )
    }

    pub fn sync_error(display_name: &str, e: &impl std::fmt::Display) -> String {
        format!("! {}: {}", display_name, e)
    }

    pub const SYNC_SKIP_WRITE: &str =
        "! Skipping config write due to sync failures. Fix the errors and re-run.";

    // ── Provider validation (CLI) ───────────────────────────────────

    pub const PROXMOX_URL_REQUIRED: &str =
        "Proxmox requires --url (e.g. --url https://pve.example.com:8006).";
    pub const AWS_REGIONS_REQUIRED: &str =
        "AWS requires --regions (e.g. --regions us-east-1,eu-west-1).";
    pub const AZURE_REGIONS_REQUIRED: &str =
        "Azure requires --regions with one or more subscription IDs.";
    pub const GCP_PROJECT_REQUIRED: &str =
        "GCP requires --project (e.g. --project my-gcp-project-id).";
    pub use super::ALIAS_PREFIX_INVALID;

    pub const WARN_URL_NOT_USED: &str =
        "Warning: --url is only used by the Proxmox provider. Ignoring.";
    pub const WARN_PROFILE_NOT_USED: &str =
        "Warning: --profile is only used by the AWS provider. Ignoring.";
    pub const WARN_PROJECT_NOT_USED: &str =
        "Warning: --project is only used by the GCP provider. Ignoring.";
    pub const WARN_COMPARTMENT_NOT_USED: &str =
        "Warning: --compartment is only used by the Oracle provider. Ignoring.";

    // ── Vault CLI ───────────────────────────────────────────────────

    pub fn vault_no_role(alias: &str) -> String {
        format!(
            "No Vault SSH role configured for '{}'. Set it in the host form \
             (Vault SSH Role field) or in the provider config (vault_role).",
            alias
        )
    }

    pub fn vault_cert_signed(path: &impl std::fmt::Display) -> String {
        format!("Certificate signed: {}", path)
    }

    pub fn vault_sign_failed(e: &impl std::fmt::Display) -> String {
        format!("failed: {}", e)
    }

    pub fn vault_config_update_warning(e: &impl std::fmt::Display) -> String {
        format!("Warning: Failed to update SSH config: {}", e)
    }

    // ── List hosts ──────────────────────────────────────────────────

    pub const NO_HOSTS: &str = "No hosts configured. Run 'purple' to add some!";

    // ── Token ───────────────────────────────────────────────────────

    pub const NO_TOKEN: &str =
        "No token provided. Use --token, --token-stdin, or set PURPLE_TOKEN env var.";
}

// ── Update messages ─────────────────────────────────────────────────

pub mod update {
    pub const WHATS_NEW: &str = "What's new:";

    pub fn already_on(current: &str) -> String {
        format!("already on v{} (latest).", current)
    }

    pub fn available(latest: &str, current: &str) -> String {
        format!("v{} available (current: v{}).", latest, current)
    }
}

// ── Askpass / password prompts ───────────────────────────────────────

pub mod askpass {
    pub const BW_NOT_FOUND: &str = "Bitwarden CLI (bw) not found. SSH will prompt for password.";
    pub const BW_NOT_LOGGED_IN: &str = "Bitwarden vault not logged in. Run 'bw login' first.";
    pub const EMPTY_PASSWORD: &str = "Empty password. SSH will prompt for password.";
    pub const PASSWORD_IN_KEYCHAIN: &str = "Password stored in keychain.";

    pub fn read_failed(e: &impl std::fmt::Display) -> String {
        format!("Failed to read password: {}", e)
    }

    pub fn unlock_failed_retry(e: &impl std::fmt::Display) -> String {
        format!("Unlock failed: {}. Try again.", e)
    }

    pub fn unlock_failed_prompt(e: &impl std::fmt::Display) -> String {
        format!("Unlock failed: {}. SSH will prompt for password.", e)
    }
}

// ── Logging ─────────────────────────────────────────────────────────

pub mod logging {
    pub fn init_failed(e: &impl std::fmt::Display) -> String {
        format!("[purple] Failed to initialize logger: {}", e)
    }

    pub const SSH_VERSION_FAILED: &str = "[purple] Failed to detect SSH version. Is ssh installed?";
}

// ── Form field hints / placeholders ─────────────────────────────────
//
// Dimmed placeholder text shown in empty form fields. Centralized here
// so every user-visible string lives in one place and is auditable.

pub mod hints {
    // ── Shared ──────────────────────────────────────────────────────
    // Picker hints mention "Space" because per the design system keyboard
    // invariants (CLAUDE.md), Enter always submits a form; pickers open on
    // Space. Keep these strings in sync with scripts/check-keybindings.sh.
    pub const IDENTITY_FILE_PICK: &str = "Space to pick a key";
    pub const DEFAULT_SSH_USER: &str = "root";

    // ── Host form ───────────────────────────────────────────────────
    pub const HOST_ALIAS: &str = "e.g. prod or db-01";
    pub const HOST_ALIAS_PATTERN: &str = "10.0.0.* or *.example.com";
    pub const HOST_HOSTNAME: &str = "192.168.1.1 or example.com";
    pub const HOST_PORT: &str = "22";
    pub const HOST_PROXY_JUMP: &str = "Space to pick a host";
    pub const HOST_VAULT_SSH: &str = "e.g. ssh-client-signer/sign/my-role (auth via vault login)";
    pub const HOST_VAULT_SSH_PICKER: &str = "Space to pick a role or type one";
    pub const HOST_VAULT_ADDR: &str =
        "e.g. http://127.0.0.1:8200 (inherits from provider or env when empty)";
    pub const HOST_TAGS: &str = "e.g. prod, staging, us-east (comma-separated)";
    pub const HOST_ASKPASS_PICK: &str = "Space to pick a source";

    pub fn askpass_default(default: &str) -> String {
        format!("default: {}", default)
    }

    pub fn inherits_from(value: &str, provider: &str) -> String {
        format!("inherits {} from {}", value, provider)
    }

    // ── Tunnel form ─────────────────────────────────────────────────
    pub const TUNNEL_BIND_PORT: &str = "8080";
    pub const TUNNEL_REMOTE_HOST: &str = "localhost";
    pub const TUNNEL_REMOTE_PORT: &str = "80";

    // ── Snippet form ────────────────────────────────────────────────
    pub const SNIPPET_NAME: &str = "check-disk";
    pub const SNIPPET_COMMAND: &str = "df -h";
    pub const SNIPPET_OPTIONAL: &str = "(optional)";

    // ── Provider form ───────────────────────────────────────────────
    pub const PROVIDER_URL: &str = "https://pve.example.com:8006";
    pub const PROVIDER_TOKEN_DEFAULT: &str = "your-api-token";
    pub const PROVIDER_TOKEN_PROXMOX: &str = "user@pam!token=secret";
    pub const PROVIDER_TOKEN_AWS: &str = "AccessKeyId:Secret (or use Profile)";
    pub const PROVIDER_TOKEN_GCP: &str = "/path/to/service-account.json (or access token)";
    pub const PROVIDER_TOKEN_AZURE: &str = "/path/to/service-principal.json (or access token)";
    pub const PROVIDER_TOKEN_TAILSCALE: &str = "API key (leave empty for local CLI)";
    pub const PROVIDER_TOKEN_ORACLE: &str = "~/.oci/config";
    pub const PROVIDER_TOKEN_OVH: &str = "app_key:app_secret:consumer_key";
    pub const PROVIDER_PROFILE: &str = "Name from ~/.aws/credentials (or use Token)";
    pub const PROVIDER_PROJECT_DEFAULT: &str = "my-gcp-project-id";
    pub const PROVIDER_PROJECT_OVH: &str = "Public Cloud project ID";
    pub const PROVIDER_COMPARTMENT: &str = "ocid1.compartment.oc1..aaaa...";
    pub const PROVIDER_REGIONS_DEFAULT: &str = "Space to select regions";
    pub const PROVIDER_REGIONS_GCP: &str = "Space to select zones (empty = all)";
    pub const PROVIDER_REGIONS_SCALEWAY: &str = "Space to select zones";
    // Azure regions is a text input (not a picker), so no key is mentioned.
    pub const PROVIDER_REGIONS_AZURE: &str = "comma-separated subscription IDs";
    pub const PROVIDER_REGIONS_OVH: &str = "Space to select endpoint (default: EU)";
    pub const PROVIDER_USER_AWS: &str = "ec2-user";
    pub const PROVIDER_USER_GCP: &str = "ubuntu";
    pub const PROVIDER_USER_AZURE: &str = "azureuser";
    pub const PROVIDER_USER_ORACLE: &str = "opc";
    pub const PROVIDER_USER_OVH: &str = "ubuntu";
    pub const PROVIDER_VAULT_ROLE: &str =
        "e.g. ssh-client-signer/sign/my-role (vault login; inherited)";
    pub const PROVIDER_VAULT_ADDR: &str = "e.g. http://127.0.0.1:8200 (inherited by all hosts)";
    pub const PROVIDER_ALIAS_PREFIX_DEFAULT: &str = "prefix";
}

#[cfg(test)]
mod hints_tests {
    use super::hints;

    #[test]
    fn askpass_default_formats() {
        assert_eq!(hints::askpass_default("keychain"), "default: keychain");
    }

    #[test]
    fn askpass_default_formats_empty() {
        assert_eq!(hints::askpass_default(""), "default: ");
    }

    #[test]
    fn inherits_from_formats() {
        assert_eq!(
            hints::inherits_from("role/x", "aws"),
            "inherits role/x from aws"
        );
    }

    #[test]
    fn picker_hints_mention_space_not_enter() {
        // Per the keyboard invariants (CLAUDE.md), pickers open on Space.
        // If these assertions fail, audit scripts/check-keybindings.sh too.
        for s in [
            hints::IDENTITY_FILE_PICK,
            hints::HOST_PROXY_JUMP,
            hints::HOST_VAULT_SSH_PICKER,
            hints::HOST_ASKPASS_PICK,
            hints::PROVIDER_REGIONS_DEFAULT,
            hints::PROVIDER_REGIONS_GCP,
            hints::PROVIDER_REGIONS_SCALEWAY,
            hints::PROVIDER_REGIONS_OVH,
        ] {
            assert!(
                s.starts_with("Space "),
                "picker hint must mention Space: {s}"
            );
            assert!(!s.contains("Enter "), "picker hint must not say Enter: {s}");
        }
    }
}