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.
- generic_ml_wrapper/__init__.py +13 -0
- generic_ml_wrapper/adapter/__init__.py +3 -0
- generic_ml_wrapper/adapter/inbound/__init__.py +3 -0
- generic_ml_wrapper/adapter/inbound/cli/__init__.py +3 -0
- generic_ml_wrapper/adapter/inbound/cli/app.py +369 -0
- generic_ml_wrapper/adapter/inbound/cli/banner.py +30 -0
- generic_ml_wrapper/adapter/outbound/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/bootstrap/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/bootstrap/filesystem_layout_seeder.py +104 -0
- generic_ml_wrapper/adapter/outbound/caller/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/caller/claude_cli_caller.py +158 -0
- generic_ml_wrapper/adapter/outbound/caller/codex_cli_caller.py +165 -0
- generic_ml_wrapper/adapter/outbound/caller/context_file.py +44 -0
- generic_ml_wrapper/adapter/outbound/caller/context_opening.py +27 -0
- generic_ml_wrapper/adapter/outbound/caller/cursor_cli_caller.py +98 -0
- generic_ml_wrapper/adapter/outbound/caller/default_provider.py +79 -0
- generic_ml_wrapper/adapter/outbound/caller/loader.py +34 -0
- generic_ml_wrapper/adapter/outbound/caller/status_line_config.py +142 -0
- generic_ml_wrapper/adapter/outbound/caller/vibe_cli_caller.py +169 -0
- generic_ml_wrapper/adapter/outbound/caller/vibe_config.py +128 -0
- generic_ml_wrapper/adapter/outbound/credentials/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/credentials/filesystem_credentials_store.py +130 -0
- generic_ml_wrapper/adapter/outbound/gateway/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/gateway/anthropic_sse.py +148 -0
- generic_ml_wrapper/adapter/outbound/gateway/openai_chat.py +112 -0
- generic_ml_wrapper/adapter/outbound/gateway/openai_responses.py +85 -0
- generic_ml_wrapper/adapter/outbound/gateway/relay.py +402 -0
- generic_ml_wrapper/adapter/outbound/interceptor/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/interceptor/compressor.py +107 -0
- generic_ml_wrapper/adapter/outbound/interceptor/size_logger.py +32 -0
- generic_ml_wrapper/adapter/outbound/status/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/status/claude_status_parser.py +76 -0
- generic_ml_wrapper/adapter/outbound/status/cursor_status_parser.py +71 -0
- generic_ml_wrapper/adapter/outbound/store/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/store/filesystem_transcript_store.py +65 -0
- generic_ml_wrapper/adapter/outbound/store/ledger.py +104 -0
- generic_ml_wrapper/adapter/outbound/store/sqlite_per_turn_store.py +68 -0
- generic_ml_wrapper/adapter/outbound/store/sqlite_session_store.py +73 -0
- generic_ml_wrapper/adapter/outbound/store/sqlite_usage_store.py +44 -0
- generic_ml_wrapper/adapter/outbound/workflow/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/workflow/filesystem_workflow_source.py +165 -0
- generic_ml_wrapper/adapter/outbound/workspace/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/workspace/local_workspace_inspector.py +75 -0
- generic_ml_wrapper/application/__init__.py +3 -0
- generic_ml_wrapper/application/domain/__init__.py +3 -0
- generic_ml_wrapper/application/domain/model/__init__.py +3 -0
- generic_ml_wrapper/application/domain/model/client_status.py +44 -0
- generic_ml_wrapper/application/domain/model/identifiers.py +76 -0
- generic_ml_wrapper/application/domain/model/run.py +35 -0
- generic_ml_wrapper/application/domain/model/session.py +24 -0
- generic_ml_wrapper/application/domain/model/turn_usage.py +62 -0
- generic_ml_wrapper/application/domain/model/workspace.py +31 -0
- generic_ml_wrapper/application/domain/service/__init__.py +3 -0
- generic_ml_wrapper/application/domain/service/interceptor.py +30 -0
- generic_ml_wrapper/application/domain/service/interceptor_chain.py +57 -0
- generic_ml_wrapper/application/domain/service/rule_cleaner.py +35 -0
- generic_ml_wrapper/application/domain/service/session_naming.py +30 -0
- generic_ml_wrapper/application/domain/service/statusline_renderer.py +81 -0
- generic_ml_wrapper/application/port/__init__.py +3 -0
- generic_ml_wrapper/application/port/inbound/__init__.py +3 -0
- generic_ml_wrapper/application/port/inbound/bootstrap.py +15 -0
- generic_ml_wrapper/application/port/inbound/export_usage.py +109 -0
- generic_ml_wrapper/application/port/inbound/list_jobs.py +33 -0
- generic_ml_wrapper/application/port/inbound/list_sessions.py +36 -0
- generic_ml_wrapper/application/port/inbound/list_workflows.py +19 -0
- generic_ml_wrapper/application/port/inbound/new_workflow.py +48 -0
- generic_ml_wrapper/application/port/inbound/render_statusline.py +24 -0
- generic_ml_wrapper/application/port/inbound/set_credential.py +35 -0
- generic_ml_wrapper/application/port/inbound/start_job.py +53 -0
- generic_ml_wrapper/application/port/outbound/__init__.py +3 -0
- generic_ml_wrapper/application/port/outbound/cli_caller.py +97 -0
- generic_ml_wrapper/application/port/outbound/client_status.py +27 -0
- generic_ml_wrapper/application/port/outbound/credentials_store.py +36 -0
- generic_ml_wrapper/application/port/outbound/interceptor.py +25 -0
- generic_ml_wrapper/application/port/outbound/layout_seeder.py +18 -0
- generic_ml_wrapper/application/port/outbound/per_turn_metering.py +38 -0
- generic_ml_wrapper/application/port/outbound/session_store.py +62 -0
- generic_ml_wrapper/application/port/outbound/transcript.py +46 -0
- generic_ml_wrapper/application/port/outbound/usage_store.py +32 -0
- generic_ml_wrapper/application/port/outbound/workflow_source.py +58 -0
- generic_ml_wrapper/application/port/outbound/workspace.py +27 -0
- generic_ml_wrapper/application/usecase/__init__.py +3 -0
- generic_ml_wrapper/application/usecase/bootstrap.py +24 -0
- generic_ml_wrapper/application/usecase/export_usage.py +93 -0
- generic_ml_wrapper/application/usecase/list_jobs.py +31 -0
- generic_ml_wrapper/application/usecase/list_sessions.py +34 -0
- generic_ml_wrapper/application/usecase/list_workflows.py +28 -0
- generic_ml_wrapper/application/usecase/new_workflow.py +109 -0
- generic_ml_wrapper/application/usecase/render_statusline.py +117 -0
- generic_ml_wrapper/application/usecase/set_credential.py +27 -0
- generic_ml_wrapper/application/usecase/start_job.py +125 -0
- generic_ml_wrapper/application/wiring/__init__.py +3 -0
- generic_ml_wrapper/application/wiring/composition.py +230 -0
- generic_ml_wrapper/common/__init__.py +3 -0
- generic_ml_wrapper/common/config.py +179 -0
- generic_ml_wrapper/common/log.py +83 -0
- generic_ml_wrapper/common/paths.py +25 -0
- generic_ml_wrapper/common/spec_loader.py +67 -0
- generic_ml_wrapper/py.typed +0 -0
- generic_ml_wrapper/resources/workflows/_common/base.md +25 -0
- generic_ml_wrapper/resources/workflows/create-workflow/workflow.md +35 -0
- generic_ml_wrapper-0.1.0.dist-info/METADATA +172 -0
- generic_ml_wrapper-0.1.0.dist-info/RECORD +107 -0
- generic_ml_wrapper-0.1.0.dist-info/WHEEL +4 -0
- generic_ml_wrapper-0.1.0.dist-info/entry_points.txt +2 -0
- generic_ml_wrapper-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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,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,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")
|