rhylthyme-cli-runner 0.2.3a0__tar.gz → 0.2.4a0__tar.gz

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 (85) hide show
  1. {rhylthyme_cli_runner-0.2.3a0/src/rhylthyme_cli_runner.egg-info → rhylthyme_cli_runner-0.2.4a0}/PKG-INFO +23 -2
  2. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/README.md +22 -1
  3. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/setup.py +1 -1
  4. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/__init__.py +1 -1
  5. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/cli.py +10 -0
  6. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/history/render.py +9 -0
  7. rhylthyme_cli_runner-0.2.4a0/src/rhylthyme_cli_runner/import_cli.py +250 -0
  8. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0/src/rhylthyme_cli_runner.egg-info}/PKG-INFO +23 -2
  9. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner.egg-info/SOURCES.txt +2 -0
  10. rhylthyme_cli_runner-0.2.4a0/tests/test_import_cli.py +228 -0
  11. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/LICENSE +0 -0
  12. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/pyproject.toml +0 -0
  13. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/setup.cfg +0 -0
  14. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/environment_icons.py +0 -0
  15. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/environment_loader.py +0 -0
  16. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/environment_schemas.py +0 -0
  17. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/eval/__init__.py +0 -0
  18. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/eval/cli.py +0 -0
  19. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/eval/compare.py +0 -0
  20. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/eval/gold.py +0 -0
  21. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/eval/harness.py +0 -0
  22. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/eval/llm.py +0 -0
  23. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/eval/matcher.py +0 -0
  24. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/eval/metrics.py +0 -0
  25. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/eval/patterns/__init__.py +0 -0
  26. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/eval/patterns/authoring_guide.md +0 -0
  27. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/eval/patterns/baseline.md +0 -0
  28. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/eval/patterns/baseline.py +0 -0
  29. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/eval/patterns/extract.py +0 -0
  30. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/eval/patterns/four_turn.py +0 -0
  31. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/eval/report.py +0 -0
  32. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/expand_replicates.py +0 -0
  33. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/history/__init__.py +0 -0
  34. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/history/calibrate.py +0 -0
  35. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/history/calibrate_cli.py +0 -0
  36. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/history/cli.py +0 -0
  37. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/history/evaluate.py +0 -0
  38. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/history/evaluate_cli.py +0 -0
  39. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/history/factors.py +0 -0
  40. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/history/hash.py +0 -0
  41. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/history/predict.py +0 -0
  42. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/history/recorder.py +0 -0
  43. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/history/replay.py +0 -0
  44. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/history/report.py +0 -0
  45. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/history/report_cli.py +0 -0
  46. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/history/store.py +0 -0
  47. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/history/synth.py +0 -0
  48. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/history/usable.py +0 -0
  49. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/instance_checks.py +0 -0
  50. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/program_planner.py +0 -0
  51. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/program_runner.py +0 -0
  52. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/remote/__init__.py +0 -0
  53. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/remote/auth.py +0 -0
  54. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/remote/checks.py +0 -0
  55. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/remote/cli.py +0 -0
  56. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/remote/mcp_client.py +0 -0
  57. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner/validate_program.py +0 -0
  58. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner.egg-info/dependency_links.txt +0 -0
  59. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner.egg-info/entry_points.txt +0 -0
  60. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner.egg-info/requires.txt +0 -0
  61. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/src/rhylthyme_cli_runner.egg-info/top_level.txt +0 -0
  62. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_calibrate.py +0 -0
  63. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_cli.py +0 -0
  64. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_eval_baseline.py +0 -0
  65. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_eval_harness.py +0 -0
  66. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_eval_providers.py +0 -0
  67. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_eval_scorer.py +0 -0
  68. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_evaluate.py +0 -0
  69. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_example_validation.py +0 -0
  70. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_examples_integration.py +0 -0
  71. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_expand_replicates.py +0 -0
  72. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_hash_parity.py +0 -0
  73. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_history_report.py +0 -0
  74. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_mcp_checks.py +0 -0
  75. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_planner_time_strings.py +0 -0
  76. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_predict.py +0 -0
  77. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_program_runner.py +0 -0
  78. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_remote.py +0 -0
  79. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_replay.py +0 -0
  80. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_run_recorder.py +0 -0
  81. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_skill.py +0 -0
  82. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_synth.py +0 -0
  83. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_usable.py +0 -0
  84. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_validate_examples_ci.py +0 -0
  85. {rhylthyme_cli_runner-0.2.3a0 → rhylthyme_cli_runner-0.2.4a0}/tests/test_validate_program.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rhylthyme-cli-runner
