Update the CLI to use the translatable strings
Marco Ricci

Marco Ricci commited on 2024-12-30 23:45:01
Zeige 2 geänderte Dateien mit 1170 Einfügungen und 336 Löschungen.


We implement the translations in a deferred manner, via objects that
stringify to their correct translation.  Since `click` 8.1, help texts
are (mostly) stored first and processed only when the help page is
emitted.  Accordingly, this system can be adapted to support our
translatable strings: we need only ensure the help text is stringified
just before it is formatted and emitted.  This in turn can be achieved
by reimplementing the help page assembly and formatting in a custom
`click.Command` class, as has already been done to implement help/option
group support.  Once this is in place, it remains to exchange all inline
help texts with the corresponding translatable string, and adapt the
tests to the new messages.

While most of the changes are straightforward, some subtleties remain.

First, the help texts are now supplied as (separately translatable)
paragraphs to the `click.command` decorator, instead of being extracted
from the respective function docstring.  The docstrings in return have
been shortened down.

Second, since the new capabilities are implemented via custom
`click.Command`, `click.MultiCommand` and `click.Option` subclasses,
every option and command must derive from one of these classes.  This
necessitates the creation of an empty `StandardOption` class for options
without an option group, and making `_DefaultToVaultGroup` a subclass of
`CommandWithHelpGroups`.

Third, since the `click.Option` class actually processes its help text
eagerly, the subclass must hide the (non-string) help text from the
`click.Option` constructor and later restore it.

Finally, while the tests currently pass as before, they now implicitly
rely on *not* loading any translations.  Furthermore, there is no
mechanism in place with which to isolate the translation system from the
user's environment for testing purposes, nor is there a (controlled)
mechanism to test `derivepassphrase` under a different language.  Once
there are actual translations, future commits will need to establish
such a missing mechanism for translation loading/switching/forcing.
... ...
@@ -41,6 +41,7 @@ from typing_extensions import (
41 41
 )
42 42
 
43 43
 import derivepassphrase as dpp
44
+from derivepassphrase import _cli_msg as _msg
44 45
 from derivepassphrase import _types, exporter, ssh_agent, vault
45 46
 
46 47
 if sys.version_info >= (3, 11):
... ...
@@ -63,14 +64,17 @@ __version__ = dpp.__version__
63 64
 
64 65
 __all__ = ('derivepassphrase',)
65 66
 
66
-PROG_NAME = 'derivepassphrase'
67
+PROG_NAME = _msg.PROG_NAME
67 68
 KEY_DISPLAY_LENGTH = 50
68 69
 
69 70
 # Error messages
70 71
 _INVALID_VAULT_CONFIG = 'Invalid vault config'
71 72
 _AGENT_COMMUNICATION_ERROR = 'Error communicating with the SSH agent'
72
-_NO_USABLE_KEYS = 'No usable SSH keys were found'
73
+_NO_SUITABLE_KEYS = 'No suitable SSH keys were found'
73 74
 _EMPTY_SELECTION = 'Empty selection'
75
+_NOT_AN_INTEGER = 'not an integer'
76
+_NOT_A_NONNEGATIVE_INTEGER = 'not a non-negative integer'
77
+_NOT_A_POSITIVE_INTEGER = 'not a positive integer'
74 78
 
75 79
 
76 80
 # Logging
... ...
@@ -181,10 +185,15 @@ class CLIofPackageFormatter(logging.Formatter):
181 185
         else:  # pragma: no cover
182 186
             msg = f'Unsupported logging level: {record.levelname}'
183 187
             raise AssertionError(msg)
