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.
rote/cli.py ADDED
@@ -0,0 +1,520 @@
1
+ """rote CLI — entry point.
2
+
3
+ The north star for this CLI:
4
+
5
+ rote graduate ./path/to/skill --runtime temporal --out ./graduated/
6
+
7
+ One command, skill in, runnable workflow out. Every other command is a
8
+ building block for that flow:
9
+
10
+ rote emit <pipeline.yaml> --runtime temporal # IR → code only
11
+ rote analyze <skill-path> # graduator dry run
12
+ rote eval <skill-path> # regression against expected/
13
+
14
+ Internally ``rote graduate`` runs the ``rote-graduate`` skill in an agent
15
+ loop, which reads the source skill, produces a ``pipeline.yaml``, stubs
16
+ the extracted/signature modules, then invokes the chosen adapter to emit
17
+ the runtime code.
18
+
19
+ This module is deliberately thin — subcommands delegate to real
20
+ implementations in sibling modules so the CLI itself stays boring and
21
+ testable.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import asyncio
28
+ import sys
29
+ from collections.abc import Sequence
30
+ from pathlib import Path
31
+
32
+ from rote import __version__
33
+ from rote.adapters import ADAPTERS, get_adapter
34
+ from rote.graduator import Graduator, GraduatorError
35
+ from rote.ir import load_pipeline
36
+
37
+ # ───────── Subcommand: emit ─────────
38
+
39
+
40
+ def _cmd_emit(args: argparse.Namespace) -> int:
41
+ """Render a runtime adapter from a pipeline.yaml.
42
+
43
+ Pure IR → code. No agent involved. This is the lowest-level
44
+ subcommand and the simplest to reason about — it should be used
45
+ when the user already has a hand-written or previously-graduated
46
+ ``pipeline.yaml`` and just wants to re-render the runtime code.
47
+ """
48
+ pipeline_path = Path(args.pipeline_yaml)
49
+ if not pipeline_path.exists():
50
+ print(f"error: pipeline file not found: {pipeline_path}", file=sys.stderr)
51
+ return 2
52
+
53
+ try:
54
+ pipeline = load_pipeline(pipeline_path)
55
+ except Exception as e:
56
+ print(f"error: failed to load pipeline: {e}", file=sys.stderr)
57
+ return 1
58
+
59
+ try:
60
+ adapter = get_adapter(args.runtime)
61
+ except KeyError as e:
62
+ print(f"error: {e.args[0]}", file=sys.stderr)
63
+ return 2
64
+
65
+ out_dir = Path(args.out)
66
+ written = adapter.emit(pipeline, out_dir)
67
+
68
+ print(f"rote: emitted {pipeline.name} v{pipeline.version} → {out_dir}")
69
+ for label, path in written.items():
70
+ print(f" {label}: {path}")
71
+ return 0
72
+
73
+
74
+ # ───────── Subcommand stubs (graduate / analyze / eval) ─────────
75
+
76
+
77
+ def _cmd_graduate(args: argparse.Namespace) -> int:
78
+ """Run the full one-shot graduation flow.
79
+
80
+ Steps:
81
+
82
+ 1. Resolve the source skill directory; reject if it's not a skill bundle.
83
+ 2. Run the graduator agent (via ``Graduator.graduate``) to produce
84
+ ``<out>/graduated/pipeline.yaml`` plus extracted modules and stubs.
85
+ 3. Hand the IR to the chosen runtime adapter to emit
86
+ ``<out>/runtime/<target>/`` with workflow.py + activities.py.
87
+ 4. Print a one-screen summary of what was produced.
88
+
89
+ Failure modes are reported with EX_USAGE (2) for user errors and
90
+ EX_SOFTWARE (70) for everything else.
91
+ """
92
+ skill_path = Path(args.skill_path)
93
+ if not skill_path.is_dir():
94
+ print(
95
+ f"error: skill path is not a directory: {skill_path}",
96
+ file=sys.stderr,
97
+ )
98
+ return 2
99
+
100
+ out_dir = Path(args.out)
101
+ graduated_dir = out_dir / "graduated"
102
+ runtime_dir = out_dir / "runtime" / args.runtime
103
+
104
+ graduator = Graduator(agent=args.agent, model=args.model)
105
+
106
+ try:
107
+ result = asyncio.run(graduator.graduate(skill_path, graduated_dir))
108
+ except GraduatorError as e:
109
+ print(f"rote graduate: {e}", file=sys.stderr)
110
+ return 1
111
+ except KeyboardInterrupt:
112
+ print("rote graduate: interrupted", file=sys.stderr)
113
+ return 130
114
+
115
+ try:
116
+ adapter = get_adapter(args.runtime)
117
+ except KeyError as e:
118
+ print(f"error: {e.args[0]}", file=sys.stderr)
119
+ return 2
120
+
121
+ written = adapter.emit(result.pipeline, runtime_dir)
122
+
123
+ # ── Summary ──
124
+ print(f"rote graduate: ✓ {result.pipeline.name} v{result.pipeline.version}")
125
+ print(f" driver: {result.driver_name}")
126
+ if result.driver_metadata:
127
+ meta_str = ", ".join(f"{k}={v}" for k, v in result.driver_metadata.items())
128
+ print(f" metadata: {meta_str}")
129
+ print(f" graduated artifacts: {graduated_dir}")
130
+ print(f" emitted runtime ({args.runtime}): {runtime_dir}")
131
+ for label, path in written.items():
132
+ print(f" {label}: {path}")
133
+ return 0
134
+
135
+
136
+ # ───────── Subcommand: register ─────────
137
+
138
+
139
+ def _resolve_pipeline_yaml(path: Path) -> Path | None:
140
+ """Find the pipeline.yaml behind a user-supplied path.
141
+
142
+ Accepts the file itself, a directory containing one, or a
143
+ ``rote graduate --out`` directory (which nests it under graduated/).
144
+ """
145
+ if path.is_file():
146
+ return path
147
+ for candidate in (path / "pipeline.yaml", path / "graduated" / "pipeline.yaml"):
148
+ if candidate.is_file():
149
+ return candidate
150
+ return None
151
+
152
+
153
+ def _cmd_register(args: argparse.Namespace) -> int:
154
+ """Add or update a registry entry for a graduated pipeline.
155
+
156
+ Reads the pipeline.yaml from a graduate run, derives the MCP tool
157
+ name / description / inputSchema from it, attaches the runtime
158
+ trigger config from the flags, and upserts ``~/.rote/registry.json``
159
+ (or ``--registry``). ``rote serve`` picks the change up live.
160
+ """
161
+ from rote.serve.registry import (
162
+ CloudflareTrigger,
163
+ Registry,
164
+ TemporalTrigger,
165
+ default_registry_path,
166
+ entry_from_pipeline,
167
+ )
168
+
169
+ pipeline_yaml = _resolve_pipeline_yaml(Path(args.graduated_dir))
170
+ if pipeline_yaml is None:
171
+ print(
172
+ f"error: no pipeline.yaml found at or under: {args.graduated_dir}",
173
+ file=sys.stderr,
174
+ )
175
+ return 2
176
+
177
+ try:
178
+ pipeline = load_pipeline(pipeline_yaml)
179
+ except Exception as e:
180
+ print(f"error: failed to load pipeline: {e}", file=sys.stderr)
181
+ return 1
182
+
183
+ trigger: TemporalTrigger | CloudflareTrigger
184
+ if args.runtime == "temporal":
185
+ workflow_name = args.workflow_name
186
+ if workflow_name is None:
187
+ # Must match the versioned @workflow.defn name the Temporal
188
+ # adapter emits (PascalCase + pipeline hash).
189
+ from rote.adapters._common import _pipeline_hash, _to_pascal_case
190
+
191
+ workflow_name = f"{_to_pascal_case(pipeline.name)}_{_pipeline_hash(pipeline)}"
192
+ trigger = TemporalTrigger(
193
+ address=args.temporal_address,
194
+ namespace=args.temporal_namespace,
195
+ task_queue=args.task_queue or pipeline.name,
196
+ workflow_name=workflow_name,
197
+ )
198
+ else: # cloudflare
199
+ if not args.url:
200
+ print(
201
+ "error: --url is required for --runtime cloudflare "
202
+ "(the deployed worker's trigger endpoint)",
203
+ file=sys.stderr,
204
+ )
205
+ return 2
206
+ trigger = CloudflareTrigger(url=args.url, status_url=args.status_url)
207
+
208
+ try:
209
+ entry = entry_from_pipeline(pipeline, pipeline_yaml, trigger, name=args.name)
210
+ except Exception as e:
211
+ print(f"error: {e}", file=sys.stderr)
212
+ return 1
213
+
214
+ registry_path = Path(args.registry) if args.registry else default_registry_path()
215
+ registry = Registry.load(registry_path)
216
+ replaced = registry.upsert(entry)
217
+ registry.save(registry_path)
218
+
219
+ verb = "updated" if replaced else "registered"
220
+ print(f"rote register: {verb} tool '{entry.name}' ({args.runtime}) → {registry_path}")
221
+ print(f" pipeline: {entry.pipeline_yaml}")
222
+ print(f" serve it: rote serve --registry {registry_path}")
223
+ return 0
224
+
225
+
226
+ # ───────── Subcommand: serve ─────────
227
+
228
+
229
+ def _cmd_serve(args: argparse.Namespace) -> int:
230
+ """Launch the MCP server exposing every registered pipeline as a tool.
231
+
232
+ stdio by default (what ``claude mcp add`` expects); ``--http`` for
233
+ Streamable HTTP. Nothing may be printed to stdout in stdio mode —
234
+ stdout is the MCP transport.
235
+ """
236
+ from rote.serve.registry import default_registry_path
237
+
238
+ try:
239
+ from rote.serve.server import build_server
240
+ except ImportError as e:
241
+ print(
242
+ f"error: fastmcp is not installed ({e}). "
243
+ "Install the serve extra: pip install 'rote[serve]'",
244
+ file=sys.stderr,
245
+ )
246
+ return 2
247
+
248
+ registry_path = Path(args.registry) if args.registry else default_registry_path()
249
+ if not registry_path.exists():
250
+ print(
251
+ f"rote serve: registry {registry_path} does not exist yet — serving an "
252
+ f"empty tool list. Register a pipeline with `rote register`.",
253
+ file=sys.stderr,
254
+ )
255
+
256
+ server = build_server(registry_path)
257
+ if args.http:
258
+ server.run(transport="http", host=args.host, port=args.port, show_banner=False)
259
+ else:
260
+ server.run(show_banner=False) # stdio
261
+ return 0
262
+
263
+
264
+ def _cmd_analyze(args: argparse.Namespace) -> int:
265
+ print("rote analyze: not yet implemented.", file=sys.stderr)
266
+ return 70
267
+
268
+
269
+ def _cmd_eval(args: argparse.Namespace) -> int:
270
+ print("rote eval: not yet implemented.", file=sys.stderr)
271
+ return 70
272
+
273
+
274
+ # ───────── Argument parsing ─────────
275
+
276
+
277
+ def _build_parser() -> argparse.ArgumentParser:
278
+ parser = argparse.ArgumentParser(
279
+ prog="rote",
280
+ description=(
281
+ "Graduate fuzzy AI skills into deterministic, reliable workflows. "
282
+ "The north-star command is `rote graduate`; `rote emit` exposes "
283
+ "the underlying IR → code step for users who already have a "
284
+ "pipeline.yaml."
285
+ ),
286
+ )
287
+ parser.add_argument(
288
+ "-v",
289
+ "--version",
290
+ action="version",
291
+ version=f"rote {__version__}",
292
+ )
293
+
294
+ subparsers = parser.add_subparsers(
295
+ dest="command",
296
+ metavar="<command>",
297
+ )
298
+
299
+ available_runtimes = sorted(ADAPTERS)
300
+
301
+ # rote emit
302
+ emit = subparsers.add_parser(
303
+ "emit",
304
+ help="Render runtime code from a pipeline.yaml (IR → code only)",
305
+ description=(
306
+ "Render runtime code from a pipeline.yaml. This is the pure "
307
+ "IR → adapter step — no graduator agent is invoked."
308
+ ),
309
+ )
310
+ emit.add_argument(
311
+ "pipeline_yaml",
312
+ help="Path to a pipeline.yaml (IR file)",
313
+ )
314
+ emit.add_argument(
315
+ "--runtime",
316
+ default="temporal",
317
+ choices=available_runtimes,
318
+ help=(
319
+ f"Target workflow runtime (default: temporal). "
320
+ f"Available: {', '.join(available_runtimes)}"
321
+ ),
322
+ )
323
+ emit.add_argument(
324
+ "--out",
325
+ required=True,
326
+ help="Output directory (created if missing)",
327
+ )
328
+ emit.set_defaults(func=_cmd_emit)
329
+
330
+ # rote graduate (stub)
331
+ graduate = subparsers.add_parser(
332
+ "graduate",
333
+ help="Graduate a skill into a runnable pipeline (north-star command)",
334
+ description=(
335
+ "Read a skill bundle, run the rote-graduate agent to produce "
336
+ "a pipeline IR + extracted modules + signature stubs, then "
337
+ "emit runtime code for the chosen adapter. Output layout: "
338
+ "<out>/graduated/ (the IR + stubs) and <out>/runtime/<target>/ "
339
+ "(the emitted workflow code)."
340
+ ),
341
+ )
342
+ graduate.add_argument("skill_path", help="Path to the source skill directory")
343
+ graduate.add_argument(
344
+ "--runtime",
345
+ default="temporal",
346
+ choices=available_runtimes,
347
+ )
348
+ graduate.add_argument(
349
+ "--out",
350
+ required=True,
351
+ help="Output directory for the graduated artifacts",
352
+ )
353
+ graduate.add_argument(
354
+ "--agent",
355
+ choices=["claude", "codex", "api"],
356
+ default=None,
357
+ help=(
358
+ "Agent runtime to use for the graduator. Defaults to auto-detect "
359
+ "(claude CLI first, then codex, then anthropic API). Users with "
360
+ "a Claude Max/Pro or ChatGPT subscription should prefer claude/codex "
361
+ "to avoid per-token API charges."
362
+ ),
363
+ )
364
+ graduate.add_argument(
365
+ "--model",
366
+ default=None,
367
+ help=(
368
+ "Override the LLM model used by the graduator agent "
369
+ "(e.g. 'claude-opus-4-6' for higher-quality / higher-cost "
370
+ "runs on complex skills). Defaults to the driver's default, "
371
+ "which is Sonnet 4.6 for the subscription path."
372
+ ),
373
+ )
374
+ graduate.set_defaults(func=_cmd_graduate)
375
+
376
+ # rote register
377
+ register = subparsers.add_parser(
378
+ "register",
379
+ help="Register a graduated pipeline as an MCP tool for `rote serve`",
380
+ description=(
381
+ "Append or update an entry in the serve registry "
382
+ "(~/.rote/registry.json by default) from a graduate run's "
383
+ "pipeline.yaml. Tool name comes from pipeline.name, description "
384
+ "from pipeline.description, inputSchema from the pipeline input "
385
+ "contract. A running `rote serve` picks the change up live."
386
+ ),
387
+ )
388
+ register.add_argument(
389
+ "graduated_dir",
390
+ help=(
391
+ "A graduate --out directory, a directory containing pipeline.yaml, "
392
+ "or the pipeline.yaml itself"
393
+ ),
394
+ )
395
+ register.add_argument(
396
+ "--registry",
397
+ default=None,
398
+ help="Registry file (default: ~/.rote/registry.json)",
399
+ )
400
+ register.add_argument(
401
+ "--runtime",
402
+ default="temporal",
403
+ choices=["temporal", "cloudflare"],
404
+ help="Runtime the graduated pipeline is deployed on (default: temporal)",
405
+ )
406
+ register.add_argument(
407
+ "--name",
408
+ default=None,
409
+ help="Override the MCP tool name (default: pipeline.name)",
410
+ )
411
+ register.add_argument(
412
+ "--temporal-address",
413
+ default="localhost:7233",
414
+ help="Temporal frontend address (default: localhost:7233)",
415
+ )
416
+ register.add_argument(
417
+ "--temporal-namespace",
418
+ default="default",
419
+ help="Temporal namespace (default: default)",
420
+ )
421
+ register.add_argument(
422
+ "--task-queue",
423
+ default=None,
424
+ help="Temporal task queue the graduated worker polls (default: pipeline.name)",
425
+ )
426
+ register.add_argument(
427
+ "--workflow-name",
428
+ default=None,
429
+ help=(
430
+ "Temporal workflow type name (default: the versioned name the "
431
+ "Temporal adapter emits for this pipeline)"
432
+ ),
433
+ )
434
+ register.add_argument(
435
+ "--url",
436
+ default=None,
437
+ help="Cloudflare: the deployed worker's trigger endpoint (required for cloudflare)",
438
+ )
439
+ register.add_argument(
440
+ "--status-url",
441
+ dest="status_url",
442
+ default=None,
443
+ help=(
444
+ "Cloudflare: optional status endpoint template containing "
445
+ "'{workflow_id}' if the worker exposes a status route"
446
+ ),
447
+ )
448
+ register.set_defaults(func=_cmd_register)
449
+
450
+ # rote serve
451
+ serve = subparsers.add_parser(
452
+ "serve",
453
+ help="Serve every registered pipeline as an MCP tool (stdio or HTTP)",
454
+ description=(
455
+ "Launch a single MCP server exposing one tool per registry entry "
456
+ "(plus a <tool>_status companion for polling long-running "
457
+ "workflows). stdio by default — add it to Claude Code with "
458
+ "`claude mcp add rote -- rote serve`. Use --http for Streamable "
459
+ "HTTP."
460
+ ),
461
+ )
462
+ serve.add_argument(
463
+ "--registry",
464
+ default=None,
465
+ help="Registry file (default: ~/.rote/registry.json)",
466
+ )
467
+ serve.add_argument(
468
+ "--http",
469
+ action="store_true",
470
+ help="Serve Streamable HTTP instead of stdio",
471
+ )
472
+ serve.add_argument(
473
+ "--host",
474
+ default="127.0.0.1",
475
+ help="HTTP bind host (default: 127.0.0.1; only with --http)",
476
+ )
477
+ serve.add_argument(
478
+ "--port",
479
+ type=int,
480
+ default=8734,
481
+ help="HTTP port (default: 8734; only with --http)",
482
+ )
483
+ serve.set_defaults(func=_cmd_serve)
484
+
485
+ # rote analyze (stub)
486
+ analyze = subparsers.add_parser(
487
+ "analyze",
488
+ help="Run the graduator against a skill and print a report only",
489
+ )
490
+ analyze.add_argument("skill_path")
491
+ analyze.set_defaults(func=_cmd_analyze)
492
+
493
+ # rote eval (stub)
494
+ eval_cmd = subparsers.add_parser(
495
+ "eval",
496
+ help="Compare graduator output against an expected/ baseline",
497
+ )
498
+ eval_cmd.add_argument("skill_path")
499
+ eval_cmd.set_defaults(func=_cmd_eval)
500
+
501
+ return parser
502
+
503
+
504
+ def main(argv: Sequence[str] | None = None) -> int:
505
+ parser = _build_parser()
506
+ args = parser.parse_args(argv)
507
+
508
+ if not getattr(args, "command", None):
509
+ # No subcommand — print a helpful banner and usage.
510
+ print(f"rote {__version__} — graduate fuzzy AI skills into deterministic workflows")
511
+ print()
512
+ parser.print_help()
513
+ return 0
514
+
515
+ func = args.func
516
+ return int(func(args))
517
+
518
+
519
+ if __name__ == "__main__":
520
+ raise SystemExit(main())