Reintegrate all functionality as subcommands
Marco Ricci

Marco Ricci commited on 2024-09-11 12:20:16
Zeige 12 geänderte Dateien mit 697 Einfügungen und 339 Löschungen.


Move the existing main functionality into a `derivepassphrase vault`
subcommand, and the existing exporter functionality into
a `derivepassphrase export vault` subcommand, in preparation of
supporting other schemes besides vault.  Install proxy command-line
interfaces that emit deprecation warnings and forward the call to the
subcommands.  (Some explanation and warning texts were relocated
appropriately.)

The exporter is no longer a separate command, but rather a subcommand
`export`.  The `derivepassphrase.exporter.cli` module has been folded
into the `derivepassphrase.cli` module.

Documentation and tests have been updated to match.  The documentation
in particular now strictly uses the one-line synopsis from the command's
docstring.
... ...
@@ -26,14 +26,16 @@ $ pip install derivepassphrase
26 26
 
27 27
 `derivepassphrase` is a pure Python package, and may be easily installed manually by placing the respective files and the package's dependencies into Python's import path.
28 28
 `derivepassphrase` requires Python 3.10 or higher as well as the [typing-extensions package][TYPING_EXTENSIONS] for its core functionality and programmatic interface, and [`click`][CLICK] 8.1 or higher for its command-line interface.
29
+Using the `export vault` subcommand additionally requires the [cryptography package][CRYPTOGRAPHY], version 39.0 or newer.
29 30
 
30 31
 [TYPING_EXTENSIONS]: https://pypi.org/project/typing-extensions/
31 32
 [CLICK]: https://click.palletsprojects.com/
33
+[CRYPTOGRAPHY]: https://github.com/pyca/cryptography
32 34
 
33 35
 ## Quick Usage
34 36
 
35 37
 ```` shell-session
36
-$ derivepassphrase -p --length 30 --upper 3 --lower 1 --number 2 --space 0 --symbol 0 my-email-account
38
+$ derivepassphrase vault -p --length 30 --upper 3 --lower 1 --number 2 --space 0 --symbol 0 my-email-account
37 39
 Passphrase: This passphrase is for demonstration purposes only.
38 40
 JKeet7GeBpxysOgdCEJo6UzmP8A0Ih
39 41
 ````
... ...
@@ -41,7 +43,7 @@ JKeet7GeBpxysOgdCEJo6UzmP8A0Ih
41 43
 Some time later…
42 44
 
43 45
 ```` shell-session
44
-$ derivepassphrase -p --length 30 --upper 3 --lower 1 --number 2 --space 0 --symbol 0 my-email-account
46
+$ derivepassphrase vault -p --length 30 --upper 3 --lower 1 --number 2 --space 0 --symbol 0 my-email-account
45 47
 Passphrase: This passphrase is for demonstration purposes only.
46 48
 JKeet7GeBpxysOgdCEJo6UzmP8A0Ih
47 49
 ````
... ...
@@ -1,14 +1,14 @@
1
-# derivepassphrase\_export(1)
1
+# derivepassphrase-export-vault(1)
2 2
 
3 3
 ## NAME
4 4
 
5
-derivepassphrase\_export – export a vault-native configuration to standard
6
-output
5
+derivepassphrase-export-vault – export a vault-native configuration to
6
+standard output
7 7
 
8 8
 ## SYNOPSIS
9 9
 
10 10
 ````
11
-derivepassphrase_export [OPTIONS] PATH
11
+derivepassphrase export vault [OPTIONS] PATH
12 12
 ````
13 13
 
14 14
 ## DESCRIPTION