184
-        return ''.join(
188
+        parts = [
189
+            ''.join(
185 190
                 prefix + level_indicator + line
186 191
                 for line in preliminary_result.splitlines(True)  # noqa: FBT003
187 192
             )
193
+        ]
194
+        if record.exc_info:
195
+            parts.append(self.formatException(record.exc_info) + '\n')
196
+        return ''.join(parts)
188 197
 
189 198
 
190 199
 class StandardCLILogging:
... ...
@@ -393,27 +402,254 @@ class OptionGroupOption(click.Option):
393 402
 
394 403
     """
395 404
 
396
-    option_group_name: str = ''
405
+    option_group_name: object = ''
397 406
     """"""
398
-    epilog: str = ''
407
+    epilog: object = ''
399 408
     """"""
400 409
 
401 410
     def __init__(self, *args: Any, **kwargs: Any) -> None:  # noqa: ANN401
402 411
         if self.__class__ == __class__:  # type: ignore[name-defined]
403 412
             raise NotImplementedError
413
+        # Though click 8.1 mostly defers help text processing until the
414
+        # `BaseCommand.format_*` methods are called, the Option
415
+        # constructor still preprocesses the help text, and asserts that
416
+        # the help text is a string.  Work around this by removing the
417
+        # help text from the constructor arguments and re-adding it,
418
+        # unprocessed, after constructor finishes.
419
+        unset = object()
420
+        help = kwargs.pop('help', unset)  # noqa: A001
404 421
         super().__init__(*args, **kwargs)
422
+        if help is not unset:  # pragma: no branch
423
+            self.help = help
405 424
 
406 425
 
407 426
 class CommandWithHelpGroups(click.Command):
408
-    """A [`click.Command`][] with support for help/option groups.
427
+    """A [`click.Command`][] with support for some help text customizations.
428
+
429
+    Supports help/option groups, group epilogs, and help text objects
430
+    (objects that stringify to help texts).  The latter is primarily
431
+    used to implement translations.
409 432
 
410
-    Inspired by [a comment on `pallets/click#373`][CLICK_ISSUE], and
411
-    further modified to support group epilogs.
433
+    Inspired by [a comment on `pallets/click#373`][CLICK_ISSUE] for
434
+    help/option group support, and further modified to include group
435
+    epilogs and help text objects.
412 436
 
413 437
     [CLICK_ISSUE]: https://github.com/pallets/click/issues/373#issuecomment-515293746
414 438
 
415 439
     """
416 440
 
441
+    @staticmethod
442
+    def _text(text: object, /) -> str:
443
+        if isinstance(text, (list, tuple)):
444
+            return '\n\n'.join(str(x) for x in text)
445
+        return str(text)
446
+
447
+    def collect_usage_pieces(self, ctx: click.Context) -> list[str]:
448
+        """Return the pieces for the usage string.
449
+
450
+        Based on code from click 8.1.  Subject to the following license
451
+        (3-clause BSD license):
452
+
453
+            Copyright 2024 Pallets
454
+
455
+            Redistribution and use in source and binary forms, with or
456
+            without modification, are permitted provided that the
457
+            following conditions are met:
458
+
459
+             1. Redistributions of source code must retain the above
460
+                copyright notice, this list of conditions and the
461
+                following disclaimer.
462
+
463
+             2. Redistributions in binary form must reproduce the above
464
+                copyright notice, this list of conditions and the
465
+                following disclaimer in the documentation and/or other
466
+                materials provided with the distribution.
467
+
468
+             3. Neither the name of the copyright holder nor the names
469
+                of its contributors may be used to endorse or promote
470
+                products derived from this software without specific
471
+                prior written permission.
472
+
473
+            THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
474
+            CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES,
475
+            INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
476
+            MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
477
+            DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
478
+            CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
479
+            SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
480
+            NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
481
+            LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
482
+            HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
483
+            CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
484
+            OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
485
+            SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
486
+
487
+        Modifications are marked with respective comments.  They too are
488
+        released under the same license above.  The original code did
489
+        not contain any "noqa" or "pragma" comments.
490
+
491
+        Args:
492
+            ctx:
493
+                The click context.
494
+
495
+        """
496
+        rv = [str(self.options_metavar)] if self.options_metavar else []
497
+        for param in self.get_params(ctx):
498
+            rv.extend(str(x) for x in param.get_usage_pieces(ctx))
499
+        return rv
500
+
501
+    def get_short_help_str(
502
+        self,
503
+        limit: int = 45,
504
+    ) -> str:
505
+        """Return the short help string for a command.
506
+
507
+        If only a long help string is given, shorten it.
508
+
509
+        Based on code from click 8.1.  Subject to the following license
510
+        (3-clause BSD license):
511
+
512
+            Copyright 2024 Pallets
513
+
514
+            Redistribution and use in source and binary forms, with or
515
+            without modification, are permitted provided that the
516
+            following conditions are met:
517
+
518
+             1. Redistributions of source code must retain the above
519
+                copyright notice, this list of conditions and the
520
+                following disclaimer.
521
+
522
+             2. Redistributions in binary form must reproduce the above
523
+                copyright notice, this list of conditions and the
524
+                following disclaimer in the documentation and/or other
525
+                materials provided with the distribution.
526
+
527
+             3. Neither the name of the copyright holder nor the names
528
+                of its contributors may be used to endorse or promote
529
+                products derived from this software without specific
530
+                prior written permission.
531
+
532
+            THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
533
+            CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES,
534
+            INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
535
+            MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
536
+            DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
537
+            CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
538
+            SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
539
+            NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
540
+            LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
541
+            HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
542
+            CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
543
+            OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
544
+            SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
545
+
546
+        Modifications are marked with respective comments.  They too are
547
+        released under the same license above.  The original code did
548
+        not contain any "noqa" or "pragma" comments.
549
+
550
+        Args:
551
+            limit:
552
+                The maximum width of the short help string.
553
+
554
+        """
555
+        # Modification against click 8.1: Call `_text()` on `self.help`
556
+        # to allow help texts to be general objects, not just strings.
557
+        # Used to implement translatable strings, as objects that
558
+        # stringify to the translation.
559
+        if self.short_help:  # pragma: no cover
560
+            text = inspect.cleandoc(self._text(self.short_help))
561
+        elif self.help:
562
+            text = click.utils.make_default_short_help(
563
+                self._text(self.help), limit
564
+            )
565
+        else:  # pragma: no cover
566
+            text = ''
567
+        if self.deprecated:  # pragma: no cover
568
+            # Modification against click 8.1: The translated string is
569
+            # looked up in the derivepassphrase message domain, not the
570
+            # gettext default domain.
571
+            text = str(
572
+                _msg.TranslatedString(_msg.Label.DEPRECATED_COMMAND_LABEL)
573
+            ).format(text=text)
574
+        return text.strip()
575
+
576
+    def format_help_text(
577
+        self,
578
+        ctx: click.Context,
579
+        formatter: click.HelpFormatter,
580
+    ) -> None:
581
+        """Format the help text prologue, if any.
582
+
583
+        Based on code from click 8.1.  Subject to the following license
584
+        (3-clause BSD license):
585
+
586
+            Copyright 2024 Pallets
587
+
588
+            Redistribution and use in source and binary forms, with or
589
+            without modification, are permitted provided that the
590
+            following conditions are met:
591
+
592
+             1. Redistributions of source code must retain the above
593
+                copyright notice, this list of conditions and the
594
+                following disclaimer.
595
+
596
+             2. Redistributions in binary form must reproduce the above
597
+                copyright notice, this list of conditions and the
598
+                following disclaimer in the documentation and/or other
599
+                materials provided with the distribution.
600
+
601
+             3. Neither the name of the copyright holder nor the names
602
+                of its contributors may be used to endorse or promote
603
+                products derived from this software without specific
604
+                prior written permission.
605
+
606
+            THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
607
+            CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES,
608
+            INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
609
+            MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
610
+            DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
611
+            CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
612
+            SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
613
+            NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
614
+            LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
615
+            HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
616
+            CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
617
+            OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
618
+            SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
619
+
620
+        Modifications are marked with respective comments.  They too are
621
+        released under the same license above.  The original code did
622
+        not contain any "noqa" or "pragma" comments.
623
+
624
+        Args:
625
+            ctx:
626
+                The click context.
627
+            formatter:
628
+                The formatter for the `--help` listing.
629
+
630
+        """
631
+        del ctx
632
+        # Modification against click 8.1: Call `_text()` on `self.help`
633
+        # to allow help texts to be general objects, not just strings.
634
+        # Used to implement translatable strings, as objects that
635
+        # stringify to the translation.
636
+        text = (
637
+            inspect.cleandoc(self._text(self.help).partition('\f')[0])
638
+            if self.help is not None
639
+            else ''
640
+        )
641
+        if self.deprecated:  # pragma: no cover
642
+            # Modification against click 8.1: The translated string is
643
+            # looked up in the derivepassphrase message domain, not the
644
+            # gettext default domain.
645
+            text = str(
646
+                _msg.TranslatedString(_msg.Label.DEPRECATED_COMMAND_LABEL)
647
+            ).format(text=text)
648
+        if text:  # pragma: no branch
649
+            formatter.write_paragraph()
650
+            with formatter.indentation():
651
+                formatter.write_text(text)
652
+
417 653
     def format_options(
418 654
         self,
419 655
         ctx: click.Context,
... ...
@@ -433,6 +669,48 @@ class CommandWithHelpGroups(click.Command):
433 669
         section heading is "Options" (or "Other options" if there are
434 670
         other option groups) and the epilog is empty.
435 671
 
672
+        We unconditionally call [`format_commands`][], and rely on it to
673
+        act as a no-op if we aren't actually a [`click.MultiCommand`][].
674
+
675
+        Based on code from click 8.1.  Subject to the following license
676
+        (3-clause BSD license):
677
+
678
+            Copyright 2024 Pallets
679
+
680
+            Redistribution and use in source and binary forms, with or
681
+            without modification, are permitted provided that the
682
+            following conditions are met:
683
+
684
+             1. Redistributions of source code must retain the above
685
+                copyright notice, this list of conditions and the
686
+                following disclaimer.
687
+
688
+             2. Redistributions in binary form must reproduce the above
689
+                copyright notice, this list of conditions and the
690
+                following disclaimer in the documentation and/or other
691
+                materials provided with the distribution.
692
+
693
+             3. Neither the name of the copyright holder nor the names
694
+                of its contributors may be used to endorse or promote
695
+                products derived from this software without specific
696
+                prior written permission.
697
+
698
+            THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
699
+            CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES,
700
+            INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
701
+            MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
702
+            DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
703
+            CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
704
+            SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
705
+            NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
706
+            LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
707
+            HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
708
+            CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
709
+            OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
710
+            SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
711
+
712
+        Modifications are released under the same license above.
713
+
436 714
         Args:
437 715
             ctx:
438 716
                 The click context.
... ...
@@ -440,6 +718,7 @@ class CommandWithHelpGroups(click.Command):
440 718
                 The formatter for the `--help` listing.
441 719
 
442 720
         """
721
+        default_group_name = ''
443 722
         help_records: dict[str, list[tuple[str, str]]] = {}
444 723
         epilogs: dict[str, str] = {}
445 724
         params = self.params[:]
... ...
@@ -451,15 +730,22 @@ class CommandWithHelpGroups(click.Command):
451 730
         for param in params:
452 731
             rec = param.get_help_record(ctx)
453 732
             if rec is not None:
733
+                rec = (rec[0], self._text(rec[1]))
454 734
                 if isinstance(param, OptionGroupOption):
455
-                    group_name = param.option_group_name
456
-                    epilogs.setdefault(group_name, param.epilog)
735
+                    group_name = self._text(param.option_group_name)
736
+                    epilogs.setdefault(group_name, self._text(param.epilog))
457 737
                 else:
458
-                    group_name = ''
738
+                    group_name = default_group_name
459 739
                 help_records.setdefault(group_name, []).append(rec)
460
-        default_group = help_records.pop('')
461
-        default_group_name = (
462
-            'Other Options' if len(default_group) > 1 else 'Options'
740
+        if default_group_name in help_records:  # pragma: no branch
741
+            default_group = help_records.pop(default_group_name)
742
+            default_group_label = (
743
+                _msg.Label.OTHER_OPTIONS_LABEL
744
+                if len(default_group) > 1
745
+                else _msg.Label.OPTIONS_LABEL
746
+            )
747
+            default_group_name = self._text(
748
+                _msg.TranslatedString(default_group_label)
463 749
             )
464 750
             help_records[default_group_name] = default_group
465 751
         for group_name, records in help_records.items():
... ...
@@ -470,12 +756,158 @@ class CommandWithHelpGroups(click.Command):
470 756
                 formatter.write_paragraph()
471 757
                 with formatter.indentation():
472 758
                     formatter.write_text(epilog)
759
+        self.format_commands(ctx, formatter)
760
+
761
+    def format_commands(
762
+        self,
763
+        ctx: click.Context,
764
+        formatter: click.HelpFormatter,
765
+    ) -> None:
766
+        """Format the subcommands, if any.
767
+
768
+        If called on a command object that isn't derived from
769
+        [`click.MultiCommand`][], then do nothing.
770
+
771
+        Based on code from click 8.1.  Subject to the following license
772
+        (3-clause BSD license):
773
+
774
+            Copyright 2024 Pallets
775
+
776
+            Redistribution and use in source and binary forms, with or
777
+            without modification, are permitted provided that the
778
+            following conditions are met:
779
+
780
+             1. Redistributions of source code must retain the above
781
+                copyright notice, this list of conditions and the
782
+                following disclaimer.
783
+
784
+             2. Redistributions in binary form must reproduce the above
785
+                copyright notice, this list of conditions and the
786
+                following disclaimer in the documentation and/or other
787
+                materials provided with the distribution.
788
+
789
+             3. Neither the name of the copyright holder nor the names
790
+                of its contributors may be used to endorse or promote
791
+                products derived from this software without specific
792
+                prior written permission.
793
+
794
+            THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
795
+            CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES,
796
+            INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
797
+            MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
798
+            DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
799
+            CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
800
+            SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
801
+            NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
802
+            LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
803
+            HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
804
+            CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
805
+            OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
806
+            SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
807
+
808
+        Modifications are marked with respective comments.  They too are
809
+        released under the same license above.  The original code did
810
+        not contain any "noqa" or "pragma" comments.
811
+
812
+        Args:
813
+            ctx:
814
+                The click context.
815
+            formatter:
816
+                The formatter for the `--help` listing.
817
+
818
+        """
819
+        if not isinstance(self, click.MultiCommand):
820
+            return
821
+        commands: list[tuple[str, click.Command]] = []
822
+        for subcommand in self.list_commands(ctx):
823
+            cmd = self.get_command(ctx, subcommand)
824
+            if cmd is None or cmd.hidden:  # pragma: no cover
825
+                continue
826
+            commands.append((subcommand, cmd))
827
+        if commands:  # pragma: no branch
828
+            longest_command = max((cmd[0] for cmd in commands), key=len)
829
+            limit = formatter.width - 6 - len(longest_command)
830
+            rows: list[tuple[str, str]] = []
831
+            for subcommand, cmd in commands:
832
+                help_str = self._text(cmd.get_short_help_str(limit) or '')
833
+                rows.append((subcommand, help_str))
834
+            if rows:  # pragma: no branch
835
+                commands_label = self._text(
836
+                    _msg.TranslatedString(_msg.Label.COMMANDS_LABEL)
837
+                )
838
+                with formatter.section(commands_label):
839
+                    formatter.write_dl(rows)
840
+
841
+    def format_epilog(
842
+        self,
843
+        ctx: click.Context,
844
+        formatter: click.HelpFormatter,
845
+    ) -> None:
846
+        """Format the epilog, if any.
847
+
848
+        Based on code from click 8.1.  Subject to the following license
849
+        (3-clause BSD license):
850
+
851
+            Copyright 2024 Pallets
852
+
853
+            Redistribution and use in source and binary forms, with or
854
+            without modification, are permitted provided that the
855
+            following conditions are met:
856
+
857
+             1. Redistributions of source code must retain the above
858
+                copyright notice, this list of conditions and the
859
+                following disclaimer.
860
+
861
+             2. Redistributions in binary form must reproduce the above
862
+                copyright notice, this list of conditions and the
863
+                following disclaimer in the documentation and/or other
864
+                materials provided with the distribution.
865
+
866
+             3. Neither the name of the copyright holder nor the names
867
+                of its contributors may be used to endorse or promote
868
+                products derived from this software without specific
869
+                prior written permission.
870
+
871
+            THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
872
+            CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES,
873
+            INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
874
+            MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
875
+            DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
876
+            CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
877
+            SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
878
+            NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
879
+            LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
880
+            HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
881
+            CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
882
+            OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
883
+            SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
884
+
885
+        Modifications are marked with respective comments.  They too are
886
+        released under the same license above.
887
+
888
+        Args:
889
+            ctx:
890
+                The click context.
891
+            formatter:
892
+                The formatter for the `--help` listing.
893
+
894
+        """
895
+        del ctx
896
+        if self.epilog:  # pragma: no branch
897
+            # Modification against click 8.1: Call `str()` on
898
+            # `self.epilog` to allow help texts to be general objects,
899
+            # not just strings.  Used to implement translatable strings,
900
+            # as objects that stringify to the translation.
901
+            epilog = inspect.cleandoc(self._text(self.epilog))
902
+            formatter.write_paragraph()
903
+            with formatter.indentation():
904
+                formatter.write_text(epilog)
473 905
 
474 906
 
475 907
 class LoggingOption(OptionGroupOption):
476 908
     """Logging options for the CLI."""
477 909
 
478
-    option_group_name = 'Logging'
910
+    option_group_name = _msg.TranslatedString(_msg.Label.LOGGING_LABEL)
479 911
     epilog = ''
480 912
 
481 913
 
... ...
@@ -486,7 +918,7 @@ debug_option = click.option(
486 918
     flag_value=logging.DEBUG,
487 919
     expose_value=False,
488 920
     callback=adjust_logging_level,
489
-    help='also emit debug information (implies --verbose)',
921
+    help=_msg.TranslatedString(_msg.Label.DEBUG_OPTION_HELP_TEXT),
490 922
     cls=LoggingOption,
491 923
 )
492 924
 verbose_option = click.option(
... ...
@@ -497,7 +929,7 @@ verbose_option = click.option(
497 929
     flag_value=logging.INFO,
498 930
     expose_value=False,
499 931
     callback=adjust_logging_level,
500
-    help='emit extra/progress information to standard error',
932
+    help=_msg.TranslatedString(_msg.Label.VERBOSE_OPTION_HELP_TEXT),
501 933
     cls=LoggingOption,
502 934
 )
503 935
 quiet_option = click.option(
... ...
@@ -508,7 +940,7 @@ quiet_option = click.option(
508 940
     flag_value=logging.ERROR,
509 941
     expose_value=False,
510 942
     callback=adjust_logging_level,
511
-    help='suppress even warnings, emit only errors',
943
+    help=_msg.TranslatedString(_msg.Label.QUIET_OPTION_HELP_TEXT),
512 944
     cls=LoggingOption,
513 945
 )
514 946
 
... ...
@@ -534,7 +966,7 @@ def standard_logging_options(f: Callable[P, R]) -> Callable[P, R]:
534 966
 # =========
535 967
 
536 968
 
537
-class _DefaultToVaultGroup(click.Group):
969
+class _DefaultToVaultGroup(CommandWithHelpGroups, click.Group):
538 970
     """A helper class to implement the default-to-"vault"-subcommand behavior.
539 971
 
540 972
     Modifies internal [`click.MultiCommand`][] methods, and thus is both
... ...
@@ -548,16 +980,53 @@ class _DefaultToVaultGroup(click.Group):
548 980
         """Resolve a command, but default to "vault" instead of erroring out.
549 981
 
550 982
         Based on code from click 8.1, which appears to be essentially
551
-        untouched since at least click 3.2.
983
+        untouched since at least click 3.2.  Subject to the following
984
+        license (3-clause BSD license):
985
+
986
+            Copyright 2024 Pallets
987
+
988
+            Redistribution and use in source and binary forms, with or
989
+            without modification, are permitted provided that the following
990
+            conditions are met:
991
+
992
+             1. Redistributions of source code must retain the above
993
+                copyright notice, this list of conditions and the following
994
+                disclaimer.
995
+
996
+             2. Redistributions in binary form must reproduce the above
997
+                copyright notice, this list of conditions and the following
998
+                disclaimer in the documentation and/or other materials
999
+                provided with the distribution.
1000
+
1001
+             3. Neither the name of the copyright holder nor the names of
1002
+                its contributors may be used to endorse or promote products
1003
+                derived from this software without specific prior written
1004
+                permission.
1005
+
1006
+            THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
1007
+            CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES,
1008
+            INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
1009
+            MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
1010
+            DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
1011
+            CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
1012
+            SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
1013
+            LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
1014
+            USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
1015
+            AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
1016
+            LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING
1017
+            IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
1018
+            THE POSSIBILITY OF SUCH DAMAGE.
1019
+
1020
+        Modifications to this routine are marked with "modifications for
1021
+        derivepassphrase".  Furthermore, all "pragma" and "noqa" comments
1022
+        are also modifications for derivepassphrase.
552 1023
 
553 1024
         """
554 1025
         cmd_name = click.utils.make_str(args[0])
555 1026
 
556
-        # ORIGINAL COMMENT
557 1027
         # Get the command
558 1028
         cmd = self.get_command(ctx, cmd_name)
559 1029
 
560
-        # ORIGINAL COMMENT
561 1030
         # If we can't find the command but there is a normalization
562 1031
         # function available, we try with that one.
563 1032
         if (  # pragma: no cover
... ...
@@ -566,7 +1035,6 @@ class _DefaultToVaultGroup(click.Group):
566 1035
             cmd_name = ctx.token_normalize_func(cmd_name)
567 1036
             cmd = self.get_command(ctx, cmd_name)
568 1037
 
569
-        # ORIGINAL COMMENT
570 1038
         # If we don't find the command we want to show an error message
571 1039
         # to the user that it was not provided.  However, there is
572 1040
         # something else we should do: if the first argument looks like
... ...
@@ -576,19 +1044,24 @@ class _DefaultToVaultGroup(click.Group):
576 1044
         if cmd is None and not ctx.resilient_parsing:
577 1045
             if click.parser.split_opt(cmd_name)[0]:
578 1046
                 self.parse_args(ctx, ctx.args)
1047
+            ####
1048
+            # BEGIN modifications for derivepassphrase
1049
+            #
579 1050
             # Instead of calling ctx.fail here, default to "vault", and
580 1051
             # issue a deprecation warning.
581
-            logger = logging.getLogger(PROG_NAME)
582 1052
             deprecation = logging.getLogger(f'{PROG_NAME}.deprecation')
583 1053
             deprecation.warning(
584
-                'A subcommand will be required in v1.0. '
585
-                'See --help for available subcommands.'
1054
+                _msg.TranslatedString(
1055
+                    _msg.WarnMsgTemplate.V10_SUBCOMMAND_REQUIRED
1056
+                )
586 1057
             )
587
-            logger.warning('Defaulting to subcommand "vault".')
588 1058
             cmd_name = 'vault'
589 1059
             cmd = self.get_command(ctx, cmd_name)
590 1060
             assert cmd is not None, 'Mandatory subcommand "vault" missing!'
591 1061
             args = [cmd_name, *args]
1062
+            #
1063
+            # END modifications for derivepassphrase
1064
+            ####
592 1065
         return cmd_name if cmd else None, cmd, args[1:]  # noqa: DOC201
593 1066
 
594 1067
 
... ...
@@ -628,14 +1101,14 @@ class _TopLevelCLIEntryPoint(_DefaultToVaultGroup):
628 1101
         'ignore_unknown_options': True,
629 1102
         'allow_interspersed_args': False,
630 1103
     },
631
-    epilog=r"""
632
-        Configuration is stored in a directory according to the
633
-        DERIVEPASSPHRASE_PATH variable, which defaults to
634
-        `~/.derivepassphrase` on UNIX-like systems and
635
-        `C:\Users\<user>\AppData\Roaming\Derivepassphrase` on Windows.
636
-    """,
1104
+    epilog=_msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_EPILOG_01),
637 1105
     invoke_without_command=True,
638 1106
     cls=_TopLevelCLIEntryPoint,
1107
+    help=(
1108
+        _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_01),
1109
+        _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_02),
1110
+        _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_03),
1111
+    ),
639 1112
 )
