generic-ml-wrapper 0.1.0__py3-none-any.whl

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 (107) hide show
  1. generic_ml_wrapper/__init__.py +13 -0
  2. generic_ml_wrapper/adapter/__init__.py +3 -0
  3. generic_ml_wrapper/adapter/inbound/__init__.py +3 -0
  4. generic_ml_wrapper/adapter/inbound/cli/__init__.py +3 -0
  5. generic_ml_wrapper/adapter/inbound/cli/app.py +369 -0
  6. generic_ml_wrapper/adapter/inbound/cli/banner.py +30 -0
  7. generic_ml_wrapper/adapter/outbound/__init__.py +3 -0
  8. generic_ml_wrapper/adapter/outbound/bootstrap/__init__.py +3 -0
  9. generic_ml_wrapper/adapter/outbound/bootstrap/filesystem_layout_seeder.py +104 -0
  10. generic_ml_wrapper/adapter/outbound/caller/__init__.py +3 -0
  11. generic_ml_wrapper/adapter/outbound/caller/claude_cli_caller.py +158 -0
  12. generic_ml_wrapper/adapter/outbound/caller/codex_cli_caller.py +165 -0
  13. generic_ml_wrapper/adapter/outbound/caller/context_file.py +44 -0
  14. generic_ml_wrapper/adapter/outbound/caller/context_opening.py +27 -0
  15. generic_ml_wrapper/adapter/outbound/caller/cursor_cli_caller.py +98 -0
  16. generic_ml_wrapper/adapter/outbound/caller/default_provider.py +79 -0
  17. generic_ml_wrapper/adapter/outbound/caller/loader.py +34 -0
  18. generic_ml_wrapper/adapter/outbound/caller/status_line_config.py +142 -0
  19. generic_ml_wrapper/adapter/outbound/caller/vibe_cli_caller.py +169 -0
  20. generic_ml_wrapper/adapter/outbound/caller/vibe_config.py +128 -0
  21. generic_ml_wrapper/adapter/outbound/credentials/__init__.py +3 -0
  22. generic_ml_wrapper/adapter/outbound/credentials/filesystem_credentials_store.py +130 -0
  23. generic_ml_wrapper/adapter/outbound/gateway/__init__.py +3 -0
  24. generic_ml_wrapper/adapter/outbound/gateway/anthropic_sse.py +148 -0
  25. generic_ml_wrapper/adapter/outbound/gateway/openai_chat.py +112 -0
  26. generic_ml_wrapper/adapter/outbound/gateway/openai_responses.py +85 -0
  27. generic_ml_wrapper/adapter/outbound/gateway/relay.py +402 -0
  28. generic_ml_wrapper/adapter/outbound/interceptor/__init__.py +3 -0
  29. generic_ml_wrapper/adapter/outbound/interceptor/compressor.py +107 -0
  30. generic_ml_wrapper/adapter/outbound/interceptor/size_logger.py +32 -0
  31. generic_ml_wrapper/adapter/outbound/status/__init__.py +3 -0
  32. generic_ml_wrapper/adapter/outbound/status/claude_status_parser.py +76 -0
  33. generic_ml_wrapper/adapter/outbound/status/cursor_status_parser.py +71 -0
  34. generic_ml_wrapper/adapter/outbound/store/__init__.py +3 -0
  35. generic_ml_wrapper/adapter/outbound/store/filesystem_transcript_store.py +65 -0
  36. generic_ml_wrapper/adapter/outbound/store/ledger.py +104 -0
  37. generic_ml_wrapper/adapter/outbound/store/sqlite_per_turn_store.py +68 -0
  38. generic_ml_wrapper/adapter/outbound/store/sqlite_session_store.py +73 -0
  39. generic_ml_wrapper/adapter/outbound/store/sqlite_usage_store.py +44 -0
  40. generic_ml_wrapper/adapter/outbound/workflow/__init__.py +3 -0
  41. generic_ml_wrapper/adapter/outbound/workflow/filesystem_workflow_source.py +165 -0
  42. generic_ml_wrapper/adapter/outbound/workspace/__init__.py +3 -0
  43. generic_ml_wrapper/adapter/outbound/workspace/local_workspace_inspector.py +75 -0
  44. generic_ml_wrapper/application/__init__.py +3 -0
  45. generic_ml_wrapper/application/domain/__init__.py +3 -0
  46. generic_ml_wrapper/application/domain/model/__init__.py +3 -0
  47. generic_ml_wrapper/application/domain/model/client_status.py +44 -0
  48. generic_ml_wrapper/application/domain/model/identifiers.py +76 -0
  49. generic_ml_wrapper/application/domain/model/run.py +35 -0
  50. generic_ml_wrapper/application/domain/model/session.py +24 -0
  51. generic_ml_wrapper/application/domain/model/turn_usage.py +62 -0
  52. generic_ml_wrapper/application/domain/model/workspace.py +31 -0
  53. generic_ml_wrapper/application/domain/service/__init__.py +3 -0
  54. generic_ml_wrapper/application/domain/service/interceptor.py +30 -0
  55. generic_ml_wrapper/application/domain/service/interceptor_chain.py +57 -0
  56. generic_ml_wrapper/application/domain/service/rule_cleaner.py +35 -0
  57. generic_ml_wrapper/application/domain/service/session_naming.py +30 -0
  58. generic_ml_wrapper/application/domain/service/statusline_renderer.py +81 -0
  59. generic_ml_wrapper/application/port/__init__.py +3 -0
  60. generic_ml_wrapper/application/port/inbound/__init__.py +3 -0
  61. generic_ml_wrapper/application/port/inbound/bootstrap.py +15 -0
  62. generic_ml_wrapper/application/port/inbound/export_usage.py +109 -0
  63. generic_ml_wrapper/application/port/inbound/list_jobs.py +33 -0
  64. generic_ml_wrapper/application/port/inbound/list_sessions.py +36 -0
  65. generic_ml_wrapper/application/port/inbound/list_workflows.py +19 -0
  66. generic_ml_wrapper/application/port/inbound/new_workflow.py +48 -0
  67. generic_ml_wrapper/application/port/inbound/render_statusline.py +24 -0
  68. generic_ml_wrapper/application/port/inbound/set_credential.py +35 -0
  69. generic_ml_wrapper/application/port/inbound/start_job.py +53 -0
  70. generic_ml_wrapper/application/port/outbound/__init__.py +3 -0
  71. generic_ml_wrapper/application/port/outbound/cli_caller.py +97 -0
  72. generic_ml_wrapper/application/port/outbound/client_status.py +27 -0
  73. generic_ml_wrapper/application/port/outbound/credentials_store.py +36 -0
  74. generic_ml_wrapper/application/port/outbound/interceptor.py +25 -0
  75. generic_ml_wrapper/application/port/outbound/layout_seeder.py +18 -0
  76. generic_ml_wrapper/application/port/outbound/per_turn_metering.py +38 -0
  77. generic_ml_wrapper/application/port/outbound/session_store.py +62 -0
  78. generic_ml_wrapper/application/port/outbound/transcript.py +46 -0
  79. generic_ml_wrapper/application/port/outbound/usage_store.py +32 -0
  80. generic_ml_wrapper/application/port/outbound/workflow_source.py +58 -0
  81. generic_ml_wrapper/application/port/outbound/workspace.py +27 -0
  82. generic_ml_wrapper/application/usecase/__init__.py +3 -0
  83. generic_ml_wrapper/application/usecase/bootstrap.py +24 -0
  84. generic_ml_wrapper/application/usecase/export_usage.py +93 -0
  85. generic_ml_wrapper/application/usecase/list_jobs.py +31 -0
  86. generic_ml_wrapper/application/usecase/list_sessions.py +34 -0
  87. generic_ml_wrapper/application/usecase/list_workflows.py +28 -0
  88. generic_ml_wrapper/application/usecase/new_workflow.py +109 -0
  89. generic_ml_wrapper/application/usecase/render_statusline.py +117 -0
  90. generic_ml_wrapper/application/usecase/set_credential.py +27 -0
  91. generic_ml_wrapper/application/usecase/start_job.py +125 -0
  92. generic_ml_wrapper/application/wiring/__init__.py +3 -0
  93. generic_ml_wrapper/application/wiring/composition.py +230 -0
  94. generic_ml_wrapper/common/__init__.py +3 -0
  95. generic_ml_wrapper/common/config.py +179 -0
  96. generic_ml_wrapper/common/log.py +83 -0
  97. generic_ml_wrapper/common/paths.py +25 -0
  98. generic_ml_wrapper/common/spec_loader.py +67 -0
  99. generic_ml_wrapper/py.typed +0 -0
  100. generic_ml_wrapper/resources/workflows/_common/base.md +25 -0
  101. generic_ml_wrapper/resources/workflows/create-workflow/workflow.md +35 -0
  102. generic_ml_wrapper-0.1.0.dist-info/METADATA +172 -0
  103. generic_ml_wrapper-0.1.0.dist-info/RECORD +107 -0
  104. generic_ml_wrapper-0.1.0.dist-info/WHEEL +4 -0
  105. generic_ml_wrapper-0.1.0.dist-info/entry_points.txt +2 -0
  106. generic_ml_wrapper-0.1.0.dist-info/licenses/LICENSE +201 -0
  107. generic_ml_wrapper-0.1.0.dist-info/licenses/NOTICE +8 -0
