runops 0.2.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 (115) hide show
  1. runops/__init__.py +5 -0
  2. runops/_data/README.md +476 -0
  3. runops/adapters/__init__.py +29 -0
  4. runops/adapters/_utils/__init__.py +36 -0
  5. runops/adapters/_utils/toml_utils.py +81 -0
  6. runops/adapters/base.py +335 -0
  7. runops/adapters/contrib/__init__.py +5 -0
  8. runops/adapters/contrib/beach.py +837 -0
  9. runops/adapters/contrib/emses.py +1010 -0
  10. runops/adapters/generic.py +439 -0
  11. runops/adapters/registry.py +244 -0
  12. runops/cli/__init__.py +3 -0
  13. runops/cli/analyze.py +222 -0
  14. runops/cli/clone.py +104 -0
  15. runops/cli/config.py +217 -0
  16. runops/cli/context.py +56 -0
  17. runops/cli/create.py +263 -0
  18. runops/cli/dashboard.py +179 -0
  19. runops/cli/extend.py +204 -0
  20. runops/cli/history.py +105 -0
  21. runops/cli/init.py +1432 -0
  22. runops/cli/jobs.py +145 -0
  23. runops/cli/knowledge.py +1017 -0
  24. runops/cli/list.py +102 -0
  25. runops/cli/log.py +163 -0
  26. runops/cli/main.py +96 -0
  27. runops/cli/manage.py +231 -0
  28. runops/cli/new.py +343 -0
  29. runops/cli/notes.py +257 -0
  30. runops/cli/run_lookup.py +148 -0
  31. runops/cli/setup.py +174 -0
  32. runops/cli/status.py +187 -0
  33. runops/cli/submit.py +297 -0
  34. runops/cli/update.py +113 -0
  35. runops/cli/update_harness.py +245 -0
  36. runops/cli/update_refs.py +370 -0
  37. runops/core/__init__.py +3 -0
  38. runops/core/actions.py +1186 -0
  39. runops/core/analysis.py +1090 -0
  40. runops/core/campaign.py +156 -0
  41. runops/core/case.py +307 -0
  42. runops/core/context.py +426 -0
  43. runops/core/discovery.py +192 -0
  44. runops/core/environment.py +266 -0
  45. runops/core/exceptions.py +93 -0
  46. runops/core/knowledge.py +595 -0
  47. runops/core/knowledge_source.py +1204 -0
  48. runops/core/manifest.py +219 -0
  49. runops/core/project.py +171 -0
  50. runops/core/provenance.py +147 -0
  51. runops/core/retry.py +193 -0
  52. runops/core/run.py +170 -0
  53. runops/core/run_creation.py +456 -0
  54. runops/core/site.py +337 -0
  55. runops/core/state.py +197 -0
  56. runops/core/survey.py +380 -0
  57. runops/core/validation.py +40 -0
  58. runops/harness/__init__.py +27 -0
  59. runops/harness/builder.py +327 -0
  60. runops/harness/claude.py +189 -0
  61. runops/jobgen/__init__.py +3 -0
  62. runops/jobgen/generator.py +295 -0
  63. runops/launchers/__init__.py +17 -0
  64. runops/launchers/base.py +313 -0
  65. runops/launchers/mpiexec.py +131 -0
  66. runops/launchers/mpirun.py +132 -0
  67. runops/launchers/srun.py +126 -0
  68. runops/sites/__init__.py +0 -0
  69. runops/sites/camphor.md +98 -0
  70. runops/sites/camphor.toml +27 -0
  71. runops/slurm/__init__.py +3 -0
  72. runops/slurm/query.py +384 -0
  73. runops/slurm/submit.py +203 -0
  74. runops/templates/__init__.py +29 -0
  75. runops/templates/adapters/beach/agent_guide.md +50 -0
  76. runops/templates/adapters/beach/beach.toml +19 -0
  77. runops/templates/adapters/beach/case.toml +16 -0
  78. runops/templates/adapters/beach/summarize.py +272 -0
  79. runops/templates/adapters/emses/agent_guide.md +39 -0
  80. runops/templates/adapters/emses/case.toml +18 -0
  81. runops/templates/adapters/emses/plasma.toml +118 -0
  82. runops/templates/adapters/emses/summarize.py +413 -0
  83. runops/templates/adapters/generic/case.toml.j2 +13 -0
  84. runops/templates/adapters/generic/summarize.py +21 -0
  85. runops/templates/agent.md +156 -0
  86. runops/templates/rules/cookbook.md +22 -0
  87. runops/templates/scaffold/campaign.toml.j2 +10 -0
  88. runops/templates/scaffold/cases_claude.md +22 -0
  89. runops/templates/scaffold/facts.toml +2 -0
  90. runops/templates/scaffold/gitignore.txt +30 -0
  91. runops/templates/scaffold/notes/README.md +69 -0
  92. runops/templates/scaffold/rules/plan-before-act.md +17 -0
  93. runops/templates/scaffold/rules/runops-workflow.md +84 -0
  94. runops/templates/scaffold/rules/upstream-feedback.md +85 -0
  95. runops/templates/scaffold/runs_claude.md +24 -0
  96. runops/templates/scaffold/vscode_settings.json +9 -0
  97. runops/templates/skills/analyze/SKILL.md +40 -0
  98. runops/templates/skills/check-status/SKILL.md +29 -0
  99. runops/templates/skills/cleanup/SKILL.md +43 -0
  100. runops/templates/skills/create-run/SKILL.md +135 -0
  101. runops/templates/skills/debug-failed/SKILL.md +38 -0
  102. runops/templates/skills/learn/SKILL.md +54 -0
  103. runops/templates/skills/new-case/SKILL.md +108 -0
  104. runops/templates/skills/note/SKILL.md +107 -0
  105. runops/templates/skills/run-all/SKILL.md +47 -0
  106. runops/templates/skills/runops-reference/SKILL.md +203 -0
  107. runops/templates/skills/setup-campaign/SKILL.md +111 -0
  108. runops/templates/skills/setup-env/SKILL.md +32 -0
  109. runops/templates/skills/survey-design/SKILL.md +73 -0
  110. runops/templates/survey.toml.j2 +22 -0
  111. runops-0.2.0.dist-info/METADATA +491 -0
  112. runops-0.2.0.dist-info/RECORD +115 -0
  113. runops-0.2.0.dist-info/WHEEL +4 -0
  114. runops-0.2.0.dist-info/entry_points.txt +2 -0
  115. runops-0.2.0.dist-info/licenses/LICENSE +201 -0
