agentshim 0.15.7__tar.gz → 0.16.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 (113) hide show
  1. {agentshim-0.15.7 → agentshim-0.16.0}/CHANGELOG.md +20 -0
  2. {agentshim-0.15.7 → agentshim-0.16.0}/PKG-INFO +1 -1
  3. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/__init__.py +21 -1
  4. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/core/__init__.py +22 -0
  5. agentshim-0.16.0/agentshim/core/auth.py +335 -0
  6. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/core/profile.py +11 -0
  7. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/core/status.py +6 -0
  8. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/probe.py +1 -0
  9. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/claude/probe.py +21 -2
  10. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/claude/provider.py +17 -0
  11. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/probe.py +10 -1
  12. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/provider.py +22 -1
  13. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/copilot/provider.py +8 -0
  14. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/gemini/provider.py +6 -0
  15. {agentshim-0.15.7 → agentshim-0.16.0}/pyproject.toml +1 -1
  16. {agentshim-0.15.7 → agentshim-0.16.0}/.gitignore +0 -0
  17. {agentshim-0.15.7 → agentshim-0.16.0}/LICENSE +0 -0
  18. {agentshim-0.15.7 → agentshim-0.16.0}/README.md +0 -0
  19. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/agent.py +0 -0
  20. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/core/_files.py +0 -0
  21. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/core/checkpoints.py +0 -0
  22. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/core/clock.py +0 -0
  23. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/core/conversation.py +0 -0
  24. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/core/env.py +0 -0
  25. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/core/errors.py +0 -0
  26. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/core/events.py +0 -0
  27. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/core/home.py +0 -0
  28. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/core/ids.py +0 -0
  29. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/core/mcp.py +0 -0
  30. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/core/permissions.py +0 -0
  31. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/core/pricing.py +0 -0
  32. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/core/provider.py +0 -0
  33. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/core/schema.py +0 -0
  34. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/core/session_policy.py +0 -0
  35. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/core/skills.py +0 -0
  36. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/core/stream.py +0 -0
  37. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/core/turn.py +0 -0
  38. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/core/usage.py +0 -0
  39. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/execution/__init__.py +0 -0
  40. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/execution/confinement.py +0 -0
  41. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/execution/docker.py +0 -0
  42. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/execution/executor.py +0 -0
  43. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/execution/host.py +0 -0
  44. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/execution/process.py +0 -0
  45. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/execution/transform.py +0 -0
  46. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/oneshot.py +0 -0
  47. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/__init__.py +0 -0
  48. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/claude/__init__.py +0 -0
  49. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/claude/events.py +0 -0
  50. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/claude/failures.py +0 -0
  51. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/claude/hooks/__init__.py +0 -0
  52. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
  53. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/claude/parser.py +0 -0
  54. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/claude/rate_limits.py +0 -0
  55. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/claude/sandbox.py +0 -0
  56. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/claude/scripted.py +0 -0
  57. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/claude/stream_argv.py +0 -0
  58. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/claude/stream_transport.py +0 -0
  59. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/claude/user_hooks.py +0 -0
  60. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/__init__.py +0 -0
  61. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/_toml.py +0 -0
  62. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/app_server/__init__.py +0 -0
  63. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/app_server/_wire.py +0 -0
  64. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/app_server/approvals.py +0 -0
  65. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/app_server/channel.py +0 -0
  66. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/app_server/errors.py +0 -0
  67. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/app_server/items.py +0 -0
  68. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/app_server/mcp.py +0 -0
  69. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/app_server/permissions.py +0 -0
  70. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/app_server/profile.py +0 -0
  71. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/app_server/protocol.py +0 -0
  72. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/app_server/rate_limits.py +0 -0
  73. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/app_server/transport.py +0 -0
  74. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/app_server/usage.py +0 -0
  75. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/events.py +0 -0
  76. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/failures.py +0 -0
  77. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/mcp_scope.py +0 -0
  78. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/parser.py +0 -0
  79. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/rules.py +0 -0
  80. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/sandbox.py +0 -0
  81. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/scripted.py +0 -0
  82. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/codex/skills.py +0 -0
  83. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/copilot/__init__.py +0 -0
  84. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/copilot/events.py +0 -0
  85. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/copilot/parser.py +0 -0
  86. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/copilot/probe.py +0 -0
  87. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/copilot/scripted.py +0 -0
  88. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/gemini/__init__.py +0 -0
  89. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/gemini/events.py +0 -0
  90. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/gemini/parser.py +0 -0
  91. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/gemini/probe.py +0 -0
  92. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/gemini/scripted.py +0 -0
  93. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/opencode/__init__.py +0 -0
  94. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/opencode/events.py +0 -0
  95. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/opencode/parser.py +0 -0
  96. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/opencode/probe.py +0 -0
  97. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/opencode/provider.py +0 -0
  98. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/providers/opencode/scripted.py +0 -0
  99. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/py.typed +0 -0
  100. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/runtime.py +0 -0
  101. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/session.py +0 -0
  102. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/testing/__init__.py +0 -0
  103. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/testing/claude_stream.py +0 -0
  104. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/testing/clock.py +0 -0
  105. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/testing/codex_app_server.py +0 -0
  106. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/testing/confinement.py +0 -0
  107. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/testing/contracts.py +0 -0
  108. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/testing/conversation.py +0 -0
  109. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/testing/probe.py +0 -0
  110. {agentshim-0.15.7 → agentshim-0.16.0}/agentshim/testing/process.py +0 -0
  111. {agentshim-0.15.7 → agentshim-0.16.0}/tests/fixtures/claude_stream/README.md +0 -0
  112. {agentshim-0.15.7 → agentshim-0.16.0}/tests/fixtures/codex/README.md +0 -0
  113. {agentshim-0.15.7 → agentshim-0.16.0}/tests/fixtures/codex_app_server/README.md +0 -0
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0 (2026-10-14)
4
+
5
+ ### Added
6
+
7
+ - `plan_auth(profile, AuthPolicy, env=, home=)` chooses the one credential a CLI logs in
8
+ with and returns an `AuthPlan`: the exact variables and files to hand it, and the names
9
+ of the credentials it withheld. `AuthPolicy` names the allowed `CredentialKind`s
10
+ (`API_KEY`, `SUBSCRIPTION`) and an optional endpoint; with no endpoint, a base URL in
11
+ the environment is withheld. `AuthUnavailableError` names what is missing.
12
+ - `ProviderProfile.credential_sources` (`EnvCredential`, `FileCredential`, in the CLI's
13
+ precedence order) and `ProviderProfile.endpoint` (`EndpointSpec`) declare each CLI's
14
+ credentials as data. opencode declares none, so no policy can be enforced for it.
15
+ - `ProviderStatus.credential`: the kind of credential the CLI reports it is using (Claude
16
+ from `claude auth status`'s `authMethod`, Codex from `codex login status`), or `None`.
17
+
18
+ ### Changed
19
+
20
+ - `auth_env_vars` lists `CLAUDE_CODE_OAUTH_TOKEN` (claude) and `CODEX_API_KEY` (codex),
21
+ which the CLIs and their readiness probes already honoured.
22
+
3
23
  ## 0.15.7 (2026-10-09)
4
24
 
5
25
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentshim
3
- Version: 0.15.7
3
+ Version: 0.16.0
4
4
  Summary: Provider-agnostic coding-agent CLI shims
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -15,7 +15,10 @@ from .core import (
15
15
  ApprovalPolicy,
16
16
  ArgvContext,
17
17
  AssistantText,
18
+ AuthPlan,
19
+ AuthPolicy,
18
20
  AuthState,
21
+ AuthUnavailableError,
19
22
  Checkpoint,
20
23
  CheckpointStore,
21
24
  CliCheckError,
@@ -32,8 +35,13 @@ from .core import (
32
35
  ContinuityError,
33
36
  Conversation,
34
37
  ConversationSpec,
38
+ CredentialKind,
39
+ CredentialSource,
40
+ EndpointSpec,
41
+ EnvCredential,
35
42
  EventHandlerBase,
36
43
  FailureKind,
44
+ FileCredential,
37
45
  FlagsInstallation,
38
46
  HttpMcpServer,
39
47
  IdAllocator,
@@ -86,6 +94,7 @@ from .core import (
86
94
  SkillSignal,
87
95
  SkillSummary,
88
96
  SkillTracker,
97
+ StagedCredentialFile,
89
98
  Stderr,
90
99
  StdioMcpServer,
91
100
  SteerableConversation,
@@ -120,6 +129,7 @@ from .core import (
120
129
  normalize,
121
130
  normalized_usage,
122
131
  parse_json_object,
132
+ plan_auth,
123
133
  prepare_config_home,
124
134
  price_for,
125
135
  )
@@ -156,7 +166,7 @@ from .providers.opencode import OpencodeProvider
156
166
  from .runtime import Agent
157
167
  from .session import Session, Turn, TurnTicket, map_mcp_servers
158
168
 
159
- __version__ = "0.15.7"
169
+ __version__ = "0.16.0"
160
170
 
161
171
  __all__ = [
162
172
  "Agent",
@@ -168,7 +178,10 @@ __all__ = [
168
178
  "ApprovalPolicy",
169
179
  "ArgvContext",
170
180
  "AssistantText",
181
+ "AuthPlan",
182
+ "AuthPolicy",
171
183
  "AuthState",
184
+ "AuthUnavailableError",
172
185
  "CallbackCommandStreamSink",
173
186
  "Checkpoint",
174
187
  "CheckpointStore",
@@ -200,9 +213,14 @@ __all__ = [
200
213
  "Conversation",
201
214
  "ConversationSpec",
202
215
  "CopilotProvider",
216
+ "CredentialKind",
217
+ "CredentialSource",
203
218
  "DockerExecConfinement",
219
+ "EndpointSpec",
220
+ "EnvCredential",
204
221
  "EventHandlerBase",
205
222
  "FailureKind",
223
+ "FileCredential",
206
224
  "FlagsInstallation",
207
225
  "GeminiProvider",
208
226
  "HostCommandExecutor",
@@ -267,6 +285,7 @@ __all__ = [
267
285
  "SkillTracker",
268
286
  "SkillsDiscovered",
269
287
  "SpawnRequest",
288
+ "StagedCredentialFile",
270
289
  "Stderr",
271
290
  "StderrLine",
272
291
  "StdioMcpServer",
@@ -310,6 +329,7 @@ __all__ = [
310
329
  "normalize",
311
330
  "normalized_usage",
312
331
  "parse_json_object",
332
+ "plan_auth",
313
333
  "prepare_config_home",
314
334
  "price_for",
315
335
  "probe_provider",
@@ -7,6 +7,18 @@ this package may not do.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ from .auth import (
11
+ AuthPlan,
12
+ AuthPolicy,
13
+ AuthUnavailableError,
14
+ CredentialKind,
15
+ CredentialSource,
16
+ EndpointSpec,
17
+ EnvCredential,
18
+ FileCredential,
19
+ StagedCredentialFile,
20
+ plan_auth,
21
+ )
10
22
  from .checkpoints import Checkpoint, CheckpointStore, InMemoryCheckpointStore
11
23
  from .clock import Clock, StopSignal, SystemClock
12
24
  from .conversation import (
@@ -116,7 +128,10 @@ __all__ = [
116
128
  "ApprovalPolicy",
117
129
  "ArgvContext",
118
130
  "AssistantText",
131
+ "AuthPlan",
132
+ "AuthPolicy",
119
133
  "AuthState",
134
+ "AuthUnavailableError",
120
135
  "Checkpoint",
121
136
  "CheckpointStore",
122
137
  "CliCheckError",
@@ -133,8 +148,13 @@ __all__ = [
133
148
  "ContinuityError",
134
149
  "Conversation",
135
150
  "ConversationSpec",
151
+ "CredentialKind",
152
+ "CredentialSource",
153
+ "EndpointSpec",
154
+ "EnvCredential",
136
155
  "EventHandlerBase",
137
156
  "FailureKind",
157
+ "FileCredential",
138
158
  "FlagsInstallation",
139
159
  "HttpMcpServer",
140
160
  "IdAllocator",
@@ -187,6 +207,7 @@ __all__ = [
187
207
  "SkillSummary",
188
208
  "SkillTracker",
189
209
  "SkillsDiscovered",
210
+ "StagedCredentialFile",
190
211
  "Stderr",
191
212
  "StdioMcpServer",
192
213
  "SteerConsumed",
@@ -221,6 +242,7 @@ __all__ = [
221
242
  "normalize",
222
243
  "normalized_usage",
223
244
  "parse_json_object",
245
+ "plan_auth",
224
246
  "prepare_config_home",
225
247
  "price_for",
226
248
  ]
@@ -0,0 +1,335 @@
1
+ """Credential policy: which credential a CLI may log in with, and what to hand it.
2
+
3
+ A CLI can log in several ways, and it picks one by its own precedence rules
4
+ when more than one is present: a pay-per-token key in the environment, a
5
+ subscription login in a file under its state root, a token that a proxy
6
+ issued. A caller that must control which one is used (bill an API key, never
7
+ the operator's subscription) cannot do it by setting one variable: whatever
8
+ else the CLI can see may win. It has to hand the CLI exactly one credential
9
+ and nothing else.
10
+
11
+ Each provider declares its credentials as data (``ProviderProfile.
12
+ credential_sources``, in the CLI's own precedence order, and
13
+ ``ProviderProfile.endpoint``). ``plan_auth`` turns that, a caller's
14
+ ``AuthPolicy`` and the host's environment and home into an ``AuthPlan``: the
15
+ exact environment variables and files to deliver. The caller delivers the
16
+ plan and nothing more of the provider's ``auth_env_vars`` or ``auth_files``;
17
+ the readiness probe then reports which kind of credential the CLI says it is
18
+ using (``ProviderStatus.credential``), so the caller can confirm it.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from dataclasses import dataclass, field
25
+ from enum import Enum
26
+ from types import MappingProxyType
27
+ from typing import TYPE_CHECKING, Any, cast
28
+ from urllib.parse import urlsplit
29
+
30
+ from .errors import AgentShimError
31
+
32
+ if TYPE_CHECKING:
33
+ from collections.abc import Callable, Mapping
34
+ from pathlib import Path
35
+
36
+ from .profile import ProviderProfile
37
+
38
+
39
+ class CredentialKind(str, Enum):
40
+ """What a credential bills and how it lives.
41
+
42
+ ``API_KEY`` is a static secret read from the environment or a key file: a
43
+ vendor API key, or a token a proxy or gateway issued. ``SUBSCRIPTION`` is a
44
+ personal or team plan login (Claude.ai, ChatGPT, a Google or GitHub
45
+ account), usually an OAuth token the CLI refreshes.
46
+ """
47
+
48
+ API_KEY = "api_key"
49
+ SUBSCRIPTION = "subscription"
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class EnvCredential:
54
+ """A credential the CLI reads from one environment variable."""
55
+
56
+ name: str
57
+ kind: CredentialKind
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class FileCredential:
62
+ """A credential the CLI reads from a home-relative file.
63
+
64
+ The file's kind is either fixed (``kind``) or read from its contents
65
+ (``classify``, which returns ``None`` for contents it does not recognise);
66
+ exactly one is set. The path is also one of the profile's
67
+ ``credential_files``.
68
+ """
69
+
70
+ path: str
71
+ kind: CredentialKind | None = None
72
+ classify: Callable[[bytes], CredentialKind | None] | None = field(default=None, compare=False)
73
+
74
+ def __post_init__(self) -> None:
75
+ """Require exactly one of ``kind`` and ``classify``."""
76
+ if (self.kind is None) == (self.classify is None):
77
+ msg = f"FileCredential {self.path!r} needs exactly one of kind and classify"
78
+ raise ValueError(msg)
79
+
80
+ def kind_of(self, contents: bytes) -> CredentialKind | None:
81
+ """The kind of credential *contents* hold, or ``None`` if unrecognised."""
82
+ if self.kind is not None:
83
+ return self.kind
84
+ assert self.classify is not None # noqa: S101 - __post_init__ guarantees it
85
+ return self.classify(contents)
86
+
87
+
88
+ CredentialSource = EnvCredential | FileCredential
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class EndpointSpec:
93
+ """How a CLI is pointed at an API endpoint other than its vendor's.
94
+
95
+ ``base_url_env`` takes the endpoint URL. ``companion_env`` are variables
96
+ that only make sense with a custom endpoint (extra headers a gateway
97
+ needs); they are forwarded from the host only when the policy names an
98
+ endpoint. ``credentials`` are the kinds the CLI sends to a custom endpoint.
99
+ """
100
+
101
+ base_url_env: str
102
+ companion_env: tuple[str, ...] = ()
103
+ credentials: frozenset[CredentialKind] = frozenset({CredentialKind.API_KEY})
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class AuthPolicy:
108
+ """Which credentials a caller allows, and the endpoint to use.
109
+
110
+ ``endpoint`` is ``None`` for the vendor's own endpoint: a base URL left in
111
+ the host environment is then withheld, not forwarded. A URL sends every
112
+ request there.
113
+ """
114
+
115
+ credentials: frozenset[CredentialKind]
116
+ endpoint: str | None = None
117
+
118
+ def __post_init__(self) -> None:
119
+ """Reject an empty credential set and an endpoint that is not an http(s) URL."""
120
+ if not self.credentials:
121
+ msg = "AuthPolicy.credentials must allow at least one CredentialKind"
122
+ raise ValueError(msg)
123
+ if self.endpoint is not None:
124
+ parts = urlsplit(self.endpoint)
125
+ if parts.scheme not in {"http", "https"} or not parts.hostname:
126
+ msg = (
127
+ f"AuthPolicy.endpoint must be an http(s) URL with a host, got {self.endpoint!r}"
128
+ )
129
+ raise ValueError(msg)
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class StagedCredentialFile:
134
+ """A host credential file the CLI must see at ``home_path`` under its home.
135
+
136
+ ``refreshable`` files hold a login the CLI rewrites when it refreshes the
137
+ token; share those writable so the refreshed token reaches the host.
138
+ """
139
+
140
+ host_path: Path
141
+ home_path: str
142
+ refreshable: bool
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class AuthPlan:
147
+ """Exactly what to hand a CLI so it logs in with one allowed credential.
148
+
149
+ ``env`` and ``files`` are the whole delivery: a caller that also forwards
150
+ any other of the profile's ``auth_env_vars`` or ``auth_files`` (settings
151
+ files included, which can carry credentials of their own) defeats the
152
+ plan. ``source`` names the chosen credential (a variable name or a
153
+ home-relative path); ``withheld`` names, never values, the credentials and
154
+ endpoint settings present on the host that the plan leaves out, for a log.
155
+ """
156
+
157
+ provider: str
158
+ credential: CredentialKind
159
+ source: str
160
+ endpoint: str | None
161
+ env: Mapping[str, str]
162
+ files: tuple[StagedCredentialFile, ...]
163
+ withheld: tuple[str, ...]
164
+
165
+
166
+ class AuthUnavailableError(AgentShimError):
167
+ """No credential the policy allows is present, or the policy cannot be met.
168
+
169
+ The message names what each allowed kind would need, and never a value.
170
+ """
171
+
172
+
173
+ def plan_auth(
174
+ profile: ProviderProfile,
175
+ policy: AuthPolicy,
176
+ *,
177
+ env: Mapping[str, str],
178
+ home: Path,
179
+ ) -> AuthPlan:
180
+ """Choose the credential *profile*'s CLI will log in with under *policy*.
181
+
182
+ Walks the profile's ``credential_sources`` in the CLI's precedence order
183
+ and takes the first one present on the host (a non-blank variable in
184
+ *env*, or an existing file under *home* whose contents classify) whose kind
185
+ the policy allows. Only that credential is delivered, so the CLI cannot
186
+ prefer another.
187
+
188
+ Raises:
189
+ AuthUnavailableError: the profile declares no credential sources, the
190
+ policy names an endpoint the CLI cannot take or that accepts none
191
+ of the allowed kinds, or no allowed credential is present.
192
+ """
193
+ allowed = _allowed_kinds(profile, policy)
194
+ chosen, kind, present = _choose(profile, allowed, env, home)
195
+ delivered_env: dict[str, str] = {}
196
+ files: tuple[StagedCredentialFile, ...] = ()
197
+ if isinstance(chosen, EnvCredential):
198
+ delivered_env[chosen.name] = env[chosen.name]
199
+ else:
200
+ refreshable = kind is CredentialKind.SUBSCRIPTION
201
+ files = (StagedCredentialFile(home / chosen.path, chosen.path, refreshable),)
202
+ if policy.endpoint is not None and profile.endpoint is not None:
203
+ delivered_env[profile.endpoint.base_url_env] = policy.endpoint
204
+ delivered_env.update(
205
+ {name: env[name] for name in profile.endpoint.companion_env if _set(env, name)}
206
+ )
207
+
208
+ delivered = set(delivered_env) | {staged.home_path for staged in files}
209
+ on_host = {name for name in _host_auth_names(profile) if _set(env, name)}
210
+ on_host |= {path for path in profile.auth_files if (home / path).is_file()}
211
+ on_host |= set(present)
212
+ return AuthPlan(
213
+ provider=profile.name,
214
+ credential=kind,
215
+ source=_name(chosen),
216
+ endpoint=policy.endpoint,
217
+ env=MappingProxyType(delivered_env),
218
+ files=files,
219
+ withheld=tuple(sorted(on_host - delivered)),
220
+ )
221
+
222
+
223
+ def classify_json_field(
224
+ key: str, kinds: Mapping[str, CredentialKind]
225
+ ) -> Callable[[bytes], CredentialKind | None]:
226
+ """A ``FileCredential.classify`` that maps a top-level JSON string field to a kind.
227
+
228
+ Contents that are not a JSON object, lack *key*, or hold a value not in
229
+ *kinds* classify as ``None``.
230
+ """
231
+
232
+ def classify(contents: bytes) -> CredentialKind | None:
233
+ try:
234
+ data: Any = json.loads(contents)
235
+ except ValueError:
236
+ return None
237
+ if not isinstance(data, dict):
238
+ return None
239
+ value = cast("dict[str, Any]", data).get(key)
240
+ return kinds.get(value) if isinstance(value, str) else None
241
+
242
+ return classify
243
+
244
+
245
+ def _allowed_kinds(profile: ProviderProfile, policy: AuthPolicy) -> frozenset[CredentialKind]:
246
+ """The kinds *policy* allows that *profile*'s CLI can use with the policy's endpoint."""
247
+ if not profile.credential_sources:
248
+ msg = (
249
+ f"{profile.name} declares no credential sources agentshim can classify, "
250
+ "so no credential policy can be enforced for it"
251
+ )
252
+ raise AuthUnavailableError(msg)
253
+ if policy.endpoint is None:
254
+ return policy.credentials
255
+ if profile.endpoint is None:
256
+ msg = f"{profile.name} cannot be pointed at another endpoint ({policy.endpoint})"
257
+ raise AuthUnavailableError(msg)
258
+ allowed = policy.credentials & profile.endpoint.credentials
259
+ if not allowed:
260
+ msg = (
261
+ f"{profile.name} sends only {_kinds(profile.endpoint.credentials)} credentials "
262
+ f"to a custom endpoint, and the policy allows {_kinds(policy.credentials)}"
263
+ )
264
+ raise AuthUnavailableError(msg)
265
+ return allowed
266
+
267
+
268
+ def _choose(
269
+ profile: ProviderProfile,
270
+ allowed: frozenset[CredentialKind],
271
+ env: Mapping[str, str],
272
+ home: Path,
273
+ ) -> tuple[CredentialSource, CredentialKind, list[str]]:
274
+ """The first present allowed source, its kind, and every present source's name."""
275
+ chosen: tuple[CredentialSource, CredentialKind] | None = None
276
+ present: list[str] = []
277
+ for source in profile.credential_sources:
278
+ kind = _present_kind(source, env, home)
279
+ if kind is None:
280
+ continue
281
+ present.append(_name(source))
282
+ if chosen is None and kind in allowed:
283
+ chosen = (source, kind)
284
+ if chosen is None:
285
+ raise AuthUnavailableError(_unavailable(profile, allowed, present))
286
+ return chosen[0], chosen[1], present
287
+
288
+
289
+ def _present_kind(
290
+ source: CredentialSource, env: Mapping[str, str], home: Path
291
+ ) -> CredentialKind | None:
292
+ if isinstance(source, EnvCredential):
293
+ return source.kind if _set(env, source.name) else None
294
+ path = home / source.path
295
+ try:
296
+ contents = path.read_bytes()
297
+ except OSError:
298
+ return None
299
+ return source.kind_of(contents)
300
+
301
+
302
+ def _set(env: Mapping[str, str], name: str) -> bool:
303
+ value = env.get(name)
304
+ return bool(value and value.strip())
305
+
306
+
307
+ def _name(source: CredentialSource) -> str:
308
+ return source.name if isinstance(source, EnvCredential) else source.path
309
+
310
+
311
+ def _host_auth_names(profile: ProviderProfile) -> set[str]:
312
+ names = set(profile.auth_env_vars)
313
+ if profile.endpoint is not None:
314
+ names.add(profile.endpoint.base_url_env)
315
+ names.update(profile.endpoint.companion_env)
316
+ return names
317
+
318
+
319
+ def _kinds(kinds: frozenset[CredentialKind]) -> str:
320
+ return ", ".join(sorted(kind.value for kind in kinds))
321
+
322
+
323
+ def _unavailable(
324
+ profile: ProviderProfile, allowed: frozenset[CredentialKind], present: list[str]
325
+ ) -> str:
326
+ wanted = [
327
+ _name(source)
328
+ for source in profile.credential_sources
329
+ if source.kind is None or source.kind in allowed
330
+ ]
331
+ found = f"; present but not allowed: {', '.join(present)}" if present else ""
332
+ return (
333
+ f"no {_kinds(allowed)} credential for {profile.name} is available: "
334
+ f"provide one of {', '.join(wanted)}{found}"
335
+ )
@@ -16,6 +16,8 @@ from .permissions import NativeMode
16
16
  if TYPE_CHECKING:
17
17
  from collections.abc import Mapping
18
18
 
19
+ from .auth import CredentialSource, EndpointSpec
20
+
19
21
 
20
22
  class McpMechanism(Enum):
21
23
  """How a provider is told about MCP servers."""
@@ -220,3 +222,12 @@ class ProviderProfile:
220
222
  #: turn runs. A provider that does not (every one-shot transport) rejects
221
223
  #: it with ``ProviderCapabilityError`` instead of delaying the message.
222
224
  supports_steer: bool = False
225
+ #: The credentials the CLI can log in with, in the CLI's own precedence
226
+ #: order (0.16.0+). ``plan_auth`` walks them to choose the one credential
227
+ #: a caller hands the CLI. Every variable is in ``auth_env_vars`` and every
228
+ #: file in ``credential_files``. Empty means the CLI's credentials cannot
229
+ #: be classified, so no credential policy can be enforced for it.
230
+ credential_sources: tuple[CredentialSource, ...] = ()
231
+ #: How the CLI is pointed at another API endpoint, or ``None`` if it
232
+ #: cannot be (0.16.0+).
233
+ endpoint: EndpointSpec | None = None
@@ -16,6 +16,8 @@ from typing import TYPE_CHECKING
16
16
  if TYPE_CHECKING:
17
17
  from collections.abc import Callable, Mapping
18
18
 
19
+ from .auth import CredentialKind
20
+
19
21
 
20
22
  class AuthState(str, Enum):
21
23
  """Whether the provider CLI is authenticated, as far as its own tooling can say.
@@ -45,6 +47,9 @@ class ProviderStatus:
45
47
  version: str | None
46
48
  auth: AuthState
47
49
  auth_detail: str
50
+ #: The kind of credential the CLI says it is logged in with (0.16.0+), or
51
+ #: ``None`` when it did not say or could not be read unambiguously.
52
+ credential: CredentialKind | None = None
48
53
 
49
54
 
50
55
  @dataclass(frozen=True)
@@ -67,6 +72,7 @@ class AuthCheck:
67
72
 
68
73
  state: AuthState
69
74
  detail: str
75
+ credential: CredentialKind | None = None
70
76
 
71
77
 
72
78
  @dataclass(frozen=True)
@@ -101,6 +101,7 @@ def probe_on(
101
101
  version=version,
102
102
  auth=check.state,
103
103
  auth_detail=check.detail,
104
+ credential=check.credential,
104
105
  )
105
106
 
106
107
 
@@ -4,6 +4,12 @@ The command prints a JSON object (``loggedIn``, ``authMethod``, ...) and exits
4
4
  1 when logged out. It reads local credentials and the environment
5
5
  (``ANTHROPIC_API_KEY``, ``CLAUDE_CODE_OAUTH_TOKEN``) only; it makes no model
6
6
  request.
7
+
8
+ ``authMethod`` names the credential in use (Claude Code 2.1): ``claude.ai``
9
+ for the stored subscription login, ``api_key`` for ``ANTHROPIC_API_KEY``, and
10
+ ``oauth_token`` for both ``ANTHROPIC_AUTH_TOKEN`` (a static token, often a
11
+ gateway's) and ``CLAUDE_CODE_OAUTH_TOKEN`` (a subscription token). Only the
12
+ environment the command ran with tells those last two apart.
7
13
  """
8
14
 
9
15
  from __future__ import annotations
@@ -11,6 +17,7 @@ from __future__ import annotations
11
17
  import json
12
18
  from typing import TYPE_CHECKING, Any, cast
13
19
 
20
+ from agentshim.core.auth import CredentialKind
14
21
  from agentshim.core.status import AuthCheck, AuthProbe, AuthState, ProbeOutput, ProbeSpec
15
22
 
16
23
  if TYPE_CHECKING:
@@ -21,7 +28,6 @@ LOGIN_HINT = "run `claude auth login` or set ANTHROPIC_API_KEY"
21
28
 
22
29
  def read_auth(output: ProbeOutput, env: Mapping[str, str]) -> AuthCheck:
23
30
  """Read ``claude auth status``'s JSON."""
24
- del env
25
31
  try:
26
32
  data: Any = json.loads(output.stdout)
27
33
  except ValueError:
@@ -33,12 +39,25 @@ def read_auth(output: ProbeOutput, env: Mapping[str, str]) -> AuthCheck:
33
39
  if logged_in is True:
34
40
  method = status.get("authMethod")
35
41
  how = f" ({method})" if isinstance(method, str) and method != "none" else ""
36
- return AuthCheck(AuthState.KNOWN_OK, f"logged in{how}")
42
+ return AuthCheck(AuthState.KNOWN_OK, f"logged in{how}", _credential(method, env))
37
43
  if logged_in is False:
38
44
  return AuthCheck(AuthState.FAILED, f"claude is not logged in; {LOGIN_HINT}")
39
45
  return AuthCheck(AuthState.UNKNOWN, _unrecognised(output))
40
46
 
41
47
 
48
+ def _credential(method: object, env: Mapping[str, str]) -> CredentialKind | None:
49
+ if method == "claude.ai":
50
+ return CredentialKind.SUBSCRIPTION
51
+ if method == "api_key":
52
+ return CredentialKind.API_KEY
53
+ if method == "oauth_token":
54
+ bearer = bool(env.get("ANTHROPIC_AUTH_TOKEN", "").strip())
55
+ subscription = bool(env.get("CLAUDE_CODE_OAUTH_TOKEN", "").strip())
56
+ if bearer != subscription:
57
+ return CredentialKind.API_KEY if bearer else CredentialKind.SUBSCRIPTION
58
+ return None
59
+
60
+
42
61
  def _unrecognised(output: ProbeOutput) -> str:
43
62
  return f"unrecognised `claude auth status` output (exit {output.returncode})"
44
63
 
@@ -5,6 +5,12 @@ from __future__ import annotations
5
5
  import json
6
6
  from typing import TYPE_CHECKING, Any
7
7
 
8
+ from agentshim.core.auth import (
9
+ CredentialKind,
10
+ EndpointSpec,
11
+ EnvCredential,
12
+ FileCredential,
13
+ )
8
14
  from agentshim.core.errors import FailureKind, ProviderCapabilityError, SessionResumeError
9
15
  from agentshim.core.mcp import HttpMcpServer, NoopInstallation, StdioMcpServer, install_config_file
10
16
  from agentshim.core.profile import (
@@ -75,6 +81,7 @@ PROFILE = ProviderProfile(
75
81
  auth_env_vars=(
76
82
  "ANTHROPIC_AUTH_TOKEN",
77
83
  "ANTHROPIC_API_KEY",
84
+ "CLAUDE_CODE_OAUTH_TOKEN",
78
85
  "ANTHROPIC_BASE_URL",
79
86
  "ANTHROPIC_CUSTOM_HEADERS",
80
87
  ),
@@ -103,6 +110,16 @@ PROFILE = ProviderProfile(
103
110
  ".claude.json",
104
111
  ),
105
112
  credential_files=(".claude/.credentials.json",),
113
+ # Precedence as ``claude auth status`` reports it: a bearer token beats an
114
+ # API key, which beats a subscription token (``claude setup-token``),
115
+ # which beats the stored Claude.ai login.
116
+ credential_sources=(
117
+ EnvCredential("ANTHROPIC_AUTH_TOKEN", CredentialKind.API_KEY),
118
+ EnvCredential("ANTHROPIC_API_KEY", CredentialKind.API_KEY),
119
+ EnvCredential("CLAUDE_CODE_OAUTH_TOKEN", CredentialKind.SUBSCRIPTION),
120
+ FileCredential(".claude/.credentials.json", kind=CredentialKind.SUBSCRIPTION),
121
+ ),
122
+ endpoint=EndpointSpec("ANTHROPIC_BASE_URL", companion_env=("ANTHROPIC_CUSTOM_HEADERS",)),
106
123
  mcp_config_file=MCP_CONFIG_FILENAME,
107
124
  )
108
125
 
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  from typing import TYPE_CHECKING
12
12
 
13
+ from agentshim.core.auth import CredentialKind
13
14
  from agentshim.core.status import AuthCheck, AuthProbe, AuthState, ProbeOutput, ProbeSpec
14
15
 
15
16
  if TYPE_CHECKING:
@@ -22,12 +23,20 @@ LOGIN_HINT = "run `codex login`"
22
23
  _API_KEY_ENV = ("CODEX_API_KEY", "OPENAI_API_KEY")
23
24
 
24
25
 
26
+ def _credential(line: str) -> CredentialKind | None:
27
+ if line.startswith("Logged in using ChatGPT"):
28
+ return CredentialKind.SUBSCRIPTION
29
+ if line.startswith("Logged in using an API key"):
30
+ return CredentialKind.API_KEY
31
+ return None
32
+
33
+
25
34
  def read_auth(output: ProbeOutput, env: Mapping[str, str]) -> AuthCheck:
26
35
  """Read ``codex login status``'s text."""
27
36
  lines = [line.strip() for line in output.text.splitlines() if line.strip()]
28
37
  for line in lines:
29
38
  if line.startswith("Logged in"):
30
- return AuthCheck(AuthState.KNOWN_OK, line)
39
+ return AuthCheck(AuthState.KNOWN_OK, line, _credential(line))
31
40
  if any(line.startswith("Not logged in") for line in lines):
32
41
  if any(env.get(key) for key in _API_KEY_ENV):
33
42
  return AuthCheck(
@@ -6,6 +6,13 @@ import os
6
6
  import re
7
7
  from typing import TYPE_CHECKING, Any
8
8
 
9
+ from agentshim.core.auth import (
10
+ CredentialKind,
11
+ EndpointSpec,
12
+ EnvCredential,
13
+ FileCredential,
14
+ classify_json_field,
15
+ )
9
16
  from agentshim.core.errors import ProviderCapabilityError, SessionResumeError
10
17
  from agentshim.core.home import state_root
11
18
  from agentshim.core.mcp import FlagsInstallation, HttpMcpServer, NoopInstallation
@@ -81,7 +88,7 @@ PROFILE = ProviderProfile(
81
88
  "Library/Application Support/com.openai.codex",
82
89
  "Library/Caches/codex",
83
90
  ),
84
- auth_env_vars=("OPENAI_API_KEY", "OPENAI_BASE_URL"),
91
+ auth_env_vars=("CODEX_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL"),
85
92
  skill_dirs=(".agents/skills",),
86
93
  # ``exec --json`` lists no skills; a load is inferred from a shell read
87
94
  # of a ``SKILL.md`` (``skills.py``).
@@ -102,6 +109,20 @@ PROFILE = ProviderProfile(
102
109
  # not needlessly expose it to an agent process.
103
110
  auth_files=(".codex/auth.json",),
104
111
  credential_files=(".codex/auth.json",),
112
+ # ``codex exec`` takes either key in place of a stored login. auth.json
113
+ # records which login it holds (``codex login`` vs ``--with-api-key``).
114
+ credential_sources=(
115
+ EnvCredential("CODEX_API_KEY", CredentialKind.API_KEY),
116
+ EnvCredential("OPENAI_API_KEY", CredentialKind.API_KEY),
117
+ FileCredential(
118
+ ".codex/auth.json",
119
+ classify=classify_json_field(
120
+ "auth_mode",
121
+ {"chatgpt": CredentialKind.SUBSCRIPTION, "apikey": CredentialKind.API_KEY},
122
+ ),
123
+ ),
124
+ ),
125
+ endpoint=EndpointSpec("OPENAI_BASE_URL"),
105
126
  # Every turn writes a rollout under sessions/, and `resume` reads it back.
106
127
  # The rest of the state directory (worktrees, caches, logs) is not needed
107
128
  # to log in or resume.
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  from typing import TYPE_CHECKING, Any
7
7
 
8
+ from agentshim.core.auth import CredentialKind, EnvCredential, FileCredential
8
9
  from agentshim.core.errors import ProviderCapabilityError
9
10
  from agentshim.core.mcp import FlagsInstallation, HttpMcpServer, NoopInstallation
10
11
  from agentshim.core.profile import McpMechanism, OutputSchemaStyle, ProviderProfile
@@ -61,6 +62,13 @@ PROFILE = ProviderProfile(
61
62
  # config.json carries the logged-in users and, without a keychain, the
62
63
  # tokens; the CLI rewrites it in place on login and refresh.
63
64
  credential_files=(".copilot/config.json",),
65
+ # Every Copilot credential is a GitHub login tied to a Copilot plan.
66
+ credential_sources=(
67
+ EnvCredential("COPILOT_GITHUB_TOKEN", CredentialKind.SUBSCRIPTION),
68
+ EnvCredential("GH_TOKEN", CredentialKind.SUBSCRIPTION),
69
+ EnvCredential("GITHUB_TOKEN", CredentialKind.SUBSCRIPTION),
70
+ FileCredential(".copilot/config.json", kind=CredentialKind.SUBSCRIPTION),
71
+ ),
64
72
  # CLI_FLAGS: Copilot takes MCP servers as one --additional-mcp-config
65
73
  # flag, never a file.
66
74
  mcp_config_file=None,
@@ -18,6 +18,7 @@ from __future__ import annotations
18
18
 
19
19
  from typing import TYPE_CHECKING, Any
20
20
 
21
+ from agentshim.core.auth import CredentialKind, EnvCredential, FileCredential
21
22
  from agentshim.core.errors import ProviderCapabilityError, SessionResumeError
22
23
  from agentshim.core.mcp import (
23
24
  HttpMcpServer,
@@ -81,6 +82,11 @@ PROFILE = ProviderProfile(
81
82
  ".gemini/.env",
82
83
  ),
83
84
  credential_files=(".gemini/oauth_creds.json",),
85
+ credential_sources=(
86
+ EnvCredential("GEMINI_API_KEY", CredentialKind.API_KEY),
87
+ EnvCredential("GOOGLE_API_KEY", CredentialKind.API_KEY),
88
+ FileCredential(".gemini/oauth_creds.json", kind=CredentialKind.SUBSCRIPTION),
89
+ ),
84
90
  mcp_config_file=MCP_CONFIG_FILENAME,
85
91
  )
86
92
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentshim"
3
- version = "0.15.7"
3
+ version = "0.16.0"
4
4
  description = "Provider-agnostic coding-agent CLI shims"
5
5
  readme = "README.md"
6
6
  license = "MIT"
File without changes
File without changes
File without changes