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,312 @@
1
+ """Claude Code CLI driver — spawns ``claude -p`` in subscription mode.
2
+
3
+ This driver is the primary subscription path. It shells out to the
4
+ ``claude`` CLI (Claude Code) in non-interactive "print" mode and
5
+ requires only that the user has run ``claude login`` interactively at
6
+ least once (or set ``CLAUDE_CODE_OAUTH_TOKEN`` for automation).
7
+
8
+ # The env-var gotcha
9
+
10
+ Claude Code's print mode (``claude -p``) has a documented behavior
11
+ where ``ANTHROPIC_API_KEY`` and ``ANTHROPIC_AUTH_TOKEN`` *always win*
12
+ over any active OAuth session. To force subscription auth, the
13
+ subprocess environment must not contain those variables.
14
+
15
+ This driver always scrubs both env vars from the child environment.
16
+ If the user wants API-key auth to Anthropic's servers, they should
17
+ use the ``api`` driver (which uses the ``anthropic`` SDK directly);
18
+ ``ClaudeDriver`` is specifically the subscription path.
19
+
20
+ ``CLAUDE_CODE_OAUTH_TOKEN`` — a long-lived token Anthropic issues for
21
+ automation — is always passed through if set. That's the cleanest
22
+ way to use ``ClaudeDriver`` in CI, where no interactive ``claude login``
23
+ has been run.
24
+
25
+ See ``docs/agent-runtime.md`` for the full rationale.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import json
32
+ import os
33
+ from pathlib import Path
34
+ from shutil import which
35
+ from typing import Any
36
+
37
+ from rote.graduator.drivers import DriverError, DriverResult, GraduatorDriver
38
+
39
+ # ───────── Defaults ─────────
40
+
41
+ DEFAULT_MODEL = "claude-sonnet-4-6"
42
+ """Default model for the graduator agent.
43
+
44
+ **Why Sonnet, not Opus:** Claude Code defaults to Opus 4.6, which is
45
+ overkill for the graduator's task. Sonnet 4.6 follows structured
46
+ rubrics just as reliably for this kind of work and is ~5× cheaper on
47
+ both the Anthropic API and the Claude Max/Pro subscription budget.
48
+
49
+ **Measured on BDR outreach (2026-04-07):** two runs with Opus burned
50
+ ~$3.50 each in subscription accounting (2.6M cache-read tokens, 31K
51
+ output tokens, 31 turns). At that rate a single Max/Pro user's "extra
52
+ usage" allowance is exhausted in 2-3 runs. The Sonnet switch brings
53
+ per-run cost to ~$0.70, which makes iterative rubric tuning actually
54
+ feasible.
55
+
56
+ Users can override with ``ClaudeDriver(model=...)`` or the CLI flag
57
+ ``rote graduate --model claude-opus-4-6`` for complex skills where
58
+ Opus's extra reasoning ability is worth the 5× cost.
59
+ """
60
+
61
+ DEFAULT_MAX_TURNS = 60
62
+ """Agents need a fairly generous turn budget for complex skills.
63
+
64
+ Measured on the BDR outreach skill (7 phases, 6 reference files,
65
+ ~14 nodes, ~8 extracted modules, ~2 signatures): a clean run needs
66
+ roughly 25 tool calls minimum (reads + writes), realistically 40–50
67
+ with exploration, list_directory calls, and between-phase thinking.
68
+ 30 is not enough; we observed an ``error_max_turns`` failure at that
69
+ limit with BDR. 60 leaves headroom for more complex skills and for
70
+ the agent to recover from tool errors."""
71
+
72
+ DEFAULT_ALLOWED_TOOLS = "Read,Write,Edit,Glob,Grep"
73
+ """Conservative tool allowlist for the graduator. No Bash (no shell
74
+ access needed), no WebFetch / WebSearch (no external network), no
75
+ TodoWrite. Read + Write + Edit cover file I/O; Glob + Grep cover
76
+ discovery of source skill structure and reference content."""
77
+
78
+
79
+ class ClaudeDriver(GraduatorDriver):
80
+ name: str = "claude"
81
+
82
+ def __init__(
83
+ self,
84
+ model: str = DEFAULT_MODEL,
85
+ max_turns: int = DEFAULT_MAX_TURNS,
86
+ allowed_tools: str = DEFAULT_ALLOWED_TOOLS,
87
+ claude_executable: str = "claude",
88
+ ) -> None:
89
+ self.model = model
90
+ self.max_turns = max_turns
91
+ self.allowed_tools = allowed_tools
92
+ self.claude_executable = claude_executable
93
+
94
+ def is_available(self) -> tuple[bool, str]:
95
+ """Check whether ``claude`` CLI is installed on PATH.
96
+
97
+ We intentionally do **not** check auth here — doing so would
98
+ require spawning a ``claude`` process, which is expensive. Auth
99
+ errors surface at ``run()`` time with their own clear messages.
100
+ """
101
+ if which(self.claude_executable) is None:
102
+ return (
103
+ False,
104
+ "The `claude` CLI is not installed. "
105
+ "Install Claude Code from https://code.claude.com/download "
106
+ "and run `claude login` once to enable subscription auth.",
107
+ )
108
+ return (True, "")
109
+
110
+ async def run(
111
+ self,
112
+ skill_dir: Path,
113
+ graduator_skill_dir: Path,
114
+ work_dir: Path,
115
+ ) -> DriverResult:
116
+ """Spawn ``claude -p`` and wait for it to produce pipeline.yaml."""
117
+ skill_dir = skill_dir.resolve()
118
+ graduator_skill_dir = graduator_skill_dir.resolve()
119
+ work_dir = work_dir.resolve()
120
+ work_dir.mkdir(parents=True, exist_ok=True)
121
+
122
+ skill_md = graduator_skill_dir / "SKILL.md"
123
+ if not skill_md.is_file():
124
+ raise DriverError(
125
+ f"rote-graduate SKILL.md not found at {skill_md}. "
126
+ f"Pass an explicit graduator_skill_dir to the orchestrator."
127
+ )
128
+
129
+ system_prompt = self._build_system_prompt(skill_md.read_text(encoding="utf-8"))
130
+ user_prompt = self._build_user_prompt(skill_dir, graduator_skill_dir, work_dir)
131
+
132
+ env = self._build_child_env()
133
+
134
+ args = [
135
+ self.claude_executable,
136
+ "-p",
137
+ user_prompt,
138
+ "--model",
139
+ self.model,
140
+ "--append-system-prompt",
141
+ system_prompt,
142
+ "--add-dir",
143
+ str(skill_dir),
144
+ "--add-dir",
145
+ str(graduator_skill_dir),
146
+ "--add-dir",
147
+ str(work_dir),
148
+ "--allowedTools",
149
+ self.allowed_tools,
150
+ "--output-format",
151
+ "json",
152
+ "--max-turns",
153
+ str(self.max_turns),
154
+ ]
155
+
156
+ proc = await asyncio.create_subprocess_exec(
157
+ *args,
158
+ stdout=asyncio.subprocess.PIPE,
159
+ stderr=asyncio.subprocess.PIPE,
160
+ env=env,
161
+ )
162
+ stdout_bytes, stderr_bytes = await proc.communicate()
163
+
164
+ stdout = stdout_bytes.decode("utf-8", errors="replace") if stdout_bytes else ""
165
+ stderr = stderr_bytes.decode("utf-8", errors="replace") if stderr_bytes else ""
166
+
167
+ pipeline_yaml = work_dir / "pipeline.yaml"
168
+
169
+ # The agent's deliverable is a file on disk, not a clean exit
170
+ # code. A real-world failure mode: the agent writes a complete
171
+ # pipeline.yaml at turn N, then runs an extra validation step at
172
+ # turn N+1 that hits a transient API error (ECONNRESET, rate
173
+ # limit, etc.) — the subprocess returns nonzero but the work is
174
+ # done. Treating that as fatal would discard a $2+ run over a
175
+ # blip, so we check for the file FIRST and only fail if it's
176
+ # missing.
177
+ if not pipeline_yaml.is_file():
178
+ if proc.returncode != 0:
179
+ raise DriverError(
180
+ f"claude CLI exited with code {proc.returncode}",
181
+ details=stderr or stdout or "(no output)",
182
+ )
183
+ raise DriverError(
184
+ f"claude CLI finished successfully but did not produce {pipeline_yaml}.",
185
+ details=stdout,
186
+ )
187
+
188
+ metadata = self._parse_metadata(stdout)
189
+ if proc.returncode != 0:
190
+ metadata["subprocess_warning"] = (
191
+ f"claude CLI exited with code {proc.returncode} but "
192
+ f"pipeline.yaml was produced. Treating as success."
193
+ )
194
+
195
+ return DriverResult(
196
+ pipeline_yaml_path=pipeline_yaml,
197
+ work_dir=work_dir,
198
+ driver_name=self.name,
199
+ metadata=metadata,
200
+ )
201
+
202
+ # ───────── Private helpers ─────────
203
+
204
+ def _build_child_env(self) -> dict[str, str]:
205
+ """Build the environment for the subprocess.
206
+
207
+ Critical: scrub ``ANTHROPIC_API_KEY`` and ``ANTHROPIC_AUTH_TOKEN``.
208
+ In ``claude -p`` mode these env vars always win over an active
209
+ OAuth session, which defeats the whole point of the subscription
210
+ path. The user who wants API-key auth should use the ``api``
211
+ driver; ``ClaudeDriver`` is specifically about reusing the
212
+ user's Claude Max/Pro subscription.
213
+ """
214
+ env = os.environ.copy()
215
+ env.pop("ANTHROPIC_API_KEY", None)
216
+ env.pop("ANTHROPIC_AUTH_TOKEN", None)
217
+ # Silence spinners in non-interactive output so stdout is just
218
+ # the JSON result and stderr is just the error text (if any).
219
+ env["CLAUDE_CODE_DISABLE_NONINTERACTIVE_ANIMATIONS"] = "1"
220
+ # CLAUDE_CODE_OAUTH_TOKEN, if set in the parent env, is
221
+ # preserved by env.copy() — this is the automation-friendly
222
+ # path for CI environments.
223
+ return env
224
+
225
+ def _build_system_prompt(self, skill_md_text: str) -> str:
226
+ """Build the ``--append-system-prompt`` content.
227
+
228
+ Claude Code has a default coding-focused system prompt that
229
+ we're happy to keep — we just *append* the rote-graduate
230
+ SKILL.md content on top of it. The agent reads reference
231
+ files via its Read tool; we don't inline them here to keep
232
+ the command-line argument small and to let Claude cache
233
+ reference reads.
234
+ """
235
+ return (
236
+ "You are the rote graduator. Follow the procedure and rubric "
237
+ "below to graduate the skill identified in the user prompt.\n\n"
238
+ "When asked to read reference files, use the Read tool. When "
239
+ "producing the pipeline.yaml and any extracted Python modules "
240
+ "or signature stubs, use the Write tool.\n\n"
241
+ "================== ROTE GRADUATE SKILL ==================\n\n"
242
+ f"{skill_md_text}"
243
+ )
244
+
245
+ def _build_user_prompt(
246
+ self,
247
+ skill_dir: Path,
248
+ graduator_skill_dir: Path,
249
+ work_dir: Path,
250
+ ) -> str:
251
+ """Build the ``-p`` prompt.
252
+
253
+ Short and task-oriented; the heavy lifting is in the system
254
+ prompt. The goal is to tell the agent which specific paths to
255
+ read from and write to, and what the final deliverable is.
256
+ """
257
+ return (
258
+ f"Graduate the skill at {skill_dir}.\n\n"
259
+ f"Paths for this run:\n"
260
+ f" - Source skill (read): {skill_dir}\n"
261
+ f" - Rote-graduate rubric (read): {graduator_skill_dir}\n"
262
+ f" - Work directory (write): {work_dir}\n\n"
263
+ f"Begin by reading {graduator_skill_dir}/SKILL.md and its "
264
+ f"reference files under {graduator_skill_dir}/references/, "
265
+ f"then follow the procedure it describes.\n\n"
266
+ f"Your final deliverable is {work_dir}/pipeline.yaml. Write "
267
+ f"any extracted Python modules to {work_dir}/extracted/ and "
268
+ f"any signature stubs to {work_dir}/signatures/, as the "
269
+ f"rubric instructs."
270
+ )
271
+
272
+ def _parse_metadata(self, stdout: str) -> dict[str, Any]:
273
+ """Parse ``claude -p``'s ``--output-format json`` stdout.
274
+
275
+ Per the Claude Code headless docs, the output is one JSON
276
+ object with fields ``result``, ``cost_usd``, ``duration_ms``,
277
+ ``num_turns``, ``session_id``. We strip ``result`` from the
278
+ metadata (it can be large) and keep the numeric/id fields.
279
+
280
+ If the stdout isn't parseable JSON — e.g. because there's a
281
+ banner before it, or animations slipped through — we fall back
282
+ to parsing the last non-empty line, then finally to returning
283
+ a truncated raw output for debugging.
284
+ """
285
+ if not stdout.strip():
286
+ return {"driver": self.name}
287
+
288
+ data: Any = None
289
+ try:
290
+ data = json.loads(stdout)
291
+ except json.JSONDecodeError:
292
+ # Try the last non-empty line — sometimes there's preamble
293
+ for line in reversed([line for line in stdout.split("\n") if line.strip()]):
294
+ try:
295
+ data = json.loads(line)
296
+ break
297
+ except json.JSONDecodeError:
298
+ continue
299
+
300
+ if not isinstance(data, dict):
301
+ return {
302
+ "driver": self.name,
303
+ "raw_output": stdout[:500],
304
+ }
305
+
306
+ return {
307
+ "driver": self.name,
308
+ "cost_usd": data.get("cost_usd"),
309
+ "duration_ms": data.get("duration_ms"),
310
+ "num_turns": data.get("num_turns"),
311
+ "session_id": data.get("session_id"),
312
+ }
@@ -0,0 +1,83 @@
1
+ """Codex CLI driver — spawns ``codex exec`` for ChatGPT subscription users.
2
+
3
+ This is the ChatGPT Plus/Pro subscription path. It shells out to the
4
+ ``codex`` CLI in its non-interactive ``exec`` subcommand.
5
+
6
+ # Auth
7
+
8
+ ``codex exec`` reuses the cached login from ``~/.codex/auth.json``.
9
+ The user runs ``codex login`` once interactively with their ChatGPT
10
+ account; tokens auto-refresh. No env scrubbing is needed — unlike
11
+ Claude Code, Codex defaults to the cached OAuth session as long as
12
+ the user hasn't overridden it with ``OPENAI_API_KEY``.
13
+
14
+ # Gotchas
15
+
16
+ * Codex insists the workspace directory be a git repo by default;
17
+ rote's scratch work dir is not, so we always pass
18
+ ``--skip-git-repo-check``.
19
+ * An active ChatGPT login *blocks* ``OPENAI_API_KEY``. rote does not
20
+ try to support both auth modes through this driver; if the user
21
+ wants API-key auth, they should use the ``api`` driver (which is
22
+ Anthropic, not OpenAI).
23
+ * Progress goes to stderr; final agent message goes to stdout. With
24
+ ``--json``, stdout is NDJSON event stream instead.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from pathlib import Path
30
+ from shutil import which
31
+
32
+ from rote.graduator.drivers import DriverResult, GraduatorDriver
33
+
34
+
35
+ class CodexDriver(GraduatorDriver):
36
+ name: str = "codex"
37
+
38
+ def is_available(self) -> tuple[bool, str]:
39
+ """Check whether ``codex`` CLI is installed on PATH.
40
+
41
+ Auth is not checked here — that surfaces at ``run()`` time.
42
+ """
43
+ if which("codex") is None:
44
+ return (
45
+ False,
46
+ "The `codex` CLI is not installed. "
47
+ "Install from https://developers.openai.com/codex/ "
48
+ "and run `codex login` once to enable subscription auth.",
49
+ )
50
+ return (True, "")
51
+
52
+ async def run(
53
+ self,
54
+ skill_dir: Path,
55
+ graduator_skill_dir: Path,
56
+ work_dir: Path,
57
+ ) -> DriverResult:
58
+ """Spawn ``codex exec`` and wait for it to produce a pipeline.yaml.
59
+
60
+ Not yet implemented. Task #13.
61
+
62
+ Planned invocation (per ``docs/agent-runtime.md``)::
63
+
64
+ codex exec \\
65
+ --cd "<work_dir>" \\
66
+ --add-dir "<skill_dir>" \\
67
+ --add-dir "<graduator_skill_dir>" \\
68
+ --sandbox workspace-write \\
69
+ --ask-for-approval never \\
70
+ --skip-git-repo-check \\
71
+ --json \\
72
+ "<prompt with inlined graduator SKILL.md>"
73
+
74
+ Notes:
75
+
76
+ * No ``--append-system-prompt`` flag exists for ``codex exec``;
77
+ the graduator's SKILL.md contents are inlined into the prompt
78
+ argument directly.
79
+ * ``--sandbox workspace-write`` confines writes to ``work_dir``
80
+ + ``/tmp`` and disables network access (consistent with our
81
+ read-the-skill / write-pipeline.yaml contract).
82
+ """
83
+ raise NotImplementedError("CodexDriver.run: to be implemented in task #13")