wen 0.1.0__tar.gz

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.
wen-0.1.0/PKG-INFO ADDED
@@ -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.
wen-0.1.0/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # am
2
+
3
+ **Your API keys, one place.**
4
+
5
+ `am` is a tiny Python CLI for keeping API keys in your operating system's
6
+ credential store and passing them to local processes when needed. Keys are
7
+ never stored in project files, and `am list` never displays their values.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install wen
13
+ ```
14
+
15
+ On Linux, install and unlock a Secret Service keyring implementation (such as
16
+ GNOME Keyring) before using `am`. On macOS and Windows, `keyring` uses the
17
+ system credential store.
18
+
19
+ ## Usage
20
+
21
+ ```bash
22
+ am add openai # securely prompts for the key; input is hidden
23
+ am list # prints saved names only
24
+ am get openai # always masked
25
+ am get openai --reveal # explicitly print the full key
26
+ am run openai -- python main.py
27
+ am remove openai
28
+ ```
29
+
30
+ `am run` adds `OPENAI_API_KEY` to the child process environment for `openai`;
31
+ it does not modify the current shell or write the key into your project. Other
32
+ names use `<NAME>_API_KEY` (uppercased, with punctuation converted to `_`),
33
+ except `github`, which uses `GITHUB_TOKEN`.
34
+
35
+ ## Security notes
36
+
37
+ - Values are stored through the OS credential manager, not in `.env`, config,
38
+ or project files.
39
+ - `am get` always masks values. `--reveal` is required to print a full key;
40
+ output may still be captured by a terminal recording, shell, or log.
41
+ - The command you run can read its injected environment variable, as intended.
42
+ - `am` only accepts the supported OS credential stores (macOS Keychain,
43
+ Windows Credential Locker, Linux Secret Service, and KDE Wallet). It refuses
44
+ unknown or file-based keyring backends rather than silently storing keys
45
+ insecurely.
Binary file
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "wen"
7
+ version = "0.1.0"
8
+ description = "Your API keys, one place."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "keyring>=25.0.0",
13
+ "rich>=13.7.0",
14
+ "typer>=0.12.0",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ test = ["pytest>=8.0"]
19
+
20
+ [project.scripts]
21
+ am = "am.cli:app"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/am"]
@@ -0,0 +1,3 @@
1
+ """A tiny local manager for API keys."""
2
+
3
+ __version__ = "0.1.0"
@@ -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,87 @@
1
+ import os
2
+ from types import SimpleNamespace
3
+
4
+ from typer.testing import CliRunner
5
+
6
+ from am import cli
7
+
8
+ REAL_SECURE_CHECK = cli._ensure_secure_keyring
9
+
10
+
11
+ class MemoryKeyring:
12
+ def __init__(self):
13
+ self.values = {}
14
+
15
+ def get_password(self, service, username):
16
+ return self.values.get((service, username))
17
+
18
+ def set_password(self, service, username, password):
19
+ self.values[(service, username)] = password
20
+
21
+ def delete_password(self, service, username):
22
+ del self.values[(service, username)]
23
+
24
+
25
+ def setup_function():
26
+ cli.app.state = None
27
+ cli._test_keyring = MemoryKeyring()
28
+ cli.keyring.get_password = cli._test_keyring.get_password
29
+ cli.keyring.set_password = cli._test_keyring.set_password
30
+ cli.keyring.delete_password = cli._test_keyring.delete_password
31
+ cli._ensure_secure_keyring = lambda: None
32
+
33
+
34
+ def test_add_list_get_and_remove(monkeypatch):
35
+ runner = CliRunner()
36
+ added = runner.invoke(cli.app, ["add", "OpenAI"], input="sk-secret-value\n")
37
+ assert added.exit_code == 0
38
+ assert "Saved openai" in added.output
39
+
40
+ listed = runner.invoke(cli.app, ["list"])
41
+ assert listed.exit_code == 0
42
+ assert "openai" in listed.output
43
+ assert "sk-secret-value" not in listed.output
44
+
45
+ masked = runner.invoke(cli.app, ["get", "openai"])
46
+ assert masked.exit_code == 0
47
+ assert "sk-secret-value" not in masked.output
48
+ assert masked.output.rstrip().endswith("alue")
49
+
50
+ revealed = runner.invoke(cli.app, ["get", "openai", "--reveal"])
51
+ assert revealed.exit_code == 0
52
+ assert "sk-secret-value" in revealed.output
53
+
54
+ removed = runner.invoke(cli.app, ["remove", "openai"])
55
+ assert removed.exit_code == 0
56
+ assert "openai" not in runner.invoke(cli.app, ["list"]).output
57
+
58
+
59
+ def test_run_injects_key_and_returns_child_status(monkeypatch):
60
+ cli._test_keyring.set_password(cli.SERVICE, "openai", "sk-secret-value")
61
+ observed = {}
62
+
63
+ def fake_run(command, env, check):
64
+ observed["command"] = command
65
+ observed["env"] = env
66
+ return SimpleNamespace(returncode=7)
67
+
68
+ monkeypatch.setattr(cli.subprocess, "run", fake_run)
69
+ result = CliRunner().invoke(
70
+ cli.app, ["run", "openai", "--", "python", "main.py"]
71
+ )
72
+ assert result.exit_code == 7
73
+ assert observed["command"] == ["python", "main.py"]
74
+ assert observed["env"]["OPENAI_API_KEY"] == "sk-secret-value"
75
+ assert os.environ.get("OPENAI_API_KEY") != "sk-secret-value"
76
+ assert "sk-secret-value" not in result.output
77
+
78
+
79
+ def test_untrusted_backend_is_rejected(monkeypatch):
80
+ class PlaintextKeyring:
81
+ pass
82
+
83
+ monkeypatch.setattr(cli.keyring, "get_keyring", lambda: PlaintextKeyring())
84
+ monkeypatch.setattr(cli, "_ensure_secure_keyring", REAL_SECURE_CHECK)
85
+ result = CliRunner().invoke(cli.app, ["list"])
86
+ assert result.exit_code == 1
87
+ assert "file-based or unknown backend" in result.output