@@ -0,0 +1,158 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """``ClaudeCliCaller``: launch Claude Code, install the status line, meter the run."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ from generic_ml_wrapper.adapter.outbound.caller import context_file, status_line_config
13
+ from generic_ml_wrapper.adapter.outbound.caller.status_line_config import StatusLineSnapshot
14
+ from generic_ml_wrapper.adapter.outbound.gateway.relay import MeteringRelay
15
+ from generic_ml_wrapper.application.domain.model.run import RunContext
16
+ from generic_ml_wrapper.application.port.outbound.cli_caller import CliCaller
17
+ from generic_ml_wrapper.common.log import log
18
+
19
+ if TYPE_CHECKING:
20
+ from generic_ml_wrapper.application.domain.service.interceptor_chain import InterceptorChain
21
+ from generic_ml_wrapper.application.port.outbound.per_turn_metering import PerTurnMeteringPort
22
+ from generic_ml_wrapper.application.port.outbound.transcript import TranscriptPort
23
+
24
+ BINARY = "claude"
25
+ _SETTINGS = Path.home() / ".claude" / "settings.json"
26
+ _STATUSLINE: dict[str, object] = {
27
+ "type": "command",
28
+ "command": status_line_config.statusline_command(),
29
+ "padding": 0,
30
+ }
31
+
32
+
33
+ class ClaudeCliCaller(CliCaller):
34
+ """Launch Claude Code for a run, metered via the status line and a local relay.
35
+
36
+ ``start_metering`` points Claude Code's ``statusLine`` at ``gmlw statusline`` and
37
+ stands up a :class:`MeteringRelay`, pointing Claude at it via ``ANTHROPIC_BASE_URL``
38
+ to record per-turn tokens; ``end_metering`` tears both down. If the relay cannot
39
+ start, the launch falls back to unmetered (status line only). A new session may
40
+ carry injected ``context`` (Claude's system prompt), an opening ``kickoff``, and a
41
+ ``cwd``; the run's identity is exported as ``GMLW_JOB`` / ``GMLW_SESSION`` /
42
+ ``GMLW_CLIENT``.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ run: RunContext,
48
+ metering: PerTurnMeteringPort,
49
+ interceptors: InterceptorChain | None = None,
50
+ transcript: TranscriptPort | None = None,
51
+ ) -> None:
52
+ """Bind the caller to a run, its metering store, and the interceptor chain.
53
+
54
+ Args:
55
+ run: The run this caller will launch and meter.
56
+ metering: Where the relay records per-turn usage.
57
+ interceptors: The interceptor chain the relay applies to wire traffic.
58
+ transcript: Where the relay records each call's transcript, or ``None``.
59
+ """
60
+ super().__init__(run)
61
+ self._metering = metering
62
+ self._interceptors = interceptors
63
+ self._transcript = transcript
64
+ self._snapshot: StatusLineSnapshot | None = None
65
+ self._relay: MeteringRelay | None = None
66
+
67
+ def can_deliver_statusline(self) -> bool:
68
+ """Claude Code hosts a command-backed status line the wrapper renders into."""
69
+ return True
70
+
71
+ def can_meter_per_call(self) -> bool:
72
+ """This caller records per-turn usage via its metering relay."""
73
+ return True
74
+
75
+ def start_metering(self) -> None:
76
+ """Install the status line and start the metering relay for this session."""
77
+ if self.can_deliver_statusline():
78
+ self._snapshot = status_line_config.install(_SETTINGS, _STATUSLINE)
79
+ relay = MeteringRelay(
80
+ job=self.run.job,
81
+ session=self.run.session_id,
82
+ metering=self._metering,
83
+ client=self.run.client,
84
+ transcript=self._transcript,
85
+ interceptors=self._interceptors,
86
+ )
87
+ try:
88
+ relay.start()
89
+ except OSError as error:
90
+ log.warning(f"metering relay failed to start ({error}); launching unmetered")
91
+ return
92
+ self._relay = relay
93
+
94
+ def end_metering(self) -> None:
95
+ """Stop the metering relay and restore the status line."""
96
+ if self._relay is not None:
97
+ self._relay.stop()
98
+ self._relay = None
99
+ if self._snapshot is not None:
100
+ status_line_config.restore(_SETTINGS, self._snapshot)
101
+
102
+ def command(self, context_file: str | None = None) -> list[str]:
103
+ """Build the ``claude`` command line for this run.
104
+
105
+ Args:
106
+ context_file: A file whose contents to append to the system prompt, or
107
+ ``None`` (only used for a new session).
108
+
109
+ Returns:
110
+ The argv list to execute.
111
+ """
112
+ run = self.run
113
+ if run.resume:
114
+ argv = [BINARY, "--resume", run.uuid or run.session_id]
115
+ else:
116
+ argv = [BINARY, "-n", run.session_id]
117
+ if run.uuid is not None:
118
+ argv += ["--session-id", run.uuid]
119
+ if context_file is not None:
120
+ argv += ["--append-system-prompt-file", context_file]
121
+ if run.kickoff is not None:
122
+ argv.append(run.kickoff)
123
+ return argv
124
+
125
+ def start_client(self) -> int:
126
+ """Launch Claude, blocking until it exits.
127
+
128
+ Injected context (new sessions only) is persisted per session and passed via
129
+ ``--append-system-prompt-file`` -- a durable provenance artifact.
130
+
131
+ Returns:
132
+ The client's exit code.
133
+ """
134
+ if self.run.context is not None and not self.run.resume:
135
+ path = context_file.write(self.run.job, self.run.session_id, self.run.context)
136
+ return self._run(self.command(str(path)))
137
+ return self._run(self.command())
138
+
139
+ def _extra_env(self) -> dict[str, str]:
140
+ """Point Claude at the relay when it is running.
141
+
142
+ Returns:
143
+ ``ANTHROPIC_BASE_URL`` for the relay, or empty when it isn't running.
144
+ """
145
+ return {} if self._relay is None else {"ANTHROPIC_BASE_URL": self._relay.base_url}
146
+
147
+ def _run(self, argv: list[str]) -> int:
148
+ env = {
149
+ **os.environ,
150
+ **dict(self.run.env),
151
+ **self._extra_env(),
152
+ "GMLW_JOB": self.run.job,
153
+ "GMLW_SESSION": self.run.session_id,
154
+ "GMLW_CLIENT": self.run.client,
155
+ }
156
+ # Trusted argv from our resolved run; no shell. The program is PATH-resolved (BINARY).
157
+ completed = subprocess.run(argv, check=False, cwd=self.run.cwd, env=env) # noqa: S603
158
+ return completed.returncode
@@ -0,0 +1,165 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Codex callers: launch codex, optionally through a per-turn metering relay."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import subprocess
9
+ from typing import TYPE_CHECKING
10
+
11
+ from generic_ml_wrapper.adapter.outbound.caller import context_file
12
+ from generic_ml_wrapper.adapter.outbound.caller.context_opening import read_first_opening
13
+ from generic_ml_wrapper.adapter.outbound.gateway import openai_responses
14
+ from generic_ml_wrapper.adapter.outbound.gateway.relay import MeteringRelay
15
+ from generic_ml_wrapper.application.port.outbound.cli_caller import CliCaller
16
+ from generic_ml_wrapper.common.log import log
17
+
18
+ if TYPE_CHECKING:
19
+ from generic_ml_wrapper.application.domain.model.run import RunContext
20
+ from generic_ml_wrapper.application.domain.service.interceptor_chain import InterceptorChain
21
+ from generic_ml_wrapper.application.port.outbound.per_turn_metering import PerTurnMeteringPort
22
+ from generic_ml_wrapper.application.port.outbound.transcript import TranscriptPort
23
+
24
+ BINARY = "codex"
25
+ # ChatGPT-sign-in upstream (the verified default). API-key mode would target
26
+ # api.openai.com with a /v1 prefix; a config-driven option is a later follow-up.
27
+ _UPSTREAM = "https://chatgpt.com"
28
+ _UPSTREAM_PREFIX = "/backend-api/codex"
29
+
30
+
31
+ class CodexCliCaller(CliCaller):
32
+ """Launch codex for a run, routed through a per-turn metering relay.
33
+
34
+ Codex has no status-line hook, so none is installed. It takes its operating
35
+ context via a "read this file first" opening message (it has no system-prompt
36
+ flag). ``start_metering`` stands up a relay pointed at the ChatGPT-Codex backend
37
+ and ``_provider_flags`` adds the ``model_providers`` overrides pointing codex at
38
+ it; if the relay cannot start, codex launches unmetered.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ run: RunContext,
44
+ metering: PerTurnMeteringPort,
45
+ interceptors: InterceptorChain | None = None,
46
+ transcript: TranscriptPort | None = None,
47
+ ) -> None:
48
+ """Bind the caller to a run, its metering store, and the interceptor chain.
49
+
50
+ Args:
51
+ run: The run this caller will launch and meter.
52
+ metering: Where the relay records per-turn usage.
53
+ interceptors: The interceptor chain the relay applies to wire traffic.
54
+ transcript: Where the relay records each call's transcript, or ``None``.
55
+ """
56
+ super().__init__(run)
57
+ self._metering = metering
58
+ self._interceptors = interceptors
59
+ self._transcript = transcript
60
+ self._relay: MeteringRelay | None = None
61
+
62
+ def can_resume(self) -> bool:
63
+ """Codex mints a fresh conversation and exposes no session id to resume.
64
+
65
+ Resuming would require scanning ``~/.codex`` for the local rollout, which
66
+ the wrapper deliberately does not do; ``--resume-latest`` is refused for
67
+ codex rather than silently starting a new session.
68
+ """
69
+ return False
70
+
71
+ def can_meter_per_call(self) -> bool:
72
+ """This caller records per-turn usage via its metering relay."""
73
+ return True
74
+
75
+ def start_metering(self) -> None:
76
+ """Start the Codex metering relay for this session."""
77
+ relay = MeteringRelay(
78
+ job=self.run.job,
79
+ session=self.run.session_id,
80
+ metering=self._metering,
81
+ client=self.run.client,
82
+ transcript=self._transcript,
83
+ upstream_base=_UPSTREAM,
84
+ path_map=_codex_path_map,
85
+ usage_reader=openai_responses.read_usage,
86
+ is_metered=_codex_metered,
87
+ interceptors=self._interceptors,
88
+ )
89
+ try:
90
+ relay.start()
91
+ except OSError as error:
92
+ log.warning(f"metering relay failed to start ({error}); launching codex unmetered")
93
+ return
94
+ self._relay = relay
95
+
96
+ def end_metering(self) -> None:
97
+ """Stop the metering relay."""
98
+ if self._relay is not None:
99
+ self._relay.stop()
100
+ self._relay = None
101
+
102
+ def _provider_flags(self) -> list[str]:
103
+ if self._relay is None:
104
+ return []
105
+ base_url = f"{self._relay.base_url}/v1"
106
+ return [
107
+ "-c",
108
+ 'model_providers.gml.name="gmlcache"',
109
+ "-c",
110
+ f'model_providers.gml.base_url="{base_url}"',
111
+ "-c",
112
+ 'model_providers.gml.wire_api="responses"',
113
+ "-c",
114
+ "model_providers.gml.requires_openai_auth=true",
115
+ "-c",
116
+ 'model_provider="gml"',
117
+ ]
118
+
119
+ def command(self, opening: str | None = None) -> list[str]:
120
+ """Build the ``codex`` command line for this run.
121
+
122
+ Args:
123
+ opening: The opening message to start the session on, or ``None``.
124
+
125
+ Returns:
126
+ The argv list to execute.
127
+ """
128
+ argv = [BINARY, *self._provider_flags()]
129
+ if opening is not None:
130
+ argv.append(opening)
131
+ return argv
132
+
133
+ def start_client(self) -> int:
134
+ """Launch codex, blocking until it exits.
135
+
136
+ Returns:
137
+ The client's exit code.
138
+ """
139
+ if self.run.context is not None and not self.run.resume:
140
+ path = context_file.write(self.run.job, self.run.session_id, self.run.context)
141
+ return self._run(self.command(read_first_opening(str(path), self.run.kickoff)))
142
+ return self._run(self.command(self.run.kickoff))
143
+
144
+ def _run(self, argv: list[str]) -> int:
145
+ env = {
146
+ **os.environ,
147
+ **dict(self.run.env),
148
+ "GMLW_JOB": self.run.job,
149
+ "GMLW_SESSION": self.run.session_id,
150
+ "GMLW_CLIENT": self.run.client,
151
+ }
152
+ # Trusted argv from our resolved run; no shell. The program is PATH-resolved (BINARY).
153
+ completed = subprocess.run(argv, check=False, cwd=self.run.cwd, env=env) # noqa: S603
154
+ return completed.returncode
155
+
156
+
157
+ def _codex_path_map(path: str) -> str:
158
+ """Map codex's ``/v1/x`` (its base_url ends ``/v1``) to the backend prefix."""
159
+ sub = path[len("/v1") :] if path.startswith("/v1") else path
160
+ return _UPSTREAM_PREFIX + sub
161
+
162
+
163
+ def _codex_metered(method: str, path: str) -> bool:
164
+ """A metered codex turn: ``POST`` to the Responses endpoint."""
165
+ return method == "POST" and path.split("?", 1)[0].endswith("/responses")
@@ -0,0 +1,44 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Persist a session's compiled context to a durable, inspectable file.
4
+
5
+ The exact operating context a session launches with (profile + rules + workflow) is
6
+ written per session to ``~/.gmlw/contexts/<job>/<session>.context.md`` and handed to
7
+ the client from there -- a durable provenance artifact you can inspect after the run,
8
+ instead of a temp file discarded the moment the client exits.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import tempfile
15
+ from pathlib import Path
16
+
17
+ from generic_ml_wrapper.common import paths
18
+
19
+
20
+ def write(job: str, session: str, context: str) -> Path:
21
+ """Write ``session``'s compiled ``context`` durably, returning the file path.
22
+
23
+ Args:
24
+ job: The job the session belongs to.
25
+ session: The session id (``<job>_NNN``).
26
+ context: The compiled operating context to persist and inject.
27
+
28
+ Returns:
29
+ The path to the written ``<session>.context.md`` file.
30
+ """
31
+ directory = paths.CONTEXTS / job
32
+ directory.mkdir(parents=True, exist_ok=True)
33
+ path = directory / f"{session}.context.md"
34
+ # Atomic: same-dir temp + replace, so a crash mid-write can't leave a torn artifact.
35
+ descriptor, temporary = tempfile.mkstemp(dir=directory, suffix=".tmp")
36
+ temporary_path = Path(temporary)
37
+ try:
38
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
39
+ handle.write(context)
40
+ temporary_path.replace(path)
41
+ except BaseException:
42
+ temporary_path.unlink()
43
+ raise
44
+ return path
@@ -0,0 +1,27 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Build the opening message that points a fileless-prompt agent at its context.
4
+
5
+ Clients without a system-prompt flag (cursor-agent, codex) receive their compiled
6
+ operating context by being told, in the opening message, to read a file first.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ def read_first_opening(context_path: str, kickoff: str | None = None) -> str:
13
+ """Build an opening message telling the agent to read its context file first.
14
+
15
+ Args:
16
+ context_path: The path to the compiled operating-context file.
17
+ kickoff: An opening user message to append after the instruction, or ``None``.
18
+
19
+ Returns:
20
+ The opening message.
21
+ """
22
+ preamble = (
23
+ f"Your operating context for this session is in the file:\n{context_path}\n"
24
+ "Read that file in full FIRST — it is your instructions, profile, rules, and "
25
+ "the workflow steps — then proceed as it says."
26
+ )
27
+ return f"{preamble}\n\n{kickoff}" if kickoff else preamble
@@ -0,0 +1,98 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """``CursorCliCaller``: launch cursor-agent and install its status line (no metering)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ from generic_ml_wrapper.adapter.outbound.caller import context_file, status_line_config
12
+ from generic_ml_wrapper.adapter.outbound.caller.context_opening import read_first_opening
13
+ from generic_ml_wrapper.adapter.outbound.caller.status_line_config import StatusLineSnapshot
14
+ from generic_ml_wrapper.application.domain.model.run import RunContext
15
+ from generic_ml_wrapper.application.port.outbound.cli_caller import CliCaller
16
+
17
+ BINARY = "cursor-agent"
18
+ _CONFIG = Path.home() / ".cursor" / "cli-config.json"
19
+ _STATUSLINE: dict[str, object] = {
20
+ "type": "command",
21
+ "command": status_line_config.statusline_command(),
22
+ "updateIntervalMs": 2000,
23
+ "timeoutMs": 5000,
24
+ }
25
+
26
+
27
+ class CursorCliCaller(CliCaller):
28
+ """Launch cursor-agent for a run, with the wrapper's status line, without metering.
29
+
30
+ cursor-agent hosts a command-backed status line (``~/.cursor/cli-config.json``),
31
+ so ``start_metering`` points it at ``gmlw statusline`` and ``end_metering``
32
+ restores the prior setting. Its ``--resume <name>`` both creates and resumes a
33
+ session. It has no system-prompt flag, so injected context is written to a file
34
+ the agent is told to read first. This light client does not meter usage.
35
+ """
36
+
37
+ def __init__(self, run: RunContext) -> None:
38
+ """Bind the caller to a run.
39
+
40
+ Args:
41
+ run: The run this caller will launch.
42
+ """
43
+ super().__init__(run)
44
+ self._snapshot: StatusLineSnapshot | None = None
45
+
46
+ def can_deliver_statusline(self) -> bool:
47
+ """cursor-agent hosts a command-backed status line the wrapper renders into."""
48
+ return True
49
+
50
+ def start_metering(self) -> None:
51
+ """Point cursor-agent's status line at ``gmlw statusline`` for this session."""
52
+ if self.can_deliver_statusline():
53
+ self._snapshot = status_line_config.install(_CONFIG, _STATUSLINE)
54
+
55
+ def end_metering(self) -> None:
56
+ """Restore cursor-agent's previous status-line setting."""
57
+ if self._snapshot is not None:
58
+ status_line_config.restore(_CONFIG, self._snapshot)
59
+
60
+ def command(self, opening: str | None = None) -> list[str]:
61
+ """Build the ``cursor-agent`` command line for this run.
62
+
63
+ Args:
64
+ opening: The opening message to start the session on, or ``None``.
65
+
66
+ Returns:
67
+ The argv list to execute.
68
+ """
69
+ argv = [BINARY, "--resume", self.run.session_id]
70
+ if opening is not None:
71
+ argv.append(opening)
72
+ return argv
73
+
74
+ def start_client(self) -> int:
75
+ """Launch cursor-agent, blocking until it exits.
76
+
77
+ Injected context (new sessions only) is written to a temporary file the
78
+ agent is told to read first, removed when the client exits.
79
+
80
+ Returns:
81
+ The client's exit code.
82
+ """
83
+ if self.run.context is not None and not self.run.resume:
84
+ path = context_file.write(self.run.job, self.run.session_id, self.run.context)
85
+ return self._run(self.command(read_first_opening(str(path), self.run.kickoff)))
86
+ return self._run(self.command(self.run.kickoff))
87
+
88
+ def _run(self, argv: list[str]) -> int:
89
+ env = {
90
+ **os.environ,
91
+ **dict(self.run.env),
92
+ "GMLW_JOB": self.run.job,
93
+ "GMLW_SESSION": self.run.session_id,
94
+ "GMLW_CLIENT": self.run.client,
95
+ }
96
+ # Trusted argv from our resolved run; no shell. The program is PATH-resolved (BINARY).
97
+ completed = subprocess.run(argv, check=False, cwd=self.run.cwd, env=env) # noqa: S603
98
+ return completed.returncode
@@ -0,0 +1,79 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """``DefaultCliCallerProvider``: config overrides first, then built-in callers."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ from generic_ml_wrapper.adapter.outbound.caller.claude_cli_caller import ClaudeCliCaller
10
+ from generic_ml_wrapper.adapter.outbound.caller.codex_cli_caller import CodexCliCaller
11
+ from generic_ml_wrapper.adapter.outbound.caller.cursor_cli_caller import CursorCliCaller
12
+ from generic_ml_wrapper.adapter.outbound.caller.loader import load_caller_class
13
+ from generic_ml_wrapper.adapter.outbound.caller.vibe_cli_caller import VibeCliCaller
14
+ from generic_ml_wrapper.application.port.outbound.cli_caller import CliCaller, CliCallerProvider
15
+
16
+ if TYPE_CHECKING:
17
+ from generic_ml_wrapper.application.domain.model.run import RunContext
18
+ from generic_ml_wrapper.application.domain.service.interceptor_chain import InterceptorChain
19
+ from generic_ml_wrapper.application.port.outbound.per_turn_metering import PerTurnMeteringPort
20
+ from generic_ml_wrapper.application.port.outbound.transcript import TranscriptPort
21
+
22
+
23
+ class UnsupportedClientError(ValueError):
24
+ """Raised when a run's client has no override and no built-in caller."""
25
+
26
+
27
+ class DefaultCliCallerProvider(CliCallerProvider):
28
+ """Resolve a run's caller: a config override if present, else the built-in one."""
29
+
30
+ def __init__(
31
+ self,
32
+ overrides: dict[str, str] | None = None,
33
+ *,
34
+ metering: PerTurnMeteringPort,
35
+ transcript: TranscriptPort | None = None,
36
+ interceptors: InterceptorChain | None = None,
37
+ ) -> None:
38
+ """Bind the provider to its overrides, metering store, and interceptor chain.
39
+
40
+ Args:
41
+ overrides: A client-to-spec mapping (``"module:Class"``) from config; an
42
+ override is loaded in place of the built-in caller for that client.
43
+ metering: The per-turn store the built-in gateway callers record to. Every
44
+ built-in client except cursor routes through a relay that records here.
45
+ transcript: Where the relay records each call's transcript, or ``None``.
46
+ interceptors: The interceptor chain the relay applies to wire traffic.
47
+ """
48
+ self._overrides = overrides or {}
49
+ self._metering = metering
50
+ self._transcript = transcript
51
+ self._interceptors = interceptors
52
+
53
+ def for_run(self, run: RunContext) -> CliCaller:
54
+ """Return the caller for the run's client.
55
+
56
+ Args:
57
+ run: The run to launch.
58
+
59
+ Returns:
60
+ A caller bound to the run — an overridden one when configured, else the
61
+ built-in caller (every built-in but cursor routes through the relay).
62
+
63
+ Raises:
64
+ UnsupportedClientError: If the client has neither an override nor a
65
+ built-in caller.
66
+ """
67
+ spec = self._overrides.get(run.client)
68
+ if spec is not None:
69
+ return load_caller_class(spec)(run)
70
+ if run.client == "claude":
71
+ return ClaudeCliCaller(run, self._metering, self._interceptors, self._transcript)
72
+ if run.client == "cursor":
73
+ return CursorCliCaller(run)
74
+ if run.client == "codex":
75
+ return CodexCliCaller(run, self._metering, self._interceptors, self._transcript)
76
+ if run.client == "vibe":
77
+ return VibeCliCaller(run, self._metering, self._interceptors, self._transcript)
78
+ message = f"unsupported client: {run.client!r}"
79
+ raise UnsupportedClientError(message)
@@ -0,0 +1,34 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Load an external ``CliCaller`` class from a config spec, at runtime."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from generic_ml_wrapper.application.port.outbound.cli_caller import CliCaller
8
+ from generic_ml_wrapper.common.spec_loader import SpecLoadError, load_class
9
+
10
+
11
+ class CallerLoadError(ValueError):
12
+ """Raised when a caller spec cannot be resolved to a ``CliCaller`` subclass."""
13
+
14
+
15
+ def load_caller_class(spec: str) -> type[CliCaller]:
16
+ """Resolve a ``"module:Class"`` or ``"/path/to/file.py:Class"`` spec to a class.
17
+
18
+ The spec lets a private metering caller be plugged in via config without ever
19
+ living in this repo.
20
+
21
+ Args:
22
+ spec: The import spec, ``"<module-or-path>:<ClassName>"``.
23
+
24
+ Returns:
25
+ The referenced ``CliCaller`` subclass.
26
+
27
+ Raises:
28
+ CallerLoadError: If the spec is malformed, cannot be imported, or does not
29
+ name a ``CliCaller`` subclass.
30
+ """
31
+ try:
32
+ return load_class(spec, CliCaller)
33
+ except SpecLoadError as error:
34
+ raise CallerLoadError(str(error)) from error