3
- Version: 0.2.3a0
3
+ Version: 0.2.4a0
4
4
  Summary: CLI runner for Rhylthyme real-time program schedules
5
5
  Home-page: https://github.com/rhylthyme/rhylthyme-cli-runner
6
6
  Author: Rhylthyme Team
@@ -145,11 +145,12 @@ is the **server an AI assistant talks to**.
145
145
  | Language | JavaScript (Node 20+) | Python 3.12+ |
146
146
  | Input | A program the assistant builds in conversation | A program file on disk (JSON or YAML) |
147
147
  | Validate a program | `validate_program` | `rhylthyme validate` (works offline) |
148
+ | Import a recipe or protocol | `import_from_source` | `rhylthyme import` |
148
149
  | Timing, conflicts, deadlines | `analyze_schedule` | `rhylthyme analyze` (asks the server) |
149
150
  | Publish a live timeline | `visualize_schedule` | `rhylthyme publish` (asks the server) |
150
151
  | Run a schedule with timers | no: it hands back a link to the web timeline | `rhylthyme run`, an interactive terminal runner |
151
152
  | Recorded runs, calibration | reads runs saved to an account | records runs locally; `rhylthyme runs`, `rhylthyme calibrate` |
152
- | Catalog search, imports, account library | yes | no |
153
+ | Catalog search, account library | yes | no |
153
154
  | Also in the repository | the `rhylthyme-mcp` PyPI package (a stdio bridge to the hosted server), the Claude plugin marketplace | the Claude skill's source, the prompt-evaluation harness and its results, `rhylthyme mcp-test` |
154
155
 
155
156
  How they fit together: this tool is one of the MCP server's clients.
@@ -542,6 +543,26 @@ A published timeline is reachable by anyone who has the link.
542
543
  - `--image PATH`: also save a PNG of the timeline
543
544
  - `--open`: open it in a browser
544
545
 
546
+ ### `rhylthyme import`
547
+
548
+ Imports a recipe, protocol or slide deck as a program, validates it, and
549
+ writes `<programId>.json`. Needs `rhylthyme-importers` (included in
550
+ `pip install rhylthyme`).
551
+
552
+ ```bash
553
+ rhylthyme import https://www.seriouseats.com/the-best-chili-recipe # any of ~580 recipe sites
554
+ rhylthyme import 52772 -i themealdb --publish # a TheMealDB id, straight to a live timeline
555
+ rhylthyme import https://www.protocols.io/view/western-blot-... # needs PROTOCOLS_IO_TOKEN
556
+ rhylthyme import protocol.py -o protocol.json # an Opentrons file
557
+ rhylthyme import "Neapolitan Pizza.cook" -i cooklang
558
+ rhylthyme importers # what is installed
559
+ rhylthyme search "pad thai" -i spoonacular # then import a hit's URL
560
+ ```
561
+
562
+ **Options:** `-i IMPORTER` (default: chosen from the URL), `-o PATH`,
563
+ `--stdout`, `--publish` (and `--open`), `--no-validate`. `-` reads pasted
564
+ source from stdin (with `-i`).
565
+
545
566
  ### `rhylthyme plan`
546
567
 
547
568
  An older stagger heuristic. It reads the pre-0.2 program format and leaves
@@ -85,11 +85,12 @@ is the **server an AI assistant talks to**.
85
85
  | Language | JavaScript (Node 20+) | Python 3.12+ |
86
86
  | Input | A program the assistant builds in conversation | A program file on disk (JSON or YAML) |
87
87
  | Validate a program | `validate_program` | `rhylthyme validate` (works offline) |
88
+ | Import a recipe or protocol | `import_from_source` | `rhylthyme import` |
88
89
  | Timing, conflicts, deadlines | `analyze_schedule` | `rhylthyme analyze` (asks the server) |
89
90
  | Publish a live timeline | `visualize_schedule` | `rhylthyme publish` (asks the server) |
90
91
  | Run a schedule with timers | no: it hands back a link to the web timeline | `rhylthyme run`, an interactive terminal runner |
91
92
  | Recorded runs, calibration | reads runs saved to an account | records runs locally; `rhylthyme runs`, `rhylthyme calibrate` |
