agent-tool-shared-cli 0.1.0__tar.gz → 0.1.1__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.
- {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/PKG-INFO +1 -1
- {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/src/agent_tool_shared_cli.egg-info/PKG-INFO +1 -1
- {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/src/agentcli/__init__.py +1 -1
- {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/src/agentcli/appspec.py +18 -0
- {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/src/agentcli/credentials.py +30 -6
- {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/tests/test_credentials.py +54 -1
- {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/README.md +0 -0
- {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/pyproject.toml +0 -0
- {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/setup.cfg +0 -0
- {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/src/agent_tool_shared_cli.egg-info/SOURCES.txt +0 -0
- {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/src/agent_tool_shared_cli.egg-info/dependency_links.txt +0 -0
- {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/src/agent_tool_shared_cli.egg-info/requires.txt +0 -0
- {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/src/agent_tool_shared_cli.egg-info/top_level.txt +0 -0
- {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/src/agentcli/errors.py +0 -0
- {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/src/agentcli/output.py +0 -0
- {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/tests/test_appspec.py +0 -0
- {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/tests/test_contract.py +0 -0
- {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/tests/test_output_render.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-tool-shared-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Shared chassis for agent-ready CLIs: the exit-code contract, JSON/table/markdown/CSV output with field projection, and keyring-backed credentials.
|
|
5
5
|
Author: Zierhut IT, Alexander Zierhut
|
|
6
6
|
License: MIT
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-tool-shared-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Shared chassis for agent-ready CLIs: the exit-code contract, JSON/table/markdown/CSV output with field projection, and keyring-backed credentials.
|
|
5
5
|
Author: Zierhut IT, Alexander Zierhut
|
|
6
6
|
License: MIT
|
|
@@ -39,6 +39,20 @@ class AppSpec:
|
|
|
39
39
|
env_prefix: str
|
|
40
40
|
"""Env-var namespace WITHOUT the trailing underscore, e.g. ``OPCLI``."""
|
|
41
41
|
|
|
42
|
+
token_env_aliases: tuple[str, ...] = ()
|
|
43
|
+
"""Extra token env vars to honour, in order, AFTER ``<PREFIX>_TOKEN``.
|
|
44
|
+
|
|
45
|
+
For wrapping a product that already has an established variable its users
|
|
46
|
+
export — Drone's ``DRONE_TOKEN``, Jira's ``JIRA_API_TOKEN``, GitLab's
|
|
47
|
+
``GITLAB_TOKEN``. Adopting the ecosystem's name is worth more than prefix
|
|
48
|
+
purity: people (and their CI) already have it set.
|
|
49
|
+
|
|
50
|
+
Ours wins when both are present — the more specific name is the more
|
|
51
|
+
deliberate one. Note the hazard this creates and surface it in `auth status`:
|
|
52
|
+
an exported alias **silently overrides a keyring login**, and for Drone the
|
|
53
|
+
``DRONE_*`` namespace is also what the runner injects into every build step.
|
|
54
|
+
"""
|
|
55
|
+
|
|
42
56
|
def __post_init__(self) -> None:
|
|
43
57
|
# These two are the whole contract; a typo here silently relocates a
|
|
44
58
|
# user's config or splits their token from their profile.
|
|
@@ -59,6 +73,10 @@ class AppSpec:
|
|
|
59
73
|
"""The full env-var name for *suffix*: ``env("TOKEN") -> "OPCLI_TOKEN"``."""
|
|
60
74
|
return f"{self.env_prefix}_{suffix}"
|
|
61
75
|
|
|
76
|
+
def token_env_names(self) -> tuple[str, ...]:
|
|
77
|
+
"""Every token env var this tool honours, in precedence order."""
|
|
78
|
+
return (self.env("TOKEN"), *self.token_env_aliases)
|
|
79
|
+
|
|
62
80
|
def getenv(self, suffix: str, default: str | None = None) -> str | None:
|
|
63
81
|
"""Read ``<PREFIX>_<SUFFIX>`` from the environment."""
|
|
64
82
|
return os.environ.get(self.env(suffix), default)
|
|
@@ -52,8 +52,24 @@ class Credentials:
|
|
|
52
52
|
|
|
53
53
|
@property
|
|
54
54
|
def env_token(self) -> str:
|
|
55
|
+
"""The canonical token env var. See :meth:`_env_token_hit` for aliases."""
|
|
55
56
|
return self.spec.env("TOKEN")
|
|
56
57
|
|
|
58
|
+
def _env_token_hit(self) -> tuple[str, str] | None:
|
|
59
|
+
"""The first token env var that is set, as ``(name, value)``.
|
|
60
|
+
|
|
61
|
+
Checks ``<PREFIX>_TOKEN`` first, then any ecosystem aliases in order.
|
|
62
|
+
Returns the NAME as well as the value, because an operator who logged in
|
|
63
|
+
via the keyring and is unknowingly being overridden by an exported
|
|
64
|
+
variable needs to be told exactly which one — "it works on my machine"
|
|
65
|
+
lives here.
|
|
66
|
+
"""
|
|
67
|
+
for name in self.spec.token_env_names():
|
|
68
|
+
val = os.environ.get(name)
|
|
69
|
+
if val:
|
|
70
|
+
return name, val
|
|
71
|
+
return None
|
|
72
|
+
|
|
57
73
|
def _fallback_file(self) -> Path:
|
|
58
74
|
return self.spec.credentials_file()
|
|
59
75
|
|
|
@@ -83,8 +99,9 @@ class Credentials:
|
|
|
83
99
|
|
|
84
100
|
def backend_name(self) -> str:
|
|
85
101
|
"""Human-readable name of the active secret backend (for `auth status`)."""
|
|
86
|
-
|
|
87
|
-
|
|
102
|
+
hit = self._env_token_hit()
|
|
103
|
+
if hit:
|
|
104
|
+
return f"environment variable ${hit[0]}"
|
|
88
105
|
if _keyring_available():
|
|
89
106
|
try:
|
|
90
107
|
import keyring
|
|
@@ -115,10 +132,17 @@ class Credentials:
|
|
|
115
132
|
return "file"
|
|
116
133
|
|
|
117
134
|
def get_token(self, profile: str) -> str | None:
|
|
118
|
-
"""Resolve the token for *profile*, honouring the env override first.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
135
|
+
"""Resolve the token for *profile*, honouring the env override first.
|
|
136
|
+
|
|
137
|
+
Precedence is **env > keyring > file**, deliberately: it is what lets a
|
|
138
|
+
tool run non-interactively in CI without touching a keyring that isn't
|
|
139
|
+
there. Do not invert it — but do surface it (`backend_name`), because an
|
|
140
|
+
exported variable silently beating a keyring login is confusing exactly
|
|
141
|
+
when you can least afford it.
|
|
142
|
+
"""
|
|
143
|
+
hit = self._env_token_hit()
|
|
144
|
+
if hit:
|
|
145
|
+
return hit[1]
|
|
122
146
|
if _keyring_available():
|
|
123
147
|
try:
|
|
124
148
|
import keyring
|
|
@@ -14,7 +14,9 @@ from agentcli import AppSpec, Credentials
|
|
|
14
14
|
from agentcli import credentials as credmod
|
|
15
15
|
|
|
16
16
|
OP = AppSpec(name="op-cli", env_prefix="OPCLI")
|
|
17
|
-
|
|
17
|
+
# Drone wraps a product whose users already export DRONE_TOKEN (the official Go
|
|
18
|
+
# CLI uses it), so we honour it as an alias behind our own name.
|
|
19
|
+
DRONE = AppSpec(name="drone-cli", env_prefix="DRONECLI", token_env_aliases=("DRONE_TOKEN",))
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
@pytest.fixture(autouse=True)
|
|
@@ -25,6 +27,57 @@ def _hermetic(monkeypatch, tmp_path):
|
|
|
25
27
|
monkeypatch.setenv("DRONECLI_CONFIG_DIR", str(tmp_path / "drone"))
|
|
26
28
|
monkeypatch.delenv("OPCLI_TOKEN", raising=False)
|
|
27
29
|
monkeypatch.delenv("DRONECLI_TOKEN", raising=False)
|
|
30
|
+
monkeypatch.delenv("DRONE_TOKEN", raising=False)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---- ecosystem token aliases (DRONE_TOKEN, JIRA_API_TOKEN, …) --------
|
|
34
|
+
|
|
35
|
+
def test_alias_env_var_is_honoured(monkeypatch):
|
|
36
|
+
"""A user who already exports DRONE_TOKEN should just work."""
|
|
37
|
+
monkeypatch.setenv("DRONE_TOKEN", "from-drone-token")
|
|
38
|
+
assert Credentials(DRONE).get_token("default") == "from-drone-token"
|
|
39
|
+
assert "$DRONE_TOKEN" in Credentials(DRONE).backend_name()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_our_own_name_beats_the_alias(monkeypatch):
|
|
43
|
+
"""The more specific name is the more deliberate one."""
|
|
44
|
+
monkeypatch.setenv("DRONE_TOKEN", "ecosystem")
|
|
45
|
+
monkeypatch.setenv("DRONECLI_TOKEN", "ours")
|
|
46
|
+
assert Credentials(DRONE).get_token("default") == "ours"
|
|
47
|
+
assert "$DRONECLI_TOKEN" in Credentials(DRONE).backend_name()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_alias_overrides_the_keyring_and_says_so(monkeypatch):
|
|
51
|
+
"""The documented hazard: env beats a keyring login, silently.
|
|
52
|
+
|
|
53
|
+
We do not invert the precedence (CI depends on env winning) — but
|
|
54
|
+
backend_name() must name the variable that actually spoke, or the operator
|
|
55
|
+
is left debugging "why is it using the wrong account?".
|
|
56
|
+
"""
|
|
57
|
+
c = Credentials(DRONE)
|
|
58
|
+
c.store_token("default", "from-keyring")
|
|
59
|
+
monkeypatch.setenv("DRONE_TOKEN", "from-env")
|
|
60
|
+
assert c.get_token("default") == "from-env"
|
|
61
|
+
assert c.backend_name() == "environment variable $DRONE_TOKEN"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_tools_without_aliases_ignore_foreign_vars(monkeypatch):
|
|
65
|
+
"""op-cli declares no aliases, so DRONE_TOKEN must mean nothing to it."""
|
|
66
|
+
monkeypatch.setenv("DRONE_TOKEN", "not-mine")
|
|
67
|
+
assert Credentials(OP).get_token("default") is None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_token_env_names_order():
|
|
71
|
+
assert DRONE.token_env_names() == ("DRONECLI_TOKEN", "DRONE_TOKEN")
|
|
72
|
+
assert OP.token_env_names() == ("OPCLI_TOKEN",)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_empty_env_var_is_not_a_token(monkeypatch):
|
|
76
|
+
"""`export DRONE_TOKEN=` must fall through, not authenticate as ""."""
|
|
77
|
+
monkeypatch.setenv("DRONE_TOKEN", "")
|
|
78
|
+
c = Credentials(DRONE)
|
|
79
|
+
c.store_token("default", "from-keyring")
|
|
80
|
+
assert c.get_token("default") == "from-keyring"
|
|
28
81
|
|
|
29
82
|
|
|
30
83
|
def test_env_token_wins_over_everything(monkeypatch):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|