wen 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.
am/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """A tiny local manager for API keys."""
2
+
3
+ __version__ = "0.1.0"
am/cli.py ADDED
@@ -0,0 +1,216 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import subprocess
6
+ import sys
7
+ from typing import Annotated
8
+
9
+ import keyring
10
+ import typer
11
+ from keyring.errors import KeyringError
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ app = typer.Typer(
16
+ name="am",
17
+ help="Your API keys, one place.",
18
+ no_args_is_help=True,
19
+ pretty_exceptions_show_locals=False,
20
+ )
21
+ console = Console()
22
+ err_console = Console(stderr=True)
23
+ SERVICE = "am-api-keys"
24
+ SAFE_KEYRINGS = {
25
+ ("keyring.backends.macOS", "Keyring"),
26
+ ("keyring.backends.Windows", "WinVaultKeyring"),
27
+ ("keyring.backends.SecretService", "Keyring"),
28
+ ("keyring.backends.kwallet", "Keyring"),
29
+ }
30
+
31
+
32
+ def _validate_name(name: str) -> str:
33
+ normalized = name.strip().lower()
34
+ if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}", normalized):
35
+ raise typer.BadParameter(
36
+ "use 1-64 letters, numbers, dots, underscores, or hyphens"
37
+ )
38
+ if normalized == "__am_names__":
39
+ raise typer.BadParameter("this name is reserved by am")
40
+ return normalized
41
+
42
+
43
+ def _env_var(name: str) -> str:
44
+ known = {
45
+ "openai": "OPENAI_API_KEY",
46
+ "anthropic": "ANTHROPIC_API_KEY",
47
+ "github": "GITHUB_TOKEN",
48
+ "deepseek": "DEEPSEEK_API_KEY",
49
+ }
50
+ return known.get(name, f"{re.sub(r'[^A-Z0-9]+', '_', name.upper()).strip('_')}_API_KEY")
51
+
52
+
53
+ def _handle_keyring_error(exc: Exception) -> None:
54
+ err_console.print(
55
+ "[red]Could not access the system credential store.[/red] "
56
+ "Install and unlock your OS keychain (on Linux, a Secret Service backend "
57
+ "is required). No key was written to a file."
58
+ )
59
+ raise typer.Exit(code=1) from exc
60
+
61
+
62
+ def _ensure_secure_keyring() -> None:
63
+ """Refuse known or unknown backends unless they are a supported OS store."""
64
+ try:
65
+ backend = keyring.get_keyring()
66
+ except Exception as exc:
67
+ _handle_keyring_error(exc)
68
+ backend_type = type(backend)
69
+ if (backend_type.__module__, backend_type.__name__) not in SAFE_KEYRINGS:
70
+ err_console.print(
71
+ "[red]No supported secure system credential store is active.[/red] "
72
+ "Use macOS Keychain, Windows Credential Locker, Linux Secret Service, "
73
+ "or KDE Wallet. am will not use a file-based or unknown backend."
74
+ )
75
+ raise typer.Exit(code=1)
76
+
77
+
78
+ @app.command()
79
+ def add(name: Annotated[str, typer.Argument(help="A short name, e.g. openai")]) -> None:
80
+ """Securely save an API key in your system credential store."""
81
+ name = _validate_name(name)
82
+ value = typer.prompt(f"API key for {name}", hide_input=True).strip()
83
+ if not value:
84
+ raise typer.BadParameter("API key cannot be empty")
85
+ try:
86
+ _ensure_secure_keyring()
87
+ previous_value = keyring.get_password(SERVICE, name)
88
+ raw_names = keyring.get_password(SERVICE, "__am_names__")
89
+ names = set(filter(None, raw_names.splitlines())) if raw_names else set()
90
+ names.add(name)
91
+ keyring.set_password(SERVICE, name, value)
92
+ try:
93
+ keyring.set_password(SERVICE, "__am_names__", "\n".join(sorted(names)))
94
+ except KeyringError:
95
+ # Best-effort rollback so a failed index update doesn't orphan a key.
96
+ if previous_value is None:
97
+ try:
98
+ keyring.delete_password(SERVICE, name)
99
+ except KeyringError:
100
+ pass
101
+ else:
102
+ keyring.set_password(SERVICE, name, previous_value)
103
+ raise
104
+ except KeyringError as exc:
105
+ _handle_keyring_error(exc)
106
+ console.print(f"[green]Saved[/green] {name} in your system credential store.")
107
+
108
+
109
+ @app.command()
110
+ def get(
111
+ name: Annotated[str, typer.Argument(help="The key name")],
112
+ reveal: Annotated[
113
+ bool,
114
+ typer.Option("--reveal", help="Print the full key; take care where output goes."),
115
+ ] = False,
116
+ ) -> None:
117
+ """Show a masked key; reveal the full value only with --reveal."""
118
+ name = _validate_name(name)
119
+ try:
120
+ _ensure_secure_keyring()
121
+ value = keyring.get_password(SERVICE, name)
122
+ except KeyringError as exc:
123
+ _handle_keyring_error(exc)
124
+ if value is None:
125
+ err_console.print(f"[red]No key named '{name}'.[/red]")
126
+ raise typer.Exit(code=1)
127
+
128
+ if not reveal:
129
+ visible = value[-4:] if len(value) > 4 else "*" * len(value)
130
+ console.print(f"{name}: {'*' * max(8, len(value) - len(visible))}{visible}")
131
+ else:
132
+ sys.stdout.write(value + "\n")
133
+
134
+
135
+ @app.command("list")
136
+ def list_keys() -> None:
137
+ """List saved key names without revealing their values."""
138
+ try:
139
+ _ensure_secure_keyring()
140
+ # keyring's portable API intentionally has no list operation. Store names
141
+ # as non-secret metadata in the platform credential store as well.
142
+ raw_names = keyring.get_password(SERVICE, "__am_names__")
143
+ names = sorted(filter(None, raw_names.splitlines())) if raw_names else []
144
+ except KeyringError as exc:
145
+ _handle_keyring_error(exc)
146
+ if not names:
147
+ console.print("No API keys saved yet. Add one with [cyan]am add <name>[/cyan].")
148
+ return
149
+ table = Table(title="Saved API keys", show_header=False, box=None, padding=(0, 1))
150
+ for name in names:
151
+ table.add_row(name)
152
+ console.print(table)
153
+
154
+
155
+ @app.command()
156
+ def remove(name: Annotated[str, typer.Argument(help="The key name")]) -> None:
157
+ """Delete a key from your system credential store."""
158
+ name = _validate_name(name)
159
+ try:
160
+ _ensure_secure_keyring()
161
+ if keyring.get_password(SERVICE, name) is None:
162
+ err_console.print(f"[red]No key named '{name}'.[/red]")
163
+ raise typer.Exit(code=1)
164
+ raw_names = keyring.get_password(SERVICE, "__am_names__")
165
+ names = set(filter(None, raw_names.splitlines())) if raw_names else set()
166
+ names.discard(name)
167
+ updated_index = "\n".join(sorted(names))
168
+ keyring.set_password(SERVICE, "__am_names__", updated_index)
169
+ try:
170
+ keyring.delete_password(SERVICE, name)
171
+ except KeyringError:
172
+ # Restore the prior index if deleting the credential failed.
173
+ keyring.set_password(
174
+ SERVICE,
175
+ "__am_names__",
176
+ raw_names or "",
177
+ )
178
+ raise
179
+ except KeyringError as exc:
180
+ _handle_keyring_error(exc)
181
+ console.print(f"[green]Removed[/green] {name}.")
182
+
183
+
184
+ @app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
185
+ def run(
186
+ ctx: typer.Context,
187
+ name: Annotated[str, typer.Argument(help="Key name to inject")],
188
+ command: Annotated[list[str], typer.Argument(help="Command to run after --")],
189
+ ) -> None:
190
+ """Run a command with the key injected as an environment variable."""
191
+ name = _validate_name(name)
192
+ # Typer captures tokens following `--` as command arguments.
193
+ if not command:
194
+ command = list(ctx.args)
195
+ if not command:
196
+ raise typer.BadParameter("provide a command after --")
197
+ try:
198
+ _ensure_secure_keyring()
199
+ value = keyring.get_password(SERVICE, name)
200
+ except KeyringError as exc:
201
+ _handle_keyring_error(exc)
202
+ if value is None:
203
+ err_console.print(f"[red]No key named '{name}'. Add it with am add {name}.[/red]")
204
+ raise typer.Exit(code=1)
205
+ env = os.environ.copy()
206
+ env[_env_var(name)] = value
207
+ try:
208
+ result = subprocess.run(command, env=env, check=False)
209
+ except FileNotFoundError:
210
+ err_console.print(f"[red]Command not found:[/red] {command[0]}")
211
+ raise typer.Exit(code=127)
212
+ raise typer.Exit(code=result.returncode)
213
+
214
+
215
+ if __name__ == "__main__":
216
+ app()
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.5
2
+ Name: wen
3
+ Version: 0.1.0
4
+ Summary: Your API keys, one place.
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: keyring>=25.0.0
7
+ Requires-Dist: rich>=13.7.0
8
+ Requires-Dist: typer>=0.12.0
9
+ Provides-Extra: test
10
+ Requires-Dist: pytest>=8.0; extra == 'test'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # am
14
+
15
+ **Your API keys, one place.**
16
+
17
+ `am` is a tiny Python CLI for keeping API keys in your operating system's
18
+ credential store and passing them to local processes when needed. Keys are
19
+ never stored in project files, and `am list` never displays their values.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install wen
25
+ ```
26
+
27
+ On Linux, install and unlock a Secret Service keyring implementation (such as
28
+ GNOME Keyring) before using `am`. On macOS and Windows, `keyring` uses the
29
+ system credential store.
30
+
31
+ ## Usage
32
+
33
+ ```bash
34
+ am add openai # securely prompts for the key; input is hidden
35
+ am list # prints saved names only
36
+ am get openai # always masked
37
+ am get openai --reveal # explicitly print the full key
38
+ am run openai -- python main.py
39
+ am remove openai
40
+ ```
41
+
42
+ `am run` adds `OPENAI_API_KEY` to the child process environment for `openai`;
43
+ it does not modify the current shell or write the key into your project. Other
44
+ names use `<NAME>_API_KEY` (uppercased, with punctuation converted to `_`),
45
+ except `github`, which uses `GITHUB_TOKEN`.
46
+
47
+ ## Security notes
48
+
49
+ - Values are stored through the OS credential manager, not in `.env`, config,
50
+ or project files.
51
+ - `am get` always masks values. `--reveal` is required to print a full key;
52
+ output may still be captured by a terminal recording, shell, or log.
53
+ - The command you run can read its injected environment variable, as intended.
54
+ - `am` only accepts the supported OS credential stores (macOS Keychain,
55
+ Windows Credential Locker, Linux Secret Service, and KDE Wallet). It refuses
56
+ unknown or file-based keyring backends rather than silently storing keys
57
+ insecurely.
@@ -0,0 +1,6 @@
1
+ am/__init__.py,sha256=DnbjLUkD61UtTpPfggJVZtLz3J7MN2I9fgPYw-txzTA,64
2
+ am/cli.py,sha256=7giIfwZA0j5l5-aR6jTRdtfe-a2_NkhwXGq9p25iXbA,7715
3
+ wen-0.1.0.dist-info/METADATA,sha256=bJDxBVjVkmZEtTmaHKXjK9oHkd7KILfSGQ_gqO77WNI,1920
4
+ wen-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
5
+ wen-0.1.0.dist-info/entry_points.txt,sha256=g7gwI9wyx4dvsowFJ1BQptsOf5Goe3gpVUD8olgX3J4,34
6
+ wen-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ am = am.cli:app