Reimplement the `vault` CLI as methods of a context object
Marco Ricci

Marco Ricci commited on 2025-03-16 13:23:05
Zeige 3 geänderte Dateien mit 1449 Einfügungen und 1109 Löschungen.


Introduce a "vault context" object holding the associated data for the
call to the `derivepassphrase vault` CLI, and implement the actual CLI
as a collection of methods on this object.  The "vault context" object
mostly just wraps the click context object and encodes the dispatch
table for the CLI operations.  The main advantage of this modelling is
the much smaller size per, and lower cyclomatic complexity of, each CLI
operation.

As an implementation detail, the old implementation of the
`get_user_config` inner function's error handling branch for `OSError`
had coverage testing purely by accident, because `get_user_config` was
being called unconditionally, very early in the CLI control flow.  The
refactored CLI however reads the user configuration only as needed for
the specific operation. So this error handling branch needs a new,
separate test.

As a further implementation detail, the `is_param_set` inner function no
longer caches its result, because it is now an object method:
`functools.cache` explicitly documents that adding a cache to object
methods causes a circular reference between the cache and the object via
the `self` parameter.  Given the actual contents of this function, there
appears to be little point in working around this memory issue just to
outfit an already cheap operation (a single `Mapping.get` call) with
a cache.

As yet another further implementation detail, checking for misleading
passphrases should work even if the passed configuration snippet is not
technically a `dict`, but rather a `Mapping`.

Finally, due to the vault context object now wrapping the click context,
it is possible (and sensible) to wrap the logging calls into methods of
the vault context object so that less out-of-bounds information needs to
be passed to the logger explicitly at the call site.
... ...
@@ -45,6 +45,7 @@ if TYPE_CHECKING:
45 45
     import socket
46 46
     from collections.abc import (
47 47
         Iterator,
48
+        Mapping,
48 49
         Sequence,
49 50
     )
50 51
 
... ...
@@ -714,7 +715,7 @@ class ORIGIN(enum.Enum):
714 715
 
