rote-cli 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.
@@ -0,0 +1,259 @@
1
+ """The graduator orchestrator.
2
+
3
+ The :class:`Graduator` is the high-level entry point used by
4
+ ``rote graduate``. It:
5
+
6
+ 1. Picks a driver (auto-detect or explicit ``--agent``).
7
+ 2. Locates the rote-graduate skill bundle.
8
+ 3. Creates a temp work directory.
9
+ 4. Calls ``driver.run()`` to produce ``pipeline.yaml`` (and any
10
+ extracted modules / signature stubs).
11
+ 5. Validates the produced ``pipeline.yaml`` via
12
+ :func:`rote.ir.load_pipeline`.
13
+ 6. Moves the work directory contents into the user's output directory.
14
+ 7. Returns a :class:`GraduationResult`.
15
+
16
+ This module deliberately knows nothing about specific runtimes
17
+ (Temporal, Inngest, etc.) — that's the adapter layer's job. The
18
+ graduator's only output is the validated IR + the stub files;
19
+ ``rote.adapters`` consumes those to emit runnable code.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import shutil
25
+ import tempfile
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from rote.graduator.drivers import (
31
+ DriverError,
32
+ GraduatorDriver,
33
+ auto_detect,
34
+ available_drivers,
35
+ get_driver,
36
+ )
37
+ from rote.ir import Pipeline, load_pipeline
38
+
39
+
40
+ class GraduatorError(RuntimeError):
41
+ """High-level graduator failure.
42
+
43
+ Wraps driver errors and validation errors with a user-facing
44
+ message. The CLI prints the message to stderr and exits non-zero.
45
+ """
46
+
47
+
48
+ @dataclass
49
+ class GraduationResult:
50
+ """The output of a successful graduation."""
51
+
52
+ pipeline: Pipeline
53
+ output_dir: Path
54
+ driver_name: str
55
+ driver_metadata: dict[str, Any] = field(default_factory=dict)
56
+
57
+
58
+ def _default_graduator_skill_dir() -> Path:
59
+ """Locate the rote-graduate skill bundled with the rote source.
60
+
61
+ Two layouts exist, checked in order:
62
+
63
+ 1. **Wheel install.** ``pyproject.toml`` force-includes the
64
+ repo-root ``skills/rote-graduate/`` into the wheel at
65
+ ``rote/skills/rote-graduate/``, so the bundle sits next to the
66
+ package's ``__file__``.
67
+ 2. **Editable install / source checkout.** The skill lives at
68
+ ``<repo>/skills/rote-graduate/``; ``rote.__file__`` is
69
+ ``<repo>/src/rote/__init__.py``, so the repo root is three
70
+ parents up.
71
+ """
72
+ import rote
73
+
74
+ rote_pkg_dir = Path(rote.__file__).resolve().parent
75
+ candidates = [
76
+ rote_pkg_dir / "skills" / "rote-graduate",
77
+ rote_pkg_dir.parent.parent / "skills" / "rote-graduate",
78
+ ]
79
+ for candidate in candidates:
80
+ if candidate.is_dir() and (candidate / "SKILL.md").is_file():
81
+ return candidate
82
+ searched = ", ".join(str(c) for c in candidates)
83
+ raise GraduatorError(
84
+ f"Could not locate the rote-graduate skill (searched: {searched}). "
85
+ "Pass an explicit graduator_skill_dir to Graduator() pointing "
86
+ "at a rote-graduate skill bundle."
87
+ )
88
+
89
+
90
+ class Graduator:
91
+ """High-level orchestrator for graduating a skill into a pipeline."""
92
+
93
+ def __init__(
94
+ self,
95
+ agent: str | None = None,
96
+ graduator_skill_dir: Path | None = None,
97
+ model: str | None = None,
98
+ ) -> None:
99
+ """
100
+ Parameters
101
+ ----------
102
+ agent
103
+ Driver name (``"claude"``, ``"codex"``, ``"api"``) or
104
+ ``None`` for auto-detect.
105
+ graduator_skill_dir
106
+ Path to the rote-graduate skill bundle. Defaults to the
107
+ bundled one inside the rote source tree.
108
+ model
109
+ Override the LLM model the driver uses for the graduator
110
+ (e.g. ``"claude-opus-4-6"`` to use Opus instead of the
111
+ default Sonnet). ``None`` uses the driver's default.
112
+ """
113
+ self.agent = agent
114
+ self._explicit_graduator_skill_dir = graduator_skill_dir
115
+ self.model = model
116
+
117
+ @property
118
+ def graduator_skill_dir(self) -> Path:
119
+ if self._explicit_graduator_skill_dir is not None:
120
+ return self._explicit_graduator_skill_dir
121
+ return _default_graduator_skill_dir()
122
+
123
+ def select_driver(self) -> GraduatorDriver:
124
+ """Choose a driver based on the agent setting or auto-detect.
125
+
126
+ When a non-default ``model`` was passed to the ``Graduator``
127
+ constructor, it's forwarded to the driver via ``get_driver``
128
+ kwargs. Auto-detect finds an available driver first (using
129
+ a zero-arg probe), then re-constructs it with the kwargs so
130
+ the override is honored.
131
+ """
132
+ driver_kwargs: dict[str, object] = {}
133
+ if self.model is not None:
134
+ driver_kwargs["model"] = self.model
135
+
136
+ if self.agent:
137
+ try:
138
+ driver = get_driver(self.agent, **driver_kwargs)
139
+ except KeyError as e:
140
+ raise GraduatorError(str(e.args[0])) from None
141
+ available, reason = driver.is_available()
142
+ if not available:
143
+ raise GraduatorError(f"Driver {self.agent!r} is not available: {reason}")
144
+ return driver
145
+
146
+ probe = auto_detect()
147
+ if probe is None:
148
+ lines = [
149
+ "No graduator driver is available. Tried:",
150
+ ]
151
+ for name, _avail, reason in available_drivers():
152
+ lines.append(f" - {name}: {reason}")
153
+ lines.append("")
154
+ lines.append(
155
+ "Install one of the supported coding agent CLIs, or set "
156
+ "ANTHROPIC_API_KEY and `pip install rote[api]`."
157
+ )
158
+ raise GraduatorError("\n".join(lines))
159
+
160
+ # Re-construct the detected driver with user-specified kwargs
161
+ # (e.g. model override) instead of returning the probe instance
162
+ # directly.
163
+ return get_driver(probe.name, **driver_kwargs)
164
+
165
+ async def graduate(
166
+ self,
167
+ skill_dir: Path | str,
168
+ output_dir: Path | str,
169
+ ) -> GraduationResult:
170
+ """Run the full graduation flow.
171
+
172
+ Parameters
173
+ ----------
174
+ skill_dir
175
+ Path to the source skill bundle (must contain a SKILL.md).
176
+ output_dir
177
+ Where to write the graduated artifacts. Created if missing;
178
+ existing files of the same name are overwritten.
179
+
180
+ Returns
181
+ -------
182
+ GraduationResult
183
+ A validated :class:`Pipeline` plus the output directory and
184
+ driver metadata.
185
+
186
+ Raises
187
+ ------
188
+ GraduatorError
189
+ For any user-actionable failure: missing skill bundle,
190
+ unavailable driver, driver failure, or invalid produced
191
+ pipeline.yaml.
192
+ """
193
+ skill_dir = Path(skill_dir).resolve()
194
+ output_dir = Path(output_dir).resolve()
195
+
196
+ if not skill_dir.is_dir():
197
+ raise GraduatorError(f"Skill directory does not exist: {skill_dir}")
198
+ if not (skill_dir / "SKILL.md").is_file():
199
+ raise GraduatorError(f"Not a skill bundle (no SKILL.md found): {skill_dir}")
200
+
201
+ driver = self.select_driver()
202
+
203
+ with tempfile.TemporaryDirectory(prefix="rote-graduate-") as work_dir_str:
204
+ work_dir = Path(work_dir_str)
205
+
206
+ try:
207
+ result = await driver.run(
208
+ skill_dir=skill_dir,
209
+ graduator_skill_dir=self.graduator_skill_dir,
210
+ work_dir=work_dir,
211
+ )
212
+ except DriverError as e:
213
+ detail = f"\n\n{e.details}" if getattr(e, "details", None) else ""
214
+ raise GraduatorError(f"Driver {driver.name!r} failed: {e}{detail}") from e
215
+
216
+ try:
217
+ pipeline = load_pipeline(result.pipeline_yaml_path)
218
+ except Exception as e:
219
+ raise GraduatorError(
220
+ f"Driver {driver.name!r} produced an invalid "
221
+ f"pipeline.yaml at {result.pipeline_yaml_path}: {e}"
222
+ ) from e
223
+
224
+ self._move_work_dir_to_output(result.work_dir, output_dir)
225
+
226
+ # Re-point the result's path to the moved location for the
227
+ # caller's benefit.
228
+ return GraduationResult(
229
+ pipeline=pipeline,
230
+ output_dir=output_dir,
231
+ driver_name=result.driver_name,
232
+ driver_metadata=result.metadata,
233
+ )
234
+
235
+ @staticmethod
236
+ def _move_work_dir_to_output(work_dir: Path, output_dir: Path) -> None:
237
+ """Move every entry in ``work_dir`` into ``output_dir``.
238
+
239
+ Existing entries with the same name in ``output_dir`` are
240
+ replaced. We use ``shutil.move`` per-entry rather than moving
241
+ the whole directory because ``output_dir`` may already exist
242
+ and may contain unrelated files.
243
+ """
244
+ output_dir.mkdir(parents=True, exist_ok=True)
245
+ for item in work_dir.iterdir():
246
+ target = output_dir / item.name
247
+ if target.exists():
248
+ if target.is_dir():
249
+ shutil.rmtree(target)
250
+ else:
251
+ target.unlink()
252
+ shutil.move(str(item), str(target))
253
+
254
+
255
+ __all__ = [
256
+ "Graduator",
257
+ "GraduationResult",
258
+ "GraduatorError",
259
+ ]
@@ -0,0 +1,226 @@
1
+ """Graduator drivers — pluggable backends for running the graduator agent.
2
+
3
+ Every driver implements the :class:`GraduatorDriver` Protocol and is
4
+ registered in :data:`DRIVERS`. The CLI dispatches to a driver by name
5
+ (``rote graduate --agent claude``) or by auto-detection
6
+ (:func:`auto_detect`).
7
+
8
+ See ``docs/agent-runtime.md`` for the full design record, including the
9
+ rationale for the three-driver lineup (Claude Code, Codex CLI,
10
+ Anthropic SDK) and the explicit non-use of ``claude-agent-sdk``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any, Protocol
19
+
20
+ # ───────── Shared types ─────────
21
+
22
+
23
+ class DriverError(RuntimeError):
24
+ """Raised when a driver fails to produce a graduated pipeline.yaml.
25
+
26
+ The message is user-facing and should explain what went wrong and
27
+ what the user can do about it. Driver implementations should attach
28
+ any captured stdout / stderr via the ``details`` attribute.
29
+ """
30
+
31
+ def __init__(self, message: str, *, details: str | None = None) -> None:
32
+ super().__init__(message)
33
+ self.details = details
34
+
35
+
36
+ @dataclass
37
+ class DriverResult:
38
+ """The output of a successful driver run.
39
+
40
+ Attributes
41
+ ----------
42
+ pipeline_yaml_path
43
+ Path to the produced ``pipeline.yaml`` inside ``work_dir``.
44
+ work_dir
45
+ Scratch directory the driver wrote into. May contain additional
46
+ artifacts (``extracted/*.py``, ``signatures/*.py``, etc.) that
47
+ the graduator orchestrator will move into the user's output
48
+ directory.
49
+ driver_name
50
+ The name of the driver that produced this result (e.g.
51
+ ``"claude"``, ``"codex"``, ``"api"``).
52
+ metadata
53
+ Free-form dict of driver-specific metadata: token counts,
54
+ estimated cost, duration, model name, session id, etc. Used for
55
+ the Phase 7 graduation report but not the IR itself.
56
+ """
57
+
58
+ pipeline_yaml_path: Path
59
+ work_dir: Path
60
+ driver_name: str
61
+ metadata: dict[str, Any] = field(default_factory=dict)
62
+
63
+
64
+ class GraduatorDriver(Protocol):
65
+ """Protocol every graduator driver implements.
66
+
67
+ The driver's only responsibility is: **given a source skill and the
68
+ rote-graduate rubric, run the agent loop and make sure
69
+ ``work_dir/pipeline.yaml`` exists when it returns.** How it gets
70
+ there — subprocess, in-process, remote — is the driver's business.
71
+ """
72
+
73
+ name: str
74
+ """Short identifier used in the CLI (``--agent <name>``)."""
75
+
76
+ def is_available(self) -> tuple[bool, str]:
77
+ """Check whether this driver can run right now.
78
+
79
+ Returns
80
+ -------
81
+ tuple[bool, str]
82
+ ``(available, reason)``. When ``available`` is ``True``, the
83
+ reason may be an empty string. When ``False``, ``reason`` is
84
+ a user-facing message explaining what needs to happen
85
+ (install the CLI, run login, set an env var).
86
+ """
87
+ ...
88
+
89
+ async def run(
90
+ self,
91
+ skill_dir: Path,
92
+ graduator_skill_dir: Path,
93
+ work_dir: Path,
94
+ ) -> DriverResult:
95
+ """Run the graduator agent against ``skill_dir``.
96
+
97
+ Parameters
98
+ ----------
99
+ skill_dir
100
+ The source skill bundle to graduate (read-only to the agent).
101
+ graduator_skill_dir
102
+ The ``rote-graduate`` skill with SKILL.md and references/.
103
+ work_dir
104
+ A scratch directory where the agent writes ``pipeline.yaml``
105
+ and any extracted/signature stubs. The caller ensures this
106
+ directory exists and is empty.
107
+
108
+ Returns
109
+ -------
110
+ DriverResult
111
+ A result pointing at ``work_dir/pipeline.yaml``.
112
+
113
+ Raises
114
+ ------
115
+ DriverError
116
+ If the driver cannot produce a valid ``pipeline.yaml``.
117
+ """
118
+ ...
119
+
120
+
121
+ # ───────── Driver registry ─────────
122
+
123
+
124
+ def _claude_driver_factory(**kwargs: Any) -> GraduatorDriver:
125
+ from rote.graduator.drivers.claude import ClaudeDriver
126
+
127
+ return ClaudeDriver(**kwargs)
128
+
129
+
130
+ def _codex_driver_factory(**kwargs: Any) -> GraduatorDriver:
131
+ from rote.graduator.drivers.codex import CodexDriver
132
+
133
+ return CodexDriver(**kwargs)
134
+
135
+
136
+ def _anthropic_driver_factory(**kwargs: Any) -> GraduatorDriver:
137
+ from rote.graduator.drivers.anthropic_api import AnthropicApiDriver
138
+
139
+ return AnthropicApiDriver(**kwargs)
140
+
141
+
142
+ #: Name → factory. Keep the values lazy so we don't pay the import cost
143
+ #: of the anthropic SDK on every CLI invocation. Factories accept
144
+ #: ``**kwargs`` which are forwarded to the driver constructor (e.g.
145
+ #: ``model``, ``max_turns``).
146
+ DRIVERS: dict[str, Callable[..., GraduatorDriver]] = {
147
+ "claude": _claude_driver_factory,
148
+ "codex": _codex_driver_factory,
149
+ "api": _anthropic_driver_factory,
150
+ }
151
+
152
+ #: Order used by :func:`auto_detect`. Claude first because Claude Max/Pro
153
+ #: is the most likely subscription rote's audience already has.
154
+ AUTO_DETECT_ORDER: tuple[str, ...] = ("claude", "codex", "api")
155
+
156
+
157
+ def get_driver(name: str, **kwargs: Any) -> GraduatorDriver:
158
+ """Return a driver instance by name, configured with ``kwargs``.
159
+
160
+ ``kwargs`` are forwarded to the driver's constructor (e.g. ``model``
161
+ for ``ClaudeDriver`` and ``AnthropicApiDriver``). Drivers ignore
162
+ kwargs they don't understand via ``**kwargs`` absorption in their
163
+ factories.
164
+
165
+ Raises :class:`KeyError` with a helpful message when the name is
166
+ unknown.
167
+ """
168
+ try:
169
+ factory = DRIVERS[name]
170
+ except KeyError:
171
+ available = ", ".join(sorted(DRIVERS))
172
+ raise KeyError(f"Unknown agent driver {name!r}. Available: {available}") from None
173
+ return factory(**kwargs)
174
+
175
+
176
+ def auto_detect() -> GraduatorDriver | None:
177
+ """Return the first available driver in :data:`AUTO_DETECT_ORDER`.
178
+
179
+ Returns ``None`` when no driver is available. The CLI translates
180
+ that into a helpful error listing setup instructions for each
181
+ driver.
182
+ """
183
+ for name in AUTO_DETECT_ORDER:
184
+ try:
185
+ driver = DRIVERS[name]()
186
+ except Exception:
187
+ # A factory raising at import time (e.g. a missing optional
188
+ # dep that isn't gracefully handled) should never block
189
+ # auto-detect from trying the next driver.
190
+ continue
191
+ try:
192
+ available, _reason = driver.is_available()
193
+ except Exception:
194
+ continue
195
+ if available:
196
+ return driver
197
+ return None
198
+
199
+
200
+ def available_drivers() -> list[tuple[str, bool, str]]:
201
+ """Return a diagnostic list of ``(name, available, reason)`` triples.
202
+
203
+ Used by the CLI's ``--agent auto`` failure path to produce a clear
204
+ message telling the user what each driver needs.
205
+ """
206
+ out: list[tuple[str, bool, str]] = []
207
+ for name in AUTO_DETECT_ORDER:
208
+ try:
209
+ driver = DRIVERS[name]()
210
+ available, reason = driver.is_available()
211
+ out.append((name, available, reason))
212
+ except Exception as e:
213
+ out.append((name, False, f"driver failed to load: {e}"))
214
+ return out
215
+
216
+
217
+ __all__ = [
218
+ "DriverError",
219
+ "DriverResult",
220
+ "GraduatorDriver",
221
+ "DRIVERS",
222
+ "AUTO_DETECT_ORDER",
223
+ "get_driver",
224
+ "auto_detect",
225
+ "available_drivers",
226
+ ]