... ...
@@ -0,0 +1,38 @@
1
+# derivepassphrase-export(1)
2
+
3
+## NAME
4
+
5
+derivepassphrase-export – export a foreign configuration to standard
6
+output
7
+
8
+## SYNOPSIS
9
+
10
+````
11
+derivepassphrase export [SUBCOMMAND_ARGS]...
12
+````
13
+
14
+## DESCRIPTION
15
+
16
+Read a foreign system configuration, extract all information from
17
+it, and export the resulting configuration to standard output.
18
+
19
+The only available subcommand is <b>vault</b>, which implements the
20
+vault-native configuration scheme.  If no subcommand is given, we
21
+default to <b>vault</b>.
22
+
23
+## SUBCOMMANDS
24
+
25
+[<b>vault</b>][VAULT_SUBCMD]
26
+:    Export a vault-native configuration to standard output.
27
+
28
+## DEPRECATION NOTICE
29
+
30
+Defaulting to <b>vault</b> is deprecated.  Starting in v1.0, the
31
+subcommand must be specified explicitly.
32
+
33
+## SEE ALSO
34
+
35
+[derivepassphrase(1)](derivepassphrase.1.md),
36
+[derivepassphrase-export-vault(1)]
37
+
38
+[VAULT_SUBCMD]: derivepassphrase-export-vault.1.md
... ...
@@ -0,0 +1,117 @@
1
+# derivepassphrase-vault(1)
2
+
3
+## NAME
4
+
5
+derivepassphrase-vault – derive a passphrase using the vault(1)
6
+derivation scheme
7
+
8
+## SYNOPSIS
9
+
10
+````
11
+derivepassphrase vault [OPTIONS] [SERVICE]
12
+````
13
+
14
+## DESCRIPTION
15
+
16
+Using a master passphrase or a master SSH key, derive a passphrase for
17
+<i>SERVICE</i>, subject to length, character and character repetition
18
+constraints.  The derivation is cryptographically strong, meaning that even
19
+if a single passphrase is compromised, guessing the master passphrase or
20
+a different service's passphrase is computationally infeasible.  The
21
+derivation is also deterministic, given the same inputs, thus the resulting
22
+passphrase need not be stored explicitly. The service name and constraints
23
+themselves also need not be kept secret; the latter are usually stored in
24
+a world-readable file.
25
+
26
+If operating on global settings, or importing/exporting settings, then
27
+<i>SERVICE</i> must be omitted.  Otherwise it is required.
28
+
29
+## OPTIONS
30
+
31
+### Password generation
32
+
33
+<b>-p</b>, <b>-</b><b>-phrase</b>
34
+:   prompts you for your passphrase
35
+
36
+<b>-k</b>, <b>-</b><b>-key</b>
37
+:   uses your SSH private key to generate passwords
38
+
39
+<b>-l</b>, <b>-</b><b>-length</b> <var>NUMBER</var>
40
+:   emits password of length <var>NUMBER</var>
41
+
42
+<b>-r</b>, <b>-</b><b>-repeat</b> <var>NUMBER</var>
43
+:   allows maximum of <var>NUMBER</var> repeated adjacent chars
44
+
45
+<b>-</b><b>-lower</b> <var>NUMBER</var>
46
+:   includes at least <var>NUMBER</var> lowercase letters
47
+
48
+<b>-</b><b>-upper</b> <var>NUMBER</var>
49
+:   includes at least <var>NUMBER</var> uppercase letters
50
+
51
+<b>-</b><b>-number</b> <var>NUMBER</var>
52
+:   includes at least <var>NUMBER</var> digits
53
+
54
+<b>-</b><b>-space</b> <var>NUMBER</var>
55
+:   includes at least <var>NUMBER</var> spaces
56
+
57
+<b>-</b><b>-dash</b> <var>NUMBER</var>
58
+:   includes at least <var>NUMBER</var> `-` or `_`
59
+
60
+<b>-</b><b>-symbol</b> <var>NUMBER</var>
61
+:   includes at least <var>NUMBER</var> symbol chars
62
+
63
+Use <var>NUMBER</var>=0, e.g. `--symbol 0`, to exclude a character type from
64
+the output.
65
+
66
+### Configuration
67
+
68
+<b>-n</b>, <b>-</b><b>-notes</b>
69
+:   spawn an editor to edit notes for <var>SERVICE</var>
70
+
71
+<b>-c</b>, <b>-</b><b>-config</b>
72
+:   saves the given settings for <var>SERVICE</var> or global
73
+
74
+<b>-x</b>, <b>-</b><b>-delete</b>
75
+:   deletes settings for <var>SERVICE</var>
76
+
77
+<b>-</b><b>-delete-globals</b>
78
+:   deletes the global shared settings
79
+
80
+<b>-X</b>, <b>-</b><b>-clear</b>
81
+:   deletes all settings
82
+
83
+Use `$VISUAL` or `$EDITOR` to configure the spawned editor.
84
+
85
+### Storage management
86
+
87
+<b>-e</b>, <b>-</b><b>-export</b> <var>PATH</var>
88
+:   export all saved settings into file <var>PATH</var>
89
+
90
+<b>-i</b>, <b>-</b><b>-import</b> <var>PATH</var>
91
+:   import saved settings from file <var>PATH</var>
92
+
93
+Using `-` as <var>PATH</var> for standard input/standard output is supported.
94
+
95
+### Other Options
96
+
97
+<b>--version</b>
98
+:   Show the version and exit.
99
+
100
+<b>-h</b>, <b>-</b><b>-help</b>
101
+:   Show this message and exit.
102
+
103
+## WARNINGS
104
+
105
+There is **no way** to retrieve the generated passphrases if the master
106
+passphrase, the SSH key, or the exact passphrase settings are lost,
107
+short of trying out all possible combinations.  You are **strongly**
108
+advised to keep independent backups of the settings and the SSH key, if
109
+any.
110
+
111
+The configuration is **not** encrypted, and you are **strongly**
112
+discouraged from using a stored passphrase.
113
+
114
+## SEE ALSO
115
+
116
+[derivepassphrase(1)](derivepassphrase.1.md),
117
+[vault(1)](https://github.com/jcoglan/vault)
... ...
@@ -8,114 +8,50 @@ a master secret
8 8
 ## SYNOPSIS
9 9
 
10 10
 ````
11
-derivepassphrase [OPTIONS] [SERVICE]
11
+derivepassphrase [SUBCOMMAND_ARGS]...
12 12
 ````
13 13
 
14 14
 ## DESCRIPTION
15 15
 
16
-Using a master passphrase or a master SSH key, derive a passphrase for
17
-<i>SERVICE</i>, subject to length, character and character repetition
18
-constraints.  The derivation is cryptographically strong, meaning that even
19
-if a single passphrase is compromised, guessing the master passphrase or
20
-a different service's passphrase is computationally infeasible.  The
21
-derivation is also deterministic, given the same inputs, thus the resulting
22
-passphrase need not be stored explicitly. The service name and constraints
23
-themselves also need not be kept secret; the latter are usually stored in
24
-a world-readable file.
16
+Using a master secret, derive a passphrase for a named service,
17
+subject to constraints e.g. on passphrase length, allowed
18
+characters, etc.  The exact derivation depends on the selected
19
+derivation scheme.  For each scheme, it is computationally
20
+infeasible to discern the master secret from the derived passphrase.
21
+The derivations are also deterministic, given the same inputs, thus
22
+the resulting passphrases need not be stored explicitly.  The
23
+service name and constraints themselves also generally need not be
24
+kept secret, depending on the scheme.
25 25
 
26
-If operating on global settings, or importing/exporting settings, then
27
-<i>SERVICE</i> must be omitted.  Otherwise it is required.
26
+The currently implemented subcommands are <b>vault</b> (for the scheme
27
+used by vault) and <b>export</b> (for exporting foreign configuration
28
+data).  See the respective `--help` output for instructions.  If no
29
+subcommand is given, we default to <b>vault</b>.
28 30
 
29
-## OPTIONS
31
+## SUBCOMMANDS
30 32
 
31
-### Password generation
33
+[<b>export</b>][EXPORT_SUBCMD]
34
+:   Export a foreign configuration to standard output.
32 35
 
33
-<b>-p</b>, <b>-</b><b>-phrase</b>
34
-:   prompts you for your passphrase
36
+[<b>vault</b>][VAULT_SUBCMD]
37
+:   Derive a passphrase using the vault(1) derivation scheme.
35 38
 
36
-<b>-k</b>, <b>-</b><b>-key</b>
37
-:   uses your SSH private key to generate passwords
39
+## DEPRECATION NOTICE
38 40
 
39
-<b>-l</b>, <b>-</b><b>-length</b> <var>NUMBER</var>
40
-:   emits password of length <var>NUMBER</var>
41
-
42
-<b>-r</b>, <b>-</b><b>-repeat</b> <var>NUMBER</var>
43
-:   allows maximum of <var>NUMBER</var> repeated adjacent chars
44
-
45
-<b>-</b><b>-lower</b> <var>NUMBER</var>
46
-:   includes at least <var>NUMBER</var> lowercase letters
47
-
48
-<b>-</b><b>-upper</b> <var>NUMBER</var>
49
-:   includes at least <var>NUMBER</var> uppercase letters
50
-
51
-<b>-</b><b>-number</b> <var>NUMBER</var>
52
-:   includes at least <var>NUMBER</var> digits
53
-
54
-<b>-</b><b>-space</b> <var>NUMBER</var>
55
-:   includes at least <var>NUMBER</var> spaces
56
-
57
-<b>-</b><b>-dash</b> <var>NUMBER</var>
58
-:   includes at least <var>NUMBER</var> `-` or `_`
59
-
60
-<b>-</b><b>-symbol</b> <var>NUMBER</var>
61
-:   includes at least <var>NUMBER</var> symbol chars
62
-
63
-Use <var>NUMBER</var>=0, e.g. `--symbol 0`, to exclude a character type from
64
-the output.
65
-
66
-### Configuration
67
-
68
-<b>-n</b>, <b>-</b><b>-notes</b>
69
-:   spawn an editor to edit notes for <var>SERVICE</var>
70
-
71
-<b>-c</b>, <b>-</b><b>-config</b>
72
-:   saves the given settings for <var>SERVICE</var> or global
73
-
74
-<b>-x</b>, <b>-</b><b>-delete</b>
75
-:   deletes settings for <var>SERVICE</var>
76
-
77
-<b>-</b><b>-delete-globals</b>
78
-:   deletes the global shared settings
79
-
80
-<b>-X</b>, <b>-</b><b>-clear</b>
81
-:   deletes all settings
82
-
83
-Use `$VISUAL` or `$EDITOR` to configure the spawned editor.
84
-
85
-### Storage management
86
-
87
-<b>-e</b>, <b>-</b><b>-export</b> <var>PATH</var>
88
-:   export all saved settings into file <var>PATH</var>
89
-
90
-<b>-i</b>, <b>-</b><b>-import</b> <var>PATH</var>
91
-:   import saved settings from file <var>PATH</var>
92
-
93
-Using `-` as <var>PATH</var> for standard input/standard output is supported.
94
-
95
-### Other Options
96
-
97
-<b>--version</b>
98
-:   Show the version and exit.
99
-
100
-<b>-h</b>, <b>-</b><b>-help</b>
101
-:   Show this message and exit.
102
-
103
-## WARNINGS
104
-
105
-There is **no way** to retrieve the generated passphrases if the master
106
-passphrase, the SSH key, or the exact passphrase settings are lost,
107
-short of trying out all possible combinations.  You are **strongly**
108
-advised to keep independent backups of the settings and the SSH key, if
109
-any.
41
+Defaulting to <b>vault</b> is deprecated.  Starting in v1.0, the
42
+subcommand must be specified explicitly.
110 43
 
111 44
 ## CONFIGURATION
112 45
 
113 46
 Configuration is stored in a directory according to the
114
-`$DERIVEPASSPHRASE_PATH` variable, which defaults to `~/.derivepassphrase` on
115
-UNIX-like systems and `C:\Users\<user>\AppData\Roaming\Derivepassphrase` on
116
-Windows. The configuration is **not** encrypted, and you are **strongly**
117
-discouraged from using a stored passphrase.
47
+`$DERIVEPASSPHRASE_PATH` variable, which defaults to
48
+`~/.derivepassphrase` on UNIX-like systems and
49
+`C:\Users\<user>\AppData\Roaming\Derivepassphrase` on Windows.
118 50
 
119 51
 ## SEE ALSO
120 52
 
121
-[vault(1)](https://github.com/jcoglan/vault)
53
+[derivepassphrase-export(1)][EXPORT_SUBCMD],
54
+[derivepassphrase-vault(1)][VAULT_SUBCMD]
55
+
56
+[EXPORT_SUBCMD]: derivepassphrase-export.1.md
57
+[VAULT_SUBCMD]: derivepassphrase-export.1.md
... ...
@@ -4,8 +4,10 @@ title: Reference overview
4 4
 
5 5
 ## Man pages
6 6
 
7
-* [`derivepassphrase(1)`][cli_man]: A deterministic, stateless password manager: command-line tool.
8
-* [`derivepassphrase_export(1)`][export_man]: Export a vault-native configuration to standard output.
7
+* [`derivepassphrase(1)`][top_man]: Derive a strong passphrase, deterministically, from a master secret.
8
+    * [`derivepassphrase-vault(1)`][top_man]: Derive a passphrase using the vault(1) derivation scheme.
9
+    * [`derivepassphrase-export(1)`][export_man]: Export a foreign configuration to standard output.
10
+        * [`derivepassphrase-export-vault(1)`][export_man]: Export a vault-native configuration to standard output.
9 11
 
10 12
 ## Modules and packages
11 13
 
... ...
@@ -19,5 +21,7 @@ title: Reference overview
19 21
     * [`derivepassphrase._types`][]: Types used by `derivepassphrase`.
20 22
     * [`derivepassphrase.vault`][]: Python port of the vault(1) password generation scheme.
21 23
 
22
-  [cli_man]: derivepassphrase.1.md
23
-  [export_man]: derivepassphrase_export.1.md
24
+  [top_man]: derivepassphrase.1.md
25
+  [vault_man]: derivepassphrase-vault.1.md
26
+  [export_man]: derivepassphrase-export.1.md
27
+  [export_vault_man]: derivepassphrase-export-vault.1.md
... ...
@@ -93,7 +93,9 @@ nav:
93 93
   - Reference:
94 94
     - reference/index.md
95 95
     - 'Man page: derivepassphrase': reference/derivepassphrase.1.md
96
-    - 'Man page: derivepassphrase_export': reference/derivepassphrase_export.1.md
96
+    - 'Man page: derivepassphrase-vault': reference/derivepassphrase-vault.1.md
97
+    - 'Man page: derivepassphrase-export': reference/derivepassphrase-export.1.md
98
+    - 'Man page: derivepassphrase-export-vault': reference/derivepassphrase-export-vault.1.md
97 99
     - Module derivepassphrase:
98 100
       - Submodule cli: reference/derivepassphrase.md
99 101
       - Subpackage exporter:
... ...
@@ -10,8 +10,10 @@ import base64
10 10
 import collections
11 11
 import contextlib
12 12
 import copy
13
+import importlib
13 14
 import inspect
14 15
 import json
16
+import logging
15 17
 import os
16 18
 import socket
17 19
 import unicodedata
... ...
@@ -30,10 +32,11 @@ from typing_extensions import (
30 32
 )
31 33
 
32 34
 import derivepassphrase as dpp
33
-from derivepassphrase import _types, ssh_agent, vault
35
+from derivepassphrase import _types, exporter, ssh_agent, vault
34 36
 
35 37
 if TYPE_CHECKING:
36 38
     import pathlib
39
+    import types
37 40
     from collections.abc import (
38 41
         Iterator,
39 42
         Sequence,
... ...
@@ -54,6 +57,289 @@ _NO_USABLE_KEYS = 'No usable SSH keys were found'
54 57
 _EMPTY_SELECTION = 'Empty selection'
55 58
 
56 59
 
60
+# Top-level
61
+# =========
62
+
63
+
64
+@click.command(
65
+    context_settings={
66
+        'help_option_names': ['-h', '--help'],
67
+        'ignore_unknown_options': True,
68
+        'allow_interspersed_args': False,
69
+    },
70
+    epilog=r"""
71
+        Configuration is stored in a directory according to the
72
+        DERIVEPASSPHRASE_PATH variable, which defaults to
73
+        `~/.derivepassphrase` on UNIX-like systems and
74
+        `C:\Users\<user>\AppData\Roaming\Derivepassphrase` on Windows.
75
+    """
76
+)
77
+@click.version_option(version=dpp.__version__, prog_name=PROG_NAME)
78
+@click.argument('subcommand_args', nargs=-1, type=click.UNPROCESSED)
79
+def derivepassphrase(
80
+    *,
81
+    subcommand_args: list[str],
82
+) -> None:
83
+    """Derive a strong passphrase, deterministically, from a master secret.
84
+
85
+    Using a master secret, derive a passphrase for a named service,
86
+    subject to constraints e.g. on passphrase length, allowed
87
+    characters, etc.  The exact derivation depends on the selected
88
+    derivation scheme.  For each scheme, it is computationally
89
+    infeasible to discern the master secret from the derived passphrase.
90
+    The derivations are also deterministic, given the same inputs, thus
91
+    the resulting passphrases need not be stored explicitly.  The
92
+    service name and constraints themselves also generally need not be
93
+    kept secret, depending on the scheme.
94
+
95
+    The currently implemented subcommands are "vault" (for the scheme
96
+    used by vault) and "export" (for exporting foreign configuration
97
+    data).  See the respective `--help` output for instructions.  If no
98
+    subcommand is given, we default to "vault".
99
+
100
+    Deprecation notice: Defaulting to "vault" is deprecated.  Starting
101
+    in v1.0, the subcommand must be specified explicitly.\f
102
+
103
+    This is a [`click`][CLICK]-powered command-line interface function,
104
+    and not intended for programmatic use.  Call with arguments
105
+    `['--help']` to see full documentation of the interface.  (See also
106
+    [`click.testing.CliRunner`][] for controlled, programmatic
107
+    invocation.)
108
+
109
+    [CLICK]: https://click.palletsprojects.com/
110
+
111
+    """  # noqa: D301
112
+    if subcommand_args and subcommand_args[0] == 'export':
113
+        return derivepassphrase_export.main(
114
+            args=subcommand_args[1:],
115
+            prog_name=f'{PROG_NAME} export',
116
+            standalone_mode=False,
117
+        )
118
+    if not (subcommand_args and subcommand_args[0] == 'vault'):
119
+        click.echo(
120
+            (
121
+                f'{PROG_NAME}: Deprecation warning: A subcommand will be '
122
+                f'required in v1.0. See --help for available subcommands.'
123
+            ),
124
+            err=True,
125
+        )
126
+        click.echo(
127
+            f'{PROG_NAME}: Warning: Defaulting to subcommand "vault".',
128
+            err=True,
129
+        )
130
+    else:
131
+        subcommand_args = subcommand_args[1:]
132
+    return derivepassphrase_vault.main(
133
+        args=subcommand_args,
134
+        prog_name=f'{PROG_NAME} vault',
135
+        standalone_mode=False,
136
+    )
137
+
138
+
139
+# Exporter
140
+# ========
141
+
142
+
143
+@click.command(
144
+    context_settings={
145
+        'help_option_names': ['-h', '--help'],
146
+        'ignore_unknown_options': True,
147
+        'allow_interspersed_args': False,
148
+    }
149
+)
150
+@click.version_option(version=dpp.__version__, prog_name=PROG_NAME)
151
+@click.argument('subcommand_args', nargs=-1, type=click.UNPROCESSED)
152
+def derivepassphrase_export(
153
+    *,
154
+    subcommand_args: list[str],
155
+) -> None:
156
+    """Export a foreign configuration to standard output.
157
+
158
+    Read a foreign system configuration, extract all information from
159
+    it, and export the resulting configuration to standard output.
160
+
161
+    The only available subcommand is "vault", which implements the
162
+    vault-native configuration scheme.  If no subcommand is given, we
163
+    default to "vault".
164
+
165
+    Deprecation notice: Defaulting to "vault" is deprecated.  Starting
166
+    in v1.0, the subcommand must be specified explicitly.\f
167
+
168
+    This is a [`click`][CLICK]-powered command-line interface function,
169
+    and not intended for programmatic use.  Call with arguments
170
+    `['--help']` to see full documentation of the interface.  (See also
171
+    [`click.testing.CliRunner`][] for controlled, programmatic
172
+    invocation.)
173
+
174
+    [CLICK]: https://click.palletsprojects.com/
175
+
176
+    """  # noqa: D301
177
+    if not (subcommand_args and subcommand_args[0] == 'vault'):
178
+        click.echo(
179
+            (
180
+                f'{PROG_NAME}: Deprecation warning: A subcommand will be '
181
+                f'required in v1.0. See --help for available subcommands.'
182
+            ),
183
+            err=True,
184
+        )
185
+        click.echo(
186
+            f'{PROG_NAME}: Warning: Defaulting to subcommand "vault".',
187
+            err=True,
188
+        )
189
+    else:
190
+        subcommand_args = subcommand_args[1:]
191
+    return derivepassphrase_export_vault.main(
192
+        args=subcommand_args,
193
+        prog_name=f'{PROG_NAME} export vault',
194
+        standalone_mode=False,
195
+    )
196
+
197
+
198
+def _load_data(
199
+    fmt: Literal['v0.2', 'v0.3', 'storeroom'],
200
+    path: str | bytes | os.PathLike[str],
201
+    key: bytes,
202
+) -> Any:  # noqa: ANN401
203
+    contents: bytes
204
+    module: types.ModuleType
205
+    match fmt:
206
+        case 'v0.2':
207
+            module = importlib.import_module(
208
+                'derivepassphrase.exporter.vault_native'
209
+            )
210
+            if module.STUBBED:
211
+                raise ModuleNotFoundError
212
+            with open(path, 'rb') as infile:
213
+                contents = base64.standard_b64decode(infile.read())
214
+            return module.export_vault_native_data(
215
+                contents, key, try_formats=['v0.2']
216
+            )
217
+        case 'v0.3':
218
+            module = importlib.import_module(
219
+                'derivepassphrase.exporter.vault_native'
220
+            )
221
+            if module.STUBBED:
222
+                raise ModuleNotFoundError
223
+            with open(path, 'rb') as infile:
224
+                contents = base64.standard_b64decode(infile.read())
225
+            return module.export_vault_native_data(
226
+                contents, key, try_formats=['v0.3']
227
+            )
228
+        case 'storeroom':
229
+            module = importlib.import_module(
230
+                'derivepassphrase.exporter.storeroom'
231
+            )
232
+            if module.STUBBED:
233
+                raise ModuleNotFoundError
234
+            return module.export_storeroom_data(path, key)
235
+        case _:  # pragma: no cover
236
+            assert_never(fmt)
237
+
238
+
239
+@click.command(
240
+    context_settings={'help_option_names': ['-h', '--help']},
241
+)
242
+@click.option(
243
+    '-f',
244
+    '--format',
245
+    'formats',
246
+    metavar='FMT',
247
+    multiple=True,
248
+    default=('v0.3', 'v0.2', 'storeroom'),
249
+    type=click.Choice(['v0.2', 'v0.3', 'storeroom']),
250
+    help='try the following storage formats, in order (default: v0.3, v0.2)',
251
+)
252
+@click.option(
253
+    '-k',
254
+    '--key',
255
+    metavar='K',
256
+    help=(
257
+        'use K as the storage master key '
258
+        '(default: check the `VAULT_KEY`, `LOGNAME`, `USER` or '
259
+        '`USERNAME` environment variables)'
260
+    ),
261
+)
262
+@click.argument('path', metavar='PATH', required=True)
263
+@click.pass_context
264
+def derivepassphrase_export_vault(
265
+    ctx: click.Context,
266
+    /,
267
+    *,
268
+    path: str | bytes | os.PathLike[str],
269
+    formats: Sequence[Literal['v0.2', 'v0.3', 'storeroom']] = (),
270
+    key: str | bytes | None = None,
271
+) -> None:
272
+    """Export a vault-native configuration to standard output.
273
+
274
+    Read the vault-native configuration at PATH, extract all information
275
+    from it, and export the resulting configuration to standard output.
276
+    Depending on the configuration format, PATH may either be a file or
277
+    a directory.  Supports the vault "v0.2", "v0.3" and "storeroom"
278
+    formats.
279
+
280
+    If PATH is explicitly given as `VAULT_PATH`, then use the
281
+    `VAULT_PATH` environment variable to determine the correct path.
282
+    (Use `./VAULT_PATH` or similar to indicate a file/directory actually
283
+    named `VAULT_PATH`.)
284
+
285
+    """
286
+    logging.basicConfig()
287
+    if path in {'VAULT_PATH', b'VAULT_PATH'}:
288
+        path = exporter.get_vault_path()
289
+    if key is None:
290
+        key = exporter.get_vault_key()
291
+    elif isinstance(key, str):  # pragma: no branch
292
+        key = key.encode('utf-8')
293
+    for fmt in formats:
294
+        try:
295
+            config = _load_data(fmt, path, key)
296
+        except (
297
+            IsADirectoryError,
298
+            NotADirectoryError,
299
+            ValueError,
300
+            RuntimeError,
301
+        ):
302
+            logging.info('Cannot load as %s: %s', fmt, path)
303
+            continue
304
+        except OSError as exc:
305
+            click.echo(
306
+                (
307
+                    f'{PROG_NAME}: ERROR: Cannot parse {path!r} as '
308
+                    f'a valid config: {exc.strerror}: {exc.filename!r}'
309
+                ),
310
+                err=True,
311
+            )
312
+            ctx.exit(1)
313
+        except ModuleNotFoundError:
314
+            # TODO(the-13th-letter): Use backslash continuation.
315
+            # https://github.com/nedbat/coveragepy/issues/1836
316
+            msg = f"""
317
+{PROG_NAME}: ERROR: Cannot load the required Python module "cryptography".
318
+{PROG_NAME}: INFO: pip users: see the "export" extra.
319
+""".lstrip('\n')
320
+            click.echo(msg, nl=False, err=True)
321
+            ctx.exit(1)
322
+        else:
323
+            if not _types.is_vault_config(config):
324
+                click.echo(
325
+                    f'{PROG_NAME}: ERROR: Invalid vault config: {config!r}',
326
+                    err=True,
327
+                )
328
+                ctx.exit(1)
329
+            click.echo(json.dumps(config, indent=2, sort_keys=True))
330
+            break
331
+    else:
332
+        click.echo(
333
+            f'{PROG_NAME}: ERROR: Cannot parse {path!r} as a valid config.',
334
+            err=True,
335
+        )
336
+        ctx.exit(1)
337
+
338
+
339
+# Vault
340
+# =====
341
+
342
+
57 343
 def _config_filename() -> str | bytes | pathlib.Path:
58 344
     """Return the filename of the configuration file.
59 345
 
... ...
@@ -603,6 +889,8 @@ DEFAULT_NOTES_MARKER = '# - - - - - >8 - - - - -'
603 889
 
604 890
 
605 891
 @click.command(
892
+    # 'vault',
893
+    # help="derivation scheme compatible with James Coglan's vault(1)",
606 894
     context_settings={'help_option_names': ['-h', '--help']},
607 895
     cls=CommandWithHelpGroups,
608 896
     epilog=r"""
... ...
@@ -612,10 +900,6 @@ DEFAULT_NOTES_MARKER = '# - - - - - >8 - - - - -'
612 900
         combinations.  You are STRONGLY advised to keep independent
613 901
         backups of the settings and the SSH key, if any.
614 902
 
615
-        Configuration is stored in a directory according to the
616
-        DERIVEPASSPHRASE_PATH variable, which defaults to
617
-        `~/.derivepassphrase` on UNIX-like systems and
618
-        `C:\Users\<user>\AppData\Roaming\Derivepassphrase` on Windows.
619 903
         The configuration is NOT encrypted, and you are STRONGLY
620 904
         discouraged from using a stored passphrase.
621 905
     """,
... ...
@@ -751,7 +1035,7 @@ DEFAULT_NOTES_MARKER = '# - - - - - >8 - - - - -'
751 1035
 @click.version_option(version=dpp.__version__, prog_name=PROG_NAME)
752 1036
 @click.argument('service', required=False)
753 1037
 @click.pass_context
754
-def derivepassphrase(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1038
+def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
755 1039
     ctx: click.Context,
756 1040
     /,
757 1041
     *,
... ...
@@ -774,7 +1058,7 @@ def derivepassphrase(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
774 1058
     export_settings: TextIO | pathlib.Path | os.PathLike[str] | None = None,
775 1059
     import_settings: TextIO | pathlib.Path | os.PathLike[str] | None = None,
776 1060
 ) -> None:
777
-    """Derive a strong passphrase, deterministically, from a master secret.
1061
+    """Derive a passphrase using the vault(1) derivation scheme.
778 1062
 
779 1063
     Using a master passphrase or a master SSH key, derive a passphrase
780 1064
     for SERVICE, subject to length, character and character repetition
... ...
@@ -1,176 +0,0 @@
1
-# SPDX-FileCopyrightText: 2024 Marco Ricci <software@the13thletter.info>
2
-#
3
-# SPDX-License-Identifier: MIT
4
-
5
-"""Command-line interface for derivepassphrase_export."""
6
-
7
-from __future__ import annotations
8
-
9
-import base64
10
-import importlib
11
-import json
12
-import logging
13
-from typing import TYPE_CHECKING, Any, Literal
14
-
15
-import click
16
-from typing_extensions import assert_never
17
-
18
-import derivepassphrase as dpp
19
-from derivepassphrase import _types, exporter
20
-
21
-if TYPE_CHECKING:
22
-    import os
23
-    import types
24
-    from collections.abc import Sequence
25
-
26
-__author__ = dpp.__author__
27
-__version__ = dpp.__version__
28
-
29
-__all__ = ('derivepassphrase_export',)
30
-
31
-PROG_NAME = 'derivepassphrase_export'
32
-
33
-
34
-def _load_data(
35
-    fmt: Literal['v0.2', 'v0.3', 'storeroom'],
36
-    path: str | bytes | os.PathLike[str],
37
-    key: bytes,
38
-) -> Any:  # noqa: ANN401
39
-    contents: bytes
40
-    module: types.ModuleType
41
-    match fmt:
42
-        case 'v0.2':
43
-            module = importlib.import_module(
44
-                'derivepassphrase.exporter.vault_native'
45
-            )
46
-            if module.STUBBED:
47
-                raise ModuleNotFoundError
48
-            with open(path, 'rb') as infile:
49
-                contents = base64.standard_b64decode(infile.read())
50
-            return module.export_vault_native_data(
51
-                contents, key, try_formats=['v0.2']
52
-            )
53
-        case 'v0.3':
54
-            module = importlib.import_module(
55
-                'derivepassphrase.exporter.vault_native'
56
-            )
57
-            if module.STUBBED:
58
-                raise ModuleNotFoundError
59
-            with open(path, 'rb') as infile:
60
-                contents = base64.standard_b64decode(infile.read())
61
-            return module.export_vault_native_data(
62
-                contents, key, try_formats=['v0.3']
63
-            )
64
-        case 'storeroom':
65
-            module = importlib.import_module(
66
-                'derivepassphrase.exporter.storeroom'
67
-            )
68
-            if module.STUBBED:
69
-                raise ModuleNotFoundError
70
-            return module.export_storeroom_data(path, key)
71
-        case _:  # pragma: no cover
72
-            assert_never(fmt)
73
-
74
-
75
-@click.command(
76
-    context_settings={'help_option_names': ['-h', '--help']},
77
-)
78
-@click.option(
79
-    '-f',
80
-    '--format',
81
-    'formats',
82
-    metavar='FMT',
83
-    multiple=True,
84
-    default=('v0.3', 'v0.2', 'storeroom'),
85
-    type=click.Choice(['v0.2', 'v0.3', 'storeroom']),
86
-    help='try the following storage formats, in order (default: v0.3, v0.2)',
87
-)
88
-@click.option(
89
-    '-k',
90
-    '--key',
91
-    metavar='K',
92
-    help=(
93
-        'use K as the storage master key '
94
-        '(default: check the `VAULT_KEY`, `LOGNAME`, `USER` or '
95
-        '`USERNAME` environment variables)'
96
-    ),
97
-)
98
-@click.argument('path', metavar='PATH', required=True)
99
-@click.pass_context
100
-def derivepassphrase_export(
101
-    ctx: click.Context,
102
-    /,
103
-    *,
104
-    path: str | bytes | os.PathLike[str],
105
-    formats: Sequence[Literal['v0.2', 'v0.3', 'storeroom']] = (),
106
-    key: str | bytes | None = None,
107
-) -> None:
108
-    """Export a vault-native configuration to standard output.
109
-
110
-    Read the vault-native configuration at PATH, extract all information
111
-    from it, and export the resulting configuration to standard output.
112
-    Depending on the configuration format, this may either be a file or
113
-    a directory.  Supports the vault "v0.2", "v0.3" and "storeroom"
114
-    formats.
115
-
116
-    If PATH is explicitly given as `VAULT_PATH`, then use the
117
-    `VAULT_PATH` environment variable to determine the correct path.
118
-    (Use `./VAULT_PATH` or similar to indicate a file/directory actually
119
-    named `VAULT_PATH`.)
120
-
121
-    """
122
-    logging.basicConfig()
123
-    if path in {'VAULT_PATH', b'VAULT_PATH'}:
124
-        path = exporter.get_vault_path()
125
-    if key is None:
126
-        key = exporter.get_vault_key()
127
-    elif isinstance(key, str):  # pragma: no branch
128
-        key = key.encode('utf-8')
129
-    for fmt in formats:
130
-        try:
131
-            config = _load_data(fmt, path, key)
132
-        except (
133
-            IsADirectoryError,
134
-            NotADirectoryError,
135
-            ValueError,
136
-            RuntimeError,
137
-        ):
138
-            logging.info('Cannot load as %s: %s', fmt, path)
139
-            continue
140
-        except OSError as exc:
141
-            click.echo(
142
-                (
143
-                    f'{PROG_NAME}: ERROR: Cannot parse {path!r} as '
144
-                    f'a valid config: {exc.strerror}: {exc.filename!r}'
145
-                ),
146
-                err=True,
147
-            )
148
-            ctx.exit(1)
149
-        except ModuleNotFoundError:
150
-            # TODO(the-13th-letter): Use backslash continuation.
151
-            # https://github.com/nedbat/coveragepy/issues/1836
152
-            msg = f"""
153
-{PROG_NAME}: ERROR: Cannot load the required Python module "cryptography".
154
-{PROG_NAME}: INFO: pip users: see the "export" extra.
155
-""".lstrip('\n')
156
-            click.echo(msg, nl=False, err=True)
157
-            ctx.exit(1)
158
-        else:
159
-            if not _types.is_vault_config(config):
160
-                click.echo(
161
-                    f'{PROG_NAME}: ERROR: Invalid vault config: {config!r}',
162
-                    err=True,
163
-                )
164
-                ctx.exit(1)
165
-            click.echo(json.dumps(config, indent=2, sort_keys=True))
166
-            break
167
-    else:
168
-        click.echo(
169
-            f'{PROG_NAME}: ERROR: Cannot parse {path!r} as a valid config.',
170
-            err=True,
171
-        )
172
-        ctx.exit(1)
173
-
174
-
175
-if __name__ == '__main__':
176
-    derivepassphrase_export()
... ...
@@ -208,7 +208,7 @@ class TestCLI:
208 208
             config={'services': {}},
209 209
         ):
210 210
             _result = runner.invoke(
211
-                cli.derivepassphrase, ['--help'], catch_exceptions=False
211
+                cli.derivepassphrase_vault, ['--help'], catch_exceptions=False
212 212
             )
213 213
             result = tests.ReadableResult.parse(_result)
214 214
         assert result.clean_exit(
... ...
@@ -234,7 +234,7 @@ class TestCLI:
234 234
             config={'services': {}},
235 235
         ):
236 236
             _result = runner.invoke(
237
-                cli.derivepassphrase,
237
+                cli.derivepassphrase_vault,
238 238
                 [option, '0', '-p', DUMMY_SERVICE],
239 239
                 input=DUMMY_PASSPHRASE,
240 240
                 catch_exceptions=False,
... ...
@@ -257,7 +257,7 @@ class TestCLI:
257 257
             config={'services': {}},
258 258
         ):
259 259
             _result = runner.invoke(
260
-                cli.derivepassphrase,
260
+                cli.derivepassphrase_vault,
261 261
                 ['--repeat', '0', '-p', DUMMY_SERVICE],
262 262
                 input=DUMMY_PASSPHRASE,
263 263
                 catch_exceptions=False,
... ...
@@ -310,7 +310,9 @@ class TestCLI:
310 310
                 dpp.vault.Vault, 'phrase_from_key', tests.phrase_from_key
311 311
             )
312 312
             _result = runner.invoke(
313
-                cli.derivepassphrase, [DUMMY_SERVICE], catch_exceptions=False
313
+                cli.derivepassphrase_vault,
314
+                [DUMMY_SERVICE],
315
+                catch_exceptions=False,
314 316
             )
315 317
         result = tests.ReadableResult.parse(_result)
316 318
         assert result.clean_exit(
... ...
@@ -340,7 +342,7 @@ class TestCLI:
340 342
                 dpp.vault.Vault, 'phrase_from_key', tests.phrase_from_key
341 343
             )
342 344
             _result = runner.invoke(
343
-                cli.derivepassphrase,
345
+                cli.derivepassphrase_vault,
344 346
                 ['-k', DUMMY_SERVICE],
345 347
                 input='1\n',
346 348
                 catch_exceptions=False,
... ...
@@ -406,7 +408,7 @@ class TestCLI:
406 408
             monkeypatch=monkeypatch, runner=runner, config=config
407 409
         ):
408 410
             _result = runner.invoke(
409
-                cli.derivepassphrase,
411
+                cli.derivepassphrase_vault,
410 412
                 ['-k', DUMMY_SERVICE],
411 413
                 input=f'{key_index}\n',
412 414
             )
... ...
@@ -437,7 +439,9 @@ class TestCLI:
437 439
             },
438 440
         ):
439 441
             _result = runner.invoke(
440
-                cli.derivepassphrase, [DUMMY_SERVICE], catch_exceptions=False
442
+                cli.derivepassphrase_vault,
443
+                [DUMMY_SERVICE],
444
+                catch_exceptions=False,
441 445
             )
442 446
         result = tests.ReadableResult.parse(_result)
443 447
         assert result.clean_exit(), 'expected clean exit'
... ...
@@ -474,7 +478,7 @@ class TestCLI:
474 478
         ):
475 479
             for value in '-42', 'invalid':
476 480
                 _result = runner.invoke(
477
-                    cli.derivepassphrase,
481
+                    cli.derivepassphrase_vault,
478 482
                     [option, value, '-p', DUMMY_SERVICE],
479 483
                     input=DUMMY_PASSPHRASE,
480 484
                     catch_exceptions=False,
... ...
@@ -508,7 +512,7 @@ class TestCLI:
508 512
             config={'global': {'phrase': 'abc'}, 'services': {}},
509 513
         ):
510 514
             _result = runner.invoke(
511
-                cli.derivepassphrase,
515
+                cli.derivepassphrase_vault,
512 516
                 options if service else [*options, DUMMY_SERVICE],
513 517
                 input=input,
514 518
                 catch_exceptions=False,
... ...
@@ -537,7 +541,7 @@ class TestCLI:
537 541
                     cli, '_prompt_for_passphrase', tests.auto_prompt
538 542
                 )
539 543
                 _result = runner.invoke(
540
-                    cli.derivepassphrase,
544
+                    cli.derivepassphrase_vault,
541 545
                     [*options, DUMMY_SERVICE] if service else options,
542 546
                     input=input,
543 547
                     catch_exceptions=False,
... ...
@@ -566,7 +570,7 @@ class TestCLI:
566 570
             config={'services': {}},
567 571
         ):
568 572
             _result = runner.invoke(
569
-                cli.derivepassphrase,
573
+                cli.derivepassphrase_vault,
570 574
                 [*options, DUMMY_SERVICE] if service else options,
571 575
                 input=DUMMY_PASSPHRASE,
572 576
                 catch_exceptions=False,
... ...
@@ -585,7 +589,7 @@ class TestCLI:
585 589
             monkeypatch=monkeypatch, runner=runner, config={'services': {}}
586 590
         ):
587 591
             _result = runner.invoke(
588
-                cli.derivepassphrase,
592
+                cli.derivepassphrase_vault,
589 593
                 ['--import', '-'],
590 594
                 input='null',
591 595
                 catch_exceptions=False,
... ...
@@ -604,7 +608,7 @@ class TestCLI:
604 608
             monkeypatch=monkeypatch, runner=runner, config={'services': {}}
605 609
         ):
606 610
             _result = runner.invoke(
607
-                cli.derivepassphrase,
611
+                cli.derivepassphrase_vault,
608 612
                 ['--import', '-'],
609 613
                 input='This string is not valid JSON.',
610 614
                 catch_exceptions=False,
... ...
@@ -631,7 +635,7 @@ class TestCLI:
631 635
                 print('This string is not valid JSON.', file=outfile)
632 636
             dname = os.path.dirname(cli._config_filename())
633 637
             _result = runner.invoke(
634
-                cli.derivepassphrase,
638
+                cli.derivepassphrase_vault,
635 639
                 ['--import', os.fsdecode(dname)],
636 640
                 catch_exceptions=False,
637 641
             )
... ...
@@ -651,7 +655,9 @@ class TestCLI:
651 655
             with contextlib.suppress(FileNotFoundError):
652 656
                 os.remove(cli._config_filename())
653 657
             _result = runner.invoke(
654
-                cli.derivepassphrase, ['--export', '-'], catch_exceptions=False
658
+                cli.derivepassphrase_vault,
659
+                ['--export', '-'],
660
+                catch_exceptions=False,
655 661
             )
656 662
         result = tests.ReadableResult.parse(_result)
657 663
         assert result.clean_exit(empty_stderr=True), 'expected clean exit'
... ...
@@ -665,7 +671,7 @@ class TestCLI:
665 671
             monkeypatch=monkeypatch, runner=runner, config={}
666 672
         ):
667 673
             _result = runner.invoke(
668
-                cli.derivepassphrase,
674
+                cli.derivepassphrase_vault,
669 675
                 ['--export', '-'],
670 676
                 input='null',
671 677
                 catch_exceptions=False,
... ...
@@ -687,7 +693,7 @@ class TestCLI:
687 693
                 os.remove(cli._config_filename())
688 694
             os.makedirs(cli._config_filename())
689 695
             _result = runner.invoke(
690
-                cli.derivepassphrase,
696
+                cli.derivepassphrase_vault,
691 697
                 ['--export', '-'],
692 698
                 input='null',
693 699
                 catch_exceptions=False,
... ...
@@ -707,7 +713,7 @@ class TestCLI:
707 713
         ):
708 714
             dname = os.path.dirname(cli._config_filename())
709 715
             _result = runner.invoke(
710
-                cli.derivepassphrase,
716
+                cli.derivepassphrase_vault,
711 717
                 ['--export', os.fsdecode(dname)],
712 718
                 input='null',
713 719
                 catch_exceptions=False,
... ...
@@ -730,7 +736,7 @@ class TestCLI:
730 736
             with open('.derivepassphrase', 'w', encoding='UTF-8') as outfile:
731 737
                 print('Obstruction!!', file=outfile)
732 738
             _result = runner.invoke(
733
-                cli.derivepassphrase,
739
+                cli.derivepassphrase_vault,
734 740
                 ['--export', '-'],
735 741
                 input='null',
736 742
                 catch_exceptions=False,
... ...
@@ -756,7 +762,9 @@ contents go here
756 762
         ):
757 763
             monkeypatch.setattr(click, 'edit', lambda *a, **kw: edit_result)  # noqa: ARG005
758 764
             _result = runner.invoke(
759
-                cli.derivepassphrase, ['--notes', 'sv'], catch_exceptions=False
765
+                cli.derivepassphrase_vault,
766
+                ['--notes', 'sv'],
767
+                catch_exceptions=False,
760 768
             )
761 769
             result = tests.ReadableResult.parse(_result)
762 770
             assert result.clean_exit(empty_stderr=True), 'expected clean exit'
... ...
@@ -778,7 +786,9 @@ contents go here
778 786
         ):
779 787
             monkeypatch.setattr(click, 'edit', lambda *a, **kw: None)  # noqa: ARG005
780 788
             _result = runner.invoke(
781
-                cli.derivepassphrase, ['--notes', 'sv'], catch_exceptions=False
789
+                cli.derivepassphrase_vault,
790
+                ['--notes', 'sv'],
791
+                catch_exceptions=False,
782 792
             )
783 793
             result = tests.ReadableResult.parse(_result)
784 794
             assert result.clean_exit(empty_stderr=True), 'expected clean exit'
... ...
@@ -797,7 +807,9 @@ contents go here
797 807
         ):
798 808
             monkeypatch.setattr(click, 'edit', lambda *a, **kw: 'long\ntext')  # noqa: ARG005
799 809
             _result = runner.invoke(
800
-                cli.derivepassphrase, ['--notes', 'sv'], catch_exceptions=False
810
+                cli.derivepassphrase_vault,
811
+                ['--notes', 'sv'],
812
+                catch_exceptions=False,
801 813
             )
802 814
             result = tests.ReadableResult.parse(_result)
803 815
             assert result.clean_exit(empty_stderr=True), 'expected clean exit'
... ...
@@ -819,7 +831,9 @@ contents go here
819 831
         ):
820 832
             monkeypatch.setattr(click, 'edit', lambda *a, **kw: '\n\n')  # noqa: ARG005
821 833
             _result = runner.invoke(
822
-                cli.derivepassphrase, ['--notes', 'sv'], catch_exceptions=False
834
+                cli.derivepassphrase_vault,
835
+                ['--notes', 'sv'],
836
+                catch_exceptions=False,
823 837
             )
824 838
             result = tests.ReadableResult.parse(_result)
825 839
             assert result.error_exit(
... ...
@@ -885,7 +899,7 @@ contents go here
885 899
                 cli, '_get_suitable_ssh_keys', tests.suitable_ssh_keys
886 900
             )
887 901
             _result = runner.invoke(
888
-                cli.derivepassphrase,
902
+                cli.derivepassphrase_vault,
889 903
                 ['--config', *command_line],
890 904
                 catch_exceptions=False,
891 905
                 input=input,
... ...
@@ -928,7 +942,7 @@ contents go here
928 942
                 cli, '_get_suitable_ssh_keys', tests.suitable_ssh_keys
929 943
             )
930 944
             _result = runner.invoke(
931
-                cli.derivepassphrase,
945
+                cli.derivepassphrase_vault,
932 946
                 ['--config', *command_line],
933 947
                 catch_exceptions=False,
934 948
                 input=input,
... ...
@@ -955,7 +969,7 @@ contents go here
955 969
 
956 970
             monkeypatch.setattr(cli, '_select_ssh_key', raiser)
957 971
             _result = runner.invoke(
958
-                cli.derivepassphrase,
972
+                cli.derivepassphrase_vault,
959 973
                 ['--key', '--config'],
960 974
                 catch_exceptions=False,
961 975
             )
... ...
@@ -976,7 +990,7 @@ contents go here
976 990
         ):
977 991
             monkeypatch.delenv('SSH_AUTH_SOCK', raising=False)
978 992
             _result = runner.invoke(
979
-                cli.derivepassphrase,
993
+                cli.derivepassphrase_vault,
980 994
                 ['--key', '--config'],
981 995
                 catch_exceptions=False,
982 996
             )
... ...
@@ -997,7 +1011,7 @@ contents go here
997 1011
         ):
998 1012
             monkeypatch.setenv('SSH_AUTH_SOCK', os.getcwd())
999 1013
             _result = runner.invoke(
1000
-                cli.derivepassphrase,
1014
+                cli.derivepassphrase_vault,
1001 1015
                 ['--key', '--config'],
1002 1016
                 catch_exceptions=False,
1003 1017
             )
... ...
@@ -1023,7 +1037,7 @@ contents go here
1023 1037
                 try_race_free_implementation=try_race_free_implementation,
1024 1038
             )
1025 1039
             _result = runner.invoke(
1026
-                cli.derivepassphrase,
1040
+                cli.derivepassphrase_vault,
1027 1041
                 ['--config', '--length=15', DUMMY_SERVICE],
1028 1042
                 catch_exceptions=False,
1029 1043
             )
... ...
@@ -1050,7 +1064,7 @@ contents go here
1050 1064
 
1051 1065
             monkeypatch.setattr(cli, '_save_config', raiser)
1052 1066
             _result = runner.invoke(
1053
-                cli.derivepassphrase,
1067
+                cli.derivepassphrase_vault,
1054 1068
                 ['--config', '--length=15', DUMMY_SERVICE],
1055 1069
                 catch_exceptions=False,
1056 1070
             )
... ...
@@ -1067,7 +1081,7 @@ contents go here
1067 1081
             config={'services': {}},
1068 1082
         ):
1069 1083
             _result = runner.invoke(
1070
-                cli.derivepassphrase, [], catch_exceptions=False
1084
+                cli.derivepassphrase_vault, [], catch_exceptions=False
1071 1085
             )
1072 1086
         result = tests.ReadableResult.parse(_result)
1073 1087
         assert result.error_exit(
... ...
@@ -1084,7 +1098,9 @@ contents go here
1084 1098
             config={'services': {}},
1085 1099
         ):
1086 1100
             _result = runner.invoke(
1087
-                cli.derivepassphrase, [DUMMY_SERVICE], catch_exceptions=False
1101
+                cli.derivepassphrase_vault,
1102
+                [DUMMY_SERVICE],
1103
+                catch_exceptions=False,
1088 1104
             )
1089 1105
         result = tests.ReadableResult.parse(_result)
1090 1106
         assert result.error_exit(
... ...
@@ -1113,7 +1129,7 @@ contents go here
1113 1129
 
1114 1130
             monkeypatch.setattr(os, 'makedirs', makedirs)
1115 1131
             _result = runner.invoke(
1116
-                cli.derivepassphrase,
1132
+                cli.derivepassphrase_vault,
1117 1133
                 ['--config', '-p'],
1118 1134
                 catch_exceptions=False,
1119 1135
                 input='abc\n',
... ...
@@ -1155,7 +1171,7 @@ contents go here
1155 1171
 
1156 1172
             monkeypatch.setattr(cli, '_save_config', obstruct_config_saving)
1157 1173
             _result = runner.invoke(
1158
-                cli.derivepassphrase,
1174
+                cli.derivepassphrase_vault,
1159 1175
                 ['--config', '-p'],
1160 1176
                 catch_exceptions=False,
1161 1177
                 input='abc\n',
... ...
@@ -1188,7 +1204,7 @@ contents go here
1188 1204
 
1189 1205
             monkeypatch.setattr(cli, '_save_config', obstruct_config_saving)
1190 1206
             _result = runner.invoke(
1191
-                cli.derivepassphrase,
1207
+                cli.derivepassphrase_vault,
1192 1208
                 ['--config', '-p'],
1193 1209
                 catch_exceptions=False,
1194 1210
                 input='abc\n',
... ...
@@ -1284,7 +1300,7 @@ contents go here
1284 1300
             config={'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}},
1285 1301
         ):
1286 1302
             _result = runner.invoke(
1287
-                cli.derivepassphrase,
1303
+                cli.derivepassphrase_vault,
1288 1304
                 command_line,
1289 1305
                 catch_exceptions=False,
1290 1306
                 input=input,
... ...
@@ -1486,7 +1502,9 @@ Boo.
1486 1502
                 monkeypatch=monkeypatch, runner=runner, config=start_config
1487 1503
             ):
1488 1504
                 _result = runner.invoke(
1489
-                    cli.derivepassphrase, command_line, catch_exceptions=False
1505
+                    cli.derivepassphrase_vault,
1506
+                    command_line,
1507
+                    catch_exceptions=False,
1490 1508
                 )
1491 1509
                 result = tests.ReadableResult.parse(_result)
1492 1510
                 assert result.clean_exit(
... ...
@@ -1516,8 +1534,8 @@ Boo.
1516 1534
         vfunc: Callable[[click.Context, click.Parameter, Any], int | None],
1517 1535
         input: int,
1518 1536
     ) -> None:
1519
-        ctx = cli.derivepassphrase.make_context(cli.PROG_NAME, [])
1520
-        param = cli.derivepassphrase.params[0]
1537
+        ctx = cli.derivepassphrase_vault.make_context(cli.PROG_NAME, [])
1538
+        param = cli.derivepassphrase_vault.params[0]
1521 1539
         assert vfunc(ctx, param, input) == input
1522 1540
 
1523 1541
     @tests.skip_if_no_agent
... ...
@@ -1549,3 +1567,136 @@ Boo.
1549 1567
             exception = e
1550 1568
         finally:
1551 1569
             assert exception is None, 'exception querying suitable SSH keys'
1570
+
1571
+
1572
+class TestCLITransition:
1573
+    def test_100_help_output(self, monkeypatch: pytest.MonkeyPatch) -> None:
1574
+        runner = click.testing.CliRunner(mix_stderr=False)
1575
+        with tests.isolated_config(
1576
+            monkeypatch=monkeypatch,
1577
+            runner=runner,
1578
+            config={'services': {}},
1579
+        ):
1580
+            _result = runner.invoke(
1581
+                cli.derivepassphrase, ['--help'], catch_exceptions=False
1582
+            )
1583
+            result = tests.ReadableResult.parse(_result)
1584
+        assert result.clean_exit(
1585
+            empty_stderr=True, output='currently implemented subcommands'
1586
+        ), 'expected clean exit, and known help text'
1587
+
1588
+    def test_101_help_output_export(
1589
+        self, monkeypatch: pytest.MonkeyPatch
1590
+    ) -> None:
1591
+        runner = click.testing.CliRunner(mix_stderr=False)
1592
+        with tests.isolated_config(
1593
+            monkeypatch=monkeypatch,
1594
+            runner=runner,
1595
+            config={'services': {}},
1596
+        ):
1597
+            _result = runner.invoke(
1598
+                cli.derivepassphrase,
1599
+                ['export', '--help'],
1600
+                catch_exceptions=False,
1601
+            )
1602
+            result = tests.ReadableResult.parse(_result)
1603
+        assert result.clean_exit(
1604
+            empty_stderr=True, output='only available subcommand'
1605
+        ), 'expected clean exit, and known help text'
1606
+
1607
+    def test_102_help_output_export_vault(
1608
+        self, monkeypatch: pytest.MonkeyPatch
1609
+    ) -> None:
1610
+        runner = click.testing.CliRunner(mix_stderr=False)
1611
+        with tests.isolated_config(
1612
+            monkeypatch=monkeypatch,
1613
+            runner=runner,
1614
+            config={'services': {}},
1615
+        ):
1616
+            _result = runner.invoke(
1617
+                cli.derivepassphrase,
1618
+                ['export', 'vault', '--help'],
1619
+                catch_exceptions=False,
1620
+            )
1621
+            result = tests.ReadableResult.parse(_result)
1622
+        assert result.clean_exit(
1623
+            empty_stderr=True, output='Read the vault-native configuration'
1624
+        ), 'expected clean exit, and known help text'
1625
+
1626
+    def test_103_help_output_vault(
1627
+        self, monkeypatch: pytest.MonkeyPatch
1628
+    ) -> None:
1629
+        runner = click.testing.CliRunner(mix_stderr=False)
1630
+        with tests.isolated_config(
1631
+            monkeypatch=monkeypatch,
1632
+            runner=runner,
1633
+            config={'services': {}},
1634
+        ):
1635
+            _result = runner.invoke(
1636
+                cli.derivepassphrase,
1637
+                ['vault', '--help'],
1638
+                catch_exceptions=False,
1639
+            )
1640
+            result = tests.ReadableResult.parse(_result)
1641
+        assert result.clean_exit(
1642
+            empty_stderr=True, output='Password generation:\n'
1643
+        ), 'expected clean exit, and option groups in help text'
1644
+        assert result.clean_exit(
1645
+            empty_stderr=True, output='Use NUMBER=0, e.g. "--symbol 0"'
1646
+        ), 'expected clean exit, and option group epilog in help text'
1647
+
1648
+    def test_200_forward_export_vault_path_parameter(
1649
+        self, monkeypatch: pytest.MonkeyPatch
1650
+    ) -> None:
1651
+        pytest.importorskip('cryptography', minversion='38.0')
1652
+        runner = click.testing.CliRunner(mix_stderr=False)
1653
+        with tests.isolated_vault_exporter_config(
1654
+            monkeypatch=monkeypatch,
1655
+            runner=runner,
1656
+            vault_config=tests.VAULT_V03_CONFIG,
1657
+            vault_key=tests.VAULT_MASTER_KEY,
1658
+        ):
1659
+            monkeypatch.setenv('VAULT_KEY', tests.VAULT_MASTER_KEY)
1660
+            _result = runner.invoke(
1661
+                cli.derivepassphrase,
1662
+                ['export', 'VAULT_PATH'],
1663
+            )
1664
+        result = tests.ReadableResult.parse(_result)
1665
+        assert result.clean_exit(empty_stderr=False), 'expected clean exit'
1666
+        assert result.stderr == f"""\
1667
+{cli.PROG_NAME}: Deprecation warning: A subcommand will be required in v1.0. See --help for available subcommands.
1668
+{cli.PROG_NAME}: Warning: Defaulting to subcommand "vault".
1669
+"""  # noqa: E501
1670
+        assert json.loads(result.output) == tests.VAULT_V03_CONFIG_DATA
1671
+
1672
+    @pytest.mark.parametrize(
1673
+        'charset_name', ['lower', 'upper', 'number', 'space', 'dash', 'symbol']
1674
+    )
1675
+    def test_210_forward_vault_disable_character_set(
1676
+        self, monkeypatch: pytest.MonkeyPatch, charset_name: str
1677
+    ) -> None:
1678
+        monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt)
1679
+        option = f'--{charset_name}'
1680
+        charset = dpp.vault.Vault._CHARSETS[charset_name].decode('ascii')
1681
+        runner = click.testing.CliRunner(mix_stderr=False)
1682
+        with tests.isolated_config(
1683
+            monkeypatch=monkeypatch,
1684
+            runner=runner,
1685
+            config={'services': {}},
1686
+        ):
1687
+            _result = runner.invoke(
1688
+                cli.derivepassphrase,
1689
+                [option, '0', '-p', DUMMY_SERVICE],
1690
+                input=DUMMY_PASSPHRASE,
1691
+                catch_exceptions=False,
1692
+            )
1693
+            result = tests.ReadableResult.parse(_result)
1694
+        assert result.clean_exit(empty_stderr=False), 'expected clean exit'
1695
+        assert result.stderr == f"""\
1696
+{cli.PROG_NAME}: Deprecation warning: A subcommand will be required in v1.0. See --help for available subcommands.
1697
+{cli.PROG_NAME}: Warning: Defaulting to subcommand "vault".
1698
+"""  # noqa: E501
1699
+        for c in charset:
1700
+            assert (
1701
+                c not in result.output
1702
+            ), f'derived password contains forbidden character {c!r}'
... ...
@@ -12,7 +12,8 @@ import click.testing
12 12
 import pytest
13 13
 
14 14
 import tests
15
-from derivepassphrase.exporter import cli, storeroom, vault_native
15
+from derivepassphrase import cli
16
+from derivepassphrase.exporter import storeroom, vault_native
16 17
 
17 18
 cryptography = pytest.importorskip('cryptography', minversion='38.0')
18 19
 
... ...
@@ -32,7 +33,7 @@ class TestCLI:
32 33
         ):
33 34
             monkeypatch.setenv('VAULT_KEY', tests.VAULT_MASTER_KEY)
34 35
             _result = runner.invoke(
35
-                cli.derivepassphrase_export,
36
+                cli.derivepassphrase_export_vault,
36 37
                 ['VAULT_PATH'],
37 38
             )
38 39
         result = tests.ReadableResult.parse(_result)
... ...
@@ -47,7 +48,7 @@ class TestCLI:
47 48
             vault_config=tests.VAULT_V03_CONFIG,
48 49
         ):
49 50
             _result = runner.invoke(
50
-                cli.derivepassphrase_export,
51
+                cli.derivepassphrase_export_vault,
51 52
                 ['-k', tests.VAULT_MASTER_KEY, '.vault'],
52 53
             )
53 54
         result = tests.ReadableResult.parse(_result)
... ...
@@ -91,7 +92,7 @@ class TestCLI:
91 92
             vault_config=config,
92 93
         ):
93 94
             _result = runner.invoke(
94
-                cli.derivepassphrase_export,
95
+                cli.derivepassphrase_export_vault,
95 96
                 ['-f', format, '-k', tests.VAULT_MASTER_KEY, 'VAULT_PATH'],
96 97
             )
97 98
         result = tests.ReadableResult.parse(_result)
... ...
@@ -113,7 +114,7 @@ class TestCLI:
113 114
             vault_key=tests.VAULT_MASTER_KEY,
114 115
         ):
115 116
             _result = runner.invoke(
116
-                cli.derivepassphrase_export,
117
+                cli.derivepassphrase_export_vault,
117 118
                 ['does-not-exist.txt'],
118 119
             )
119 120
         result = tests.ReadableResult.parse(_result)
... ...
@@ -134,7 +135,7 @@ class TestCLI:
134 135
             vault_key=tests.VAULT_MASTER_KEY,
135 136
         ):
136 137
             _result = runner.invoke(
137
-                cli.derivepassphrase_export,
138
+                cli.derivepassphrase_export_vault,
138 139
                 ['.vault'],
139 140
             )
140 141
         result = tests.ReadableResult.parse(_result)
... ...
@@ -155,7 +156,7 @@ class TestCLI:
155 156
             vault_key=tests.VAULT_MASTER_KEY,
156 157
         ):
157 158
             _result = runner.invoke(
158
-                cli.derivepassphrase_export,
159
+                cli.derivepassphrase_export_vault,
159 160
                 ['-f', 'v0.3', '.vault'],
160 161
             )
161 162
         result = tests.ReadableResult.parse(_result)
... ...
@@ -181,7 +182,7 @@ class TestCLI:
181 182
 
182 183
             monkeypatch.setattr(cli, '_load_data', _load_data)
183 184
             _result = runner.invoke(
184
-                cli.derivepassphrase_export,
185
+                cli.derivepassphrase_export_vault,
185 186
                 ['.vault'],
186 187
             )
187 188
         result = tests.ReadableResult.parse(_result)
... ...
@@ -10,8 +10,7 @@ import click.testing
10 10
 import pytest
11 11
 
12 12
 import tests
13
-from derivepassphrase import exporter
14
-from derivepassphrase.exporter import cli
13
+from derivepassphrase import cli, exporter
15 14
 
16 15
 
17 16
 class Test001ExporterUtils:
... ...
@@ -116,7 +115,7 @@ class Test002CLI:
116 115
             vault_key=tests.VAULT_MASTER_KEY,
117 116
         ):
118 117
             _result = runner.invoke(
119
-                cli.derivepassphrase_export,
118
+                cli.derivepassphrase_export_vault,
120 119
                 ['-f', 'INVALID', 'VAULT_PATH'],
121 120
                 catch_exceptions=False,
122 121
             )
... ...
@@ -165,7 +164,7 @@ class Test002CLI:
165 164
             vault_key=key,
166 165
         ):
167 166
             _result = runner.invoke(
168
-                cli.derivepassphrase_export,
167
+                cli.derivepassphrase_export_vault,
169 168
                 ['-f', format, 'VAULT_PATH'],
170 169
                 catch_exceptions=False,
171 170
             )
172 171