tiny-pki 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,176 @@
1
+ """Command table, per-command flags, and help text for the tiny-pki CLI.
2
+
3
+ ``COMMAND_FLAGS`` is the single source for which flags each verb accepts: the
4
+ flag parser, ``help <command>``, the REPL completer, and the bash/zsh/fish
5
+ completion scripts all read it, and ``docs/cli.md`` is tested against it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ from tiny_pki.constants import KEY_TYPES
13
+ from tiny_pki.store import CHECK_KINDS
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Flag:
18
+ """One ``--name`` option of a command.
19
+
20
+ ``value`` is the placeholder shown in help (``None`` for a switch that takes
21
+ no value). ``choices`` lists the accepted values for completion, ``path``
22
+ marks values that name a file, ``repeatable`` flags accumulate, and
23
+ ``allow_empty`` lets ``--name`` without a value fall back to the default.
24
+ """
25
+
26
+ name: str
27
+ help: str
28
+ value: str | None = None
29
+ choices: tuple[str, ...] = ()
30
+ path: bool = False
31
+ repeatable: bool = False
32
+ allow_empty: bool = False
33
+
34
+ @property
35
+ def option(self) -> str:
36
+ return f"--{self.name}"
37
+
38
+
39
+ _DAYS = Flag("days", "validity in days (default 397 for clients, 90 for servers)", value="N", allow_empty=True)
40
+ _KEY_SIZE = Flag(
41
+ "key-size",
42
+ "RSA key size in bits (default 4096 for the CA, 3072 for leaves)",
43
+ value="BITS",
44
+ choices=("2048", "3072", "4096"),
45
+ )
46
+ _KEY_TYPE = Flag("key-type", "key algorithm (default rsa)", value="TYPE", choices=KEY_TYPES, allow_empty=True)
47
+ _ORG = Flag("org", "organization (O) in the subject", value="NAME", allow_empty=True)
48
+ _DRY_RUN = Flag("dry-run", "show what would change and write nothing")
49
+ _JSON = Flag("json", "machine-readable JSON output")
50
+ _PASSWORD_FILE = Flag("password-file", "read the PKCS#12 password from this file", value="PATH", path=True)
51
+
52
+ COMMAND_FLAGS: dict[str, tuple[Flag, ...]] = {
53
+ "check": (
54
+ Flag("by", "alert on anything expiring by this date", value="YYYY-MM-DD"),
55
+ Flag("ca", "CA certificate to verify file targets against", value="PATH", path=True),
56
+ Flag("crl", "CRL to check file targets against", value="PATH", path=True),
57
+ Flag("include-revoked", "also report revoked store entries"),
58
+ _JSON,
59
+ Flag(
60
+ "kind",
61
+ "only check this kind (repeatable)",
62
+ value="KIND",
63
+ choices=tuple(sorted(CHECK_KINDS)),
64
+ repeatable=True,
65
+ ),
66
+ _PASSWORD_FILE,
67
+ Flag("quiet", "print only rows that need attention"),
68
+ Flag("within", "alert window in days (default: a third of each lifetime)", value="DAYS"),
69
+ ),
70
+ "create": (
71
+ Flag("allow-dn-special-chars", 'allow , + = " < > ; or a leading # in the CN'),
72
+ Flag("allow-long-validity", "allow validity beyond the 200 / 825 day caps"),
73
+ _DAYS,
74
+ Flag("keep-previous", "client only: keep the previous certificate live for rotation"),
75
+ _KEY_SIZE,
76
+ _KEY_TYPE,
77
+ Flag("no-cn-san", "server only: do not add the CN to the SANs"),
78
+ _ORG,
79
+ Flag("san", "server only: DNS name or IP address (repeatable)", value="NAME", repeatable=True),
80
+ Flag("yes", "server only: add the CN to the SANs without asking"),
81
+ ),
82
+ "crl": (Flag("days", "change the stored CRL lifetime (1-365)", value="N"),),
83
+ "delete": (Flag("force", "revoke an active certificate first"), _DRY_RUN),
84
+ "export": (
85
+ Flag("legacy", "p12 only: 3DES / SHA-1 encryption for old Android and Apple keychains"),
86
+ Flag("out", "output file", value="PATH", path=True),
87
+ _PASSWORD_FILE,
88
+ ),
89
+ "init": (
90
+ Flag("cn", 'CA common name (default "Private CA")', value="NAME", allow_empty=True),
91
+ Flag("crl-days", "CRL lifetime in days (1-365, default 30)", value="N"),
92
+ Flag("days", "CA validity in days (default 3650)", value="N", allow_empty=True),
93
+ _KEY_SIZE,
94
+ _KEY_TYPE,
95
+ _ORG,
96
+ Flag("permit", "name constraint: DNS suffix or IP network (repeatable)", value="NAME", repeatable=True),
97
+ ),
98
+ "inspect": (),
99
+ "list": (_JSON,),
100
+ "revoke": (_DRY_RUN,),
101
+ "show": (),
102
+ }
103
+ COMMAND_FLAGS["renew-crl"] = COMMAND_FLAGS["crl"]
104
+ COMMAND_FLAGS["completion"] = (
105
+ Flag("force", "with --install: overwrite a different existing script"),
106
+ Flag("install", "write the script to the per-user completion directory"),
107
+ _JSON,
108
+ )
109
+
110
+ COMMAND_USAGE: dict[str, str] = {
111
+ "check": "check [PATH...]",
112
+ "completion": "completion bash|zsh|fish [--install] [--force] [--json]",
113
+ "create": "create client|server NAME",
114
+ "crl": "crl",
115
+ "delete": "delete NAME|0xSERIAL",
116
+ "edit-mode": "edit-mode [emacs|vim]",
117
+ "export": "export pem|p12 NAME",
118
+ "help": "help [COMMAND]",
119
+ "init": "init",
120
+ "inspect": "inspect NAME|PATH",
121
+ "list": "list [ca|certs|clients|servers|revoked]",
122
+ "renew-crl": "renew-crl",
123
+ "revoke": "revoke NAME|0xSERIAL",
124
+ "show": "show ca|certs|crl|NAME",
125
+ }
126
+
127
+ COMMAND_HELP: tuple[tuple[str, str], ...] = (
128
+ (
129
+ "check",
130
+ "Flag expired, expiring, revoked or untrusted certificates and CRLs in the store or in files.",
131
+ ),
132
+ ("clear", "Clear the terminal screen."),
133
+ ("completion", "Print or install bash/zsh/fish tab-completion scripts."),
134
+ ("create", "Issue a client or server certificate."),
135
+ ("crl", "Regenerate the CRL from revoked entries; --days N changes the stored CRL lifetime."),
136
+ ("delete", "Remove a revoked certificate's files; --force revokes an active one first; --dry-run previews."),
137
+ ("edit-mode", "Switch Emacs vs Vim keys: edit-mode emacs | vim."),
138
+ ("exit", "Leave the REPL."),
139
+ ("export", "Export pem|p12 for an identity."),
140
+ ("help", "Show this list, or help <command> for its usage and flags."),
141
+ ("init", "Create a new CA in --store."),
142
+ ("inspect", "Inspect a store identity or PEM path."),
143
+ ("list", "List ca|clients|servers|revoked|certs (optional --json)."),
144
+ ("quit", "Leave the REPL (same as exit)."),
145
+ ("renew-crl", "Alias for crl."),
146
+ ("revoke", "Revoke an identity and regenerate the CRL; --dry-run previews."),
147
+ ("show", "Show ca|certs|crl|<identity> (aliases list categories)."),
148
+ )
149
+
150
+ COMMANDS: tuple[str, ...] = tuple(sorted({name for name, _ in COMMAND_HELP}))
151
+
152
+ POSITIONAL_CHOICES: dict[str, tuple[str, ...]] = {
153
+ "completion": ("bash", "fish", "zsh"),
154
+ "create": ("client", "server"),
155
+ "edit-mode": ("emacs", "vim"),
156
+ "export": ("p12", "pem"),
157
+ "help": COMMANDS,
158
+ "list": ("ca", "certs", "clients", "revoked", "servers"),
159
+ "show": ("ca", "certs", "clients", "crl", "revoked", "servers"),
160
+ }
161
+
162
+ PKI_COMMANDS: frozenset[str] = frozenset(
163
+ {
164
+ "check",
165
+ "create",
166
+ "crl",
167
+ "delete",
168
+ "export",
169
+ "init",
170
+ "inspect",
171
+ "list",
172
+ "renew-crl",
173
+ "revoke",
174
+ "show",
175
+ }
176
+ )
@@ -0,0 +1,91 @@
1
+ """prompt_toolkit completer for the tiny-pki REPL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Callable, Iterable
7
+ from dataclasses import dataclass
8
+
9
+ from prompt_toolkit.completion import Completer, Completion
10
+ from prompt_toolkit.document import Document
11
+
12
+ from tiny_pki.cli.commands import COMMAND_FLAGS
13
+ from tiny_pki.cli.theme import Theme
14
+
15
+ _CMD_THEN_REST = re.compile(r"^(\S+)(\s+)(.*)$", re.DOTALL)
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class CmdCtx:
20
+ """Completing a command name."""
21
+
22
+ partial: str
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ArgCtx:
27
+ """Completing arguments for a known command; ``previous`` holds the complete tokens before ``partial``."""
28
+
29
+ command: str
30
+ partial: str
31
+ previous: tuple[str, ...] = ()
32
+
33
+
34
+ def parse_completion_buffer(buf: str) -> CmdCtx | ArgCtx:
35
+ """Classify the current input buffer for completion."""
36
+ stripped = buf.lstrip()
37
+ if not stripped:
38
+ return CmdCtx(partial="")
39
+ match = _CMD_THEN_REST.match(stripped)
40
+ if match is None:
41
+ return CmdCtx(partial=stripped)
42
+ command, _ws, rest = match.group(1), match.group(2), match.group(3)
43
+ tokens = tuple(rest.split())
44
+ if not rest or stripped[-1:].isspace():
45
+ return ArgCtx(command=command, partial="", previous=tokens)
46
+ return ArgCtx(command=command, partial=tokens[-1], previous=tokens[:-1])
47
+
48
+
49
+ class ReplCompleter(Completer):
50
+ """Complete command names, their flags and flag values, and positional argument tokens."""
51
+
52
+ def __init__(
53
+ self,
54
+ *,
55
+ commands: Iterable[str],
56
+ theme: Theme,
57
+ argument_tokens: Callable[[str], Iterable[str]] | None = None,
58
+ ) -> None:
59
+ self._commands = tuple(sorted(commands))
60
+ self._theme = theme
61
+
62
+ def _empty_tokens(_cmd: str) -> tuple[str, ...]:
63
+ return ()
64
+
65
+ self._argument_tokens: Callable[[str], Iterable[str]] = argument_tokens or _empty_tokens
66
+
67
+ def get_completions(self, document: Document, complete_event: object) -> Iterable[Completion]:
68
+ del complete_event
69
+ ctx = parse_completion_buffer(document.text_before_cursor)
70
+ if isinstance(ctx, CmdCtx):
71
+ style = self._theme.completion_command_style()
72
+ for cmd in self._commands:
73
+ if cmd.startswith(ctx.partial):
74
+ yield Completion(cmd, start_position=-len(ctx.partial), style=style)
75
+ return
76
+ style = self._theme.completion_parameter_style()
77
+ for token in self._candidates(ctx):
78
+ if token.startswith(ctx.partial):
79
+ yield Completion(token, start_position=-len(ctx.partial), style=style)
80
+
81
+ def _candidates(self, ctx: ArgCtx) -> Iterable[str]:
82
+ flags = COMMAND_FLAGS.get(ctx.command, ())
83
+ pending = next((f for f in flags if ctx.previous[-1:] == (f.option,)), None)
84
+ if pending is not None and pending.value is not None:
85
+ return pending.choices
86
+ used = set(ctx.previous)
87
+ unused = [f.option for f in flags if f.repeatable or f.option not in used]
88
+ if ctx.partial.startswith("-"):
89
+ return unused
90
+ tokens = list(self._argument_tokens(ctx.command))
91
+ return [*tokens, *unused] if not ctx.partial else tokens
@@ -0,0 +1,368 @@
1
+ """Shell completion script generation and optional per-user install."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import NoReturn
10
+
11
+ from tiny_pki.cli.commands import COMMAND_FLAGS, COMMAND_HELP, COMMANDS, POSITIONAL_CHOICES, Flag
12
+
13
+ _PROG = "tiny-pki"
14
+ _SHELLS = frozenset({"bash", "fish", "zsh"})
15
+ _TOP_FLAGS = (
16
+ Flag("color", "colour output", value="WHEN", choices=("auto", "always", "never")),
17
+ Flag("edit-mode", "REPL key bindings", value="MODE", choices=POSITIONAL_CHOICES["edit-mode"]),
18
+ Flag("help", "show launcher help"),
19
+ Flag("store", "store directory (or TINY_PKI_STORE)", value="DIR", path=True),
20
+ Flag("version", "print the version and exit"),
21
+ )
22
+ # Commands whose positional arguments are file paths.
23
+ _PATH_POSITIONALS = frozenset({"check", "inspect"})
24
+
25
+
26
+ def completion_install_path(shell: str) -> Path:
27
+ """Conventional per-user path for a shell's tiny-pki completion script."""
28
+ xdg_data = _xdg_base("XDG_DATA_HOME", Path.home() / ".local" / "share")
29
+ if shell == "bash":
30
+ return xdg_data / "bash-completion" / "completions" / "tiny-pki.bash"
31
+ if shell == "fish":
32
+ xdg_config = _xdg_base("XDG_CONFIG_HOME", Path.home() / ".config")
33
+ return xdg_config / "fish" / "completions" / "tiny-pki.fish"
34
+ return xdg_data / "zsh" / "site-functions" / "_tiny-pki"
35
+
36
+
37
+ def completion_script(shell: str) -> str:
38
+ """Return a tab-completion script for ``bash``, ``zsh``, or ``fish``."""
39
+ if shell == "bash":
40
+ return _bash_script()
41
+ if shell == "fish":
42
+ return _fish_script()
43
+ if shell == "zsh":
44
+ return _zsh_script()
45
+ raise ValueError(f"unsupported shell: {shell!r} (expected bash, zsh, or fish)")
46
+
47
+
48
+ def run_completion(argv: list[str]) -> int:
49
+ """Handle ``tiny-pki completion <shell> [--install] [--force] [--json]``.
50
+
51
+ Returns 0 on success, 2 on usage errors, 1 on install I/O failures.
52
+ """
53
+ try:
54
+ shell, install, force, as_json = _parse_completion_argv(argv)
55
+ except SystemExit as exc:
56
+ code = exc.code
57
+ return int(code) if isinstance(code, int) else 2
58
+ if force and not install:
59
+ _emit_error(
60
+ message="--force only applies with --install",
61
+ as_json=as_json,
62
+ hint="Pass --install, or drop --force.",
63
+ )
64
+ return 2
65
+
66
+ script = completion_script(shell)
67
+ if not script.endswith("\n"):
68
+ script += "\n"
69
+
70
+ if not install:
71
+ if as_json:
72
+ print(json.dumps({"shell": shell, "script": script}, sort_keys=True))
73
+ else:
74
+ sys.stdout.write(script)
75
+ return 0
76
+
77
+ path = completion_install_path(shell)
78
+ want = script.encode()
79
+
80
+ def _refuse_clobber() -> NoReturn:
81
+ _emit_error(
82
+ message=f"{path} already exists with different contents",
83
+ as_json=as_json,
84
+ hint="Re-run with --force to overwrite it.",
85
+ )
86
+ raise SystemExit(2)
87
+
88
+ try:
89
+ # Refuse symlinks (including dangling) so --force cannot rewrite a
90
+ # link target outside the conventional install path.
91
+ if path.is_symlink() or (path.exists() and not path.is_file()):
92
+ _emit_error(
93
+ message=f"{path} exists but is not a regular file",
94
+ as_json=as_json,
95
+ hint="Remove it (or point XDG_DATA_HOME/XDG_CONFIG_HOME elsewhere), then retry.",
96
+ )
97
+ return 1
98
+ current = path.read_bytes() if path.is_file() else None
99
+ if current == want:
100
+ action = "unchanged"
101
+ elif current is not None and not force:
102
+ _refuse_clobber()
103
+ elif force:
104
+ path.parent.mkdir(parents=True, exist_ok=True)
105
+ path.write_bytes(want)
106
+ action = "written"
107
+ else:
108
+ path.parent.mkdir(parents=True, exist_ok=True)
109
+ try:
110
+ with path.open("xb") as handle:
111
+ handle.write(want)
112
+ action = "written"
113
+ except FileExistsError:
114
+ if path.read_bytes() != want:
115
+ _refuse_clobber()
116
+ action = "unchanged"
117
+ except SystemExit as exc:
118
+ code = exc.code
119
+ return int(code) if isinstance(code, int) else 2
120
+ except OSError as exc:
121
+ _emit_error(
122
+ message=f"could not install the completion script to {path}: {exc}",
123
+ as_json=as_json,
124
+ hint="Ensure the path is a writable file (not a directory) that you own.",
125
+ )
126
+ return 1
127
+
128
+ if as_json:
129
+ print(json.dumps({"action": action, "path": str(path), "shell": shell}, sort_keys=True))
130
+ else:
131
+ lines = [f"{action}: {path}"]
132
+ if shell == "zsh":
133
+ lines.append(
134
+ f" ensure {path.parent} is on $fpath before `compinit` (e.g. in ~/.zshrc), then open a new shell"
135
+ )
136
+ else:
137
+ lines.append(" open a new shell to pick it up")
138
+ print("\n".join(lines))
139
+ return 0
140
+
141
+
142
+ def _bash_script() -> str:
143
+ value_flags = " ".join(f.option for f in _TOP_FLAGS if f.value is not None)
144
+ top = " ".join(f.option for f in _TOP_FLAGS)
145
+ top_values = "\n".join(f" {f.option}) {_bash_values(f)}; return ;;" for f in _TOP_FLAGS if f.value is not None)
146
+ value_cases = [f' *" {f.option}") {_bash_values(f)}; return ;;' for f in _TOP_FLAGS if f.value is not None]
147
+ flag_cases: list[str] = []
148
+ positional_cases: list[str] = []
149
+ for cmd in COMMANDS:
150
+ flags = COMMAND_FLAGS.get(cmd, ())
151
+ for flag in flags:
152
+ if flag.value is not None:
153
+ value_cases.append(f' "{cmd} {flag.option}") {_bash_values(flag)}; return ;;')
154
+ if flags:
155
+ flag_cases.append(
156
+ f' {cmd}) COMPREPLY=( $(compgen -W "{" ".join(f.option for f in flags)}" -- "$cur") ) ;;'
157
+ )
158
+ if cmd in _PATH_POSITIONALS:
159
+ positional_cases.append(f' {cmd}) COMPREPLY=( $(compgen -f -- "$cur") ) ;;')
160
+ elif cmd in POSITIONAL_CHOICES:
161
+ words = " ".join(POSITIONAL_CHOICES[cmd])
162
+ positional_cases.append(
163
+ f' {cmd}) [[ "$prev" == "$cmd" ]] && COMPREPLY=( $(compgen -W "{words}" -- "$cur") ) ;;'
164
+ )
165
+ nl = "\n"
166
+ return f"""# tiny-pki bash completion — generated by `tiny-pki completion bash`
167
+ _tiny_pki_completion() {{
168
+ local cur prev cmd="" i word
169
+ COMPREPLY=()
170
+ cur="${{COMP_WORDS[COMP_CWORD]}}"
171
+ prev="${{COMP_WORDS[COMP_CWORD-1]}}"
172
+ for (( i = 1; i < COMP_CWORD; i++ )); do
173
+ word="${{COMP_WORDS[i]}}"
174
+ case " {value_flags} " in
175
+ *" $word "*) (( i++ )); continue ;;
176
+ esac
177
+ [[ "$word" == -* ]] && continue
178
+ cmd="$word"
179
+ break
180
+ done
181
+ if [[ -z "$cmd" ]]; then
182
+ case "$prev" in
183
+ {top_values}
184
+ esac
185
+ COMPREPLY=( $(compgen -W "{" ".join(COMMANDS)} {top}" -- "$cur") )
186
+ return
187
+ fi
188
+ case "$cmd $prev" in
189
+ {nl.join(value_cases)}
190
+ esac
191
+ if [[ "$cur" != -* ]]; then
192
+ case "$cmd" in
193
+ {nl.join(positional_cases)}
194
+ esac
195
+ (( ${{#COMPREPLY[@]}} )) && return
196
+ fi
197
+ case "$cmd" in
198
+ {nl.join(flag_cases)}
199
+ esac
200
+ }}
201
+ complete -F _tiny_pki_completion {_PROG}
202
+ """
203
+
204
+
205
+ def _bash_values(flag: Flag) -> str:
206
+ """The ``COMPREPLY=...`` assignment for a flag's value."""
207
+ if flag.choices:
208
+ return f'COMPREPLY=( $(compgen -W "{" ".join(flag.choices)}" -- "$cur") )'
209
+ if flag.name == "store":
210
+ return 'COMPREPLY=( $(compgen -d -- "$cur") )'
211
+ if flag.path:
212
+ return 'COMPREPLY=( $(compgen -f -- "$cur") )'
213
+ return "COMPREPLY=()"
214
+
215
+
216
+ def _emit_error(*, message: str, as_json: bool, hint: str | None = None) -> None:
217
+ if as_json:
218
+ payload: dict[str, object] = {"error": "usage_error", "message": message, "ok": False}
219
+ if hint:
220
+ payload["hint"] = hint
221
+ if "not a regular file" in message or "could not install" in message:
222
+ payload["error"] = "install_failed"
223
+ print(json.dumps(payload, sort_keys=True), file=sys.stderr)
224
+ return
225
+ print(message, file=sys.stderr)
226
+ if hint:
227
+ print(hint, file=sys.stderr)
228
+
229
+
230
+ def _fish_script() -> str:
231
+ lines = [
232
+ "# tiny-pki fish completion — generated by `tiny-pki completion fish`",
233
+ f"complete -c {_PROG} -f",
234
+ ]
235
+ for flag in _TOP_FLAGS:
236
+ condition = "" if flag.value is not None else "-n __fish_use_subcommand "
237
+ lines.append(f"complete -c {_PROG} {condition}{_fish_flag(flag)}")
238
+ descriptions = dict(COMMAND_HELP)
239
+ for cmd in COMMANDS:
240
+ lines.append(f"complete -c {_PROG} -n __fish_use_subcommand -a {cmd} -d {_sq(descriptions[cmd])}")
241
+ for cmd in COMMANDS:
242
+ seen = _sq(f"__fish_seen_subcommand_from {cmd}")
243
+ if cmd in _PATH_POSITIONALS:
244
+ lines.append(f"complete -c {_PROG} -n {seen} -F")
245
+ elif cmd in POSITIONAL_CHOICES:
246
+ lines.append(f"complete -c {_PROG} -n {seen} -a {_sq(' '.join(POSITIONAL_CHOICES[cmd]))}")
247
+ for flag in COMMAND_FLAGS.get(cmd, ()):
248
+ lines.append(f"complete -c {_PROG} -n {seen} {_fish_flag(flag)}")
249
+ return "\n".join(lines) + "\n"
250
+
251
+
252
+ def _fish_flag(flag: Flag) -> str:
253
+ parts = [f"-l {flag.name}"]
254
+ if flag.choices:
255
+ parts.append(f"-x -a {_sq(' '.join(flag.choices))}")
256
+ elif flag.name == "store":
257
+ parts.append("-x -a '(__fish_complete_directories)'")
258
+ elif flag.path:
259
+ parts.append("-r -F")
260
+ elif flag.value is not None:
261
+ parts.append("-x")
262
+ parts.append(f"-d {_sq(flag.help)}")
263
+ return " ".join(parts)
264
+
265
+
266
+ def _parse_completion_argv(argv: list[str]) -> tuple[str, bool, bool, bool]:
267
+ if not argv:
268
+ print(
269
+ "usage: tiny-pki completion <bash|zsh|fish> [--install] [--force] [--json]",
270
+ file=sys.stderr,
271
+ )
272
+ raise SystemExit(2)
273
+ shell = argv[0]
274
+ if shell not in _SHELLS:
275
+ print(f"unsupported shell: {shell!r} (expected bash, zsh, or fish)", file=sys.stderr)
276
+ raise SystemExit(2)
277
+ install = False
278
+ force = False
279
+ as_json = False
280
+ for token in argv[1:]:
281
+ if token == "--install":
282
+ install = True
283
+ elif token == "--force":
284
+ force = True
285
+ elif token == "--json":
286
+ as_json = True
287
+ else:
288
+ print(f"unexpected argument: {token!r}", file=sys.stderr)
289
+ raise SystemExit(2)
290
+ return shell, install, force, as_json
291
+
292
+
293
+ def _xdg_base(var: str, default: Path) -> Path:
294
+ """XDG base dir from ``$var``, honoring the spec: a relative value is ignored."""
295
+ value = os.environ.get(var, "")
296
+ candidate = Path(value) if value else default
297
+ return candidate if candidate.is_absolute() else default
298
+
299
+
300
+ def _zsh_script() -> str:
301
+ descriptions = dict(COMMAND_HELP)
302
+ cmds = "\n ".join(_sq(f"{cmd.replace(':', '\\:')}:{descriptions[cmd]}") for cmd in COMMANDS)
303
+ top = " \\\n ".join(_zsh_spec(flag) for flag in _TOP_FLAGS)
304
+ global_specs = [_zsh_spec(flag) for flag in _TOP_FLAGS if flag.value is not None]
305
+ cases: list[str] = []
306
+ for cmd in COMMANDS:
307
+ specs = [_zsh_spec(flag) for flag in COMMAND_FLAGS.get(cmd, ())] + global_specs
308
+ if cmd in _PATH_POSITIONALS:
309
+ specs.append("'*:file:_files'")
310
+ elif cmd == "export":
311
+ specs.extend(["'1:format:(p12 pem)'", "'2:name: '"])
312
+ elif cmd in POSITIONAL_CHOICES:
313
+ specs.append(_sq(f"1:argument:({' '.join(POSITIONAL_CHOICES[cmd])})"))
314
+ cases.append(f" {cmd}) _arguments {' '.join(specs)} ;;")
315
+ body = "\n".join(cases)
316
+ # Function name must match the installed file (``_tiny-pki``) so zsh
317
+ # autoload from ``$fpath`` finds the right completer without a second
318
+ # ``compdef`` call.
319
+ return f"""#compdef {_PROG}
320
+ # tiny-pki zsh completion — generated by `tiny-pki completion zsh`
321
+ _tiny-pki() {{
322
+ local -a cmds
323
+ cmds=(
324
+ {cmds}
325
+ )
326
+ local context state state_descr line
327
+ typeset -A opt_args
328
+ _arguments -C \\
329
+ {top} \\
330
+ '1:command:->cmd' \\
331
+ '*::arg:->args'
332
+ case $state in
333
+ cmd)
334
+ _describe -t commands 'tiny-pki command' cmds
335
+ ;;
336
+ args)
337
+ case $words[1] in
338
+ {body}
339
+ esac
340
+ ;;
341
+ esac
342
+ }}
343
+ """
344
+
345
+
346
+ def _zsh_spec(flag: Flag) -> str:
347
+ """An ``_arguments`` option spec such as ``'--key-type[key algorithm]:TYPE:(rsa ec-p256)'``."""
348
+ text = flag.help.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
349
+ prefix = "*" if flag.repeatable else ""
350
+ spec = f"{prefix}{flag.option}[{text}]"
351
+ if flag.value is not None:
352
+ if flag.choices:
353
+ action = f"({' '.join(flag.choices)})"
354
+ elif flag.name == "store":
355
+ action = "_files -/"
356
+ elif flag.path:
357
+ action = "_files"
358
+ else:
359
+ action = " "
360
+ spec += f":{flag.value}:{action}"
361
+ elif flag.name in {"help", "version"}:
362
+ spec = f"(- *){spec}"
363
+ return _sq(spec)
364
+
365
+
366
+ def _sq(text: str) -> str:
367
+ """Quote ``text`` as one single-quoted shell word."""
368
+ return "'" + text.replace("'", "'\\''") + "'"
tiny_pki/cli/entry.py ADDED
@@ -0,0 +1,27 @@
1
+ """``tiny-pki`` console script: explain a missing ``[cli]`` extra instead of a traceback.
2
+
3
+ The library needs only ``cryptography``; the REPL and one-shot CLI also need
4
+ ``prompt-toolkit``, which ships in the optional ``tiny-pki[cli]`` extra. This
5
+ module must not import :mod:`tiny_pki.cli.main` at module level, because that
6
+ import is exactly what fails without the extra.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib
12
+ import sys
13
+ from importlib.util import find_spec
14
+
15
+ CLI_EXTRA_HINT = (
16
+ "tiny-pki: the command-line tool needs the [cli] extra, which is not installed. "
17
+ "Install it with: pipx install 'tiny-pki[cli]' (or uv tool install 'tiny-pki[cli]', "
18
+ "or pip install 'tiny-pki[cli]')"
19
+ )
20
+
21
+
22
+ def main() -> None:
23
+ """Run the CLI, or exit 1 with install instructions when ``prompt-toolkit`` is missing."""
24
+ if find_spec("prompt_toolkit") is None:
25
+ print(CLI_EXTRA_HINT, file=sys.stderr)
26
+ raise SystemExit(1)
27
+ importlib.import_module("tiny_pki.cli.main").main()