agentshim 0.7.0__tar.gz → 0.9.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 (61) hide show
  1. {agentshim-0.7.0 → agentshim-0.9.0}/CHANGELOG.md +60 -0
  2. {agentshim-0.7.0 → agentshim-0.9.0}/PKG-INFO +1 -1
  3. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/__init__.py +13 -1
  4. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/agent.py +26 -5
  5. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/__init__.py +17 -1
  6. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/events.py +32 -0
  7. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/profile.py +37 -0
  8. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/provider.py +4 -0
  9. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/schema.py +14 -9
  10. agentshim-0.9.0/agentshim/core/skills.py +75 -0
  11. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/turn.py +5 -1
  12. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/__init__.py +7 -2
  13. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/claude/events.py +9 -0
  14. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/claude/parser.py +41 -0
  15. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/claude/provider.py +25 -1
  16. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/claude/scripted.py +19 -4
  17. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/codex/parser.py +3 -0
  18. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/codex/provider.py +9 -0
  19. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/codex/scripted.py +17 -1
  20. agentshim-0.9.0/agentshim/providers/codex/skills.py +86 -0
  21. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/copilot/scripted.py +12 -1
  22. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/gemini/scripted.py +12 -1
  23. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/opencode/scripted.py +12 -1
  24. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/testing/__init__.py +12 -1
  25. {agentshim-0.7.0 → agentshim-0.9.0}/pyproject.toml +1 -1
  26. {agentshim-0.7.0 → agentshim-0.9.0}/.gitignore +0 -0
  27. {agentshim-0.7.0 → agentshim-0.9.0}/README.md +0 -0
  28. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/_files.py +0 -0
  29. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/env.py +0 -0
  30. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/errors.py +0 -0
  31. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/mcp.py +0 -0
  32. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/pricing.py +0 -0
  33. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/stream.py +0 -0
  34. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/usage.py +0 -0
  35. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/execution/__init__.py +0 -0
  36. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/execution/executor.py +0 -0
  37. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/execution/host.py +0 -0
  38. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/execution/transform.py +0 -0
  39. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/claude/__init__.py +0 -0
  40. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/claude/hooks/__init__.py +0 -0
  41. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
  42. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/claude/sandbox.py +0 -0
  43. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/claude/user_hooks.py +0 -0
  44. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/codex/__init__.py +0 -0
  45. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/codex/_toml.py +0 -0
  46. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/codex/events.py +0 -0
  47. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/codex/rules.py +0 -0
  48. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/codex/sandbox.py +0 -0
  49. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/copilot/__init__.py +0 -0
  50. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/copilot/events.py +0 -0
  51. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/copilot/parser.py +0 -0
  52. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/copilot/provider.py +0 -0
  53. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/gemini/__init__.py +0 -0
  54. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/gemini/events.py +0 -0
  55. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/gemini/parser.py +0 -0
  56. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/gemini/provider.py +0 -0
  57. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/opencode/__init__.py +0 -0
  58. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/opencode/events.py +0 -0
  59. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/opencode/parser.py +0 -0
  60. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/opencode/provider.py +0 -0
  61. {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/py.typed +0 -0
@@ -1,5 +1,65 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 (2026-10-03)
4
+
5
+ Skill isolation. Additive: the default scope keeps today's behaviour.
6
+
7
+ ### Added
8
+
9
+ - `SkillScope` (`ALL`, `PROJECT`) and `start_session(skill_scope=...)` /
10
+ `run(skill_scope=...)`. `PROJECT` offers only the workspace's skills and
11
+ the CLI's built-in ones, not the user's personal skills or plugins.
12
+ - `ProviderProfile.skill_scopes` declares the scopes a provider enforces;
13
+ asking for another raises `ProviderCapabilityError` from `start_session`.
14
+ - `ArgvContext.skill_scope` carries the session's scope to `build_argv`.
15
+ - Claude Code: `PROJECT` passes `--setting-sources project,local`, which also
16
+ skips user settings and `~/.claude/CLAUDE.md`; credentials still work.
17
+ - Codex: `PROJECT` sets `features.plugins=false` and disables every user
18
+ `SKILL.md` (`$CODEX_HOME/skills` except `.system`, `~/.agents/skills`)
19
+ through `skills.config`.
20
+ - Gemini, opencode and Copilot support `ALL` only.
21
+ - Live e2e: `SkillScope.PROJECT` hides a skill seeded in a relocated user
22
+ home while the workspace skill stays offered (`tests/e2e/test_skills_e2e.py`).
23
+
24
+ ## 0.8.0 (2026-10-03)
25
+
26
+ Skill observability. Additive: a caller that ignores the new events and the
27
+ new `TurnResult` field sees no change, but an exhaustive match over
28
+ `AgentEvent` gains two members.
29
+
30
+ ### Added
31
+
32
+ - `SkillsDiscovered(names)` and `SkillInvoked(name, source_path, tool_id)`
33
+ events. `SkillInvoked` sits directly after the `ToolCall` that loaded the
34
+ skill.
35
+ - `TurnResult.skills`: a `SkillSummary` (`discovered`, `invocations`,
36
+ `invoked`, `invocation_count`) folded from those events by `SkillTracker`,
37
+ which callers can also register as a handler to cover several turns.
38
+ `None` means unknown, never zero.
39
+ - `ProviderProfile.skill_discovery` and `skill_invocation`, each a
40
+ `SkillSignal` (`NONE`, `STRUCTURED`, `INFERRED`).
41
+ - Claude Code: discovery from the `system/init` `skills` list; a load is a
42
+ `Skill` tool call or a `Read` of a `SKILL.md`.
43
+ - Codex: a load is inferred from a shell command that names a `SKILL.md`
44
+ under a `skills/` directory. `codex exec --json` does not list offered
45
+ skills, so discovery is unknown.
46
+ - Gemini, opencode and Copilot report unknown for both.
47
+ - `scripted_turn(..., skills_offered=[...], skills_invoked=[...])` scripts the
48
+ offered list (Claude) and skill loads (Claude, Codex) in each provider's own
49
+ format; a provider whose stream cannot carry them raises `ValueError`.
50
+ - Recorded skill streams under `tests/fixtures/{claude,codex}/` and a live
51
+ e2e suite, `tests/e2e/test_skills_e2e.py`, with a positive and a negative
52
+ turn per provider.
53
+
54
+ ### Fixed
55
+
56
+ - `normalize(schema, SchemaDialect.STRICT)` no longer closes an open map
57
+ (`additionalProperties` a schema or `true`), which silently turned
58
+ `dict[str, float]` into an object that can only be empty. The map is kept
59
+ and `dialect_problems` on the normalized schema reports it, so
60
+ `dialect_problems(normalize(s, d), d) == []` is the check for whether a
61
+ generated schema can go native.
62
+
3
63
  ## 0.7.0 (2026-09-27)
4
64
 
5
65
  First-class cache accounting and a static pricing table. Additive except one
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentshim
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Summary: Provider-agnostic coding-agent CLI shims
5
5
  Requires-Python: >=3.10
6
6
  Provides-Extra: test
@@ -49,6 +49,12 @@ from .core import (
49
49
  SchemaDialectError,
50
50
  SessionResumeError,
51
51
  SessionStarted,
52
+ SkillInvoked,
53
+ SkillScope,
54
+ SkillsDiscovered,
55
+ SkillSignal,
56
+ SkillSummary,
57
+ SkillTracker,
52
58
  Stderr,
53
59
  StdioMcpServer,
54
60
  StreamParser,
@@ -91,7 +97,7 @@ from .providers.copilot import CopilotProvider
91
97
  from .providers.gemini import GeminiProvider
92
98
  from .providers.opencode import OpencodeProvider
93
99
 
94
- __version__ = "0.7.0"
100
+ __version__ = "0.9.0"
95
101
 
96
102
  __all__ = [
97
103
  "AgentEvent",
@@ -153,6 +159,12 @@ __all__ = [
153
159
  "SchemaDialectError",
154
160
  "SessionResumeError",
155
161
  "SessionStarted",
162
+ "SkillInvoked",
163
+ "SkillScope",
164
+ "SkillSignal",
165
+ "SkillSummary",
166
+ "SkillTracker",
167
+ "SkillsDiscovered",
156
168
  "Stderr",
157
169
  "StdioMcpServer",
158
170
  "StreamParser",
@@ -23,9 +23,10 @@ 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 McpMechanism, OutputSchemaStyle, SchemaDialect, SkillScope
27
27
  from agentshim.core.provider import ArgvContext
28
28
  from agentshim.core.schema import compact_json, dialect_problems, materialize
29
+ from agentshim.core.skills import SkillTracker
29
30
  from agentshim.core.turn import TurnRequest, TurnResult, coerce_request
30
31
  from agentshim.execution.executor import CommandRequest
31
32
  from agentshim.execution.host import HostCommandExecutor
@@ -113,9 +114,15 @@ class CliAgent:
113
114
  cwd: str | None = None,
114
115
  timeout: float | None = None,
115
116
  session_id: str | None = None,
117
+ skill_scope: SkillScope = SkillScope.ALL,
116
118
  ) -> AgentSession:
117
- """Open a conversation whose turns resume one another."""
118
- return AgentSession(self, cwd=cwd, timeout=timeout, session_id=session_id)
119
+ """Open a conversation whose turns resume one another.
120
+
121
+ ``skill_scope`` is fixed for the conversation (see ``AgentSession``).
122
+ """
123
+ return AgentSession(
124
+ self, cwd=cwd, timeout=timeout, session_id=session_id, skill_scope=skill_scope
125
+ )
119
126
 
120
127
  def run(
121
128
  self,
@@ -123,9 +130,11 @@ class CliAgent:
123
130
  *,
124
131
  cwd: str | None = None,
125
132
  timeout: float | None = None,
133
+ skill_scope: SkillScope = SkillScope.ALL,
126
134
  ) -> TurnResult:
127
135
  """Run one turn in a throwaway session."""
128
- return self.start_session(cwd=cwd, timeout=timeout).turn(request)
136
+ session = self.start_session(cwd=cwd, timeout=timeout, skill_scope=skill_scope)
137
+ return session.turn(request)
129
138
 
130
139
 
131
140
  def _discard(message: str) -> None:
@@ -142,6 +151,7 @@ class AgentSession:
142
151
  cwd: str | None = None,
143
152
  timeout: float | None = None,
144
153
  session_id: str | None = None,
154
+ skill_scope: SkillScope = SkillScope.ALL,
145
155
  ) -> None:
146
156
  """Bind a conversation to one agent, with per-turn defaults.
147
157
 
@@ -149,8 +159,15 @@ class AgentSession:
149
159
  successive turns resume one another. ``cwd`` and ``timeout`` are
150
160
  defaults an individual ``TurnRequest`` may override. Passing
151
161
  ``session_id`` adopts a conversation the provider already has, so the
152
- first turn resumes rather than starts fresh.
162
+ first turn resumes rather than starts fresh. ``skill_scope`` limits
163
+ which skills every turn's CLI may discover; a scope the provider's
164
+ ``profile.skill_scopes`` does not list raises
165
+ ``ProviderCapabilityError`` here, before any turn runs.
153
166
  """
167
+ if skill_scope not in agent.profile.skill_scopes:
168
+ msg = f"{agent.profile.name} cannot limit skills to scope {skill_scope.value!r}"
169
+ raise ProviderCapabilityError(msg)
170
+ self.skill_scope = skill_scope
154
171
  self._agent = agent
155
172
  self._cwd = cwd
156
173
  self._timeout = timeout
@@ -259,6 +276,7 @@ class AgentSession:
259
276
  mcp_argv=installation.argv,
260
277
  extra_args=req.extra_args,
261
278
  cwd=cwd,
279
+ skill_scope=self.skill_scope,
262
280
  )
263
281
  )