runops/cli/analyze.py ADDED
@@ -0,0 +1,222 @@
1
+ """CLI commands for analysis: summarize, collect, and plot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from runops.cli.run_lookup import resolve_run_or_cwd
11
+ from runops.core.actions import ActionStatus, execute_action
12
+ from runops.core.analysis import (
13
+ list_survey_plot_recipes,
14
+ load_survey_plot_table,
15
+ render_survey_plot,
16
+ resolve_survey_plot_recipe,
17
+ )
18
+ from runops.core.exceptions import SimctlError
19
+
20
+
21
+ def summarize(
22
+ run: str = typer.Argument(None, help="Run directory or run_id (defaults to cwd)."),
23
+ ) -> None:
24
+ """Generate or update analysis/summary.json for a run."""
25
+ run_dir = resolve_run_or_cwd(run, search_dir=Path.cwd())
26
+
27
+ try:
28
+ result = execute_action("summarize_run", run_dir=run_dir)
29
+ except (OSError, TypeError, json.JSONDecodeError, SimctlError) as e:
30
+ typer.echo(f"Error generating summary: {e}", err=True)
31
+ raise typer.Exit(code=1) from None
32
+
33
+ if result.status is not ActionStatus.SUCCESS:
34
+ typer.echo(f"Error generating summary: {result.message}", err=True)
35
+ raise typer.Exit(code=1)
36
+
37
+ script_path = str(result.data.get("script_path", ""))
38
+ if script_path:
39
+ typer.echo(f" Applied script: {Path(script_path).name}")
40
+ for warning in result.data.get("warnings", []):
41
+ typer.echo(f"Warning: {warning}", err=True)
42
+
43
+ summary = result.data.get("summary", {})
44
+ run_id = result.data.get("run_id", "???")
45
+ typer.echo(f"Summary written: {result.data.get('summary_path', '')}")
46
+ typer.echo(f" Run: {run_id}")
47
+ typer.echo(f" Keys: {', '.join(sorted(summary.keys()))}")
48
+
49
+
50
+ def collect(
51
+ survey_dir: Path = typer.Argument(None, help="Survey directory (defaults to cwd)."),
52
+ ) -> None:
53
+ """Collect summaries from all runs in a survey into aggregate artifacts."""
54
+ if survey_dir is None:
55
+ survey_dir = Path.cwd()
56
+ if not survey_dir.is_dir():
57
+ typer.echo(f"Error: directory not found: {survey_dir}", err=True)
58
+ raise typer.Exit(code=1)
59
+
60
+ try:
61
+ result = execute_action("collect_survey", survey_dir=survey_dir)
62
+ except (OSError, TypeError, json.JSONDecodeError, SimctlError) as e:
63
+ typer.echo(f"Error collecting summaries: {e}", err=True)
64
+ raise typer.Exit(code=1) from None
65
+
66
+ if result.status is not ActionStatus.SUCCESS:
67
+ typer.echo(f"Error collecting summaries: {result.message}", err=True)
68
+ raise typer.Exit(code=1)
69
+
70
+ typer.echo(f"Collected {result.message.removeprefix('Collected ').strip()}")
71
+ typer.echo(f" CSV: {result.data.get('csv_path', '')}")
72
+ typer.echo(f" JSON: {result.data.get('json_path', '')}")
73
+ typer.echo(f" Figures: {result.data.get('figures_path', '')}")
74
+ typer.echo(f" Report: {result.data.get('report_path', '')}")
75
+ generated_summaries = int(result.data.get("generated_summaries", 0))
76
+ if generated_summaries > 0:
77
+ typer.echo(
78
+ f" Auto-summarized: {generated_summaries} completed runs during collect"
79
+ )
80
+ missing_summaries = int(result.data.get("missing_summaries", 0))
81
+ if missing_summaries > 0:
82
+ typer.echo(f" ({missing_summaries} runs missing summary.json)")
83
+ for warning in result.data.get("warnings", []):
84
+ typer.echo(f"Warning: {warning}", err=True)
85
+
86
+
87
+ def plot(
88
+ survey_dir: Path = typer.Argument(None, help="Survey directory (defaults to cwd)."),
89
+ x: str | None = typer.Option(None, "--x", help="Column for the x-axis."),
90
+ y: str | None = typer.Option(None, "--y", help="Column for the y-axis."),
91
+ recipe: str = typer.Option(
92
+ "",
93
+ "--recipe",
94
+ help="Adapter-aware plot recipe name.",
95
+ ),
96
+ kind: str = typer.Option(
97
+ "auto",
98
+ "--kind",
99
+ help="Plot kind: auto, line, scatter, or bar.",
100
+ ),
101
+ group_by: str = typer.Option(
102
+ "",
103
+ "--group",
104
+ help="Optional column used to split data into multiple series.",
105
+ ),
106
+ title: str = typer.Option("", "--title", help="Optional plot title."),
107
+ output: Path | None = typer.Option(
108
+ None,
109
+ "--output",
110
+ "-o",
111
+ help="Output image path (default: summary/plots/<y>_vs_<x>.png).",
112
+ ),
113
+ list_columns: bool = typer.Option(
114
+ False,
115
+ "--list-columns",
116
+ help="Print available plot columns and exit.",
117
+ ),
118
+ list_recipes: bool = typer.Option(
119
+ False,
120
+ "--list-recipes",
121
+ help="Print available adapter plot recipes and exit.",
122
+ ),
123
+ ) -> None:
124
+ """Render a survey plot from explicit columns or an adapter recipe."""
125
+ if survey_dir is None:
126
+ survey_dir = Path.cwd()
127
+ if not survey_dir.is_dir():
128
+ typer.echo(f"Error: directory not found: {survey_dir}", err=True)
129
+ raise typer.Exit(code=1)
130
+
131
+ if list_columns:
132
+ try:
133
+ table = load_survey_plot_table(survey_dir)
134
+ except (OSError, TypeError, json.JSONDecodeError, SimctlError) as e:
135
+ typer.echo(f"Error loading plot columns: {e}", err=True)
136
+ raise typer.Exit(code=1) from None
137
+
138
+ typer.echo("Available columns:")
139
+ for column in table.columns:
140
+ typer.echo(f" {column}")
141
+ return
142
+
143
+ if list_recipes:
144
+ try:
145
+ recipes = list_survey_plot_recipes(survey_dir)
146
+ except (OSError, TypeError, json.JSONDecodeError, SimctlError) as e:
147
+ typer.echo(f"Error loading plot recipes: {e}", err=True)
148
+ raise typer.Exit(code=1) from None
149
+
150
+ if not recipes:
151
+ typer.echo("No adapter plot recipes available for this survey.")
152
+ return
153
+
154
+ typer.echo("Available plot recipes:")
155
+ for item in recipes:
156
+ x_candidates = ", ".join(item.x_candidates)
157
+ y_candidates = ", ".join(item.y_candidates)
158
+ group_candidates = (
159
+ ", ".join(item.group_by_candidates)
160
+ if item.group_by_candidates
161
+ else "(none)"
162
+ )
163
+ typer.echo(f" {item.name} [{item.adapter}]")
164
+ if item.description:
165
+ typer.echo(f" {item.description}")
166
+ typer.echo(f" x: {x_candidates}")
167
+ typer.echo(f" y: {y_candidates}")
168
+ typer.echo(f" kind: {item.kind}")
169
+ typer.echo(f" group_by: {group_candidates}")
170
+ return
171
+
172
+ resolved_recipe = None
173
+ if recipe:
174
+ try:
175
+ resolved_recipe = resolve_survey_plot_recipe(survey_dir, recipe)
176
+ except (OSError, TypeError, json.JSONDecodeError, SimctlError) as e:
177
+ typer.echo(f"Error resolving plot recipe: {e}", err=True)
178
+ raise typer.Exit(code=1) from None
179
+
180
+ if not x and resolved_recipe is not None:
181
+ x = resolved_recipe.x
182
+ if not y and resolved_recipe is not None:
183
+ y = resolved_recipe.y
184
+ if not group_by and resolved_recipe is not None:
185
+ group_by = resolved_recipe.group_by
186
+ if kind == "auto" and resolved_recipe is not None:
187
+ kind = resolved_recipe.recipe.kind
188
+ if not title and resolved_recipe is not None:
189
+ title = resolved_recipe.recipe.title
190
+
191
+ if not x or not y:
192
+ typer.echo(
193
+ "Error: --x and --y are required unless --recipe, --list-columns, "
194
+ "or --list-recipes is used.",
195
+ err=True,
196
+ )
197
+ raise typer.Exit(code=1)
198
+
199
+ try:
200
+ result = render_survey_plot(
201
+ survey_dir,
202
+ x=x,
203
+ y=y,
204
+ kind=kind,
205
+ group_by=group_by,
206
+ title=title,
207
+ output_path=output,
208
+ )
209
+ except (OSError, TypeError, json.JSONDecodeError, SimctlError) as e:
210
+ typer.echo(f"Error rendering plot: {e}", err=True)
211
+ raise typer.Exit(code=1) from None
212
+
213
+ typer.echo(f"Plot written: {result.output_path}")
214
+ if resolved_recipe is not None:
215
+ typer.echo(f" Recipe: {resolved_recipe.recipe.name}")
216
+ typer.echo(f" Kind: {result.kind}")
217
+ typer.echo(f" Points: {result.points_plotted}")
218
+ if result.generated_summaries > 0:
219
+ typer.echo(
220
+ " Auto-summarized:"
221
+ f" {result.generated_summaries} completed runs during plot"
222
+ )
runops/cli/clone.py ADDED
@@ -0,0 +1,104 @@
1
+ """CLI command for cloning and deriving runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import typer
10
+
11
+ from runops.cli.run_lookup import resolve_run_or_cwd
12
+ from runops.core.discovery import collect_existing_run_ids
13
+ from runops.core.exceptions import SimctlError
14
+ from runops.core.manifest import ManifestData, read_manifest, write_manifest
15
+ from runops.core.run import next_run_id
16
+ from runops.core.state import RunState
17
+
18
+
19
+ def clone(
20
+ run: str = typer.Argument(None, help="Run directory or run_id (defaults to cwd)."),
21
+ dest: Optional[Path] = typer.Option(
22
+ None, "--dest", "-d", help="Destination directory (defaults to cwd)."
23
+ ),
24
+ set_params: Optional[list[str]] = typer.Option(
25
+ None, "--set", help="Override parameters as key=value."
26
+ ),
27
+ ) -> None:
28
+ """Clone a run, optionally modifying parameters."""
29
+ search_dir = Path.cwd()
30
+ source_dir = resolve_run_or_cwd(run, search_dir=search_dir)
31
+
32
+ try:
33
+ source_manifest = read_manifest(source_dir)
34
+ except SimctlError as e:
35
+ typer.echo(f"Error reading manifest: {e}", err=True)
36
+ raise typer.Exit(code=1) from None
37
+
38
+ # Determine destination parent directory
39
+ dest_parent = dest or source_dir.parent
40
+
41
+ # Generate new run_id, searching both cwd and dest for existing IDs
42
+ try:
43
+ existing_ids = collect_existing_run_ids(search_dir)
44
+ if dest_parent != search_dir:
45
+ existing_ids |= collect_existing_run_ids(dest_parent)
46
+ new_run_id = next_run_id(existing_ids)
47
+ except SimctlError as e:
48
+ typer.echo(f"Error generating run_id: {e}", err=True)
49
+ raise typer.Exit(code=1) from None
50
+
51
+ new_run_dir = dest_parent / new_run_id
52
+
53
+ try:
54
+ # Create run directory structure
55
+ new_run_dir.mkdir(parents=True)
56
+ for subdir in ("input", "submit", "work", "analysis", "status"):
57
+ (new_run_dir / subdir).mkdir(exist_ok=True)
58
+
59
+ # Copy input files from source
60
+ source_input = source_dir / "input"
61
+ if source_input.is_dir():
62
+ dest_input = new_run_dir / "input"
63
+ # Remove the empty dir we just created, then copy tree
64
+ dest_input.rmdir()
65
+ shutil.copytree(source_input, dest_input)
66
+
67
+ # Copy submit/ directory (including job.sh) from source
68
+ source_submit = source_dir / "submit"
69
+ if source_submit.is_dir():
70
+ dest_submit = new_run_dir / "submit"
71
+ dest_submit.rmdir()
72
+ shutil.copytree(source_submit, dest_submit)
73
+
74
+ # Build new manifest
75
+ source_run_id = source_manifest.run.get("id", "")
76
+ new_manifest = ManifestData.from_dict(source_manifest.to_dict())
77
+ new_manifest.run["id"] = new_run_id
78
+ new_manifest.run["status"] = RunState.CREATED.value
79
+ new_manifest.run["display_name"] = f"clone of {source_run_id}"
80
+ new_manifest.origin["parent_run"] = source_run_id
81
+
82
+ # Apply parameter overrides
83
+ if set_params:
84
+ for param in set_params:
85
+ if "=" not in param:
86
+ typer.echo(
87
+ f"Error: invalid --set format {param!r}, expected key=value",
88
+ err=True,
89
+ )
90
+ raise typer.Exit(code=1)
91
+ key, value = param.split("=", 1)
92
+ new_manifest.params_snapshot[key.strip()] = value.strip()
93
+
94
+ write_manifest(new_run_dir, new_manifest)
95
+
96
+ except SimctlError as e:
97
+ typer.echo(f"Error creating clone: {e}", err=True)
98
+ raise typer.Exit(code=1) from None
99
+ except OSError as e:
100
+ typer.echo(f"Error creating clone directory: {e}", err=True)
101
+ raise typer.Exit(code=1) from None
102
+
103
+ typer.echo(f"Cloned {source_run_id} -> {new_run_id}")
104
+ typer.echo(f" Path: {new_run_dir}")
runops/cli/config.py ADDED
@@ -0,0 +1,217 @@
1
+ """CLI commands for project configuration management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import logging
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Annotated, Any, Optional
10
+
11
+ import typer
12
+
13
+ if sys.version_info >= (3, 11):
14
+ import tomllib
15
+ else:
16
+ import tomli as tomllib
17
+
18
+ try:
19
+ import tomli_w
20
+ except ImportError:
21
+ tomli_w = None # type: ignore[assignment]
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ config_app = typer.Typer(
26
+ name="config",
27
+ help="View and modify project configuration.",
28
+ no_args_is_help=True,
29
+ )
30
+
31
+
32
+ def _find_project_dir(path: Path | None) -> Path:
33
+ """Resolve and validate the project directory."""
34
+ project_dir = (path or Path.cwd()).resolve()
35
+ if not (project_dir / "runops.toml").exists():
36
+ typer.echo(f"Error: runops.toml not found in {project_dir}")
37
+ raise typer.Exit(code=1)
38
+ return project_dir
39
+
40
+
41
+ def _load_toml(path: Path) -> dict[str, Any]:
42
+ """Load a TOML file."""
43
+ with open(path, "rb") as f:
44
+ return tomllib.load(f)
45
+
46
+
47
+ def _write_toml(path: Path, data: dict[str, Any]) -> None:
48
+ """Write a TOML file."""
49
+ if tomli_w is None:
50
+ msg = "tomli_w is required to write TOML files"
51
+ raise RuntimeError(msg)
52
+ with open(path, "wb") as f:
53
+ tomli_w.dump(data, f)
54
+
55
+
56
+ def _toml_to_str(data: dict[str, Any]) -> str:
57
+ """Serialize dict to TOML string."""
58
+ if tomli_w is None:
59
+ msg = "tomli_w is required to serialize TOML"
60
+ raise RuntimeError(msg)
61
+ buf = io.BytesIO()
62
+ tomli_w.dump(data, buf)
63
+ return buf.getvalue().decode("utf-8")
64
+
65
+
66
+ @config_app.command("show")
67
+ def show(
68
+ path: Annotated[
69
+ Optional[Path],
70
+ typer.Option("--path", "-p", help="Project directory."),
71
+ ] = None,
72
+ ) -> None:
73
+ """Display current project configuration."""
74
+ project_dir = _find_project_dir(path)
75
+
76
+ # runops.toml
77
+ typer.echo("=== runops.toml ===")
78
+ simproject = project_dir / "runops.toml"
79
+ typer.echo(simproject.read_text(encoding="utf-8"))
80
+
81
+ # simulators.toml
82
+ typer.echo("=== simulators.toml ===")
83
+ simulators = project_dir / "simulators.toml"
84
+ if simulators.exists():
85
+ typer.echo(simulators.read_text(encoding="utf-8"))
86
+ else:
87
+ typer.echo("(not found)\n")
88
+
89
+ # launchers.toml
90
+ typer.echo("=== launchers.toml ===")
91
+ launchers = project_dir / "launchers.toml"
92
+ if launchers.exists():
93
+ typer.echo(launchers.read_text(encoding="utf-8"))
94
+ else:
95
+ typer.echo("(not found)\n")
96
+
97
+
98
+ @config_app.command("add-simulator")
99
+ def add_simulator(
100
+ simulator: Annotated[
101
+ Optional[str],
102
+ typer.Argument(help="Simulator name (e.g. emses, beach)."),
103
+ ] = None,
104
+ path: Annotated[
105
+ Optional[Path],
106
+ typer.Option("--path", "-p", help="Project directory."),
107
+ ] = None,
108
+ ) -> None:
109
+ """Add a simulator to simulators.toml (interactive)."""
110
+ import runops.adapters # noqa: F401
111
+ from runops.adapters.registry import get_global_registry
112
+
113
+ project_dir = _find_project_dir(path)
114
+ registry = get_global_registry()
115
+ available = registry.list_adapters()
116
+
117
+ # Select simulator
118
+ if not simulator:
119
+ typer.echo("\nAvailable simulators:")
120
+ for i, name in enumerate(available, 1):
121
+ typer.echo(f" {i}. {name}")
122
+
123
+ selection = typer.prompt("\nSelect simulator (number or name)")
124
+ if selection.isdigit():
125
+ idx = int(selection) - 1
126
+ if 0 <= idx < len(available):
127
+ simulator = available[idx]
128
+ else:
129
+ typer.echo("Invalid selection.")
130
+ raise typer.Exit(code=1)
131
+ else:
132
+ simulator = selection
133
+
134
+ if simulator not in available:
135
+ typer.echo(
136
+ f"Unknown simulator: '{simulator}'. Available: {', '.join(available)}"
137
+ )
138
+ raise typer.Exit(code=1)
139
+
140
+ # Load existing config
141
+ sim_path = project_dir / "simulators.toml"
142
+ existing = _load_toml(sim_path) if sim_path.exists() else {"simulators": {}}
143
+
144
+ if "simulators" not in existing:
145
+ existing["simulators"] = {}
146
+
147
+ if simulator in existing["simulators"] and not typer.confirm(
148
+ f"'{simulator}' already exists. Overwrite?", default=False
149
+ ):
150
+ typer.echo("Cancelled.")
151
+ raise typer.Exit()
152
+
153
+ # Interactive config
154
+ adapter_cls = registry.get(simulator)
155
+ config = adapter_cls.interactive_config()
156
+
157
+ existing["simulators"][simulator] = config
158
+ _write_toml(sim_path, existing)
159
+
160
+ typer.echo(f"\nAdded simulator '{simulator}' to simulators.toml:")
161
+ typer.echo(_toml_to_str({"simulators": {simulator: config}}))
162
+
163
+
164
+ @config_app.command("add-launcher")
165
+ def add_launcher(
166
+ path: Annotated[
167
+ Optional[Path],
168
+ typer.Option("--path", "-p", help="Project directory."),
169
+ ] = None,
170
+ ) -> None:
171
+ """Add a launcher profile to launchers.toml (interactive)."""
172
+ project_dir = _find_project_dir(path)
173
+
174
+ typer.echo("\nAvailable launcher types:")
175
+ typer.echo(" 1. srun (Slurm)")
176
+ typer.echo(" 2. mpirun (OpenMPI)")
177
+ typer.echo(" 3. mpiexec (MPICH)")
178
+
179
+ selection = typer.prompt("\nSelect launcher type (number or name)")
180
+ launcher_map = {"1": "srun", "2": "mpirun", "3": "mpiexec"}
181
+ launcher_type = launcher_map.get(selection.strip(), selection.strip())
182
+
183
+ if launcher_type not in ("srun", "mpirun", "mpiexec"):
184
+ typer.echo(f"Unknown launcher type: '{launcher_type}'")
185
+ raise typer.Exit(code=1)
186
+
187
+ launcher_name = typer.prompt("Launcher profile name", default="default")
188
+
189
+ config: dict[str, Any] = {"type": launcher_type}
190
+
191
+ extra_args = typer.prompt(
192
+ f"Extra {launcher_type} arguments (Enter to skip)", default=""
193
+ )
194
+ if extra_args:
195
+ config["args"] = extra_args
196
+
197
+ # Load existing
198
+ launcher_path = project_dir / "launchers.toml"
199
+ if launcher_path.exists():
200
+ existing = _load_toml(launcher_path)
201
+ else:
202
+ existing = {"launchers": {}}
203
+
204
+ if "launchers" not in existing:
205
+ existing["launchers"] = {}
206
+
207
+ if launcher_name in existing["launchers"] and not typer.confirm(
208
+ f"'{launcher_name}' already exists. Overwrite?", default=False
209
+ ):
210
+ typer.echo("Cancelled.")
211
+ raise typer.Exit()
212
+
213
+ existing["launchers"][launcher_name] = config
214
+ _write_toml(launcher_path, existing)
215
+
216
+ typer.echo(f"\nAdded launcher '{launcher_name}' to launchers.toml:")
217
+ typer.echo(_toml_to_str({"launchers": {launcher_name: config}}))
runops/cli/context.py ADDED
@@ -0,0 +1,56 @@
1
+ """CLI command: runops context — project context bundle for agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from runops.core.project import find_project_root
11
+
12
+
13
+ def context(
14
+ directory: Path = typer.Argument(
15
+ Path("."),
16
+ help="Project directory (default: cwd).",
17
+ exists=True,
18
+ ),
19
+ output_json: bool = typer.Option(
20
+ True,
21
+ "--json/--no-json",
22
+ help="Output as JSON (default: true).",
23
+ ),
24
+ ) -> None:
25
+ """Show project context bundle (designed for AI agents)."""
26
+ from runops.core.context import build_project_context
27
+
28
+ root = find_project_root(directory)
29
+ ctx = build_project_context(root)
30
+
31
+ if output_json:
32
+ typer.echo(json.dumps(ctx, indent=2, ensure_ascii=False))
33
+ else:
34
+ # Simple text summary
35
+ proj = ctx.get("project", {})
36
+ typer.echo(f"Project: {proj.get('name', '?')}")
37
+ typer.echo(f"Root: {proj.get('root', '?')}")
38
+
39
+ camp = ctx.get("campaign", {})
40
+ if camp.get("hypothesis"):
41
+ typer.echo(f"Hypothesis: {camp['hypothesis']}")
42
+
43
+ sims = ctx.get("simulators", [])
44
+ if sims:
45
+ typer.echo(f"Simulators: {', '.join(sims)}")
46
+
47
+ runs = ctx.get("runs", {})
48
+ if runs.get("total", 0) > 0:
49
+ parts = [f"{k}={v}" for k, v in runs.items()]
50
+ typer.echo(f"Runs: {', '.join(parts)}")
51
+
52
+ failures = ctx.get("recent_failures", [])
53
+ if failures:
54
+ typer.echo(f"Recent failures ({len(failures)}):")
55
+ for f in failures:
56
+ typer.echo(f" {f['run_id']}: {f['reason']}")