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,13 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """generic-ml-wrapper: a wrapper around an ML coding CLI.
4
+
5
+ You enter it at a job, it mints a named, resumable session on the client, and
6
+ optionally drives it through a workflow. This is the package root; the walking
7
+ skeleton exposes only the version and a minimal entry point. See ``docs/DESIGN.md``
8
+ for the architecture the use cases will fill in.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The adapters: concrete implementations of the ports."""
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Inbound adapters (driving side)."""
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The command-line inbound adapter."""
@@ -0,0 +1,369 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The argparse command-line inbound adapter."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import getpass
9
+ import json
10
+ import os
11
+ import sys
12
+ from dataclasses import asdict
13
+ from datetime import UTC, datetime
14
+
15
+ from generic_ml_wrapper.adapter.inbound.cli.banner import banner
16
+ from generic_ml_wrapper.adapter.outbound.caller.status_line_config import SettingsUnreadableError
17
+ from generic_ml_wrapper.adapter.outbound.credentials.filesystem_credentials_store import (
18
+ CredentialsUnreadableError,
19
+ )
20
+ from generic_ml_wrapper.application.domain.model.identifiers import (
21
+ EnvVarName,
22
+ IdentifierError,
23
+ JobId,
24
+ WorkflowName,
25
+ )
26
+ from generic_ml_wrapper.application.port.inbound.export_usage import UsageReport
27
+ from generic_ml_wrapper.application.port.inbound.list_jobs import JobSummary
28
+ from generic_ml_wrapper.application.port.inbound.list_sessions import SessionSummary
29
+ from generic_ml_wrapper.application.port.inbound.new_workflow import (
30
+ NewWorkflowCommand,
31
+ WorkflowExistsError,
32
+ WorkflowNameError,
33
+ )
34
+ from generic_ml_wrapper.application.port.inbound.set_credential import SetCredentialCommand
35
+ from generic_ml_wrapper.application.port.inbound.start_job import (
36
+ ResumeNotSupportedError,
37
+ StartJobCommand,
38
+ UnknownWorkflowError,
39
+ )
40
+ from generic_ml_wrapper.application.wiring.composition import (
41
+ build_bootstrap,
42
+ build_export_usage,
43
+ build_list_jobs,
44
+ build_list_sessions,
45
+ build_list_workflows,
46
+ build_new_workflow,
47
+ build_render_statusline,
48
+ build_set_credential,
49
+ build_start_job,
50
+ )
51
+ from generic_ml_wrapper.common import config
52
+ from generic_ml_wrapper.common.log import configure as configure_logging
53
+ from generic_ml_wrapper.common.spec_loader import SpecLoadError
54
+
55
+
56
+ def _add_json_flag(parser: argparse.ArgumentParser) -> None:
57
+ """Add the shared ``--json`` flag to a read command's parser."""
58
+ parser.add_argument("--json", action="store_true", help="output as JSON instead of text")
59
+
60
+
61
+ def _as_json(payload: object) -> str:
62
+ """Render a payload as pretty-printed JSON (no trailing newline)."""
63
+ return json.dumps(payload, indent=2)
64
+
65
+
66
+ def build_parser() -> argparse.ArgumentParser:
67
+ """Build the top-level argument parser.
68
+
69
+ Returns:
70
+ The configured parser.
71
+ """
72
+ parser = argparse.ArgumentParser(
73
+ prog="gmlw",
74
+ description=banner(),
75
+ formatter_class=argparse.RawDescriptionHelpFormatter,
76
+ )
77
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
78
+
79
+ start = sub.add_parser("start", help="start or resume a session on a job")
80
+ start.add_argument("job", help="the job identifier")
81
+ start.add_argument(
82
+ "--client",
83
+ default=None,
84
+ help="which client to wrap (default: the configured default, or claude)",
85
+ )
86
+ start.add_argument(
87
+ "--resume-latest",
88
+ action="store_true",
89
+ help="resume the job's most recent session",
90
+ )
91
+ start.add_argument(
92
+ "--workflow",
93
+ "-w",
94
+ default=None,
95
+ help="run a workflow on the job (see: gmlw workflow list)",
96
+ )
97
+
98
+ jobs = sub.add_parser("jobs", help="list the jobs with recorded activity")
99
+ _add_json_flag(jobs)
100
+
101
+ sessions = sub.add_parser("sessions", help="list a job's sessions")
102
+ sessions.add_argument("job", help="the job identifier")
103
+ _add_json_flag(sessions)
104
+
105
+ export = sub.add_parser("export", help="report a job's recorded usage")
106
+ export.add_argument("job", help="the job identifier")
107
+ _add_json_flag(export)
108
+
109
+ sub.add_parser("statusline", help="render the status line (called by the client)")
110
+
111
+ workflow = sub.add_parser("workflow", help="author/list workflows")
112
+ workflow_sub = workflow.add_subparsers(dest="workflow_command", metavar="<action>")
113
+ new = workflow_sub.add_parser("new", help="author a new workflow (no job)")
114
+ new.add_argument("name", help="the new workflow's name")
115
+ new.add_argument(
116
+ "--client",
117
+ default=None,
118
+ help="which client to wrap (default: the configured default, or claude)",
119
+ )
120
+ workflow_list = workflow_sub.add_parser("list", help="list the runnable workflows")
121
+ _add_json_flag(workflow_list)
122
+
123
+ creds = sub.add_parser("creds", help="manage per-workflow credentials")
124
+ creds_sub = creds.add_subparsers(dest="creds_command", metavar="<action>")
125
+ creds_set = creds_sub.add_parser("set", help="store a workflow credential")
126
+ creds_set.add_argument("workflow", help="the workflow the credential belongs to")
127
+ creds_set.add_argument("name", help="the environment-variable name to export at launch")
128
+ return parser
129
+
130
+
131
+ def format_jobs(summaries: list[JobSummary]) -> str:
132
+ """Render the job summaries as human-readable lines.
133
+
134
+ Args:
135
+ summaries: The job summaries to render.
136
+
137
+ Returns:
138
+ The text to print (no trailing newline).
139
+ """
140
+ if not summaries:
141
+ return "No jobs yet. Start one with: gmlw start <job>"
142
+ lines = [f"{len(summaries)} job(s):", ""]
143
+ width = max(len(summary.job) for summary in summaries)
144
+ lines += [
145
+ f" {summary.job:<{width}} {summary.session_count} session(s)" for summary in summaries
146
+ ]
147
+ return "\n".join(lines)
148
+
149
+
150
+ def format_sessions(job: str, sessions: list[SessionSummary]) -> str:
151
+ """Render a job's sessions as human-readable lines.
152
+
153
+ Args:
154
+ job: The job the sessions belong to.
155
+ sessions: The session summaries to render.
156
+
157
+ Returns:
158
+ The text to print (no trailing newline).
159
+ """
160
+ if not sessions:
161
+ return f"No sessions for {job!r}. Start one with: gmlw start {job}"
162
+ lines = [f"{job} — {len(sessions)} session(s):", ""]
163
+ width = max(len(session.session_id) for session in sessions)
164
+ lines += [f" {session.session_id:<{width}} {session.client}" for session in sessions]
165
+ return "\n".join(lines)
166
+
167
+
168
+ def format_usage(report: UsageReport) -> str:
169
+ """Render a job's usage report: per-turn rows, totals by model, cost, and totals.
170
+
171
+ Args:
172
+ report: The usage report to render.
173
+
174
+ Returns:
175
+ The text to print (no trailing newline).
176
+ """
177
+ if report.turn_count == 0 and not report.session_costs:
178
+ return f"No usage recorded for {report.job!r} yet."
179
+ width = max(
180
+ (len(model.model) for model in report.models),
181
+ default=len(_UNKNOWN_LABEL),
182
+ )
183
+ lines = [f"{report.job} — usage ({report.turn_count} turn(s))", ""]
184
+ for turn in report.turns:
185
+ lines.append(
186
+ f" {_clock(turn.timestamp)} {turn.model:<{width}} {turn.duration_s:>5.1f}s"
187
+ f"{_tokens(turn.input_tokens, turn.output_tokens, turn.cache_tokens)}"
188
+ f" · [{turn.turn_id or '-'}]"
189
+ )
190
+ if report.models:
191
+ lines += ["", " ── totals by model ──"]
192
+ lines += [
193
+ f" {model.model:<{width}} {model.calls:>3} call(s)"
194
+ f"{_tokens(model.input_tokens, model.output_tokens, model.cache_tokens)}"
195
+ f" {model.duration_s:.1f}s"
196
+ for model in report.models
197
+ ]
198
+ if report.session_costs:
199
+ lines += ["", " ── cost by session ──"]
200
+ lines += [f" {cost.session_id} ${cost.cost_usd:.2f}" for cost in report.session_costs]
201
+ lines += [
202
+ "",
203
+ f" ── total ── {report.turn_count} turn(s)"
204
+ f"{_tokens(report.input_tokens, report.output_tokens, report.cache_tokens)}"
205
+ f" {report.duration_s:.1f}s ${report.total_usd:.2f}",
206
+ ]
207
+ return "\n".join(lines)
208
+
209
+
210
+ _UNKNOWN_LABEL = "(unknown)"
211
+
212
+
213
+ def _clock(timestamp: float) -> str:
214
+ """Render an epoch timestamp as a local ``HH:MM:SS``, or a dash when unset."""
215
+ if timestamp <= 0:
216
+ return "--:--:--"
217
+ return datetime.fromtimestamp(timestamp, tz=UTC).astimezone().strftime("%H:%M:%S")
218
+
219
+
220
+ def _tokens(input_tokens: int, output_tokens: int, cache_tokens: int) -> str:
221
+ """Render a token triple as `` <in>(+<cache> cache)+<out> tok`` for the report."""
222
+ cache = f"(+{cache_tokens} cache)" if cache_tokens else ""
223
+ return f" {input_tokens}{cache}+{output_tokens} tok"
224
+
225
+
226
+ def format_workflows(names: list[str]) -> str:
227
+ """Render the runnable workflow names as human-readable lines.
228
+
229
+ Args:
230
+ names: The workflow names to render.
231
+
232
+ Returns:
233
+ The text to print (no trailing newline).
234
+ """
235
+ if not names:
236
+ return "No workflows yet. Author one with: gmlw workflow new <name>"
237
+ lines = [f"{len(names)} workflow(s):", ""]
238
+ lines += [f" {name}" for name in names]
239
+ return "\n".join(lines)
240
+
241
+
242
+ def main(argv: list[str] | None = None) -> int:
243
+ """Run the CLI.
244
+
245
+ Args:
246
+ argv: Arguments to parse; defaults to ``sys.argv[1:]``.
247
+
248
+ Returns:
249
+ The process exit code.
250
+ """
251
+ parser = build_parser()
252
+ args = parser.parse_args(argv)
253
+ configure_logging(os.environ.get("GMLW_LOG_LEVEL") or config.log_level())
254
+ # Self-initialize on a real command; skip the statusline hot path and bare help.
255
+ if args.command not in (None, "statusline"):
256
+ build_bootstrap().execute()
257
+ try:
258
+ if args.command == "start":
259
+ return _start(args)
260
+ if args.command == "statusline":
261
+ return _statusline()
262
+ if args.command == "workflow":
263
+ return _workflow(args)
264
+ if args.command == "creds":
265
+ return _creds(args)
266
+ view = _view(args) # the print-and-exit-0 commands (jobs, sessions, export)
267
+ except (
268
+ IdentifierError,
269
+ SettingsUnreadableError,
270
+ CredentialsUnreadableError,
271
+ SpecLoadError,
272
+ ) as error:
273
+ print(f"error: {error}", file=sys.stderr)
274
+ return 2
275
+ if view is None:
276
+ parser.print_help()
277
+ else:
278
+ print(view)
279
+ return 0
280
+
281
+
282
+ def _view(args: argparse.Namespace) -> str | None:
283
+ """Render a read-only command's output, or ``None`` if it isn't one."""
284
+ as_json = bool(getattr(args, "json", False))
285
+ if args.command == "jobs":
286
+ summaries = build_list_jobs().execute()
287
+ return _as_json([asdict(s) for s in summaries]) if as_json else format_jobs(summaries)
288
+ if args.command == "sessions":
289
+ job = JobId(args.job)
290
+ sessions = build_list_sessions().execute(job)
291
+ if as_json:
292
+ return _as_json([asdict(s) for s in sessions])
293
+ return format_sessions(job, sessions)
294
+ if args.command == "export":
295
+ job = JobId(args.job)
296
+ report = build_export_usage().execute(job)
297
+ return _as_json(asdict(report)) if as_json else format_usage(report)
298
+ return None
299
+
300
+
301
+ def _client(raw: str | None) -> str:
302
+ """Resolve the client to wrap: the explicit ``--client``, else the config default."""
303
+ return raw if raw else config.default_client()
304
+
305
+
306
+ _MAX_STATUSLINE_BYTES = 1_000_000 # a client's status payload is small JSON; cap the read
307
+
308
+
309
+ def _statusline() -> int:
310
+ payload = "" if sys.stdin.isatty() else sys.stdin.read(_MAX_STATUSLINE_BYTES)
311
+ # The launching caller exports GMLW_CLIENT so the status line parses with the
312
+ # right client's parser (claude's quota vs cursor's plan block).
313
+ line = build_render_statusline(os.environ.get("GMLW_CLIENT")).execute(
314
+ payload,
315
+ os.environ.get("GMLW_JOB"),
316
+ os.environ.get("GMLW_SESSION"),
317
+ )
318
+ print(line)
319
+ return 0
320
+
321
+
322
+ def _start(args: argparse.Namespace) -> int:
323
+ workflow = None if args.workflow is None else str(args.workflow)
324
+ command = StartJobCommand(
325
+ job=JobId(args.job),
326
+ client=_client(args.client),
327
+ resume_latest=bool(args.resume_latest),
328
+ workflow=workflow,
329
+ )
330
+ try:
331
+ return build_start_job().execute(command)
332
+ except (UnknownWorkflowError, ResumeNotSupportedError) as error:
333
+ print(f"error: {error}")
334
+ return 2
335
+
336
+
337
+ def _read_secret() -> str:
338
+ """Read a secret value: a secure prompt at a TTY, else one line from stdin."""
339
+ if sys.stdin.isatty():
340
+ return getpass.getpass("value: ")
341
+ return sys.stdin.readline().rstrip("\n")
342
+
343
+
344
+ def _creds(args: argparse.Namespace) -> int:
345
+ if args.creds_command == "set":
346
+ workflow = WorkflowName(args.workflow)
347
+ name = EnvVarName(args.name)
348
+ build_set_credential().execute(
349
+ SetCredentialCommand(workflow=workflow, name=name, value=_read_secret())
350
+ )
351
+ print(f"stored {workflow}.{name}")
352
+ return 0
353
+ return 0
354
+
355
+
356
+ def _workflow(args: argparse.Namespace) -> int:
357
+ if args.workflow_command == "new":
358
+ try:
359
+ return build_new_workflow().execute(
360
+ NewWorkflowCommand(name=str(args.name), client=_client(args.client))
361
+ )
362
+ except (WorkflowNameError, WorkflowExistsError) as error:
363
+ print(f"error: {error}")
364
+ return 2
365
+ if args.workflow_command == "list":
366
+ names = build_list_workflows().execute()
367
+ print(_as_json(names) if bool(args.json) else format_workflows(names))
368
+ return 0
369
+ return 0
@@ -0,0 +1,30 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The banner shown atop ``gmlw --help``."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import sys
8
+
9
+ _TITLE = "gmlw"
10
+ _TAGLINE = "a wrapper around an ML coding CLI"
11
+ _SUBTITLE = "enter at a job · one resumable, metered session · optional workflow"
12
+
13
+ _BOLD_CYAN = "\033[1;36m"
14
+ _DIM = "\033[2m"
15
+ _RESET = "\033[0m"
16
+
17
+
18
+ def banner() -> str:
19
+ """Render the help banner, colored only when stdout is a terminal.
20
+
21
+ Returns:
22
+ The two-line banner (title + subtitle), no trailing newline.
23
+ """
24
+ if sys.stdout.isatty():
25
+ title = f"{_BOLD_CYAN}{_TITLE}{_RESET} {_DIM}· {_TAGLINE}{_RESET}"
26
+ subtitle = f"{_DIM}{_SUBTITLE}{_RESET}"
27
+ else:
28
+ title = f"{_TITLE} · {_TAGLINE}"
29
+ subtitle = _SUBTITLE
30
+ return f"{title}\n{subtitle}"
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Outbound adapters (driven side)."""
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Runtime self-initialization adapters."""
@@ -0,0 +1,104 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Filesystem ``LayoutSeederPort``: create ``~/.gmlw`` dirs and a commented config."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ from generic_ml_wrapper.application.port.outbound.layout_seeder import LayoutSeederPort
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+ _DIRS = ("profile/me", "profile/company", "rules")
15
+ _CONFIG = "config.toml"
16
+
17
+ # Seeded once when no config exists. Every real setting is commented out, so the
18
+ # file parses to nothing and the wrapper keeps its built-in defaults until edited.
19
+ _CONFIG_TEMPLATE = """\
20
+ # gmlw configuration. Every setting is optional; delete this file to fall back to
21
+ # the built-in defaults. Uncomment and edit what you need.
22
+
23
+ [client]
24
+ # The client to wrap when --client is not given.
25
+ # Built-in clients: claude | cursor | codex | vibe.
26
+ # default = "claude"
27
+
28
+ # SECURITY: the [callers] and [[interceptors]] specs below name Python code that gmlw
29
+ # LOADS AND RUNS with your permissions on the next invocation. Only point them at code
30
+ # you wrote or trust -- this file is a trusted-code boundary.
31
+
32
+ [callers]
33
+ # Per-client caller overrides: map a client to an importable "module:Class" or
34
+ # "/path/to/file.py:Class" spec, loaded at runtime in place of the built-in caller.
35
+ # (This is how a private, uncommitted metering caller is plugged in.)
36
+ # cursor = "/path/to/my_cursor_caller.py:CursorCaller"
37
+
38
+ [logging]
39
+ # Diagnostic verbosity on stderr: debug | info | warning | error.
40
+ # level = "warning"
41
+
42
+ [transcript]
43
+ # Persist the request, response, and usage of each metered call under
44
+ # transcripts/<job>/<session>/ as a portable per-call trio (call_NNN.in.json /
45
+ # .out.sse / .usage.json). Default: OFF. It contains your prompts and the model's
46
+ # replies -- a local data-at-rest surface you own; nothing manages retention.
47
+ # enabled = true
48
+ # root = "/some/dir" # optional; defaults to ~/.gmlw/transcripts
49
+
50
+ # Interceptors (0..N, ordered), each a str->str transform (InterceptorPort) bound to
51
+ # a target. Compile-time targets: "profile" | "rules" | "workflow" | "context". Wire
52
+ # targets (metered clients only): "request" (outbound body) | "response" (captured
53
+ # reply, observe-only). A target may have many; one spec may appear under several.
54
+ # The built-in MessageSizeLogger logs each message's size — put it on request and
55
+ # response to trace sizes in and out:
56
+ # [[interceptors]]
57
+ # target = "request"
58
+ # spec = "generic_ml_wrapper.adapter.outbound.interceptor.size_logger:MessageSizeLogger"
59
+ # [[interceptors]]
60
+ # target = "response"
61
+ # spec = "generic_ml_wrapper.adapter.outbound.interceptor.size_logger:MessageSizeLogger"
62
+
63
+ # The context compressor is one such interceptor. It compresses through generic-ml-cache
64
+ # (record/replay), so compressing the same context replays for free and returns the same
65
+ # result. It is OFF until [compress] prompt names a prompt file (the prompt is your IP —
66
+ # the repo ships none). To enable it, add it as an interceptor on the "context" target:
67
+ # [[interceptors]]
68
+ # target = "context"
69
+ # spec = "generic_ml_wrapper.adapter.outbound.interceptor.compressor:CompressorInterceptor"
70
+
71
+ [compress]
72
+ # The compression prompt file (NOT shipped — it is your IP). Compression stays off
73
+ # until this points at a prompt.
74
+ # prompt = "/path/to/compress-prompt.md"
75
+ #
76
+ # We tested gpt-5.4 at low effort via the cursor adapter as the best compressor; change
77
+ # it to whatever you have (any generic-ml-cache client adapter / model / effort).
78
+ # adapter = "cursor"
79
+ # model = "gpt-5.4"
80
+ # effort = "low"
81
+ """
82
+
83
+
84
+ class FilesystemLayoutSeeder(LayoutSeederPort):
85
+ """Create the ``~/.gmlw`` profile/rules directories and seed a default config."""
86
+
87
+ def __init__(self, home: Path) -> None:
88
+ """Bind the seeder to the runtime home directory.
89
+
90
+ Args:
91
+ home: The ``~/.gmlw`` root under which the layout is created.
92
+ """
93
+ self._home = home
94
+
95
+ def ensure(self) -> None:
96
+ """Create missing ``profile/me``, ``profile/company``, ``rules`` and config."""
97
+ self._home.mkdir(parents=True, exist_ok=True)
98
+ # Owner-only: the home holds credentials, config (which names code to run), and state.
99
+ self._home.chmod(0o700)
100
+ for relative in _DIRS:
101
+ (self._home / relative).mkdir(parents=True, exist_ok=True)
102
+ config = self._home / _CONFIG
103
+ if not config.exists():
104
+ config.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Client-caller adapters."""