640 1113
 @click.version_option(version=dpp.__version__, prog_name=PROG_NAME)
641 1114
 @standard_logging_options
... ...
@@ -643,41 +1116,20 @@ class _TopLevelCLIEntryPoint(_DefaultToVaultGroup):
643 1116
 def derivepassphrase(ctx: click.Context, /) -> None:
644 1117
     """Derive a strong passphrase, deterministically, from a master secret.
645 1118
 
646
-    Using a master secret, derive a passphrase for a named service,
647
-    subject to constraints e.g. on passphrase length, allowed
648
-    characters, etc.  The exact derivation depends on the selected
649
-    derivation scheme.  For each scheme, it is computationally
650
-    infeasible to discern the master secret from the derived passphrase.
651
-    The derivations are also deterministic, given the same inputs, thus
652
-    the resulting passphrases need not be stored explicitly.  The
653
-    service name and constraints themselves also generally need not be
654
-    kept secret, depending on the scheme.
655
-
656
-    The currently implemented subcommands are "vault" (for the scheme
657
-    used by vault) and "export" (for exporting foreign configuration
658
-    data).  See the respective `--help` output for instructions.  If no
659
-    subcommand is given, we default to "vault".
660
-
661
-    Deprecation notice: Defaulting to "vault" is deprecated.  Starting
662
-    in v1.0, the subcommand must be specified explicitly.\f
663
-
664 1119
     This is a [`click`][CLICK]-powered command-line interface function,
665
-    and not intended for programmatic use.  Call with arguments
666
-    `['--help']` to see full documentation of the interface.  (See also
1120
+    and not intended for programmatic use.  See the derivepassphrase(1)
1121
+    manpage for full documentation of the interface.  (See also
667 1122
     [`click.testing.CliRunner`][] for controlled, programmatic
668 1123
     invocation.)
669 1124
 
670 1125
     [CLICK]: https://pypi.org/package/click/
671 1126
 
672
-    """  # noqa: D301
673
-    logger = logging.getLogger(PROG_NAME)
1127
+    """
674 1128
     deprecation = logging.getLogger(f'{PROG_NAME}.deprecation')
675 1129
     if ctx.invoked_subcommand is None:
676 1130
         deprecation.warning(
677
-            'A subcommand will be required in v1.0. '
678
-            'See --help for available subcommands.'
1131
+            _msg.TranslatedString(_msg.WarnMsgTemplate.V10_SUBCOMMAND_REQUIRED)
679 1132
         )
680
-        logger.warning('Defaulting to subcommand "vault".')
681 1133
         # See definition of click.Group.invoke, non-chained case.
682 1134
         with ctx:
683 1135
             sub_ctx = derivepassphrase_vault.make_context(
... ...
@@ -701,6 +1153,11 @@ def derivepassphrase(ctx: click.Context, /) -> None:
701 1153
     },
702 1154
     invoke_without_command=True,
703 1155
     cls=_DefaultToVaultGroup,
1156
+    help=(
1157
+        _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_EXPORT_01),
1158
+        _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_EXPORT_02),
1159
+        _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_EXPORT_03),
1160
+    ),
704 1161
 )
705 1162
 @click.version_option(version=dpp.__version__, prog_name=PROG_NAME)
706 1163
 @standard_logging_options
... ...
@@ -708,33 +1165,20 @@ def derivepassphrase(ctx: click.Context, /) -> None:
708 1165
 def derivepassphrase_export(ctx: click.Context, /) -> None:
709 1166
     """Export a foreign configuration to standard output.
710 1167
 
711
-    Read a foreign system configuration, extract all information from
712
-    it, and export the resulting configuration to standard output.
713
-
714
-    The only available subcommand is "vault", which implements the
715
-    vault-native configuration scheme.  If no subcommand is given, we
716
-    default to "vault".
717
-
718
-    Deprecation notice: Defaulting to "vault" is deprecated.  Starting
719
-    in v1.0, the subcommand must be specified explicitly.\f
720
-
721 1168
     This is a [`click`][CLICK]-powered command-line interface function,
722
-    and not intended for programmatic use.  Call with arguments
723
-    `['--help']` to see full documentation of the interface.  (See also
724
-    [`click.testing.CliRunner`][] for controlled, programmatic
725
-    invocation.)
1169
+    and not intended for programmatic use.  See the
1170
+    derivepassphrase-export(1) manpage for full documentation of the
1171
+    interface.  (See also [`click.testing.CliRunner`][] for controlled,
1172
+    programmatic invocation.)
726 1173
 
727 1174
     [CLICK]: https://pypi.org/package/click/
728 1175
 
729
-    """  # noqa: D301
730
-    logger = logging.getLogger(PROG_NAME)
1176
+    """
731 1177
     deprecation = logging.getLogger(f'{PROG_NAME}.deprecation')
732 1178
     if ctx.invoked_subcommand is None:
733 1179
         deprecation.warning(
734
-            'A subcommand will be required in v1.0. '
735
-            'See --help for available subcommands.'
1180
+            _msg.TranslatedString(_msg.WarnMsgTemplate.V10_SUBCOMMAND_REQUIRED)
736 1181
         )
737
-        logger.warning('Defaulting to subcommand "vault".')
738 1182
         # See definition of click.Group.invoke, non-chained case.
739 1183
         with ctx:
