Skip to content

Commit fee99e2

Browse files
Add a0min provider-key loader; ignore .env
Wires OpenAI/DeepSeek/xAI keys into a0min via a presence-only loader (explicit path exclusive, no value logging) and ignores .env to keep local secrets out of git.
1 parent 1765596 commit fee99e2

6 files changed

Lines changed: 196 additions & 6 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
__pycache__/
22
.skill-lib/
3+
4+
# Local secrets
5+
.env

a0min/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,30 @@ python3 -m a0min show a0z-12345678
7070
python3 -m a0min merge a0z-12345678
7171
python3 -m a0min superpotential # dump the imported superpotential
7272
python3 -m a0min caps --tier seeker
73+
python3 -m a0min env # provider keys present (never values)
7374
```
7475

7576
Every command accepts `--json` for machine-readable output.
7677

78+
## Provider keys
79+
80+
`a0min.env` reads provider API keys from a local `.env` file without hardcoding
81+
them and without ever emitting key values:
82+
83+
```python
84+
from a0min import load_provider_keys, provider_key, available_providers, presence
85+
86+
keys = load_provider_keys() # {'openai': ..., 'deepseek': ..., 'xai': ...}
87+
provider_key("openai") # the key, or None
88+
available_providers() # ('openai', 'deepseek', 'xai') subset
89+
presence() # {'openai': True, ...} — booleans only
90+
```
91+
92+
Search order: `A0MIN_ENV_PATH`, then `./.env`, then `~/.env` (first match wins
93+
per provider). Supported variables: `OPENAI_API_KEY`, `DEEPSEEK_API_KEY`,
94+
`XAI_API_KEY`. The `env` CLI command (and `presence()`) expose presence only —
95+
key material is returned solely to in-process callers that ask for it.
96+
7797
## Tests
7898

7999
```bash

a0min/a0min/__init__.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# ratios: loc_comments=32:8 imports_exports=2:1 calls_definitions=0:0
1+
# ratios: loc_comments=37:10 imports_exports=3:1 calls_definitions=0:0
22
"""a0min — minimal agent harness over the imported a0 platonic superpotential.
33
44
Public surface:
@@ -7,8 +7,11 @@
77
- candidate_platonic_agent — the open superpotential with current a0 regions
88
- Harness — creates any potential sub-agent by projecting a declared region
99
- SubAgent, PotentialSubAgent, SpawnCapExceeded
10+
- load_provider_keys, provider_key, available_providers, presence
11+
(provider-key loader; reads .env, never exposes key values in summaries)
1012
"""
1113

14+
from .env import available_providers, load_provider_keys, presence, provider_key
1215
from .harness import (
1316
SUPPORTED_CUT_MODES,
1417
SUPPORTED_ORCHESTRATION_MODES,
@@ -41,5 +44,9 @@
4144
"SpawnCapExceeded",
4245
"SUPPORTED_ORCHESTRATION_MODES",
4346
"SUPPORTED_CUT_MODES",
47+
"available_providers",
48+
"load_provider_keys",
49+
"presence",
50+
"provider_key",
4451
]
45-
# ratios: loc_comments=32:8 imports_exports=2:1 calls_definitions=0:0
52+
# ratios: loc_comments=37:10 imports_exports=3:1 calls_definitions=0:0

a0min/a0min/cli.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# ratios: loc_comments=201:10 imports_exports=8:1 calls_definitions=84:9
1+
# ratios: loc_comments=221:11 imports_exports=9:1 calls_definitions=92:10
22
"""Minimal CLI for the a0min agent harness.
33
44
Commands:
@@ -8,6 +8,7 @@
88
merge ID mark a created sub-agent merged
99
superpotential dump the imported platonic superpotential
1010
caps show spawn caps for a tier
11+
env show which provider keys are present (never the values)
1112
1213
Every command accepts ``--json`` for machine-readable output.
1314
"""
@@ -21,6 +22,7 @@
2122
from pathlib import Path
2223
from typing import Any
2324

