agentshim 0.7.0__tar.gz → 0.8.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.8.0}/CHANGELOG.md +39 -0
  2. {agentshim-0.7.0 → agentshim-0.8.0}/PKG-INFO +1 -1
  3. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/__init__.py +11 -1
  4. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/agent.py +4 -0
  5. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/__init__.py +15 -1
  6. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/events.py +32 -0
  7. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/profile.py +19 -0
  8. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/schema.py +14 -9
  9. agentshim-0.8.0/agentshim/core/skills.py +75 -0
  10. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/turn.py +5 -1
  11. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/__init__.py +7 -2
  12. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/claude/events.py +9 -0
  13. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/claude/parser.py +41 -0
  14. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/claude/provider.py +10 -1
  15. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/claude/scripted.py +19 -4
  16. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/codex/parser.py +3 -0
  17. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/codex/provider.py +4 -0
  18. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/codex/scripted.py +17 -1
  19. agentshim-0.8.0/agentshim/providers/codex/skills.py +27 -0
  20. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/copilot/scripted.py +12 -1
  21. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/gemini/scripted.py +12 -1
  22. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/opencode/scripted.py +12 -1
  23. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/testing/__init__.py +12 -1
  24. {agentshim-0.7.0 → agentshim-0.8.0}/pyproject.toml +1 -1
  25. {agentshim-0.7.0 → agentshim-0.8.0}/.gitignore +0 -0
  26. {agentshim-0.7.0 → agentshim-0.8.0}/README.md +0 -0
  27. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/_files.py +0 -0
  28. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/env.py +0 -0
  29. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/errors.py +0 -0
  30. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/mcp.py +0 -0
  31. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/pricing.py +0 -0
  32. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/provider.py +0 -0
  33. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/stream.py +0 -0
  34. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/usage.py +0 -0
  35. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/execution/__init__.py +0 -0
  36. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/execution/executor.py +0 -0
  37. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/execution/host.py +0 -0
  38. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/execution/transform.py +0 -0
  39. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/claude/__init__.py +0 -0
  40. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/claude/hooks/__init__.py +0 -0
  41. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
  42. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/claude/sandbox.py +0 -0
  43. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/claude/user_hooks.py +0 -0
  44. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/codex/__init__.py +0 -0
  45. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/codex/_toml.py +0 -0
  46. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/codex/events.py +0 -0
  47. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/codex/rules.py +0 -0
  48. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/codex/sandbox.py +0 -0
  49. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/copilot/__init__.py +0 -0
  50. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/copilot/events.py +0 -0
  51. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/copilot/parser.py +0 -0
  52. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/copilot/provider.py +0 -0
  53. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/gemini/__init__.py +0 -0
  54. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/gemini/events.py +0 -0
  55. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/gemini/parser.py +0 -0
  56. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/gemini/provider.py +0 -0
  57. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/opencode/__init__.py +0 -0
  58. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/opencode/events.py +0 -0
  59. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/opencode/parser.py +0 -0
  60. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/opencode/provider.py +0 -0
  61. {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/py.typed +0 -0
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 (2026-10-03)
4
+
5
+ Skill observability. Additive: a caller that ignores the new events and the
6
+ new `TurnResult` field sees no change, but an exhaustive match over
7
+ `AgentEvent` gains two members.
8
+
9
+ ### Added
10
+
11
+ - `SkillsDiscovered(names)` and `SkillInvoked(name, source_path, tool_id)`
12
+ events. `SkillInvoked` sits directly after the `ToolCall` that loaded the
13
+ skill.
14
+ - `TurnResult.skills`: a `SkillSummary` (`discovered`, `invocations`,
15
+ `invoked`, `invocation_count`) folded from those events by `SkillTracker`,
16
+ which callers can also register as a handler to cover several turns.
17
+ `None` means unknown, never zero.
18
+ - `ProviderProfile.skill_discovery` and `skill_invocation`, each a
19
+ `SkillSignal` (`NONE`, `STRUCTURED`, `INFERRED`).
20
+ - Claude Code: discovery from the `system/init` `skills` list; a load is a
21
+ `Skill` tool call or a `Read` of a `SKILL.md`.
22
+ - Codex: a load is inferred from a shell command that names a `SKILL.md`
23
+ under a `skills/` directory. `codex exec --json` does not list offered
24
+ skills, so discovery is unknown.
25
+ - Gemini, opencode and Copilot report unknown for both.
26
+ - `scripted_turn(..., skills_offered=[...], skills_invoked=[...])` scripts the
27
+ offered list (Claude) and skill loads (Claude, Codex) in each provider's own
28
+ format; a provider whose stream cannot carry them raises `ValueError`.
29
+ - Recorded skill streams under `tests/fixtures/{claude,codex}/` and a live
30
+ e2e suite, `tests/e2e/test_skills_e2e.py`, with a positive and a negative
31
+ turn per provider.
32
+
33
+ ### Fixed
34
+
35
+ - `normalize(schema, SchemaDialect.STRICT)` no longer closes an open map
36
+ (`additionalProperties` a schema or `true`), which silently turned
37
+ `dict[str, float]` into an object that can only be empty. The map is kept
38
+ and `dialect_problems` on the normalized schema reports it, so
39
+ `dialect_problems(normalize(s, d), d) == []` is the check for whether a
40
+ generated schema can go native.
41
+
3
42
  ## 0.7.0 (2026-09-27)
4
43
 
5
44
  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.8.0
4
4
  Summary: Provider-agnostic coding-agent CLI shims
5
5
  Requires-Python: >=3.10
6
6
  Provides-Extra: test
@@ -49,6 +49,11 @@ from .core import (
49
49
  SchemaDialectError,
50
50
  SessionResumeError,
51
51
  SessionStarted,
52
+ SkillInvoked,
53
+ SkillsDiscovered,
54
+ SkillSignal,
55
+ SkillSummary,
56
+ SkillTracker,
52
57
  Stderr,
53
58
  StdioMcpServer,
54
59
  StreamParser,
@@ -91,7 +96,7 @@ from .providers.copilot import CopilotProvider
91
96
  from .providers.gemini import GeminiProvider
92
97
  from .providers.opencode import OpencodeProvider
93
98
 
94
- __version__ = "0.7.0"
99
+ __version__ = "0.8.0"
95
100
 
96
101
  __all__ = [
97
102
  "AgentEvent",
@@ -153,6 +158,11 @@ __all__ = [
153
158
  "SchemaDialectError",
154
159
  "SessionResumeError",
155
160
  "SessionStarted",
161
+ "SkillInvoked",
162
+ "SkillSignal",
163
+ "SkillSummary",
164
+ "SkillTracker",
165
+ "SkillsDiscovered",
156
166
  "Stderr",
157
167
  "StdioMcpServer",
158
168
  "StreamParser",
@@ -26,6 +26,7 @@ from agentshim.core.events import RunFinished, RunStarted, compose_event_handler
26
26
  from agentshim.core.profile import McpMechanism, OutputSchemaStyle, SchemaDialect
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
@@ -278,8 +279,10 @@ class AgentSession:
278
279
  agent = self._agent
279
280
  handler = agent.event_handler
280
281
  resumed = self.session_id is not None
282
+ skills = SkillTracker(self.profile)
281
283
 
282
284
  def emit(event: AgentEvent) -> None:
285
+ skills.on_event(event)
283
286
  handler.on_event(event)
284
287
 
285
288
  argv = list(command.argv)
@@ -305,6 +308,7 @@ class AgentSession:
305
308
  cost_usd=parsed.cost_usd,
306
309
  duration_ms=duration_ms,
307
310
  exit_code=result.returncode,
311
+ skills=skills.summary(),
308
312
  )
309
313
  self.last_result = turn_result
310
314
  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,16 @@ 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
+ SkillSignal,
62
+ )
55
63
  from .provider import ArgvContext, McpInstallation, ParsedTurn, Provider, StreamParser
56
64
  from .schema import compact_json, dialect_problems, materialize, normalize
65
+ from .skills import SkillSummary, SkillTracker
57
66
  from .stream import ToolTracker, parse_json_object
58
67
  from .turn import OutputSchema, TurnRequest, TurnResult
59
68
  from .usage import ProviderUsage, TokenUsage, TokenWeights, normalized_usage
@@ -100,6 +109,11 @@ __all__ = [
100
109
  "SchemaDialectError",
101
110
  "SessionResumeError",
102
111
  "SessionStarted",
112
+ "SkillInvoked",
113
+ "SkillSignal",
114
+ "SkillSummary",
115
+ "SkillTracker",
116
+ "SkillsDiscovered",
103
117
  "Stderr",
104
118
  "StdioMcpServer",
105
119
  "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,21 @@ 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
+
49
64
  def _no_container_env() -> Mapping[str, str]:
50
65
  """Empty read-only default: a frozen spec must not carry a mutable one.
51
66
 
@@ -90,3 +105,7 @@ class ProviderProfile:
90
105
  state_root_env: str | None = None
91
106
  auth_files: tuple[str, ...] = ()
92
107
  mcp_config_file: str | None = None
108
+ #: Whether the stream lists the skills offered (``SkillsDiscovered``).
109
+ skill_discovery: SkillSignal = SkillSignal.NONE
110
+ #: Whether the stream reveals skill loads (``SkillInvoked``).
111
+ skill_invocation: SkillSignal = SkillSignal.NONE
@@ -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,13 @@ 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
+ SkillSignal,
16
+ )
11
17
 
12
18
  from .parser import ClaudeStreamParser
13
19
  from .sandbox import SANDBOX_ENV, SandboxConfig, build_settings, resolve_sandbox
@@ -45,6 +51,9 @@ PROFILE = ProviderProfile(
45
51
  "ANTHROPIC_CUSTOM_HEADERS",
46
52
  ),
47
53
  skill_dirs=(".claude/skills",),
54
+ # ``system/init`` lists ``skills``; the ``Skill`` tool call names one.
55
+ skill_discovery=SkillSignal.STRUCTURED,
56
+ skill_invocation=SkillSignal.STRUCTURED,
48
57
  container_install=(
49
58
  "apt-get update && apt-get install -y --no-install-recommends curl ca-certificates",
50
59
  "curl -fsSL https://claude.ai/install.sh | bash",
@@ -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,6 +13,7 @@ from agentshim.core.profile import (
13
13
  OutputSchemaStyle,
14
14
  ProviderProfile,
15
15
  SchemaDialect,
16
+ SkillSignal,
16
17
  )
17
18
 
18
19
  from ._toml import toml_array, toml_str, unescape_toml
@@ -75,6 +76,9 @@ PROFILE = ProviderProfile(
75
76
  ),
76
77
  auth_env_vars=("OPENAI_API_KEY", "OPENAI_BASE_URL"),
77
78
  skill_dirs=(".agents/skills",),
79
+ # ``exec --json`` lists no skills; a load is inferred from a shell read
80
+ # of a ``SKILL.md`` (``skills.py``).
81
+ skill_invocation=SkillSignal.INFERRED,
78
82
  container_install=(_NODE_INSTALL, _CODEX_INSTALL),
79
83
  # Documented Codex CLI variable that relocates ~/.codex (``codex --help``:
80
84
  # "Layer $CODEX_HOME/<name>.config.toml on top of the base user config";
@@ -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,27 @@
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
+ ]
@@ -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.8.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