agentshim 0.6.0__tar.gz → 0.6.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 (56) hide show
  1. {agentshim-0.6.0 → agentshim-0.6.1}/CHANGELOG.md +53 -0
  2. {agentshim-0.6.0 → agentshim-0.6.1}/PKG-INFO +1 -1
  3. agentshim-0.6.1/agentshim/core/profile.py +89 -0
  4. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/__init__.py +41 -0
  5. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/claude/__init__.py +2 -1
  6. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/claude/provider.py +12 -0
  7. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/claude/scripted.py +21 -0
  8. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/codex/__init__.py +4 -2
  9. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/codex/provider.py +99 -1
  10. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/codex/scripted.py +19 -0
  11. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/copilot/__init__.py +4 -2
  12. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/copilot/provider.py +48 -1
  13. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/copilot/scripted.py +19 -0
  14. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/gemini/__init__.py +2 -1
  15. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/gemini/provider.py +12 -2
  16. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/gemini/scripted.py +21 -0
  17. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/opencode/__init__.py +2 -1
  18. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/opencode/provider.py +12 -0
  19. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/opencode/scripted.py +20 -0
  20. agentshim-0.6.1/agentshim/testing/__init__.py +318 -0
  21. {agentshim-0.6.0 → agentshim-0.6.1}/pyproject.toml +1 -1
  22. agentshim-0.6.0/agentshim/core/profile.py +0 -57
  23. agentshim-0.6.0/agentshim/testing/__init__.py +0 -174
  24. {agentshim-0.6.0 → agentshim-0.6.1}/.gitignore +0 -0
  25. {agentshim-0.6.0 → agentshim-0.6.1}/README.md +0 -0
  26. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/__init__.py +0 -0
  27. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/agent.py +0 -0
  28. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/core/__init__.py +0 -0
  29. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/core/_files.py +0 -0
  30. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/core/env.py +0 -0
  31. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/core/errors.py +0 -0
  32. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/core/events.py +0 -0
  33. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/core/mcp.py +0 -0
  34. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/core/provider.py +0 -0
  35. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/core/schema.py +0 -0
  36. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/core/stream.py +0 -0
  37. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/core/turn.py +0 -0
  38. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/core/usage.py +0 -0
  39. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/execution/__init__.py +0 -0
  40. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/execution/executor.py +0 -0
  41. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/execution/host.py +0 -0
  42. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/execution/transform.py +0 -0
  43. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/claude/events.py +0 -0
  44. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/claude/hooks/__init__.py +0 -0
  45. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
  46. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/claude/parser.py +0 -0
  47. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/claude/sandbox.py +0 -0
  48. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/codex/events.py +0 -0
  49. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/codex/parser.py +0 -0
  50. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/copilot/events.py +0 -0
  51. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/copilot/parser.py +0 -0
  52. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/gemini/events.py +0 -0
  53. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/gemini/parser.py +0 -0
  54. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/opencode/events.py +0 -0
  55. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/opencode/parser.py +0 -0
  56. {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/py.typed +0 -0
@@ -1,5 +1,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.1 (2026-09-10)
4
+
5
+ Additive only: every new field defaults, so a 0.6.0 consumer's existing
6
+ `ProviderProfile` construction and every shipped provider keep working
7
+ unchanged.
8
+
9
+ `ProviderProfile` gains four fields:
10
+
11
+ - `container_env: Mapping[str, str]` (default empty): environment a CLI
12
+ needs to run as root in a container, beyond auth. Claude Code sets
13
+ `IS_SANDBOX=1`, which it requires before accepting
14
+ `--dangerously-skip-permissions` as root; the other four providers need
15
+ nothing extra.
16
+ - `state_root_env: str | None` (default `None`): the CLI's own documented
17
+ variable for relocating its primary state directory (`state_dirs[0]`).
18
+ `CLAUDE_CONFIG_DIR` for Claude, `CODEX_HOME` for Codex, `COPILOT_HOME` for
19
+ Copilot; `None` for Gemini and opencode, which document no such variable.
20
+ - `auth_files: tuple[str, ...]` (default empty): home-relative paths to the
21
+ files that hold credentials and user configuration, the minimal set to
22
+ copy into another environment so the CLI is already logged in. Every entry
23
+ lies inside a `state_dirs` entry.
24
+ - `mcp_config_file: str | None` (default `None`): for a `CONFIG_FILE`
25
+ provider, the workspace-relative path its MCP config is written to for a
26
+ turn (`.mcp.json`, `.gemini/settings.json`, `opencode.json`). The provider
27
+ module defines it as the same constant `install_mcp` writes to, so the two
28
+ cannot drift. `None` for `CLI_FLAGS`/`NONE` providers.
29
+
30
+ `tests/unit/providers/test_conventions.py` pins these across every provider:
31
+ `auth_files` entries lie inside `state_dirs`, `mcp_config_file` is set
32
+ exactly when `mcp` is `CONFIG_FILE` and matches where `install_mcp` actually
33
+ writes, `state_root_env` is uppercase when set, and `container_env` keys are
34
+ uppercase.
35
+
36
+ ### Added
37
+
38
+ - `agentshim.testing.scripted_resume_failure(provider, *, session_id=None)`:
39
+ a `FakeRun` that fails the way *provider* recognises a lost resumed
40
+ conversation, so a consumer can test `SessionResumeError` handling without
41
+ knowing any provider's wire format. Backed by a required
42
+ `resume_failure_lines(...)` entry in every `providers/<name>/scripted.py`,
43
+ found through `providers.get_resume_failure_lines(name)` the same way
44
+ `scripted_turn` finds `scripted_lines`, so a new provider cannot ship
45
+ without one.
46
+ - `agentshim.testing.installed_mcp_servers(provider, request, workspace)`:
47
+ the MCP servers one turn installed, keyed by server name, each normalized
48
+ to `command`/`args`/`env` (stdio) or `url`/`transport` (HTTP) regardless of
49
+ how the provider itself renders it. Reads the config file for a
50
+ CONFIG_FILE provider and parses `request.argv` for a CLI_FLAGS one, via a
51
+ `parse_mcp_servers` kept next to the renderer it inverts
52
+ (`providers/codex/provider.py`, `providers/copilot/provider.py`). Meant to
53
+ be called from inside a `FakeExecutor` `run` callback: a CONFIG_FILE
54
+ provider's config exists only for the lifetime of the turn.
55
+
3
56
  ## 0.6.0
4
57
 
5
58
  A restructure, not an upgrade. 0.6 replaces the whole public API of 0.5 and
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentshim
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: Provider-agnostic coding-agent CLI shims
5
5
  Requires-Python: >=3.10
6
6
  Provides-Extra: test
@@ -0,0 +1,89 @@
1
+ """Declared provider capabilities.
2
+
3
+ Every optional behaviour is declared here (or on a protocol) so no caller
4
+ has to probe a provider object for attributes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from enum import Enum
11
+ from types import MappingProxyType
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Mapping
16
+
17
+
18
+ class McpMechanism(Enum):
19
+ """How a provider is told about MCP servers."""
20
+
21
+ NONE = "none"
22
+ CONFIG_FILE = "config_file"
23
+ CLI_FLAGS = "cli_flags"
24
+
25
+
26
+ class OutputSchemaStyle(Enum):
27
+ """How a provider accepts a JSON Schema for structured output."""
28
+
29
+ NONE = "none"
30
+ INLINE_JSON = "inline_json"
31
+ FILE_PATH = "file_path"
32
+
33
+
34
+ class SchemaDialect(Enum):
35
+ """Which JSON Schema subset a provider accepts.
36
+
37
+ ``STRICT`` is Codex's ``--output-schema`` subset: every object declares
38
+ its properties and forbids undeclared keys. ``OPEN`` also accepts a
39
+ schema-valued or ``true`` ``additionalProperties``.
40
+ """
41
+
42
+ STRICT = "strict"
43
+ OPEN = "open"
44
+
45
+
46
+ def _no_container_env() -> Mapping[str, str]:
47
+ """Empty read-only default: a frozen spec must not carry a mutable one.
48
+
49
+ A factory rather than a shared constant because Python 3.10 and 3.11
50
+ reject an unhashable dataclass default, and ``mappingproxy`` is one. See
51
+ ``agentshim.core.mcp._no_env`` for the same pattern.
52
+ """
53
+ return MappingProxyType({})
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class ProviderProfile:
58
+ """Everything a caller needs to know about a provider without running it.
59
+
60
+ The last four fields are additive (0.6.1+) and all default, so an
61
+ existing keyword-built ``ProviderProfile`` keeps working unchanged.
62
+ ``container_env`` is environment a CLI needs to run as root in a
63
+ container, beyond auth (e.g. Claude Code's ``IS_SANDBOX=1``).
64
+ ``state_root_env`` is the documented variable that relocates
65
+ ``state_dirs[0]``, or ``None`` when the CLI names none. ``auth_files`` are
66
+ the home-relative paths, each inside a ``state_dirs`` entry, whose
67
+ contents are the minimal set to copy so the CLI is logged in elsewhere.
68
+ ``mcp_config_file`` is the workspace-relative path a ``CONFIG_FILE``
69
+ provider writes its MCP config to for a turn, and ``None`` for
70
+ ``CLI_FLAGS``/``NONE`` providers.
71
+ """
72
+
73
+ name: str
74
+ display_name: str
75
+ binary: str
76
+ supports_resume: bool
77
+ supports_reasoning_effort: bool
78
+ mcp: McpMechanism
79
+ output_schema: OutputSchemaStyle
80
+ schema_dialect: SchemaDialect | None
81
+ state_dirs: tuple[str, ...]
82
+ darwin_state_dirs: tuple[str, ...]
83
+ auth_env_vars: tuple[str, ...]
84
+ skill_dirs: tuple[str, ...]
85
+ container_install: tuple[str, ...]
86
+ container_env: Mapping[str, str] = field(default_factory=_no_container_env)
87
+ state_root_env: str | None = None
88
+ auth_files: tuple[str, ...] = ()
89
+ mcp_config_file: str | None = None
@@ -11,14 +11,19 @@ from __future__ import annotations
11
11
  from typing import TYPE_CHECKING, Any, Protocol
12
12
 
13
13
  from .claude import ClaudeProvider
14
+ from .claude import resume_failure_lines as _claude_resume_failure
14
15
  from .claude import scripted_lines as _claude_scripted
15
16
  from .codex import CodexProvider
17
+ from .codex import resume_failure_lines as _codex_resume_failure
16
18
  from .codex import scripted_lines as _codex_scripted
17
19
  from .copilot import CopilotProvider
20
+ from .copilot import resume_failure_lines as _copilot_resume_failure
18
21
  from .copilot import scripted_lines as _copilot_scripted
19
22
  from .gemini import GeminiProvider
23
+ from .gemini import resume_failure_lines as _gemini_resume_failure
20
24
  from .gemini import scripted_lines as _gemini_scripted
21
25
  from .opencode import OpencodeProvider
26
+ from .opencode import resume_failure_lines as _opencode_resume_failure
22
27
  from .opencode import scripted_lines as _opencode_scripted
23
28
 
24
29
  if TYPE_CHECKING:
@@ -49,6 +54,21 @@ class ScriptedLines(Protocol):
49
54
  ...
50
55
 
51
56
 
57
+ class ResumeFailureLines(Protocol):
58
+ """Builds the stdout, stderr and exit code of a resumed turn gone bad."""
59
+
60
+ def __call__(
61
+ self, *, session_id: str | None = None
62
+ ) -> tuple[Sequence[str], Sequence[str], int]:
63
+ """Return ``(stdout, stderr, returncode)`` for a ``FakeRun``.
64
+
65
+ ``session_id`` is folded into the scripted message for realism only;
66
+ what a resumed turn's ``classify_exit`` actually reports comes from
67
+ the turn's own argv.
68
+ """
69
+ ...
70
+
71
+
52
72
  # To add a provider: implement providers/<name>/ (provider.py, parser.py,
53
73
  # events.py, scripted.py) and add one entry to each dict below.
54
74
  _FACTORIES: dict[str, Callable[[], Provider]] = {
@@ -67,6 +87,14 @@ _SCRIPTED: dict[str, ScriptedLines] = {
67
87
  "opencode": _opencode_scripted,
68
88
  }
69
89
 
90
+ _RESUME_FAILURES: dict[str, ResumeFailureLines] = {
91
+ "claude": _claude_resume_failure,
92
+ "codex": _codex_resume_failure,
93
+ "copilot": _copilot_resume_failure,
94
+ "gemini": _gemini_resume_failure,
95
+ "opencode": _opencode_resume_failure,
96
+ }
97
+
70
98
 
71
99
  def provider_names() -> list[str]:
72
100
  """Return the supported provider names, sorted."""
@@ -91,14 +119,27 @@ def get_scripted_lines(name: str) -> ScriptedLines:
91
119
  return scripted
92
120
 
93
121
 
122
+ def get_resume_failure_lines(name: str) -> ResumeFailureLines:
123
+ """Return the test-double resume-failure builder for *name*."""
124
+ resume_failure = _RESUME_FAILURES.get(name)
125
+ if resume_failure is None:
126
+ msg = (
127
+ f"no resume-failure stream for provider {name!r}; available: {sorted(_RESUME_FAILURES)}"
128
+ )
129
+ raise ValueError(msg)
130
+ return resume_failure
131
+
132
+
94
133
  __all__ = [
95
134
  "ClaudeProvider",
96
135
  "CodexProvider",
97
136
  "CopilotProvider",
98
137
  "GeminiProvider",
99
138
  "OpencodeProvider",
139
+ "ResumeFailureLines",
100
140
  "ScriptedLines",
101
141
  "get_provider",
142
+ "get_resume_failure_lines",
102
143
  "get_scripted_lines",
103
144
  "provider_names",
104
145
  ]
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from .parser import ClaudeStreamParser
6
6
  from .provider import PROFILE, ClaudeProvider, mcp_entry
7
7
  from .sandbox import SandboxConfig, build_settings, resolve_sandbox
8
- from .scripted import scripted_lines
8
+ from .scripted import resume_failure_lines, scripted_lines
9
9
 
10
10
  __all__ = [
11
11
  "PROFILE",
@@ -15,5 +15,6 @@ __all__ = [
15
15
  "build_settings",
16
16
  "mcp_entry",
17
17
  "resolve_sandbox",
18
+ "resume_failure_lines",
18
19
  "scripted_lines",
19
20
  ]
@@ -50,6 +50,18 @@ PROFILE = ProviderProfile(
50
50
  # The installer drops the binary in /root/.local/bin.
51
51
  "ln -sf /root/.local/bin/claude /usr/local/bin/claude",
52
52
  ),
53
+ # Claude Code refuses --dangerously-skip-permissions as root unless this
54
+ # is set.
55
+ container_env={"IS_SANDBOX": "1"},
56
+ # Documented Claude Code variable that relocates ~/.claude.
57
+ state_root_env="CLAUDE_CONFIG_DIR",
58
+ auth_files=(
59
+ ".claude/.credentials.json",
60
+ ".claude/settings.json",
61
+ ".claude/settings.local.json",
62
+ ".claude.json",
63
+ ),
64
+ mcp_config_file=MCP_CONFIG_FILENAME,
53
65
  )
54
66
 
55
67
 
@@ -81,6 +81,27 @@ def scripted_lines(
81
81
  return lines
82
82
 
83
83
 
84
+ def resume_failure_lines(*, session_id: str | None = None) -> tuple[list[str], list[str], int]:
85
+ """Build the stdout, stderr and exit code of a resumed turn Claude cannot continue.
86
+
87
+ ``ClaudeProvider.classify_exit`` treats any nonzero exit of a resumed
88
+ turn as ``SessionResumeError``: ``claude --resume`` gives no
89
+ distinguishable exit code for a missing transcript, so a bare failure on
90
+ stderr is enough to trigger it. ``session_id`` is folded into the message
91
+ for realism only; the id ``SessionResumeError`` actually reports comes
92
+ from the resumed turn's own argv, not from anything scripted here.
93
+
94
+ Args:
95
+ session_id: Conversation id to name in the scripted stderr message.
96
+
97
+ Returns:
98
+ ``(stdout, stderr, returncode)`` for a ``FakeRun``.
99
+ """
100
+ detail = f" {session_id}" if session_id else ""
101
+ stderr = [f"Error: no conversation found to resume{detail}\n"]
102
+ return [], stderr, 1
103
+
104
+
84
105
  def _usage_payload(usage: TokenUsage | None) -> dict[str, int]:
85
106
  if usage is None:
86
107
  return {
@@ -3,12 +3,14 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from .parser import CodexStreamParser
6
- from .provider import PROFILE, CodexProvider
7
- from .scripted import scripted_lines
6
+ from .provider import PROFILE, CodexProvider, parse_mcp_servers
7
+ from .scripted import resume_failure_lines, scripted_lines
8
8
 
9
9
  __all__ = [
10
10
  "PROFILE",
11
11
  "CodexProvider",
12
12
  "CodexStreamParser",
13
+ "parse_mcp_servers",
14
+ "resume_failure_lines",
13
15
  "scripted_lines",
14
16
  ]
@@ -2,7 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import TYPE_CHECKING
5
+ import re
6
+ from typing import TYPE_CHECKING, Any
6
7
 
7
8
  from agentshim.core.errors import ProviderCapabilityError, SessionResumeError
8
9
  from agentshim.core.mcp import FlagsInstallation, HttpMcpServer, NoopInstallation
@@ -71,6 +72,13 @@ PROFILE = ProviderProfile(
71
72
  auth_env_vars=("OPENAI_API_KEY", "OPENAI_BASE_URL"),
72
73
  skill_dirs=(".agents/skills",),
73
74
  container_install=(_NODE_INSTALL, _CODEX_INSTALL),
75
+ # Documented Codex CLI variable that relocates ~/.codex (``codex --help``:
76
+ # "Layer $CODEX_HOME/<name>.config.toml on top of the base user config";
77
+ # "auth still uses CODEX_HOME").
78
+ state_root_env="CODEX_HOME",
79
+ auth_files=(".codex/auth.json", ".codex/config.toml"),
80
+ # CLI_FLAGS: Codex takes MCP servers as --config overrides, never a file.
81
+ mcp_config_file=None,
74
82
  )
75
83
 
76
84
 
@@ -199,3 +207,93 @@ def _toml_str(value: str) -> str:
199
207
  def _toml_array(values: Sequence[str]) -> str:
200
208
  """Render a sequence of strings as a TOML inline array."""
201
209
  return "[" + ",".join(_toml_str(value) for value in values) + "]"
210
+
211
+
212
+ _CONFIG_OVERRIDE_RE = re.compile(r"^mcp_servers\.([^.=]+)\.(.+)$")
213
+ _TOML_STRING_RE = re.compile(r'"((?:[^"\\]|\\.)*)"')
214
+
215
+
216
+ def parse_mcp_servers(argv: Sequence[str]) -> dict[str, dict[str, Any]]:
217
+ """Recover the MCP servers rendered into *argv* by ``_server_flags``.
218
+
219
+ The inverse of that renderer, kept next to it so the two cannot drift:
220
+ walks the ``--config mcp_servers.<key>.<field>=<value>`` overrides this
221
+ provider emits and rebuilds one canonical entry per server. A stdio
222
+ server comes back as ``command``, ``args`` and ``env``; an HTTP one as
223
+ ``url`` and ``transport`` (always ``"http"``, since a ``--config``
224
+ override carries only the address and this provider cannot express any
225
+ other transport).
226
+
227
+ Args:
228
+ argv: The argv a turn actually ran, as recorded on a ``CommandRequest``.
229
+
230
+ Returns:
231
+ One canonical entry per server, keyed by the dotted-path name
232
+ ``_server_flags`` gave it (``-`` already mangled to ``_``).
233
+ """
234
+ servers: dict[str, dict[str, Any]] = {}
235
+ for key, field, raw_value in _mcp_config_overrides(argv):
236
+ _apply_mcp_override(servers.setdefault(key, {}), field, raw_value)
237
+ for entry in servers.values():
238
+ if "url" not in entry:
239
+ entry.setdefault("args", [])
240
+ entry.setdefault("env", {})
241
+ return servers
242
+
243
+
244
+ def _mcp_config_overrides(argv: Sequence[str]) -> list[tuple[str, str, str]]:
245
+ """Pull every ``mcp_servers.<key>.<field>=<value>`` override out of *argv*."""
246
+ args = list(argv)
247
+ overrides: list[tuple[str, str, str]] = []
248
+ for index, arg in enumerate(args):
249
+ if arg != "--config" or index + 1 >= len(args):
250
+ continue
251
+ path, sep, raw_value = args[index + 1].partition("=")
252
+ if not sep:
253
+ continue
254
+ match = _CONFIG_OVERRIDE_RE.match(path)
255
+ if match is not None:
256
+ key, field = match.groups()
257
+ overrides.append((key, field, raw_value))
258
+ return overrides
259
+
260
+
261
+ def _apply_mcp_override(entry: dict[str, Any], field: str, raw_value: str) -> None:
262
+ """Fold one dotted-path override into the entry being rebuilt for it."""
263
+ if field == "command":
264
+ entry["command"] = _parse_toml_str(raw_value)
265
+ elif field == "args":
266
+ entry["args"] = _parse_toml_array(raw_value)
267
+ elif field == "url":
268
+ entry["url"] = _parse_toml_str(raw_value)
269
+ entry["transport"] = "http"
270
+ elif field.startswith("env."):
271
+ entry.setdefault("env", {})[field.removeprefix("env.")] = _parse_toml_str(raw_value)
272
+
273
+
274
+ def _parse_toml_str(literal: str) -> str:
275
+ """Reverse ``_toml_str``: unescape one TOML basic string literal."""
276
+ match = _TOML_STRING_RE.fullmatch(literal)
277
+ if match is None:
278
+ return literal
279
+ return _unescape_toml(match.group(1))
280
+
281
+
282
+ def _parse_toml_array(literal: str) -> list[str]:
283
+ """Reverse ``_toml_array``: unescape every string in a TOML inline array."""
284
+ return [_unescape_toml(inner) for inner in _TOML_STRING_RE.findall(literal)]
285
+
286
+
287
+ def _unescape_toml(escaped: str) -> str:
288
+ """Undo the backslash-escaping ``_toml_str`` applies to a string body."""
289
+ result: list[str] = []
290
+ index = 0
291
+ while index < len(escaped):
292
+ char = escaped[index]
293
+ if char == "\\" and index + 1 < len(escaped):
294
+ result.append(escaped[index + 1])
295
+ index += 2
296
+ else:
297
+ result.append(char)
298
+ index += 1
299
+ return "".join(result)
@@ -85,6 +85,25 @@ def scripted_lines(
85
85
  return lines
86
86
 
87
87
 
88
+ def resume_failure_lines(*, session_id: str | None = None) -> tuple[list[str], list[str], int]:
89
+ """Build the stdout, stderr and exit code of a resumed turn with no rollout.
90
+
91
+ ``CodexProvider.classify_exit`` is the one provider that names its
92
+ failure explicitly: it matches "thread/resume failed" and "no rollout
93
+ found" on stderr, so unlike the other providers a plain nonzero exit is
94
+ not enough here; the message has to say so.
95
+
96
+ Args:
97
+ session_id: Thread id to name in the scripted stderr message.
98
+
99
+ Returns:
100
+ ``(stdout, stderr, returncode)`` for a ``FakeRun``.
101
+ """
102
+ thread = session_id or "unknown-thread"
103
+ stderr = [f"thread/resume failed: no rollout found for thread {thread}\n"]
104
+ return [], stderr, 1
105
+
106
+
88
107
  def _usage_payload(usage: TokenUsage | None) -> dict[str, int]:
89
108
  if usage is None:
90
109
  return {"input_tokens": 0, "cached_input_tokens": 0, "output_tokens": 0}
@@ -3,13 +3,15 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from .parser import CopilotStreamParser
6
- from .provider import PROFILE, CopilotProvider, mcp_entry
7
- from .scripted import scripted_lines
6
+ from .provider import PROFILE, CopilotProvider, mcp_entry, parse_mcp_servers
7
+ from .scripted import resume_failure_lines, scripted_lines
8
8
 
9
9
  __all__ = [
10
10
  "PROFILE",
11
11
  "CopilotProvider",
12
12
  "CopilotStreamParser",
13
13
  "mcp_entry",
14
+ "parse_mcp_servers",
15
+ "resume_failure_lines",
14
16
  "scripted_lines",
15
17
  ]
@@ -11,7 +11,7 @@ from agentshim.core.profile import McpMechanism, OutputSchemaStyle, ProviderProf
11
11
  from .parser import CopilotStreamParser
12
12
 
13
13
  if TYPE_CHECKING:
14
- from collections.abc import Callable, Sequence
14
+ from collections.abc import Callable, Mapping, Sequence
15
15
  from pathlib import Path
16
16
 
17
17
  from agentshim.core.errors import AgentShimError, CliExitError
@@ -47,6 +47,19 @@ PROFILE = ProviderProfile(
47
47
  skill_dirs=(".github/skills",),
48
48
  # The npm package ships the CLI; the image supplies node and npm.
49
49
  container_install=("npm install -g @github/copilot",),
50
+ # ``COPILOT_HOME`` relocates ~/.copilot; documented above and by
51
+ # ``copilot help environment``.
52
+ state_root_env="COPILOT_HOME",
53
+ # Copilot documents no specific credential filenames. ``.copilot/`` on a
54
+ # logged-in install holds ``config.json`` (opaque, not plain JSON; most
55
+ # likely where the login state lives) and ``settings.json`` (user
56
+ # config, e.g. the chosen model); ``.config/github-copilot/`` on the same
57
+ # install held only ``versions.json``, which is not credential-bearing,
58
+ # so it is left out.
59
+ auth_files=(".copilot/config.json", ".copilot/settings.json"),
60
+ # CLI_FLAGS: Copilot takes MCP servers as one --additional-mcp-config
61
+ # flag, never a file.
62
+ mcp_config_file=None,
50
63
  )
51
64
 
52
65
 
@@ -132,3 +145,37 @@ def mcp_entry(server: McpServer) -> dict[str, Any]:
132
145
  if stdio.env:
133
146
  rendered["env"] = dict(stdio.env)
134
147
  return rendered
148
+
149
+
150
+ def parse_mcp_servers(argv: Sequence[str]) -> dict[str, dict[str, Any]]:
151
+ """Recover the MCP servers rendered into *argv* by ``install_mcp``.
152
+
153
+ The inverse of ``mcp_entry``, kept next to it so the two cannot drift:
154
+ reads the ``--additional-mcp-config`` flag's JSON payload back and maps
155
+ each entry to the canonical shape ``installed_mcp_servers`` returns.
156
+
157
+ Args:
158
+ argv: The argv a turn actually ran, as recorded on a ``CommandRequest``.
159
+
160
+ Returns:
161
+ One canonical entry per server, keyed by server name.
162
+ """
163
+ args = list(argv)
164
+ if MCP_CONFIG_FLAG not in args:
165
+ return {}
166
+ index = args.index(MCP_CONFIG_FLAG)
167
+ if index + 1 >= len(args):
168
+ return {}
169
+ config = json.loads(args[index + 1])
170
+ servers = config.get(MCP_SERVER_KEY, {})
171
+ return {name: _canonical_entry(raw) for name, raw in servers.items()}
172
+
173
+
174
+ def _canonical_entry(raw: Mapping[str, Any]) -> dict[str, Any]:
175
+ if "url" in raw:
176
+ return {"url": raw["url"], "transport": raw.get("type", "http")}
177
+ return {
178
+ "command": raw.get("command", ""),
179
+ "args": list(raw.get("args", ())),
180
+ "env": dict(raw.get("env", {})),
181
+ }
@@ -104,6 +104,25 @@ def scripted_lines(
104
104
  return lines
105
105
 
106
106
 
107
+ def resume_failure_lines(*, session_id: str | None = None) -> tuple[list[str], list[str], int]:
108
+ """Build the stdout, stderr and exit code of a resumed turn Copilot cannot continue.
109
+
110
+ ``CopilotProvider.classify_exit`` returns every exit unchanged: Copilot
111
+ gives no signal that distinguishes a lost session from any other
112
+ failure, so this always surfaces as a plain ``CliExitError``, never
113
+ ``SessionResumeError``, whether the turn was resumed or not.
114
+
115
+ Args:
116
+ session_id: Conversation id to name in the scripted stderr message.
117
+
118
+ Returns:
119
+ ``(stdout, stderr, returncode)`` for a ``FakeRun``.
120
+ """
121
+ detail = f" {session_id}" if session_id else ""
122
+ stderr = [f"Error: could not resume session{detail}\n"]
123
+ return [], stderr, 1
124
+
125
+
107
126
  def _usage_payload(usage: TokenUsage) -> dict[str, Any]:
108
127
  """Undo the parser's folding to print the disjoint counts Copilot reports."""
109
128
  cached = usage.cached_input_tokens
@@ -4,12 +4,13 @@ from __future__ import annotations
4
4
 
5
5
  from .parser import GeminiStreamParser
6
6
  from .provider import PROFILE, GeminiProvider, mcp_entry
7
- from .scripted import scripted_lines
7
+ from .scripted import resume_failure_lines, scripted_lines
8
8
 
9
9
  __all__ = [
10
10
  "PROFILE",
11
11
  "GeminiProvider",
12
12
  "GeminiStreamParser",
13
13
  "mcp_entry",
14
+ "resume_failure_lines",
14
15
  "scripted_lines",
15
16
  ]
@@ -38,7 +38,7 @@ if TYPE_CHECKING:
38
38
  from agentshim.core.mcp import McpServer
39
39
  from agentshim.core.provider import ArgvContext, McpInstallation
40
40
 
41
- MCP_CONFIG_PATH = (".gemini", "settings.json")
41
+ MCP_CONFIG_FILENAME = ".gemini/settings.json"
42
42
  MCP_SERVER_KEY = "mcpServers"
43
43
 
44
44
  _NO_WORKSPACE = "gemini installs MCP servers into <cwd>/.gemini/settings.json; the turn needs a cwd"
@@ -71,6 +71,16 @@ PROFILE = ProviderProfile(
71
71
  "rm -f node.tgz; }",
72
72
  "npm install -g @google/gemini-cli",
73
73
  ),
74
+ # ``gemini --help`` documents no variable that relocates ~/.gemini, so
75
+ # this stays unset rather than guessed.
76
+ state_root_env=None,
77
+ auth_files=(
78
+ ".gemini/oauth_creds.json",
79
+ ".gemini/google_accounts.json",
80
+ ".gemini/settings.json",
81
+ ".gemini/.env",
82
+ ),
83
+ mcp_config_file=MCP_CONFIG_FILENAME,
74
84
  )
75
85
 
76
86
 
@@ -106,7 +116,7 @@ class GeminiProvider:
106
116
  if workspace is None:
107
117
  raise ProviderCapabilityError(_NO_WORKSPACE)
108
118
  return install_config_file(
109
- workspace.joinpath(*MCP_CONFIG_PATH),
119
+ workspace / MCP_CONFIG_FILENAME,
110
120
  server_key=MCP_SERVER_KEY,
111
121
  servers={server.name: mcp_entry(server) for server in servers},
112
122
  )
@@ -90,6 +90,27 @@ def scripted_lines(
90
90
  return lines
91
91
 
92
92
 
93
+ def resume_failure_lines(*, session_id: str | None = None) -> tuple[list[str], list[str], int]:
94
+ """Build the stdout, stderr and exit code of a resumed turn Gemini cannot continue.
95
+
96
+ ``GeminiProvider.classify_exit`` treats any nonzero exit of a resumed
97
+ turn as ``SessionResumeError``: the CLI reports a refused resume the same
98
+ way it reports any other startup failure. ``session_id`` is folded into
99
+ the message for realism only; the id ``SessionResumeError`` actually
100
+ reports comes from the resumed turn's own argv, not from anything
101
+ scripted here.
102
+
103
+ Args:
104
+ session_id: Conversation id to name in the scripted stderr message.
105
+
106
+ Returns:
107
+ ``(stdout, stderr, returncode)`` for a ``FakeRun``.
108
+ """
109
+ detail = f" {session_id}" if session_id else ""
110
+ stderr = [f"Error: failed to load session{detail}\n"]
111
+ return [], stderr, 1
112
+
113
+
93
114
  def _stats(usage: TokenUsage | None, tool_calls: int) -> dict[str, int]:
94
115
  """Render token counts the way ``convertToStreamStats`` does.
95
116
 
@@ -4,12 +4,13 @@ from __future__ import annotations
4
4
 
5
5
  from .parser import OpencodeStreamParser
6
6
  from .provider import PROFILE, OpencodeProvider, mcp_entry
7
- from .scripted import scripted_lines
7
+ from .scripted import resume_failure_lines, scripted_lines
8
8
 
9
9
  __all__ = [
10
10
  "PROFILE",
11
11
  "OpencodeProvider",
12
12
  "OpencodeStreamParser",
13
13
  "mcp_entry",
14
+ "resume_failure_lines",
14
15
  "scripted_lines",
15
16
  ]