stepyard 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.
Files changed (84) hide show
  1. stepyard/__init__.py +8 -0
  2. stepyard/_version.py +24 -0
  3. stepyard/api/__init__.py +1 -0
  4. stepyard/api/service.py +540 -0
  5. stepyard/cli/__init__.py +10 -0
  6. stepyard/cli/__main__.py +4 -0
  7. stepyard/cli/app.py +266 -0
  8. stepyard/cli/commands/__init__.py +1 -0
  9. stepyard/cli/commands/doctor.py +99 -0
  10. stepyard/cli/commands/dx.py +155 -0
  11. stepyard/cli/commands/inspect.py +296 -0
  12. stepyard/cli/commands/interactive.py +135 -0
  13. stepyard/cli/commands/logs.py +298 -0
  14. stepyard/cli/commands/manage.py +211 -0
  15. stepyard/cli/commands/plugin.py +212 -0
  16. stepyard/cli/commands/run.py +281 -0
  17. stepyard/cli/commands/tools.py +514 -0
  18. stepyard/cli/completions.py +89 -0
  19. stepyard/cli/renderers/__init__.py +1 -0
  20. stepyard/cli/renderers/live_view.py +224 -0
  21. stepyard/cli/repl.py +325 -0
  22. stepyard/cli/run/__init__.py +1 -0
  23. stepyard/cli/run/inputs.py +199 -0
  24. stepyard/cli/run/panels.py +70 -0
  25. stepyard/cli/run/session.py +546 -0
  26. stepyard/cli/theme.py +68 -0
  27. stepyard/cli/ui.py +304 -0
  28. stepyard/config.py +50 -0
  29. stepyard/core/__init__.py +18 -0
  30. stepyard/core/errors.py +174 -0
  31. stepyard/core/expressions.py +77 -0
  32. stepyard/core/flow.py +189 -0
  33. stepyard/core/models.py +91 -0
  34. stepyard/core/node_executor.py +110 -0
  35. stepyard/core/ports.py +97 -0
  36. stepyard/core/service.py +28 -0
  37. stepyard/engine/__init__.py +1 -0
  38. stepyard/engine/evaluator.py +120 -0
  39. stepyard/engine/executor.py +585 -0
  40. stepyard/engine/navigation.py +73 -0
  41. stepyard/engine/recorder.py +140 -0
  42. stepyard/engine/runner.py +103 -0
  43. stepyard/engine/strategies.py +162 -0
  44. stepyard/executor/__init__.py +10 -0
  45. stepyard/executor/process_manager.py +228 -0
  46. stepyard/executor/worker.py +116 -0
  47. stepyard/logging_/__init__.py +1 -0
  48. stepyard/logging_/log_store.py +199 -0
  49. stepyard/plugin.py +34 -0
  50. stepyard/plugins/__init__.py +22 -0
  51. stepyard/plugins/execution.py +94 -0
  52. stepyard/plugins/host.py +474 -0
  53. stepyard/plugins/invoker.py +120 -0
  54. stepyard/plugins/manager.py +315 -0
  55. stepyard/py.typed +0 -0
  56. stepyard/scheduler/__init__.py +9 -0
  57. stepyard/scheduler/__main__.py +90 -0
  58. stepyard/scheduler/daemon.py +151 -0
  59. stepyard/scheduler/triggers.py +45 -0
  60. stepyard/sdk/__init__.py +27 -0
  61. stepyard/sdk/_stamps.py +37 -0
  62. stepyard/sdk/hooks.py +20 -0
  63. stepyard/sdk/inputs.py +34 -0
  64. stepyard/sdk/node.py +132 -0
  65. stepyard/sdk/testing.py +134 -0
  66. stepyard/sdk/trigger.py +32 -0
  67. stepyard/storage/__init__.py +17 -0
  68. stepyard/storage/database.py +121 -0
  69. stepyard/storage/facade.py +295 -0
  70. stepyard/storage/models.py +58 -0
  71. stepyard-0.1.0.dist-info/METADATA +281 -0
  72. stepyard-0.1.0.dist-info/RECORD +84 -0
  73. stepyard-0.1.0.dist-info/WHEEL +4 -0
  74. stepyard-0.1.0.dist-info/entry_points.txt +19 -0
  75. stepyard-0.1.0.dist-info/licenses/LICENSE +21 -0
  76. stepyard_builtin/__init__.py +1 -0
  77. stepyard_builtin/file.py +54 -0
  78. stepyard_builtin/hooks.py +70 -0
  79. stepyard_builtin/http.py +97 -0
  80. stepyard_builtin/llm.py +489 -0
  81. stepyard_builtin/shell.py +105 -0
  82. stepyard_builtin/system.py +180 -0
  83. stepyard_builtin/text.py +37 -0
  84. stepyard_builtin/triggers.py +50 -0
