bashboy 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.
bashboy/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """BashBoy: a deny-only shell-command gate for coding agents."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
bashboy/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Enable ``python -m bashboy``."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
bashboy/classify.py ADDED
@@ -0,0 +1,124 @@
1
+ """Verdict pipeline: fast local paths first, model second, fail closed.
2
+
3
+ Precedence is fixed and auditable:
4
+
5
+ 1. ``BASHBOY_DISABLE`` -- allow, source ``disabled`` (documented escape hatch).
6
+ 2. Blank command -- deny, source ``fallback``.
7
+ 3. Denylist -- deny, source ``denylist``.
8
+ 4. Allowlist -- allow, source ``allowlist``.
9
+ 5. Model -- allow/deny, source ``model``; any failure becomes ``fallback``.
10
+
11
+ The provider is imported lazily inside branch 5 so the fast paths never pay for
12
+ ``urllib`` or ``json`` at startup.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import sys
19
+ import time
20
+ from dataclasses import dataclass
21
+
22
+ from .config import Config, api_key, load_config
23
+ from .rules import check_allowlist, check_denylist
24
+
25
+ _TRUTHY = frozenset({"1", "true", "yes"})
26
+
27
+
28
+ def log(msg: str) -> None:
29
+ """Write a diagnostic line to stderr, but only when ``BASHBOY_DEBUG`` is truthy.
30
+
31
+ Never pass the API key, an ``Authorization`` header, or a full request body.
32
+ """
33
+ value = os.environ.get("BASHBOY_DEBUG", "")
34
+ if value.strip().lower() in _TRUTHY:
35
+ print(f"bashboy: {msg}", file=sys.stderr)
36
+
37
+
38
+ def _enabled(name: str) -> bool:
39
+ return os.environ.get(name, "").strip().lower() in _TRUTHY
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Classification:
44
+ """The final verdict the CLI prints and the hooks act on."""
45
+
46
+ decision: str
47
+ source: str
48
+ reason: str
49
+ risk: str | None = None
50
+ latency_ms: int = 0
51
+
52
+ def __post_init__(self) -> None:
53
+ object.__setattr__(self, "latency_ms", int(self.latency_ms))
54
+
55
+ def to_dict(self) -> dict[str, object]:
56
+ # Key order is part of the CLI contract; keep it stable.
57
+ return {
58
+ "decision": self.decision,
59
+ "source": self.source,
60
+ "reason": self.reason,
61
+ "risk": self.risk,
62
+ "latency_ms": self.latency_ms,
63
+ }
64
+
65
+ def _elapsed_ms(started: float) -> int:
66
+ return max(int((time.perf_counter() - started) * 1000), 0)
67
+
68
+
69
+ def classify_command(
70
+ command: str,
71
+ cwd: str | None = None,
72
+ cfg: Config | None = None,
73
+ provider: object | None = None,
74
+ ) -> Classification:
75
+ """Classify ``command``. Never raises for provider problems; fails closed."""
76
+ started = time.perf_counter()
77
+ if cfg is None:
78
+ cfg = load_config()
79
+
80
+ if _enabled("BASHBOY_DISABLE"):
81
+ latency = _elapsed_ms(started)
82
+ log(f"BASHBOY_DISABLE is set: allowing without checks (latency={latency}ms)")
83
+ return Classification("allow", "disabled", "BASHBOY_DISABLE is set", None, latency)
84
+
85
+ if not isinstance(command, str) or not command.strip():
86
+ return Classification("deny", "fallback", "no command provided", None, _elapsed_ms(started))
87
+
88
+ if cfg.policy.denylist:
89
+ hit = check_denylist(command, cwd)
90
+ if hit is not None:
91
+ latency = _elapsed_ms(started)
92
+ log(f"denylist hit: {hit.reason} (latency={latency}ms)")
93
+ return Classification("deny", "denylist", hit.reason, None, latency)
94
+
95
+ if cfg.policy.allowlist:
96
+ label = check_allowlist(command)
97
+ if label is not None:
98
+ latency = _elapsed_ms(started)
99
+ log(f"allowlist hit: {label} (latency={latency}ms)")
100
+ return Classification(
101
+ "allow", "allowlist", f"matches allowlist: {label}", None, latency
102
+ )
103
+
104
+ from .provider import Provider
105
+
106
+ target_cwd = cwd or os.getcwd()
107
+ try:
108
+ model = provider if provider is not None else Provider(cfg.provider, key=api_key(cfg))
109
+ verdict = model.classify(target_cwd, command) # type: ignore[attr-defined]
110
+ except Exception as exc: # noqa: BLE001 - every failure must map to a verdict
111
+ fail_open = cfg.policy.fail_mode == "open"
112
+ decision = "allow" if fail_open else "deny"
113
+ latency = _elapsed_ms(started)
114
+ log(f"classifier unavailable: {exc} (latency={latency}ms)")
115
+ return Classification(
116
+ decision, "fallback", f"classifier unavailable: {exc}", None, latency
117
+ )
118
+
119
+ latency = _elapsed_ms(started)
120
+ log(
121
+ f"model verdict {verdict.decision} from {cfg.provider.endpoint} "
122
+ f"(latency={latency}ms)"
123
+ )
124
+ return Classification(verdict.decision, "model", verdict.reason, verdict.risk, latency)
bashboy/cli.py ADDED
@@ -0,0 +1,271 @@
1
+ """Command-line interface.
2
+
3
+ stdout is reserved for verdict output (``--json``), hook payloads, and installer
4
+ JSON in dry-run mode. Diagnostics go to stderr so ``| jq`` and hooks stay clean.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import os
12
+ import platform
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from . import __version__
17
+ from .classify import classify_command, log
18
+ from .config import (
19
+ DEFAULT_CONFIG_TOML,
20
+ Config,
21
+ ConfigError,
22
+ api_key,
23
+ config_path,
24
+ load_config,
25
+ )
26
+
27
+
28
+ def _toml_bool(value: bool) -> str:
29
+ return "true" if value else "false"
30
+
31
+
32
+ def render_config(cfg: Config) -> str:
33
+ lines = [
34
+ f"# source: {cfg.path}",
35
+ "[provider]",
36
+ f'endpoint = "{cfg.provider.endpoint}"',
37
+ f'api_key_env = "{cfg.provider.api_key_env}"',
38
+ f"timeout_ms = {cfg.provider.timeout_ms}",
39
+ "",
40
+ "[policy]",
41
+ f'fail_mode = "{cfg.policy.fail_mode}"',
42
+ f"denylist = {_toml_bool(cfg.policy.denylist)}",
43
+ f"allowlist = {_toml_bool(cfg.policy.allowlist)}",
44
+ "",
45
+ "[integrations.claude]",
46
+ f"timeout_s = {cfg.claude.timeout_s}",
47
+ "",
48
+ "[integrations.opencode]",
49
+ f"timeout_ms = {cfg.opencode.timeout_ms}",
50
+ ]
51
+ return "\n".join(lines) + "\n"
52
+
53
+
54
+ # --- check ----------------------------------------------------------------
55
+
56
+
57
+ def _cmd_check(args: argparse.Namespace) -> int:
58
+ if args.claude_hook:
59
+ from .hooks.claude import run_hook
60
+
61
+ return run_hook(sys.stdin, sys.stdout)
62
+
63
+ if args.command_text is None:
64
+ print("bashboy: --command is required (or use --claude-hook)", file=sys.stderr)
65
+ return 2
66
+
67
+ cfg = load_config()
68
+ log(f"config: {cfg.path}")
69
+ result = classify_command(args.command_text, cwd=args.cwd, cfg=cfg)
70
+
71
+ if args.json:
72
+ print(json.dumps(result.to_dict()))
73
+ else:
74
+ print(f"{result.decision} ({result.source}): {result.reason}")
75
+ return 0 if result.decision == "allow" else 1
76
+
77
+
78
+ # --- config ---------------------------------------------------------------
79
+
80
+
81
+ def _cmd_config(args: argparse.Namespace) -> int:
82
+ path = config_path()
83
+ if args.action is None or args.action == "path":
84
+ print(path)
85
+ return 0
86
+
87
+ if args.action == "show":
88
+ cfg = load_config()
89
+ sys.stdout.write(render_config(cfg))
90
+ return 0
91
+
92
+ if args.action == "init":
93
+ if path.exists() and not args.force:
94
+ print(f"{path} already exists; re-run with --force to overwrite")
95
+ return 1
96
+ path.parent.mkdir(parents=True, exist_ok=True)
97
+ path.write_text(DEFAULT_CONFIG_TOML, encoding="utf-8")
98
+ print(f"wrote {path}")
99
+ return 0
100
+
101
+ print(f"bashboy: unknown config action {args.action!r}", file=sys.stderr)
102
+ return 2
103
+
104
+
105
+ # --- install --------------------------------------------------------------
106
+
107
+ _DEFAULT_CLAUDE_SETTINGS = Path.home() / ".claude" / "settings.json"
108
+
109
+
110
+ def _cmd_install(args: argparse.Namespace) -> int:
111
+ both = args.all or (not args.claude and not args.opencode)
112
+ run_claude = args.claude or both
113
+ run_opencode = args.opencode or both
114
+
115
+ try:
116
+ from .installers.claude import install_claude
117
+ from .installers.opencode import install_opencode
118
+ except ImportError as exc: # pragma: no cover - installers ship with the package
119
+ print(f"bashboy: installers unavailable: {exc}", file=sys.stderr)
120
+ return 2
121
+
122
+ results: dict[str, dict] = {}
123
+ try:
124
+ if run_claude:
125
+ results["claude"] = install_claude(
126
+ args.settings, from_spec=args.from_spec, dry_run=args.dry_run
127
+ )
128
+ if run_opencode:
129
+ results["opencode"] = install_opencode(args.config_dir, dry_run=args.dry_run)
130
+ except (ConfigError, OSError, ValueError) as exc:
131
+ print(f"bashboy: {exc}", file=sys.stderr)
132
+ return 2
133
+
134
+ if args.dry_run:
135
+ if run_claude and run_opencode:
136
+ print(json.dumps({"claude": results["claude"], "opencode": results["opencode"]}))
137
+ elif run_claude:
138
+ print(json.dumps(results["claude"]))
139
+ else:
140
+ print(json.dumps(results["opencode"]))
141
+ return 0
142
+
143
+ if run_claude:
144
+ settings = Path(args.settings) if args.settings else _DEFAULT_CLAUDE_SETTINGS
145
+ print(f"claude: settings updated at {settings}")
146
+ if run_opencode:
147
+ print(f"opencode: plugin written to {results['opencode']['plugin_path']}")
148
+ return 0
149
+
150
+
151
+ # --- selftest / version ---------------------------------------------------
152
+
153
+
154
+ class _FailingProvider:
155
+ """Stand-in provider used by ``selftest`` to prove the fail-closed path."""
156
+
157
+ def classify(self, cwd: str, command: str) -> object:
158
+ raise RuntimeError("selftest: simulated provider outage")
159
+
160
+
161
+ def _cmd_selftest(args: argparse.Namespace) -> int:
162
+ cfg = load_config()
163
+ checks: list[tuple[str, bool]] = []
164
+
165
+ deny_result = classify_command("rm -rf /", cfg=cfg)
166
+ checks.append(
167
+ ("deny 'rm -rf /' -> deny/denylist",
168
+ deny_result.decision == "deny" and deny_result.source == "denylist")
169
+ )
170
+
171
+ allow_result = classify_command("git status", cfg=cfg)
172
+ checks.append(
173
+ ("allow 'git status' -> allow/allowlist",
174
+ allow_result.decision == "allow" and allow_result.source == "allowlist")
175
+ )
176
+
177
+ fallback = classify_command("docker run --rm alpine sh", cfg=cfg, provider=_FailingProvider())
178
+ checks.append(
179
+ ("provider outage -> deny/fallback",
180
+ fallback.decision == "deny" and fallback.source == "fallback")
181
+ )
182
+
183
+ for name, ok in checks:
184
+ print(f"{'PASS' if ok else 'FAIL'} {name}")
185
+ passed = sum(1 for _, ok in checks if ok)
186
+ print(f"{passed}/{len(checks)} checks passed")
187
+
188
+ key = api_key(cfg)
189
+ if key:
190
+ try:
191
+ from .provider import Provider
192
+
193
+ verdict = Provider(cfg.provider, key=key).classify(os.getcwd(), "git status")
194
+ print(f"provider: ok ({verdict.decision})")
195
+ except Exception as exc: # noqa: BLE001 - reported, never fatal
196
+ print(f"provider: FAILED ({exc})")
197
+ else:
198
+ print("provider: skipped (no API key)")
199
+
200
+ return 0 if passed == len(checks) else 1
201
+
202
+
203
+ def _cmd_version(args: argparse.Namespace) -> int:
204
+ print(f"bashboy {__version__} (python {platform.python_version()})")
205
+ return 0
206
+
207
+
208
+ # --- parser ---------------------------------------------------------------
209
+
210
+
211
+ def _build_parser() -> argparse.ArgumentParser:
212
+ parser = argparse.ArgumentParser(
213
+ prog="bashboy",
214
+ description="Classify shell commands as allow or deny before an agent runs them.",
215
+ )
216
+ sub = parser.add_subparsers(dest="subcommand")
217
+
218
+ check = sub.add_parser("check", help="classify a command")
219
+ check.add_argument("--command", dest="command_text", default=None, help="command to classify")
220
+ check.add_argument("--cwd", default=os.getcwd(), help="working directory for the command")
221
+ check.add_argument("--json", action="store_true", help="emit one JSON verdict object")
222
+ check.add_argument(
223
+ "--claude-hook",
224
+ action="store_true",
225
+ help="read a Claude Code PreToolUse payload from stdin",
226
+ )
227
+ check.set_defaults(handler=_cmd_check)
228
+
229
+ install = sub.add_parser("install", help="install host integrations")
230
+ install.add_argument("--claude", action="store_true", help="install the Claude Code hook")
231
+ install.add_argument("--opencode", action="store_true", help="install the OpenCode plugin")
232
+ install.add_argument("--all", action="store_true", help="install both integrations")
233
+ install.add_argument("--dry-run", action="store_true", help="print what would be written")
234
+ install.add_argument("--from", dest="from_spec", default=None, help="uvx --from SPEC")
235
+ install.add_argument("--settings", default=None, help="Claude settings.json path")
236
+ install.add_argument("--config-dir", default=None, help="OpenCode config directory")
237
+ install.set_defaults(handler=_cmd_install)
238
+
239
+ config = sub.add_parser("config", help="inspect or create the config file")
240
+ config.add_argument("action", nargs="?", choices=["path", "show", "init"], default="path")
241
+ config.add_argument("--force", action="store_true", help="overwrite an existing config")
242
+ config.set_defaults(handler=_cmd_config)
243
+
244
+ selftest = sub.add_parser("selftest", help="run offline self-checks")
245
+ selftest.set_defaults(handler=_cmd_selftest)
246
+
247
+ version = sub.add_parser("version", help="print the version")
248
+ version.set_defaults(handler=_cmd_version)
249
+
250
+ return parser
251
+
252
+
253
+ def main(argv: list[str] | None = None) -> int:
254
+ parser = _build_parser()
255
+ args = parser.parse_args(argv)
256
+ handler = getattr(args, "handler", None)
257
+ if handler is None:
258
+ parser.print_help(sys.stderr)
259
+ return 2
260
+ try:
261
+ return int(handler(args))
262
+ except ConfigError as exc:
263
+ print(f"bashboy: {exc}", file=sys.stderr)
264
+ return 2
265
+ except KeyboardInterrupt: # pragma: no cover - interactive only
266
+ print("bashboy: interrupted", file=sys.stderr)
267
+ return 2
268
+
269
+
270
+ if __name__ == "__main__": # pragma: no cover
271
+ raise SystemExit(main())
bashboy/config.py ADDED
@@ -0,0 +1,219 @@
1
+ """Configuration loading for BashBoy.
2
+
3
+ The config file is optional: a missing file means "documented defaults". A file
4
+ that exists but cannot be parsed is a hard error (exit 2 at the CLI), never a
5
+ silent fallback to defaults -- silently loosening policy would be worse than
6
+ refusing to run.
7
+
8
+ The API key is never read from, or written to, the config file. It is resolved
9
+ from the environment at call time.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import tomllib
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ DEFAULT_CONFIG_TOML = """\
20
+ [provider]
21
+ endpoint = "https://clocked.rezolve.ai/clocked/fill"
22
+ api_key_env = "CLOCKED_API_KEY"
23
+ timeout_ms = 4000
24
+
25
+ [policy]
26
+ fail_mode = "closed" # closed | open
27
+ denylist = true
28
+ allowlist = true
29
+
30
+ [integrations.claude]
31
+ timeout_s = 15
32
+
33
+ [integrations.opencode]
34
+ timeout_ms = 15000
35
+ """
36
+
37
+ FAIL_MODES = ("closed", "open")
38
+
39
+ DEFAULT_ENDPOINT = "https://clocked.rezolve.ai/clocked/fill"
40
+ DEFAULT_API_KEY_ENV = "CLOCKED_API_KEY"
41
+ DEFAULT_TIMEOUT_MS = 4000
42
+ DEFAULT_CLAUDE_TIMEOUT_S = 15
43
+ DEFAULT_OPENCODE_TIMEOUT_MS = 15000
44
+
45
+
46
+ class ConfigError(Exception):
47
+ """Raised when the configuration file or environment is unusable."""
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class ProviderConfig:
52
+ endpoint: str = DEFAULT_ENDPOINT
53
+ api_key_env: str = DEFAULT_API_KEY_ENV
54
+ timeout_ms: int = DEFAULT_TIMEOUT_MS
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class PolicyConfig:
59
+ fail_mode: str = "closed"
60
+ denylist: bool = True
61
+ allowlist: bool = True
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class ClaudeIntegration:
66
+ timeout_s: int = DEFAULT_CLAUDE_TIMEOUT_S
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class OpencodeIntegration:
71
+ timeout_ms: int = DEFAULT_OPENCODE_TIMEOUT_MS
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Config:
76
+ provider: ProviderConfig
77
+ policy: PolicyConfig
78
+ claude: ClaudeIntegration
79
+ opencode: OpencodeIntegration
80
+ path: Path
81
+
82
+
83
+ def config_path() -> Path:
84
+ """Return the config file location: ``BASHBOY_CONFIG`` or ``~/.bashboy/config.toml``."""
85
+ override = os.environ.get("BASHBOY_CONFIG")
86
+ if override and override.strip():
87
+ return Path(override.strip()).expanduser()
88
+ return Path.home() / ".bashboy" / "config.toml"
89
+
90
+
91
+ def _table(parent: dict, key: str, where: Path) -> dict:
92
+ value = parent.get(key)
93
+ if value is None:
94
+ return {}
95
+ if not isinstance(value, dict):
96
+ raise ConfigError(f"{where}: [{key}] must be a table")
97
+ return value
98
+
99
+
100
+ def _as_str(table: dict, key: str, default: str, where: Path) -> str:
101
+ if key not in table:
102
+ return default
103
+ value = table[key]
104
+ if not isinstance(value, str):
105
+ raise ConfigError(f"{where}: '{key}' must be a string")
106
+ return value
107
+
108
+
109
+ def _as_int(table: dict, key: str, default: int, where: Path) -> int:
110
+ if key not in table:
111
+ return default
112
+ value = table[key]
113
+ if isinstance(value, bool) or not isinstance(value, int):
114
+ raise ConfigError(f"{where}: '{key}' must be an integer")
115
+ return int(value)
116
+
117
+
118
+ def _as_bool(table: dict, key: str, default: bool, where: Path) -> bool:
119
+ if key not in table:
120
+ return default
121
+ value = table[key]
122
+ if not isinstance(value, bool):
123
+ raise ConfigError(f"{where}: '{key}' must be true or false")
124
+ return value
125
+
126
+
127
+ def load_config(path: Path | None = None) -> Config:
128
+ """Load configuration, applying documented defaults and environment overrides."""
129
+ target = Path(path) if path is not None else config_path()
130
+
131
+ raw = b""
132
+ try:
133
+ raw = target.read_bytes()
134
+ except FileNotFoundError:
135
+ raw = b""
136
+ except OSError as exc:
137
+ raise ConfigError(f"cannot read {target}: {exc}") from exc
138
+
139
+ data: dict = {}
140
+ if raw:
141
+ try:
142
+ decoded = raw.decode("utf-8")
143
+ except UnicodeDecodeError as exc:
144
+ raise ConfigError(f"{target}: not valid UTF-8: {exc}") from exc
145
+ try:
146
+ data = tomllib.loads(decoded)
147
+ except tomllib.TOMLDecodeError as exc:
148
+ raise ConfigError(f"{target}: invalid TOML: {exc}") from exc
149
+ if not isinstance(data, dict):
150
+ raise ConfigError(f"{target}: top-level value must be a table")
151
+
152
+ provider_tbl = _table(data, "provider", target)
153
+ policy_tbl = _table(data, "policy", target)
154
+ integrations_tbl = _table(data, "integrations", target)
155
+ claude_tbl = _table(integrations_tbl, "claude", target)
156
+ opencode_tbl = _table(integrations_tbl, "opencode", target)
157
+
158
+ provider = ProviderConfig(
159
+ endpoint=_as_str(provider_tbl, "endpoint", DEFAULT_ENDPOINT, target),
160
+ api_key_env=_as_str(provider_tbl, "api_key_env", DEFAULT_API_KEY_ENV, target),
161
+ timeout_ms=_as_int(provider_tbl, "timeout_ms", DEFAULT_TIMEOUT_MS, target),
162
+ )
163
+ policy = PolicyConfig(
164
+ fail_mode=_as_str(policy_tbl, "fail_mode", "closed", target),
165
+ denylist=_as_bool(policy_tbl, "denylist", True, target),
166
+ allowlist=_as_bool(policy_tbl, "allowlist", True, target),
167
+ )
168
+ claude = ClaudeIntegration(
169
+ timeout_s=_as_int(claude_tbl, "timeout_s", DEFAULT_CLAUDE_TIMEOUT_S, target),
170
+ )
171
+ opencode = OpencodeIntegration(
172
+ timeout_ms=_as_int(opencode_tbl, "timeout_ms", DEFAULT_OPENCODE_TIMEOUT_MS, target),
173
+ )
174
+
175
+ env_endpoint = os.environ.get("BASHBOY_ENDPOINT")
176
+ if env_endpoint and env_endpoint.strip():
177
+ provider = ProviderConfig(
178
+ endpoint=env_endpoint.strip(),
179
+ api_key_env=provider.api_key_env,
180
+ timeout_ms=provider.timeout_ms,
181
+ )
182
+
183
+ env_key_env = os.environ.get("BASHBOY_API_KEY_ENV")
184
+ if env_key_env and env_key_env.strip():
185
+ provider = ProviderConfig(
186
+ endpoint=provider.endpoint,
187
+ api_key_env=env_key_env.strip(),
188
+ timeout_ms=provider.timeout_ms,
189
+ )
190
+
191
+ env_fail_mode = os.environ.get("BASHBOY_FAIL_MODE")
192
+ if env_fail_mode and env_fail_mode.strip():
193
+ policy = PolicyConfig(
194
+ fail_mode=env_fail_mode.strip(),
195
+ denylist=policy.denylist,
196
+ allowlist=policy.allowlist,
197
+ )
198
+
199
+ if policy.fail_mode not in FAIL_MODES:
200
+ raise ConfigError(
201
+ f"{target}: fail_mode must be one of {', '.join(FAIL_MODES)} (got {policy.fail_mode!r})"
202
+ )
203
+
204
+ return Config(
205
+ provider=provider,
206
+ policy=policy,
207
+ claude=claude,
208
+ opencode=opencode,
209
+ path=target,
210
+ )
211
+
212
+
213
+ def api_key(cfg: Config) -> str | None:
214
+ """Return the API key from the environment named by the config, or ``None``."""
215
+ raw = os.environ.get(cfg.provider.api_key_env)
216
+ if raw is None:
217
+ return None
218
+ raw = raw.strip()
219
+ return raw or None
@@ -0,0 +1 @@
1
+ """Host adapters. Kept as a package so imports stay explicit and lazy."""