bashboy 0.1.0__tar.gz → 0.2.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.
Files changed (30) hide show
  1. {bashboy-0.1.0 → bashboy-0.2.0}/PKG-INFO +72 -15
  2. {bashboy-0.1.0 → bashboy-0.2.0}/README.md +71 -14
  3. {bashboy-0.1.0 → bashboy-0.2.0}/pyproject.toml +1 -1
  4. {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/__init__.py +1 -1
  5. {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/classify.py +4 -2
  6. {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/cli.py +173 -1
  7. {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/config.py +55 -9
  8. bashboy-0.2.0/src/bashboy/credentials.py +254 -0
  9. {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/provider.py +13 -1
  10. {bashboy-0.1.0 → bashboy-0.2.0}/tests/conftest.py +5 -0
  11. {bashboy-0.1.0 → bashboy-0.2.0}/tests/test_classify.py +52 -0
  12. bashboy-0.2.0/tests/test_cli.py +484 -0
  13. {bashboy-0.1.0 → bashboy-0.2.0}/tests/test_config.py +73 -1
  14. bashboy-0.2.0/tests/test_credentials.py +340 -0
  15. {bashboy-0.1.0 → bashboy-0.2.0}/tests/test_provider.py +1 -0
  16. {bashboy-0.1.0 → bashboy-0.2.0}/uv.lock +1 -1
  17. bashboy-0.1.0/tests/test_cli.py +0 -171
  18. {bashboy-0.1.0 → bashboy-0.2.0}/.github/workflows/release.yml +0 -0
  19. {bashboy-0.1.0 → bashboy-0.2.0}/.gitignore +0 -0
  20. {bashboy-0.1.0 → bashboy-0.2.0}/LICENSE +0 -0
  21. {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/__main__.py +0 -0
  22. {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/hooks/__init__.py +0 -0
  23. {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/hooks/claude.py +0 -0
  24. {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/installers/__init__.py +0 -0
  25. {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/installers/claude.py +0 -0
  26. {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/installers/opencode.py +0 -0
  27. {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/rules.py +0 -0
  28. {bashboy-0.1.0 → bashboy-0.2.0}/tests/test_claude_hook.py +0 -0
  29. {bashboy-0.1.0 → bashboy-0.2.0}/tests/test_installers.py +0 -0
  30. {bashboy-0.1.0 → bashboy-0.2.0}/tests/test_rules.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bashboy
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Classify shell commands as allow or deny before an agent runs them.
5
5
  Project-URL: Homepage, https://github.com/Mann10/bashboy
6
6
  Project-URL: Issues, https://github.com/Mann10/bashboy/issues
@@ -17,6 +17,10 @@ Description-Content-Type: text/markdown
17
17
 
18
18
  # BashBoy
19
19
 
20
+ [![PyPI](https://img.shields.io/pypi/v/bashboy.svg)](https://pypi.org/project/bashboy/)
21
+ [![Python](https://img.shields.io/pypi/pyversions/bashboy.svg)](https://pypi.org/project/bashboy/)
22
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
23
+
20
24
  BashBoy is a small, dependency-free Python CLI that classifies a shell command as
21
25
  `allow` or `deny` **before** a coding agent runs it. It sits in front of Claude Code
22
26
  (`PreToolUse` hook) and OpenCode (plugin) and answers one question: is this command
@@ -40,14 +44,22 @@ cannot answer clearly becomes `deny`.
40
44
 
41
45
  ## Install
42
46
 
43
- Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
47
+ Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/) (recommended; `pipx` works too).
44
48
 
45
49
  ```bash
46
- uv tool install bashboy # install once
50
+ uv tool install bashboy # install the `bashboy` command
47
51
  uvx bashboy --help # or run without installing
52
+ pipx install bashboy # pipx alternative
48
53
  ```
49
54
 
50
- From a local checkout (uvx resolves the project from the path):
55
+ Verify the install:
56
+
57
+ ```bash
58
+ bashboy version # bashboy 0.2.0
59
+ bashboy selftest # offline checks; reports provider: ok when the key works
60
+ ```
61
+
62
+ From a local checkout (for development, before a release is published):
51
63
 
52
64
  ```bash
53
65
  uvx --from /path/to/BashBoy bashboy check --command "git status"
@@ -57,14 +69,54 @@ uvx --from /path/to/BashBoy bashboy install --claude
57
69
  The package has **zero runtime dependencies** — everything is the Python standard
58
70
  library. `pytest` is a development-only dependency.
59
71
 
60
- The API key is read from the environment (default `CLOCKED_API_KEY`). It is never
61
- written to disk:
72
+ ### API key
73
+
74
+ The token is resolved at call time; the first hit wins:
75
+
76
+ | # | Source | Notes |
77
+ |---|--------|-------|
78
+ | 1 | `CLOCKED_API_KEY` (or the variable named by `[provider].api_key_env`) | Process environment; wins over everything |
79
+ | 2 | `BASHBOY_API_KEY_FILE` | Full path to a credential file |
80
+ | 3 | `[provider].api_key_file` in `config.toml` | `~` is expanded |
81
+ | 4 | `~/.bashboy/credentials` | Default per-user file |
82
+ | 5 | nothing | Model-path calls fail closed (`deny`, `source: "fallback"`) |
83
+
84
+ GUI-launched hosts (Claude Code desktop, OpenCode's background service) do not
85
+ inherit a shell profile, so a variable exported in a terminal is invisible to
86
+ them. `setx` fixes that by exposing the key to every process on the machine. A
87
+ per-user credential file is the middle ground: one host-agnostic location, no
88
+ machine-wide secret, and rotation is a single file write.
62
89
 
63
90
  ```bash
64
- export CLOCKED_API_KEY=... # bash
65
- $env:CLOCKED_API_KEY = "..." # PowerShell
91
+ bashboy auth set # prompt twice; input is never echoed
92
+ bashboy auth status # path, source, presence, permissions
93
+ bashboy auth status --check # + one live provider call
94
+ bashboy auth clear # delete after confirmation
95
+
96
+ # provisioning / CI
97
+ echo "$CLOCKED_API_KEY" | bashboy auth set --stdin --force
66
98
  ```
67
99
 
100
+ `auth set` writes the file atomically, creates `~/.bashboy` with mode `0700` on
101
+ POSIX, and sets the file itself to `0600`. The token is never accepted as a
102
+ command-line argument — it would land in shell history and process listings — and
103
+ it is never printed, logged, or stored in `config.toml` (which is shareable and
104
+ gets pasted into issues). `auth status` never prints the value and makes no
105
+ network call unless you pass `--check`.
106
+
107
+ Permission policy:
108
+
109
+ - **POSIX:** any group/other permission bit (`mode & 0o077`) is refused with a
110
+ message naming `chmod 600 <path>`, and the model path fails closed. Failing
111
+ closed beats silently consuming a leaked secret.
112
+ - **Windows:** the file lives under your user profile, whose default ACL is
113
+ owner-only. `bashboy auth status` inspects the ACL with `icacls` and reports
114
+ any extra principals; it warns but still uses the key, because inherited
115
+ entries are normal. The ACL probe never runs on the `bashboy check` hot path.
116
+
117
+ The token is read from the file at call time, so rotating it needs no host
118
+ restart. A missing or empty file simply means "no key".
119
+
68
120
  ## Quick start
69
121
 
70
122
  ```bash
@@ -75,7 +127,7 @@ bashboy check --command "git status"
75
127
  # allow (allowlist): matches allowlist: git status
76
128
 
77
129
  bashboy check --command "docker run --rm alpine sh" --json
78
- # {"decision": "deny", "source": "fallback", "reason": "classifier unavailable: no API key ...", ...}
130
+ # {"decision": "deny", "source": "fallback", "reason": "classifier unavailable: no API key configured (set CLOCKED_API_KEY or run `bashboy auth set`)", ...}
79
131
  ```
80
132
 
81
133
  Exit codes:
@@ -106,6 +158,7 @@ A missing file means these defaults:
106
158
  [provider]
107
159
  endpoint = "https://clocked.rezolve.ai/clocked/fill"
108
160
  api_key_env = "CLOCKED_API_KEY"
161
+ api_key_file = "~/.bashboy/credentials"
109
162
  timeout_ms = 4000
110
163
 
111
164
  [policy]
@@ -128,6 +181,7 @@ loosens because a file failed to parse.
128
181
  | `BASHBOY_CONFIG` | Path to the config file (default `~/.bashboy/config.toml`) |
129
182
  | `BASHBOY_ENDPOINT` | Overrides `[provider].endpoint` |
130
183
  | `BASHBOY_API_KEY_ENV` | Name of the environment variable holding the key |
184
+ | `BASHBOY_API_KEY_FILE` | Path to the credential file; overrides `[provider].api_key_file` |
131
185
  | `BASHBOY_FAIL_MODE` | `closed` (default) or `open`; anything else is an error |
132
186
  | `BASHBOY_DISABLE` | `1`/`true`/`yes` disables **all** checks (see below) |
133
187
  | `BASHBOY_DEBUG` | `1`/`true`/`yes` writes diagnostics to stderr only |
@@ -177,8 +231,9 @@ bashboy install --claude --from /path/to/BashBoy
177
231
 
178
232
  `--from SPEC` rewrites the argv to
179
233
  `["--from", SPEC, "bashboy", "check", "--claude-hook"]`, so `uvx` runs the given
180
- local checkout. The path is written verbatim as a single argv element — never
181
- interpolated into a shell string.
234
+ local checkout. Use it only for a checkout or an unreleased build; a normal
235
+ published install needs no `--from`. The path is written verbatim as a single
236
+ argv element — never interpolated into a shell string.
182
237
 
183
238
  The hook reads one JSON object on stdin. On `deny` it prints the
184
239
  `hookSpecificOutput` envelope with `permissionDecision: "deny"`; on `allow`, blank
@@ -242,8 +297,9 @@ Nothing else is transmitted; no files are uploaded.
242
297
 
243
298
  Fast-path verdicts (`denylist`, `allowlist`, `disabled`) never leave the machine.
244
299
  There is no analytics, no audit log, no cache, and no telemetry. The API key is
245
- read from the environment at call time, sent only as the `Authorization` header,
246
- and never logged, persisted, or included in `config show`.
300
+ read at call time from the environment or the credential file, sent only as the
301
+ `Authorization` header, and never logged, persisted in `config.toml`, or included
302
+ in `config show`.
247
303
 
248
304
  ## Known limitation: unverified Clocked envelope
249
305
 
@@ -283,8 +339,9 @@ Layout:
283
339
 
284
340
  ```
285
341
  src/bashboy/
286
- cli.py check / install / config / selftest / version
287
- config.py TOML defaults, env overrides, API key lookup
342
+ cli.py check / install / config / auth / selftest / version
343
+ config.py TOML defaults, env overrides, API key resolution
344
+ credentials.py credential file read/write/clear + permission checks
288
345
  rules.py structural allowlist + hard denylist (pure)
289
346
  provider.py Clocked client, tolerant envelope parser
290
347
  classify.py verdict pipeline (fast paths -> model -> fail closed)
@@ -1,5 +1,9 @@
1
1
  # BashBoy
2
2
 
3
+ [![PyPI](https://img.shields.io/pypi/v/bashboy.svg)](https://pypi.org/project/bashboy/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/bashboy.svg)](https://pypi.org/project/bashboy/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
3
7
  BashBoy is a small, dependency-free Python CLI that classifies a shell command as
4
8
  `allow` or `deny` **before** a coding agent runs it. It sits in front of Claude Code
5
9
  (`PreToolUse` hook) and OpenCode (plugin) and answers one question: is this command
@@ -23,14 +27,22 @@ cannot answer clearly becomes `deny`.
23
27
 
24
28
  ## Install
25
29
 
26
- Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
30
+ Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/) (recommended; `pipx` works too).
27
31
 
28
32
  ```bash
29
- uv tool install bashboy # install once
33
+ uv tool install bashboy # install the `bashboy` command
30
34
  uvx bashboy --help # or run without installing
35
+ pipx install bashboy # pipx alternative
31
36
  ```
32
37
 
33
- From a local checkout (uvx resolves the project from the path):
38
+ Verify the install:
39
+
40
+ ```bash
41
+ bashboy version # bashboy 0.2.0
42
+ bashboy selftest # offline checks; reports provider: ok when the key works
43
+ ```
44
+
45
+ From a local checkout (for development, before a release is published):
34
46
 
35
47
  ```bash
36
48
  uvx --from /path/to/BashBoy bashboy check --command "git status"
@@ -40,14 +52,54 @@ uvx --from /path/to/BashBoy bashboy install --claude
40
52
  The package has **zero runtime dependencies** — everything is the Python standard
41
53
  library. `pytest` is a development-only dependency.
42
54
 
43
- The API key is read from the environment (default `CLOCKED_API_KEY`). It is never
44
- written to disk:
55
+ ### API key
56
+
57
+ The token is resolved at call time; the first hit wins:
58
+
59
+ | # | Source | Notes |
60
+ |---|--------|-------|
61
+ | 1 | `CLOCKED_API_KEY` (or the variable named by `[provider].api_key_env`) | Process environment; wins over everything |
62
+ | 2 | `BASHBOY_API_KEY_FILE` | Full path to a credential file |
63
+ | 3 | `[provider].api_key_file` in `config.toml` | `~` is expanded |
64
+ | 4 | `~/.bashboy/credentials` | Default per-user file |
65
+ | 5 | nothing | Model-path calls fail closed (`deny`, `source: "fallback"`) |
66
+
67
+ GUI-launched hosts (Claude Code desktop, OpenCode's background service) do not
68
+ inherit a shell profile, so a variable exported in a terminal is invisible to
69
+ them. `setx` fixes that by exposing the key to every process on the machine. A
70
+ per-user credential file is the middle ground: one host-agnostic location, no
71
+ machine-wide secret, and rotation is a single file write.
45
72
 
46
73
  ```bash
47
- export CLOCKED_API_KEY=... # bash
48
- $env:CLOCKED_API_KEY = "..." # PowerShell
74
+ bashboy auth set # prompt twice; input is never echoed
75
+ bashboy auth status # path, source, presence, permissions
76
+ bashboy auth status --check # + one live provider call
77
+ bashboy auth clear # delete after confirmation
78
+
79
+ # provisioning / CI
80
+ echo "$CLOCKED_API_KEY" | bashboy auth set --stdin --force
49
81
  ```
50
82
 
83
+ `auth set` writes the file atomically, creates `~/.bashboy` with mode `0700` on
84
+ POSIX, and sets the file itself to `0600`. The token is never accepted as a
85
+ command-line argument — it would land in shell history and process listings — and
86
+ it is never printed, logged, or stored in `config.toml` (which is shareable and
87
+ gets pasted into issues). `auth status` never prints the value and makes no
88
+ network call unless you pass `--check`.
89
+
90
+ Permission policy:
91
+
92
+ - **POSIX:** any group/other permission bit (`mode & 0o077`) is refused with a
93
+ message naming `chmod 600 <path>`, and the model path fails closed. Failing
94
+ closed beats silently consuming a leaked secret.
95
+ - **Windows:** the file lives under your user profile, whose default ACL is
96
+ owner-only. `bashboy auth status` inspects the ACL with `icacls` and reports
97
+ any extra principals; it warns but still uses the key, because inherited
98
+ entries are normal. The ACL probe never runs on the `bashboy check` hot path.
99
+
100
+ The token is read from the file at call time, so rotating it needs no host
101
+ restart. A missing or empty file simply means "no key".
102
+
51
103
  ## Quick start
52
104
 
53
105
  ```bash
@@ -58,7 +110,7 @@ bashboy check --command "git status"
58
110
  # allow (allowlist): matches allowlist: git status
59
111
 
60
112
  bashboy check --command "docker run --rm alpine sh" --json
61
- # {"decision": "deny", "source": "fallback", "reason": "classifier unavailable: no API key ...", ...}
113
+ # {"decision": "deny", "source": "fallback", "reason": "classifier unavailable: no API key configured (set CLOCKED_API_KEY or run `bashboy auth set`)", ...}
62
114
  ```
63
115
 
64
116
  Exit codes:
@@ -89,6 +141,7 @@ A missing file means these defaults:
89
141
  [provider]
90
142
  endpoint = "https://clocked.rezolve.ai/clocked/fill"
91
143
  api_key_env = "CLOCKED_API_KEY"
144
+ api_key_file = "~/.bashboy/credentials"
92
145
  timeout_ms = 4000
93
146
 
94
147
  [policy]
@@ -111,6 +164,7 @@ loosens because a file failed to parse.
111
164
  | `BASHBOY_CONFIG` | Path to the config file (default `~/.bashboy/config.toml`) |
112
165
  | `BASHBOY_ENDPOINT` | Overrides `[provider].endpoint` |
113
166
  | `BASHBOY_API_KEY_ENV` | Name of the environment variable holding the key |
167
+ | `BASHBOY_API_KEY_FILE` | Path to the credential file; overrides `[provider].api_key_file` |
114
168
  | `BASHBOY_FAIL_MODE` | `closed` (default) or `open`; anything else is an error |
115
169
  | `BASHBOY_DISABLE` | `1`/`true`/`yes` disables **all** checks (see below) |
116
170
  | `BASHBOY_DEBUG` | `1`/`true`/`yes` writes diagnostics to stderr only |
@@ -160,8 +214,9 @@ bashboy install --claude --from /path/to/BashBoy
160
214
 
161
215
  `--from SPEC` rewrites the argv to
162
216
  `["--from", SPEC, "bashboy", "check", "--claude-hook"]`, so `uvx` runs the given
163
- local checkout. The path is written verbatim as a single argv element — never
164
- interpolated into a shell string.
217
+ local checkout. Use it only for a checkout or an unreleased build; a normal
218
+ published install needs no `--from`. The path is written verbatim as a single
219
+ argv element — never interpolated into a shell string.
165
220
 
166
221
  The hook reads one JSON object on stdin. On `deny` it prints the
167
222
  `hookSpecificOutput` envelope with `permissionDecision: "deny"`; on `allow`, blank
@@ -225,8 +280,9 @@ Nothing else is transmitted; no files are uploaded.
225
280
 
226
281
  Fast-path verdicts (`denylist`, `allowlist`, `disabled`) never leave the machine.
227
282
  There is no analytics, no audit log, no cache, and no telemetry. The API key is
228
- read from the environment at call time, sent only as the `Authorization` header,
229
- and never logged, persisted, or included in `config show`.
283
+ read at call time from the environment or the credential file, sent only as the
284
+ `Authorization` header, and never logged, persisted in `config.toml`, or included
285
+ in `config show`.
230
286
 
231
287
  ## Known limitation: unverified Clocked envelope
232
288
 
@@ -266,8 +322,9 @@ Layout:
266
322
 
267
323
  ```
268
324
  src/bashboy/
269
- cli.py check / install / config / selftest / version
270
- config.py TOML defaults, env overrides, API key lookup
325
+ cli.py check / install / config / auth / selftest / version
326
+ config.py TOML defaults, env overrides, API key resolution
327
+ credentials.py credential file read/write/clear + permission checks
271
328
  rules.py structural allowlist + hard denylist (pure)
272
329
  provider.py Clocked client, tolerant envelope parser
273
330
  classify.py verdict pipeline (fast paths -> model -> fail closed)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bashboy"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Classify shell commands as allow or deny before an agent runs them."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,5 +1,5 @@
1
1
  """BashBoy: a deny-only shell-command gate for coding agents."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -19,7 +19,7 @@ import sys
19
19
  import time
20
20
  from dataclasses import dataclass
21
21
 
22
- from .config import Config, api_key, load_config
22
+ from .config import Config, load_config, resolve_api_key
23
23
  from .rules import check_allowlist, check_denylist
24
24
 
25
25
  _TRUTHY = frozenset({"1", "true", "yes"})
@@ -105,7 +105,9 @@ def classify_command(
105
105
 
106
106
  target_cwd = cwd or os.getcwd()
107
107
  try:
108
- model = provider if provider is not None else Provider(cfg.provider, key=api_key(cfg))
108
+ key, key_source = resolve_api_key(cfg)
109
+ log(f"api key source: {key_source}") # source label only -- never the token
110
+ model = provider if provider is not None else Provider(cfg.provider, key=key)
109
111
  verdict = model.classify(target_cwd, command) # type: ignore[attr-defined]
110
112
  except Exception as exc: # noqa: BLE001 - every failure must map to a verdict
111
113
  fail_open = cfg.policy.fail_mode == "open"
@@ -7,10 +7,12 @@ JSON in dry-run mode. Diagnostics go to stderr so ``| jq`` and hooks stay clean.
7
7
  from __future__ import annotations
8
8
 
9
9
  import argparse
10
+ import dataclasses
10
11
  import json
11
12
  import os
12
13
  import platform
13
14
  import sys
15
+ from getpass import getpass as _getpass
14
16
  from pathlib import Path
15
17
 
16
18
  from . import __version__
@@ -22,6 +24,15 @@ from .config import (
22
24
  api_key,
23
25
  config_path,
24
26
  load_config,
27
+ resolve_api_key,
28
+ )
29
+ from .credentials import (
30
+ CredentialError,
31
+ CredentialExistsError,
32
+ CredentialPermissionError,
33
+ clear_token,
34
+ permissions,
35
+ write_token,
25
36
  )
26
37
 
27
38
 
@@ -29,12 +40,17 @@ def _toml_bool(value: bool) -> str:
29
40
  return "true" if value else "false"
30
41
 
31
42
 
43
+ def _toml_string(value: str) -> str:
44
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
45
+
46
+
32
47
  def render_config(cfg: Config) -> str:
33
48
  lines = [
34
49
  f"# source: {cfg.path}",
35
50
  "[provider]",
36
51
  f'endpoint = "{cfg.provider.endpoint}"',
37
52
  f'api_key_env = "{cfg.provider.api_key_env}"',
53
+ f"api_key_file = {_toml_string(str(cfg.provider.api_key_file))}",
38
54
  f"timeout_ms = {cfg.provider.timeout_ms}",
39
55
  "",
40
56
  "[policy]",
@@ -102,6 +118,142 @@ def _cmd_config(args: argparse.Namespace) -> int:
102
118
  return 2
103
119
 
104
120
 
121
+ # --- auth -----------------------------------------------------------------
122
+
123
+
124
+ def _credential_target(args: argparse.Namespace, cfg: Config) -> Path:
125
+ override = getattr(args, "file", None)
126
+ if override:
127
+ return Path(override).expanduser().resolve()
128
+ return cfg.provider.api_key_file
129
+
130
+
131
+ def _cmd_auth(args: argparse.Namespace) -> int:
132
+ print(
133
+ "usage: bashboy auth {set,status,clear} ... (see `bashboy auth --help`)",
134
+ file=sys.stderr,
135
+ )
136
+ return 2
137
+
138
+
139
+ def _cmd_auth_set(args: argparse.Namespace) -> int:
140
+ cfg = load_config()
141
+ path = _credential_target(args, cfg)
142
+
143
+ if args.stdin:
144
+ token = sys.stdin.read().strip()
145
+ else:
146
+ token = _getpass("Clocked API key: ").strip()
147
+ if token:
148
+ confirm = _getpass("Repeat to confirm: ").strip()
149
+ if confirm != token:
150
+ print("bashboy: tokens did not match", file=sys.stderr)
151
+ return 2
152
+ if not token:
153
+ print("bashboy: no token provided", file=sys.stderr)
154
+ return 2
155
+
156
+ try:
157
+ write_token(path, token, overwrite=args.force)
158
+ except CredentialExistsError as exc:
159
+ print(f"bashboy: {exc}", file=sys.stderr)
160
+ return 1
161
+ except (CredentialError, OSError) as exc:
162
+ print(f"bashboy: {exc}", file=sys.stderr)
163
+ return 2
164
+
165
+ print(f"saved {path}")
166
+ print(f"permissions: {permissions(path).detail}")
167
+
168
+ if os.environ.get(cfg.provider.api_key_env, "").strip():
169
+ print(
170
+ f"note: {cfg.provider.api_key_env} is set and takes precedence over the file",
171
+ file=sys.stderr,
172
+ )
173
+
174
+ from .provider import verify_key # lazy: keeps `bashboy --help` fast
175
+
176
+ try:
177
+ verdict = verify_key(cfg.provider, token)
178
+ except Exception as exc: # noqa: BLE001 - reported, never fatal
179
+ print(f"provider: FAILED ({exc})")
180
+ return 2
181
+ print(f"provider: ok ({verdict.decision})")
182
+ return 0
183
+
184
+
185
+ def _cmd_auth_status(args: argparse.Namespace) -> int:
186
+ cfg = load_config()
187
+ path = _credential_target(args, cfg)
188
+ if path != cfg.provider.api_key_file:
189
+ # `--file` must drive resolution too, not just the reported path.
190
+ cfg = dataclasses.replace(
191
+ cfg, provider=dataclasses.replace(cfg.provider, api_key_file=path)
192
+ )
193
+ # The only place the Windows ACL is probed: never on the `check` hot path.
194
+ state = permissions(path, probe_acl=True)
195
+
196
+ print(f"config: {cfg.path}")
197
+ print(f"endpoint: {cfg.provider.endpoint}")
198
+ print(f"credential file: {path}")
199
+ print(f"permissions: {state.detail}")
200
+ if state.label == "extra-principals":
201
+ log(f"credential file ACL: {state.detail}")
202
+
203
+ try:
204
+ key, source = resolve_api_key(cfg)
205
+ except CredentialPermissionError as exc:
206
+ key, source = None, f"refused ({exc})"
207
+ except CredentialError as exc:
208
+ key, source = None, f"unreadable ({exc})"
209
+ if key and state.label in {"extra-principals", "unverified"}:
210
+ # The probe above is more specific than the hot-path label in `source`.
211
+ source = f"file {path} ({state.label})"
212
+ print(f"key source: {source}")
213
+ if key:
214
+ print("key: present")
215
+ elif source.startswith(("refused", "unreadable")):
216
+ print("key: refused")
217
+ else:
218
+ print("key: absent")
219
+
220
+ if args.check:
221
+ if key:
222
+ from .provider import verify_key # lazy: opt-in network call only
223
+
224
+ try:
225
+ verdict = verify_key(cfg.provider, key)
226
+ except Exception as exc: # noqa: BLE001 - reported, never fatal
227
+ print(f"provider: FAILED ({exc})")
228
+ else:
229
+ print(f"provider: ok ({verdict.decision})")
230
+ else:
231
+ print("provider: skipped (no API key)")
232
+ return 0
233
+
234
+
235
+ def _cmd_auth_clear(args: argparse.Namespace) -> int:
236
+ cfg = load_config()
237
+ path = _credential_target(args, cfg)
238
+ if not path.is_file():
239
+ print(f"no credential file at {path}")
240
+ return 0
241
+
242
+ if not args.force:
243
+ answer = input(f"delete {path}? [y/N] ")
244
+ if answer.strip().lower() not in {"y", "yes"}:
245
+ print("aborted")
246
+ return 1
247
+
248
+ try:
249
+ clear_token(path)
250
+ except OSError as exc:
251
+ print(f"bashboy: {exc}", file=sys.stderr)
252
+ return 2
253
+ print(f"removed {path}")
254
+ return 0
255
+
256
+
105
257
  # --- install --------------------------------------------------------------
106
258
 
107
259
  _DEFAULT_CLAUDE_SETTINGS = Path.home() / ".claude" / "settings.json"
@@ -241,6 +393,26 @@ def _build_parser() -> argparse.ArgumentParser:
241
393
  config.add_argument("--force", action="store_true", help="overwrite an existing config")
242
394
  config.set_defaults(handler=_cmd_config)
243
395
 
396
+ auth = sub.add_parser("auth", help="manage the Clocked API key file")
397
+ auth.set_defaults(handler=_cmd_auth)
398
+ auth_sub = auth.add_subparsers(dest="auth_action")
399
+
400
+ auth_set = auth_sub.add_parser("set", help="save the API key to the credential file")
401
+ auth_set.add_argument("--file", default=None, help="credential file path")
402
+ auth_set.add_argument("--stdin", action="store_true", help="read the key from stdin")
403
+ auth_set.add_argument("--force", action="store_true", help="overwrite an existing file")
404
+ auth_set.set_defaults(handler=_cmd_auth_set)
405
+
406
+ auth_status = auth_sub.add_parser("status", help="show the key source and file permissions")
407
+ auth_status.add_argument("--file", default=None, help="credential file path")
408
+ auth_status.add_argument("--check", action="store_true", help="make one live provider call")
409
+ auth_status.set_defaults(handler=_cmd_auth_status)
410
+
411
+ auth_clear = auth_sub.add_parser("clear", help="delete the credential file")
412
+ auth_clear.add_argument("--file", default=None, help="credential file path")
413
+ auth_clear.add_argument("--force", action="store_true", help="skip the confirmation prompt")
414
+ auth_clear.set_defaults(handler=_cmd_auth_clear)
415
+
244
416
  selftest = sub.add_parser("selftest", help="run offline self-checks")
245
417
  selftest.set_defaults(handler=_cmd_selftest)
246
418
 
@@ -259,7 +431,7 @@ def main(argv: list[str] | None = None) -> int:
259
431
  return 2
260
432
  try:
261
433
  return int(handler(args))
262
- except ConfigError as exc:
434
+ except (ConfigError, CredentialError) as exc:
263
435
  print(f"bashboy: {exc}", file=sys.stderr)
264
436
  return 2
265
437
  except KeyboardInterrupt: # pragma: no cover - interactive only