25+
from .env import presence as provider_presence
2426
from .harness import (
2527
SUPPORTED_CUT_MODES,
2628
SUPPORTED_ORCHESTRATION_MODES,
@@ -94,6 +96,16 @@ def _build_parser() -> argparse.ArgumentParser:
9496
caps_parser.add_argument("--tier", default="free", help="spawn-cap tier")
9597
caps_parser.add_argument("--json", action="store_true", dest="as_json")
9698

99+
env_parser = sub.add_parser(
100+
"env", help="show which provider keys are present (never the values)"
101+
)
102+
env_parser.add_argument(
103+
"--env-file",
104+
default=None,
105+
help="explicit .env file path (default: A0MIN_ENV_PATH, then ./.env, then ~/.env)",
106+
)
107+
env_parser.add_argument("--json", action="store_true", dest="as_json")
108+
97109
return parser
98110

99111

@@ -209,6 +221,16 @@ def _superpotential(harness: Harness, as_json: bool) -> int:
209221
return 0
210222

211223

224+
def _env(args: argparse.Namespace) -> int:
225+
report = provider_presence(explicit=args.env_file)
226+
if args.as_json:
227+
print(json.dumps(report, indent=2))
228+
return 0
229+
for provider, present in report.items():
230+
print(f"{provider}: {'present' if present else 'missing'}")
231+
return 0
232+
233+
212234
def main(argv: list[str] | None = None) -> int:
213235
parser = _build_parser()
214236
args = parser.parse_args(argv)
@@ -236,11 +258,13 @@ def main(argv: list[str] | None = None) -> int:
236258
else:
237259
_print_or_json(harness.caps, False)
238260
code = 0
261+
elif args.command == "env":
262+
code = _env(args)
239263
else:
240264
parser.error(f"unknown command: {args.command}")
241265
code = 2
242266

243267
if state_path and code == 0:
244268
harness.save(state_path)
245269
return code
246-
# ratios: loc_comments=201:10 imports_exports=8:1 calls_definitions=84:9
270+
# ratios: loc_comments=221:11 imports_exports=9:1 calls_definitions=92:10

a0min/a0min/env.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# ratios: loc_comments=53:15 imports_exports=4:4 calls_definitions=15:6
2+
"""Minimal provider-key loader for a0min.
3+
4+
Reads provider API keys from a local ``.env`` file without hardcoding them and
5+
without ever emitting key values.
6+
7+
When an explicit path is given, only that file is read. Otherwise the search
8+
order is ``./.env`` (current working directory) then ``~/.env`` (user home),
9+
first match wins per provider.
10+
11+
Supported provider keys: ``OPENAI_API_KEY``, ``DEEPSEEK_API_KEY``,
12+
``XAI_API_KEY``. Raw key material is returned only to in-process callers on
13+
request; summaries expose presence only.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import os
19+
from pathlib import Path
20+
from typing import Mapping
21+
22+
PROVIDER_KEY_NAMES = ("OPENAI_API_KEY", "DEEPSEEK_API_KEY", "XAI_API_KEY")
23+
24+
_PROVIDER_BY_KEY = {
25+
"OPENAI_API_KEY": "openai",
26+
"DEEPSEEK_API_KEY": "deepseek",
27+
"XAI_API_KEY": "xai",
28+
}
29+
30+
_PROVIDER_ORDER = ("openai", "deepseek", "xai")
31+
32+
33+
def _parse_env_file(path: Path) -> dict[str, str]:
34+
"""Parse KEY=VALUE lines from a .env file; never logs values."""
35+
values: dict[str, str] = {}
36+
for raw in path.read_text(encoding="utf-8").splitlines():
37+
line = raw.strip()
38+
if not line or line.startswith("#") or "=" not in line:
39+
continue
40+
key, _, value = line.partition("=")
41+
values[key.strip()] = value.strip().strip('"').strip("'")
42+
return values
43+
44+
45+
def _candidate_paths(
46+
explicit: str | os.PathLike[str] | None = None,
47+
) -> list[Path]:
48+
if explicit:
49+
return [Path(explicit)]
50+
return [Path.cwd() / ".env", Path.home() / ".env"]
51+
52+
53+
def load_provider_keys(
54+
explicit: str | os.PathLike[str] | None = None,
55+
) -> dict[str, str]:
56+
"""Return ``provider -> key`` for every supported key found."""
57+
found: dict[str, str] = {}
58+
for path in _candidate_paths(explicit):
59+
if not path.is_file():
60+
continue
61+
for key, value in _parse_env_file(path).items():
62+
provider = _PROVIDER_BY_KEY.get(key)
63+
if provider and value and provider not in found:
64+
found[provider] = value
65+
return found
66+
67+
68+
def provider_key(
69+
provider: str,
70+
explicit: str | os.PathLike[str] | None = None,
71+
) -> str | None:
72+
"""Return one provider key, or None when not configured."""
73+
return load_provider_keys(explicit).get(provider)
74+
75+
76+
def available_providers(
77+
explicit: str | os.PathLike[str] | None = None,
78+
) -> tuple[str, ...]:
79+
"""Configured provider names in stable order."""
80+
found = load_provider_keys(explicit)
81+
return tuple(provider for provider in _PROVIDER_ORDER if provider in found)
82+
83+
84+
def presence(
85+
explicit: str | os.PathLike[str] | None = None,
86+
) -> Mapping[str, bool]:
87+
"""Presence map for every supported provider; values never contain keys."""
88+
found = load_provider_keys(explicit)
89+
return {provider: provider in found for provider in _PROVIDER_ORDER}
90+
# ratios: loc_comments=53:15 imports_exports=4:4 calls_definitions=15:6

a0min/tests/test_a0min.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# ratios: loc_comments=143:4 imports_exports=9:3 calls_definitions=86:21
1+
# ratios: loc_comments=184:4 imports_exports=10:4 calls_definitions=112:26
22
"""Stdlib-only tests for the a0min harness and CLI.
33
44
Run from the a0min project root:
@@ -11,6 +11,7 @@
1111
import json
1212
import subprocess
1313
import sys
14+
import tempfile
1415
import unittest
1516
from pathlib import Path
1617

@@ -19,7 +20,11 @@
1920
from a0min import ( # noqa: E402 (project root on sys.path via test runner)
2021
Harness,
2122
SpawnCapExceeded,
23+
available_providers,
2224
candidate_platonic_agent,
25+
load_provider_keys,
26+
presence,
27+
provider_key,
2328
)
2429

2530

@@ -120,6 +125,37 @@ def test_save_load_roundtrip(self) -> None:
120125
self.assertEqual(restored._index, self.harness._index)
121126

122127

128+
class EnvLoaderTests(unittest.TestCase):
129+
def test_loads_provider_keys_without_exposing_them(self) -> None:
130+
with tempfile.TemporaryDirectory() as tmp:
131+
env_path = Path(tmp) / ".env"
132+
env_path.write_text(
133+
"OPENAI_API_KEY=sk-test-openai\n"
134+
"DEEPSEEK_API_KEY=sk-test-deepseek\n"
135+
"XAI_API_KEY=xai-test\n",
136+
encoding="utf-8",
137+
)
138+
keys = load_provider_keys(explicit=env_path)
139+
self.assertEqual(keys["openai"], "sk-test-openai")
140+
self.assertEqual(keys["deepseek"], "sk-test-deepseek")
141+
self.assertEqual(keys["xai"], "xai-test")
142+
143+
def test_presence_never_contains_values(self) -> None:
144+
with tempfile.TemporaryDirectory() as tmp:
145+
env_path = Path(tmp) / ".env"
146+
env_path.write_text("OPENAI_API_KEY=sk-secret\n", encoding="utf-8")
147+
report = presence(explicit=env_path)
148+
self.assertEqual(report, {"openai": True, "deepseek": False, "xai": False})
149+
self.assertNotIn("sk-secret", json.dumps(report))
150+
151+
def test_provider_key_missing_returns_none(self) -> None:
152+
with tempfile.TemporaryDirectory() as tmp:
153+
env_path = Path(tmp) / ".env"
154+
env_path.write_text("", encoding="utf-8")
155+
self.assertIsNone(provider_key("openai", explicit=env_path))
156+
self.assertEqual(available_providers(explicit=env_path), ())
157+
158+
123159
class CliSmokeTests(unittest.TestCase):
124160
def run_cli(self, *args: str) -> subprocess.CompletedProcess[str]:
125161
return subprocess.run(
@@ -176,7 +212,17 @@ def test_superpotential_json(self) -> None:
176212
self.assertEqual(len(payload["dimensions"]), 13)
177213
self.assertEqual(len(payload["regions"]), 11)
178214

215+
def test_env_json_shows_presence_only(self) -> None:
216+
with tempfile.TemporaryDirectory() as tmp:
217+
env_path = Path(tmp) / ".env"
218+
env_path.write_text("OPENAI_API_KEY=sk-secret\n", encoding="utf-8")
219+
result = self.run_cli("env", "--env-file", str(env_path), "--json")
220+
self.assertEqual(result.returncode, 0, result.stderr)
221+
payload = json.loads(result.stdout)
222+
self.assertEqual(payload, {"openai": True, "deepseek": False, "xai": False})
223+
self.assertNotIn("sk-secret", result.stdout)
224+
179225

180226
if __name__ == "__main__":
181227
unittest.main()
182-
# ratios: loc_comments=143:4 imports_exports=9:3 calls_definitions=86:21
228+
# ratios: loc_comments=184:4 imports_exports=10:4 calls_definitions=112:26

0 commit comments

Comments
 (0)