agentshim 0.16.0__tar.gz → 0.18.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 (114) hide show
  1. {agentshim-0.16.0 → agentshim-0.18.0}/CHANGELOG.md +41 -0
  2. {agentshim-0.16.0 → agentshim-0.18.0}/PKG-INFO +1 -1
  3. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/__init__.py +21 -1
  4. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/__init__.py +16 -0
  5. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/auth.py +270 -11
  6. agentshim-0.18.0/agentshim/core/models.py +247 -0
  7. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/profile.py +20 -4
  8. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/claude/provider.py +34 -3
  9. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/provider.py +11 -0
  10. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/gemini/provider.py +18 -1
  11. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/opencode/provider.py +3 -0
  12. {agentshim-0.16.0 → agentshim-0.18.0}/pyproject.toml +1 -1
  13. {agentshim-0.16.0 → agentshim-0.18.0}/.gitignore +0 -0
  14. {agentshim-0.16.0 → agentshim-0.18.0}/LICENSE +0 -0
  15. {agentshim-0.16.0 → agentshim-0.18.0}/README.md +0 -0
  16. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/agent.py +0 -0
  17. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/_files.py +0 -0
  18. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/checkpoints.py +0 -0
  19. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/clock.py +0 -0
  20. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/conversation.py +0 -0
  21. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/env.py +0 -0
  22. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/errors.py +0 -0
  23. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/events.py +0 -0
  24. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/home.py +0 -0
  25. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/ids.py +0 -0
  26. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/mcp.py +0 -0
  27. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/permissions.py +0 -0
  28. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/pricing.py +0 -0
  29. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/provider.py +0 -0
  30. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/schema.py +0 -0
  31. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/session_policy.py +0 -0
  32. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/skills.py +0 -0
  33. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/status.py +0 -0
  34. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/stream.py +0 -0
  35. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/turn.py +0 -0
  36. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/core/usage.py +0 -0
  37. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/execution/__init__.py +0 -0
  38. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/execution/confinement.py +0 -0
  39. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/execution/docker.py +0 -0
  40. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/execution/executor.py +0 -0
  41. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/execution/host.py +0 -0
  42. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/execution/process.py +0 -0
  43. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/execution/transform.py +0 -0
  44. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/oneshot.py +0 -0
  45. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/probe.py +0 -0
  46. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/__init__.py +0 -0
  47. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/claude/__init__.py +0 -0
  48. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/claude/events.py +0 -0
  49. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/claude/failures.py +0 -0
  50. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/claude/hooks/__init__.py +0 -0
  51. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
  52. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/claude/parser.py +0 -0
  53. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/claude/probe.py +0 -0
  54. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/claude/rate_limits.py +0 -0
  55. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/claude/sandbox.py +0 -0
  56. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/claude/scripted.py +0 -0
  57. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/claude/stream_argv.py +0 -0
  58. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/claude/stream_transport.py +0 -0
  59. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/claude/user_hooks.py +0 -0
  60. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/__init__.py +0 -0
  61. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/_toml.py +0 -0
  62. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/app_server/__init__.py +0 -0
  63. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/app_server/_wire.py +0 -0
  64. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/app_server/approvals.py +0 -0
  65. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/app_server/channel.py +0 -0
  66. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/app_server/errors.py +0 -0
  67. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/app_server/items.py +0 -0
  68. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/app_server/mcp.py +0 -0
  69. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/app_server/permissions.py +0 -0
  70. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/app_server/profile.py +0 -0
  71. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/app_server/protocol.py +0 -0
  72. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/app_server/rate_limits.py +0 -0
  73. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/app_server/transport.py +0 -0
  74. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/app_server/usage.py +0 -0
  75. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/events.py +0 -0
  76. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/failures.py +0 -0
  77. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/mcp_scope.py +0 -0
  78. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/parser.py +0 -0
  79. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/probe.py +0 -0
  80. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/rules.py +0 -0
  81. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/sandbox.py +0 -0
  82. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/scripted.py +0 -0
  83. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/codex/skills.py +0 -0
  84. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/copilot/__init__.py +0 -0
  85. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/copilot/events.py +0 -0
  86. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/copilot/parser.py +0 -0
  87. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/copilot/probe.py +0 -0
  88. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/copilot/provider.py +0 -0
  89. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/copilot/scripted.py +0 -0
  90. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/gemini/__init__.py +0 -0
  91. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/gemini/events.py +0 -0
  92. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/gemini/parser.py +0 -0
  93. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/gemini/probe.py +0 -0
  94. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/gemini/scripted.py +0 -0
  95. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/opencode/__init__.py +0 -0
  96. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/opencode/events.py +0 -0
  97. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/opencode/parser.py +0 -0
  98. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/opencode/probe.py +0 -0
  99. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/providers/opencode/scripted.py +0 -0
  100. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/py.typed +0 -0
  101. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/runtime.py +0 -0
  102. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/session.py +0 -0
  103. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/testing/__init__.py +0 -0
  104. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/testing/claude_stream.py +0 -0
  105. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/testing/clock.py +0 -0
  106. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/testing/codex_app_server.py +0 -0
  107. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/testing/confinement.py +0 -0
  108. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/testing/contracts.py +0 -0
  109. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/testing/conversation.py +0 -0
  110. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/testing/probe.py +0 -0
  111. {agentshim-0.16.0 → agentshim-0.18.0}/agentshim/testing/process.py +0 -0
  112. {agentshim-0.16.0 → agentshim-0.18.0}/tests/fixtures/claude_stream/README.md +0 -0
  113. {agentshim-0.16.0 → agentshim-0.18.0}/tests/fixtures/codex/README.md +0 -0
  114. {agentshim-0.16.0 → agentshim-0.18.0}/tests/fixtures/codex_app_server/README.md +0 -0
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.0 (2026-10-16)
4
+
5
+ ### Added
6
+
7
+ - `context_window(provider, model)` and `model_spec(provider, model)`: a model's context
8
+ window in tokens from a static, versioned table (`ModelSpec`, `ModelTable`,
9
+ `default_models()`), each entry citing the vendor page it was read from. Covers the
10
+ Claude 5 family (`claude-fable-5-1`, `claude-opus-5-5`, `claude-sonnet-5-5`,
11
+ `claude-haiku-5-5`, all 1M) and current OpenAI models. An unknown model, or a
12
+ bracketed variant, returns `None`; the lookup never matches by prefix.
13
+ - `ProviderProfile.models`: the model ids a provider's CLI is known to run, the default
14
+ first, and `ProviderProfile.default_model`. Claude, Codex and opencode declare theirs.
15
+
16
+ ## 0.17.0 (2026-10-15)
17
+
18
+ ### Added
19
+
20
+ - `host_auth_plan(profile, env=, home=)` returns the `AuthPlan` that reproduces how the
21
+ CLI logs in by itself on a host: the first present credential source of any kind, plus
22
+ the host's endpoint variables exactly as set, so a confined copy given only the plan
23
+ sends the same credential to the same place. A host base URL is reproduced, not
24
+ validated, and a credential goes to the host's endpoint whatever its kind (Claude Code
25
+ sends `CLAUDE_CODE_OAUTH_TOKEN` to a custom `ANTHROPIC_BASE_URL`). It raises
26
+ `HostAuthUnplannableError`, whose `reason` (`HostAuthGap`) is `UNCLASSIFIED`,
27
+ `NO_CREDENTIAL`, `EXTERNAL_CREDENTIAL` or `SETTINGS_ENV`, when no plan can reproduce
28
+ the host's login.
29
+ - `ExternalCredential`, a `CredentialSource` for a way the CLI logs in that no plan can
30
+ hand over, detected by variables, paths or settings keys. Claude declares a cloud
31
+ provider (`CLAUDE_CODE_USE_BEDROCK`, `_VERTEX`, `_FOUNDRY`), `apiKeyHelper`, and
32
+ Anthropic profiles at their documented places in its precedence; Gemini declares an
33
+ auth type selected in settings or by variable, and `.gemini/.env`.
34
+ - `ProviderProfile.settings` (`SettingsSpec`): the settings files a CLI reads at start
35
+ that can change how it logs in, and the key of the block it applies over its
36
+ environment (Claude's `env`).
37
+
38
+ ### Changed
39
+
40
+ - `CredentialSource` is now `EnvCredential | FileCredential | ExternalCredential`;
41
+ code that matched on two variants must handle the third. `plan_auth` never chooses an
42
+ `ExternalCredential` and names a present one in `AuthPlan.withheld`.
43
+
3
44
  ## 0.16.0 (2026-10-14)
4
45
 
5
46
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentshim
3
- Version: 0.16.0
3
+ Version: 0.18.0
4
4
  Summary: Provider-agnostic coding-agent CLI shims
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -40,9 +40,12 @@ from .core import (
40
40
  EndpointSpec,
41
41
  EnvCredential,
42
42
  EventHandlerBase,
43
+ ExternalCredential,
43
44
  FailureKind,
44
45
  FileCredential,
45
46
  FlagsInstallation,
47
+ HostAuthGap,
48
+ HostAuthUnplannableError,
46
49
  HttpMcpServer,
47
50
  IdAllocator,
48
51
  InMemoryCheckpointStore,
@@ -54,6 +57,8 @@ from .core import (
54
57
  McpServer,
55
58
  McpTransport,
56
59
  ModelPricing,
60
+ ModelSpec,
61
+ ModelTable,
57
62
  NativeMode,
58
63
  NativePermissions,
59
64
  NoopInstallation,
@@ -88,6 +93,7 @@ from .core import (
88
93
  SessionStarted,
89
94
  SessionState,
90
95
  SessionStateError,
96
+ SettingsSpec,
91
97
  SkillInvoked,
92
98
  SkillScope,
93
99
  SkillsDiscovered,
@@ -120,12 +126,16 @@ from .core import (
120
126
  UsageReport,
121
127
  compact_json,
122
128
  compose_event_handlers,
129
+ context_window,
123
130
  cost_usd,
131
+ default_models,
124
132
  default_pricing,
125
133
  dialect_problems,
134
+ host_auth_plan,
126
135
  install_config_file,
127
136
  interactive_env,
128
137
  materialize,
138
+ model_spec,
129
139
  normalize,
130
140
  normalized_usage,
131
141
  parse_json_object,
@@ -166,7 +176,7 @@ from .providers.opencode import OpencodeProvider
166
176
  from .runtime import Agent
167
177
  from .session import Session, Turn, TurnTicket, map_mcp_servers
168
178
 
169
- __version__ = "0.16.0"
179
+ __version__ = "0.18.0"
170
180
 
171
181
  __all__ = [
172
182
  "Agent",
@@ -219,10 +229,13 @@ __all__ = [
219
229
  "EndpointSpec",
220
230
  "EnvCredential",
221
231
  "EventHandlerBase",
232
+ "ExternalCredential",
222
233
  "FailureKind",
223
234
  "FileCredential",
224
235
  "FlagsInstallation",
225
236
  "GeminiProvider",
237
+ "HostAuthGap",
238
+ "HostAuthUnplannableError",
226
239
  "HostCommandExecutor",
227
240
  "HostProcess",
228
241
  "HttpMcpServer",
@@ -236,6 +249,8 @@ __all__ = [
236
249
  "McpServer",
237
250
  "McpTransport",
238
251
  "ModelPricing",
252
+ "ModelSpec",
253
+ "ModelTable",
239
254
  "NativeMode",
240
255
  "NativePermissions",
241
256
  "NoRunningTurnError",
@@ -278,6 +293,7 @@ __all__ = [
278
293
  "SessionStarted",
279
294
  "SessionState",
280
295
  "SessionStateError",
296
+ "SettingsSpec",
281
297
  "SkillInvoked",
282
298
  "SkillScope",
283
299
  "SkillSignal",
@@ -318,14 +334,18 @@ __all__ = [
318
334
  "compact_json",
319
335
  "compose_event_handlers",
320
336
  "confine",
337
+ "context_window",
321
338
  "cost_usd",
339
+ "default_models",
322
340
  "default_pricing",
323
341
  "dialect_problems",
324
342
  "get_provider",
343
+ "host_auth_plan",
325
344
  "install_config_file",
326
345
  "interactive_env",
327
346
  "map_mcp_servers",
328
347
  "materialize",
348
+ "model_spec",
329
349
  "normalize",
330
350
  "normalized_usage",
331
351
  "parse_json_object",
@@ -15,8 +15,13 @@ from .auth import (
15
15
  CredentialSource,
16
16
  EndpointSpec,
17
17
  EnvCredential,
18
+ ExternalCredential,
18
19
  FileCredential,
20
+ HostAuthGap,
21
+ HostAuthUnplannableError,
22
+ SettingsSpec,
19
23
  StagedCredentialFile,
24
+ host_auth_plan,
20
25
  plan_auth,
21
26
  )
22
27
  from .checkpoints import Checkpoint, CheckpointStore, InMemoryCheckpointStore
@@ -90,6 +95,7 @@ from .mcp import (
90
95
  StdioMcpServer,
91
96
  install_config_file,
92
97
  )
98
+ from .models import ModelSpec, ModelTable, context_window, default_models, model_spec
93
99
  from .permissions import ApprovalPolicy, NativeMode, NativePermissions
94
100
  from .pricing import ModelPricing, PricingTable, cost_usd, default_pricing, price_for
95
101
  from .profile import (
@@ -153,9 +159,12 @@ __all__ = [
153
159
  "EndpointSpec",
154
160
  "EnvCredential",
155
161
  "EventHandlerBase",
162
+ "ExternalCredential",
156
163
  "FailureKind",
157
164
  "FileCredential",
158
165
  "FlagsInstallation",
166
+ "HostAuthGap",
167
+ "HostAuthUnplannableError",
159
168
  "HttpMcpServer",
160
169
  "IdAllocator",
161
170
  "InMemoryCheckpointStore",
@@ -167,6 +176,8 @@ __all__ = [
167
176
  "McpServer",
168
177
  "McpTransport",
169
178
  "ModelPricing",
179
+ "ModelSpec",
180
+ "ModelTable",
170
181
  "NativeMode",
171
182
  "NativePermissions",
172
183
  "NoRunningTurnError",
@@ -201,6 +212,7 @@ __all__ = [
201
212
  "SessionStarted",
202
213
  "SessionState",
203
214
  "SessionStateError",
215
+ "SettingsSpec",
204
216
  "SkillInvoked",
205
217
  "SkillScope",
206
218
  "SkillSignal",
@@ -233,12 +245,16 @@ __all__ = [
233
245
  "UsageReport",
234
246
  "compact_json",
235
247
  "compose_event_handlers",
248
+ "context_window",
236
249
  "cost_usd",
250
+ "default_models",
237
251
  "default_pricing",
238
252
  "dialect_problems",
253
+ "host_auth_plan",
239
254
  "install_config_file",
240
255
  "interactive_env",
241
256
  "materialize",
257
+ "model_spec",
242
258
  "normalize",
243
259
  "normalized_usage",
244
260
  "parse_json_object",
@@ -16,6 +16,12 @@ exact environment variables and files to deliver. The caller delivers the
16
16
  plan and nothing more of the provider's ``auth_env_vars`` or ``auth_files``;
17
17
  the readiness probe then reports which kind of credential the CLI says it is
18
18
  using (``ProviderStatus.credential``), so the caller can confirm it.
19
+
20
+ A caller with no policy of its own, that wants a confined copy of the CLI to
21
+ log in exactly as the CLI would on the host, asks ``host_auth_plan`` instead:
22
+ it applies the CLI's own precedence with every kind allowed and the host's
23
+ endpoint as it is, and refuses (``HostAuthUnplannableError``) when the host
24
+ CLI would log in some way a plan cannot reproduce.
19
25
  """
20
26
 
21
27
  from __future__ import annotations
@@ -85,7 +91,49 @@ class FileCredential:
85
91
  return self.classify(contents)
86
92
 
87
93
 
88
- CredentialSource = EnvCredential | FileCredential
94
+ @dataclass(frozen=True)
95
+ class ExternalCredential:
96
+ """A way the CLI logs in that no plan can hand to another copy of it.
97
+
98
+ A cloud provider selected by a variable (whose own credentials live in
99
+ other tools' state), a helper command named in a settings file, a
100
+ credential store outside the CLI's state root. It is declared at its place
101
+ in the precedence order so ``host_auth_plan`` knows when the host CLI would
102
+ use it; ``plan_auth`` never chooses it. It is present when any variable in
103
+ ``env`` is set, any home-relative path in ``paths`` exists, or any of the
104
+ profile's ``settings`` files has one of ``settings_keys`` at its top level.
105
+ """
106
+
107
+ label: str
108
+ env: tuple[str, ...] = ()
109
+ paths: tuple[str, ...] = ()
110
+ settings_keys: tuple[str, ...] = ()
111
+
112
+ def __post_init__(self) -> None:
113
+ """Require some way to detect the credential."""
114
+ if not (self.env or self.paths or self.settings_keys):
115
+ msg = f"ExternalCredential {self.label!r} needs an env, paths or settings_keys entry"
116
+ raise ValueError(msg)
117
+
118
+
119
+ CredentialSource = EnvCredential | FileCredential | ExternalCredential
120
+ #: The sources a plan can hand over.
121
+ _DeliverableSource = EnvCredential | FileCredential
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class SettingsSpec:
126
+ """The JSON settings files a CLI reads from its home when it starts.
127
+
128
+ ``files`` are home-relative paths. ``env_key`` names a top-level object
129
+ whose string entries the CLI applies over its own environment (Claude
130
+ Code's ``env``), or ``None`` when the CLI has no such block. Settings can
131
+ change which credential or endpoint the CLI uses, so ``host_auth_plan``
132
+ reads them; neither plan function delivers them.
133
+ """
134
+
135
+ files: tuple[str, ...]
136
+ env_key: str | None = None
89
137
 
90
138
 
91
139
  @dataclass(frozen=True)
@@ -170,6 +218,37 @@ class AuthUnavailableError(AgentShimError):
170
218
  """
171
219
 
172
220
 
221
+ class HostAuthGap(str, Enum):
222
+ """Why ``host_auth_plan`` cannot reproduce how the host CLI logs in.
223
+
224
+ ``UNCLASSIFIED``: the profile declares no credential sources. ``NO_CREDENTIAL``:
225
+ none of the declared sources is present, so the CLI either cannot log in
226
+ or does it some way agentshim does not know. ``EXTERNAL_CREDENTIAL``: the
227
+ CLI would use an ``ExternalCredential``. ``SETTINGS_ENV``: a settings file
228
+ sets a credential or endpoint variable of its own, so the environment the
229
+ CLI logs in with is not the caller's.
230
+ """
231
+
232
+ UNCLASSIFIED = "unclassified"
233
+ NO_CREDENTIAL = "no_credential"
234
+ EXTERNAL_CREDENTIAL = "external_credential"
235
+ SETTINGS_ENV = "settings_env"
236
+
237
+
238
+ class HostAuthUnplannableError(AgentShimError):
239
+ """The host CLI's login cannot be reproduced by a plan; ``reason`` says why.
240
+
241
+ The message names the variables or files involved, never a value. A caller
242
+ that still wants the CLI logged in elsewhere has only the host's whole auth
243
+ state (``auth_env_vars`` and ``auth_files``) to hand over.
244
+ """
245
+
246
+ def __init__(self, reason: HostAuthGap, message: str) -> None:
247
+ """Record *reason* beside the message."""
248
+ super().__init__(message)
249
+ self.reason = reason
250
+
251
+
173
252
  def plan_auth(
174
253
  profile: ProviderProfile,
175
254
  policy: AuthPolicy,
@@ -205,10 +284,6 @@ def plan_auth(
205
284
  {name: env[name] for name in profile.endpoint.companion_env if _set(env, name)}
206
285
  )
207
286
 
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
287
  return AuthPlan(
213
288
  provider=profile.name,
214
289
  credential=kind,
@@ -216,7 +291,61 @@ def plan_auth(
216
291
  endpoint=policy.endpoint,
217
292
  env=MappingProxyType(delivered_env),
218
293
  files=files,
219
- withheld=tuple(sorted(on_host - delivered)),
294
+ withheld=_withheld(profile, env, home, present, _delivered(delivered_env, files)),
295
+ )
296
+
297
+
298
+ def host_auth_plan(profile: ProviderProfile, *, env: Mapping[str, str], home: Path) -> AuthPlan:
299
+ """Say how *profile*'s CLI logs in by itself on a host, as a plan a confined copy can use.
300
+
301
+ The CLI's own choice: the first present source in ``credential_sources``,
302
+ of any kind, at whatever endpoint *env* points it to. The plan delivers
303
+ that credential, and the host's endpoint variables (``EndpointSpec``
304
+ ``base_url_env`` and ``companion_env``) exactly as they are set, so a
305
+ confined copy given only the plan sends the same credential to the same
306
+ place. ``AuthPlan.endpoint`` is the host's base URL, or ``None`` for the
307
+ vendor's endpoint.
308
+
309
+ The host's base URL is reproduced, not validated: a value the CLI cannot
310
+ use fails the copy the same way it fails the host CLI (Claude Code fails
311
+ every request with "Invalid URL" and never falls back to its vendor). The
312
+ credential's kind is not checked against ``EndpointSpec.credentials``
313
+ either: what the CLI does with a credential and a custom endpoint is the
314
+ CLI's behaviour, and the copy reproduces it.
315
+
316
+ Raises:
317
+ HostAuthUnplannableError: the profile declares no credential sources,
318
+ none is present, the CLI would log in with an
319
+ ``ExternalCredential``, or a settings file sets a credential or
320
+ endpoint variable; ``reason`` is the matching ``HostAuthGap``.
321
+ """
322
+ if not profile.credential_sources:
323
+ msg = f"{profile.name} declares no credential sources agentshim can classify"
324
+ raise HostAuthUnplannableError(HostAuthGap.UNCLASSIFIED, msg)
325
+ settings = _read_settings(profile, home)
326
+ _refuse_settings_env(profile, settings)
327
+ chosen, kind = _host_choice(profile, env, home, settings)
328
+ delivered_env: dict[str, str] = {}
329
+ files: tuple[StagedCredentialFile, ...] = ()
330
+ if isinstance(chosen, EnvCredential):
331
+ delivered_env[chosen.name] = env[chosen.name]
332
+ else:
333
+ refreshable = kind is CredentialKind.SUBSCRIPTION
334
+ files = (StagedCredentialFile(home / chosen.path, chosen.path, refreshable),)
335
+ endpoint: str | None = None
336
+ if profile.endpoint is not None:
337
+ names = (profile.endpoint.base_url_env, *profile.endpoint.companion_env)
338
+ delivered_env.update({name: env[name] for name in names if _set(env, name)})
339
+ endpoint = delivered_env.get(profile.endpoint.base_url_env)
340
+ present = _present_names(profile, env, home, settings)
341
+ return AuthPlan(
342
+ provider=profile.name,
343
+ credential=kind,
344
+ source=_name(chosen),
345
+ endpoint=endpoint,
346
+ env=MappingProxyType(delivered_env),
347
+ files=files,
348
+ withheld=_withheld(profile, env, home, present, _delivered(delivered_env, files)),
220
349
  )
221
350
 
222
351
 
@@ -270,11 +399,14 @@ def _choose(
270
399
  allowed: frozenset[CredentialKind],
271
400
  env: Mapping[str, str],
272
401
  home: Path,
273
- ) -> tuple[CredentialSource, CredentialKind, list[str]]:
402
+ ) -> tuple[_DeliverableSource, CredentialKind, list[str]]:
274
403
  """The first present allowed source, its kind, and every present source's name."""
275
- chosen: tuple[CredentialSource, CredentialKind] | None = None
404
+ chosen: tuple[_DeliverableSource, CredentialKind] | None = None
276
405
  present: list[str] = []
277
406
  for source in profile.credential_sources:
407
+ if isinstance(source, ExternalCredential):
408
+ present += _external_found(profile, source, env, home)
409
+ continue
278
410
  kind = _present_kind(source, env, home)
279
411
  if kind is None:
280
412
  continue
@@ -286,8 +418,134 @@ def _choose(
286
418
  return chosen[0], chosen[1], present
287
419
 
288
420
 
421
+ def _host_choice(
422
+ profile: ProviderProfile,
423
+ env: Mapping[str, str],
424
+ home: Path,
425
+ settings: Mapping[str, dict[str, Any]],
426
+ ) -> tuple[_DeliverableSource, CredentialKind]:
427
+ """The source the CLI itself would log in with: the first present one, of any kind."""
428
+ for source in profile.credential_sources:
429
+ if isinstance(source, ExternalCredential):
430
+ found = _external_found(profile, source, env, home, settings)
431
+ if found:
432
+ msg = (
433
+ f"{profile.name} would log in with {source.label} "
434
+ f"({', '.join(found)}), which no plan can hand to another copy of it"
435
+ )
436
+ raise HostAuthUnplannableError(HostAuthGap.EXTERNAL_CREDENTIAL, msg)
437
+ continue
438
+ kind = _present_kind(source, env, home)
439
+ if kind is not None:
440
+ return source, kind
441
+ wanted = ", ".join(
442
+ _name(source)
443
+ for source in profile.credential_sources
444
+ if not isinstance(source, ExternalCredential)
445
+ )
446
+ msg = f"no credential {profile.name} logs in with is present: none of {wanted}"
447
+ raise HostAuthUnplannableError(HostAuthGap.NO_CREDENTIAL, msg)
448
+
449
+
450
+ def _read_settings(profile: ProviderProfile, home: Path) -> dict[str, dict[str, Any]]:
451
+ """Each of *profile*'s settings files that holds a JSON object, by path."""
452
+ out: dict[str, dict[str, Any]] = {}
453
+ for path in profile.settings.files if profile.settings is not None else ():
454
+ try:
455
+ data: Any = json.loads((home / path).read_bytes())
456
+ except (OSError, ValueError):
457
+ continue
458
+ if isinstance(data, dict):
459
+ out[path] = cast("dict[str, Any]", data)
460
+ return out
461
+
462
+
463
+ def _refuse_settings_env(profile: ProviderProfile, settings: Mapping[str, dict[str, Any]]) -> None:
464
+ """Raise when a settings file's env block sets a variable the CLI logs in with."""
465
+ key = profile.settings.env_key if profile.settings is not None else None
466
+ if key is None:
467
+ return
468
+ names = _host_auth_names(profile) | _credential_env_names(profile)
469
+ for path, data in settings.items():
470
+ block: Any = data.get(key)
471
+ if not isinstance(block, dict):
472
+ continue
473
+ overridden = sorted(names & set(cast("dict[str, Any]", block)))
474
+ if overridden:
475
+ msg = (
476
+ f"{path} sets {', '.join(overridden)} in its {key!r} block, so "
477
+ f"{profile.name} does not log in with the caller's environment"
478
+ )
479
+ raise HostAuthUnplannableError(HostAuthGap.SETTINGS_ENV, msg)
480
+
481
+
482
+ def _external_found(
483
+ profile: ProviderProfile,
484
+ source: ExternalCredential,
485
+ env: Mapping[str, str],
486
+ home: Path,
487
+ settings: Mapping[str, dict[str, Any]] | None = None,
488
+ ) -> list[str]:
489
+ """What of *source* is on the host: set variables, existing paths, settings keys."""
490
+ found = [name for name in source.env if _set(env, name)]
491
+ found += [path for path in source.paths if (home / path).exists()]
492
+ if source.settings_keys:
493
+ loaded = _read_settings(profile, home) if settings is None else settings
494
+ found += [
495
+ f"{key} in {path}"
496
+ for path, data in loaded.items()
497
+ for key in source.settings_keys
498
+ if key in data
499
+ ]
500
+ return found
501
+
502
+
503
+ def _present_names(
504
+ profile: ProviderProfile,
505
+ env: Mapping[str, str],
506
+ home: Path,
507
+ settings: Mapping[str, dict[str, Any]],
508
+ ) -> list[str]:
509
+ """The names of every declared source present on the host."""
510
+ names: list[str] = []
511
+ for source in profile.credential_sources:
512
+ if isinstance(source, ExternalCredential):
513
+ names += _external_found(profile, source, env, home, settings)
514
+ elif _present_kind(source, env, home) is not None:
515
+ names.append(_name(source))
516
+ return names
517
+
518
+
519
+ def _withheld(
520
+ profile: ProviderProfile,
521
+ env: Mapping[str, str],
522
+ home: Path,
523
+ present: list[str],
524
+ delivered: set[str],
525
+ ) -> tuple[str, ...]:
526
+ """The names of the host's auth state a plan delivering *delivered* leaves out."""
527
+ on_host = {name for name in _host_auth_names(profile) if _set(env, name)}
528
+ on_host |= {path for path in profile.auth_files if (home / path).is_file()}
529
+ on_host |= set(present)
530
+ return tuple(sorted(on_host - delivered))
531
+
532
+
533
+ def _delivered(env: Mapping[str, str], files: tuple[StagedCredentialFile, ...]) -> set[str]:
534
+ return set(env) | {staged.home_path for staged in files}
535
+
536
+
537
+ def _credential_env_names(profile: ProviderProfile) -> set[str]:
538
+ names: set[str] = set()
539
+ for source in profile.credential_sources:
540
+ if isinstance(source, EnvCredential):
541
+ names.add(source.name)
542
+ elif isinstance(source, ExternalCredential):
543
+ names.update(source.env)
544
+ return names
545
+
546
+
289
547
  def _present_kind(
290
- source: CredentialSource, env: Mapping[str, str], home: Path
548
+ source: _DeliverableSource, env: Mapping[str, str], home: Path
291
549
  ) -> CredentialKind | None:
292
550
  if isinstance(source, EnvCredential):
293
551
  return source.kind if _set(env, source.name) else None
@@ -304,7 +562,7 @@ def _set(env: Mapping[str, str], name: str) -> bool:
304
562
  return bool(value and value.strip())
305
563
 
306
564
 
307
- def _name(source: CredentialSource) -> str:
565
+ def _name(source: _DeliverableSource) -> str:
308
566
  return source.name if isinstance(source, EnvCredential) else source.path
309
567
 
310
568
 
@@ -326,7 +584,8 @@ def _unavailable(
326
584
  wanted = [
327
585
  _name(source)
328
586
  for source in profile.credential_sources
329
- if source.kind is None or source.kind in allowed
587
+ if not isinstance(source, ExternalCredential)
588
+ and (source.kind is None or source.kind in allowed)
330
589
  ]
331
590
  found = f"; present but not allowed: {', '.join(present)}" if present else ""
332
591
  return (