740 1184
             sub_ctx = derivepassphrase_export_vault.make_context(
... ...
@@ -787,32 +1231,68 @@ def _load_data(
787 1231
         assert_never(fmt)
788 1232
 
789 1233
 
1234
+class StandardOption(OptionGroupOption):
1235
+    pass
1236
+
1237
+
790 1238
 @derivepassphrase_export.command(
791 1239
     'vault',
792 1240
     context_settings={'help_option_names': ['-h', '--help']},
1241
+    cls=CommandWithHelpGroups,
1242
+    help=(
1243
+        _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_EXPORT_VAULT_01),
1244
+        _msg.TranslatedString(
1245
+            _msg.Label.DERIVEPASSPHRASE_EXPORT_VAULT_02,
1246
+            path_metavar=_msg.TranslatedString(
1247
+                _msg.Label.EXPORT_VAULT_METAVAR_PATH,
1248
+            ),
1249
+        ),
1250
+        _msg.TranslatedString(
1251
+            _msg.Label.DERIVEPASSPHRASE_EXPORT_VAULT_03,
1252
+            path_metavar=_msg.TranslatedString(
1253
+                _msg.Label.EXPORT_VAULT_METAVAR_PATH,
1254
+            ),
1255
+        ),
1256
+    ),
793 1257
 )
794 1258
 @standard_logging_options
795 1259
 @click.option(
796 1260
     '-f',
797 1261
     '--format',
798 1262
     'formats',
799
-    metavar='FMT',
1263
+    metavar=_msg.TranslatedString(_msg.Label.EXPORT_VAULT_FORMAT_METAVAR_FMT),
800 1264
     multiple=True,
801 1265
     default=('v0.3', 'v0.2', 'storeroom'),
802 1266
     type=click.Choice(['v0.2', 'v0.3', 'storeroom']),
803
-    help='try the following storage formats, in order (default: v0.3, v0.2)',
1267
+    help=_msg.TranslatedString(
1268
+        _msg.Label.EXPORT_VAULT_FORMAT_HELP_TEXT,
1269
+        defaults_hint=_msg.TranslatedString(
1270
+            _msg.Label.EXPORT_VAULT_FORMAT_DEFAULTS_HELP_TEXT,
1271
+        ),
1272
+        metavar=_msg.TranslatedString(
1273
+            _msg.Label.EXPORT_VAULT_FORMAT_METAVAR_FMT,
1274
+        ),
1275
+    ),
1276
+    cls=StandardOption,
804 1277
 )
805 1278
 @click.option(
806 1279
     '-k',
807 1280
     '--key',
808
-    metavar='K',
809
-    help=(
810
-        'use K as the storage master key '
811
-        '(default: check the `VAULT_KEY`, `LOGNAME`, `USER` or '
812
-        '`USERNAME` environment variables)'
1281
+    metavar=_msg.TranslatedString(_msg.Label.EXPORT_VAULT_KEY_METAVAR_K),
1282
+    help=_msg.TranslatedString(
1283
+        _msg.Label.EXPORT_VAULT_KEY_HELP_TEXT,
1284
+        metavar=_msg.TranslatedString(_msg.Label.EXPORT_VAULT_KEY_METAVAR_K),
1285
+        defaults_hint=_msg.TranslatedString(
1286
+            _msg.Label.EXPORT_VAULT_KEY_DEFAULTS_HELP_TEXT,
813 1287
         ),
1288
+    ),
1289
+    cls=StandardOption,
1290
+)
1291
+@click.argument(
1292
+    'path',
1293
+    metavar=_msg.TranslatedString(_msg.Label.EXPORT_VAULT_METAVAR_PATH),
1294
+    required=True,
814 1295
 )
815
-@click.argument('path', metavar='PATH', required=True)
816 1296
 @click.pass_context
817 1297
 def derivepassphrase_export_vault(
818 1298
     ctx: click.Context,
... ...
@@ -824,16 +1304,13 @@ def derivepassphrase_export_vault(
824 1304
 ) -> None:
825 1305
     """Export a vault-native configuration to standard output.
826 1306
 
827
-    Read the vault-native configuration at PATH, extract all information
828
-    from it, and export the resulting configuration to standard output.
829
-    Depending on the configuration format, PATH may either be a file or
830
-    a directory.  Supports the vault "v0.2", "v0.3" and "storeroom"
831
-    formats.
1307
+    This is a [`click`][CLICK]-powered command-line interface function,
1308
+    and not intended for programmatic use.  See the
1309
+    derivepassphrase-export-vault(1) manpage for full documentation of
1310
+    the interface.  (See also [`click.testing.CliRunner`][] for
1311
+    controlled, programmatic invocation.)
832 1312
 
833
-    If PATH is explicitly given as `VAULT_PATH`, then use the
834
-    `VAULT_PATH` environment variable to determine the correct path.
835
-    (Use `./VAULT_PATH` or similar to indicate a file/directory actually
836
-    named `VAULT_PATH`.)
1313
+    [CLICK]: https://pypi.org/package/click/
837 1314
 
838 1315
     """
839 1316
     logger = logging.getLogger(PROG_NAME)
... ...
@@ -1093,7 +1570,7 @@ def _get_suitable_ssh_keys(
1093 1570
             if vault.Vault.is_suitable_ssh_key(key, client=client):
1094 1571
                 yield pair
1095 1572
     if not suitable_keys:  # pragma: no cover
1096
-        raise LookupError(_NO_USABLE_KEYS)
1573
+        raise LookupError(_NO_SUITABLE_KEYS)
1097 1574
 
1098 1575
 
1099 1576
 def _prompt_for_selection(
... ...
@@ -1317,9 +1794,11 @@ def _check_for_misleading_passphrase(
1317 1794
         if not unicodedata.is_normalized(form, phrase):
1318 1795
             logger.warning(
1319 1796
                 (
1320
-                    'the %s passphrase is not %s-normalized.  '
1321
-                    'Make sure to double-check this is really the '
1322
-                    'passphrase you want.'
1797
+                    'The %s passphrase is not %s-normalized.  Its '
1798
+                    'serialization as a byte string may not be what you '
1799
+                    'expect it to be, even if it *displays* correctly.  '
1800
+                    'Please make sure to double-check any derived '
1801
+                    'passphrases for unexpected results.'
1323 1802
                 ),
1324 1803
                 formatted_key,
1325 1804
                 form,
... ...
@@ -1350,19 +1829,35 @@ def _key_to_phrase(
1350 1829
                         k == key for k, _ in keylist
1351 1830
                     ):
1352 1831
                         error_callback(
1353
-                            'The requested SSH key is not loaded '
1354
-                            'into the agent.'
1832
+                            _msg.TranslatedString(
1833
+                                _msg.ErrMsgTemplate.SSH_KEY_NOT_LOADED
1834
+                            )
1835
+                        )
1836
+                error_callback(
1837
+                    _msg.TranslatedString(
1838
+                        _msg.ErrMsgTemplate.AGENT_REFUSED_SIGNATURE
1839
+                    ),
1840
+                    exc_info=exc,
1355 1841
                 )
1356
-                error_callback(exc)
1357 1842
     except KeyError:
1358
-        error_callback('Cannot find running SSH agent; check SSH_AUTH_SOCK')
1359
-    except NotImplementedError:
1360 1843
         error_callback(
1361
-            'Cannot connect to SSH agent because '
1362
-            'this Python version does not support UNIX domain sockets'
1844
+            _msg.TranslatedString(_msg.ErrMsgTemplate.NO_SSH_AGENT_FOUND)
1363 1845
         )
1846
+    except NotImplementedError:
1847
+        error_callback(_msg.TranslatedString(_msg.ErrMsgTemplate.NO_AF_UNIX))
1364 1848
     except OSError as exc:
1365
-        error_callback('Cannot connect to SSH agent: %s', exc.strerror)
1849
+        error_callback(
1850
+            _msg.TranslatedString(
1851
+                _msg.ErrMsgTemplate.CANNOT_CONNECT_TO_AGENT,
1852
+                error=exc.strerror,
1853
+                filename=exc.filename,
1854
+            ).maybe_without_filename()
1855
+        )
1856
+    except RuntimeError as exc:
1857
+        error_callback(
1858
+            _msg.TranslatedString(_msg.ErrMsgTemplate.CANNOT_UNDERSTAND_AGENT),
1859
+            exc_info=exc,
1860
+        )
1366 1861
 
1367 1862
 
1368 1863
 def _print_config_as_sh_script(
... ...
@@ -1441,36 +1936,44 @@ def _print_config_as_sh_script(
1441 1936
 class PassphraseGenerationOption(OptionGroupOption):
1442 1937
     """Passphrase generation options for the CLI."""
1443 1938
 
1444
-    option_group_name = 'Passphrase generation'
1445
-    epilog = """
1446
-        Use NUMBER=0, e.g. "--symbol 0", to exclude a character type
1447
-        from the output.
1448
-    """
1939
+    option_group_name = _msg.TranslatedString(
1940
+        _msg.Label.PASSPHRASE_GENERATION_LABEL
1941
+    )
1942
+    epilog = _msg.TranslatedString(
1943
+        _msg.Label.PASSPHRASE_GENERATION_EPILOG,
1944
+        metavar=_msg.TranslatedString(
1945
+            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
1946
+        ),
1947
+    )
1449 1948
 
1450 1949
 
1451 1950
 class ConfigurationOption(OptionGroupOption):
1452 1951
     """Configuration options for the CLI."""
1453 1952
 
1454
-    option_group_name = 'Configuration'
1455
-    epilog = """
1456
-        Use $VISUAL or $EDITOR to configure the spawned editor.
1457
-    """
1953
+    option_group_name = _msg.TranslatedString(_msg.Label.CONFIGURATION_LABEL)
1954
+    epilog = _msg.TranslatedString(_msg.Label.CONFIGURATION_EPILOG)
1458 1955
 
1459 1956
 
1460 1957
 class StorageManagementOption(OptionGroupOption):
1461 1958
     """Storage management options for the CLI."""
1462 1959
 
1463
-    option_group_name = 'Storage management'
1464
-    epilog = """
1465
-        Using "-" as PATH for standard input/standard output is
1466
-        supported.
1467
-    """
1960
+    option_group_name = _msg.TranslatedString(
1961
+        _msg.Label.STORAGE_MANAGEMENT_LABEL
1962
+    )
1963
+    epilog = _msg.TranslatedString(
1964
+        _msg.Label.STORAGE_MANAGEMENT_EPILOG,
1965
+        metavar=_msg.TranslatedString(
1966
+            _msg.Label.STORAGE_MANAGEMENT_METAVAR_PATH
1967
+        ),
1968
+    )
1468 1969
 
1469 1970
 
1470 1971
 class CompatibilityOption(OptionGroupOption):
1471 1972
     """Compatibility and incompatibility options for the CLI."""
1472 1973
 
1473
-    option_group_name = 'Compatibility and extension options'
1974
+    option_group_name = _msg.TranslatedString(
1975
+        _msg.Label.COMPATIBILITY_OPTION_LABEL
1976
+    )
1474 1977
 
1475 1978
 
1476 1979
 def _validate_occurrence_constraint(
... ...
@@ -1502,11 +2005,9 @@ def _validate_occurrence_constraint(
1502 2005
         try:
1503 2006
             int_value = int(value, 10)
1504 2007
         except ValueError as exc:
1505
-            msg = 'not an integer'
1506
-            raise click.BadParameter(msg) from exc
2008
+            raise click.BadParameter(_NOT_AN_INTEGER) from exc
1507 2009
     if int_value < 0:
1508
-        msg = 'not a non-negative integer'
1509
-        raise click.BadParameter(msg)
2010
+        raise click.BadParameter(_NOT_A_NONNEGATIVE_INTEGER)
1510 2011
     return int_value
1511 2012
 
1512 2013
 
... ...
@@ -1539,11 +2040,9 @@ def _validate_length(
1539 2040
         try:
1540 2041
             int_value = int(value, 10)
1541 2042
         except ValueError as exc:
1542
-            msg = 'not an integer'
1543
-            raise click.BadParameter(msg) from exc
2043
+            raise click.BadParameter(_NOT_AN_INTEGER) from exc
1544 2044
     if int_value < 1:
1545
-        msg = 'not a positive integer'
1546
-        raise click.BadParameter(msg)
2045
+        raise click.BadParameter(_NOT_A_POSITIVE_INTEGER)
1547 2046
     return int_value
1548 2047
 
1549 2048
 
... ...
@@ -1565,23 +2064,28 @@ DEFAULT_NOTES_MARKER = '# - - - - - >8 - - - - -'
1565 2064
     'vault',
1566 2065
     context_settings={'help_option_names': ['-h', '--help']},
1567 2066
     cls=CommandWithHelpGroups,
1568
-    epilog=r"""
1569
-        WARNING: There is NO WAY to retrieve the generated passphrases
1570
-        if the master passphrase, the SSH key, or the exact passphrase
1571
-        settings are lost, short of trying out all possible
1572
-        combinations.  You are STRONGLY advised to keep independent
1573
-        backups of the settings and the SSH key, if any.
1574
-
1575
-        The configuration is NOT encrypted, and you are STRONGLY
1576
-        discouraged from using a stored passphrase.
1577
-    """,
2067
+    help=(
2068
+        _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_VAULT_01),
2069
+        _msg.TranslatedString(
2070
+            _msg.Label.DERIVEPASSPHRASE_VAULT_02,
2071
+            service_metavar=_msg.TranslatedString(
2072
+                _msg.Label.VAULT_METAVAR_SERVICE
2073
+            ),
2074
+        ),
2075
+    ),
2076
+    epilog=(
2077
+        _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_VAULT_EPILOG_01),
2078
+        _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_VAULT_EPILOG_02),
2079
+    ),
1578 2080
 )
1579 2081
 @click.option(
1580 2082
     '-p',
1581 2083
     '--phrase',
1582 2084
     'use_phrase',
1583 2085
     is_flag=True,
1584
-    help='prompts you for your passphrase',
2086
+    help=_msg.TranslatedString(
2087
+        _msg.Label.DERIVEPASSPHRASE_VAULT_PHRASE_HELP_TEXT
2088
+    ),
1585 2089
     cls=PassphraseGenerationOption,
1586 2090
 )
1587 2091
 @click.option(
... ...
@@ -1589,65 +2093,123 @@ DEFAULT_NOTES_MARKER = '# - - - - - >8 - - - - -'
1589 2093
     '--key',
1590 2094
     'use_key',
1591 2095
     is_flag=True,
1592
-    help='uses your SSH private key to generate passwords',
2096
+    help=_msg.TranslatedString(
2097
+        _msg.Label.DERIVEPASSPHRASE_VAULT_KEY_HELP_TEXT
2098
+    ),
1593 2099
     cls=PassphraseGenerationOption,
1594 2100
 )
1595 2101
 @click.option(
1596 2102
     '-l',
1597 2103
     '--length',
1598
-    metavar='NUMBER',
2104
+    metavar=_msg.TranslatedString(
2105
+        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
2106
+    ),
1599 2107
     callback=_validate_length,
1600
-    help='emits password of length NUMBER',
2108
+    help=_msg.TranslatedString(
2109
+        _msg.Label.DERIVEPASSPHRASE_VAULT_LENGTH_HELP_TEXT,
2110
+        metavar=_msg.TranslatedString(
2111
+            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
2112
+        ),
2113
+    ),
1601 2114
     cls=PassphraseGenerationOption,
1602 2115
 )
1603 2116
 @click.option(
1604 2117
     '-r',
1605 2118
     '--repeat',
1606
-    metavar='NUMBER',
2119
+    metavar=_msg.TranslatedString(
2120
+        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
2121
+    ),
1607 2122
     callback=_validate_occurrence_constraint,
1608
-    help='allows maximum of NUMBER repeated adjacent chars',
2123
+    help=_msg.TranslatedString(
2124
+        _msg.Label.DERIVEPASSPHRASE_VAULT_REPEAT_HELP_TEXT,
2125
+        metavar=_msg.TranslatedString(
2126
+            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
2127
+        ),
2128
+    ),
1609 2129
     cls=PassphraseGenerationOption,
1610 2130
 )
1611 2131
 @click.option(
1612 2132
     '--lower',
1613
-    metavar='NUMBER',
2133
+    metavar=_msg.TranslatedString(
2134
+        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
2135
+    ),
1614 2136
     callback=_validate_occurrence_constraint,
1615
-    help='includes at least NUMBER lowercase letters',
2137
+    help=_msg.TranslatedString(
2138
+        _msg.Label.DERIVEPASSPHRASE_VAULT_LOWER_HELP_TEXT,
2139
+        metavar=_msg.TranslatedString(
2140
+            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
2141
+        ),
2142
+    ),
1616 2143
     cls=PassphraseGenerationOption,
1617 2144
 )
1618 2145
 @click.option(
1619 2146
     '--upper',
1620
-    metavar='NUMBER',
2147
+    metavar=_msg.TranslatedString(
2148
+        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
2149
+    ),
1621 2150
     callback=_validate_occurrence_constraint,
1622
-    help='includes at least NUMBER uppercase letters',
2151
+    help=_msg.TranslatedString(
2152
+        _msg.Label.DERIVEPASSPHRASE_VAULT_UPPER_HELP_TEXT,
2153
+        metavar=_msg.TranslatedString(
2154
+            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
2155
+        ),
2156
+    ),
1623 2157
     cls=PassphraseGenerationOption,
1624 2158
 )
1625 2159
 @click.option(
1626 2160
     '--number',
1627
-    metavar='NUMBER',
2161
+    metavar=_msg.TranslatedString(
2162
+        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
2163
+    ),
1628 2164
     callback=_validate_occurrence_constraint,
1629
-    help='includes at least NUMBER digits',
2165
+    help=_msg.TranslatedString(
2166
+        _msg.Label.DERIVEPASSPHRASE_VAULT_NUMBER_HELP_TEXT,
2167
+        metavar=_msg.TranslatedString(
2168
+            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
2169
+        ),
2170
+    ),
1630 2171
     cls=PassphraseGenerationOption,
1631 2172
 )
1632 2173
 @click.option(
1633 2174
     '--space',
1634
-    metavar='NUMBER',
2175
+    metavar=_msg.TranslatedString(
2176
+        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
2177
+    ),
1635 2178
     callback=_validate_occurrence_constraint,
1636
-    help='includes at least NUMBER spaces',
2179
+    help=_msg.TranslatedString(
2180
+        _msg.Label.DERIVEPASSPHRASE_VAULT_SPACE_HELP_TEXT,
2181
+        metavar=_msg.TranslatedString(
2182
+            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
2183
+        ),
2184
+    ),
1637 2185
     cls=PassphraseGenerationOption,
1638 2186
 )
1639 2187
 @click.option(
1640 2188
     '--dash',
1641
-    metavar='NUMBER',
2189
+    metavar=_msg.TranslatedString(
2190
+        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
2191
+    ),
1642 2192
     callback=_validate_occurrence_constraint,
1643
-    help='includes at least NUMBER "-" or "_"',
2193
+    help=_msg.TranslatedString(
2194
+        _msg.Label.DERIVEPASSPHRASE_VAULT_DASH_HELP_TEXT,
2195
+        metavar=_msg.TranslatedString(
2196
+            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
2197
+        ),
2198
+    ),
1644 2199
     cls=PassphraseGenerationOption,
1645 2200
 )
1646 2201
 @click.option(
1647 2202
     '--symbol',
1648
-    metavar='NUMBER',
2203
+    metavar=_msg.TranslatedString(
2204
+        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
2205
+    ),
1649 2206
     callback=_validate_occurrence_constraint,
1650
-    help='includes at least NUMBER symbol chars',
2207
+    help=_msg.TranslatedString(
2208
+        _msg.Label.DERIVEPASSPHRASE_VAULT_SYMBOL_HELP_TEXT,
2209
+        metavar=_msg.TranslatedString(
2210
+            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
2211
+        ),
2212
+    ),
1651 2213
     cls=PassphraseGenerationOption,
1652 2214
 )
1653 2215
 @click.option(
... ...
@@ -1655,7 +2217,12 @@ DEFAULT_NOTES_MARKER = '# - - - - - >8 - - - - -'
1655 2217
     '--notes',
1656 2218
     'edit_notes',
1657 2219
     is_flag=True,
1658
-    help='spawn an editor to edit notes for SERVICE',
2220
+    help=_msg.TranslatedString(
2221
+        _msg.Label.DERIVEPASSPHRASE_VAULT_NOTES_HELP_TEXT,
2222
+        service_metavar=_msg.TranslatedString(
2223
+            _msg.Label.VAULT_METAVAR_SERVICE
2224
+        ),
2225
+    ),
1659 2226
     cls=ConfigurationOption,
1660 2227
 )
1661 2228
 @click.option(
... ...
@@ -1663,7 +2230,12 @@ DEFAULT_NOTES_MARKER = '# - - - - - >8 - - - - -'
1663 2230
     '--config',
1664 2231
     'store_config_only',
1665 2232
     is_flag=True,
1666
-    help='saves the given settings for SERVICE or global',
2233
+    help=_msg.TranslatedString(
2234
+        _msg.Label.DERIVEPASSPHRASE_VAULT_CONFIG_HELP_TEXT,
2235
+        service_metavar=_msg.TranslatedString(
2236
+            _msg.Label.VAULT_METAVAR_SERVICE
2237
+        ),
2238
+    ),
1667 2239
     cls=ConfigurationOption,
1668 2240
 )
1669 2241
 @click.option(
... ...
@@ -1671,13 +2243,20 @@ DEFAULT_NOTES_MARKER = '# - - - - - >8 - - - - -'
1671 2243
     '--delete',
1672 2244
     'delete_service_settings',
1673 2245
     is_flag=True,
1674
-    help='deletes settings for SERVICE',
2246
+    help=_msg.TranslatedString(
2247
+        _msg.Label.DERIVEPASSPHRASE_VAULT_DELETE_HELP_TEXT,
2248
+        service_metavar=_msg.TranslatedString(
2249
+            _msg.Label.VAULT_METAVAR_SERVICE
2250
+        ),
2251
+    ),
1675 2252
     cls=ConfigurationOption,
1676 2253
 )
1677 2254
 @click.option(
1678 2255
     '--delete-globals',
1679 2256
     is_flag=True,
1680
-    help='deletes the global shared settings',
2257
+    help=_msg.TranslatedString(
2258
+        _msg.Label.DERIVEPASSPHRASE_VAULT_DELETE_GLOBALS_HELP_TEXT,
2259
+    ),
1681 2260
     cls=ConfigurationOption,
1682 2261
 )
1683 2262
 @click.option(
... ...
@@ -1685,30 +2264,48 @@ DEFAULT_NOTES_MARKER = '# - - - - - >8 - - - - -'
1685 2264
     '--clear',
1686 2265
     'clear_all_settings',
1687 2266
     is_flag=True,
1688
-    help='deletes all settings',
2267
+    help=_msg.TranslatedString(
2268
+        _msg.Label.DERIVEPASSPHRASE_VAULT_DELETE_ALL_HELP_TEXT,
2269
+    ),
1689 2270
     cls=ConfigurationOption,
1690 2271
 )
1691 2272
 @click.option(
1692 2273
     '-e',
1693 2274
     '--export',
1694 2275
     'export_settings',
1695
-    metavar='PATH',
1696
-    help='export all saved settings into file PATH',
2276
+    metavar=_msg.TranslatedString(
2277
+        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
2278
+    ),
2279
+    help=_msg.TranslatedString(
2280
+        _msg.Label.DERIVEPASSPHRASE_VAULT_EXPORT_HELP_TEXT,
2281
+        metavar=_msg.TranslatedString(
2282
+            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
2283
+        ),
2284
+    ),
1697 2285
     cls=StorageManagementOption,
1698 2286
 )
1699 2287
 @click.option(
1700 2288
     '-i',
1701 2289
     '--import',
1702 2290
     'import_settings',
1703
-    metavar='PATH',
1704
-    help='import saved settings from file PATH',
2291
+    metavar=_msg.TranslatedString(
2292
+        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
2293
+    ),
2294
+    help=_msg.TranslatedString(
2295
+        _msg.Label.DERIVEPASSPHRASE_VAULT_IMPORT_HELP_TEXT,
2296
+        metavar=_msg.TranslatedString(
2297
+            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
2298
+        ),
2299
+    ),
1705 2300
     cls=StorageManagementOption,
1706 2301
 )
1707 2302
 @click.option(
1708 2303
     '--overwrite-existing/--merge-existing',
1709 2304
     'overwrite_config',
1710 2305
     default=False,
1711
-    help='overwrite or merge (default) the existing configuration',
2306
+    help=_msg.TranslatedString(
2307
+        _msg.Label.DERIVEPASSPHRASE_VAULT_OVERWRITE_HELP_TEXT
2308
+    ),
1712 2309
     cls=CompatibilityOption,
1713 2310
 )
1714 2311
 @click.option(
... ...
@@ -1727,9 +2324,8 @@ DEFAULT_NOTES_MARKER = '# - - - - - >8 - - - - -'
1727 2324
         'dash',
1728 2325
         'symbol',
1729 2326
     ]),
1730
-    help=(
1731
-        'with --config, also unsets the given setting; '
1732
-        'may be specified multiple times'
2327
+    help=_msg.TranslatedString(
2328
+        _msg.Label.DERIVEPASSPHRASE_VAULT_UNSET_HELP_TEXT
1733 2329
     ),
1734 2330
     cls=CompatibilityOption,
1735 2331
 )
... ...
@@ -1737,12 +2333,19 @@ DEFAULT_NOTES_MARKER = '# - - - - - >8 - - - - -'
1737 2333
     '--export-as',
1738 2334
     type=click.Choice(['json', 'sh']),
1739 2335
     default='json',
1740
-    help='when exporting, export as JSON (default) or POSIX sh',
2336
+    help=_msg.TranslatedString(
2337
+        _msg.Label.DERIVEPASSPHRASE_VAULT_EXPORT_AS_HELP_TEXT
2338
+    ),
1741 2339
     cls=CompatibilityOption,
1742 2340
 )
1743 2341
 @click.version_option(version=dpp.__version__, prog_name=PROG_NAME)
1744 2342
 @standard_logging_options
1745
-@click.argument('service', required=False)
2343
+@click.argument(
2344
+    'service',
2345
+    metavar=_msg.TranslatedString(_msg.Label.VAULT_METAVAR_SERVICE),
2346
+    required=False,
2347
+    default=None,
2348
+)
1746 2349
 @click.pass_context
1747 2350
 def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1748 2351
     ctx: click.Context,
... ...
@@ -1772,24 +2375,11 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1772 2375
 ) -> None:
1773 2376
     """Derive a passphrase using the vault(1) derivation scheme.
1774 2377
 
1775
-    Using a master passphrase or a master SSH key, derive a passphrase
1776
-    for SERVICE, subject to length, character and character repetition
1777
-    constraints.  The derivation is cryptographically strong, meaning
1778
-    that even if a single passphrase is compromised, guessing the master
1779
-    passphrase or a different service's passphrase is computationally
1780
-    infeasible.  The derivation is also deterministic, given the same
1781
-    inputs, thus the resulting passphrase need not be stored explicitly.
1782
-    The service name and constraints themselves also need not be kept
1783
-    secret; the latter are usually stored in a world-readable file.
1784
-
1785
-    If operating on global settings, or importing/exporting settings,
1786
-    then SERVICE must be omitted.  Otherwise it is required.\f
1787
-
1788 2378
     This is a [`click`][CLICK]-powered command-line interface function,
1789
-    and not intended for programmatic use.  Call with arguments
1790
-    `['--help']` to see full documentation of the interface.  (See also
1791
-    [`click.testing.CliRunner`][] for controlled, programmatic
1792
-    invocation.)
2379
+    and not intended for programmatic use.  See the
2380
+    derivepassphrase-vault(1) manpage for full documentation of the
2381
+    interface.  (See also [`click.testing.CliRunner`][] for controlled,
2382
+    programmatic invocation.)
1793 2383
 
1794 2384
     [CLICK]: https://pypi.org/package/click/
1795 2385
 
... ...
@@ -1874,9 +2464,10 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1874 2464
             `--export`, selects the format to export the current
1875 2465
             configuration as: JSON ("json", default) or POSIX sh ("sh").
1876 2466
 
1877
-    """  # noqa: D301
2467
+    """
1878 2468
     logger = logging.getLogger(PROG_NAME)
1879 2469
     deprecation = logging.getLogger(PROG_NAME + '.deprecation')
2470
+    service_metavar = _msg.TranslatedString(_msg.Label.VAULT_METAVAR_SERVICE)
1880 2471
     options_in_group: dict[type[click.Option], list[click.Option]] = {}
1881 2472
     params_by_str: dict[str, click.Parameter] = {}
1882 2473
     for param in ctx.command.params:
... ...
@@ -1934,7 +2525,13 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1934 2525
             param2_str = option_name(param2)
1935 2526
             raise click.BadOptionUsage(
1936 2527
                 param1_str,
1937
-                f'{param1_str} is mutually exclusive with {param2_str}',
2528
+                str(
2529
+                    _msg.TranslatedString(
2530
+                        _msg.ErrMsgTemplate.PARAMS_MUTUALLY_EXCLUSIVE,
2531
+                        param1=param1_str,
2532
+                        param2=param2_str,
2533
+                    )
2534
+                ),
1938 2535
                 ctx=ctx,
1939 2536
             )
1940 2537
         return
... ...
@@ -1958,37 +2555,67 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1958 2555
             )
1959 2556
             new_name = os.path.basename(_config_filename(subsystem='vault'))
1960 2557
             deprecation.warning(
1961
-                (
1962
-                    'Using deprecated v0.1-style config file %r, '
1963
-                    'instead of v0.2-style %r.  '
1964
-                    'Support for v0.1-style config filenames will be '
1965
-                    'removed in v1.0.'
2558
+                _msg.TranslatedString(
2559
+                    _msg.WarnMsgTemplate.V01_STYLE_CONFIG,
2560
+                    old=old_name,
2561
+                    new=new_name,
1966 2562
                 ),
1967
-                old_name,
1968
-                new_name,
1969 2563
             )
1970 2564
             if isinstance(exc, OSError):
1971 2565
                 logger.warning(
1972
-                    'Failed to migrate to %r: %s: %r',
1973
-                    new_name,
1974
-                    exc.strerror,
1975
-                    exc.filename,
2566
+                    _msg.TranslatedString(
2567
+                        _msg.WarnMsgTemplate.FAILED_TO_MIGRATE_CONFIG,
2568
+                        path=new_name,
2569
+                        error=exc.strerror,
2570
+                        filename=exc.filename,
2571
+                    ).maybe_without_filename(),
1976 2572
                 )
1977 2573
             else:
1978
-                deprecation.info('Successfully migrated to %r.', new_name)
2574
+                deprecation.info(
2575
+                    _msg.TranslatedString(
2576
+                        _msg.InfoMsgTemplate.SUCCESSFULLY_MIGRATED,
2577
+                        path=new_name,
2578
+                    ),
2579
+                )
1979 2580
             return backup_config
1980 2581
         except OSError as exc:
1981
-            err('Cannot load config: %s: %r', exc.strerror, exc.filename)
2582
+            err(
2583
+                _msg.TranslatedString(
2584
+                    _msg.ErrMsgTemplate.CANNOT_LOAD_VAULT_SETTINGS,
2585
+                    error=exc.strerror,
2586
+                    filename=exc.filename,
2587
+                ).maybe_without_filename(),
2588
+            )
1982 2589
         except Exception as exc:  # noqa: BLE001
1983
-            err('Cannot load config: %s', str(exc), exc_info=exc)
2590
+            err(
2591
+                _msg.TranslatedString(
2592
+                    _msg.ErrMsgTemplate.CANNOT_LOAD_VAULT_SETTINGS,
2593
+                    error=str(exc),
2594
+                    filename=None,
2595
+                ).maybe_without_filename(),
2596
+                exc_info=exc,
2597
+            )
1984 2598
 
1985 2599
     def put_config(config: _types.VaultConfig, /) -> None:
1986 2600
         try:
1987 2601
             _save_config(config)
1988 2602
         except OSError as exc:
1989
-            err('Cannot store config: %s: %r', exc.strerror, exc.filename)
2603
+            err(
2604
+                _msg.TranslatedString(
2605
+                    _msg.ErrMsgTemplate.CANNOT_STORE_VAULT_SETTINGS,
2606
+                    error=exc.strerror,
2607
+                    filename=exc.filename,
2608
+                ).maybe_without_filename(),
2609
+            )
1990 2610
         except Exception as exc:  # noqa: BLE001
1991
-            err('Cannot store config: %s', str(exc), exc_info=exc)
2611
+            err(
2612
+                _msg.TranslatedString(
2613
+                    _msg.ErrMsgTemplate.CANNOT_STORE_VAULT_SETTINGS,
2614
+                    error=str(exc),
2615
+                    filename=None,
2616
+                ).maybe_without_filename(),
2617
+                exc_info=exc,
2618
+            )
1992 2619
 
1993 2620
     def get_user_config() -> dict[str, Any]:
1994 2621
         try:
... ...
@@ -1996,9 +2623,22 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1996 2623
         except FileNotFoundError:
1997 2624
             return {}
1998 2625
         except OSError as exc:
1999
-            err('Cannot load user config: %s: %r', exc.strerror, exc.filename)
2626
+            err(
2627
+                _msg.TranslatedString(
2628
+                    _msg.ErrMsgTemplate.CANNOT_LOAD_USER_CONFIG,
2629
+                    error=exc.strerror,
2630
+                    filename=exc.filename,
2631
+                ).maybe_without_filename(),
2632
+            )
2000 2633
         except Exception as exc:  # noqa: BLE001
2001
-            err('Cannot load user config: %s', str(exc), exc_info=exc)
2634
+            err(
2635
+                _msg.TranslatedString(
2636
+                    _msg.ErrMsgTemplate.CANNOT_LOAD_USER_CONFIG,
2637
+                    error=str(exc),
2638
+                    filename=None,
2639
+                ).maybe_without_filename(),
2640
+                exc_info=exc,
2641
+            )
2002 2642
 
2003 2643
     configuration: _types.VaultConfig
2004 2644
 
... ...
@@ -2020,15 +2660,21 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
2020 2660
         if is_param_set(param) and not (
2021 2661
             service or is_param_set(params_by_str['--config'])
2022 2662
         ):
2023
-            opt_str = param.opts[0]
2024
-            msg = f'{opt_str} requires a SERVICE or --config'
2025
-            raise click.UsageError(msg)  # noqa: DOC501
2663
+            err_msg = _msg.TranslatedString(
2664
+                _msg.ErrMsgTemplate.PARAMS_NEEDS_SERVICE_OR_CONFIG,
2665
+                param=param.opts[0],
2666
+                service_metavar=service_metavar,
2667
+            )
2668
+            raise click.UsageError(str(err_msg))  # noqa: DOC501
2026 2669
     sv_options = [params_by_str['--notes'], params_by_str['--delete']]
2027 2670
     for param in sv_options:
2028 2671
         if is_param_set(param) and not service:
2029
-            opt_str = param.opts[0]
2030
-            msg = f'{opt_str} requires a SERVICE'
2031
-            raise click.UsageError(msg)
2672
+            err_msg = _msg.TranslatedString(
2673
+                _msg.ErrMsgTemplate.PARAMS_NEEDS_SERVICE,
2674
+                param=param.opts[0],
2675
+                service_metavar=service_metavar,
2676
+            )
2677
+            raise click.UsageError(str(err_msg))
2032 2678
     no_sv_options = [
2033 2679
         params_by_str['--delete-globals'],
2034 2680
         params_by_str['--clear'],
... ...
@@ -2036,18 +2682,21 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
2036 2682
     ]
2037 2683
     for param in no_sv_options:
2038 2684
         if is_param_set(param) and service:
2039
-            opt_str = param.opts[0]
2040
-            msg = f'{opt_str} does not take a SERVICE argument'
2041
-            raise click.UsageError(msg)
2685
+            err_msg = _msg.TranslatedString(
2686
+                _msg.ErrMsgTemplate.PARAMS_NO_SERVICE,
2687
+                param=param.opts[0],
2688
+                service_metavar=service_metavar,
2689
+            )
2690
+            raise click.UsageError(str(err_msg))
2042 2691
 
2043 2692
     user_config = get_user_config()
2044 2693
 
2045 2694
     if service == '':  # noqa: PLC1901
2046 2695
         logger.warning(
2047
-            'An empty SERVICE is not supported by vault(1).  '
2048
-            'For compatibility, this will be treated as if SERVICE '
2049
-            'was not supplied, i.e., it will error out, or '
2050
-            'operate on global settings.'
2696
+            _msg.TranslatedString(
2697
+                _msg.WarnMsgTemplate.EMPTY_SERVICE_NOT_SUPPORTED,
2698
+                service_metavar=service_metavar,
2699
+            )
2051 2700
         )
2052 2701
 
2053 2702
     if edit_notes:
... ...
@@ -2066,7 +2715,11 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
2066 2715
                     break
2067 2716
             else:
2068 2717
                 if not notes_value.strip():
2069
-                    err('Not saving new notes: user aborted request')
2718
+                    err(
2719
+                        _msg.TranslatedString(
2720
+                            _msg.ErrMsgTemplate.USER_ABORTED_EDIT
2721
+                        )
2722
+                    )
2070 2723
             configuration['services'].setdefault(service, {})['notes'] = (
2071 2724
                 notes_value.strip('\n')
2072 2725
             )
... ...
@@ -2104,12 +2757,32 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
2104 2757
             with infile:
2105 2758
                 maybe_config = json.load(infile)
2106 2759
         except json.JSONDecodeError as exc:
2107
-            err('Cannot load config: cannot decode JSON: %s', exc)
2760
+            err(
2761
+                _msg.TranslatedString(
2762
+                    _msg.ErrMsgTemplate.CANNOT_DECODEIMPORT_VAULT_SETTINGS,
2763
+                    error=exc,
2764
+                )
2765
+            )
2108 2766
         except OSError as exc:
2109
-            err('Cannot load config: %s: %r', exc.strerror, exc.filename)
2767
+            err(
2768
+                _msg.TranslatedString(
2769
+                    _msg.ErrMsgTemplate.CANNOT_IMPORT_VAULT_SETTINGS,
2770
+                    error=exc.strerror,
2771
+                    filename=exc.filename,
2772
+                ).maybe_without_filename()
2773
+            )
2110 2774
         cleaned = _types.clean_up_falsy_vault_config_values(maybe_config)
2111 2775
         if not _types.is_vault_config(maybe_config):
2112
-            err('Cannot load config: %s', _INVALID_VAULT_CONFIG)
2776
+            err(
2777
+                _msg.TranslatedString(
2778
+                    _msg.ErrMsgTemplate.CANNOT_IMPORT_VAULT_SETTINGS,
2779
+                    error=_msg.TranslatedString(
2780
+                        _msg.ErrMsgTemplate.INVALID_VAULT_CONFIG,
2781
+                        config=maybe_config,
2782
+                    ),
2783
+                    filename=None,
2784
+                ).maybe_without_filename()
2785
+            )
2113 2786
         assert cleaned is not None
2114 2787
         for step in cleaned:
2115 2788
             # These are never fatal errors, because the semantics of
... ...
@@ -2117,27 +2790,28 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
2117 2790
             # but not ill-defined.
2118 2791
             if step.action == 'replace':
2119 2792
                 logger.warning(
2120
-                    'Replacing invalid value %s for key %s with %s.',
2121
-                    json.dumps(step.old_value),
2122
-                    _types.json_path(step.path),
2123
-                    json.dumps(step.new_value),
2793
+                    _msg.TranslatedString(
2794
+                        _msg.WarnMsgTemplate.STEP_REPLACE_INVALID_VALUE,
2795
+                        old=json.dumps(step.old_value),
2796
+                        path=_types.json_path(step.path),
2797
+                        new=json.dumps(step.new_value),
2798
+                    ),
2124 2799
                 )
2125 2800
             else:
2126 2801
                 logger.warning(
2127
-                    'Removing ineffective setting %s = %s.',
2128
-                    _types.json_path(step.path),
2129
-                    json.dumps(step.old_value),
2802
+                    _msg.TranslatedString(
2803
+                        _msg.WarnMsgTemplate.STEP_REMOVE_INEFFECTIVE_VALUE,
2804
+                        path=_types.json_path(step.path),
2805
+                        old=json.dumps(step.old_value),
2806
+                    ),
2130 2807
                 )
2131 2808
         if '' in maybe_config['services']:
2132 2809
             logger.warning(
2133
-                (
2134
-                    'An empty SERVICE is not supported by vault(1), '
2135
-                    'and the empty-string service settings will be '
2136
-                    'inaccessible and ineffective.  To ensure that '
2137
-                    'vault(1) and %s see the settings, move them '
2138
-                    'into the "global" section.'
2810
+                _msg.TranslatedString(
2811
+                    _msg.WarnMsgTemplate.EMPTY_SERVICE_SETTINGS_INACCESSIBLE,
2812
+                    service_metavar=service_metavar,
2813
+                    PROG_NAME=PROG_NAME,
2139 2814
                 ),
2140
-                PROG_NAME,
2141 2815
             )
2142 2816
         try:
2143 2817
             _check_for_misleading_passphrase(
... ...
@@ -2152,14 +2826,21 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
2152 2826
                     main_config=user_config,
2153 2827
                 )
2154 2828
         except AssertionError as exc:
2155
-            err('The configuration file is invalid.  ' + str(exc))
2829
+            err(
2830
+                _msg.TranslatedString(
2831
+                    _msg.ErrMsgTemplate.INVALID_USER_CONFIG,
2832
+                    error=exc,
2833
+                    filename=None,
2834
+                ).maybe_without_filename(),
2835
+            )
2156 2836
         global_obj = maybe_config.get('global', {})
2157 2837
         has_key = _types.js_truthiness(global_obj.get('key'))
2158 2838
         has_phrase = _types.js_truthiness(global_obj.get('phrase'))
2159 2839
         if has_key and has_phrase:
2160 2840
             logger.warning(
2161
-                'Setting a global passphrase is ineffective '
2162
-                'because a key is also set.'
2841
+                _msg.TranslatedString(
2842
+                    _msg.WarnMsgTemplate.GLOBAL_PASSPHRASE_INEFFECTIVE,
2843
+                )
2163 2844
             )
2164 2845
         for service_name, service_obj in maybe_config['services'].items():
2165 2846
             has_key = _types.js_truthiness(
... ...
@@ -2170,11 +2851,10 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
2170 2851
             ) or _types.js_truthiness(global_obj.get('phrase'))
2171 2852
             if has_key and has_phrase:
2172 2853
                 logger.warning(
2173
-                    (
2174
-                        'Setting a service passphrase is ineffective '
2175
-                        'because a key is also set: %s'
2854
+                    _msg.TranslatedString(
2855
+                        _msg.WarnMsgTemplate.SERVICE_PASSPHRASE_INEFFECTIVE,
2856
+                        service=json.dumps(service_name),
2176 2857
                     ),
2177
-                    json.dumps(service_name),
2178 2858
                 )
2179 2859
         if overwrite_config:
2180 2860
             put_config(maybe_config)
... ...
@@ -2240,7 +2920,13 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
2240 2920
                 else:
2241 2921
                     json.dump(configuration, outfile)
2242 2922
         except OSError as exc:
2243
-            err('Cannot store config: %s: %r', exc.strerror, exc.filename)
2923
+            err(
2924
+                _msg.TranslatedString(
2925
+                    _msg.ErrMsgTemplate.CANNOT_EXPORT_VAULT_SETTINGS,
2926
+                    error=exc.strerror,
2927
+                    filename=exc.filename,
2928
+                ).maybe_without_filename(),
2929
+            )
2244 2930
     else:
2245 2931
         configuration = get_config()
2246 2932
         # This block could be type checked more stringently, but this
... ...
@@ -2278,26 +2964,56 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
2278 2964
                     'ASCII'
2279 2965
                 )
2280 2966
             except IndexError:
2281
-                err('No valid SSH key selected')
2967
+                err(
2968
+                    _msg.TranslatedString(
2969
+                        _msg.ErrMsgTemplate.USER_ABORTED_SSH_KEY_SELECTION
2970
+                    ),
2971
+                )
2282 2972
             except KeyError:
2283
-                err('Cannot find running SSH agent; check SSH_AUTH_SOCK')
2284
-            except NotImplementedError:
2285 2973
                 err(
2286
-                    'Cannot connect to SSH agent because '
2287
-                    'this Python version does not support UNIX domain sockets'
2974
+                    _msg.TranslatedString(
2975
+                        _msg.ErrMsgTemplate.NO_SSH_AGENT_FOUND
2976
+                    ),
2977
+                )
2978
+            except LookupError:
2979
+                err(
2980
+                    _msg.TranslatedString(
2981
+                        _msg.ErrMsgTemplate.NO_SUITABLE_SSH_KEYS,
2982
+                        PROG_NAME=PROG_NAME,
2288 2983
                     )
2984
+                )
2985
+            except NotImplementedError:
2986
+                err(_msg.TranslatedString(_msg.ErrMsgTemplate.NO_AF_UNIX))
2289 2987
             except OSError as exc:
2290
-                err('Cannot connect to SSH agent: %s', exc.strerror)
2291
-            except (
2292
-                LookupError,
2293
-                RuntimeError,
2294
-                ssh_agent.SSHAgentFailedError,
2295
-            ) as exc:
2296
-                err(str(exc))
2988
+                err(
2989
+                    _msg.TranslatedString(
2990
+                        _msg.ErrMsgTemplate.CANNOT_CONNECT_TO_AGENT,
2991
+                        error=exc.strerror,
2992
+                        filename=exc.filename,
2993
+                    ).maybe_without_filename(),
2994
+                )
2995
+            except ssh_agent.SSHAgentFailedError as exc:
2996
+                err(
2997
+                    _msg.TranslatedString(
2998
+                        _msg.ErrMsgTemplate.AGENT_REFUSED_LIST_KEYS
2999
+                    ),
3000
+                    exc_info=exc,
3001
+                )
3002
+            except RuntimeError as exc:
3003
+                err(
3004
+                    _msg.TranslatedString(
3005
+                        _msg.ErrMsgTemplate.CANNOT_UNDERSTAND_AGENT
3006
+                    ),
3007
+                    exc_info=exc,
3008
+                )
2297 3009
         elif use_phrase:
2298 3010
             maybe_phrase = _prompt_for_passphrase()
2299 3011
             if not maybe_phrase:
2300
-                err('No passphrase given')
3012
+                err(
3013
+                    _msg.TranslatedString(
3014
+                        _msg.ErrMsgTemplate.USER_ABORTED_PASSPHRASE
3015
+                    )
3016
+                )
2301 3017
             else:
2302 3018
                 phrase = maybe_phrase
2303 3019
         if store_config_only:
... ...
@@ -2319,35 +3035,41 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
2319 3035
                         main_config=user_config,
2320 3036
                     )
2321 3037
                 except AssertionError as exc:
2322
-                    err('The configuration file is invalid.  ' + str(exc))
3038
+                    err(
3039
+                        _msg.TranslatedString(
3040
+                            _msg.ErrMsgTemplate.INVALID_USER_CONFIG,
3041
+                            error=exc,
3042
+                            filename=None,
3043
+                        ).maybe_without_filename(),
3044
+                    )
2323 3045
                 if 'key' in settings:
2324 3046
                     if service:
2325 3047
                         logger.warning(
2326
-                            (
2327
-                                'Setting a service passphrase is ineffective '
2328
-                                'because a key is also set: %s'
2329
-                            ),
2330
-                            json.dumps(service),
3048
+                            _msg.TranslatedString(
3049
+                                _msg.WarnMsgTemplate.SERVICE_PASSPHRASE_INEFFECTIVE,
3050
+                                service=json.dumps(service),
3051
+                            )
2331 3052
                         )
2332 3053
                     else:
2333 3054
                         logger.warning(
2334
-                            'Setting a global passphrase is ineffective '
2335
-                            'because a key is also set.'
3055
+                            _msg.TranslatedString(
3056
+                                _msg.WarnMsgTemplate.GLOBAL_PASSPHRASE_INEFFECTIVE
3057
+                            )
2336 3058
                         )
2337 3059
             if not view.maps[0] and not unset_settings:
2338 3060
                 settings_type = 'service' if service else 'global'
2339
-                msg = (
2340
-                    f'Cannot update {settings_type} settings without '
2341
-                    f'actual settings'
3061
+                err_msg = _msg.TranslatedString(
3062
+                    _msg.ErrMsgTemplate.CANNOT_UPDATE_SETTINGS_NO_SETTINGS,
3063
+                    settings_type=settings_type,
2342 3064
                 )
2343
-                raise click.UsageError(msg)
3065
+                raise click.UsageError(str(err_msg))
2344 3066
             for setting in unset_settings:
2345 3067
                 if setting in view.maps[0]:
2346
-                    msg = (
2347
-                        f'Attempted to unset and set --{setting} '
2348
-                        f'at the same time.'
3068
+                    err_msg = _msg.TranslatedString(
3069
+                        _msg.ErrMsgTemplate.SET_AND_UNSET_SAME_SETTING,
3070
+                        setting=setting,
2349 3071
                     )
2350
-                    raise click.UsageError(msg)
3072
+                    raise click.UsageError(str(err_msg))
2351 3073
             subtree: dict[str, Any] = (
2352 3074
                 configuration['services'].setdefault(service, {})  # type: ignore[assignment]
2353 3075
                 if service
... ...
@@ -2365,8 +3087,13 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
2365 3087
             put_config(configuration)
2366 3088
         else:
2367 3089
             if not service:
2368
-                msg = 'SERVICE is required'
2369
-                raise click.UsageError(msg)
3090
+                err_msg = _msg.TranslatedString(
3091
+                    _msg.ErrMsgTemplate.SERVICE_REQUIRED,
3092
+                    service_metavar=_msg.TranslatedString(
3093
+                        _msg.Label.VAULT_METAVAR_SERVICE
3094
+                    ),
3095
+                )
3096
+                raise click.UsageError(str(err_msg))
2370 3097
             kwargs: dict[str, Any] = {
2371 3098
                 k: v
2372 3099
                 for k, v in settings.items()
... ...
@@ -2381,7 +3108,13 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
2381 3108
                         main_config=user_config,
2382 3109
                     )
2383 3110
                 except AssertionError as exc:
2384
-                    err('The configuration file is invalid.  ' + str(exc))
3111
+                    err(
3112
+                        _msg.TranslatedString(
3113
+                            _msg.ErrMsgTemplate.INVALID_USER_CONFIG,
3114
+                            error=exc,
3115
+                            filename=None,
3116
+                        ).maybe_without_filename(),
3117
+                    )
2385 3118
 
2386 3119
             # If either --key or --phrase are given, use that setting.
2387 3120
             # Otherwise, if both key and phrase are set in the config,
... ...
@@ -2402,11 +3135,10 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
2402 3135
             elif kwargs.get('phrase'):
2403 3136
                 pass
2404 3137
             else:
2405
-                msg = (
2406
-                    'No passphrase or key given on command-line '
2407
-                    'or in configuration'
3138
+                err_msg = _msg.TranslatedString(
3139
+                    _msg.ErrMsgTemplate.NO_KEY_OR_PHRASE
2408 3140
                 )
2409
-                raise click.UsageError(msg)
3141
+                raise click.UsageError(str(err_msg))
2410 3142
             kwargs.pop('key', '')
2411 3143
             result = vault.Vault(**kwargs).generate(service)
2412 3144
             click.echo(result.decode('ASCII'))
... ...
@@ -295,7 +295,7 @@ class TestCLI:
295 295
             empty_stderr=True, output='Passphrase generation:\n'
296 296
         ), 'expected clean exit, and option groups in help text'
297 297
         assert result.clean_exit(
298
-            empty_stderr=True, output='Use NUMBER=0, e.g. "--symbol 0"'
298
+            empty_stderr=True, output='Use $VISUAL or $EDITOR to configure'
299 299
         ), 'expected clean exit, and option group epilog in help text'
300 300
 
301 301
     @pytest.mark.parametrize(
... ...
@@ -971,7 +971,7 @@ class TestCLI:
971 971
             )
972 972
         result = tests.ReadableResult.parse(_result)
973 973
         assert result.error_exit(
974
-            error='Cannot load config'
974
+            error='Cannot load vault settings:'
975 975
         ), 'expected error exit and known error message'
976 976
 
977 977
     @pytest.mark.parametrize(
... ...
@@ -999,7 +999,7 @@ class TestCLI:
999 999
             )
1000 1000
         result = tests.ReadableResult.parse(_result)
1001 1001
         assert result.error_exit(
1002
-            error='Cannot load config'
1002
+            error='Cannot load vault settings:'
1003 1003
         ), 'expected error exit and known error message'
1004 1004
 
1005 1005
     @pytest.mark.parametrize(
... ...
@@ -1025,7 +1025,7 @@ class TestCLI:
1025 1025
             )
1026 1026
         result = tests.ReadableResult.parse(_result)
1027 1027
         assert result.error_exit(
1028
-            error='Cannot store config'
1028
+            error='Cannot export vault settings:'
1029 1029
         ), 'expected error exit and known error message'
1030 1030
 
1031 1031
     @pytest.mark.parametrize(
... ...
@@ -1054,9 +1054,9 @@ class TestCLI:
1054 1054
             )
1055 1055
         result = tests.ReadableResult.parse(_result)
1056 1056
         assert result.error_exit(
1057
-            error='Cannot load config'
1057
+            error='Cannot load vault settings:'
1058 1058
         ) or result.error_exit(
1059
-            error='Cannot load user config'
1059
+            error='Cannot load user config:'
1060 1060
         ), 'expected error exit and known error message'
1061 1061
 
1062 1062
     def test_220_edit_notes_successfully(
... ...
@@ -1156,7 +1156,7 @@ contents go here
1156 1156
             )
1157 1157
             result = tests.ReadableResult.parse(_result)
1158 1158
             assert result.error_exit(
1159
-                error='user aborted request'
1159
+                error='the user aborted the request'
1160 1160
             ), 'expected known error message'
1161 1161
             with open(
1162 1162
                 cli._config_filename(subsystem='vault'), encoding='UTF-8'
... ...
@@ -1241,14 +1241,18 @@ contents go here
1241 1241
     @pytest.mark.parametrize(
1242 1242
         ['command_line', 'input', 'err_text'],
1243 1243
         [
1244
-            ([], '', 'Cannot update global settings without actual settings'),
1244
+            (
1245
+                [],
1246
+                '',
1247
+                'Cannot update global settings without any given settings',
1248
+            ),
1245 1249
             (
1246 1250
                 ['--', 'sv'],
1247 1251
                 '',
1248
-                'Cannot update service settings without actual settings',
1252
+                'Cannot update service settings without any given settings',
1249 1253
             ),
1250
-            (['--phrase', '--', 'sv'], '', 'No passphrase given'),
1251
-            (['--key'], '', 'No valid SSH key selected'),
1254
+            (['--phrase', '--', 'sv'], '', 'No passphrase was given'),
1255
+            (['--key'], '', 'No SSH key was selected'),
1252 1256
         ],
1253 1257
     )
1254 1258
     def test_225_store_config_fail(
... ...
@@ -1324,7 +1328,7 @@ contents go here
1324 1328
             )
1325 1329
         result = tests.ReadableResult.parse(_result)
1326 1330
         assert result.error_exit(
1327
-            error='Cannot find running SSH agent'
1331
+            error='Cannot find any running SSH agent'
1328 1332
         ), 'expected error exit and known error message'
1329 1333
 
1330 1334
     def test_225c_store_config_fail_manual_bad_ssh_agent_connection(
... ...
@@ -1345,7 +1349,7 @@ contents go here
1345 1349
             )
1346 1350
         result = tests.ReadableResult.parse(_result)
1347 1351
         assert result.error_exit(
1348
-            error='Cannot connect to SSH agent'
1352
+            error='Cannot connect to the SSH agent'
1349 1353
         ), 'expected error exit and known error message'
1350 1354
 
1351 1355
     @pytest.mark.parametrize('try_race_free_implementation', [True, False])
... ...
@@ -1371,7 +1375,7 @@ contents go here
1371 1375
             )
1372 1376
         result = tests.ReadableResult.parse(_result)
1373 1377
         assert result.error_exit(
1374
-            error='Cannot store config'
1378
+            error='Cannot store vault settings:'
1375 1379
         ), 'expected error exit and known error message'
1376 1380
 
1377 1381
     def test_225e_store_config_fail_manual_custom_error(
... ...
@@ -1427,6 +1431,89 @@ contents go here
1427 1431
             error='Attempted to unset and set --length at the same time.'
1428 1432
         ), 'expected error exit and known error message'
1429 1433
 
1434
+    def test_225g_store_config_fail_manual_ssh_agent_no_keys_loaded(
1435
+        self,
1436
+        monkeypatch: pytest.MonkeyPatch,
1437
+        running_ssh_agent: tests.RunningSSHAgentInfo,
1438
+    ) -> None:
1439
+        del running_ssh_agent
1440
+        runner = click.testing.CliRunner(mix_stderr=False)
1441
+        with tests.isolated_vault_config(
1442
+            monkeypatch=monkeypatch,
1443
+            runner=runner,
1444
+            vault_config={'global': {'phrase': 'abc'}, 'services': {}},
1445
+        ):
1446
+            def func(
1447
+                *_args: Any,
1448
+                **_kwargs: Any,
1449
+            ) -> list[_types.KeyCommentPair]:
1450
+                return []
1451
+
1452
+            monkeypatch.setattr(ssh_agent.SSHAgentClient, 'list_keys', func)
1453
+            _result = runner.invoke(
1454
+                cli.derivepassphrase_vault,
1455
+                ['--key', '--config'],
1456
+                catch_exceptions=False,
1457
+            )
1458
+        result = tests.ReadableResult.parse(_result)
1459
+        assert result.error_exit(
1460
+            error='no keys suitable'
1461
+        ), 'expected error exit and known error message'
1462
+
1463
+    def test_225h_store_config_fail_manual_ssh_agent_runtime_error(
1464
+        self,
1465
+        monkeypatch: pytest.MonkeyPatch,
1466
+        running_ssh_agent: tests.RunningSSHAgentInfo,
1467
+    ) -> None:
1468
+        del running_ssh_agent
1469
+        runner = click.testing.CliRunner(mix_stderr=False)
1470
+        with tests.isolated_vault_config(
1471
+            monkeypatch=monkeypatch,
1472
+            runner=runner,
1473
+            vault_config={'global': {'phrase': 'abc'}, 'services': {}},
1474
+        ):
1475
+            def raiser(*_args: Any, **_kwargs: Any) -> None:
1476
+                raise ssh_agent.TrailingDataError()
1477
+
1478
+            monkeypatch.setattr(ssh_agent.SSHAgentClient, 'list_keys', raiser)
1479
+            _result = runner.invoke(
1480
+                cli.derivepassphrase_vault,
1481
+                ['--key', '--config'],
1482
+                catch_exceptions=False,
1483
+            )
1484
+        result = tests.ReadableResult.parse(_result)
1485
+        assert result.error_exit(
1486
+            error='violates the communications protocol.'
1487
+        ), 'expected error exit and known error message'
1488
+
1489
+    def test_225i_store_config_fail_manual_ssh_agent_refuses(
1490
+        self,
1491
+        monkeypatch: pytest.MonkeyPatch,
1492
+        running_ssh_agent: tests.RunningSSHAgentInfo,
1493
+    ) -> None:
1494
+        del running_ssh_agent
1495
+        runner = click.testing.CliRunner(mix_stderr=False)
1496
+        with tests.isolated_vault_config(
1497
+            monkeypatch=monkeypatch,
1498
+            runner=runner,
1499
+            vault_config={'global': {'phrase': 'abc'}, 'services': {}},
1500
+        ):
1501
+            def func(*_args: Any, **_kwargs: Any) -> NoReturn:
1502
+                raise ssh_agent.SSHAgentFailedError(
1503
+                    _types.SSH_AGENT.FAILURE, b''
1504
+                )
1505
+
1506
+            monkeypatch.setattr(ssh_agent.SSHAgentClient, 'list_keys', func)
1507
+            _result = runner.invoke(
1508
+                cli.derivepassphrase_vault,
1509
+                ['--key', '--config'],
1510
+                catch_exceptions=False,
1511
+            )
1512
+        result = tests.ReadableResult.parse(_result)
1513
+        assert result.error_exit(
1514
+            error='refused to'
1515
+        ), 'expected error exit and known error message'
1516
+
1430 1517
     def test_226_no_arguments(self, monkeypatch: pytest.MonkeyPatch) -> None:
1431 1518
         runner = click.testing.CliRunner(mix_stderr=False)
1432 1519
         with tests.isolated_config(
... ...
@@ -1438,7 +1525,7 @@ contents go here
1438 1525
             )
1439 1526
         result = tests.ReadableResult.parse(_result)
1440 1527
         assert result.error_exit(
1441
-            error='SERVICE is required'
1528
+            error='Deriving a passphrase requires a SERVICE'
1442 1529
         ), 'expected error exit and known error message'
1443 1530
 
1444 1531
     def test_226a_no_passphrase_or_key(
... ...
@@ -1456,7 +1543,7 @@ contents go here
1456 1543
             )
1457 1544
         result = tests.ReadableResult.parse(_result)
1458 1545
         assert result.error_exit(
1459
-            error='No passphrase or key given'
1546
+            error='No passphrase or key was given'
1460 1547
         ), 'expected error exit and known error message'
1461 1548
 
1462 1549
     def test_230_config_directory_nonexistant(
... ...
@@ -1529,7 +1616,7 @@ contents go here
1529 1616
             )
1530 1617
             result = tests.ReadableResult.parse(_result)
1531 1618
             assert result.error_exit(
1532
-                error='Cannot store config'
1619
+                error='Cannot store vault settings:'
1533 1620
             ), 'expected error exit and known error message'
1534 1621
 
1535 1622
     def test_230b_store_config_custom_error(
... ...
@@ -1568,7 +1655,7 @@ contents go here
1568 1655
                     'global': {'phrase': 'Du\u0308sseldorf'},
1569 1656
                     'services': {},
1570 1657
                 }),
1571
-                'the $.global passphrase is not NFC-normalized',
1658
+                'The $.global passphrase is not NFC-normalized',
1572 1659
                 id='global-NFC',
1573 1660
             ),
1574 1661
             pytest.param(
... ...
@@ -1581,7 +1668,7 @@ contents go here
1581 1668
                     }
1582 1669
                 }),
1583 1670
                 (
1584
-                    'the $.services["weird entry name"] passphrase '
1671
+                    'The $.services["weird entry name"] passphrase '
1585 1672
                     'is not NFC-normalized'
1586 1673
                 ),
1587 1674
                 id='service-weird-name-NFC',
... ...
@@ -1591,7 +1678,7 @@ contents go here
1591 1678
                 ['--config', '-p', '--', DUMMY_SERVICE],
1592 1679
                 'Du\u0308sseldorf',
1593 1680
                 (
1594
-                    f'the $.services.{DUMMY_SERVICE} passphrase '
1681
+                    f'The $.services.{DUMMY_SERVICE} passphrase '
1595 1682
                     f'is not NFC-normalized'
1596 1683
                 ),
1597 1684
                 id='config-NFC',
... ...
@@ -1600,7 +1687,7 @@ contents go here
1600 1687
                 '',
1601 1688
                 ['-p', '--', DUMMY_SERVICE],
1602 1689
                 'Du\u0308sseldorf',
1603
-                'the interactive input passphrase is not NFC-normalized',
1690
+                'The interactive input passphrase is not NFC-normalized',
1604 1691
                 id='direct-input-NFC',
1605 1692
             ),
1606 1693
             pytest.param(
... ...
@@ -1615,7 +1702,7 @@ contents go here
1615 1702
                     },
1616 1703
                     'services': {},
1617 1704
                 }),
1618
-                'the $.global passphrase is not NFD-normalized',
1705
+                'The $.global passphrase is not NFD-normalized',
1619 1706
                 id='global-NFD',
1620 1707
             ),
1621 1708
             pytest.param(
... ...
@@ -1631,7 +1718,7 @@ contents go here
1631 1718
                     },
1632 1719
                 }),
1633 1720
                 (
1634
-                    'the $.services["weird entry name"] passphrase '
1721
+                    'The $.services["weird entry name"] passphrase '
1635 1722
                     'is not NFD-normalized'
1636 1723
                 ),
1637 1724
                 id='service-weird-name-NFD',
... ...
@@ -1650,7 +1737,7 @@ contents go here
1650 1737
                     },
1651 1738
                 }),
1652 1739
                 (
1653
-                    'the $.services["weird entry name 2"] passphrase '
1740
+                    'The $.services["weird entry name 2"] passphrase '
1654 1741
                     'is not NFKD-normalized'
1655 1742
                 ),
1656 1743
                 id='service-weird-name-2-NFKD',
... ...
@@ -1753,7 +1840,7 @@ contents go here
1753 1840
             )
1754 1841
         result = tests.ReadableResult.parse(_result)
1755 1842
         assert result.error_exit(
1756
-            error='The configuration file is invalid.'
1843
+            error='The user configuration file is invalid.'
1757 1844
         ), 'expected error exit and known error message'
1758 1845
         assert result.error_exit(
1759 1846
             error=error_message
... ...
@@ -1797,7 +1884,7 @@ contents go here
1797 1884
             )
1798 1885
             result = tests.ReadableResult.parse(_result)
1799 1886
             assert result.error_exit(
1800
-                error='The configuration file is invalid.'
1887
+                error='The user configuration file is invalid.'
1801 1888
             ), 'expected error exit and known error message'
1802 1889
             assert result.error_exit(
1803 1890
                 error=(
... ...
@@ -2464,12 +2551,14 @@ Boo.
2464 2551
         skip_if_no_af_unix_support: None,
2465 2552
         ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient,
2466 2553
     ) -> None:
2467
-        class CustomError(RuntimeError):
2468
-            pass
2554
+        class ErrCallback(BaseException):
2555
+            def __init__(self, *args: Any, **kwargs: Any) -> None:
2556
+                super().__init__(*args[:1])
2557
+                self.args = args
2558
+                self.kwargs = kwargs
2469 2559
 
2470 2560
         def err(*args: Any, **_kwargs: Any) -> NoReturn:
2471
-            args = args or ('custom error message',)
2472
-            raise CustomError(*args)
2561
+            raise ErrCallback(*args, **_kwargs)
2473 2562
 
2474 2563
         def fail(*_args: Any, **_kwargs: Any) -> Any:
2475 2564
             raise ssh_agent.SSHAgentFailedError(
... ...
@@ -2477,6 +2566,9 @@ Boo.
2477 2566
                 b'',
2478 2567
             )
2479 2568
 
2569
+        def fail_runtime(*_args: Any, **_kwargs: Any) -> Any:
2570
+            raise ssh_agent.TrailingDataError()
2571
+
2480 2572
         del skip_if_no_af_unix_support
2481 2573
         monkeypatch.setattr(ssh_agent.SSHAgentClient, 'sign', fail)
2482 2574
         loaded_keys = list(ssh_agent_client_with_test_keys_loaded.list_keys())
... ...
@@ -2487,42 +2579,52 @@ Boo.
2487 2579
                 'list_keys',
2488 2580
                 lambda *_a, **_kw: [],
2489 2581
             )
2490
-            with pytest.raises(CustomError, match='not loaded into the agent'):
2582
+            with pytest.raises(ErrCallback, match='not loaded into the agent'):
2491 2583
                 cli._key_to_phrase(loaded_key, error_callback=err)
2492 2584
         with monkeypatch.context() as mp:
2493 2585
             mp.setattr(ssh_agent.SSHAgentClient, 'list_keys', fail)
2494 2586
             with pytest.raises(
2495
-                CustomError, match='SSH agent failed to complete'
2587
+                ErrCallback, match='SSH agent failed to or refused to'
2496 2588
             ):
2497 2589
                 cli._key_to_phrase(loaded_key, error_callback=err)
2498 2590
         with monkeypatch.context() as mp:
2499
-            mp.setattr(ssh_agent.SSHAgentClient, 'list_keys', err)
2591
+            mp.setattr(ssh_agent.SSHAgentClient, 'list_keys', fail_runtime)
2500 2592
             with pytest.raises(
2501
-                CustomError, match='SSH agent failed to complete'
2593
+                ErrCallback, match='SSH agent failed to or refused to'
2502 2594
             ) as excinfo:
2503 2595
                 cli._key_to_phrase(loaded_key, error_callback=err)
2504
-            assert excinfo.value.args
2596
+            assert excinfo.value.kwargs
2597
+            assert isinstance(
2598
+                excinfo.value.kwargs['exc_info'],
2599
+                ssh_agent.SSHAgentFailedError,
2600
+            )
2601
+            assert excinfo.value.kwargs['exc_info'].__context__ is not None
2505 2602
             assert isinstance(
2506
-                excinfo.value.args[0], ssh_agent.SSHAgentFailedError
2603
+                excinfo.value.kwargs['exc_info'].__context__,
2604
+                ssh_agent.TrailingDataError,
2507 2605
             )
2508
-            assert excinfo.value.args[0].__context__ is not None
2509
-            assert isinstance(excinfo.value.args[0].__context__, CustomError)
2510 2606
         with monkeypatch.context() as mp:
2511 2607
             mp.delenv('SSH_AUTH_SOCK', raising=True)
2512 2608
             with pytest.raises(
2513
-                CustomError, match='Cannot find running SSH agent'
2609
+                ErrCallback, match='Cannot find any running SSH agent'
2514 2610
             ):
2515 2611
                 cli._key_to_phrase(loaded_key, error_callback=err)
2516 2612
         with monkeypatch.context() as mp:
2517 2613
             mp.setenv('SSH_AUTH_SOCK', os.environ['SSH_AUTH_SOCK'] + '~')
2518 2614
             with pytest.raises(
2519
-                CustomError, match='Cannot connect to SSH agent'
2615
+                ErrCallback, match='Cannot connect to the SSH agent'
2520 2616
             ):
2521 2617
                 cli._key_to_phrase(loaded_key, error_callback=err)
2522 2618
         with monkeypatch.context() as mp:
2523 2619
             mp.delattr(socket, 'AF_UNIX', raising=True)
2524 2620
             with pytest.raises(
2525
-                CustomError, match='does not support UNIX domain sockets'
2621
+                ErrCallback, match='does not support UNIX domain sockets'
2622
+            ):
2623
+                cli._key_to_phrase(loaded_key, error_callback=err)
2624
+        with monkeypatch.context() as mp:
2625
+            mp.setattr(ssh_agent.SSHAgentClient, 'sign', fail_runtime)
2626
+            with pytest.raises(
2627
+                ErrCallback, match='violates the communications protocol'
2526 2628
             ):
2527 2629
                 cli._key_to_phrase(loaded_key, error_callback=err)
2528 2630
 
... ...
@@ -2575,7 +2677,7 @@ class TestCLITransition:
2575 2677
             )
2576 2678
             result = tests.ReadableResult.parse(_result)
2577 2679
         assert result.clean_exit(
2578
-            empty_stderr=True, output='Read the vault-native configuration'
2680
+            empty_stderr=True, output='Export a vault-native configuration'
2579 2681
         ), 'expected clean exit, and known help text'
2580 2682
 
2581 2683
     def test_103_help_output_vault(
... ...
@@ -2596,7 +2698,7 @@ class TestCLITransition:
2596 2698
             empty_stderr=True, output='Passphrase generation:\n'
2597 2699
         ), 'expected clean exit, and option groups in help text'
2598 2700
         assert result.clean_exit(
2599
-            empty_stderr=True, output='Use NUMBER=0, e.g. "--symbol 0"'
2701
+            empty_stderr=True, output='Use $VISUAL or $EDITOR to configure'
2600 2702
         ), 'expected clean exit, and option group epilog in help text'
2601 2703
 
2602 2704
     @pytest.mark.parametrize(
... ...
@@ -2749,9 +2851,9 @@ class TestCLITransition:
2749 2851
         result = tests.ReadableResult.parse(_result)
2750 2852
         assert result.clean_exit(empty_stderr=False), 'expected clean exit'
2751 2853
         assert tests.deprecation_warning_emitted(
2752
-            'A subcommand will be required in v1.0', caplog.record_tuples
2854
+            'A subcommand will be required here in v1.0', caplog.record_tuples
2753 2855
         )
2754
-        assert tests.warning_emitted(
2856
+        assert tests.deprecation_warning_emitted(
2755 2857
             'Defaulting to subcommand "vault"', caplog.record_tuples
2756 2858
         )
2757 2859
         assert json.loads(result.output) == tests.VAULT_V03_CONFIG_DATA
... ...
@@ -2773,9 +2875,9 @@ class TestCLITransition:
2773 2875
             )
2774 2876
         result = tests.ReadableResult.parse(_result)
2775 2877
         assert tests.deprecation_warning_emitted(
2776
-            'A subcommand will be required in v1.0', caplog.record_tuples
2878
+            'A subcommand will be required here in v1.0', caplog.record_tuples
2777 2879
         )
2778
-        assert tests.warning_emitted(
2880
+        assert tests.deprecation_warning_emitted(
2779 2881
             'Defaulting to subcommand "vault"', caplog.record_tuples
2780 2882
         )
2781 2883
         assert result.error_exit(
... ...
@@ -2808,9 +2910,9 @@ class TestCLITransition:
2808 2910
             result = tests.ReadableResult.parse(_result)
2809 2911
         assert result.clean_exit(empty_stderr=False), 'expected clean exit'
2810 2912
         assert tests.deprecation_warning_emitted(
2811
-            'A subcommand will be required in v1.0', caplog.record_tuples
2913
+            'A subcommand will be required here in v1.0', caplog.record_tuples
2812 2914
         )
2813
-        assert tests.warning_emitted(
2915
+        assert tests.deprecation_warning_emitted(
2814 2916
             'Defaulting to subcommand "vault"', caplog.record_tuples
2815 2917
         )
2816 2918
         for c in charset:
... ...
@@ -2836,13 +2938,13 @@ class TestCLITransition:
2836 2938
             )
2837 2939
             result = tests.ReadableResult.parse(_result)
2838 2940
         assert tests.deprecation_warning_emitted(
2839
-            'A subcommand will be required in v1.0', caplog.record_tuples
2941
+            'A subcommand will be required here in v1.0', caplog.record_tuples
2840 2942
         )
2841
-        assert tests.warning_emitted(
2943
+        assert tests.deprecation_warning_emitted(
2842 2944
             'Defaulting to subcommand "vault"', caplog.record_tuples
2843 2945
         )
2844 2946
         assert result.error_exit(
2845
-            error='SERVICE is required'
2947
+            error='Deriving a passphrase requires a SERVICE.'
2846 2948
         ), 'expected error exit and known error type'
2847 2949
 
2848 2950
     def test_300_export_using_old_config_file(
2849 2951