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 +57 -0
- wen-0.1.0/README.md +45 -0
- wen-0.1.0/dist-aii/aii-0.1.0-py3-none-any.whl +0 -0
- wen-0.1.0/dist-aii/aii-0.1.0.tar.gz +0 -0
- wen-0.1.0/pyproject.toml +24 -0
- wen-0.1.0/src/am/__init__.py +3 -0
- wen-0.1.0/src/am/cli.py +216 -0
- wen-0.1.0/tests/test_cli.py +87 -0
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
|
|
Binary file
|
wen-0.1.0/pyproject.toml
ADDED
|
@@ -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"]
|
wen-0.1.0/src/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,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
|