715 716
 def check_for_misleading_passphrase(
716 717
     key: tuple[str, ...] | ORIGIN,
717
-    value: dict[str, Any],
718
+    value: Mapping[str, Any],
718 719
     *,
719 720
     main_config: dict[str, Any],
720 721
     ctx: click.Context | None = None,
... ...
@@ -11,12 +11,12 @@ from __future__ import annotations
11 11
 import base64
12 12
 import collections
13 13
 import contextlib
14
-import functools
15 14
 import json
16 15
 import logging
17 16
 import os
18 17
 from typing import (
19 18
     TYPE_CHECKING,
19
+    Final,
20 20
     Literal,
21 21
     NoReturn,
22 22
     TextIO,
... ...
@@ -34,10 +34,8 @@ from derivepassphrase._internals import cli_helpers, cli_machinery
34 34
 from derivepassphrase._internals import cli_messages as _msg
35 35
 
36 36
 if TYPE_CHECKING:
37
-    from collections.abc import (
38
-        Callable,
39
-        Sequence,
40
-    )
37
+    from collections.abc import Sequence
38
+    from collections.abc import Set as AbstractSet
41 39
 
42 40
 __all__ = ('derivepassphrase',)
43 41
 
... ...
@@ -317,263 +315,788 @@ def derivepassphrase_export_vault(
317 315
         ctx.exit(1)
318 316
 
319 317
 
320
-@derivepassphrase.command(
321
-    'vault',
322
-    context_settings={'help_option_names': ['-h', '--help']},
323
-    cls=cli_machinery.CommandWithHelpGroups,
324
-    help=(
325
-        _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_VAULT_01),
326
-        _msg.TranslatedString(
327
-            _msg.Label.DERIVEPASSPHRASE_VAULT_02,
328
-            service_metavar=_msg.TranslatedString(
329
-                _msg.Label.VAULT_METAVAR_SERVICE
330
-            ),
331
-        ),
332
-    ),
333
-    epilog=(
334
-        _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_VAULT_EPILOG_01),
335
-        _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_VAULT_EPILOG_02),
336
-    ),
318
+class _VaultContext:  # noqa: PLR0904
319
+    """The context for the "vault" command-line interface.
320
+
321
+    This context object -- wrapping a [`click.Context`][] object --
322
+    encapsulates a single call to the `derivepassphrase vault`
323
+    command-line.  Although fully documented, this class is an
324
+    implementation detail of the `derivepassphrase vault` command-line
325
+    and should not be instantiated directly by users or API clients.
326
+
327
+    Attributes:
328
+        logger:
329
+            The logger used for warnings and error messages.
330
+        deprecation:
331
+            The logger used for deprecation warnings.
332
+        ctx:
333
+            The underlying [`click.Context`][] from which the
334
+            command-line settings and parameter values are queried.
335
+        all_ops:
336
+            A list of operations supported by the CLI, in the
337
+            order that they are queried in the `click` context.  The
338
+            final entry is the default operation, and does not
339
+            correspond to a `click` parameter; all others are `click`
340
+            parameter names.
341
+        readwrite_ops:
342
+            A set of operations which modify the `derivepassphrase`
343
+            state.  All other operations are read-only.
344
+        options_in_group:
345
+            A mapping of option group names to lists of known options
346
+            from this group.  Used during the validation of the command
347
+            line.
348
+        params_by_str:
349
+            A mapping of option names (long names, short names, etc.) to
350
+            option objects.  Used during the validation of the command
351
+            line.
352
+
353
+    """
354
+
355
+    logger: Final = logging.getLogger(PROG_NAME)
356
+    """"""
357
+    deprecation: Final = logging.getLogger(PROG_NAME + '.deprecation')
358
+    """"""
359
+    ctx: Final[click.Context]
360
+    """"""
361
+    all_ops: tuple[str, ...] = (
362
+        'delete_service_settings',
363
+        'delete_globals',
364
+        'clear_all_settings',
365
+        'import_settings',
366
+        'export_settings',
367
+        'store_config_only',
368
+        # The default op "derive_passphrase" must be last!
369
+        'derive_passphrase',
337 370
     )
338
-@click.option(
339
-    '-p',
340
-    '--phrase',
341
-    'use_phrase',
342
-    is_flag=True,
343
-    help=_msg.TranslatedString(
344
-        _msg.Label.DERIVEPASSPHRASE_VAULT_PHRASE_HELP_TEXT
345
-    ),
346
-    cls=cli_machinery.PassphraseGenerationOption,
371
+    readwrite_ops: AbstractSet[str] = frozenset({
372
+        'delete_service_settings',
373
+        'delete_globals',
374
+        'clear_all_settings',
375
+        'import_settings',
376
+        'store_config_only',
377
+    })
378
+    options_in_group: dict[type[click.Option], list[click.Option]]
379
+    params_by_str: dict[str, click.Parameter]
380
+
381
+    def __init__(self, ctx: click.Context, /) -> None:
382
+        """Initialize the vault context.
383
+
384
+        Args:
385
+            ctx:
386
+                The underlying [`click.Context`][] from which the
387
+                command-line settings and parameter values are queried.
388
+
389
+        """
390
+        self.ctx = ctx
391
+        self.params_by_str = {}
392
+        self.options_in_group = {}
393
+        for param in ctx.command.params:
394
+            if isinstance(param, click.Option):
395
+                group: type[click.Option]
396
+                known_option_groups = [
397
+                    cli_machinery.PassphraseGenerationOption,
398
+                    cli_machinery.ConfigurationOption,
399
+                    cli_machinery.StorageManagementOption,
400
+                    cli_machinery.LoggingOption,
401
+                    cli_machinery.CompatibilityOption,
402
+                    cli_machinery.StandardOption,
403
+                ]
404
+                if isinstance(param, cli_machinery.OptionGroupOption):
405
+                    for class_ in known_option_groups:
406
+                        if isinstance(param, class_):
407
+                            group = class_
408
+                            break
409
+                    else:  # pragma: no cover
410
+                        assert False, f'Unknown option group for {param!r}'  # noqa: B011,PT015
411
+                else:
412
+                    group = click.Option
413
+                self.options_in_group.setdefault(group, []).append(param)
414
+            self.params_by_str[param.human_readable_name] = param
415
+            for name in param.opts + param.secondary_opts:
416
+                self.params_by_str[name] = param
417
+
418
+    def is_param_set(self, param: click.Parameter, /) -> bool:
419
+        """Return true if the parameter is set."""
420
+        return bool(self.ctx.params.get(param.human_readable_name))
421
+
422
+    def option_name(self, param: click.Parameter | str, /) -> str:
423
+        """Return the option name of a parameter.
424
+
425
+        Annoyingly, `param.human_readable_name` contains the *function*
426
+        parameter name, not the list of option names.  *Those* are
427
+        stashed in the `.opts` and `.secondary_opts` attributes, which
428
+        are visible in the `.to_info_dict()` output, but not otherwise
429
+        documented.  We return the shortest one among the long-form
430
+        option names.
431
+
432
+        Args:
433
+            param: The parameter whose option name is requested.
434
+
435
+        Raises:
436
+            ValueError: The parameter has no long-form option names.
437
+
438
+        """
439
+        param = self.params_by_str[param] if isinstance(param, str) else param
440
+        names = [param.human_readable_name, *param.opts, *param.secondary_opts]
441
+        option_names = [n for n in names if n.startswith('--')]
442
+        return min(option_names, key=len)
443
+
444
+    def check_incompatible_options(
445
+        self,
446
+        param1: click.Parameter | str,
447
+        param2: click.Parameter | str,
448
+    ) -> None:
449
+        """Raise an error if the two options are incompatible.
450
+
451
+        Raises:
452
+            click.BadOptionUsage: The given options are incompatible.
453
+
454
+        """
455
+        param1 = (
456
+            self.params_by_str[param1] if isinstance(param1, str) else param1
347 457
         )
348
-@click.option(
349
-    '-k',
350
-    '--key',
351
-    'use_key',
352
-    is_flag=True,
353
-    help=_msg.TranslatedString(
354
-        _msg.Label.DERIVEPASSPHRASE_VAULT_KEY_HELP_TEXT
355
-    ),
356
-    cls=cli_machinery.PassphraseGenerationOption,
458
+        param2 = (
459
+            self.params_by_str[param2] if isinstance(param2, str) else param2
357 460
         )
358
-@click.option(
359
-    '-l',
360
-    '--length',
361
-    metavar=_msg.TranslatedString(
362
-        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
363
-    ),
364
-    callback=cli_machinery.validate_length,
365
-    help=_msg.TranslatedString(
366
-        _msg.Label.DERIVEPASSPHRASE_VAULT_LENGTH_HELP_TEXT,
367
-        metavar=_msg.TranslatedString(
368
-            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
369
-        ),
370
-    ),
371
-    cls=cli_machinery.PassphraseGenerationOption,
461
+        if param1 == param2:
462
+            return
463
+        if not self.is_param_set(param1):
464
+            return
465
+        if self.is_param_set(param2):
466
+            param1_str = self.option_name(param1)
467
+            param2_str = self.option_name(param2)
468
+            raise click.BadOptionUsage(
469
+                param1_str,
470
+                str(
471
+                    _msg.TranslatedString(
472
+                        _msg.ErrMsgTemplate.PARAMS_MUTUALLY_EXCLUSIVE,
473
+                        param1=param1_str,
474
+                        param2=param2_str,
372 475
                     )
373
-@click.option(
374
-    '-r',
375
-    '--repeat',
376
-    metavar=_msg.TranslatedString(
377
-        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
378
-    ),
379
-    callback=cli_machinery.validate_occurrence_constraint,
380
-    help=_msg.TranslatedString(
381
-        _msg.Label.DERIVEPASSPHRASE_VAULT_REPEAT_HELP_TEXT,
382
-        metavar=_msg.TranslatedString(
383
-            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
384
-        ),
385 476
                 ),
386
-    cls=cli_machinery.PassphraseGenerationOption,
477
+                ctx=self.ctx,
387 478
             )
388
-@click.option(
389
-    '--lower',
390
-    metavar=_msg.TranslatedString(
391
-        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
392
-    ),
393
-    callback=cli_machinery.validate_occurrence_constraint,
394
-    help=_msg.TranslatedString(
395
-        _msg.Label.DERIVEPASSPHRASE_VAULT_LOWER_HELP_TEXT,
396
-        metavar=_msg.TranslatedString(
397
-            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
398
-        ),
399
-    ),
400
-    cls=cli_machinery.PassphraseGenerationOption,
479
+        return
480
+
481
+    def err(self, msg: Any, /, **kwargs: Any) -> NoReturn:  # noqa: ANN401
482
+        """Log an error, then abort the function call.
483
+
484
+        We ensure that color handling is done properly before the error
485
+        is logged.
486
+
487
+        """
488
+        stacklevel = kwargs.pop('stacklevel', 1)
489
+        stacklevel += 1
490
+        extra = kwargs.pop('extra', {})
491
+        extra.setdefault('color', self.ctx.color)
492
+        self.logger.error(msg, stacklevel=stacklevel, extra=extra, **kwargs)
493
+        self.ctx.exit(1)
494
+
495
+    def warning(self, msg: Any, /, **kwargs: Any) -> None:  # noqa: ANN401
496
+        """Log a warning.
497
+
498
+        We ensure that color handling is done properly before the
499
+        warning is logged.
500
+
501
+        """
502
+        stacklevel = kwargs.pop('stacklevel', 1)
503
+        stacklevel += 1
504
+        extra = kwargs.pop('extra', {})
505
+        extra.setdefault('color', self.ctx.color)
506
+        self.logger.warning(msg, stacklevel=stacklevel, extra=extra, **kwargs)
507
+
508
+    def deprecation_warning(self, msg: Any, /, **kwargs: Any) -> None:  # noqa: ANN401
509
+        """Log a deprecation warning.
510
+
511
+        We ensure that color handling is done properly before the
512
+        warning is logged.
513
+
514
+        """
515
+        stacklevel = kwargs.pop('stacklevel', 1)
516
+        stacklevel += 1
517
+        extra = kwargs.pop('extra', {})
518
+        extra.setdefault('color', self.ctx.color)
519
+        self.deprecation.warning(
520
+            msg, stacklevel=stacklevel, extra=extra, **kwargs
401 521
         )
402
-@click.option(
403
-    '--upper',
404
-    metavar=_msg.TranslatedString(
405
-        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
406
-    ),
407
-    callback=cli_machinery.validate_occurrence_constraint,
408
-    help=_msg.TranslatedString(
409
-        _msg.Label.DERIVEPASSPHRASE_VAULT_UPPER_HELP_TEXT,
410
-        metavar=_msg.TranslatedString(
411
-            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
412
-        ),
413
-    ),
414
-    cls=cli_machinery.PassphraseGenerationOption,
522
+
523
+    def deprecation_info(self, msg: Any, /, **kwargs: Any) -> None:  # noqa: ANN401
524
+        """Log a deprecation info message.
525
+
526
+        We ensure that color handling is done properly before the
527
+        warning is logged.
528
+
529
+        """
530
+        stacklevel = kwargs.pop('stacklevel', 1)
531
+        stacklevel += 1
532
+        extra = kwargs.pop('extra', {})
533
+        extra.setdefault('color', self.ctx.color)
534
+        self.deprecation.info(
535
+            msg, stacklevel=stacklevel, extra=extra, **kwargs
415 536
         )
416
-@click.option(
417
-    '--number',
418
-    metavar=_msg.TranslatedString(
419
-        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
420
-    ),
421
-    callback=cli_machinery.validate_occurrence_constraint,
422
-    help=_msg.TranslatedString(
423
-        _msg.Label.DERIVEPASSPHRASE_VAULT_NUMBER_HELP_TEXT,
424
-        metavar=_msg.TranslatedString(
425
-            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
426
-        ),
537
+
538
+    def get_config(self) -> _types.VaultConfig:
539
+        """Return the vault configuration stored on disk.
540
+
541
+        If no configuration is stored, return an empty configuration.
542
+
543
+        If only a v0.1-style configuration is found, attempt to migrate
544
+        it.  This will be removed in v1.0.
545
+
546
+        """
547
+        try:
548
+            return cli_helpers.load_config()
549
+        except FileNotFoundError:
550
+            # TODO(the-13th-letter): Return the empty default
551
+            # configuration directly.
552
+            # https://the13thletter.info/derivepassphrase/latest/upgrade-notes.html#v1.0-old-settings-file
553
+            try:
554
+                backup_config, exc = cli_helpers.migrate_and_load_old_config()
555
+            except FileNotFoundError:
556
+                return {'services': {}}
557
+            old_name = cli_helpers.config_filename(
558
+                subsystem='old settings.json'
559
+            ).name
560
+            new_name = cli_helpers.config_filename(subsystem='vault').name
561
+            self.deprecation_warning(
562
+                _msg.TranslatedString(
563
+                    _msg.WarnMsgTemplate.V01_STYLE_CONFIG,
564
+                    old=old_name,
565
+                    new=new_name,
427 566
                 ),
428
-    cls=cli_machinery.PassphraseGenerationOption,
429 567
             )
430
-@click.option(
431
-    '--space',
432
-    metavar=_msg.TranslatedString(
433
-        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
434
-    ),
435
-    callback=cli_machinery.validate_occurrence_constraint,
436
-    help=_msg.TranslatedString(
437
-        _msg.Label.DERIVEPASSPHRASE_VAULT_SPACE_HELP_TEXT,
438
-        metavar=_msg.TranslatedString(
439
-            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
440
-        ),
441
-    ),
442
-    cls=cli_machinery.PassphraseGenerationOption,
568
+            if isinstance(exc, OSError):
569
+                self.warning(
570
+                    _msg.TranslatedString(
571
+                        _msg.WarnMsgTemplate.FAILED_TO_MIGRATE_CONFIG,
572
+                        path=new_name,
573
+                        error=exc.strerror,
574
+                        filename=exc.filename,
575
+                    ).maybe_without_filename(),
443 576
                 )
444
-@click.option(
445
-    '--dash',
446
-    metavar=_msg.TranslatedString(
447
-        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
448
-    ),
449
-    callback=cli_machinery.validate_occurrence_constraint,
450
-    help=_msg.TranslatedString(
451
-        _msg.Label.DERIVEPASSPHRASE_VAULT_DASH_HELP_TEXT,
452
-        metavar=_msg.TranslatedString(
453
-            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
454
-        ),
577
+            else:
578
+                self.deprecation_info(
579
+                    _msg.TranslatedString(
580
+                        _msg.InfoMsgTemplate.SUCCESSFULLY_MIGRATED,
581
+                        path=new_name,
455 582
                     ),
456
-    cls=cli_machinery.PassphraseGenerationOption,
457 583
                 )
458
-@click.option(
459
-    '--symbol',
460
-    metavar=_msg.TranslatedString(
461
-        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
462
-    ),
463
-    callback=cli_machinery.validate_occurrence_constraint,
464
-    help=_msg.TranslatedString(
465
-        _msg.Label.DERIVEPASSPHRASE_VAULT_SYMBOL_HELP_TEXT,
466
-        metavar=_msg.TranslatedString(
467
-            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
468
-        ),
469
-    ),
470
-    cls=cli_machinery.PassphraseGenerationOption,
584
+            return backup_config
585
+        except OSError as exc:
586
+            self.err(
587
+                _msg.TranslatedString(
588
+                    _msg.ErrMsgTemplate.CANNOT_LOAD_VAULT_SETTINGS,
589
+                    error=exc.strerror,
590
+                    filename=exc.filename,
591
+                ).maybe_without_filename(),
471 592
             )
472
-@click.option(
473
-    '-n',
474
-    '--notes',
475
-    'edit_notes',
476
-    is_flag=True,
477
-    help=_msg.TranslatedString(
478
-        _msg.Label.DERIVEPASSPHRASE_VAULT_NOTES_HELP_TEXT,
593
+        except Exception as exc:  # noqa: BLE001
594
+            self.err(
595
+                _msg.TranslatedString(
596
+                    _msg.ErrMsgTemplate.CANNOT_LOAD_VAULT_SETTINGS,
597
+                    error=str(exc),
598
+                    filename=None,
599
+                ).maybe_without_filename(),
600
+                exc_info=exc,
601
+            )
602
+
603
+    def put_config(self, config: _types.VaultConfig, /) -> None:
604
+        """Store the given vault configuration to disk."""
605
+        try:
606
+            cli_helpers.save_config(config)
607
+        except OSError as exc:
608
+            self.err(
609
+                _msg.TranslatedString(
610
+                    _msg.ErrMsgTemplate.CANNOT_STORE_VAULT_SETTINGS,
611
+                    error=exc.strerror,
612
+                    filename=exc.filename,
613
+                ).maybe_without_filename(),
614
+            )
615
+        except Exception as exc:  # noqa: BLE001
616
+            self.err(
617
+                _msg.TranslatedString(
618
+                    _msg.ErrMsgTemplate.CANNOT_STORE_VAULT_SETTINGS,
619
+                    error=str(exc),
620
+                    filename=None,
621
+                ).maybe_without_filename(),
622
+                exc_info=exc,
623
+            )
624
+
625
+    def get_user_config(self) -> dict[str, Any]:
626
+        """Return the global user configuration stored on disk.
627
+
628
+        If no configuration is stored, return an empty configuration.
629
+
630
+        """
631
+        try:
632
+            return cli_helpers.load_user_config()
633
+        except FileNotFoundError:
634
+            return {}
635
+        except OSError as exc:
636
+            self.err(
637
+                _msg.TranslatedString(
638
+                    _msg.ErrMsgTemplate.CANNOT_LOAD_USER_CONFIG,
639
+                    error=exc.strerror,
640
+                    filename=exc.filename,
641
+                ).maybe_without_filename(),
642
+            )
643
+        except Exception as exc:  # noqa: BLE001
644
+            self.err(
645
+                _msg.TranslatedString(
646
+                    _msg.ErrMsgTemplate.CANNOT_LOAD_USER_CONFIG,
647
+                    error=str(exc),
648
+                    filename=None,
649
+                ).maybe_without_filename(),
650
+                exc_info=exc,
651
+            )
652
+
653
+    def validate_command_line(self) -> None:  # noqa: C901,PLR0912
654
+        """Check for missing/extra arguments and conflicting options.
655
+
656
+        Raises:
657
+            click.UsageError:
658
+                The command-line is invalid because of missing or extra
659
+                arguments, or because of conflicting options.  The error
660
+                message contains further details.
661
+
662
+        """
663
+        param: click.Parameter
664
+        self.check_incompatible_options('--phrase', '--key')
665
+        for group in (
666
+            cli_machinery.ConfigurationOption,
667
+            cli_machinery.StorageManagementOption,
668
+        ):
669
+            for opt in self.options_in_group[group]:
670
+                if opt not in {
671
+                    self.params_by_str['--config'],
672
+                    self.params_by_str['--notes'],
673
+                }:
674
+                    for other_opt in self.options_in_group[
675
+                        cli_machinery.PassphraseGenerationOption
676
+                    ]:
677
+                        self.check_incompatible_options(opt, other_opt)
678
+
679
+        for group in (
680
+            cli_machinery.ConfigurationOption,
681
+            cli_machinery.StorageManagementOption,
682
+        ):
683
+            for opt in self.options_in_group[group]:
684
+                for other_opt in self.options_in_group[
685
+                    cli_machinery.ConfigurationOption
686
+                ]:
687
+                    if {opt, other_opt} != {
688
+                        self.params_by_str['--config'],
689
+                        self.params_by_str['--notes'],
690
+                    }:
691
+                        self.check_incompatible_options(opt, other_opt)
692
+                for other_opt in self.options_in_group[
693
+                    cli_machinery.StorageManagementOption
694
+                ]:
695
+                    self.check_incompatible_options(opt, other_opt)
696
+        service: str | None = self.ctx.params['service']
479 697
         service_metavar = _msg.TranslatedString(
480 698
             _msg.Label.VAULT_METAVAR_SERVICE
481
-        ),
482
-    ),
483
-    cls=cli_machinery.ConfigurationOption,
484 699
         )
485
-@click.option(
486
-    '-c',
487
-    '--config',
488
-    'store_config_only',
489
-    is_flag=True,
490
-    help=_msg.TranslatedString(
491
-        _msg.Label.DERIVEPASSPHRASE_VAULT_CONFIG_HELP_TEXT,
700
+        sv_or_global_options = self.options_in_group[
701
+            cli_machinery.PassphraseGenerationOption
702
+        ]
703
+        for param in sv_or_global_options:
704
+            if self.is_param_set(param) and not (
705
+                service is not None
706
+                or self.is_param_set(self.params_by_str['--config'])
707
+            ):
708
+                err_msg = _msg.TranslatedString(
709
+                    _msg.ErrMsgTemplate.PARAMS_NEEDS_SERVICE_OR_CONFIG,
710
+                    param=param.opts[0],
711
+                    service_metavar=service_metavar,
712
+                )
713
+                raise click.UsageError(str(err_msg))
714
+        sv_options = [
715
+            self.params_by_str['--notes'],
716
+            self.params_by_str['--delete'],
717
+        ]
718
+        for param in sv_options:
719
+            if self.is_param_set(param) and not service is not None:
720
+                err_msg = _msg.TranslatedString(
721
+                    _msg.ErrMsgTemplate.PARAMS_NEEDS_SERVICE,
722
+                    param=param.opts[0],
723
+                    service_metavar=service_metavar,
724
+                )
725
+                raise click.UsageError(str(err_msg))
726
+        no_sv_options = [
727
+            self.params_by_str['--delete-globals'],
728
+            self.params_by_str['--clear'],
729
+            *self.options_in_group[cli_machinery.StorageManagementOption],
730
+        ]
731
+        for param in no_sv_options:
732
+            if self.is_param_set(param) and service is not None:
733
+                err_msg = _msg.TranslatedString(
734
+                    _msg.ErrMsgTemplate.PARAMS_NO_SERVICE,
735
+                    param=param.opts[0],
736
+                    service_metavar=service_metavar,
737
+                )
738
+                raise click.UsageError(str(err_msg))
739
+
740
+    def get_mutex(self, op: str) -> contextlib.AbstractContextManager[None]:
741
+        """Return a mutex for accessing the configuration on disk.
742
+
743
+        The mutex is a context manager, and will lock out other threads
744
+        and processes attempting to access the configuration in an
745
+        incompatible manner.
746
+
747
+        Returns:
748
+            If the requested operation is a read-only operation, return
749
+            a no-op mutex.  (Concurrent reads are always allowed, even
750
+            in the presence of writers.)  Otherwise, for read-write
751
+            operations, returns the result from
752
+            [`cli_helpers.configuration_mutex`][].
753
+
754
+        """
755
+        return (
756
+            cli_helpers.configuration_mutex()
757
+            if op in self.readwrite_ops
758
+            else contextlib.nullcontext()
759
+        )
760
+
761
+    def dispatch_op(self) -> None:
762
+        """Dispatch to the handler matching the command-line call.
763
+
764
+        Also issue any appropriate warnings about the command-line,
765
+        e.g., incompatibilities with vault(1) or ineffective options.
766
+
767
+        """
492 768
         service_metavar = _msg.TranslatedString(
493 769
             _msg.Label.VAULT_METAVAR_SERVICE
770
+        )
771
+        if self.ctx.params['service'] == '':  # noqa: PLC1901
772
+            self.warning(
773
+                _msg.TranslatedString(
774
+                    _msg.WarnMsgTemplate.EMPTY_SERVICE_NOT_SUPPORTED,
775
+                    service_metavar=service_metavar,
494 776
                 ),
777
+            )
778
+        if self.ctx.params.get('edit_notes') and not self.ctx.params.get(
779
+            'store_config_only'
780
+        ):
781
+            self.warning(
782
+                _msg.TranslatedString(
783
+                    _msg.WarnMsgTemplate.EDITING_NOTES_BUT_NOT_STORING_CONFIG,
784
+                    service_metavar=service_metavar,
495 785
                 ),
496
-    cls=cli_machinery.ConfigurationOption,
497 786
             )
498
-@click.option(
499
-    '-x',
500
-    '--delete',
501
-    'delete_service_settings',
502
-    is_flag=True,
503
-    help=_msg.TranslatedString(
504
-        _msg.Label.DERIVEPASSPHRASE_VAULT_DELETE_HELP_TEXT,
787
+        op: str
788
+        for candidate_op in self.all_ops[:-1]:
789
+            if self.ctx.params.get(candidate_op):
790
+                op = candidate_op
791
+                break
792
+        else:
793
+            op = self.all_ops[-1]
794
+        with self.get_mutex(op):
795
+            op_func = getattr(self, 'run_op_' + op)
796
+            return op_func()
797
+
798
+    def run_op_delete_service_settings(self) -> None:
799
+        """Delete settings for a specific service."""
800
+        service = self.ctx.params['service']
801
+        assert service is not None
802
+        configuration = self.get_config()
803
+        if service in configuration['services']:
804
+            del configuration['services'][service]
805
+            self.put_config(configuration)
806
+
807
+    def run_op_delete_globals(self) -> None:
808
+        """Delete the global settings."""
809
+        configuration = self.get_config()
810
+        if 'global' in configuration:
811
+            del configuration['global']
812
+            self.put_config(configuration)
813
+
814
+    def run_op_clear_all_settings(self) -> None:
815
+        """Clear all settings."""
816
+        self.put_config({'services': {}})
817
+
818
+    def run_op_import_settings(self) -> None:  # noqa: C901,PLR0912
819
+        """Import settings from a given file.
820
+
821
+        Issue multiple warnings, if appropriate, e.g. for Unicode
822
+        normalization issues with stored passphrases or conflicting
823
+        stored passphrases and keys.  Respect the `--overwrite-config`
824
+        and `--merge-config` options when writing the imported
825
+        configuration to disk.
826
+
827
+        """
505 828
         service_metavar = _msg.TranslatedString(
506 829
             _msg.Label.VAULT_METAVAR_SERVICE
830
+        )
831
+        user_config = self.get_user_config()
832
+        import_settings = self.ctx.params['import_settings']
833
+        overwrite_config = self.ctx.params['overwrite_config']
834
+        try:
835
+            # TODO(the-13th-letter): keep track of auto-close; try
836
+            # os.dup if feasible
837
+            infile = cast(
838
+                'TextIO',
839
+                (
840
+                    import_settings
841
+                    if hasattr(import_settings, 'close')
842
+                    else click.open_file(os.fspath(import_settings), 'rt')
507 843
                 ),
844
+            )
845
+            # Don't specifically catch TypeError or ValueError here if
846
+            # the passed-in fileobj is not a readable text stream.  This
847
+            # will never happen on the command-line (thanks to `click`),
848
+            # and for programmatic use, our caller may want accurate
849
+            # error information.
850
+            with infile:
851
+                maybe_config = json.load(infile)
852
+        except json.JSONDecodeError as exc:
853
+            self.err(
854
+                _msg.TranslatedString(
855
+                    _msg.ErrMsgTemplate.CANNOT_DECODEIMPORT_VAULT_SETTINGS,
856
+                    error=exc,
857
+                )
858
+            )
859
+        except OSError as exc:
860
+            self.err(
861
+                _msg.TranslatedString(
862
+                    _msg.ErrMsgTemplate.CANNOT_IMPORT_VAULT_SETTINGS,
863
+                    error=exc.strerror,
864
+                    filename=exc.filename,
865
+                ).maybe_without_filename()
866
+            )
867
+        cleaned = _types.clean_up_falsy_vault_config_values(maybe_config)
868
+        if not _types.is_vault_config(maybe_config):
869
+            self.err(
870
+                _msg.TranslatedString(
871
+                    _msg.ErrMsgTemplate.CANNOT_IMPORT_VAULT_SETTINGS,
872
+                    error=_msg.TranslatedString(
873
+                        _msg.ErrMsgTemplate.INVALID_VAULT_CONFIG,
874
+                        config=maybe_config,
508 875
                     ),
509
-    cls=cli_machinery.ConfigurationOption,
876
+                    filename=None,
877
+                ).maybe_without_filename()
510 878
             )
511
-@click.option(
512
-    '--delete-globals',
513
-    is_flag=True,
514
-    help=_msg.TranslatedString(
515
-        _msg.Label.DERIVEPASSPHRASE_VAULT_DELETE_GLOBALS_HELP_TEXT,
879
+        assert cleaned is not None
880
+        for step in cleaned:
881
+            # These are never fatal errors, because the semantics of
882
+            # vault upon encountering these settings are ill-specified,
883
+            # but not ill-defined.
884
+            if step.action == 'replace':
885
+                self.warning(
886
+                    _msg.TranslatedString(
887
+                        _msg.WarnMsgTemplate.STEP_REPLACE_INVALID_VALUE,
888
+                        old=json.dumps(step.old_value),
889
+                        path=_types.json_path(step.path),
890
+                        new=json.dumps(step.new_value),
516 891
                     ),
517
-    cls=cli_machinery.ConfigurationOption,
518 892
                 )
519
-@click.option(
520
-    '-X',
521
-    '--clear',
522
-    'clear_all_settings',
523
-    is_flag=True,
524
-    help=_msg.TranslatedString(
525
-        _msg.Label.DERIVEPASSPHRASE_VAULT_DELETE_ALL_HELP_TEXT,
893
+            else:
894
+                self.warning(
895
+                    _msg.TranslatedString(
896
+                        _msg.WarnMsgTemplate.STEP_REMOVE_INEFFECTIVE_VALUE,
897
+                        path=_types.json_path(step.path),
898
+                        old=json.dumps(step.old_value),
526 899
                     ),
527
-    cls=cli_machinery.ConfigurationOption,
528 900
                 )
529
-@click.option(
530
-    '-e',
531
-    '--export',
532
-    'export_settings',
533
-    metavar=_msg.TranslatedString(
534
-        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
535
-    ),
536
-    help=_msg.TranslatedString(
537
-        _msg.Label.DERIVEPASSPHRASE_VAULT_EXPORT_HELP_TEXT,
538
-        metavar=_msg.TranslatedString(
539
-            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
901
+        if '' in maybe_config['services']:
902
+            self.warning(
903
+                _msg.TranslatedString(
904
+                    _msg.WarnMsgTemplate.EMPTY_SERVICE_SETTINGS_INACCESSIBLE,
905
+                    service_metavar=service_metavar,
906
+                    PROG_NAME=PROG_NAME,
540 907
                 ),
908
+            )
909
+        for service_name in sorted(maybe_config['services'].keys()):
910
+            if not cli_helpers.is_completable_item(service_name):
911
+                self.warning(
912
+                    _msg.TranslatedString(
913
+                        _msg.WarnMsgTemplate.SERVICE_NAME_INCOMPLETABLE,
914
+                        service=service_name,
541 915
                     ),
542
-    cls=cli_machinery.StorageManagementOption,
543
-    shell_complete=cli_helpers.shell_complete_path,
544 916
                 )
545
-@click.option(
546
-    '-i',
547
-    '--import',
548
-    'import_settings',
549
-    metavar=_msg.TranslatedString(
550
-        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
917
+        try:
918
+            cli_helpers.check_for_misleading_passphrase(
919
+                ('global',),
920
+                cast('dict[str, Any]', maybe_config.get('global', {})),
921
+                main_config=user_config,
922
+                ctx=self.ctx,
923
+            )
924
+            for key, value in maybe_config['services'].items():
925
+                cli_helpers.check_for_misleading_passphrase(
926
+                    ('services', key),
927
+                    cast('dict[str, Any]', value),
928
+                    main_config=user_config,
929
+                    ctx=self.ctx,
930
+                )
931
+        except AssertionError as exc:
932
+            self.err(
933
+                _msg.TranslatedString(
934
+                    _msg.ErrMsgTemplate.INVALID_USER_CONFIG,
935
+                    error=exc,
936
+                    filename=None,
937
+                ).maybe_without_filename(),
938
+            )
939
+        global_obj = maybe_config.get('global', {})
940
+        has_key = _types.js_truthiness(global_obj.get('key'))
941
+        has_phrase = _types.js_truthiness(global_obj.get('phrase'))
942
+        if has_key and has_phrase:
943
+            self.warning(
944
+                _msg.TranslatedString(
945
+                    _msg.WarnMsgTemplate.GLOBAL_PASSPHRASE_INEFFECTIVE,
551 946
                 ),
552
-    help=_msg.TranslatedString(
553
-        _msg.Label.DERIVEPASSPHRASE_VAULT_IMPORT_HELP_TEXT,
554
-        metavar=_msg.TranslatedString(
555
-            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
947
+            )
948
+        for service_name, service_obj in maybe_config['services'].items():
949
+            has_key = _types.js_truthiness(
950
+                service_obj.get('key')
951
+            ) or _types.js_truthiness(global_obj.get('key'))
952
+            has_phrase = _types.js_truthiness(
953
+                service_obj.get('phrase')
954
+            ) or _types.js_truthiness(global_obj.get('phrase'))
955
+            if has_key and has_phrase:
956
+                self.warning(
957
+                    _msg.TranslatedString(
958
+                        _msg.WarnMsgTemplate.SERVICE_PASSPHRASE_INEFFECTIVE,
959
+                        service=json.dumps(service_name),
556 960
                     ),
961
+                )
962
+        if overwrite_config:
963
+            self.put_config(maybe_config)
964
+        else:
965
+            configuration = self.get_config()
966
+            merged_config: collections.ChainMap[str, Any] = (
967
+                collections.ChainMap(
968
+                    {
969
+                        'services': collections.ChainMap(
970
+                            maybe_config['services'],
971
+                            configuration['services'],
557 972
                         ),
558
-    cls=cli_machinery.StorageManagementOption,
559
-    shell_complete=cli_helpers.shell_complete_path,
973
+                    },
974
+                    {'global': maybe_config['global']}
975
+                    if 'global' in maybe_config
976
+                    else {},
977
+                    {'global': configuration['global']}
978
+                    if 'global' in configuration
979
+                    else {},
560 980
                 )
561
-@click.option(
562
-    '--overwrite-existing/--merge-existing',
563
-    'overwrite_config',
564
-    default=False,
565
-    help=_msg.TranslatedString(
566
-        _msg.Label.DERIVEPASSPHRASE_VAULT_OVERWRITE_HELP_TEXT
981
+            )
982
+            new_config: Any = {
983
+                k: dict(v) if isinstance(v, collections.ChainMap) else v
984
+                for k, v in sorted(merged_config.items())
985
+            }
986
+            assert _types.is_vault_config(new_config)
987
+            self.put_config(new_config)
988
+
989
+    def run_op_export_settings(self) -> None:
990
+        """Export settings to a given file.
991
+
992
+        Respect the `--export-as` option when writing the exported
993
+        configuration to the file.
994
+
995
+        """
996
+        export_settings = self.ctx.params['export_settings']
997
+        export_as = self.ctx.params['export_as']
998
+        configuration = self.get_config()
999
+        try:
1000
+            # TODO(the-13th-letter): keep track of auto-close; try
1001
+            # os.dup if feasible
1002
+            outfile = cast(
1003
+                'TextIO',
1004
+                (
1005
+                    export_settings
1006
+                    if hasattr(export_settings, 'close')
1007
+                    else click.open_file(os.fspath(export_settings), 'wt')
567 1008
                 ),
568
-    cls=cli_machinery.CompatibilityOption,
569 1009
             )
570
-@click.option(
571
-    '--unset',
572
-    'unset_settings',
573
-    multiple=True,
574
-    type=click.Choice([
575
-        'phrase',
576
-        'key',
1010
+            # Don't specifically catch TypeError or ValueError here if
1011
+            # the passed-in fileobj is not a writable text stream.  This
1012
+            # will never happen on the command-line (thanks to `click`),
1013
+            # and for programmatic use, our caller may want accurate
1014
+            # error information.
1015
+            with outfile:
1016
+                if export_as == 'sh':
1017
+                    this_ctx = self.ctx
1018
+                    prog_name_pieces = collections.deque([
1019
+                        this_ctx.info_name or 'vault',
1020
+                    ])
1021
+                    while (
1022
+                        this_ctx.parent is not None
1023
+                        and this_ctx.parent.info_name is not None
1024
+                    ):
1025
+                        prog_name_pieces.appendleft(this_ctx.parent.info_name)
1026
+                        this_ctx = this_ctx.parent
1027
+                    cli_helpers.print_config_as_sh_script(
1028
+                        configuration,
1029
+                        outfile=outfile,
1030
+                        prog_name_list=prog_name_pieces,
1031
+                    )
1032
+                else:
1033
+                    json.dump(
1034
+                        configuration,
1035
+                        outfile,
1036
+                        ensure_ascii=False,
1037
+                        indent=2,
1038
+                        sort_keys=True,
1039
+                    )
1040
+        except OSError as exc:
1041
+            self.err(
1042
+                _msg.TranslatedString(
1043
+                    _msg.ErrMsgTemplate.CANNOT_EXPORT_VAULT_SETTINGS,
1044
+                    error=exc.strerror,
1045
+                    filename=exc.filename,
1046
+                ).maybe_without_filename(),
1047
+            )
1048
+
1049
+    def run_subop_query_phrase_or_key_change(  # noqa: C901,PLR0912
1050
+        self,
1051
+        *,
1052
+        empty_service_permitted: bool,
1053
+        configuration: _types.VaultConfig | None = None,
1054
+    ) -> collections.ChainMap[str, Any]:
1055
+        """Query the master passphrase or master SSH key, if changed.
1056
+
1057
+        If the user indicates they want to change the master passphrase
1058
+        or master SSH key for this call, or if they want to configure
1059
+        a stored global or service-specific master passphrase or master
1060
+        SSH key, then query the user.
1061
+
1062
+        This is not a complete command-line call operation in and of
1063
+        itself.
1064
+
1065
+        Args:
1066
+            empty_service_permitted:
1067
+                True if an empty service name is permitted, False otherwise.
1068
+            configuration:
1069
+                The vault configuration, parsed from disk.  If not
1070
+                given, we read the configuration from disk ourselves.
1071
+
1072
+                The returned effective configuration already contains
1073
+                a relevant slice of the vault configuration. However,
1074
+                some callers need access to the full configuration *and*
1075
+                need the slice within the effective configuration to
1076
+                refer to the same object.
1077
+
1078
+        Returns:
1079
+            The effective configuration for the (possibly empty) given
1080
+            service, as a [chained map][collections.ChainMap].  Any
1081
+            master passphrase or master SSH key overrides that may be in
1082
+            effect are stored in the first map.
1083
+
1084
+        Raises:
1085
+            click.UsageError:
1086
+                The service name was empty, and an empty service name
1087
+                was not permitted as per the method parameters.
1088
+
1089
+        Warning:
1090
+            It is the caller's responsibility to vet any interactively
1091
+            entered master passphrase for Unicode normalization issues.
1092
+
1093
+        """
1094
+        service = self.ctx.params['service']
1095
+        use_key = self.ctx.params['use_key']
1096
+        use_phrase = self.ctx.params['use_phrase']
1097
+        if configuration is None:
1098
+            configuration = self.get_config()
1099
+        service_keys_on_commandline = {
577 1100
             'length',
578 1101
             'repeat',
579 1102
             'lower',
... ...
@@ -582,706 +1105,329 @@ def derivepassphrase_export_vault(
582 1105
             'space',
583 1106
             'dash',
584 1107
             'symbol',
585
-        'notes',
586
-    ]),
587
-    help=_msg.TranslatedString(
588
-        _msg.Label.DERIVEPASSPHRASE_VAULT_UNSET_HELP_TEXT
1108
+        }
1109
+        settings: collections.ChainMap[str, Any] = collections.ChainMap(
1110
+            {
1111
+                k: v
1112
+                for k in service_keys_on_commandline
1113
+                if (v := self.ctx.params.get(k)) is not None
1114
+            },
1115
+            cast(
1116
+                'dict[str, Any]',
1117
+                configuration['services'].get(service, {}) if service else {},
589 1118
             ),
590
-    cls=cli_machinery.CompatibilityOption,
1119
+            cast('dict[str, Any]', configuration.get('global', {})),
591 1120
         )
592
-@click.option(
593
-    '--export-as',
594
-    type=click.Choice(['json', 'sh']),
595
-    default='json',
596
-    help=_msg.TranslatedString(
597
-        _msg.Label.DERIVEPASSPHRASE_VAULT_EXPORT_AS_HELP_TEXT
1121
+        if not service and not empty_service_permitted:
1122
+            err_msg = _msg.TranslatedString(
1123
+                _msg.ErrMsgTemplate.SERVICE_REQUIRED,
1124
+                service_metavar=_msg.TranslatedString(
1125
+                    _msg.Label.VAULT_METAVAR_SERVICE
598 1126
                 ),
599
-    cls=cli_machinery.CompatibilityOption,
600 1127
             )
601
-@click.option(
602
-    '--modern-editor-interface/--vault-legacy-editor-interface',
603
-    'modern_editor_interface',
604
-    default=False,
605
-    help=_msg.TranslatedString(
606
-        _msg.Label.DERIVEPASSPHRASE_VAULT_EDITOR_INTERFACE_HELP_TEXT
1128
+            raise click.UsageError(str(err_msg))
1129
+        if use_key:
1130
+            try:
1131
+                settings.maps[0]['key'] = base64.standard_b64encode(
1132
+                    cli_helpers.select_ssh_key(ctx=self.ctx)
1133
+                ).decode('ASCII')
1134
+            except IndexError:
1135
+                self.err(
1136
+                    _msg.TranslatedString(
1137
+                        _msg.ErrMsgTemplate.USER_ABORTED_SSH_KEY_SELECTION
607 1138
                     ),
608
-    cls=cli_machinery.CompatibilityOption,
609 1139
                 )
610
-@click.option(
611
-    '--print-notes-before/--print-notes-after',
612
-    'print_notes_before',
613
-    default=False,
614
-    help=_msg.TranslatedString(
615
-        _msg.Label.DERIVEPASSPHRASE_VAULT_PRINT_NOTES_BEFORE_HELP_TEXT
1140
+            except KeyError:
1141
+                self.err(
1142
+                    _msg.TranslatedString(
1143
+                        _msg.ErrMsgTemplate.NO_SSH_AGENT_FOUND
616 1144
                     ),
617
-    cls=cli_machinery.CompatibilityOption,
618 1145
                 )
619
-@cli_machinery.version_option(cli_machinery.vault_version_option_callback)
620
-@cli_machinery.color_forcing_pseudo_option
621
-@cli_machinery.standard_logging_options
622
-@click.argument(
623
-    'service',
624
-    metavar=_msg.TranslatedString(_msg.Label.VAULT_METAVAR_SERVICE),
625
-    required=False,
626
-    default=None,
627
-    shell_complete=cli_helpers.shell_complete_service,
1146
+            except LookupError:
1147
+                self.err(
1148
+                    _msg.TranslatedString(
1149
+                        _msg.ErrMsgTemplate.NO_SUITABLE_SSH_KEYS,
1150
+                        PROG_NAME=PROG_NAME,
628 1151
                     )
629
-@click.pass_context
630
-def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
631
-    ctx: click.Context,
632
-    /,
633
-    *,
634
-    service: str | None = None,
635
-    use_phrase: bool = False,
636
-    use_key: bool = False,
637
-    length: int | None = None,
638
-    repeat: int | None = None,
639
-    lower: int | None = None,
640
-    upper: int | None = None,
641
-    number: int | None = None,
642
-    space: int | None = None,
643
-    dash: int | None = None,
644
-    symbol: int | None = None,
645
-    edit_notes: bool = False,
646
-    store_config_only: bool = False,
647
-    delete_service_settings: bool = False,
648
-    delete_globals: bool = False,
649
-    clear_all_settings: bool = False,
650
-    export_settings: TextIO | os.PathLike[str] | None = None,
651
-    import_settings: TextIO | os.PathLike[str] | None = None,
652
-    overwrite_config: bool = False,
653
-    unset_settings: Sequence[str] = (),
654
-    export_as: Literal['json', 'sh'] = 'json',
655
-    modern_editor_interface: bool = False,
656
-    print_notes_before: bool = False,
657
-) -> None:
658
-    """Derive a passphrase using the vault(1) derivation scheme.
1152
+                )
1153
+            except NotImplementedError:
1154
+                self.err(_msg.TranslatedString(_msg.ErrMsgTemplate.NO_AF_UNIX))
1155
+            except OSError as exc:
1156
+                self.err(
1157
+                    _msg.TranslatedString(
1158
+                        _msg.ErrMsgTemplate.CANNOT_CONNECT_TO_AGENT,
1159
+                        error=exc.strerror,
1160
+                        filename=exc.filename,
1161
+                    ).maybe_without_filename(),
1162
+                )
1163
+            except ssh_agent.SSHAgentFailedError as exc:
1164
+                self.err(
1165
+                    _msg.TranslatedString(
1166
+                        _msg.ErrMsgTemplate.AGENT_REFUSED_LIST_KEYS
1167
+                    ),
1168
+                    exc_info=exc,
1169
+                )
1170
+            except RuntimeError as exc:
1171
+                self.err(
1172
+                    _msg.TranslatedString(
1173
+                        _msg.ErrMsgTemplate.CANNOT_UNDERSTAND_AGENT
1174
+                    ),
1175
+                    exc_info=exc,
1176
+                )
1177
+        elif use_phrase:
1178
+            maybe_phrase = cli_helpers.prompt_for_passphrase()
1179
+            if not maybe_phrase:
1180
+                self.err(
1181
+                    _msg.TranslatedString(
1182
+                        _msg.ErrMsgTemplate.USER_ABORTED_PASSPHRASE
1183
+                    )
1184
+                )
1185
+            else:
1186
+                settings.maps[0]['phrase'] = maybe_phrase
1187
+        return settings
659 1188
 
660
-    This is a [`click`][CLICK]-powered command-line interface function,
661
-    and not intended for programmatic use.  See the
662
-    derivepassphrase-vault(1) manpage for full documentation of the
663
-    interface.  (See also [`click.testing.CliRunner`][] for controlled,
664
-    programmatic invocation.)
665
-
666
-    [CLICK]: https://pypi.org/package/click/
1189
+    def run_op_store_config_only(self) -> None:  # noqa: C901,PLR0912,PLR0914,PLR0915
1190
+        """Update the stored vault configuration.
667 1191
 
668
-    Parameters:
669
-        ctx (click.Context):
670
-            The `click` context.
1192
+        Depending on the presence or the absence of a service name,
1193
+        update either the service-specific settings, or the global
1194
+        settings.  (An empty service name is respected, i.e., updates
1195
+        the former.)
671 1196
 
672
-    Other Parameters:
673
-        service:
674
-            A service name.  Required, unless operating on global
675
-            settings or importing/exporting settings.
676
-        use_phrase:
677
-            Command-line argument `-p`/`--phrase`.  If given, query the
678
-            user for a passphrase instead of an SSH key.
679
-        use_key:
680
-            Command-line argument `-k`/`--key`.  If given, query the
681
-            user for an SSH key instead of a passphrase.
682
-        length:
683
-            Command-line argument `-l`/`--length`.  Override the default
684
-            length of the generated passphrase.
685
-        repeat:
686
-            Command-line argument `-r`/`--repeat`.  Override the default
687
-            repetition limit if positive, or disable the repetition
688
-            limit if 0.
689
-        lower:
690
-            Command-line argument `--lower`.  Require a given amount of
691
-            ASCII lowercase characters if positive, else forbid ASCII
692
-            lowercase characters if 0.
693
-        upper:
694
-            Command-line argument `--upper`.  Same as `lower`, but for
695
-            ASCII uppercase characters.
696
-        number:
697
-            Command-line argument `--number`.  Same as `lower`, but for
698
-            ASCII digits.
699
-        space:
700
-            Command-line argument `--space`.  Same as `lower`, but for
701
-            the space character.
702
-        dash:
703
-            Command-line argument `--dash`.  Same as `lower`, but for
704
-            the hyphen-minus and underscore characters.
705
-        symbol:
706
-            Command-line argument `--symbol`.  Same as `lower`, but for
707
-            all other ASCII printable characters except lowercase
708
-            characters, uppercase characters, digits, space and
709
-            backquote.
710
-        edit_notes:
711
-            Command-line argument `-n`/`--notes`.  If given, spawn an
712
-            editor to edit notes for `service`.
713
-        store_config_only:
714
-            Command-line argument `-c`/`--config`.  If given, saves the
715
-            other given settings (`--key`, ..., `--symbol`) to the
716
-            configuration file, either specifically for `service` or as
717
-            global settings.
718
-        delete_service_settings:
719
-            Command-line argument `-x`/`--delete`.  If given, removes
720
-            the settings for `service` from the configuration file.
721
-        delete_globals:
722
-            Command-line argument `--delete-globals`.  If given, removes
723
-            the global settings from the configuration file.
724
-        clear_all_settings:
725
-            Command-line argument `-X`/`--clear`.  If given, removes all
726
-            settings from the configuration file.
727
-        export_settings:
728
-            Command-line argument `-e`/`--export`.  If a file object,
729
-            then it must be open for writing and accept `str` inputs.
730
-            Otherwise, a filename to open for writing.  Using `-` for
731
-            standard output is supported.
732
-        import_settings:
733
-            Command-line argument `-i`/`--import`.  If a file object, it
734
-            must be open for reading and yield `str` values.  Otherwise,
735
-            a filename to open for reading.  Using `-` for standard
736
-            input is supported.
737
-        overwrite_config:
738
-            Command-line arguments `--overwrite-existing` (True) and
739
-            `--merge-existing` (False).  Controls whether config saving
740
-            and config importing overwrite existing configurations, or
741
-            merge them section-wise instead.
742
-        unset_settings:
743
-            Command-line argument `--unset`.  If given together with
744
-            `--config`, unsets the specified settings (in addition to
745
-            any other changes requested).
746
-        export_as:
747
-            Command-line argument `--export-as`.  If given together with
748
-            `--export`, selects the format to export the current
749
-            configuration as: JSON ("json", default) or POSIX sh ("sh").
750
-        modern_editor_interface:
751
-            Command-line arguments `--modern-editor-interface` (True)
752
-            and `--vault-legacy-editor-interface` (False).  Controls
753
-            whether editing notes uses a modern editor interface
754
-            (supporting comments and aborting) or a vault(1)-compatible
755
-            legacy editor interface (WYSIWYG notes contents).
756
-        print_notes_before:
757
-            Command-line arguments `--print-notes-before` (True) and
758
-            `--print-notes-after` (False).  Controls whether the service
759
-            notes (if any) are printed before the passphrase, or after.
1197
+        Respect the `--unset=SETTING` option to unset the named
1198
+        settings, and the `--notes` option to edit notes interactively
1199
+        in a spawned text editor.  Issue multiple warnings, if
1200
+        appropriate, e.g. for Unicode normalization issues with stored
1201
+        passphrases or conflicting stored passphrases and keys.  Respect
1202
+        the `--overwrite-config` and `--merge-config` options when
1203
+        writing the imported configuration to disk, and the
1204
+        `--modern-editor-interface` and
1205
+        `--vault-legacy-editor-interface` options when editing notes.
760 1206
 
761
-    """  # noqa: DOC501
762
-    logger = logging.getLogger(PROG_NAME)
763
-    deprecation = logging.getLogger(PROG_NAME + '.deprecation')
764
-    service_metavar = _msg.TranslatedString(_msg.Label.VAULT_METAVAR_SERVICE)
765
-    options_in_group: dict[type[click.Option], list[click.Option]] = {}
766
-    params_by_str: dict[str, click.Parameter] = {}
767
-    for param in ctx.command.params:
768
-        if isinstance(param, click.Option):
769
-            group: type[click.Option]
770
-            known_option_groups = [
771
-                cli_machinery.PassphraseGenerationOption,
772
-                cli_machinery.ConfigurationOption,
773
-                cli_machinery.StorageManagementOption,
774
-                cli_machinery.LoggingOption,
775
-                cli_machinery.CompatibilityOption,
776
-                cli_machinery.StandardOption,
777
-            ]
778
-            if isinstance(param, cli_machinery.OptionGroupOption):
779
-                for class_ in known_option_groups:
780
-                    if isinstance(param, class_):
781
-                        group = class_
782
-                        break
783
-                else:  # pragma: no cover
784
-                    raise AssertionError(  # noqa: TRY003
785
-                        f'Unknown option group for {param!r}'  # noqa: EM102
786
-                    )
787
-            else:
788
-                group = click.Option
789
-            options_in_group.setdefault(group, []).append(param)
790
-        params_by_str[param.human_readable_name] = param
791
-        for name in param.opts + param.secondary_opts:
792
-            params_by_str[name] = param
793
-
794
-    @functools.cache
795
-    def is_param_set(param: click.Parameter) -> bool:
796
-        return bool(ctx.params.get(param.human_readable_name))
797
-
798
-    def option_name(param: click.Parameter | str) -> str:
799
-        # Annoyingly, `param.human_readable_name` contains the *function*
800
-        # parameter name, not the list of option names.  *Those* are
801
-        # stashed in the `.opts` and `.secondary_opts` attributes, which
802
-        # are visible in the `.to_info_dict()` output, but not otherwise
803
-        # documented.
804
-        param = params_by_str[param] if isinstance(param, str) else param
805
-        names = [param.human_readable_name, *param.opts, *param.secondary_opts]
806
-        option_names = [n for n in names if n.startswith('--')]
807
-        return min(option_names, key=len)
1207
+        Raises:
1208
+            click.UsageError:
1209
+                The user requested that the same setting be both unset
1210
+                and set.
808 1211
 
809
-    def check_incompatible_options(
810
-        param1: click.Parameter | str,
811
-        param2: click.Parameter | str,
812
-    ) -> None:
813
-        param1 = params_by_str[param1] if isinstance(param1, str) else param1
814
-        param2 = params_by_str[param2] if isinstance(param2, str) else param2
815
-        if param1 == param2:
816
-            return
817
-        if not is_param_set(param1):
818
-            return
819
-        if is_param_set(param2):
820
-            param1_str = option_name(param1)
821
-            param2_str = option_name(param2)
822
-            raise click.BadOptionUsage(
823
-                param1_str,
824
-                str(
825
-                    _msg.TranslatedString(
826
-                        _msg.ErrMsgTemplate.PARAMS_MUTUALLY_EXCLUSIVE,
827
-                        param1=param1_str,
828
-                        param2=param2_str,
829
-                    )
830
-                ),
831
-                ctx=ctx,
1212
+        """
1213
+        service = self.ctx.params['service']
1214
+        use_key = self.ctx.params['use_key']
1215
+        use_phrase = self.ctx.params['use_phrase']
1216
+        unset_settings = self.ctx.params['unset_settings']
1217
+        overwrite_config = self.ctx.params['overwrite_config']
1218
+        edit_notes = self.ctx.params['edit_notes']
1219
+        modern_editor_interface = self.ctx.params['modern_editor_interface']
1220
+        configuration = self.get_config()
1221
+        user_config = self.get_user_config()
1222
+        settings = self.run_subop_query_phrase_or_key_change(
1223
+            configuration=configuration, empty_service_permitted=True
1224
+        )
1225
+        overrides = settings.maps[0]
1226
+        view: collections.ChainMap[str, Any]
1227
+        view = (
1228
+            collections.ChainMap(*settings.maps[:2])
1229
+            if service
1230
+            else collections.ChainMap(settings.maps[0], settings.maps[2])
832 1231
         )
833
-        return
834
-
835
-    def err(msg: Any, /, **kwargs: Any) -> NoReturn:  # noqa: ANN401
836
-        stacklevel = kwargs.pop('stacklevel', 1)
837
-        stacklevel += 1
838
-        extra = kwargs.pop('extra', {})
839
-        extra.setdefault('color', ctx.color)
840
-        logger.error(msg, stacklevel=stacklevel, extra=extra, **kwargs)
841
-        ctx.exit(1)
842
-
843
-    def get_config() -> _types.VaultConfig:
844
-        try:
845
-            return cli_helpers.load_config()
846
-        except FileNotFoundError:
1232
+        if use_key:
1233
+            view['key'] = overrides['key']
1234
+        elif use_phrase:
1235
+            view['phrase'] = overrides['phrase']
847 1236
             try:
848
-                backup_config, exc = cli_helpers.migrate_and_load_old_config()
849
-            except FileNotFoundError:
850
-                return {'services': {}}
851
-            old_name = cli_helpers.config_filename(
852
-                subsystem='old settings.json'
853
-            ).name
854
-            new_name = cli_helpers.config_filename(subsystem='vault').name
855
-            deprecation.warning(
856
-                _msg.TranslatedString(
857
-                    _msg.WarnMsgTemplate.V01_STYLE_CONFIG,
858
-                    old=old_name,
859
-                    new=new_name,
860
-                ),
861
-                extra={'color': ctx.color},
1237
+                cli_helpers.check_for_misleading_passphrase(
1238
+                    ('services', service) if service else ('global',),
1239
+                    overrides,
1240
+                    main_config=user_config,
1241
+                    ctx=self.ctx,
862 1242
                 )
863
-            if isinstance(exc, OSError):
864
-                logger.warning(
1243
+            except AssertionError as exc:
1244
+                self.err(
865 1245
                     _msg.TranslatedString(
866
-                        _msg.WarnMsgTemplate.FAILED_TO_MIGRATE_CONFIG,
867
-                        path=new_name,
868
-                        error=exc.strerror,
869
-                        filename=exc.filename,
1246
+                        _msg.ErrMsgTemplate.INVALID_USER_CONFIG,
1247
+                        error=exc,
1248
+                        filename=None,
870 1249
                     ).maybe_without_filename(),
871
-                    extra={'color': ctx.color},
1250
+                )
1251
+            if 'key' in settings:
1252
+                if service:
1253
+                    w_msg = _msg.TranslatedString(
1254
+                        _msg.WarnMsgTemplate.SERVICE_PASSPHRASE_INEFFECTIVE,
1255
+                        service=json.dumps(service),
872 1256
                     )
873 1257
                 else:
874
-                deprecation.info(
875
-                    _msg.TranslatedString(
876
-                        _msg.InfoMsgTemplate.SUCCESSFULLY_MIGRATED,
877
-                        path=new_name,
1258
+                    w_msg = _msg.TranslatedString(
1259
+                        _msg.WarnMsgTemplate.GLOBAL_PASSPHRASE_INEFFECTIVE
1260
+                    )
1261
+                self.warning(w_msg)
1262
+        if not view.maps[0] and not unset_settings and not edit_notes:
1263
+            err_msg = _msg.TranslatedString(
1264
+                _msg.ErrMsgTemplate.CANNOT_UPDATE_SETTINGS_NO_SETTINGS,
1265
+                settings_type=_msg.TranslatedString(
1266
+                    _msg.Label.CANNOT_UPDATE_SETTINGS_METAVAR_SETTINGS_TYPE_SERVICE
1267
+                    if service
1268
+                    else _msg.Label.CANNOT_UPDATE_SETTINGS_METAVAR_SETTINGS_TYPE_GLOBAL  # noqa: E501
878 1269
                 ),
879
-                    extra={'color': ctx.color},
880 1270
             )
881
-            return backup_config
882
-        except OSError as exc:
883
-            err(
884
-                _msg.TranslatedString(
885
-                    _msg.ErrMsgTemplate.CANNOT_LOAD_VAULT_SETTINGS,
886
-                    error=exc.strerror,
887
-                    filename=exc.filename,
888
-                ).maybe_without_filename(),
1271
+            raise click.UsageError(str(err_msg))
1272
+        for setting in unset_settings:
1273
+            if setting in view.maps[0]:
1274
+                err_msg = _msg.TranslatedString(
1275
+                    _msg.ErrMsgTemplate.SET_AND_UNSET_SAME_SETTING,
1276
+                    setting=setting,
889 1277
                 )
890
-        except Exception as exc:  # noqa: BLE001
891
-            err(
1278
+                raise click.UsageError(str(err_msg))
1279
+        if not cli_helpers.is_completable_item(service):
1280
+            self.warning(
892 1281
                 _msg.TranslatedString(
893
-                    _msg.ErrMsgTemplate.CANNOT_LOAD_VAULT_SETTINGS,
894
-                    error=str(exc),
895
-                    filename=None,
896
-                ).maybe_without_filename(),
897
-                exc_info=exc,
1282
+                    _msg.WarnMsgTemplate.SERVICE_NAME_INCOMPLETABLE,
1283
+                    service=service,
1284
+                ),
898 1285
             )
899
-
900
-    def put_config(config: _types.VaultConfig, /) -> None:
901
-        try:
902
-            cli_helpers.save_config(config)
903
-        except OSError as exc:
904
-            err(
905
-                _msg.TranslatedString(
906
-                    _msg.ErrMsgTemplate.CANNOT_STORE_VAULT_SETTINGS,
907
-                    error=exc.strerror,
908
-                    filename=exc.filename,
909
-                ).maybe_without_filename(),
1286
+        subtree: dict[str, Any] = (
1287
+            configuration['services'].setdefault(service, {})  # type: ignore[assignment]
1288
+            if service
1289
+            else configuration.setdefault('global', {})
910 1290
         )
911
-        except Exception as exc:  # noqa: BLE001
912
-            err(
913
-                _msg.TranslatedString(
914
-                    _msg.ErrMsgTemplate.CANNOT_STORE_VAULT_SETTINGS,
915
-                    error=str(exc),
916
-                    filename=None,
917
-                ).maybe_without_filename(),
918
-                exc_info=exc,
1291
+        if overwrite_config:
1292
+            subtree.clear()
1293
+        else:
1294
+            for setting in unset_settings:
1295
+                subtree.pop(setting, None)
1296
+        subtree.update(view)
1297
+        assert _types.is_vault_config(configuration), (
1298
+            f'Invalid vault configuration: {configuration!r}'
919 1299
         )
920
-
921
-    def get_user_config() -> dict[str, Any]:
922
-        try:
923
-            return cli_helpers.load_user_config()
924
-        except FileNotFoundError:
925
-            return {}
926
-        except OSError as exc:
927
-            err(
928
-                _msg.TranslatedString(
929
-                    _msg.ErrMsgTemplate.CANNOT_LOAD_USER_CONFIG,
930
-                    error=exc.strerror,
931
-                    filename=exc.filename,
932
-                ).maybe_without_filename(),
933
-            )
934
-        except Exception as exc:  # noqa: BLE001
935
-            err(
936
-                _msg.TranslatedString(
937
-                    _msg.ErrMsgTemplate.CANNOT_LOAD_USER_CONFIG,
938
-                    error=str(exc),
939
-                    filename=None,
940
-                ).maybe_without_filename(),
941
-                exc_info=exc,
942
-            )
943
-
944
-    configuration: _types.VaultConfig
945
-
946
-    check_incompatible_options('--phrase', '--key')
947
-    for group in (
948
-        cli_machinery.ConfigurationOption,
949
-        cli_machinery.StorageManagementOption,
950
-    ):
951
-        for opt in options_in_group[group]:
952
-            if opt not in {
953
-                params_by_str['--config'],
954
-                params_by_str['--notes'],
955
-            }:
956
-                for other_opt in options_in_group[
957
-                    cli_machinery.PassphraseGenerationOption
958
-                ]:
959
-                    check_incompatible_options(opt, other_opt)
960
-
961
-    for group in (
962
-        cli_machinery.ConfigurationOption,
963
-        cli_machinery.StorageManagementOption,
964
-    ):
965
-        for opt in options_in_group[group]:
966
-            for other_opt in options_in_group[
967
-                cli_machinery.ConfigurationOption
968
-            ]:
969
-                if {opt, other_opt} != {
970
-                    params_by_str['--config'],
971
-                    params_by_str['--notes'],
972
-                }:
973
-                    check_incompatible_options(opt, other_opt)
974
-            for other_opt in options_in_group[
975
-                cli_machinery.StorageManagementOption
976
-            ]:
977
-                check_incompatible_options(opt, other_opt)
978
-    sv_or_global_options = options_in_group[
979
-        cli_machinery.PassphraseGenerationOption
980
-    ]
981
-    for param in sv_or_global_options:
982
-        if is_param_set(param) and not (
983
-            service is not None or is_param_set(params_by_str['--config'])
984
-        ):
985
-            err_msg = _msg.TranslatedString(
986
-                _msg.ErrMsgTemplate.PARAMS_NEEDS_SERVICE_OR_CONFIG,
987
-                param=param.opts[0],
988
-                service_metavar=service_metavar,
989
-            )
990
-            raise click.UsageError(str(err_msg))
991
-    sv_options = [params_by_str['--notes'], params_by_str['--delete']]
992
-    for param in sv_options:
993
-        if is_param_set(param) and not service is not None:
994
-            err_msg = _msg.TranslatedString(
995
-                _msg.ErrMsgTemplate.PARAMS_NEEDS_SERVICE,
996
-                param=param.opts[0],
997
-                service_metavar=service_metavar,
998
-            )
999
-            raise click.UsageError(str(err_msg))
1000
-    no_sv_options = [
1001
-        params_by_str['--delete-globals'],
1002
-        params_by_str['--clear'],
1003
-        *options_in_group[cli_machinery.StorageManagementOption],
1004
-    ]
1005
-    for param in no_sv_options:
1006
-        if is_param_set(param) and service is not None:
1007
-            err_msg = _msg.TranslatedString(
1008
-                _msg.ErrMsgTemplate.PARAMS_NO_SERVICE,
1009
-                param=param.opts[0],
1010
-                service_metavar=service_metavar,
1011
-            )
1012
-            raise click.UsageError(str(err_msg))
1013
-
1014
-    user_config = get_user_config()
1015
-
1016
-    if service == '':  # noqa: PLC1901
1017
-        logger.warning(
1018
-            _msg.TranslatedString(
1019
-                _msg.WarnMsgTemplate.EMPTY_SERVICE_NOT_SUPPORTED,
1020
-                service_metavar=service_metavar,
1021
-            ),
1022
-            extra={'color': ctx.color},
1023
-        )
1024
-
1025
-    if edit_notes and not store_config_only:
1026
-        logger.warning(
1027
-            _msg.TranslatedString(
1028
-                _msg.WarnMsgTemplate.EDITING_NOTES_BUT_NOT_STORING_CONFIG,
1029
-                service_metavar=service_metavar,
1030
-            ),
1031
-            extra={'color': ctx.color},
1032
-        )
1033
-
1034
-    readwrite_ops = [
1035
-        delete_service_settings,
1036
-        delete_globals,
1037
-        clear_all_settings,
1038
-        import_settings,
1039
-        store_config_only,
1040
-    ]
1041
-    mutex: Callable[[], contextlib.AbstractContextManager[None]] = (
1042
-        cli_helpers.configuration_mutex
1043
-        if any(readwrite_ops)
1044
-        else contextlib.nullcontext
1045
-    )
1046
-
1047
-    with mutex():  # noqa: PLR1702
1048
-        if delete_service_settings:
1300
+        if edit_notes:
1049 1301
             assert service is not None
1050
-            configuration = get_config()
1051
-            if service in configuration['services']:
1052
-                del configuration['services'][service]
1053
-                put_config(configuration)
1054
-        elif delete_globals:
1055
-            configuration = get_config()
1056
-            if 'global' in configuration:
1057
-                del configuration['global']
1058
-                put_config(configuration)
1059
-        elif clear_all_settings:
1060
-            put_config({'services': {}})
1061
-        elif import_settings:
1062
-            try:
1063
-                # TODO(the-13th-letter): keep track of auto-close; try
1064
-                # os.dup if feasible
1065
-                infile = cast(
1066
-                    'TextIO',
1067
-                    (
1068
-                        import_settings
1069
-                        if hasattr(import_settings, 'close')
1070
-                        else click.open_file(os.fspath(import_settings), 'rt')
1071
-                    ),
1302
+            notes_instructions = _msg.TranslatedString(
1303
+                _msg.Label.DERIVEPASSPHRASE_VAULT_NOTES_INSTRUCTION_TEXT
1072 1304
             )
1073
-                # Don't specifically catch TypeError or ValueError here if
1074
-                # the passed-in fileobj is not a readable text stream.  This
1075
-                # will never happen on the command-line (thanks to `click`),
1076
-                # and for programmatic use, our caller may want accurate
1077
-                # error information.
1078
-                with infile:
1079
-                    maybe_config = json.load(infile)
1080
-            except json.JSONDecodeError as exc:
1081
-                err(
1082
-                    _msg.TranslatedString(
1083
-                        _msg.ErrMsgTemplate.CANNOT_DECODEIMPORT_VAULT_SETTINGS,
1084
-                        error=exc,
1305
+            notes_marker = _msg.TranslatedString(
1306
+                _msg.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER
1085 1307
             )
1308
+            notes_legacy_instructions = _msg.TranslatedString(
1309
+                _msg.Label.DERIVEPASSPHRASE_VAULT_NOTES_LEGACY_INSTRUCTION_TEXT
1086 1310
             )
1087
-            except OSError as exc:
1088
-                err(
1089
-                    _msg.TranslatedString(
1090
-                        _msg.ErrMsgTemplate.CANNOT_IMPORT_VAULT_SETTINGS,
1091
-                        error=exc.strerror,
1092
-                        filename=exc.filename,
1093
-                    ).maybe_without_filename()
1311
+            old_notes_value = subtree.get('notes', '')
1312
+            if modern_editor_interface:
1313
+                text = '\n'.join([
1314
+                    str(notes_instructions),
1315
+                    str(notes_marker),
1316
+                    old_notes_value,
1317
+                ])
1318
+            else:
1319
+                text = old_notes_value or str(notes_legacy_instructions)
1320
+            notes_value = click.edit(text=text, require_save=False)
1321
+            assert notes_value is not None
1322
+            if (
1323
+                not modern_editor_interface
1324
+                and notes_value.strip() != old_notes_value.strip()
1325
+            ):
1326
+                backup_file = cli_helpers.config_filename(
1327
+                    subsystem='notes backup'
1094 1328
                 )
1095
-            cleaned = _types.clean_up_falsy_vault_config_values(maybe_config)
1096
-            if not _types.is_vault_config(maybe_config):
1097
-                err(
1329
+                backup_file.write_text(old_notes_value, encoding='UTF-8')
1330
+                self.warning(
1098 1331
                     _msg.TranslatedString(
1099
-                        _msg.ErrMsgTemplate.CANNOT_IMPORT_VAULT_SETTINGS,
1100
-                        error=_msg.TranslatedString(
1101
-                            _msg.ErrMsgTemplate.INVALID_VAULT_CONFIG,
1102
-                            config=maybe_config,
1332
+                        _msg.WarnMsgTemplate.LEGACY_EDITOR_INTERFACE_NOTES_BACKUP,
1333
+                        filename=str(backup_file),
1103 1334
                     ),
1104
-                        filename=None,
1105
-                    ).maybe_without_filename()
1106 1335
                 )
1107
-            assert cleaned is not None
1108
-            for step in cleaned:
1109
-                # These are never fatal errors, because the semantics of
1110
-                # vault upon encountering these settings are ill-specified,
1111
-                # but not ill-defined.
1112
-                if step.action == 'replace':
1113
-                    logger.warning(
1114
-                        _msg.TranslatedString(
1115
-                            _msg.WarnMsgTemplate.STEP_REPLACE_INVALID_VALUE,
1116
-                            old=json.dumps(step.old_value),
1117
-                            path=_types.json_path(step.path),
1118
-                            new=json.dumps(step.new_value),
1119
-                        ),
1120
-                        extra={'color': ctx.color},
1336
+                subtree['notes'] = notes_value.strip()
1337
+            elif (
1338
+                modern_editor_interface and notes_value.strip() != text.strip()
1339
+            ):
1340
+                notes_lines = collections.deque(
1341
+                    notes_value.splitlines(keepends=True)
1121 1342
                 )
1343
+                while notes_lines:
1344
+                    line = notes_lines.popleft()
1345
+                    if line.startswith(str(notes_marker)):
1346
+                        notes_value = ''.join(notes_lines)
1347
+                        break
1122 1348
                 else:
1123
-                    logger.warning(
1349
+                    if not notes_value.strip():
1350
+                        self.err(
1124 1351
                             _msg.TranslatedString(
1125
-                            _msg.WarnMsgTemplate.STEP_REMOVE_INEFFECTIVE_VALUE,
1126
-                            path=_types.json_path(step.path),
1127
-                            old=json.dumps(step.old_value),
1128
-                        ),
1129
-                        extra={'color': ctx.color},
1352
+                                _msg.ErrMsgTemplate.USER_ABORTED_EDIT
1130 1353
                             )
1131
-            if '' in maybe_config['services']:
1132
-                logger.warning(
1133
-                    _msg.TranslatedString(
1134
-                        _msg.WarnMsgTemplate.EMPTY_SERVICE_SETTINGS_INACCESSIBLE,
1135
-                        service_metavar=service_metavar,
1136
-                        PROG_NAME=PROG_NAME,
1137
-                    ),
1138
-                    extra={'color': ctx.color},
1139 1354
                         )
1140
-            for service_name in sorted(maybe_config['services'].keys()):
1141
-                if not cli_helpers.is_completable_item(service_name):
1142
-                    logger.warning(
1143
-                        _msg.TranslatedString(
1144
-                            _msg.WarnMsgTemplate.SERVICE_NAME_INCOMPLETABLE,
1145
-                            service=service_name,
1146
-                        ),
1147
-                        extra={'color': ctx.color},
1355
+                subtree['notes'] = notes_value.strip()
1356
+        self.put_config(configuration)
1357
+
1358
+    def run_op_derive_passphrase(self) -> None:
1359
+        """Derive a passphrase.
1360
+
1361
+        Derive a service passphrase using the effective settings from
1362
+        both the command-line and the stored configuration.  If any
1363
+        service notes are stored for this service, print them as well.
1364
+
1365
+        (An empty service name is permitted, though discouraged for
1366
+        compatibility reasons.)
1367
+
1368
+        Issue a warning (if appropriate) for Unicode normalization
1369
+        issues with the interactive passphrase.  Respect the
1370
+        `--print-notes-before` and `--print-notes-after` options when
1371
+        printing notes.
1372
+
1373
+        Raises:
1374
+            click.UsageError:
1375
+                No master passphrase or master SSH key was given on both
1376
+                the command-line and in the vault configuration on disk.
1377
+        """
1378
+        service = self.ctx.params['service']
1379
+        use_key = self.ctx.params['use_key']
1380
+        use_phrase = self.ctx.params['use_phrase']
1381
+        print_notes_before = self.ctx.params['print_notes_before']
1382
+        user_config = self.get_user_config()
1383
+        settings = self.run_subop_query_phrase_or_key_change(
1384
+            empty_service_permitted=False
1148 1385
         )
1386
+        if use_phrase:
1149 1387
             try:
1150 1388
                 cli_helpers.check_for_misleading_passphrase(
1151
-                    ('global',),
1152
-                    cast('dict[str, Any]', maybe_config.get('global', {})),
1153
-                    main_config=user_config,
1154
-                    ctx=ctx,
1155
-                )
1156
-                for key, value in maybe_config['services'].items():
1157
-                    cli_helpers.check_for_misleading_passphrase(
1158
-                        ('services', key),
1159
-                        cast('dict[str, Any]', value),
1389
+                    cli_helpers.ORIGIN.INTERACTIVE,
1390
+                    {'phrase': settings['phrase']},
1160 1391
                     main_config=user_config,
1161
-                        ctx=ctx,
1392
+                    ctx=self.ctx,
1162 1393
                 )
1163 1394
             except AssertionError as exc:
1164
-                err(
1395
+                self.err(
1165 1396
                     _msg.TranslatedString(
1166 1397
                         _msg.ErrMsgTemplate.INVALID_USER_CONFIG,
1167 1398
                         error=exc,
1168 1399
                         filename=None,
1169 1400
                     ).maybe_without_filename(),
1170 1401
                 )
1171
-            global_obj = maybe_config.get('global', {})
1172
-            has_key = _types.js_truthiness(global_obj.get('key'))
1173
-            has_phrase = _types.js_truthiness(global_obj.get('phrase'))
1174
-            if has_key and has_phrase:
1175
-                logger.warning(
1176
-                    _msg.TranslatedString(
1177
-                        _msg.WarnMsgTemplate.GLOBAL_PASSPHRASE_INEFFECTIVE,
1178
-                    ),
1179
-                    extra={'color': ctx.color},
1402
+        phrase: str | bytes
1403
+        overrides = cast('dict[str, int | str]', settings.maps[0])
1404
+        # If either --key or --phrase are given, use that setting.
1405
+        # Otherwise, if both key and phrase are set in the config,
1406
+        # use the key.  Otherwise, if only one of key and phrase is
1407
+        # set in the config, use that one.  In all these above
1408
+        # cases, set the phrase via vault.Vault.phrase_from_key if
1409
+        # a key is given.  Finally, if nothing is set, error out.
1410
+        if use_key:
1411
+            phrase = cli_helpers.key_to_phrase(
1412
+                cast('str', overrides['key']), error_callback=self.err
1180 1413
             )
1181
-            for service_name, service_obj in maybe_config['services'].items():
1182
-                has_key = _types.js_truthiness(
1183
-                    service_obj.get('key')
1184
-                ) or _types.js_truthiness(global_obj.get('key'))
1185
-                has_phrase = _types.js_truthiness(
1186
-                    service_obj.get('phrase')
1187
-                ) or _types.js_truthiness(global_obj.get('phrase'))
1188
-                if has_key and has_phrase:
1189
-                    logger.warning(
1190
-                        _msg.TranslatedString(
1191
-                            _msg.WarnMsgTemplate.SERVICE_PASSPHRASE_INEFFECTIVE,
1192
-                            service=json.dumps(service_name),
1193
-                        ),
1194
-                        extra={'color': ctx.color},
1414
+        elif use_phrase:
1415
+            phrase = cast('str', overrides['phrase'])
1416
+        elif settings.get('key'):
1417
+            phrase = cli_helpers.key_to_phrase(
1418
+                cast('str', settings['key']), error_callback=self.err
1195 1419
             )
1196
-            if overwrite_config:
1197
-                put_config(maybe_config)
1420
+        elif settings.get('phrase'):
1421
+            phrase = cast('str', settings['phrase'])
1198 1422
         else:
1199
-                configuration = get_config()
1200
-                merged_config: collections.ChainMap[str, Any] = (
1201
-                    collections.ChainMap(
1202
-                        {
1203
-                            'services': collections.ChainMap(
1204
-                                maybe_config['services'],
1205
-                                configuration['services'],
1206
-                            ),
1207
-                        },
1208
-                        {'global': maybe_config['global']}
1209
-                        if 'global' in maybe_config
1210
-                        else {},
1211
-                        {'global': configuration['global']}
1212
-                        if 'global' in configuration
1213
-                        else {},
1214
-                    )
1423
+            err_msg = _msg.TranslatedString(
1424
+                _msg.ErrMsgTemplate.NO_KEY_OR_PHRASE
1215 1425
             )
1216
-                new_config: Any = {
1217
-                    k: dict(v) if isinstance(v, collections.ChainMap) else v
1218
-                    for k, v in sorted(merged_config.items())
1219
-                }
1220
-                assert _types.is_vault_config(new_config)
1221
-                put_config(new_config)
1222
-        elif export_settings:
1223
-            configuration = get_config()
1224
-            try:
1225
-                # TODO(the-13th-letter): keep track of auto-close; try
1226
-                # os.dup if feasible
1227
-                outfile = cast(
1228
-                    'TextIO',
1229
-                    (
1230
-                        export_settings
1231
-                        if hasattr(export_settings, 'close')
1232
-                        else click.open_file(os.fspath(export_settings), 'wt')
1233
-                    ),
1234
-                )
1235
-                # Don't specifically catch TypeError or ValueError here if
1236
-                # the passed-in fileobj is not a writable text stream.  This
1237
-                # will never happen on the command-line (thanks to `click`),
1238
-                # and for programmatic use, our caller may want accurate
1239
-                # error information.
1240
-                with outfile:
1241
-                    if export_as == 'sh':
1242
-                        this_ctx = ctx
1243
-                        prog_name_pieces = collections.deque([
1244
-                            this_ctx.info_name or 'vault',
1245
-                        ])
1246
-                        while (
1247
-                            this_ctx.parent is not None
1248
-                            and this_ctx.parent.info_name is not None
1249
-                        ):
1250
-                            prog_name_pieces.appendleft(
1251
-                                this_ctx.parent.info_name
1252
-                            )
1253
-                            this_ctx = this_ctx.parent
1254
-                        cli_helpers.print_config_as_sh_script(
1255
-                            configuration,
1256
-                            outfile=outfile,
1257
-                            prog_name_list=prog_name_pieces,
1258
-                        )
1259
-                    else:
1260
-                        json.dump(
1261
-                            configuration,
1262
-                            outfile,
1263
-                            ensure_ascii=False,
1264
-                            indent=2,
1265
-                            sort_keys=True,
1266
-                        )
1267
-            except OSError as exc:
1268
-                err(
1269
-                    _msg.TranslatedString(
1270
-                        _msg.ErrMsgTemplate.CANNOT_EXPORT_VAULT_SETTINGS,
1271
-                        error=exc.strerror,
1272
-                        filename=exc.filename,
1273
-                    ).maybe_without_filename(),
1274
-                )
1275
-        else:
1276
-            configuration = get_config()
1277
-            # This block could be type checked more stringently, but this
1278
-            # would probably involve a lot of code repetition.  Since we
1279
-            # have a type guarding function anyway, assert that we didn't
1280
-            # make any mistakes at the end instead.
1281
-            global_keys = {'key', 'phrase'}
1282
-            service_keys = {
1283
-                'key',
1284
-                'phrase',
1426
+            raise click.UsageError(str(err_msg))
1427
+        overrides.pop('key', '')
1428
+        overrides.pop('phrase', '')
1429
+        assert service is not None
1430
+        vault_service_keys = {
1285 1431
             'length',
1286 1432
             'repeat',
1287 1433
             'lower',
... ...
@@ -1291,283 +1437,442 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1291 1437
             'dash',
1292 1438
             'symbol',
1293 1439
         }
1294
-            settings: collections.ChainMap[str, Any] = collections.ChainMap(
1295
-                {
1296
-                    k: v
1297
-                    for k, v in locals().items()
1298
-                    if k in service_keys and v is not None
1299
-                },
1300
-                cast(
1301
-                    'dict[str, Any]',
1302
-                    configuration['services'].get(service, {})
1303
-                    if service
1304
-                    else {},
1305
-                ),
1306
-                cast('dict[str, Any]', configuration.get('global', {})),
1307
-            )
1308
-            if not store_config_only and not service:
1309
-                err_msg = _msg.TranslatedString(
1310
-                    _msg.ErrMsgTemplate.SERVICE_REQUIRED,
1440
+        kwargs = {
1441
+            cast('str', k): cast('int', settings[k])
1442
+            for k in vault_service_keys
1443
+            if k in settings
1444
+        }
1445
+        result = vault.Vault(phrase=phrase, **kwargs).generate(service)
1446
+        service_notes = cast('str', settings.get('notes', '')).strip()
1447
+        if print_notes_before and service_notes.strip():
1448
+            click.echo(f'{service_notes}\n', err=True, color=self.ctx.color)
1449
+        click.echo(result.decode('ASCII'), color=self.ctx.color)
1450
+        if not print_notes_before and service_notes.strip():
1451
+            click.echo(f'\n{service_notes}\n', err=True, color=self.ctx.color)
1452
+
1453
+
1454
+@derivepassphrase.command(
1455
+    'vault',
1456
+    context_settings={'help_option_names': ['-h', '--help']},
1457
+    cls=cli_machinery.CommandWithHelpGroups,
1458
+    help=(
1459
+        _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_VAULT_01),
1460
+        _msg.TranslatedString(
1461
+            _msg.Label.DERIVEPASSPHRASE_VAULT_02,
1311 1462
             service_metavar=_msg.TranslatedString(
1312 1463
                 _msg.Label.VAULT_METAVAR_SERVICE
1313 1464
             ),
1465
+        ),
1466
+    ),
1467
+    epilog=(
1468
+        _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_VAULT_EPILOG_01),
1469
+        _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_VAULT_EPILOG_02),
1470
+    ),
1314 1471
 )
1315
-                raise click.UsageError(str(err_msg))
1316
-            if use_key:
1317
-                try:
1318
-                    key = base64.standard_b64encode(
1319
-                        cli_helpers.select_ssh_key(ctx=ctx)
1320
-                    ).decode('ASCII')
1321
-                except IndexError:
1322
-                    err(
1323
-                        _msg.TranslatedString(
1324
-                            _msg.ErrMsgTemplate.USER_ABORTED_SSH_KEY_SELECTION
1472
+@click.option(
1473
+    '-p',
1474
+    '--phrase',
1475
+    'use_phrase',
1476
+    is_flag=True,
1477
+    help=_msg.TranslatedString(
1478
+        _msg.Label.DERIVEPASSPHRASE_VAULT_PHRASE_HELP_TEXT
1325 1479
     ),
1480
+    cls=cli_machinery.PassphraseGenerationOption,
1326 1481
 )
1327
-                except KeyError:
1328
-                    err(
1329
-                        _msg.TranslatedString(
1330
-                            _msg.ErrMsgTemplate.NO_SSH_AGENT_FOUND
1482
+@click.option(
1483
+    '-k',
1484
+    '--key',
1485
+    'use_key',
1486
+    is_flag=True,
1487
+    help=_msg.TranslatedString(
1488
+        _msg.Label.DERIVEPASSPHRASE_VAULT_KEY_HELP_TEXT
1331 1489
     ),
1490
+    cls=cli_machinery.PassphraseGenerationOption,
1332 1491
 )
1333
-                except LookupError:
1334
-                    err(
1335
-                        _msg.TranslatedString(
1336
-                            _msg.ErrMsgTemplate.NO_SUITABLE_SSH_KEYS,
1337
-                            PROG_NAME=PROG_NAME,
1492
+@click.option(
1493
+    '-l',
1494
+    '--length',
1495
+    metavar=_msg.TranslatedString(
1496
+        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
1497
+    ),
1498
+    callback=cli_machinery.validate_length,
1499
+    help=_msg.TranslatedString(
1500
+        _msg.Label.DERIVEPASSPHRASE_VAULT_LENGTH_HELP_TEXT,
1501
+        metavar=_msg.TranslatedString(
1502
+            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
1503
+        ),
1504
+    ),
1505
+    cls=cli_machinery.PassphraseGenerationOption,
1338 1506
 )
1507
+@click.option(
1508
+    '-r',
1509
+    '--repeat',
1510
+    metavar=_msg.TranslatedString(
1511
+        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
1512
+    ),
1513
+    callback=cli_machinery.validate_occurrence_constraint,
1514
+    help=_msg.TranslatedString(
1515
+        _msg.Label.DERIVEPASSPHRASE_VAULT_REPEAT_HELP_TEXT,
1516
+        metavar=_msg.TranslatedString(
1517
+            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
1518
+        ),
1519
+    ),
1520
+    cls=cli_machinery.PassphraseGenerationOption,
1339 1521
 )
1340
-                except NotImplementedError:
1341
-                    err(_msg.TranslatedString(_msg.ErrMsgTemplate.NO_AF_UNIX))
1342
-                except OSError as exc:
1343
-                    err(
1344
-                        _msg.TranslatedString(
1345
-                            _msg.ErrMsgTemplate.CANNOT_CONNECT_TO_AGENT,
1346
-                            error=exc.strerror,
1347
-                            filename=exc.filename,
1348
-                        ).maybe_without_filename(),
1522
+@click.option(
1523
+    '--lower',
1524
+    metavar=_msg.TranslatedString(
1525
+        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
1526
+    ),
1527
+    callback=cli_machinery.validate_occurrence_constraint,
1528
+    help=_msg.TranslatedString(
1529
+        _msg.Label.DERIVEPASSPHRASE_VAULT_LOWER_HELP_TEXT,
1530
+        metavar=_msg.TranslatedString(
1531
+            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
1532
+        ),
1533
+    ),
1534
+    cls=cli_machinery.PassphraseGenerationOption,
1349 1535
 )
1350
-                except ssh_agent.SSHAgentFailedError as exc:
1351
-                    err(
1352
-                        _msg.TranslatedString(
1353
-                            _msg.ErrMsgTemplate.AGENT_REFUSED_LIST_KEYS
1536
+@click.option(
1537
+    '--upper',
1538
+    metavar=_msg.TranslatedString(
1539
+        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
1354 1540
     ),
1355
-                        exc_info=exc,
1541
+    callback=cli_machinery.validate_occurrence_constraint,
1542
+    help=_msg.TranslatedString(
1543
+        _msg.Label.DERIVEPASSPHRASE_VAULT_UPPER_HELP_TEXT,
1544
+        metavar=_msg.TranslatedString(
1545
+            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
1546
+        ),
1547
+    ),
1548
+    cls=cli_machinery.PassphraseGenerationOption,
1356 1549
 )
1357
-                except RuntimeError as exc:
1358
-                    err(
1359
-                        _msg.TranslatedString(
1360
-                            _msg.ErrMsgTemplate.CANNOT_UNDERSTAND_AGENT
1550
+@click.option(
1551
+    '--number',
1552
+    metavar=_msg.TranslatedString(
1553
+        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
1361 1554
     ),
1362
-                        exc_info=exc,
1555
+    callback=cli_machinery.validate_occurrence_constraint,
1556
+    help=_msg.TranslatedString(
1557
+        _msg.Label.DERIVEPASSPHRASE_VAULT_NUMBER_HELP_TEXT,
1558
+        metavar=_msg.TranslatedString(
1559
+            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
1560
+        ),
1561
+    ),
1562
+    cls=cli_machinery.PassphraseGenerationOption,
1363 1563
 )
1364
-            elif use_phrase:
1365
-                maybe_phrase = cli_helpers.prompt_for_passphrase()
1366
-                if not maybe_phrase:
1367
-                    err(
1368
-                        _msg.TranslatedString(
1369
-                            _msg.ErrMsgTemplate.USER_ABORTED_PASSPHRASE
1564
+@click.option(
1565
+    '--space',
1566
+    metavar=_msg.TranslatedString(
1567
+        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
1568
+    ),
1569
+    callback=cli_machinery.validate_occurrence_constraint,
1570
+    help=_msg.TranslatedString(
1571
+        _msg.Label.DERIVEPASSPHRASE_VAULT_SPACE_HELP_TEXT,
1572
+        metavar=_msg.TranslatedString(
1573
+            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
1574
+        ),
1575
+    ),
1576
+    cls=cli_machinery.PassphraseGenerationOption,
1370 1577
 )
1578
+@click.option(
1579
+    '--dash',
1580
+    metavar=_msg.TranslatedString(
1581
+        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
1582
+    ),
1583
+    callback=cli_machinery.validate_occurrence_constraint,
1584
+    help=_msg.TranslatedString(
1585
+        _msg.Label.DERIVEPASSPHRASE_VAULT_DASH_HELP_TEXT,
1586
+        metavar=_msg.TranslatedString(
1587
+            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
1588
+        ),
1589
+    ),
1590
+    cls=cli_machinery.PassphraseGenerationOption,
1371 1591
 )
1372
-                else:
1373
-                    phrase = maybe_phrase
1374
-            if store_config_only:
1375
-                view: collections.ChainMap[str, Any]
1376
-                view = (
1377
-                    collections.ChainMap(*settings.maps[:2])
1378
-                    if service
1379
-                    else collections.ChainMap(
1380
-                        settings.maps[0], settings.maps[2]
1592
+@click.option(
1593
+    '--symbol',
1594
+    metavar=_msg.TranslatedString(
1595
+        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
1596
+    ),
1597
+    callback=cli_machinery.validate_occurrence_constraint,
1598
+    help=_msg.TranslatedString(
1599
+        _msg.Label.DERIVEPASSPHRASE_VAULT_SYMBOL_HELP_TEXT,
1600
+        metavar=_msg.TranslatedString(
1601
+            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
1602
+        ),
1603
+    ),
1604
+    cls=cli_machinery.PassphraseGenerationOption,
1381 1605
 )
1606
+@click.option(
1607
+    '-n',
1608
+    '--notes',
1609
+    'edit_notes',
1610
+    is_flag=True,
1611
+    help=_msg.TranslatedString(
1612
+        _msg.Label.DERIVEPASSPHRASE_VAULT_NOTES_HELP_TEXT,
1613
+        service_metavar=_msg.TranslatedString(
1614
+            _msg.Label.VAULT_METAVAR_SERVICE
1615
+        ),
1616
+    ),
1617
+    cls=cli_machinery.ConfigurationOption,
1382 1618
 )
1383
-                if use_key:
1384
-                    view['key'] = key
1385
-                elif use_phrase:
1386
-                    view['phrase'] = phrase
1387
-                    try:
1388
-                        cli_helpers.check_for_misleading_passphrase(
1389
-                            ('services', service) if service else ('global',),
1390
-                            {'phrase': phrase},
1391
-                            main_config=user_config,
1392
-                            ctx=ctx,
1393
-                        )
1394
-                    except AssertionError as exc:
1395
-                        err(
1396
-                            _msg.TranslatedString(
1397
-                                _msg.ErrMsgTemplate.INVALID_USER_CONFIG,
1398
-                                error=exc,
1399
-                                filename=None,
1400
-                            ).maybe_without_filename(),
1401
-                        )
1402
-                    if 'key' in settings:
1403
-                        if service:
1404
-                            w_msg = _msg.TranslatedString(
1405
-                                _msg.WarnMsgTemplate.SERVICE_PASSPHRASE_INEFFECTIVE,
1406
-                                service=json.dumps(service),
1407
-                            )
1408
-                        else:
1409
-                            w_msg = _msg.TranslatedString(
1410
-                                _msg.WarnMsgTemplate.GLOBAL_PASSPHRASE_INEFFECTIVE
1411
-                            )
1412
-                        logger.warning(w_msg, extra={'color': ctx.color})
1413
-                if not view.maps[0] and not unset_settings and not edit_notes:
1414
-                    err_msg = _msg.TranslatedString(
1415
-                        _msg.ErrMsgTemplate.CANNOT_UPDATE_SETTINGS_NO_SETTINGS,
1416
-                        settings_type=_msg.TranslatedString(
1417
-                            _msg.Label.CANNOT_UPDATE_SETTINGS_METAVAR_SETTINGS_TYPE_SERVICE
1418
-                            if service
1419
-                            else _msg.Label.CANNOT_UPDATE_SETTINGS_METAVAR_SETTINGS_TYPE_GLOBAL  # noqa: E501
1619
+@click.option(
1620
+    '-c',
1621
+    '--config',
1622
+    'store_config_only',
1623
+    is_flag=True,
1624
+    help=_msg.TranslatedString(
1625
+        _msg.Label.DERIVEPASSPHRASE_VAULT_CONFIG_HELP_TEXT,
1626
+        service_metavar=_msg.TranslatedString(
1627
+            _msg.Label.VAULT_METAVAR_SERVICE
1420 1628
         ),
1421
-                    )
1422
-                    raise click.UsageError(str(err_msg))
1423
-                for setting in unset_settings:
1424
-                    if setting in view.maps[0]:
1425
-                        err_msg = _msg.TranslatedString(
1426
-                            _msg.ErrMsgTemplate.SET_AND_UNSET_SAME_SETTING,
1427
-                            setting=setting,
1428
-                        )
1429
-                        raise click.UsageError(str(err_msg))
1430
-                if not cli_helpers.is_completable_item(service):
1431
-                    logger.warning(
1432
-                        _msg.TranslatedString(
1433
-                            _msg.WarnMsgTemplate.SERVICE_NAME_INCOMPLETABLE,
1434
-                            service=service,
1435 1629
     ),
1436
-                        extra={'color': ctx.color},
1437
-                    )
1438
-                subtree: dict[str, Any] = (
1439
-                    configuration['services'].setdefault(service, {})  # type: ignore[assignment]
1440
-                    if service
1441
-                    else configuration.setdefault('global', {})
1442
-                )
1443
-                if overwrite_config:
1444
-                    subtree.clear()
1445
-                else:
1446
-                    for setting in unset_settings:
1447
-                        subtree.pop(setting, None)
1448
-                subtree.update(view)
1449
-                assert _types.is_vault_config(configuration), (
1450
-                    f'Invalid vault configuration: {configuration!r}'
1451
-                )
1452
-                if edit_notes:
1453
-                    assert service is not None
1454
-                    notes_instructions = _msg.TranslatedString(
1455
-                        _msg.Label.DERIVEPASSPHRASE_VAULT_NOTES_INSTRUCTION_TEXT
1456
-                    )
1457
-                    notes_marker = _msg.TranslatedString(
1458
-                        _msg.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER
1459
-                    )
1460
-                    notes_legacy_instructions = _msg.TranslatedString(
1461
-                        _msg.Label.DERIVEPASSPHRASE_VAULT_NOTES_LEGACY_INSTRUCTION_TEXT
1630
+    cls=cli_machinery.ConfigurationOption,
1462 1631
 )
1463
-                    old_notes_value = subtree.get('notes', '')
1464
-                    if modern_editor_interface:
1465
-                        text = '\n'.join([
1466
-                            str(notes_instructions),
1467
-                            str(notes_marker),
1468
-                            old_notes_value,
1469
-                        ])
1470
-                    else:
1471
-                        text = old_notes_value or str(
1472
-                            notes_legacy_instructions
1632
+@click.option(
1633
+    '-x',
1634
+    '--delete',
1635
+    'delete_service_settings',
1636
+    is_flag=True,
1637
+    help=_msg.TranslatedString(
1638
+        _msg.Label.DERIVEPASSPHRASE_VAULT_DELETE_HELP_TEXT,
1639
+        service_metavar=_msg.TranslatedString(
1640
+            _msg.Label.VAULT_METAVAR_SERVICE
1641
+        ),
1642
+    ),
1643
+    cls=cli_machinery.ConfigurationOption,
1473 1644
 )
1474
-                    notes_value = click.edit(text=text, require_save=False)
1475
-                    assert notes_value is not None
1476
-                    if (
1477
-                        not modern_editor_interface
1478
-                        and notes_value.strip() != old_notes_value.strip()
1479
-                    ):
1480
-                        backup_file = cli_helpers.config_filename(
1481
-                            subsystem='notes backup'
1645
+@click.option(
1646
+    '--delete-globals',
1647
+    is_flag=True,
1648
+    help=_msg.TranslatedString(
1649
+        _msg.Label.DERIVEPASSPHRASE_VAULT_DELETE_GLOBALS_HELP_TEXT,
1650
+    ),
1651
+    cls=cli_machinery.ConfigurationOption,
1482 1652
 )
1483
-                        backup_file.write_text(
1484
-                            old_notes_value, encoding='UTF-8'
1653
+@click.option(
1654
+    '-X',
1655
+    '--clear',
1656
+    'clear_all_settings',
1657
+    is_flag=True,
1658
+    help=_msg.TranslatedString(
1659
+        _msg.Label.DERIVEPASSPHRASE_VAULT_DELETE_ALL_HELP_TEXT,
1660
+    ),
1661
+    cls=cli_machinery.ConfigurationOption,
1485 1662
 )
1486
-                        logger.warning(
1487
-                            _msg.TranslatedString(
1488
-                                _msg.WarnMsgTemplate.LEGACY_EDITOR_INTERFACE_NOTES_BACKUP,
1489
-                                filename=str(backup_file),
1663
+@click.option(
1664
+    '-e',
1665
+    '--export',
1666
+    'export_settings',
1667
+    metavar=_msg.TranslatedString(
1668
+        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
1490 1669
     ),
1491
-                            extra={'color': ctx.color},
1670
+    help=_msg.TranslatedString(
1671
+        _msg.Label.DERIVEPASSPHRASE_VAULT_EXPORT_HELP_TEXT,
1672
+        metavar=_msg.TranslatedString(
1673
+            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
1674
+        ),
1675
+    ),
1676
+    cls=cli_machinery.StorageManagementOption,
1677
+    shell_complete=cli_helpers.shell_complete_path,
1492 1678
 )
1493
-                        subtree['notes'] = notes_value.strip()
1494
-                    elif (
1495
-                        modern_editor_interface
1496
-                        and notes_value.strip() != text.strip()
1497
-                    ):
1498
-                        notes_lines = collections.deque(
1499
-                            notes_value.splitlines(True)  # noqa: FBT003
1679
+@click.option(
1680
+    '-i',
1681
+    '--import',
1682
+    'import_settings',
1683
+    metavar=_msg.TranslatedString(
1684
+        _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
1685
+    ),
1686
+    help=_msg.TranslatedString(
1687
+        _msg.Label.DERIVEPASSPHRASE_VAULT_IMPORT_HELP_TEXT,
1688
+        metavar=_msg.TranslatedString(
1689
+            _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER
1690
+        ),
1691
+    ),
1692
+    cls=cli_machinery.StorageManagementOption,
1693
+    shell_complete=cli_helpers.shell_complete_path,
1500 1694
 )
1501
-                        while notes_lines:
1502
-                            line = notes_lines.popleft()
1503
-                            if line.startswith(str(notes_marker)):
1504
-                                notes_value = ''.join(notes_lines)
1505
-                                break
1506
-                        else:
1507
-                            if not notes_value.strip():
1508
-                                err(
1509
-                                    _msg.TranslatedString(
1510
-                                        _msg.ErrMsgTemplate.USER_ABORTED_EDIT
1695
+@click.option(
1696
+    '--overwrite-existing/--merge-existing',
1697
+    'overwrite_config',
1698
+    default=False,
1699
+    help=_msg.TranslatedString(
1700
+        _msg.Label.DERIVEPASSPHRASE_VAULT_OVERWRITE_HELP_TEXT
1701
+    ),
1702
+    cls=cli_machinery.CompatibilityOption,
1511 1703
 )
1704
+@click.option(
1705
+    '--unset',
1706
+    'unset_settings',
1707
+    multiple=True,
1708
+    type=click.Choice([
1709
+        'phrase',
1710
+        'key',
1711
+        'length',
1712
+        'repeat',
1713
+        'lower',
1714
+        'upper',
1715
+        'number',
1716
+        'space',
1717
+        'dash',
1718
+        'symbol',
1719
+        'notes',
1720
+    ]),
1721
+    help=_msg.TranslatedString(
1722
+        _msg.Label.DERIVEPASSPHRASE_VAULT_UNSET_HELP_TEXT
1723
+    ),
1724
+    cls=cli_machinery.CompatibilityOption,
1512 1725
 )
1513
-                        subtree['notes'] = notes_value.strip()
1514
-                put_config(configuration)
1515
-            else:
1516
-                assert service is not None
1517
-                kwargs: dict[str, Any] = {
1518
-                    k: v
1519
-                    for k, v in settings.items()
1520
-                    if k in service_keys and v is not None
1521
-                }
1522
-                if use_phrase:
1523
-                    try:
1524
-                        cli_helpers.check_for_misleading_passphrase(
1525
-                            cli_helpers.ORIGIN.INTERACTIVE,
1526
-                            {'phrase': phrase},
1527
-                            main_config=user_config,
1528
-                            ctx=ctx,
1726
+@click.option(
1727
+    '--export-as',
1728
+    type=click.Choice(['json', 'sh']),
1729
+    default='json',
1730
+    help=_msg.TranslatedString(
1731
+        _msg.Label.DERIVEPASSPHRASE_VAULT_EXPORT_AS_HELP_TEXT
1732
+    ),
1733
+    cls=cli_machinery.CompatibilityOption,
1529 1734
 )
1530
-                    except AssertionError as exc:
1531
-                        err(
1532
-                            _msg.TranslatedString(
1533
-                                _msg.ErrMsgTemplate.INVALID_USER_CONFIG,
1534
-                                error=exc,
1535
-                                filename=None,
1536
-                            ).maybe_without_filename(),
1735
+@click.option(
1736
+    '--modern-editor-interface/--vault-legacy-editor-interface',
1737
+    'modern_editor_interface',
1738
+    default=False,
1739
+    help=_msg.TranslatedString(
1740
+        _msg.Label.DERIVEPASSPHRASE_VAULT_EDITOR_INTERFACE_HELP_TEXT
1741
+    ),
1742
+    cls=cli_machinery.CompatibilityOption,
1537 1743
 )
1538
-                # If either --key or --phrase are given, use that setting.
1539
-                # Otherwise, if both key and phrase are set in the config,
1540
-                # use the key.  Otherwise, if only one of key and phrase is
1541
-                # set in the config, use that one.  In all these above
1542
-                # cases, set the phrase via vault.Vault.phrase_from_key if
1543
-                # a key is given.  Finally, if nothing is set, error out.
1544
-                if use_key or use_phrase:
1545
-                    kwargs['phrase'] = (
1546
-                        cli_helpers.key_to_phrase(key, error_callback=err)
1547
-                        if use_key
1548
-                        else phrase
1549
-                    )
1550
-                elif kwargs.get('key'):
1551
-                    kwargs['phrase'] = cli_helpers.key_to_phrase(
1552
-                        kwargs['key'], error_callback=err
1553
-                    )
1554
-                elif kwargs.get('phrase'):
1555
-                    pass
1556
-                else:
1557
-                    err_msg = _msg.TranslatedString(
1558
-                        _msg.ErrMsgTemplate.NO_KEY_OR_PHRASE
1744
+@click.option(
1745
+    '--print-notes-before/--print-notes-after',
1746
+    'print_notes_before',
1747
+    default=False,
1748
+    help=_msg.TranslatedString(
1749
+        _msg.Label.DERIVEPASSPHRASE_VAULT_PRINT_NOTES_BEFORE_HELP_TEXT
1750
+    ),
1751
+    cls=cli_machinery.CompatibilityOption,
1559 1752
 )
1560
-                    raise click.UsageError(str(err_msg))
1561
-                kwargs.pop('key', '')
1562
-                service_notes = settings.get('notes', '').strip()
1563
-                result = vault.Vault(**kwargs).generate(service)
1564
-                if print_notes_before and service_notes.strip():
1565
-                    click.echo(f'{service_notes}\n', err=True, color=ctx.color)
1566
-                click.echo(result.decode('ASCII'), color=ctx.color)
1567
-                if not print_notes_before and service_notes.strip():
1568
-                    click.echo(
1569
-                        f'\n{service_notes}\n', err=True, color=ctx.color
1753
+@cli_machinery.version_option(cli_machinery.vault_version_option_callback)
1754
+@cli_machinery.color_forcing_pseudo_option
1755
+@cli_machinery.standard_logging_options
1756
+@click.argument(
1757
+    'service',
1758
+    metavar=_msg.TranslatedString(_msg.Label.VAULT_METAVAR_SERVICE),
1759
+    required=False,
1760
+    default=None,
1761
+    shell_complete=cli_helpers.shell_complete_service,
1570 1762
 )
1763
+@click.pass_context
1764
+def derivepassphrase_vault(
1765
+    ctx: click.Context,
1766
+    /,
1767
+    **_kwargs: Any,  # noqa: ANN401
1768
+) -> None:
1769
+    """Derive a passphrase using the vault(1) derivation scheme.
1770
+
1771
+    This is a [`click`][CLICK]-powered command-line interface function,
1772
+    and not intended for programmatic use.  See the
1773
+    derivepassphrase-vault(1) manpage for full documentation of the
1774
+    interface.  (See also [`click.testing.CliRunner`][] for controlled,
1775
+    programmatic invocation.)
1776
+
1777
+    [CLICK]: https://pypi.org/package/click/
1778
+
1779
+    Parameters:
1780
+        ctx (click.Context):
1781
+            The `click` context.
1782
+
1783
+    Other Parameters:
1784
+        service:
1785
+            A service name.  Required, unless operating on global
1786
+            settings or importing/exporting settings.
1787
+        use_phrase:
1788
+            Command-line argument `-p`/`--phrase`.  If given, query the
1789
+            user for a passphrase instead of an SSH key.
1790
+        use_key:
1791
+            Command-line argument `-k`/`--key`.  If given, query the
1792
+            user for an SSH key instead of a passphrase.
1793
+        length:
1794
+            Command-line argument `-l`/`--length`.  Override the default
1795
+            length of the generated passphrase.
1796
+        repeat:
1797
+            Command-line argument `-r`/`--repeat`.  Override the default
1798
+            repetition limit if positive, or disable the repetition
1799
+            limit if 0.
1800
+        lower:
1801
+            Command-line argument `--lower`.  Require a given amount of
1802
+            ASCII lowercase characters if positive, else forbid ASCII
1803
+            lowercase characters if 0.
1804
+        upper:
1805
+            Command-line argument `--upper`.  Same as `lower`, but for
1806
+            ASCII uppercase characters.
1807
+        number:
1808
+            Command-line argument `--number`.  Same as `lower`, but for
1809
+            ASCII digits.
1810
+        space:
1811
+            Command-line argument `--space`.  Same as `lower`, but for
1812
+            the space character.
1813
+        dash:
1814
+            Command-line argument `--dash`.  Same as `lower`, but for
1815
+            the hyphen-minus and underscore characters.
1816
+        symbol:
1817
+            Command-line argument `--symbol`.  Same as `lower`, but for
1818
+            all other ASCII printable characters except lowercase
1819
+            characters, uppercase characters, digits, space and
1820
+            backquote.
1821
+        edit_notes:
1822
+            Command-line argument `-n`/`--notes`.  If given, spawn an
1823
+            editor to edit notes for `service`.
1824
+        store_config_only:
1825
+            Command-line argument `-c`/`--config`.  If given, saves the
1826
+            other given settings (`--key`, ..., `--symbol`) to the
1827
+            configuration file, either specifically for `service` or as
1828
+            global settings.
1829
+        delete_service_settings:
1830
+            Command-line argument `-x`/`--delete`.  If given, removes
1831
+            the settings for `service` from the configuration file.
1832
+        delete_globals:
1833
+            Command-line argument `--delete-globals`.  If given, removes
1834
+            the global settings from the configuration file.
1835
+        clear_all_settings:
1836
+            Command-line argument `-X`/`--clear`.  If given, removes all
1837
+            settings from the configuration file.
1838
+        export_settings:
1839
+            Command-line argument `-e`/`--export`.  If a file object,
1840
+            then it must be open for writing and accept `str` inputs.
1841
+            Otherwise, a filename to open for writing.  Using `-` for
1842
+            standard output is supported.
1843
+        import_settings:
1844
+            Command-line argument `-i`/`--import`.  If a file object, it
1845
+            must be open for reading and yield `str` values.  Otherwise,
1846
+            a filename to open for reading.  Using `-` for standard
1847
+            input is supported.
1848
+        overwrite_config:
1849
+            Command-line arguments `--overwrite-existing` (True) and
1850
+            `--merge-existing` (False).  Controls whether config saving
1851
+            and config importing overwrite existing configurations, or
1852
+            merge them section-wise instead.
1853
+        unset_settings:
1854
+            Command-line argument `--unset`.  If given together with
1855
+            `--config`, unsets the specified settings (in addition to
1856
+            any other changes requested).
1857
+        export_as:
1858
+            Command-line argument `--export-as`.  If given together with
1859
+            `--export`, selects the format to export the current
1860
+            configuration as: JSON ("json", default) or POSIX sh ("sh").
1861
+        modern_editor_interface:
1862
+            Command-line arguments `--modern-editor-interface` (True)
1863
+            and `--vault-legacy-editor-interface` (False).  Controls
1864
+            whether editing notes uses a modern editor interface
1865
+            (supporting comments and aborting) or a vault(1)-compatible
1866
+            legacy editor interface (WYSIWYG notes contents).
1867
+        print_notes_before:
1868
+            Command-line arguments `--print-notes-before` (True) and
1869
+            `--print-notes-after` (False).  Controls whether the service
1870
+            notes (if any) are printed before the passphrase, or after.
1871
+
1872
+    """
1873
+    vault_context = _VaultContext(ctx)
1874
+    vault_context.validate_command_line()
1875
+    vault_context.dispatch_op()
1571 1876
 
1572 1877
 
1573 1878
 if __name__ == '__main__':
... ...
@@ -4290,6 +4290,40 @@ class TestCLI:
4290 4290
                 'expected error exit and known error message'
4291 4291
             )
4292 4292
 
4293
+    def test_311_bad_user_config_is_a_directory(
4294
+        self,
4295
+    ) -> None:
4296
+        """Loading a user configuration file in an invalid format fails."""
4297
+        runner = click.testing.CliRunner(mix_stderr=False)
4298
+        # TODO(the-13th-letter): Rewrite using parenthesized
4299
+        # with-statements.
4300
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4301
+        with contextlib.ExitStack() as stack:
4302
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
4303
+            stack.enter_context(
4304
+                tests.isolated_vault_config(
4305
+                    monkeypatch=monkeypatch,
4306
+                    runner=runner,
4307
+                    vault_config={'services': {}},
4308
+                    main_config_str='',
4309
+                )
4310
+            )
4311
+            user_config = cli_helpers.config_filename(
4312
+                subsystem='user configuration'
4313
+            )
4314
+            user_config.unlink()
4315
+            user_config.mkdir(parents=True, exist_ok=True)
4316
+            result_ = runner.invoke(
4317
+                cli.derivepassphrase_vault,
4318
+                ['--phrase', '--', DUMMY_SERVICE],
4319
+                input=DUMMY_PASSPHRASE,
4320
+                catch_exceptions=False,
4321
+            )
4322
+            result = tests.ReadableResult.parse(result_)
4323
+            assert result.error_exit(error='Cannot load user config:'), (
4324
+                'expected error exit and known error message'
4325
+            )
4326
+
4293 4327
     def test_400_missing_af_unix_support(
4294 4328
         self,
4295 4329
     ) -> None:
4296 4330