92
- | Catalog search, imports, account library | yes | no |
93
+ | Catalog search, account library | yes | no |
93
94
  | Also in the repository | the `rhylthyme-mcp` PyPI package (a stdio bridge to the hosted server), the Claude plugin marketplace | the Claude skill's source, the prompt-evaluation harness and its results, `rhylthyme mcp-test` |
94
95
 
95
96
  How they fit together: this tool is one of the MCP server's clients.
@@ -482,6 +483,26 @@ A published timeline is reachable by anyone who has the link.
482
483
  - `--image PATH`: also save a PNG of the timeline
483
484
  - `--open`: open it in a browser
484
485
 
486
+ ### `rhylthyme import`
487
+
488
+ Imports a recipe, protocol or slide deck as a program, validates it, and
489
+ writes `<programId>.json`. Needs `rhylthyme-importers` (included in
490
+ `pip install rhylthyme`).
491
+
492
+ ```bash
493
+ rhylthyme import https://www.seriouseats.com/the-best-chili-recipe # any of ~580 recipe sites
494
+ rhylthyme import 52772 -i themealdb --publish # a TheMealDB id, straight to a live timeline
495
+ rhylthyme import https://www.protocols.io/view/western-blot-... # needs PROTOCOLS_IO_TOKEN
496
+ rhylthyme import protocol.py -o protocol.json # an Opentrons file
497
+ rhylthyme import "Neapolitan Pizza.cook" -i cooklang
498
+ rhylthyme importers # what is installed
499
+ rhylthyme search "pad thai" -i spoonacular # then import a hit's URL
500
+ ```
501
+
502
+ **Options:** `-i IMPORTER` (default: chosen from the URL), `-o PATH`,
503
+ `--stdout`, `--publish` (and `--open`), `--no-validate`. `-` reads pasted
504
+ source from stdin (with `-i`).
505
+
485
506
  ### `rhylthyme plan`
486
507
 
487
508
  An older stagger heuristic. It reads the pre-0.2 program format and leaves
@@ -19,7 +19,7 @@ def read_readme():
19
19
 
