agentshim 0.8.0__tar.gz → 0.10.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 (63) hide show
  1. {agentshim-0.8.0 → agentshim-0.10.0}/CHANGELOG.md +54 -0
  2. {agentshim-0.8.0 → agentshim-0.10.0}/PKG-INFO +2 -1
  3. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/__init__.py +5 -1
  4. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/agent.py +49 -6
  5. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/core/__init__.py +4 -0
  6. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/core/profile.py +36 -0
  7. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/core/provider.py +11 -0
  8. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/claude/provider.py +34 -0
  9. agentshim-0.10.0/agentshim/providers/codex/mcp_scope.py +93 -0
  10. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/codex/provider.py +25 -0
  11. agentshim-0.10.0/agentshim/providers/codex/skills.py +86 -0
  12. {agentshim-0.8.0 → agentshim-0.10.0}/pyproject.toml +4 -2
  13. agentshim-0.8.0/agentshim/providers/codex/skills.py +0 -27
  14. {agentshim-0.8.0 → agentshim-0.10.0}/.gitignore +0 -0
  15. {agentshim-0.8.0 → agentshim-0.10.0}/README.md +0 -0
  16. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/core/_files.py +0 -0
  17. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/core/env.py +0 -0
  18. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/core/errors.py +0 -0
  19. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/core/events.py +0 -0
  20. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/core/mcp.py +0 -0
  21. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/core/pricing.py +0 -0
  22. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/core/schema.py +0 -0
  23. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/core/skills.py +0 -0
  24. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/core/stream.py +0 -0
  25. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/core/turn.py +0 -0
  26. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/core/usage.py +0 -0
  27. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/execution/__init__.py +0 -0
  28. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/execution/executor.py +0 -0
  29. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/execution/host.py +0 -0
  30. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/execution/transform.py +0 -0
  31. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/__init__.py +0 -0
  32. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/claude/__init__.py +0 -0
  33. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/claude/events.py +0 -0
  34. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/claude/hooks/__init__.py +0 -0
  35. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
  36. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/claude/parser.py +0 -0
  37. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/claude/sandbox.py +0 -0
  38. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/claude/scripted.py +0 -0
  39. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/claude/user_hooks.py +0 -0
  40. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/codex/__init__.py +0 -0
  41. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/codex/_toml.py +0 -0
  42. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/codex/events.py +0 -0
  43. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/codex/parser.py +0 -0
  44. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/codex/rules.py +0 -0
  45. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/codex/sandbox.py +0 -0
  46. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/codex/scripted.py +0 -0
  47. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/copilot/__init__.py +0 -0
  48. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/copilot/events.py +0 -0
  49. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/copilot/parser.py +0 -0
  50. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/copilot/provider.py +0 -0
  51. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/copilot/scripted.py +0 -0
  52. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/gemini/__init__.py +0 -0
  53. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/gemini/events.py +0 -0
  54. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/gemini/parser.py +0 -0
  55. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/gemini/provider.py +0 -0
  56. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/gemini/scripted.py +0 -0
  57. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/opencode/__init__.py +0 -0
  58. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/opencode/events.py +0 -0
  59. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/opencode/parser.py +0 -0
  60. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/opencode/provider.py +0 -0
  61. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/providers/opencode/scripted.py +0 -0
  62. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/py.typed +0 -0
  63. {agentshim-0.8.0 → agentshim-0.10.0}/agentshim/testing/__init__.py +0 -0
