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.
Files changed (18) hide show
  1. {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/PKG-INFO +1 -1
  2. {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
  3. {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/src/agentcli/__init__.py +1 -1
  4. {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/src/agentcli/appspec.py +18 -0
  5. {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/src/agentcli/credentials.py +30 -6
  6. {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/tests/test_credentials.py +54 -1
  7. {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/README.md +0 -0
  8. {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/pyproject.toml +0 -0
  9. {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/setup.cfg +0 -0
  10. {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
  11. {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
  12. {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
  13. {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
  14. {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/src/agentcli/errors.py +0 -0
  15. {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/src/agentcli/output.py +0 -0
  16. {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/tests/test_appspec.py +0 -0
  17. {agent_tool_shared_cli-0.1.0 → agent_tool_shared_cli-0.1.1}/tests/test_contract.py +0 -0
  18. {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.0
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.0
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
@@ -38,7 +38,7 @@ from .errors import (
38
38
  )
39
39
  from .output import Emitter, OutputFormat, print_error # NDJSON is Emitter.stream_json()
40
40
 
41
- __version__ = "0.1.0"
41
+ __version__ = "0.1.1"
42
42
 
43
43
  __all__ = [
44
44
  "AppSpec",
@@ -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
- if os.environ.get(self.env_token):
87
- return f"environment variable ${self.env_token}"
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
- env = os.environ.get(self.env_token)
120
- if env:
121
- return env
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
- DRONE = AppSpec(name="drone-cli", env_prefix="DRONECLI")
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):