20
20
  setup(
21
21
  name="rhylthyme-cli-runner",
22
- version="0.2.3a0",
22
+ version="0.2.4a0",
23
23
  description="CLI runner for Rhylthyme real-time program schedules",
24
24
  long_description=read_readme(),
25
25
  long_description_content_type="text/markdown",
@@ -6,7 +6,7 @@ This package provides the command-line interface for running and validating
6
6
  Rhylthyme real-time program schedules.
7
7
  """
8
8
 
9
- __version__ = "0.2.3a0"
9
+ __version__ = "0.2.4a0"
10
10
  __author__ = "Rhylthyme Team"
11
11
  __description__ = "CLI runner for Rhylthyme real-time program schedules"
12
12
 
@@ -544,6 +544,16 @@ def _register_remote_commands():
544
544
  _register_remote_commands()
545
545
 
546
546
 
547
+ def _register_import_commands():
548
+ """Attach `import`, `search`, `importers` (rhylthyme_cli_runner.import_cli)."""
549
+ from .import_cli import register as register_import
550
+
551
+ register_import(cli)
552
+
553
+
554
+ _register_import_commands()
555
+
556
+
547
557
  def main():
548
558
  """Entry point for the CLI."""
549
559
  cli()
@@ -76,6 +76,15 @@ def find_renderer() -> Optional[Path]:
76
76
  candidate = Path(explicit).expanduser()
77
77
  # Absolute: `node -e` has no script directory to resolve against.
78
78
  return candidate.resolve() if candidate.is_file() else None
79
+ # The pip package (rhylthyme-timeline) carries the renderer.
80
+ try:
81
+ from rhylthyme_timeline import renderer_path
82
+
83
+ packaged = renderer_path()
84
+ if packaged.is_file():
85
+ return packaged
86
+ except ImportError:
87
+ pass
79
88
  here = Path(__file__).resolve()
80
89
  for parent in here.parents:
81
90
  for relative in _RENDERER_PATHS:
@@ -0,0 +1,250 @@
1
+ """``rhylthyme import`` and ``rhylthyme search``: the importers from
2
+ rhylthyme-importers, reached from the one command people already have.
3
+
4
+ ``rhylthyme-import`` (the importers' own command) still exists; this is the
5
+ same work with the CLI's validation and publishing on the end of it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ import tempfile
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ import click
17
+
18
+ INSTALL_HINT = (
19
+ "The importers are a separate package: pip install rhylthyme-importers "
20
+ "(or pip install rhylthyme, which includes them)."
21
+ )
22
+
23
+
24
+ def _registry():
25
+ try:
26
+ from rhylthyme_importers import ImporterRegistry
27
+ except ImportError:
28
+ raise click.ClickException(INSTALL_HINT)
29
+ return ImporterRegistry
30
+
31
+
32
+ def _pick(registry, source: str, importer_name: Optional[str]):
33
+ if importer_name:
34
+ importer = registry.get(importer_name)
35
+ if importer is None:
36
+ names = ", ".join(sorted(i["name"] for i in registry.list_importers()))
37
+ raise click.ClickException(
38
+ f"Unknown importer '{importer_name}'. Installed: {names}."
39
+ )
40
+ return importer
41
+ importer = registry.find_for_url(source)
42
+ if importer is None:
43
+ names = ", ".join(sorted(i["name"] for i in registry.list_importers()))
44
+ raise click.ClickException(
45
+ f"No importer recognises {source!r}. Pass -i with one of: {names}. "
46
+ "For a file on disk, name its importer (-i opentrons, -i cooklang, -i slidedeck)."
47
+ )
48
+ return importer
49
+
50
+
51
+ def _import_one(importer, source: str, text: Optional[str]):
52
+ if text is not None and hasattr(importer, "import_from_source"):
53
+ return importer.import_from_source(text)
54
+ if text is not None and hasattr(importer, "import_from_content"):
55
+ return importer.import_from_content(
56
+ text, source_name=Path(source).stem if source != "-" else "recipe"
57
+ )
58
+ path = Path(source)
59
+ if path.is_file():
60
+ # A local file was named explicitly; that is the command line's job
61
+ # to allow (a server never does this).
62
+ if hasattr(importer, "allow_local_files"):
63
+ importer.allow_local_files = True
64
+ return importer.import_from_url(str(path))
65
+ return importer.import_from_url(source)
66
+
67
+
68
+ def _validation(program: dict):
69
+ from .validate_program import validate_program_file_structured
70
+
71
+ with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as tmp:
72
+ json.dump(program, tmp)
73
+ tmp_path = tmp.name
74
+ try:
75
+ from .cli import _default_schema_path
76
+
77
+ return validate_program_file_structured(
78
+ tmp_path, schema_file=_default_schema_path()
79
+ )
80
+ finally:
81
+ Path(tmp_path).unlink(missing_ok=True)
82
+
83
+
84
+ @click.command("import")
85
+ @click.argument("source", metavar="URL_OR_ID_OR_FILE")
86
+ @click.option(
87
+ "-i",
88
+ "--importer",
89
+ "importer_name",
90
+ default=None,
91
+ help="Which importer to use (default: chosen from the URL).",
92
+ )
93
+ @click.option(
94
+ "-o",
95
+ "--output",
96
+ type=click.Path(dir_okay=False),
97
+ default=None,
98
+ help="Write the program here (default: <name>.json in the current directory).",
99
+ )
100
+ @click.option(
101
+ "--stdout",
102
+ "to_stdout",
103
+ is_flag=True,
104
+ help="Print the program instead of writing a file.",
105
+ )
106
+ @click.option(
107
+ "--publish",
108
+ "do_publish",
109
+ is_flag=True,
110
+ help="Also publish it as a live timeline and print the URL.",
111
+ )
112
+ @click.option(
113
+ "--open",
114
+ "open_url",
115
+ is_flag=True,
116
+ help="With --publish: open the timeline in a browser.",
117
+ )
118
+ @click.option(
119
+ "--no-validate", is_flag=True, help="Skip validation of the imported program."
120
+ )
121
+ def import_command(
122
+ source, importer_name, output, to_stdout, do_publish, open_url, no_validate
123
+ ):
124
+ """Import a recipe, protocol or deck as a program.
125
+
126
+ \b
127
+ URL_OR_ID_OR_FILE is a recipe-site or protocols.io URL, a TheMealDB or
128
+ Spoonacular id, a .cook or GitHub .cook URL, or a local .py (Opentrons),
129
+ .cook or .pptx file. `-` reads the source text from stdin (with -i).
130
+ rhylthyme import https://www.seriouseats.com/the-best-chili-recipe
131
+ rhylthyme import 52772 -i themealdb --publish
132
+ rhylthyme import protocol.py -o protocol.json
133
+ rhylthyme importers # what is installed
134
+ """
135
+ registry = _registry()
136
+ text = None
137
+ if source == "-":
138
+ if not importer_name:
139
+ raise click.ClickException(
140
+ "Reading from stdin needs -i to say which importer."
141
+ )
142
+ text = sys.stdin.read()
143
+ importer = _pick(registry, source, importer_name)
144
+ click.echo(f"Importing with {importer.name}…", err=True)
145
+ result = _import_one(importer, source, text)
146
+ if not result.success:
147
+ raise click.ClickException(result.error or "import failed")
148
+ program = result.program
149
+
150
+ if not no_validate:
151
+ v = _validation(program)
152
+ errors = v.get("errors") or v.get("logic_errors") or []
153
+ if not v.get("is_valid", v.get("valid", True)):
154
+ for e in errors[:8]:
155
+ click.echo(
156
+ f" ✗ {e if isinstance(e, str) else e.get('message', e)}", err=True
157
+ )
158
+ raise click.ClickException(
159
+ "The imported program does not validate; not published. Re-run with --no-validate to keep it anyway."
160
+ )
161
+ for w in (v.get("warnings") or [])[:5]:
162
+ click.echo(
163
+ f" ! {w if isinstance(w, str) else w.get('message', w)}", err=True
164
+ )
165
+
166
+ payload = json.dumps(program, indent=2, ensure_ascii=False)
167
+ if to_stdout:
168
+ click.echo(payload)
169
+ else:
170
+ target = (
171
+ Path(output)
172
+ if output
173
+ else Path(f"{program.get('programId') or 'program'}.json")
174
+ )
175
+ target.write_text(payload + "\n")
176
+ click.echo(f"Saved {target}", err=True)
177
+
178
+ if do_publish:
179
+ from .remote.cli import publish as publish_command
180
+
181
+ ctx = click.get_current_context()
182
+ with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as tmp:
183
+ tmp.write(payload)
184
+ tmp_path = tmp.name
185
+ try:
186
+ ctx.invoke(
187
+ publish_command,
188
+ program_file=tmp_path,
189
+ environment=None,
190
+ open_url=open_url,
191
+ json_output=False,
192
+ quiet=True,
193
+ image_path=None,
194
+ )
195
+ finally:
196
+ Path(tmp_path).unlink(missing_ok=True)
197
+
198
+
199
+ @click.command("search")
200
+ @click.argument("query")
201
+ @click.option(
202
+ "-i",
203
+ "--importer",
204
+ "importer_name",
205
+ default="themealdb",
206
+ show_default=True,
207
+ help="Which catalogue to search.",
208
+ )
209
+ @click.option("--json", "json_output", is_flag=True)
210
+ def search_command(query, importer_name, json_output):
211
+ """Search a recipe or protocol source; import a hit with `rhylthyme import <url>`."""
212
+ registry = _registry()
213
+ importer = registry.get(importer_name)
214
+ if importer is None:
215
+ names = ", ".join(sorted(i["name"] for i in registry.list_importers()))
216
+ raise click.ClickException(
217
+ f"Unknown importer '{importer_name}'. Installed: {names}."
218
+ )
219
+ hits = importer.search(query)
220
+ if json_output:
221
+ click.echo(json.dumps(hits, indent=2, ensure_ascii=False))
222
+ return
223
+ if not hits:
224
+ click.echo("No results.")
225
+ return
226
+ for h in hits:
227
+ click.echo(f"{h.get('name')}\n {h.get('url')}")
228
+ if h.get("description"):
229
+ click.echo(f" {h['description']}")
230
+
231
+
232
+ @click.command("importers")
233
+ def importers_command():
234
+ """List the installed importers."""
235
+ registry = _registry()
236
+ for info in registry.list_importers():
237
+ domains = info.get("supported_domains") or []
238
+ where = (
239
+ ", ".join(domains[:4])
240
+ + (f", … ({len(domains)} sites)" if len(domains) > 4 else "")
241
+ if domains
242
+ else "files, ids or pasted text"
243
+ )
244
+ click.echo(f"{info['name']:18} {info['description']} [{where}]")
245
+
246
+
247
+ def register(cli_group: click.Group) -> None:
248
+ cli_group.add_command(import_command)
249
+ cli_group.add_command(search_command)
250
+ cli_group.add_command(importers_command)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rhylthyme-cli-runner
3
- Version: 0.2.3a0
3
+ Version: 0.2.4a0
4
4
  Summary: CLI runner for Rhylthyme real-time program schedules
5
5
  Home-page: https://github.com/rhylthyme/rhylthyme-cli-runner
6
6
  Author: Rhylthyme Team
@@ -145,11 +145,12 @@ is the **server an AI assistant talks to**.
145
145
  | Language | JavaScript (Node 20+) | Python 3.12+ |
146
146
  | Input | A program the assistant builds in conversation | A program file on disk (JSON or YAML) |
147
147
  | Validate a program | `validate_program` | `rhylthyme validate` (works offline) |
148
+ | Import a recipe or protocol | `import_from_source` | `rhylthyme import` |
148
149
  | Timing, conflicts, deadlines | `analyze_schedule` | `rhylthyme analyze` (asks the server) |
149
150
  | Publish a live timeline | `visualize_schedule` | `rhylthyme publish` (asks the server) |
150
151
  | Run a schedule with timers | no: it hands back a link to the web timeline | `rhylthyme run`, an interactive terminal runner |
151
152
  | Recorded runs, calibration | reads runs saved to an account | records runs locally; `rhylthyme runs`, `rhylthyme calibrate` |
152
- | Catalog search, imports, account library | yes | no |
153
+ | Catalog search, account library | yes | no |
153
154
  | Also in the repository | the `rhylthyme-mcp` PyPI package (a stdio bridge to the hosted server), the Claude plugin marketplace | the Claude skill's source, the prompt-evaluation harness and its results, `rhylthyme mcp-test` |
154
155
 
155
156
  How they fit together: this tool is one of the MCP server's clients.
@@ -542,6 +543,26 @@ A published timeline is reachable by anyone who has the link.
542
543
  - `--image PATH`: also save a PNG of the timeline
543
544
  - `--open`: open it in a browser
544
545
 
546
+ ### `rhylthyme import`
547
+
548
+ Imports a recipe, protocol or slide deck as a program, validates it, and
549
+ writes `<programId>.json`. Needs `rhylthyme-importers` (included in
550
+ `pip install rhylthyme`).
551
+
552
+ ```bash
553
+ rhylthyme import https://www.seriouseats.com/the-best-chili-recipe # any of ~580 recipe sites
554
+ rhylthyme import 52772 -i themealdb --publish # a TheMealDB id, straight to a live timeline
555
+ rhylthyme import https://www.protocols.io/view/western-blot-... # needs PROTOCOLS_IO_TOKEN
556
+ rhylthyme import protocol.py -o protocol.json # an Opentrons file
557
+ rhylthyme import "Neapolitan Pizza.cook" -i cooklang
558
+ rhylthyme importers # what is installed
559
+ rhylthyme search "pad thai" -i spoonacular # then import a hit's URL
560
+ ```
561
+
562
+ **Options:** `-i IMPORTER` (default: chosen from the URL), `-o PATH`,
563
+ `--stdout`, `--publish` (and `--open`), `--no-validate`. `-` reads pasted
564
+ source from stdin (with `-i`).
565
+
545
566
  ### `rhylthyme plan`
546
567
 
547
568
  An older stagger heuristic. It reads the pre-0.2 program format and leaves
@@ -8,6 +8,7 @@ src/rhylthyme_cli_runner/environment_icons.py
8
8
  src/rhylthyme_cli_runner/environment_loader.py
9
9
  src/rhylthyme_cli_runner/environment_schemas.py
10
10
  src/rhylthyme_cli_runner/expand_replicates.py
11
+ src/rhylthyme_cli_runner/import_cli.py
11
12
  src/rhylthyme_cli_runner/instance_checks.py
12
13
  src/rhylthyme_cli_runner/program_planner.py
13
14
  src/rhylthyme_cli_runner/program_runner.py
@@ -67,6 +68,7 @@ tests/test_examples_integration.py
67
68
  tests/test_expand_replicates.py
68
69
  tests/test_hash_parity.py
69
70
  tests/test_history_report.py
71
+ tests/test_import_cli.py
70
72
  tests/test_mcp_checks.py
71
73
  tests/test_planner_time_strings.py
72
74
  tests/test_predict.py
@@ -0,0 +1,228 @@
1
+ """`rhylthyme import`, `search` and `importers`, with a fake importer registry
2
+ so nothing is fetched."""
3
+
4
+ import json
5
+ import sys
6
+ import types
7
+
8
+ import pytest
9
+ from click.testing import CliRunner
10
+
11
+ from rhylthyme_cli_runner.cli import cli
12
+
13
+ PROGRAM = {
14
+ "schemaVersion": "0.3.0-alpha",
15
+ "programId": "fake-dinner",
16
+ "name": "Fake dinner",
17
+ "environmentType": "kitchen",
18
+ "tracks": [
19
+ {
20
+ "trackId": "a",
21
+ "name": "A",
22
+ "steps": [
23
+ {
24
+ "stepId": "s1",
25
+ "name": "Cook",
26
+ "task": "stove",
27
+ "duration": {"type": "fixed", "seconds": 600},
28
+ "startTrigger": {"type": "programStart"},
29
+ }
30
+ ],
31
+ }
32
+ ],
33
+ "resourceConstraints": [{"task": "stove", "maxConcurrent": 1}],
34
+ }
35
+ BAD = dict(
36
+ PROGRAM,
37
+ tracks=[
38
+ {
39
+ "trackId": "a",
40
+ "name": "A",
41
+ "steps": [
42
+ {
43
+ "stepId": "s1",
44
+ "name": "Cook",
45
+ "task": "stove",
46
+ "duration": {"type": "fixed", "seconds": 600},
47
+ "startTrigger": {"type": "afterStep", "stepId": "nope"},
48
+ }
49
+ ],
50
+ }
51
+ ],
52
+ )
53
+
54
+
55
+ class Result:
56
+ def __init__(self, program=None, error=None):
57
+ self.success = program is not None
58
+ self.program = program
59
+ self.error = error
60
+
61
+
62
+ class FakeImporter:
63
+ name = "fake"
64
+ description = "Fake source"
65
+ supported_domains = ["fake.example"]
66
+ allow_local_files = False
67
+
68
+ def __init__(self):
69
+ self.calls = []
70
+
71
+ def can_import(self, s):
72
+ return "fake.example" in s
73
+
74
+ def search(self, q):
75
+ return [
76
+ {
77
+ "name": f"Hit for {q}",
78
+ "url": "https://fake.example/1",
79
+ "description": "d",
80
+ }
81
+ ]
82
+
83
+ def import_from_url(self, url):
84
+ self.calls.append(("url", url, self.allow_local_files))
85
+ return Result(BAD if "bad" in url else PROGRAM)
86
+
87
+ def import_from_source(self, text):
88
+ self.calls.append(("text", text, None))
89
+ return Result(PROGRAM)
90
+
91
+
92
+ @pytest.fixture
93
+ def fake(monkeypatch):
94
+ importer = FakeImporter()
95
+
96
+ class Registry:
97
+ @staticmethod
98
+ def get(name):
99
+ return importer if name == "fake" else None
100
+
101
+ @staticmethod
102
+ def find_for_url(url):
103
+ return importer if importer.can_import(url) else None
104
+
105
+ @staticmethod
106
+ def list_importers():
107
+ return [
108
+ {
109
+ "name": "fake",
110
+ "description": "Fake source",
111
+ "supported_domains": ["fake.example"],
112
+ }
113
+ ]
114
+
115
+ module = types.ModuleType("rhylthyme_importers")
116
+ module.ImporterRegistry = Registry
117
+ monkeypatch.setitem(sys.modules, "rhylthyme_importers", module)
118
+ return importer
119
+
120
+
121
+ def test_import_by_url_writes_a_named_file(fake, tmp_path, monkeypatch):
122
+ monkeypatch.chdir(tmp_path)
123
+ r = CliRunner().invoke(cli, ["import", "https://fake.example/dinner"])
124
+ assert r.exit_code == 0, r.output
125
+ assert (
126
+ json.loads((tmp_path / "fake-dinner.json").read_text())["name"] == "Fake dinner"
127
+ )
128
+ assert "Importing with fake" in r.output
129
+
130
+
131
+ def test_explicit_importer_output_path_and_stdout(fake, tmp_path):
132
+ out = tmp_path / "d.json"
133
+ r = CliRunner().invoke(cli, ["import", "12345", "-i", "fake", "-o", str(out)])
134
+ assert (
135
+ r.exit_code == 0 and json.loads(out.read_text())["programId"] == "fake-dinner"
136
+ )
137
+ r = CliRunner().invoke(cli, ["import", "12345", "-i", "fake", "--stdout"])
138
+ assert r.exit_code == 0 and '"programId": "fake-dinner"' in r.output
139
+
140
+
141
+ def test_a_local_file_is_allowed_from_the_command_line(fake, tmp_path):
142
+ f = tmp_path / "protocol.py"
143
+ f.write_text("def run(protocol): pass\n")
144
+ r = CliRunner().invoke(cli, ["import", str(f), "-i", "fake", "--stdout"])
145
+ assert r.exit_code == 0, r.output
146
+ assert fake.calls[-1] == (
147
+ "url",
148
+ str(f),
149
+ True,
150
+ ), "the CLI opts in to local files; servers never do"
151
+
152
+
153
+ def test_stdin_needs_an_importer_and_goes_to_import_from_source(fake):
154
+ r = CliRunner().invoke(cli, ["import", "-"], input="text")
155
+ assert r.exit_code != 0 and "-i" in r.output
156
+ r = CliRunner().invoke(
157
+ cli, ["import", "-", "-i", "fake", "--stdout"], input="def run(p): pass"
158
+ )
159
+ assert r.exit_code == 0 and fake.calls[-1][0] == "text"
160
+
161
+
162
+ def test_an_invalid_import_is_reported_and_not_published(fake, tmp_path):
163
+ r = CliRunner().invoke(
164
+ cli,
165
+ [
166
+ "import",
167
+ "https://fake.example/bad",
168
+ "-o",
169
+ str(tmp_path / "x.json"),
170
+ "--publish",
171
+ ],
172
+ )
173
+ assert r.exit_code != 0
174
+ assert "does not validate" in r.output and "nope" in r.output
175
+ assert not (tmp_path / "x.json").exists()
176
+
177
+
178
+ def test_publish_after_import(fake, tmp_path, monkeypatch):
179
+ from rhylthyme_cli_runner.remote import cli as remote
180
+
181
+ seen = {}
182
+
183
+ def fake_publish(
184
+ program_file, environment, open_url, json_output, quiet, image_path
185
+ ):
186
+ seen["program"] = json.loads(open(program_file).read())
187
+ import click
188
+
189
+ click.echo("https://kitchen.rhylthyme.com?share=abc")
190
+
191
+ monkeypatch.setattr(remote.publish, "callback", fake_publish)
192
+ r = CliRunner().invoke(
193
+ cli,
194
+ [
195
+ "import",
196
+ "https://fake.example/dinner",
197
+ "-o",
198
+ str(tmp_path / "d.json"),
199
+ "--publish",
200
+ ],
201
+ )
202
+ assert r.exit_code == 0, r.output
203
+ assert seen["program"]["programId"] == "fake-dinner"
204
+ assert "share=abc" in r.output
205
+
206
+
207
+ def test_unknown_importer_and_unrecognised_source(fake):
208
+ r = CliRunner().invoke(cli, ["import", "x", "-i", "nope"])
209
+ assert r.exit_code != 0 and "Unknown importer" in r.output and "fake" in r.output
210
+ r = CliRunner().invoke(cli, ["import", "https://example.org/x"])
211
+ assert r.exit_code != 0 and "No importer recognises" in r.output
212
+
213
+
214
+ def test_search_and_importers(fake):
215
+ r = CliRunner().invoke(cli, ["search", "chili", "-i", "fake"])
216
+ assert (
217
+ r.exit_code == 0
218
+ and "Hit for chili" in r.output
219
+ and "https://fake.example/1" in r.output
220
+ )
221
+ r = CliRunner().invoke(cli, ["importers"])
222
+ assert r.exit_code == 0 and "fake" in r.output and "fake.example" in r.output
223
+
224
+
225
+ def test_without_the_importers_package_the_message_says_how_to_get_it(monkeypatch):
226
+ monkeypatch.setitem(sys.modules, "rhylthyme_importers", None)
227
+ r = CliRunner().invoke(cli, ["import", "https://fake.example/dinner"])
228
+ assert r.exit_code != 0 and "pip install rhylthyme-importers" in r.output