@@ -1,5 +1,59 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0 (2026-10-03)
4
+
5
+ MCP isolation. Additive: the default scope keeps today's behaviour. Adds a
6
+ runtime dependency on `tomli` for Python 3.10 only (Codex config parsing).
7
+
8
+ ### Added
9
+
10
+ - `McpScope` (`ALL`, `SESSION`) and `start_session(mcp_scope=...)` /
11
+ `run(mcp_scope=...)`. `SESSION` connects the CLI only to the servers in
12
+ `TurnRequest.mcp_servers`; a turn given none sees no MCP server. It is a
13
+ separate option from `SkillScope` because a provider may enforce one and not
14
+ the other; each is refused independently.
15
+ - `ProviderProfile.mcp_scopes` declares the scopes a provider enforces;
16
+ asking for another raises `ProviderCapabilityError` from `start_session`.
17
+ - `ArgvContext.mcp_scope` and `ArgvContext.mcp_servers` carry the scope and
18
+ the turn's servers to `build_argv`.
19
+ - Claude Code: `SESSION` passes `--strict-mcp-config` plus the turn's servers
20
+ inline as `--mcp-config`. This drops user and project (`.mcp.json`) servers,
21
+ plugin servers and claude.ai account connectors.
22
+ - Codex: `SESSION` sets `enabled = false` on every `mcp_servers` entry found
23
+ in `$CODEX_HOME/config.toml`, `/etc/codex/config.toml` and
24
+ `<cwd>/.codex/config.toml` that the turn was not given, and sets
25
+ `features.plugins=false` and `features.apps=false` (plugin servers and the
26
+ account's ChatGPT apps).
27
+ - Gemini, opencode and Copilot support `ALL` only.
28
+ - Live e2e: a server seeded into the CLI's default configuration is reachable
29
+ under `ALL` and not under `SESSION` (`tests/e2e/test_mcp_scope_e2e.py`).
30
+
31
+ ### Changed
32
+
33
+ - `parse_mcp_servers` ignores `enabled=false` overrides: they disable a
34
+ configured server and do not define one.
35
+
36
+ ## 0.9.0 (2026-10-03)
37
+
38
+ Skill isolation. Additive: the default scope keeps today's behaviour.
39
+
40
+ ### Added
41
+
42
+ - `SkillScope` (`ALL`, `PROJECT`) and `start_session(skill_scope=...)` /
43
+ `run(skill_scope=...)`. `PROJECT` offers only the workspace's skills and
44
+ the CLI's built-in ones, not the user's personal skills or plugins.
45
+ - `ProviderProfile.skill_scopes` declares the scopes a provider enforces;
46
+ asking for another raises `ProviderCapabilityError` from `start_session`.
47
+ - `ArgvContext.skill_scope` carries the session's scope to `build_argv`.
48
+ - Claude Code: `PROJECT` passes `--setting-sources project,local`, which also
49
+ skips user settings and `~/.claude/CLAUDE.md`; credentials still work.
50
+ - Codex: `PROJECT` sets `features.plugins=false` and disables every user
51
+ `SKILL.md` (`$CODEX_HOME/skills` except `.system`, `~/.agents/skills`)
52
+ through `skills.config`.
53
+ - Gemini, opencode and Copilot support `ALL` only.
54
+ - Live e2e: `SkillScope.PROJECT` hides a skill seeded in a relocated user
55
+ home while the workspace skill stays offered (`tests/e2e/test_skills_e2e.py`).
56
+
3
57
  ## 0.8.0 (2026-10-03)
4
58
 
5
59
  Skill observability. Additive: a caller that ignores the new events and the
@@ -1,8 +1,9 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentshim
3
- Version: 0.8.0
3
+ Version: 0.10.0
4
4
  Summary: Provider-agnostic coding-agent CLI shims
5
5
  Requires-Python: >=3.10
6
+ Requires-Dist: tomli>=2; python_full_version < '3.11'
6
7
  Provides-Extra: test
7
8
  Requires-Dist: pytest>=8.0.0; extra == 'test'
8
9
  Description-Content-Type: text/markdown
@@ -27,6 +27,7 @@ from .core import (
27
27
  McpConfigError,
28
28
  McpInstallation,
29
29
  McpMechanism,
30
+ McpScope,
30
31
  McpServer,
31
32
  McpTransport,
32
33
  ModelPricing,
@@ -50,6 +51,7 @@ from .core import (
50
51
  SessionResumeError,
51
52
  SessionStarted,
52
53
  SkillInvoked,
54
+ SkillScope,
53
55
  SkillsDiscovered,
54
56
  SkillSignal,
55
57
  SkillSummary,
@@ -96,7 +98,7 @@ from .providers.copilot import CopilotProvider
96
98
  from .providers.gemini import GeminiProvider
97
99
  from .providers.opencode import OpencodeProvider
98
100
 
99
- __version__ = "0.8.0"
101
+ __version__ = "0.10.0"
100
102
 
101
103
  __all__ = [
102
104
  "AgentEvent",
@@ -133,6 +135,7 @@ __all__ = [
133
135
  "McpConfigError",
134
136
  "McpInstallation",
135
137
  "McpMechanism",
138
+ "McpScope",
136
139
  "McpServer",
137
140
  "McpTransport",
138
141
  "ModelPricing",
@@ -159,6 +162,7 @@ __all__ = [
159
162
  "SessionResumeError",
160
163
  "SessionStarted",
161
164
  "SkillInvoked",
165
+ "SkillScope",
162
166
  "SkillSignal",
163
167
  "SkillSummary",
164
168
  "SkillTracker",
@@ -23,7 +23,13 @@ from agentshim.core.errors import (
23
23
  SessionResumeError,
24
24
  )
25
25
  from agentshim.core.events import RunFinished, RunStarted, compose_event_handlers
26
- from agentshim.core.profile import McpMechanism, OutputSchemaStyle, SchemaDialect
26
+ from agentshim.core.profile import (
27
+ McpMechanism,
28
+ McpScope,
29
+ OutputSchemaStyle,
30
+ SchemaDialect,
31
+ SkillScope,
32
+ )
27
33
  from agentshim.core.provider import ArgvContext
28
34
  from agentshim.core.schema import compact_json, dialect_problems, materialize
29
35
  from agentshim.core.skills import SkillTracker
@@ -114,9 +120,22 @@ class CliAgent:
114
120
  cwd: str | None = None,
115
121
  timeout: float | None = None,
116
122
  session_id: str | None = None,
123
+ skill_scope: SkillScope = SkillScope.ALL,
124
+ mcp_scope: McpScope = McpScope.ALL,
117
125
  ) -> AgentSession:
118
- """Open a conversation whose turns resume one another."""
119
- return AgentSession(self, cwd=cwd, timeout=timeout, session_id=session_id)
126
+ """Open a conversation whose turns resume one another.
127
+
128
+ ``skill_scope`` and ``mcp_scope`` are fixed for the conversation (see
129
+ ``AgentSession``).
130
+ """
131
+ return AgentSession(
132
+ self,
133
+ cwd=cwd,
134
+ timeout=timeout,
135
+ session_id=session_id,
136
+ skill_scope=skill_scope,
137
+ mcp_scope=mcp_scope,
138
+ )
120
139
 
121
140
  def run(
122
141
  self,
@@ -124,9 +143,14 @@ class CliAgent:
124
143
  *,
125
144
  cwd: str | None = None,
126
145
  timeout: float | None = None,
146
+ skill_scope: SkillScope = SkillScope.ALL,
147
+ mcp_scope: McpScope = McpScope.ALL,
127
148
  ) -> TurnResult:
128
149
  """Run one turn in a throwaway session."""
129
- return self.start_session(cwd=cwd, timeout=timeout).turn(request)
150
+ session = self.start_session(
151
+ cwd=cwd, timeout=timeout, skill_scope=skill_scope, mcp_scope=mcp_scope
152
+ )
153
+ return session.turn(request)
130
154
 
131
155
 
132
156
  def _discard(message: str) -> None:
@@ -136,13 +160,15 @@ def _discard(message: str) -> None:
136
160
  class AgentSession:
137
161
  """A provider conversation. One turn at a time; ``cancel`` is thread-safe."""
138
162
 
139
- def __init__(
163
+ def __init__( # noqa: PLR0913 - one keyword per independent session option
140
164
  self,
141
165
  agent: CliAgent,
142
166
  *,
143
167
  cwd: str | None = None,
144
168
  timeout: float | None = None,
145
169
  session_id: str | None = None,
170
+ skill_scope: SkillScope = SkillScope.ALL,
171
+ mcp_scope: McpScope = McpScope.ALL,
146
172
  ) -> None:
147
173
  """Bind a conversation to one agent, with per-turn defaults.
148
174
 
@@ -150,8 +176,22 @@ class AgentSession:
150
176
  successive turns resume one another. ``cwd`` and ``timeout`` are
151
177
  defaults an individual ``TurnRequest`` may override. Passing
152
178
  ``session_id`` adopts a conversation the provider already has, so the
153
- first turn resumes rather than starts fresh.
179
+ first turn resumes rather than starts fresh. ``skill_scope`` limits
180
+ which skills every turn's CLI may discover; a scope the provider's
181
+ ``profile.skill_scopes`` does not list raises
182
+ ``ProviderCapabilityError`` here, before any turn runs. ``mcp_scope``
183
+ limits which MCP servers every turn's CLI may connect to
184
+ (``McpScope.SESSION``: only those in ``TurnRequest.mcp_servers``) and
185
+ is checked against ``profile.mcp_scopes`` the same way.
154
186
  """
187
+ if skill_scope not in agent.profile.skill_scopes:
188
+ msg = f"{agent.profile.name} cannot limit skills to scope {skill_scope.value!r}"
189
+ raise ProviderCapabilityError(msg)
190
+ if mcp_scope not in agent.profile.mcp_scopes:
191
+ msg = f"{agent.profile.name} cannot limit MCP servers to scope {mcp_scope.value!r}"
192
+ raise ProviderCapabilityError(msg)
193
+ self.skill_scope = skill_scope
194
+ self.mcp_scope = mcp_scope
155
195
  self._agent = agent
156
196
  self._cwd = cwd
157
197
  self._timeout = timeout
@@ -260,6 +300,9 @@ class AgentSession:
260
300
  mcp_argv=installation.argv,
261
301
  extra_args=req.extra_args,
262
302
  cwd=cwd,
303
+ skill_scope=self.skill_scope,
304
+ mcp_scope=self.mcp_scope,
305
+ mcp_servers=tuple(req.mcp_servers),
263
306
  )
264
307
  )
265
308
  command = CommandRequest(argv=argv, stdin=req.prompt, cwd=cwd, env=env, timeout=timeout)
@@ -55,9 +55,11 @@ from .mcp import (
55
55
  from .pricing import ModelPricing, PricingTable, cost_usd, default_pricing, price_for
56
56
  from .profile import (
57
57
  McpMechanism,
58
+ McpScope,
58
59
  OutputSchemaStyle,
59
60
  ProviderProfile,
60
61
  SchemaDialect,
62
+ SkillScope,
61
63
  SkillSignal,
62
64
  )
63
65
  from .provider import ArgvContext, McpInstallation, ParsedTurn, Provider, StreamParser
@@ -87,6 +89,7 @@ __all__ = [
87
89
  "McpConfigError",
88
90
  "McpInstallation",
89
91
  "McpMechanism",
92
+ "McpScope",
90
93
  "McpServer",
91
94
  "McpTransport",
92
95
  "ModelPricing",
@@ -110,6 +113,7 @@ __all__ = [
110
113
  "SessionResumeError",
111
114
  "SessionStarted",
112
115
  "SkillInvoked",
116
+ "SkillScope",
113
117
  "SkillSignal",
114
118
  "SkillSummary",
115
119
  "SkillTracker",
@@ -61,6 +61,38 @@ class SkillSignal(Enum):
61
61
  INFERRED = "inferred"
62
62
 
63
63
 
64
+ class SkillScope(Enum):
65
+ """Which skills a session's CLI may discover.
66
+
67
+ ``ALL``: whatever the CLI finds by default, including the user's own
68
+ skills and installed plugins. ``PROJECT``: only the skills under the
69
+ session's working directory (the profile's ``skill_dirs``) plus the
70
+ CLI's built-in ones, so the user running agentshim does not change what
71
+ the agent is offered. A provider declares the scopes it can enforce in
72
+ ``ProviderProfile.skill_scopes``; asking for another one is an error,
73
+ never a silent ``ALL``.
74
+ """
75
+
76
+ ALL = "all"
77
+ PROJECT = "project"
78
+
79
+
80
+ class McpScope(Enum):
81
+ """Which MCP servers a session's CLI may connect to.
82
+
83
+ ``ALL``: whatever the CLI finds by default, including the user's and the
84
+ project's own MCP configuration, plugin servers and account-level
85
+ connectors. ``SESSION``: only the servers the session was given through
86
+ ``TurnRequest.mcp_servers``; a session given none sees no MCP server at
87
+ all. A provider declares the scopes it can enforce in
88
+ ``ProviderProfile.mcp_scopes``; asking for another one is an error, never
89
+ a silent ``ALL``.
90
+ """
91
+
92
+ ALL = "all"
93
+ SESSION = "session"
94
+
95
+
64
96
  def _no_container_env() -> Mapping[str, str]:
65
97
  """Empty read-only default: a frozen spec must not carry a mutable one.
66
98
 
@@ -109,3 +141,7 @@ class ProviderProfile:
109
141
  skill_discovery: SkillSignal = SkillSignal.NONE
110
142
  #: Whether the stream reveals skill loads (``SkillInvoked``).
111
143
  skill_invocation: SkillSignal = SkillSignal.NONE
144
+ #: The ``SkillScope`` values a session on this provider may request.
145
+ skill_scopes: frozenset[SkillScope] = frozenset({SkillScope.ALL})
146
+ #: The ``McpScope`` values a session on this provider may request.
147
+ mcp_scopes: frozenset[McpScope] = frozenset({McpScope.ALL})
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from dataclasses import dataclass, field
6
6
  from typing import TYPE_CHECKING, Any, Protocol
7
7
 
8
+ from .profile import McpScope, SkillScope
8
9
  from .usage import ProviderUsage
9
10
 
10
11
  if TYPE_CHECKING:
@@ -37,6 +38,16 @@ class ArgvContext:
37
38
  #: The directory the CLI will run in, or ``None`` for the executor's own.
38
39
  #: Argv never carries it; a provider reads it to validate paths against it.
39
40
  cwd: str | None = None
41
+ #: Which skills the CLI may discover; the session has already checked
42
+ #: that the provider's profile supports it.
43
+ skill_scope: SkillScope = SkillScope.ALL
44
+ #: Which MCP servers the CLI may connect to; the session has already
45
+ #: checked that the provider's profile supports it.
46
+ mcp_scope: McpScope = McpScope.ALL
47
+ #: The servers the turn was given (the ones ``install_mcp`` installed).
48
+ #: A provider enforcing ``McpScope.SESSION`` renders them itself when its
49
+ #: installed config file is not enough to exclude the user's own.
50
+ mcp_servers: Sequence[McpServer] = ()
40
51
 
41
52
 
42
53
  @dataclass(frozen=True)
@@ -9,9 +9,11 @@ from agentshim.core.errors import ProviderCapabilityError, SessionResumeError
9
9
  from agentshim.core.mcp import HttpMcpServer, NoopInstallation, StdioMcpServer, install_config_file
10
10
  from agentshim.core.profile import (
11
11
  McpMechanism,
12
+ McpScope,
12
13
  OutputSchemaStyle,
13
14
  ProviderProfile,
14
15
  SchemaDialect,
16
+ SkillScope,
15
17
  SkillSignal,
16
18
  )
17
19
 
@@ -31,6 +33,23 @@ if TYPE_CHECKING:
31
33
  MCP_CONFIG_FILENAME = ".mcp.json"
32
34
  MCP_SERVER_KEY = "mcpServers"
33
35
 
36
+ #: ``SkillScope.PROJECT``: load settings only from the workspace's
37
+ #: ``.claude/settings.json`` and ``.claude/settings.local.json`` (plus
38
+ #: ``--settings`` and managed policy, which always apply). Leaving out the
39
+ #: ``user`` source drops ``~/.claude/skills``, the user's plugins (with their
40
+ #: skills, hooks and MCP servers), ``~/.claude/CLAUDE.md`` and the user's
41
+ #: settings (default model, permissions, hooks, ``env``, ``apiKeyHelper``).
42
+ #: Credentials are not settings: OAuth in ``.credentials.json`` and the
43
+ #: ``auth_env_vars`` keep working. Claude Code's built-in skills and claude.ai
44
+ #: account connectors are not user settings and stay.
45
+ PROJECT_SETTING_SOURCES = ("--setting-sources", "project,local")
46
+
47
+ #: ``McpScope.SESSION``: ``--strict-mcp-config`` makes ``--mcp-config`` the
48
+ #: only source of MCP servers. It drops the user's and the project's
49
+ #: (``.mcp.json``) servers, plugin servers and claude.ai account connectors,
50
+ #: so the servers the turn was given are passed inline as ``--mcp-config``.
51
+ STRICT_MCP_FLAG = "--strict-mcp-config"
52
+
34
53
  PROFILE = ProviderProfile(
35
54
  name="claude",
36
55
  display_name="Claude Code",
@@ -54,6 +73,8 @@ PROFILE = ProviderProfile(
54
73
  # ``system/init`` lists ``skills``; the ``Skill`` tool call names one.
55
74
  skill_discovery=SkillSignal.STRUCTURED,
56
75
  skill_invocation=SkillSignal.STRUCTURED,
76
+ skill_scopes=frozenset({SkillScope.ALL, SkillScope.PROJECT}),
77
+ mcp_scopes=frozenset({McpScope.ALL, McpScope.SESSION}),
57
78
  container_install=(
58
79
  "apt-get update && apt-get install -y --no-install-recommends curl ca-certificates",
59
80
  "curl -fsSL https://claude.ai/install.sh | bash",
@@ -117,6 +138,8 @@ class ClaudeProvider:
117
138
  "stream-json",
118
139
  "--verbose",
119
140
  ]
141
+ if ctx.skill_scope is SkillScope.PROJECT:
142
+ argv += PROJECT_SETTING_SOURCES
120
143
  if ctx.model:
121
144
  argv += ["--model", ctx.model]
122
145
  if ctx.reasoning_effort:
@@ -126,6 +149,8 @@ class ClaudeProvider:
126
149
  settings = build_settings(self.sandbox, hooks=self.hooks)
127
150
  if settings:
128
151
  argv += ["--settings", json.dumps(settings)]
152
+ if ctx.mcp_scope is McpScope.SESSION:
153
+ argv += _session_mcp_argv(ctx.mcp_servers)
129
154
  argv += list(ctx.mcp_argv)
130
155
  argv += list(ctx.extra_args)
131
156
  return argv
@@ -178,6 +203,15 @@ def _resumed_session_id(argv: Sequence[str]) -> str | None:
178
203
  return args[index + 1]
179
204
 
180
205
 
206
+ def _session_mcp_argv(servers: Sequence[McpServer]) -> list[str]:
207
+ """Flags that leave Claude Code exactly *servers* and nothing else."""
208
+ argv = [STRICT_MCP_FLAG]
209
+ if servers:
210
+ config = {MCP_SERVER_KEY: {server.name: mcp_entry(server) for server in servers}}
211
+ argv += ["--mcp-config", json.dumps(config)]
212
+ return argv
213
+
214
+
181
215
  def mcp_entry(server: McpServer) -> dict[str, Any]:
182
216
  """Render one server the way ``.mcp.json`` describes it."""
183
217
  if server.startup_timeout_s is not None:
@@ -0,0 +1,93 @@
1
+ """Codex MCP servers: hide every one the session was not given.
2
+
3
+ Codex merges MCP servers from several layers: ``$CODEX_HOME/config.toml``,
4
+ system config, a trusted workspace's ``.codex/config.toml``, installed
5
+ plugins, and the account's ChatGPT apps (the ``codex_apps`` server). A
6
+ ``--config mcp_servers={}`` override does not replace the merged table, so
7
+ each configured server is switched off by name (``enabled = false``), and
8
+ plugins and apps are switched off as features.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import sys
15
+ from typing import TYPE_CHECKING, Any, cast
16
+
17
+ from ._toml import toml_str
18
+
19
+ if sys.version_info >= (3, 11):
20
+ import tomllib
21
+ else: # pragma: no cover - exercised on the 3.10 CI leg
22
+ import tomli as tomllib # pyright: ignore[reportMissingImports]
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Collection, Iterable, Mapping
26
+
27
+ _SYSTEM_CONFIG = "/etc/codex/config.toml"
28
+ _BARE_KEY = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-")
29
+
30
+ #: Features that bring in MCP servers on their own: plugins (each may bundle
31
+ #: servers) and apps (the account's ChatGPT connectors).
32
+ _MCP_FEATURES = ("plugins", "apps")
33
+
34
+
35
+ def configured_server_keys(env: Mapping[str, str], cwd: str | None) -> list[str]:
36
+ """Every ``mcp_servers`` key the config files Codex would read define.
37
+
38
+ Reads the user's ``$CODEX_HOME/config.toml`` (default ``~/.codex``), the
39
+ system config, and ``<cwd>/.codex/config.toml``. A file that is missing or
40
+ not valid TOML contributes nothing (Codex reports a broken config itself).
41
+ The scan runs where agentshim runs, so a CLI in a container is only
42
+ covered when the container shares these files.
43
+ """
44
+ home = env.get("HOME") or os.path.expanduser("~") # noqa: PTH111 - a str contract, not a Path
45
+ codex_home = env.get("CODEX_HOME") or os.path.join(home, ".codex") # noqa: PTH118
46
+ paths = [os.path.join(codex_home, "config.toml"), _SYSTEM_CONFIG] # noqa: PTH118
47
+ if cwd is not None:
48
+ paths.append(os.path.join(cwd, ".codex", "config.toml")) # noqa: PTH118
49
+ keys: list[str] = []
50
+ for path in paths:
51
+ keys += _server_keys(path)
52
+ return list(dict.fromkeys(keys))
53
+
54
+
55
+ def _server_keys(path: str) -> Iterable[str]:
56
+ config = _read_toml(path)
57
+ servers: object = config.get("mcp_servers")
58
+ if not isinstance(servers, dict):
59
+ return []
60
+ return [str(key) for key in cast("dict[object, object]", servers)]
61
+
62
+
63
+ def _read_toml(path: str) -> dict[str, Any]:
64
+ try:
65
+ with open(path, "rb") as handle: # noqa: PTH123 - a str contract, not a Path
66
+ # pyright analyzes the 3.10 leg, where the optional ``tomli`` has no
67
+ # stubs in the 3.12 environment: the types are known at runtime.
68
+ return tomllib.load(handle) # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
69
+ except (OSError, tomllib.TOMLDecodeError): # pyright: ignore[reportUnknownMemberType]
70
+ return {}
71
+
72
+
73
+ def _table_key(key: str) -> str:
74
+ return key if key and set(key) <= _BARE_KEY else toml_str(key)
75
+
76
+
77
+ def session_scope_overrides(
78
+ env: Mapping[str, str], cwd: str | None, given_keys: Collection[str]
79
+ ) -> list[str]:
80
+ """``--config`` flags that leave Codex only the servers in *given_keys*.
81
+
82
+ *given_keys* are the ``mcp_servers`` keys the turn's own overrides define;
83
+ they are not disabled. A given server that shares a key with a configured
84
+ one is merged field by field with it by Codex, so give session servers
85
+ names the user's config does not use.
86
+ """
87
+ flags: list[str] = []
88
+ for key in configured_server_keys(env, cwd):
89
+ if key not in given_keys:
90
+ flags += ["--config", f"mcp_servers.{_table_key(key)}.enabled=false"]
91
+ for feature in _MCP_FEATURES:
92
+ flags += ["--config", f"features.{feature}=false"]
93
+ return flags
@@ -10,16 +10,20 @@ from agentshim.core.errors import ProviderCapabilityError, SessionResumeError
10
10
  from agentshim.core.mcp import FlagsInstallation, HttpMcpServer, NoopInstallation
11
11
  from agentshim.core.profile import (
12
12
  McpMechanism,
13
+ McpScope,
13
14
  OutputSchemaStyle,
14
15
  ProviderProfile,
15
16
  SchemaDialect,
17
+ SkillScope,
16
18
  SkillSignal,
17
19
  )
18
20
 
19
21
  from ._toml import toml_array, toml_str, unescape_toml
22
+ from .mcp_scope import session_scope_overrides
20
23
  from .parser import CodexStreamParser
21
24
  from .rules import RULES_FILENAME
22
25
  from .sandbox import CodexSandboxConfig, resolve_sandbox, sandbox_overrides
26
+ from .skills import project_scope_overrides
23
27
 
24
28
  if TYPE_CHECKING:
25
29
  from collections.abc import Callable, Mapping, Sequence
@@ -79,6 +83,8 @@ PROFILE = ProviderProfile(
79
83
  # ``exec --json`` lists no skills; a load is inferred from a shell read
80
84
  # of a ``SKILL.md`` (``skills.py``).
81
85
  skill_invocation=SkillSignal.INFERRED,
86
+ skill_scopes=frozenset({SkillScope.ALL, SkillScope.PROJECT}),
87
+ mcp_scopes=frozenset({McpScope.ALL, McpScope.SESSION}),
82
88
  container_install=(_NODE_INSTALL, _CODEX_INSTALL),
83
89
  # Documented Codex CLI variable that relocates ~/.codex (``codex --help``:
84
90
  # "Layer $CODEX_HOME/<name>.config.toml on top of the base user config";
@@ -130,6 +136,7 @@ class CodexProvider:
130
136
  if ctx.model:
131
137
  argv += ["--model", ctx.model]
132
138
  argv += _shell_path_config(ctx.env)
139
+ argv += _scope_overrides(ctx)
133
140
  if ctx.reasoning_effort:
134
141
  argv += ["--config", f"model_reasoning_effort={toml_str(ctx.reasoning_effort)}"]
135
142
  argv += list(ctx.mcp_argv)
@@ -265,6 +272,21 @@ def _shell_path_config(env: Mapping[str, str]) -> list[str]:
265
272
  return ["--config", f"shell_environment_policy.set.PATH={toml_str(path)}"]
266
273
 
267
274
 
275
+ def _scope_overrides(ctx: ArgvContext) -> list[str]:
276
+ """The ``--config`` flags the session's skill and MCP scopes call for.
277
+
278
+ Both scopes switch ``features.plugins`` off; the flag is emitted once.
279
+ """
280
+ flags: list[str] = []
281
+ if ctx.skill_scope is SkillScope.PROJECT:
282
+ flags += project_scope_overrides(ctx.env)
283
+ if ctx.mcp_scope is McpScope.SESSION:
284
+ given = {server.name.replace("-", "_") for server in ctx.mcp_servers}
285
+ flags += session_scope_overrides(ctx.env, ctx.cwd, given)
286
+ pairs = [flags[i : i + 2] for i in range(0, len(flags), 2)]
287
+ return [item for pair in dict.fromkeys(map(tuple, pairs)) for item in pair]
288
+
289
+
268
290
  def _server_flags(server: McpServer) -> list[str]:
269
291
  """Render one MCP server as dotted-path TOML overrides.
270
292
 
@@ -333,6 +355,9 @@ def parse_mcp_servers(argv: Sequence[str]) -> dict[str, dict[str, Any]]:
333
355
  servers: dict[str, dict[str, Any]] = {}
334
356
  for key, field, raw_value in _mcp_config_overrides(argv):
335
357
  _apply_mcp_override(servers.setdefault(key, {}), field, raw_value)
358
+ # ``McpScope.SESSION`` also emits ``enabled=false`` overrides for servers
359
+ # the user configured; those define no server, so they are not reported.
360
+ servers = {key: entry for key, entry in servers.items() if "command" in entry or "url" in entry}
336
361
  for entry in servers.values():
337
362
  if "url" not in entry:
338
363
  entry.setdefault("args", [])
@@ -0,0 +1,86 @@
1
+ """Codex skills: recognize a load in a shell command, and hide the user's.
2
+
3
+ Codex has no skill event in ``codex exec --json``: it injects a list of
4
+ skills into the prompt and the model loads one by reading its ``SKILL.md``
5
+ with a shell command (``sed -n '1,240p' .../.agents/skills/<name>/SKILL.md``).
6
+ That read is the only trace of the load in the stream, so it is inferred
7
+ here and nowhere else.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ from typing import TYPE_CHECKING
15
+
16
+ from agentshim.core.events import SkillInvoked
17
+
18
+ from ._toml import toml_str
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Mapping
22
+
23
+ #: A path ending in ``skills/[<group>/...]<name>/SKILL.md``, as one shell word.
24
+ _SKILL_FILE = re.compile(
25
+ r"(?<![^\s'\"=])(?P<path>(?:[^\s'\"]*/)?skills/(?:[^\s'\"/]+/)*?(?P<name>[^\s'\"/]+)/SKILL\.md)(?![^\s'\";|&)])"
26
+ )
27
+
28
+
29
+ def skill_reads(command: str, tool_id: str | None) -> list[SkillInvoked]:
30
+ """Return one ``SkillInvoked`` per ``SKILL.md`` the command names, in order."""
31
+ return [
32
+ SkillInvoked(name=match["name"], source_path=match["path"], tool_id=tool_id)
33
+ for match in _SKILL_FILE.finditer(command)
34
+ ]
35
+
36
+
37
+ #: Codex's own bundled skills, which it re-extracts into every home.
38
+ _SYSTEM_SKILLS = ".system"
39
+
40
+
41
+ def user_skill_files(env: Mapping[str, str]) -> list[str]:
42
+ """Every user-installed ``SKILL.md`` the CLI run with *env* would offer.
43
+
44
+ Codex reads user skills from ``$CODEX_HOME/skills`` (default
45
+ ``~/.codex/skills``) and ``~/.agents/skills``, beside the workspace's own
46
+ ``.agents/skills``. ``$CODEX_HOME/skills/.system`` holds Codex's bundled
47
+ skills and is not the user's. Paths are listed as found and, when a
48
+ symlink is involved, also resolved, since either spelling may be the one
49
+ Codex matches against. The scan runs where agentshim runs, so a CLI in a
50
+ container is only covered when the container shares this home.
51
+ """
52
+ home = env.get("HOME") or os.path.expanduser("~") # noqa: PTH111 - a str contract, not a Path
53
+ codex_home = env.get("CODEX_HOME") or os.path.join(home, ".codex") # noqa: PTH118
54
+ roots = [os.path.join(codex_home, "skills"), os.path.join(home, ".agents", "skills")] # noqa: PTH118
55
+ found: list[str] = []
56
+ for root in roots:
57
+ for directory, subdirs, files in os.walk(root, followlinks=True):
58
+ if directory == root and _SYSTEM_SKILLS in subdirs:
59
+ subdirs.remove(_SYSTEM_SKILLS)
60
+ subdirs.sort()
61
+ if "SKILL.md" in files:
62
+ path = os.path.join(directory, "SKILL.md") # noqa: PTH118
63
+ found.append(path)
64
+ resolved = os.path.realpath(path)
65
+ if resolved != path:
66
+ found.append(resolved)
67
+ return list(dict.fromkeys(found))
68
+
69
+
70
+ def project_scope_overrides(env: Mapping[str, str]) -> list[str]:
71
+ """``--config`` flags that leave Codex only the workspace's skills.
72
+
73
+ Plugins are switched off as a feature (their skills, MCP servers and
74
+ apps go with them), and each user skill is disabled by path through
75
+ ``skills.config``. The override replaces any ``skills.config`` in the
76
+ user's ``config.toml``, which is user policy this scope excludes anyway.
77
+ """
78
+ entries = ", ".join(
79
+ f"{{path = {toml_str(path)}, enabled = false}}" for path in user_skill_files(env)
80
+ )
81
+ return [
82
+ "--config",
83
+ "features.plugins=false",
84
+ "--config",
85
+ f"skills.config=[{entries}]",
86
+ ]
@@ -1,10 +1,12 @@
1
1
  [project]
2
2
  name = "agentshim"
3
- version = "0.8.0"
3
+ version = "0.10.0"
4
4
  description = "Provider-agnostic coding-agent CLI shims"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
- dependencies = []
7
+ dependencies = [
8
+ "tomli>=2 ; python_full_version < '3.11'",
9
+ ]
8
10
 
9
11
  [project.optional-dependencies]
10
12
  test = [
@@ -1,27 +0,0 @@
1
- """Recognize a Codex skill load in a shell command.
2
-
3
- Codex has no skill event in ``codex exec --json``: it injects a list of
4
- skills into the prompt and the model loads one by reading its ``SKILL.md``
5
- with a shell command (``sed -n '1,240p' .../.agents/skills/<name>/SKILL.md``).
6
- That read is the only trace of the load in the stream, so it is inferred
7
- here and nowhere else.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import re
13
-
14
- from agentshim.core.events import SkillInvoked
15
-
16
- #: A path ending in ``skills/[<group>/...]<name>/SKILL.md``, as one shell word.
17
- _SKILL_FILE = re.compile(
18
- r"(?<![^\s'\"=])(?P<path>(?:[^\s'\"]*/)?skills/(?:[^\s'\"/]+/)*?(?P<name>[^\s'\"/]+)/SKILL\.md)(?![^\s'\";|&)])"
19
- )
20
-
21
-
22
- def skill_reads(command: str, tool_id: str | None) -> list[SkillInvoked]:
23
- """Return one ``SkillInvoked`` per ``SKILL.md`` the command names, in order."""
24
- return [
25
- SkillInvoked(name=match["name"], source_path=match["path"], tool_id=tool_id)
26
- for match in _SKILL_FILE.finditer(command)
27
- ]
File without changes
File without changes
File without changes