stepyard/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from .sdk.node import NodeContext, node
2
+
3
+ try:
4
+ from ._version import __version__
5
+ except ImportError:
6
+ __version__ = "0.0.0+unknown"
7
+
8
+ __all__ = ["node", "NodeContext", "__version__"]
stepyard/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1 @@
1
+ """Stepyard public API package."""
@@ -0,0 +1,540 @@
1
+ """
2
+ Stepyard Service Facade.
3
+
4
+ Single high-level API used by the CLI and any future integrations.
5
+ The CLI should never import directly from ``core/``, ``engine/``, or
6
+ ``scheduler/`` - all operations go through this class.
7
+
8
+ Usage::
9
+
10
+ svc = StepyardService.from_cwd() # auto-detect project root
11
+ svc = StepyardService("/path/to/project") # explicit path
12
+
13
+ logs = svc.get_log_lines(run_id)
14
+ svc.start_scheduler(foreground=False)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import subprocess
21
+ import sys
22
+ import uuid
23
+ from collections.abc import Iterator
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from stepyard.core.flow import Flow, FlowResolver
29
+ from stepyard.logging_.log_store import LogStore
30
+ from stepyard.storage.facade import Storage
31
+
32
+ # ─── Result types ─────────────────────────────────────────────────────────────
33
+
34
+
35
+ @dataclass
36
+ class SchedulerStatus:
37
+ is_running: bool
38
+ pid: int | None = None
39
+
40
+
41
+ @dataclass
42
+ class FlowInfo:
43
+ name: str
44
+ file_path: str
45
+ is_active: bool
46
+ has_trigger: bool
47
+ trigger_type: str | None = None
48
+ trigger_schedule: str | None = None
49
+
50
+
51
+ # ─── Service ──────────────────────────────────────────────────────────────────
52
+
53
+
54
+ class StepyardService:
55
+ """High-level facade - the ONLY entry point for CLI commands."""
56
+
57
+ def __init__(self, project_dir: str) -> None:
58
+ self.project_dir = os.path.abspath(project_dir)
59
+ self.storage = Storage(self.project_dir)
60
+ self._stepyard_dir = os.path.join(self.project_dir, ".stepyard")
61
+
62
+ # Transparent Background Initialization
63
+ os.makedirs(self._stepyard_dir, exist_ok=True)
64
+
65
+ self._log_store = LogStore(self._stepyard_dir)
66
+ self._resolver = FlowResolver(self.project_dir)
67
+
68
+ os.makedirs(self._resolver.flows_dir, exist_ok=True)
69
+
70
+ @classmethod
71
+ def from_cwd(cls) -> StepyardService:
72
+ """Auto-detect the project root by walking up from cwd."""
73
+ curr = os.getcwd()
74
+ while True:
75
+ if os.path.isdir(os.path.join(curr, ".stepyard")):
76
+ return cls(curr)
77
+ parent = os.path.dirname(curr)
78
+ if parent == curr:
79
+ break
80
+ curr = parent
81
+ return cls(os.getcwd())
82
+
83
+ # ── Flow execution ────────────────────────────────────────────────────────
84
+
85
+ def run_flow(
86
+ self,
87
+ flow_name: str,
88
+ vars: dict[str, Any] | None = None,
89
+ trigger_type: str = "manual",
90
+ ) -> str:
91
+ """Queue a flow for execution. Returns the new run_id."""
92
+ flow_file = self.find_flow_file(flow_name)
93
+ if not flow_file:
94
+ from stepyard.core.errors import FlowNotFoundError
95
+
96
+ raise FlowNotFoundError(flow_name)
97
+
98
+ import datetime
99
+
100
+ run_id = f"run-{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}-{uuid.uuid4().hex[:6]}"
101
+ self.storage.create_run(run_id, flow_name, trigger_type=trigger_type)
102
+ return run_id
103
+
104
+ def find_flow_file(self, flow_name: str) -> str | None:
105
+ """Resolve flow name to YAML file path."""
106
+ return self._resolver.find(flow_name)
107
+
108
+ def list_flows(self) -> list[FlowInfo]:
109
+ """List all available flows from the flows directory."""
110
+ flows_dir = self._resolver.flows_dir
111
+ if not os.path.isdir(flows_dir):
112
+ return []
113
+
114
+ result: list[FlowInfo] = []
115
+ for fn in sorted(os.listdir(flows_dir)):
116
+ if not fn.endswith((".yaml", ".yml")):
117
+ continue
118
+ filepath = os.path.join(flows_dir, fn)
119
+ name = fn.rsplit(".", 1)[0]
120
+ try:
121
+ flow = Flow.from_file(filepath)
122
+ trigger = flow.model.trigger
123
+ result.append(
124
+ FlowInfo(
125
+ name=flow.model.name,
126
+ file_path=filepath,
127
+ is_active=self.storage.is_flow_active(flow.model.name),
128
+ has_trigger=trigger is not None,
129
+ trigger_type=trigger.uses if trigger else None,
130
+ trigger_schedule=trigger.with_config.get("schedule") if trigger else None,
131
+ )
132
+ )
133
+ except Exception:
134
+ result.append(
135
+ FlowInfo(
136
+ name=name,
137
+ file_path=filepath,
138
+ is_active=False,
139
+ has_trigger=False,
140
+ )
141
+ )
142
+ return result
143
+
144
+ # ── Run inspection ────────────────────────────────────────────────────────
145
+
146
+ def get_run(self, run_id: str) -> dict[str, Any] | None:
147
+ return self.storage.get_run(run_id)
148
+
149
+ def get_step_runs(self, run_id: str) -> list[dict[str, Any]]:
150
+ return self.storage.get_step_runs(run_id)
151
+
152
+ def cancel_run(self, run_id: str) -> bool:
153
+ """Attempt to cancel a running flow (state-driven cancellation)."""
154
+ self.storage.update_run_status(run_id, "cancelled")
155
+ return True
156
+
157
+ # ── Logs ──────────────────────────────────────────────────────────────────
158
+
159
+ def get_log_lines(self, run_id: str, last_n: int | None = None) -> list[str]:
160
+ return self._log_store.tail(run_id, last_n)
161
+
162
+ def follow_logs(self, run_id: str) -> Iterator[str]:
163
+ return self._log_store.follow(run_id)
164
+
165
+ def get_scheduler_logs(self, last_n: int | None = None) -> list[str]:
166
+ return self._log_store.tail_scheduler(last_n)
167
+
168
+ def follow_scheduler_logs(self) -> Iterator[str]:
169
+ return self._log_store.follow_scheduler()
170
+
171
+ def search_logs(self, query: str, run_id: str | None = None) -> list[dict]:
172
+ return self._log_store.search(query, run_id)
173
+
174
+ # ── Scheduler management ──────────────────────────────────────────────────
175
+
176
+ def _scheduler_pid_path(self) -> str:
177
+ return os.path.join(self._stepyard_dir, "scheduler.pid")
178
+
179
+ def _scheduler_command(self, executable: str | None = None) -> list[str]:
180
+ return [
181
+ executable or sys.executable,
182
+ "-m",
183
+ "stepyard.scheduler",
184
+ "--project-dir",
185
+ self.project_dir,
186
+ ]
187
+
188
+ def _launchd_plist_path(self, label: str = "com.stepyard.scheduler") -> Path:
189
+ return Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
190
+
191
+ def _systemd_service_path(self) -> Path:
192
+ return Path.home() / ".config" / "systemd" / "user" / "stepyard.service"
193
+
194
+ def scheduler_status(self) -> SchedulerStatus:
195
+ pid_file = self._scheduler_pid_path()
196
+ if not os.path.exists(pid_file):
197
+ return SchedulerStatus(is_running=False)
198
+ try:
199
+ with open(pid_file) as fh:
200
+ pid = int(fh.read().strip())
201
+ os.kill(pid, 0) # Check if process exists
202
+ return SchedulerStatus(is_running=True, pid=pid)
203
+ except (ProcessLookupError, ValueError, OSError):
204
+ return SchedulerStatus(is_running=False)
205
+
206
+ def start_scheduler(self, foreground: bool = False) -> None:
207
+ """Start the scheduler daemon."""
208
+ if foreground:
209
+ import asyncio
210
+
211
+ from stepyard.executor.process_manager import ProcessManager
212
+ from stepyard.executor.worker import ExecutorWorker
213
+ from stepyard.scheduler.daemon import SchedulerDaemon, _configure_logging
214
+
215
+ log_path = str(self._log_store.scheduler_log_path())
216
+ _configure_logging(log_path)
217
+ pm = ProcessManager(logs_dir=os.path.join(self._stepyard_dir, "logs"))
218
+ scheduler = SchedulerDaemon(
219
+ storage=self.storage,
220
+ log_store=self._log_store,
221
+ )
222
+ executor = ExecutorWorker(
223
+ storage=self.storage,
224
+ process_manager=pm,
225
+ log_store=self._log_store,
226
+ )
227
+
228
+ async def run_supervisor():
229
+ await asyncio.gather(
230
+ scheduler.run_forever(),
231
+ executor.run_forever(),
232
+ )
233
+
234
+ asyncio.run(run_supervisor())
235
+ else:
236
+ # Spawn detached background process
237
+ cmd = self._scheduler_command()
238
+ proc = subprocess.Popen(
239
+ cmd,
240
+ stdout=subprocess.DEVNULL,
241
+ stderr=subprocess.DEVNULL,
242
+ stdin=subprocess.DEVNULL,
243
+ start_new_session=True,
244
+ )
245
+ with open(self._scheduler_pid_path(), "w") as fh:
246
+ fh.write(str(proc.pid))
247
+
248
+ def stop_scheduler(self) -> bool:
249
+ status = self.scheduler_status()
250
+ if not status.is_running or status.pid is None:
251
+ return False
252
+ try:
253
+ import signal
254
+
255
+ os.kill(status.pid, signal.SIGTERM)
256
+ try:
257
+ os.remove(self._scheduler_pid_path())
258
+ except OSError:
259
+ pass
260
+ return True
261
+ except ProcessLookupError:
262
+ return False
263
+
264
+ def install_system_service(self) -> str:
265
+ """Generate and install a system service file (launchd/systemd).
266
+
267
+ Returns a human-readable description of what was installed.
268
+ """
269
+ executable = sys.executable
270
+ if sys.platform == "darwin":
271
+ return self._install_launchd(executable)
272
+ return self._install_systemd(executable)
273
+
274
+ def _install_launchd(self, executable: str) -> str:
275
+ import plistlib
276
+
277
+ log_path = str(self._log_store.scheduler_log_path())
278
+ label = "com.stepyard.scheduler"
279
+ plist = {
280
+ "Label": label,
281
+ "ProgramArguments": self._scheduler_command(executable),
282
+ "KeepAlive": True,
283
+ "RunAtLoad": True,
284
+ "StandardOutPath": log_path,
285
+ "StandardErrorPath": log_path,
286
+ "WorkingDirectory": self.project_dir,
287
+ }
288
+ plist_path = self._launchd_plist_path(label)
289
+ plist_path.parent.mkdir(parents=True, exist_ok=True)
290
+ with open(plist_path, "wb") as fh:
291
+ plistlib.dump(plist, fh)
292
+ return f"launchd plist installed at {plist_path}\nRun: launchctl load {plist_path}"
293
+
294
+ def _install_systemd(self, executable: str) -> str:
295
+ try:
296
+ login = os.getlogin()
297
+ except Exception:
298
+ login = "root"
299
+ log_path = str(self._log_store.scheduler_log_path())
300
+ exec_start = " ".join(self._scheduler_command(executable))
301
+ service_content = f"""[Unit]
302
+ Description=Stepyard Scheduler Daemon
303
+ After=network.target
304
+
305
+ [Service]
306
+ ExecStart={exec_start}
307
+ Restart=always
308
+ User={login}
309
+ WorkingDirectory={self.project_dir}
310
+ StandardOutput=append:{log_path}
311
+ StandardError=append:{log_path}
312
+
313
+ [Install]
314
+ WantedBy=default.target
315
+ """
316
+ service_path = self._systemd_service_path()
317
+ service_path.parent.mkdir(parents=True, exist_ok=True)
318
+ service_path.write_text(service_content)
319
+ return (
320
+ f"systemd unit installed at {service_path}\n"
321
+ f"Run: systemctl --user enable --now stepyard.service"
322
+ )
323
+
324
+ def uninstall_system_service(self) -> str:
325
+ """Remove the system service file (launchd/systemd).
326
+
327
+ Returns a human-readable description of what was removed.
328
+ """
329
+ if sys.platform == "darwin":
330
+ return self._uninstall_launchd()
331
+ return self._uninstall_systemd()
332
+
333
+ def _uninstall_launchd(self) -> str:
334
+ label = "com.stepyard.scheduler"
335
+ plist_path = self._launchd_plist_path(label)
336
+
337
+ try:
338
+ import subprocess
339
+
340
+ subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True)
341
+ except Exception: # noqa: BLE001 - launchctl may be absent on non-macOS
342
+ pass
343
+
344
+ if plist_path.exists():
345
+ plist_path.unlink()
346
+ return f"launchd plist removed at {plist_path}"
347
+ return f"launchd plist not found at {plist_path}"
348
+
349
+ def _uninstall_systemd(self) -> str:
350
+ service_path = self._systemd_service_path()
351
+
352
+ try:
353
+ import subprocess
354
+
355
+ subprocess.run(
356
+ ["systemctl", "--user", "disable", "--now", "stepyard.service"], capture_output=True
357
+ )
358
+ except Exception: # noqa: BLE001 - systemctl may be absent on non-Linux
359
+ pass
360
+
361
+ if service_path.exists():
362
+ service_path.unlink()
363
+ return f"systemd unit removed at {service_path}"
364
+ return f"systemd unit not found at {service_path}"
365
+
366
+ # ── DX helpers ────────────────────────────────────────────────────────────
367
+
368
+ def init_project(self, *, force: bool = False) -> dict[str, list[str]]:
369
+ """Scaffold a new Stepyard project in :attr:`project_dir`.
370
+
371
+ Creates ``flows/``, ``.gitignore``, and an example flow if the
372
+ directory is empty or *force* is ``True``.
373
+
374
+ Returns
375
+ -------
376
+ dict
377
+ ``{"created": [...], "skipped": [...]}`` listing which files were
378
+ written and which were already present.
379
+ """
380
+ created: list[str] = []
381
+ skipped: list[str] = []
382
+
383
+ def _write(path: str, content: str) -> None:
384
+ if os.path.exists(path) and not force:
385
+ skipped.append(path)
386
+ return
387
+ os.makedirs(os.path.dirname(path), exist_ok=True)
388
+ with open(path, "w", encoding="utf-8") as fh:
389
+ fh.write(content)
390
+ created.append(path)
391
+
392
+ flows_dir = os.path.join(self.project_dir, "flows")
393
+ os.makedirs(flows_dir, exist_ok=True)
394
+
395
+ _write(
396
+ os.path.join(flows_dir, "hello.yaml"),
397
+ """\
398
+ name: hello
399
+ description: "A minimal example flow"
400
+ steps:
401
+ - id: greet
402
+ uses: shell.run
403
+ with:
404
+ command: echo "Hello from Stepyard!"
405
+ """,
406
+ )
407
+
408
+ _write(
409
+ os.path.join(self.project_dir, ".gitignore"),
410
+ """\
411
+ # Stepyard runtime data
412
+ .stepyard/
413
+ .stepyard_history
414
+ """,
415
+ )
416
+
417
+ # Force Storage initialisation so the .stepyard/ dir is created.
418
+ _ = self.storage
419
+
420
+ return {"created": created, "skipped": skipped}
421
+
422
+ def validate_flow(self, flow_file: str) -> list[dict]:
423
+ """Validate *flow_file* and return a list of error dicts.
424
+
425
+ Each error dict has keys ``field``, ``message``, and ``hint``.
426
+
427
+ Returns an empty list when the flow is valid.
428
+ """
429
+ import difflib # noqa: PLC0415
430
+
431
+ from stepyard.core.flow import Flow # noqa: PLC0415
432
+
433
+ errors: list[dict] = []
434
+
435
+ try:
436
+ flow = Flow.from_file(flow_file)
437
+ except Exception as exc:
438
+ # Extract Pydantic validation locations when available.
439
+ if hasattr(exc, "errors"):
440
+ for err in exc.errors():
441
+ loc = ".".join(str(x) for x in err.get("loc", []))
442
+ errors.append(
443
+ {
444
+ "field": loc or "(root)",
445
+ "message": err.get("msg", str(err)),
446
+ "hint": "",
447
+ }
448
+ )
449
+ else:
450
+ errors.append({"field": "(root)", "message": str(exc), "hint": ""})
451
+ return errors
452
+
453
+ # Semantic validation: check that all `uses` values are registered.
454
+ registry = None
455
+ try:
456
+ from stepyard.plugin import discover_capabilities # noqa: PLC0415
457
+
458
+ registry = discover_capabilities(self.project_dir)
459
+ except Exception: # noqa: BLE001 - plugin discovery is best-effort during validation
460
+ pass
461
+
462
+ if registry is not None:
463
+ available = sorted(registry.nodes.keys())
464
+ for step in _iter_steps(flow.model.steps):
465
+ if not step.uses:
466
+ continue
467
+ if step.uses not in registry.nodes:
468
+ close = difflib.get_close_matches(step.uses, available, n=3, cutoff=0.5)
469
+ hint = f"Did you mean: {', '.join(close)}?" if close else ""
470
+ errors.append(
471
+ {
472
+ "field": f"steps[{step.id}].uses",
473
+ "message": f"Unknown node '{step.uses}'",
474
+ "hint": hint,
475
+ }
476
+ )
477
+
478
+ return errors
479
+
480
+ def export_flow_schema(self, output_path: str | None = None) -> str:
481
+ """Export a JSON Schema for flow YAML files.
482
+
483
+ If *output_path* is omitted the schema is written to
484
+ ``.stepyard/flow.schema.json`` and that path is returned.
485
+ """
486
+ import json # noqa: PLC0415
487
+
488
+ from stepyard.core.flow import FlowModel # noqa: PLC0415
489
+
490
+ schema = FlowModel.model_json_schema()
491
+
492
+ # Enrich the `uses` field with available node names.
493
+ try:
494
+ from stepyard.plugin import discover_capabilities # noqa: PLC0415
495
+
496
+ registry = discover_capabilities(self.project_dir)
497
+ node_names = sorted(registry.nodes.keys())
498
+ if node_names and "properties" in schema:
499
+ # Inject enum into every `uses` property recursively.
500
+ _inject_uses_enum(schema, node_names)
501
+ except Exception: # noqa: BLE001 - schema enum enrichment is optional
502
+ pass
503
+
504
+ if output_path is None:
505
+ output_path = os.path.join(self._stepyard_dir, "flow.schema.json")
506
+
507
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
508
+ with open(output_path, "w", encoding="utf-8") as fh:
509
+ json.dump(schema, fh, indent=2)
510
+
511
+ return output_path
512
+
513
+
514
+ def _iter_steps(steps, parent_id: str | None = None):
515
+ """Recursively yield all StepModel instances from *steps*."""
516
+ for step in steps:
517
+ yield step
518
+ if getattr(step, "steps", None):
519
+ yield from _iter_steps(step.steps)
520
+
521
+
522
+ def _inject_uses_enum(schema: dict, node_names: list[str]) -> None:
523
+ """Recursively add an ``enum`` hint for ``uses`` fields in the schema."""
524
+ if isinstance(schema, dict):
525
+ if schema.get("title") == "Uses" or "uses" in str(schema.get("description", "")).lower():
526
+ pass
527
+ for key, value in schema.items():
528
+ if key == "uses" and isinstance(value, dict):
529
+ value["enum"] = node_names
530
+ value["description"] = (
531
+ value.get("description", "")
532
+ + f" Available: {', '.join(node_names[:10])}"
533
+ + (" …" if len(node_names) > 10 else "")
534
+ )
535
+ elif isinstance(value, dict):
536
+ _inject_uses_enum(value, node_names)
537
+ elif isinstance(value, list):
538
+ for item in value:
539
+ if isinstance(item, dict):
540
+ _inject_uses_enum(item, node_names)
@@ -0,0 +1,10 @@
1
+ """
2
+ Stepyard CLI package.
3
+
4
+ Re-exports the ``cli`` entry point for use in ``pyproject.toml`` scripts
5
+ and direct invocation.
6
+ """
7
+
8
+ from stepyard.cli.app import cli
9
+
10
+ __all__ = ["cli"]
@@ -0,0 +1,4 @@
1
+ from stepyard.cli.app import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()