264
282
  command = CommandRequest(argv=argv, stdin=req.prompt, cwd=cwd, env=env, timeout=timeout)
@@ -278,8 +296,10 @@ class AgentSession:
278
296
  agent = self._agent
279
297
  handler = agent.event_handler
280
298
  resumed = self.session_id is not None
299
+ skills = SkillTracker(self.profile)
281
300
 
282
301
  def emit(event: AgentEvent) -> None:
302
+ skills.on_event(event)
283
303
  handler.on_event(event)
284
304
 
285
305
  argv = list(command.argv)
@@ -305,6 +325,7 @@ class AgentSession:
305
325
  cost_usd=parsed.cost_usd,
306
326
  duration_ms=duration_ms,
307
327
  exit_code=result.returncode,
328
+ skills=skills.summary(),
308
329
  )
309
330
  self.last_result = turn_result
310
331
  return turn_result
@@ -34,6 +34,8 @@ from .events import (
34
34
  RunFinished,
35
35
  RunStarted,
36
36
  SessionStarted,
37
+ SkillInvoked,
38
+ SkillsDiscovered,
37
39
  Stderr,
38
40
  ToolCall,
39
41
  ToolResult,
@@ -51,9 +53,17 @@ from .mcp import (
51
53
  install_config_file,
52
54
  )
53
55
  from .pricing import ModelPricing, PricingTable, cost_usd, default_pricing, price_for
54
- from .profile import McpMechanism, OutputSchemaStyle, ProviderProfile, SchemaDialect
56
+ from .profile import (
57
+ McpMechanism,
58
+ OutputSchemaStyle,
59
+ ProviderProfile,
60
+ SchemaDialect,
61
+ SkillScope,
62
+ SkillSignal,
63
+ )
55
64
  from .provider import ArgvContext, McpInstallation, ParsedTurn, Provider, StreamParser
56
65
  from .schema import compact_json, dialect_problems, materialize, normalize
66
+ from .skills import SkillSummary, SkillTracker
57
67
  from .stream import ToolTracker, parse_json_object
58
68
  from .turn import OutputSchema, TurnRequest, TurnResult
59
69
  from .usage import ProviderUsage, TokenUsage, TokenWeights, normalized_usage
@@ -100,6 +110,12 @@ __all__ = [
100
110
  "SchemaDialectError",
101
111
  "SessionResumeError",
102
112
  "SessionStarted",
113
+ "SkillInvoked",
114
+ "SkillScope",
115
+ "SkillSignal",
116
+ "SkillSummary",
117
+ "SkillTracker",
118
+ "SkillsDiscovered",
103
119
  "Stderr",
104
120
  "StdioMcpServer",
105
121
  "StreamParser",
@@ -74,6 +74,34 @@ class ToolResult:
74
74
  duration_s: float | None
75
75
 
76
76
 
77
+ @dataclass(frozen=True)
78
+ class SkillsDiscovered:
79
+ """The provider listed the skills it offers this session.
80
+
81
+ ``names`` are the skill names as the provider reports them (a plugin
82
+ skill may carry a ``plugin:`` prefix). Only emitted by a provider whose
83
+ profile declares ``skill_discovery`` other than ``SkillSignal.NONE``, and
84
+ only when its stream actually carried the list.
85
+ """
86
+
87
+ names: tuple[str, ...]
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class SkillInvoked:
92
+ """The agent loaded a skill's instructions.
93
+
94
+ Emitted at the point in the stream where the load happened, directly
95
+ after the ``ToolCall`` that performed it; ``tool_id`` names that call.
96
+ ``source_path`` is the skill file or directory when the stream reveals
97
+ it, and ``None`` otherwise.
98
+ """
99
+
100
+ name: str
101
+ source_path: str | None = None
102
+ tool_id: str | None = None
103
+
104
+
77
105
  @dataclass(frozen=True)
78
106
  class UsageReport:
79
107
  """Provider accounting for the turn so far."""
@@ -119,6 +147,8 @@ AgentEvent = (
119
147
  | Reasoning
120
148
  | ToolCall
121
149
  | ToolResult
150
+ | SkillsDiscovered
151
+ | SkillInvoked
122
152
  | UsageReport
123
153
  | Lifecycle
124
154
  | Stderr
@@ -351,6 +381,8 @@ class ConsoleEventHandler:
351
381
  """
352
382
  if isinstance(event, Stderr):
353
383
  self._line(f"[stderr] {event.text.rstrip()}")
384
+ elif isinstance(event, SkillInvoked):
385
+ self._line(self._paint(f"[Skill] {event.name}", self._BLUE))
354
386
  elif isinstance(event, ProviderError):
355
387
  self._line(f"[error] {event.message}")
356
388
  elif isinstance(event, Lifecycle) and self._show_lifecycle:
@@ -46,6 +46,37 @@ class SchemaDialect(Enum):
46
46
  OPEN = "open"
47
47
 
48
48
 
49
+ class SkillSignal(Enum):
50
+ """How much a provider's output stream says about skills.
51
+
52
+ ``NONE``: nothing; a caller must treat skill use as unknown, never as
53
+ zero. ``STRUCTURED``: the CLI emits a dedicated, documented frame.
54
+ ``INFERRED``: agentshim derives it from tool activity (for example a
55
+ shell read of a skill's ``SKILL.md``), so a load by other means can be
56
+ missed.
57
+ """
58
+
59
+ NONE = "none"
60
+ STRUCTURED = "structured"
61
+ INFERRED = "inferred"
62
+
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
+
49
80
  def _no_container_env() -> Mapping[str, str]:
50
81
  """Empty read-only default: a frozen spec must not carry a mutable one.
51
82
 
@@ -90,3 +121,9 @@ class ProviderProfile:
90
121
  state_root_env: str | None = None
91
122
  auth_files: tuple[str, ...] = ()
92
123
  mcp_config_file: str | None = None
124
+ #: Whether the stream lists the skills offered (``SkillsDiscovered``).
125
+ skill_discovery: SkillSignal = SkillSignal.NONE
126
+ #: Whether the stream reveals skill loads (``SkillInvoked``).
127
+ skill_invocation: SkillSignal = SkillSignal.NONE
128
+ #: The ``SkillScope`` values a session on this provider may request.
129
+ skill_scopes: frozenset[SkillScope] = frozenset({SkillScope.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 SkillScope
8
9
  from .usage import ProviderUsage
9
10
 
10
11
  if TYPE_CHECKING:
@@ -37,6 +38,9 @@ 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
40
44
 
41
45
 
42
46
  @dataclass(frozen=True)
@@ -272,9 +272,11 @@ def normalize(schema: Mapping[str, Any], dialect: SchemaDialect) -> dict[str, An
272
272
  requires every declared property, drops ``default``, strips the annotation
273
273
  siblings of a ``$ref`` that the strict subset forbids, and removes the
274
274
  document metadata (``$schema``, ``$id``, ``title``, ``description``,
275
- ``examples``) that ``dialect_problems`` reports under ``STRICT``. What
276
- comes back is therefore a schema the strict subset accepts. Callers that
277
- want the schema passed through untouched skip this.
275
+ ``examples``) that ``dialect_problems`` reports under ``STRICT``. An open
276
+ map (``additionalProperties`` a schema or ``true``) is kept, since closing
277
+ it would change what the schema accepts; so ``dialect_problems`` on the
278
+ normalized schema reports exactly what the dialect cannot express.
279
+ Callers that want the schema passed through untouched skip this.
278
280
  """
279
281
  return cast("dict[str, Any]", _normalized(copy.deepcopy(dict(schema)), dialect))
280
282
 
@@ -314,12 +316,15 @@ def _normalized(node: object, dialect: SchemaDialect) -> object:
314
316
 
315
317
 
316
318
  def _close_object(mapping: dict[str, Any], dialect: SchemaDialect) -> None:
317
- additional = mapping.get("additionalProperties")
318
- if additional in (None, False):
319
- mapping["additionalProperties"] = False
320
- return
321
- if dialect is SchemaDialect.STRICT:
322
- # dialect_problems already reported this; normalize does not guess.
319
+ """Close an object whose extra keys are merely unspecified.
320
+
321
+ An open map (``additionalProperties`` a schema or ``true``) is left open
322
+ in every dialect: closing it would silently turn ``dict[str, float]`` into
323
+ an object that can only be empty. Under ``STRICT`` it stays a problem for
324
+ ``dialect_problems`` to report on the normalized schema.
325
+ """
326
+ del dialect # an open map is left open in every dialect
327
+ if mapping.get("additionalProperties") in (None, False):
323
328
  mapping["additionalProperties"] = False
324
329
 
325
330
 
@@ -0,0 +1,75 @@
1
+ """What a turn did with skills, folded from its events.
2
+
3
+ ``SkillTracker`` is an ordinary event handler, so the summary on
4
+ ``TurnResult.skills`` and any caller that watches the stream itself read the
5
+ same events: there is one source of truth.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING
12
+
13
+ from .events import SkillInvoked, SkillsDiscovered
14
+ from .profile import SkillSignal
15
+
16
+ if TYPE_CHECKING:
17
+ from .events import AgentEvent
18
+ from .profile import ProviderProfile
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class SkillSummary:
23
+ """Skills a turn (or several) offered and loaded.
24
+
25
+ ``None`` means unknown: the provider's stream carries no such signal, or
26
+ did not carry it this time. It never means zero. An empty tuple means the
27
+ signal was available and nothing matched.
28
+ """
29
+
30
+ discovered: tuple[str, ...] | None = None
31
+ invocations: tuple[SkillInvoked, ...] | None = None
32
+
33
+ @property
34
+ def invoked(self) -> tuple[str, ...] | None:
35
+ """Distinct invoked skill names, in order of first use."""
36
+ if self.invocations is None:
37
+ return None
38
+ return tuple(dict.fromkeys(event.name for event in self.invocations))
39
+
40
+ @property
41
+ def invocation_count(self) -> int | None:
42
+ """How many loads happened, or ``None`` when unknown."""
43
+ if self.invocations is None:
44
+ return None
45
+ return len(self.invocations)
46
+
47
+
48
+ class SkillTracker:
49
+ """Event handler that folds skill events into a ``SkillSummary``.
50
+
51
+ Feed it one turn or many; the summary covers everything it has seen.
52
+ """
53
+
54
+ def __init__(self, profile: ProviderProfile) -> None:
55
+ """Track skills for a provider with *profile*'s declared signals."""
56
+ self._invocation_known = profile.skill_invocation is not SkillSignal.NONE
57
+ self._discovered: list[str] | None = None
58
+ self._invocations: list[SkillInvoked] = []
59
+
60
+ def on_event(self, event: AgentEvent) -> None:
61
+ """Record a skill event; ignore every other event."""
62
+ if isinstance(event, SkillsDiscovered):
63
+ known = self._discovered if self._discovered is not None else []
64
+ known.extend(name for name in event.names if name not in known)
65
+ self._discovered = known
66
+ elif isinstance(event, SkillInvoked):
67
+ self._invocations.append(event)
68
+
69
+ def summary(self) -> SkillSummary:
70
+ """Return what has been seen so far."""
71
+ discovered = tuple(self._discovered) if self._discovered is not None else None
72
+ invocations: tuple[SkillInvoked, ...] | None = tuple(self._invocations)
73
+ if not self._invocation_known and not self._invocations:
74
+ invocations = None
75
+ return SkillSummary(discovered=discovered, invocations=invocations)
@@ -2,9 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from dataclasses import dataclass
5
+ from dataclasses import dataclass, field
6
6
  from typing import TYPE_CHECKING, Any
7
7
 
8
+ from .skills import SkillSummary
9
+
8
10
  if TYPE_CHECKING:
9
11
  from collections.abc import Mapping, Sequence
10
12
  from pathlib import Path
@@ -62,6 +64,8 @@ class TurnResult:
62
64
  cost_usd: float | None
63
65
  duration_ms: int
64
66
  exit_code: int
67
+ #: Skills offered and loaded during the turn, derived from its events.
68
+ skills: SkillSummary = field(default_factory=SkillSummary)
65
69
 
66
70
 
67
71
  def coerce_request(request: TurnRequest | str) -> TurnRequest:
@@ -36,7 +36,7 @@ if TYPE_CHECKING:
36
36
  class ScriptedLines(Protocol):
37
37
  """Builds one turn's stdout in a provider's own stream format."""
38
38
 
39
- def __call__(
39
+ def __call__( # noqa: PLR0913 # mirrors scripted_turn's independent knobs
40
40
  self,
41
41
  *,
42
42
  text: str = "",
@@ -44,12 +44,17 @@ class ScriptedLines(Protocol):
44
44
  usage: TokenUsage | None = None,
45
45
  tool_calls: Sequence[tuple[str, Mapping[str, Any], str]] = (),
46
46
  structured_output: object | None = None,
47
+ skills_offered: Sequence[str] | None = None,
48
+ skills_invoked: Sequence[str] = (),
47
49
  ) -> list[str]:
48
50
  """Return the stdout lines of one scripted turn.
49
51
 
50
52
  ``tool_calls`` entries are ``(tool, args, output)``;
51
53
  ``structured_output`` is any JSON-serializable value the turn should
52
- report as its structured payload.
54
+ report as its structured payload. ``skills_offered`` is the skill
55
+ list the provider announces and ``skills_invoked`` the skills the turn
56
+ loads, each scripted the way the provider shows a load; a provider
57
+ whose stream cannot carry either raises ``ValueError``.
53
58
  """
54
59
  ...
55
60
 
@@ -20,6 +20,8 @@ class SystemInit:
20
20
 
21
21
  session_id: str | None
22
22
  subtype: str | None
23
+ #: The ``skills`` list of an ``init`` frame; ``None`` when absent.
24
+ skills: tuple[str, ...] | None = None
23
25
 
24
26
 
25
27
  @dataclass(frozen=True)
@@ -93,6 +95,7 @@ def parse_frame(data: Mapping[str, Any]) -> ClaudeFrame | None:
93
95
  return SystemInit(
94
96
  session_id=_str_or_none(data.get("session_id")),
95
97
  subtype=_str_or_none(data.get("subtype")),
98
+ skills=_str_tuple_or_none(data.get("skills")),
96
99
  )
97
100
  if kind == "assistant":
98
101
  return _assistant(data)
@@ -208,6 +211,12 @@ def _str_or_none(value: object) -> str | None:
208
211
  return value if isinstance(value, str) else None
209
212
 
210
213
 
214
+ def _str_tuple_or_none(value: object) -> tuple[str, ...] | None:
215
+ if not isinstance(value, list):
216
+ return None
217
+ return tuple(item for item in cast("list[object]", value) if isinstance(item, str))
218
+
219
+
211
220
  def _int_or_none(value: object) -> int | None:
212
221
  if isinstance(value, bool):
213
222
  return None
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import re
6
7
  from typing import TYPE_CHECKING, Any, cast
7
8
 
8
9
  from agentshim.core.events import (
@@ -11,6 +12,8 @@ from agentshim.core.events import (
11
12
  RawOutput,
12
13
  Reasoning,
13
14
  SessionStarted,
15
+ SkillInvoked,
16
+ SkillsDiscovered,
14
17
  Stderr,
15
18
  ToolCall,
16
19
  ToolResult,
@@ -37,6 +40,15 @@ if TYPE_CHECKING:
37
40
 
38
41
  PROVIDER_NAME = "claude"
39
42
 
43
+ #: The built-in tool through which Claude Code loads a skill.
44
+ SKILL_TOOL = "Skill"
45
+
46
+ #: The built-in tool through which Claude Code reads a file.
47
+ READ_TOOL = "Read"
48
+
49
+ #: ``.../skills/<name>/SKILL.md``: a skill's instructions, read directly.
50
+ _SKILL_FILE = re.compile(r"/skills/(?:[^/]+/)*?(?P<name>[^/]+)/SKILL\.md$")
51
+
40
52
 
41
53
  def fold_usage(usage: Mapping[str, Any] | None, turns: int = 0) -> TokenUsage:
42
54
  """Normalize Claude's usage mapping to the shared token counts.
@@ -76,6 +88,30 @@ def _int(value: object) -> int:
76
88
  return 0
77
89
 
78
90
 
91
+ def skill_invocation(
92
+ tool_id: str | None, tool: str, args: Mapping[str, Any] | str | None
93
+ ) -> SkillInvoked | None:
94
+ """Recognize a tool call that loads a skill.
95
+
96
+ Claude Code loads a skill through its ``Skill`` tool. An agent can also
97
+ read a ``SKILL.md`` with ``Read``, which loads the same instructions.
98
+ """
99
+ if not isinstance(args, dict):
100
+ return None
101
+ if tool == SKILL_TOOL:
102
+ name = args.get("skill")
103
+ if isinstance(name, str) and name:
104
+ return SkillInvoked(name=name, tool_id=tool_id)
105
+ return None
106
+ if tool == READ_TOOL:
107
+ path = args.get("file_path")
108
+ if isinstance(path, str):
109
+ match = _SKILL_FILE.search(path)
110
+ if match is not None:
111
+ return SkillInvoked(name=match["name"], source_path=path, tool_id=tool_id)
112
+ return None
113
+
114
+
79
115
  class ClaudeStreamParser:
80
116
  """Stateful parser for one Claude Code run."""
81
117
 
@@ -141,6 +177,8 @@ class ClaudeStreamParser:
141
177
  if self._session_id is None and frame.session_id:
142
178
  self._session_id = frame.session_id
143
179
  self._emit(SessionStarted(frame.session_id))
180
+ if frame.skills is not None:
181
+ self._emit(SkillsDiscovered(frame.skills))
144
182
  elif isinstance(frame, AssistantMessage):
145
183
  self._assistant(frame)
146
184
  elif isinstance(frame, ToolResultBlock):
@@ -170,6 +208,9 @@ class ClaudeStreamParser:
170
208
  else:
171
209
  self._tools.start(block.tool_id, block.tool)
172
210
  self._emit(ToolCall(block.tool_id, block.tool, block.args))
211
+ skill = skill_invocation(block.tool_id, block.tool, block.args)
212
+ if skill is not None:
213
+ self._emit(skill)
173
214
 
174
215
  def _tool_result(self, frame: ToolResultBlock) -> None:
175
216
  name = self._tools.name(frame.tool_id)
@@ -7,7 +7,14 @@ from typing import TYPE_CHECKING, Any
7
7
 
8
8
  from agentshim.core.errors import ProviderCapabilityError, SessionResumeError
9
9
  from agentshim.core.mcp import HttpMcpServer, NoopInstallation, StdioMcpServer, install_config_file
10
- from agentshim.core.profile import McpMechanism, OutputSchemaStyle, ProviderProfile, SchemaDialect
10
+ from agentshim.core.profile import (
11
+ McpMechanism,
12
+ OutputSchemaStyle,
13
+ ProviderProfile,
14
+ SchemaDialect,
15
+ SkillScope,
16
+ SkillSignal,
17
+ )
11
18
 
12
19
  from .parser import ClaudeStreamParser
13
20
  from .sandbox import SANDBOX_ENV, SandboxConfig, build_settings, resolve_sandbox
@@ -25,6 +32,17 @@ if TYPE_CHECKING:
25
32
  MCP_CONFIG_FILENAME = ".mcp.json"
26
33
  MCP_SERVER_KEY = "mcpServers"
27
34
 
35
+ #: ``SkillScope.PROJECT``: load settings only from the workspace's
36
+ #: ``.claude/settings.json`` and ``.claude/settings.local.json`` (plus
37
+ #: ``--settings`` and managed policy, which always apply). Leaving out the
38
+ #: ``user`` source drops ``~/.claude/skills``, the user's plugins (with their
39
+ #: skills, hooks and MCP servers), ``~/.claude/CLAUDE.md`` and the user's
40
+ #: settings (default model, permissions, hooks, ``env``, ``apiKeyHelper``).
41
+ #: Credentials are not settings: OAuth in ``.credentials.json`` and the
42
+ #: ``auth_env_vars`` keep working. Claude Code's built-in skills and claude.ai
43
+ #: account connectors are not user settings and stay.
44
+ PROJECT_SETTING_SOURCES = ("--setting-sources", "project,local")
45
+
28
46
  PROFILE = ProviderProfile(
29
47
  name="claude",
30
48
  display_name="Claude Code",
@@ -45,6 +63,10 @@ PROFILE = ProviderProfile(
45
63
  "ANTHROPIC_CUSTOM_HEADERS",
46
64
  ),
47
65
  skill_dirs=(".claude/skills",),
66
+ # ``system/init`` lists ``skills``; the ``Skill`` tool call names one.
67
+ skill_discovery=SkillSignal.STRUCTURED,
68
+ skill_invocation=SkillSignal.STRUCTURED,
69
+ skill_scopes=frozenset({SkillScope.ALL, SkillScope.PROJECT}),
48
70
  container_install=(
49
71
  "apt-get update && apt-get install -y --no-install-recommends curl ca-certificates",
50
72
  "curl -fsSL https://claude.ai/install.sh | bash",
@@ -108,6 +130,8 @@ class ClaudeProvider:
108
130
  "stream-json",
109
131
  "--verbose",
110
132
  ]
133
+ if ctx.skill_scope is SkillScope.PROJECT:
134
+ argv += PROJECT_SETTING_SOURCES
111
135
  if ctx.model:
112
136
  argv += ["--model", ctx.model]
113
137
  if ctx.reasoning_effort:
@@ -16,22 +16,37 @@ if TYPE_CHECKING:
16
16
  from agentshim.core.usage import TokenUsage
17
17
 
18
18
 
19
- def scripted_lines(
19
+ # Mirrors scripted_turn: each option is an independent knob of the test double.
20
+ def scripted_lines( # noqa: PLR0913
20
21
  *,
21
22
  text: str = "",
22
23
  session_id: str | None = None,
23
24
  usage: TokenUsage | None = None,
24
25
  tool_calls: Sequence[tuple[str, Mapping[str, Any], str]] = (),
25
26
  structured_output: object | None = None,
27
+ skills_offered: Sequence[str] | None = None,
28
+ skills_invoked: Sequence[str] = (),
26
29
  ) -> list[str]:
27
30
  """Build the stdout of one Claude turn.
28
31
 
29
32
  ``tool_calls`` entries are ``(tool, args, output)`` and become a
30
- ``tool_use`` block plus the matching ``tool_result`` frame.
33
+ ``tool_use`` block plus the matching ``tool_result`` frame; a ``Skill``
34
+ call with ``{"skill": name}`` is a skill load, and each of
35
+ ``skills_invoked`` becomes one ahead of ``tool_calls``. ``skills_offered``
36
+ goes in the ``init`` frame's ``skills`` list.
31
37
  """
38
+ tool_calls = [
39
+ *(("Skill", {"skill": name}, f"Launching skill: {name}") for name in skills_invoked),
40
+ *tool_calls,
41
+ ]
32
42
  lines: list[str] = []
33
- if session_id is not None:
34
- lines.append(_line({"type": "system", "subtype": "init", "session_id": session_id}))
43
+ if session_id is not None or skills_offered is not None:
44
+ init: dict[str, Any] = {"type": "system", "subtype": "init"}
45
+ if session_id is not None:
46
+ init["session_id"] = session_id
47
+ if skills_offered is not None:
48
+ init["skills"] = list(skills_offered)
49
+ lines.append(_line(init))
35
50
 
36
51
  blocks: list[dict[str, Any]] = []
37
52
  if text:
@@ -34,6 +34,7 @@ from .events import (
34
34
  parse_frame,
35
35
  summarize_item,
36
36
  )
37
+ from .skills import skill_reads
37
38
 
38
39
  if TYPE_CHECKING:
39
40
  from collections.abc import Callable
@@ -170,6 +171,8 @@ class CodexStreamParser:
170
171
  if isinstance(item, CommandItem):
171
172
  self._tools.start(item.item_id, COMMAND_TOOL)
172
173
  self._emit(ToolCall(item.item_id, COMMAND_TOOL, {"command": item.command}))
174
+ for skill in skill_reads(item.command, item.item_id):
175
+ self._emit(skill)
173
176
  elif isinstance(item, GenericItem):
174
177
  self._tools.start(item.item_id, item.kind)
175
178
  self._emit(ToolCall(item.item_id, item.kind, dict(item.fields)))
@@ -13,12 +13,15 @@ from agentshim.core.profile import (
13
13
  OutputSchemaStyle,
14
14
  ProviderProfile,
15
15
  SchemaDialect,
16
+ SkillScope,
17
+ SkillSignal,
16
18
  )
17
19
 
18
20
  from ._toml import toml_array, toml_str, unescape_toml
19
21
  from .parser import CodexStreamParser
20
22
  from .rules import RULES_FILENAME
21
23
  from .sandbox import CodexSandboxConfig, resolve_sandbox, sandbox_overrides
24
+ from .skills import project_scope_overrides
22
25
 
23
26
  if TYPE_CHECKING:
24
27
  from collections.abc import Callable, Mapping, Sequence
@@ -75,6 +78,10 @@ PROFILE = ProviderProfile(
75
78
  ),
76
79
  auth_env_vars=("OPENAI_API_KEY", "OPENAI_BASE_URL"),
77
80
  skill_dirs=(".agents/skills",),
81
+ # ``exec --json`` lists no skills; a load is inferred from a shell read
82
+ # of a ``SKILL.md`` (``skills.py``).
83
+ skill_invocation=SkillSignal.INFERRED,
84
+ skill_scopes=frozenset({SkillScope.ALL, SkillScope.PROJECT}),
78
85
  container_install=(_NODE_INSTALL, _CODEX_INSTALL),
79
86
  # Documented Codex CLI variable that relocates ~/.codex (``codex --help``:
80
87
  # "Layer $CODEX_HOME/<name>.config.toml on top of the base user config";
@@ -126,6 +133,8 @@ class CodexProvider:
126
133
  if ctx.model:
127
134
  argv += ["--model", ctx.model]
128
135
  argv += _shell_path_config(ctx.env)
136
+ if ctx.skill_scope is SkillScope.PROJECT:
137
+ argv += project_scope_overrides(ctx.env)
129
138
  if ctx.reasoning_effort:
130
139
  argv += ["--config", f"model_reasoning_effort={toml_str(ctx.reasoning_effort)}"]
131
140
  argv += list(ctx.mcp_argv)
@@ -16,13 +16,16 @@ if TYPE_CHECKING:
16
16
  from agentshim.core.usage import TokenUsage
17
17
 
18
18
 
19
- def scripted_lines(
19
+ # Mirrors scripted_turn: each option is an independent knob of the test double.
20
+ def scripted_lines( # noqa: PLR0913
20
21
  *,
21
22
  text: str = "",
22
23
  session_id: str | None = None,
23
24
  usage: TokenUsage | None = None,
24
25
  tool_calls: Sequence[tuple[str, Mapping[str, Any], str]] = (),
25
26
  structured_output: object | None = None,
27
+ skills_offered: Sequence[str] | None = None,
28
+ skills_invoked: Sequence[str] = (),
26
29
  ) -> list[str]:
27
30
  """Build the stdout of one Codex turn.
28
31
 
@@ -38,10 +41,23 @@ def scripted_lines(
38
41
  usage: Token counts reported by ``turn.completed``.
39
42
  tool_calls: Commands to report as started-then-completed items.
40
43
  structured_output: Payload to serialize as the final message.
44
+ skills_offered: Must be None; the stream lists no offered skills.
45
+ skills_invoked: Skills to load first, each as a shell read of its
46
+ ``SKILL.md``.
41
47
 
42
48
  Returns:
43
49
  One newline-terminated JSON line per Codex event.
44
50
  """
51
+ tool_calls = [
52
+ *(
53
+ ("execute", {"command": f"cat .agents/skills/{name}/SKILL.md"}, "")
54
+ for name in skills_invoked
55
+ ),
56
+ *tool_calls,
57
+ ]
58
+ if skills_offered is not None:
59
+ msg = "Codex does not list offered skills in its stream"
60
+ raise ValueError(msg)
45
61
  lines: list[str] = []
46
62
  if session_id is not None:
47
63
  lines.append(_line({"type": "thread.started", "thread_id": session_id}))
@@ -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
+ ]
@@ -20,13 +20,16 @@ _NO_STRUCTURED_OUTPUT = (
20
20
  )
21
21
 
22
22
 
23
- def scripted_lines(
23
+ # Mirrors scripted_turn: each option is an independent knob of the test double.
24
+ def scripted_lines( # noqa: PLR0913
24
25
  *,
25
26
  text: str = "",
26
27
  session_id: str | None = None,
27
28
  usage: TokenUsage | None = None,
28
29
  tool_calls: Sequence[tuple[str, Mapping[str, Any], str]] = (),
29
30
  structured_output: object | None = None,
31
+ skills_offered: Sequence[str] | None = None,
32
+ skills_invoked: Sequence[str] = (),
30
33
  ) -> list[str]:
31
34
  """Build the stdout of one Copilot turn.
32
35
 
@@ -40,6 +43,8 @@ def scripted_lines(
40
43
  usage: Token counts to report in the ``assistant.usage`` frame.
41
44
  tool_calls: Tool calls to script.
42
45
  structured_output: Must be None; Copilot has no native output schema.
46
+ skills_offered: Must be None; the stream lists no offered skills.
47
+ skills_invoked: Must be empty; the stream reports no skill loads.
43
48
 
44
49
  Returns:
45
50
  The stdout lines, each a JSON object with a trailing newline.
@@ -47,6 +52,12 @@ def scripted_lines(
47
52
  Raises:
48
53
  ValueError: If ``structured_output`` is given.
49
54
  """
55
+ if skills_invoked:
56
+ msg = "Copilot reports no skill loads in its stream"
57
+ raise ValueError(msg)
58
+ if skills_offered is not None:
59
+ msg = "Copilot does not list offered skills in its stream"
60
+ raise ValueError(msg)
50
61
  if structured_output is not None:
51
62
  raise ValueError(_NO_STRUCTURED_OUTPUT)
52
63
 
@@ -25,13 +25,16 @@ _NO_STRUCTURED_OUTPUT = (
25
25
  )
26
26
 
27
27
 
28
- def scripted_lines(
28
+ # Mirrors scripted_turn: each option is an independent knob of the test double.
29
+ def scripted_lines( # noqa: PLR0913
29
30
  *,
30
31
  text: str = "",
31
32
  session_id: str | None = None,
32
33
  usage: TokenUsage | None = None,
33
34
  tool_calls: Sequence[tuple[str, Mapping[str, Any], str]] = (),
34
35
  structured_output: object | None = None,
36
+ skills_offered: Sequence[str] | None = None,
37
+ skills_invoked: Sequence[str] = (),
35
38
  ) -> list[str]:
36
39
  """Build the stdout of one Gemini turn.
37
40
 
@@ -45,6 +48,8 @@ def scripted_lines(
45
48
  usage: Token counts to report in the terminal ``result`` frame.
46
49
  tool_calls: Tool calls to script.
47
50
  structured_output: Must be None; Gemini has no native output schema.
51
+ skills_offered: Must be None; the stream lists no offered skills.
52
+ skills_invoked: Must be empty; the stream reports no skill loads.
48
53
 
49
54
  Returns:
50
55
  The stdout lines, each a JSON object with a trailing newline.
@@ -52,6 +57,12 @@ def scripted_lines(
52
57
  Raises:
53
58
  ValueError: If ``structured_output`` is given.
54
59
  """
60
+ if skills_invoked:
61
+ msg = "Gemini reports no skill loads in its stream"
62
+ raise ValueError(msg)
63
+ if skills_offered is not None:
64
+ msg = "Gemini does not list offered skills in its stream"
65
+ raise ValueError(msg)
55
66
  if structured_output is not None:
56
67
  raise ValueError(_NO_STRUCTURED_OUTPUT)
57
68
 
@@ -25,13 +25,16 @@ _NO_STRUCTURED_OUTPUT = (
25
25
  )
26
26
 
27
27
 
28
- def scripted_lines(
28
+ # Mirrors scripted_turn: each option is an independent knob of the test double.
29
+ def scripted_lines( # noqa: PLR0913
29
30
  *,
30
31
  text: str = "",
31
32
  session_id: str | None = None,
32
33
  usage: TokenUsage | None = None,
33
34
  tool_calls: Sequence[tuple[str, Mapping[str, Any], str]] = (),
34
35
  structured_output: object | None = None,
36
+ skills_offered: Sequence[str] | None = None,
37
+ skills_invoked: Sequence[str] = (),
35
38
  ) -> list[str]:
36
39
  """Build the stdout of one opencode turn.
37
40
 
@@ -45,6 +48,8 @@ def scripted_lines(
45
48
  usage: Token counts to report in the closing ``step_finish`` frame.
46
49
  tool_calls: Tool calls to script.
47
50
  structured_output: Must be None; opencode has no native output schema.
51
+ skills_offered: Must be None; the stream lists no offered skills.
52
+ skills_invoked: Must be empty; the stream reports no skill loads.
48
53
 
49
54
  Returns:
50
55
  The stdout lines, each a JSON object with a trailing newline.
@@ -52,6 +57,12 @@ def scripted_lines(
52
57
  Raises:
53
58
  ValueError: If ``structured_output`` is given.
54
59
  """
60
+ if skills_invoked:
61
+ msg = "opencode reports no skill loads in its stream"
62
+ raise ValueError(msg)
63
+ if skills_offered is not None:
64
+ msg = "opencode does not list offered skills in its stream"
65
+ raise ValueError(msg)
55
66
  if structured_output is not None:
56
67
  raise ValueError(_NO_STRUCTURED_OUTPUT)
57
68
 
@@ -160,14 +160,25 @@ def scripted_turn( # noqa: PLR0913
160
160
  tool_calls: Sequence[tuple[str, Mapping[str, Any], str]] = (),
161
161
  structured_output: object | None = None,
162
162
  returncode: int = 0,
163
+ skills_offered: Sequence[str] | None = None,
164
+ skills_invoked: Sequence[str] = (),
163
165
  ) -> FakeRun:
164
- """Build a ``FakeRun`` whose stdout is *provider*'s real stream format."""
166
+ """Build a ``FakeRun`` whose stdout is *provider*'s real stream format.
167
+
168
+ ``skills_invoked`` scripts one load per name, before ``tool_calls``, the
169
+ way *provider* shows a load, so the turn reports a ``SkillInvoked`` each.
170
+ ``skills_offered`` scripts the offered list. Either raises ``ValueError``
171
+ on a provider whose stream cannot carry it (the matching
172
+ ``profile.skill_*`` signal is ``NONE``).
173
+ """
165
174
  lines = get_scripted_lines(provider)(
166
175
  text=text,
167
176
  session_id=session_id,
168
177
  usage=usage,
169
178
  tool_calls=tool_calls,
170
179
  structured_output=structured_output,
180
+ skills_offered=skills_offered,
181
+ skills_invoked=skills_invoked,
171
182
  )
172
183
  return FakeRun(stdout=lines, returncode=returncode)
173
184
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentshim"
3
- version = "0.7.0"
3
+ version = "0.9.0"
4
4
  description = "Provider-agnostic coding-agent CLI shims"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
File without changes
File without changes
File without changes