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/init.py ADDED
@@ -0,0 +1,1432 @@
1
+ """CLI commands for project initialization and environment checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.resources
6
+ import logging
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Annotated, Any, Optional
13
+
14
+ import typer
15
+
16
+ from runops.core.discovery import validate_uniqueness
17
+ from runops.core.exceptions import DuplicateRunIdError, ProjectConfigError
18
+ from runops.core.project import load_project
19
+ from runops.harness.builder import _collect_doc_repos, _collect_pip_packages
20
+
21
+ if sys.version_info >= (3, 11):
22
+ import tomllib
23
+ else:
24
+ import tomli as tomllib
25
+
26
+ try:
27
+ import tomli_w
28
+ except ImportError:
29
+ tomli_w = None # type: ignore[assignment]
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ _SIMPROJECT_FILE = "runops.toml"
34
+ _SIMULATORS_FILE = "simulators.toml"
35
+ _LAUNCHERS_FILE = "launchers.toml"
36
+ _CAMPAIGN_FILE = "campaign.toml"
37
+ _CLAUDE_MD = "CLAUDE.md"
38
+ _AGENTS_MD = "AGENTS.md"
39
+ _SKILLS_DIR = ".claude/skills"
40
+ _RULES_DIR = ".claude/rules"
41
+ _CLAUDE_SETTINGS = ".claude/settings.json"
42
+ _VSCODE_DIR = ".vscode"
43
+ _VSCODE_SETTINGS = "settings.json"
44
+
45
+ _SCHEMA_BASE_URL = "https://raw.githubusercontent.com/Nkzono99/runops/main/schemas"
46
+ _DEFAULT_SIMCTL_REPO = "https://github.com/Nkzono99/runops.git"
47
+
48
+
49
+ def _safe_echo(message: str, *, err: bool = False) -> None:
50
+ """Echo text even when the console encoding cannot represent it."""
51
+ try:
52
+ typer.echo(message, err=err)
53
+ except UnicodeEncodeError:
54
+ stream = sys.stderr if err else sys.stdout
55
+ encoding = getattr(stream, "encoding", None) or "utf-8"
56
+ safe_message = message.encode(encoding, errors="replace").decode(
57
+ encoding,
58
+ errors="replace",
59
+ )
60
+ typer.echo(safe_message, err=err)
61
+
62
+
63
+ def _write_if_missing(path: Path, content: str) -> bool:
64
+ """Write content to path if the file does not already exist.
65
+
66
+ Args:
67
+ path: File path to create.
68
+ content: File content to write.
69
+
70
+ Returns:
71
+ True if the file was created, False if it already existed.
72
+ """
73
+ if path.exists():
74
+ return False
75
+ path.write_text(content, encoding="utf-8")
76
+ return True
77
+
78
+
79
+ def _mkdir_if_missing(path: Path) -> bool:
80
+ """Create a directory if it does not already exist.
81
+
82
+ Args:
83
+ path: Directory path to create.
84
+
85
+ Returns:
86
+ True if the directory was created, False if it already existed.
87
+ """
88
+ if path.exists():
89
+ return False
90
+ path.mkdir(parents=True)
91
+ return True
92
+
93
+
94
+ def _create_runops_skeleton(project_dir: Path, created: list[str]) -> None:
95
+ """Create .runops/ skeleton (insights/, facts.toml, knowledge/).
96
+
97
+ Args:
98
+ project_dir: Project root directory.
99
+ created: Mutable list to append created items.
100
+ """
101
+ runops_dir = project_dir / ".runops"
102
+ if _mkdir_if_missing(runops_dir):
103
+ created.append(".runops/")
104
+ if _mkdir_if_missing(runops_dir / "insights"):
105
+ created.append(".runops/insights/")
106
+ from runops.templates import load_static
107
+
108
+ if _write_if_missing(runops_dir / "facts.toml", load_static("scaffold/facts.toml")):
109
+ created.append(".runops/facts.toml")
110
+ # Knowledge integration directories
111
+ if _mkdir_if_missing(runops_dir / "knowledge"):
112
+ created.append(".runops/knowledge/")
113
+ if _mkdir_if_missing(runops_dir / "knowledge" / "enabled"):
114
+ created.append(".runops/knowledge/enabled/")
115
+ if _mkdir_if_missing(runops_dir / "knowledge" / "candidates"):
116
+ created.append(".runops/knowledge/candidates/")
117
+ if _mkdir_if_missing(runops_dir / "knowledge" / "candidates" / "facts"):
118
+ created.append(".runops/knowledge/candidates/facts/")
119
+
120
+
121
+ def _create_notes_skeleton(project_dir: Path, created: list[str]) -> None:
122
+ """Create the lab-notebook skeleton (``notes/`` + README + ``reports/``).
123
+
124
+ The lab notebook lives next to ``.runops/insights/`` but serves a
125
+ different purpose: chronological, append-only entries, edited via
126
+ ``runops notes append`` or the ``/note`` skill.
127
+
128
+ Args:
129
+ project_dir: Project root directory.
130
+ created: Mutable list to append created items.
131
+ """
132
+ notes_dir = project_dir / "notes"
133
+ if _mkdir_if_missing(notes_dir):
134
+ created.append("notes/")
135
+ if _mkdir_if_missing(notes_dir / "reports"):
136
+ created.append("notes/reports/")
137
+
138
+ from runops.templates import load_static
139
+
140
+ readme_path = notes_dir / "README.md"
141
+ if _write_if_missing(readme_path, load_static("scaffold/notes/README.md")):
142
+ created.append("notes/README.md")
143
+
144
+
145
+ def _build_simulators_toml(simulator_names: list[str]) -> str:
146
+ """Build simulators.toml content from adapter default configs.
147
+
148
+ Args:
149
+ simulator_names: List of simulator adapter names (e.g. ["emses", "beach"]).
150
+
151
+ Returns:
152
+ TOML string for simulators.toml.
153
+
154
+ Raises:
155
+ typer.BadParameter: If a simulator name is not recognized.
156
+ """
157
+ # Ensure built-in adapters are registered
158
+ import runops.adapters # noqa: F401
159
+ from runops.adapters.registry import get_global_registry
160
+
161
+ registry = get_global_registry()
162
+ available = registry.list_adapters()
163
+
164
+ config: dict[str, Any] = {"simulators": {}}
165
+ for sim_name in simulator_names:
166
+ if sim_name not in available:
167
+ msg = f"Unknown simulator: '{sim_name}'. Available: {', '.join(available)}"
168
+ raise typer.BadParameter(msg)
169
+ adapter_cls = registry.get(sim_name)
170
+ config["simulators"][sim_name] = adapter_cls.default_config()
171
+
172
+ if tomli_w is None:
173
+ # Fallback to manual TOML generation
174
+ lines = ["[simulators]", ""]
175
+ for sim_name, sim_cfg in config["simulators"].items():
176
+ lines.append(f"[simulators.{sim_name}]")
177
+ for key, value in sim_cfg.items():
178
+ if isinstance(value, list):
179
+ items = ", ".join(f'"{v}"' for v in value)
180
+ lines.append(f"{key} = [{items}]")
181
+ elif isinstance(value, str):
182
+ lines.append(f'{key} = "{value}"')
183
+ else:
184
+ lines.append(f"{key} = {value}")
185
+ lines.append("")
186
+ return "\n".join(lines) + "\n"
187
+
188
+ import io
189
+
190
+ buf = io.BytesIO()
191
+ tomli_w.dump(config, buf)
192
+ return buf.getvalue().decode("utf-8")
193
+
194
+
195
+ def _clone_doc_repos(
196
+ project_dir: Path, simulator_names: list[str]
197
+ ) -> tuple[list[str], list[str]]:
198
+ """Clone documentation repos into project_dir/refs/.
199
+
200
+ Returns:
201
+ Tuple of (created_list, skipped_list).
202
+ """
203
+ repos = _collect_doc_repos(simulator_names)
204
+ if not repos:
205
+ return [], []
206
+
207
+ created: list[str] = []
208
+ skipped: list[str] = []
209
+ refs_dir = project_dir / "refs"
210
+ refs_dir.mkdir(exist_ok=True)
211
+
212
+ for url, dest in repos:
213
+ dest_path = refs_dir / dest
214
+ rel = f"refs/{dest}"
215
+ if dest_path.exists():
216
+ skipped.append(rel)
217
+ continue
218
+ result = subprocess.run(
219
+ ["git", "clone", "--depth", "1", url, str(dest_path)],
220
+ capture_output=True,
221
+ text=True,
222
+ encoding="utf-8",
223
+ errors="replace",
224
+ check=False,
225
+ )
226
+ if result.returncode == 0:
227
+ created.append(rel)
228
+ else:
229
+ logger.warning(
230
+ "git clone %s failed: %s", url, (result.stderr or "").strip()
231
+ )
232
+
233
+ return created, skipped
234
+
235
+
236
+ def _discover_agent_docs(
237
+ project_dir: Path, doc_repos: list[tuple[str, str]]
238
+ ) -> list[str]:
239
+ """Discover manifest-declared agent doc imports from cloned repos.
240
+
241
+ Returns:
242
+ List of relative import paths rooted at the project directory.
243
+ """
244
+ from runops.core.knowledge_source import discover_repo_imports
245
+
246
+ refs_dir = project_dir / "refs"
247
+ paths: list[str] = []
248
+ for _url, dest in doc_repos:
249
+ repo_root = refs_dir / dest
250
+ if not repo_root.is_dir():
251
+ continue
252
+ for rel_path in discover_repo_imports(repo_root):
253
+ paths.append(f"refs/{dest}/{rel_path}".replace("\\", "/"))
254
+
255
+ runops_root = project_dir / "tools" / "runops"
256
+ if runops_root.is_dir():
257
+ for rel_path in discover_repo_imports(runops_root):
258
+ paths.append(f"tools/runops/{rel_path}".replace("\\", "/"))
259
+ return paths
260
+
261
+
262
+ def _prepare_knowledge_imports(
263
+ project_dir: Path,
264
+ simulator_names: list[str],
265
+ *,
266
+ sync_sources: bool = False,
267
+ validate_sources: bool = False,
268
+ ) -> str:
269
+ """Sync knowledge sources and render the unified imports.md bundle."""
270
+ from runops.core.knowledge_source import (
271
+ KnowledgeConfig,
272
+ load_knowledge_config,
273
+ render_imports,
274
+ sync_all_sources,
275
+ validate_source_structure,
276
+ )
277
+
278
+ knowledge_imports_path = ""
279
+ config = load_knowledge_config(project_dir)
280
+
281
+ if sync_sources and config is not None and config.sources:
282
+ typer.echo("Syncing knowledge sources...")
283
+ for name, status in sync_all_sources(project_dir, config):
284
+ typer.echo(f" {name}: {status}")
285
+
286
+ if validate_sources:
287
+ for source in config.sources:
288
+ if not source.mount:
289
+ continue
290
+ source_path = project_dir / source.mount
291
+ if not source_path.is_dir():
292
+ continue
293
+ issues = validate_source_structure(source_path)
294
+ for issue in issues:
295
+ typer.echo(f" Warning ({source.name}): {issue}")
296
+
297
+ doc_repos = _collect_doc_repos(simulator_names) if simulator_names else []
298
+ agent_doc_imports = _discover_agent_docs(project_dir, doc_repos)
299
+
300
+ render_config = config if config is not None else KnowledgeConfig()
301
+ should_render = bool(agent_doc_imports or (config is not None and config.sources))
302
+ if should_render:
303
+ render_imports(
304
+ project_dir,
305
+ render_config,
306
+ extra_imports=agent_doc_imports or None,
307
+ )
308
+ typer.echo(" Rendered knowledge imports")
309
+
310
+ if render_config.generate_claude_imports:
311
+ imports_file = (
312
+ project_dir / render_config.derived_dir / "enabled" / "imports.md"
313
+ )
314
+ if imports_file.is_file():
315
+ knowledge_imports_path = f"{render_config.derived_dir}/enabled/imports.md"
316
+
317
+ return knowledge_imports_path
318
+
319
+
320
+ def _get_data_path() -> Path:
321
+ """Return the path to the package's bundled _data directory.
322
+
323
+ Falls back to the repository root when running in editable/dev mode
324
+ where force-include has not been applied.
325
+ """
326
+ pkg_data = Path(str(importlib.resources.files("runops") / "_data"))
327
+ if (pkg_data / "README.md").is_file():
328
+ return pkg_data
329
+ # Dev mode fallback: walk up from this file to the repo root
330
+ repo_root = Path(__file__).resolve().parent.parent.parent.parent
331
+ if (repo_root / "README.md").is_file() and (repo_root / "docs").is_dir():
332
+ return repo_root
333
+ return pkg_data
334
+
335
+
336
+ def _copy_docs(project_dir: Path) -> tuple[list[str], list[str]]:
337
+ """Copy bundled README.md and docs/ into the project directory.
338
+
339
+ Returns:
340
+ Tuple of (created_list, skipped_list).
341
+ """
342
+ created: list[str] = []
343
+ skipped: list[str] = []
344
+ data_path = _get_data_path()
345
+
346
+ # README.md -> docs/runops-guide.md
347
+ readme_src = data_path / "README.md"
348
+ readme_dst = project_dir / "docs" / "runops-guide.md"
349
+ if readme_dst.exists():
350
+ skipped.append("docs/runops-guide.md")
351
+ elif readme_src.exists():
352
+ readme_dst.parent.mkdir(exist_ok=True)
353
+ shutil.copy2(readme_src, readme_dst)
354
+ created.append("docs/runops-guide.md")
355
+
356
+ # docs/*.md
357
+ docs_src = data_path / "docs"
358
+ if docs_src.is_dir():
359
+ docs_dst = project_dir / "docs"
360
+ docs_dst.mkdir(exist_ok=True)
361
+ for src_file in sorted(docs_src.iterdir()):
362
+ if src_file.suffix == ".md":
363
+ dst_file = docs_dst / src_file.name
364
+ rel = f"docs/{src_file.name}"
365
+ if dst_file.exists():
366
+ skipped.append(rel)
367
+ else:
368
+ shutil.copy2(src_file, dst_file)
369
+ created.append(rel)
370
+
371
+ return created, skipped
372
+
373
+
374
+ def _search_knowledge_repos() -> list[tuple[str, str]]:
375
+ """Search GitHub for shared knowledge repos using ``gh``.
376
+
377
+ Looks for repos matching ``*shared_knowledge*`` in the
378
+ authenticated user's repositories.
379
+
380
+ Returns:
381
+ List of (full_name, clone_url) tuples.
382
+ """
383
+ try:
384
+ result = subprocess.run(
385
+ [
386
+ "gh",
387
+ "repo",
388
+ "list",
389
+ "--limit",
390
+ "50",
391
+ "--json",
392
+ "nameWithOwner,sshUrl,description",
393
+ ],
394
+ capture_output=True,
395
+ text=True,
396
+ encoding="utf-8",
397
+ errors="replace",
398
+ check=False,
399
+ )
400
+ except FileNotFoundError:
401
+ return []
402
+ if result.returncode != 0:
403
+ return []
404
+
405
+ import json
406
+
407
+ try:
408
+ repos = json.loads(result.stdout)
409
+ except (json.JSONDecodeError, TypeError):
410
+ return []
411
+
412
+ candidates: list[tuple[str, str]] = []
413
+ for repo in repos:
414
+ name = repo.get("nameWithOwner", "")
415
+ # Match *shared_knowledge* or *shared-knowledge* (case-insensitive)
416
+ repo_name = name.rsplit("/", 1)[-1] if "/" in name else name
417
+ lower = repo_name.lower()
418
+ if "shared_knowledge" in lower or "shared-knowledge" in lower:
419
+ ssh_url = repo.get("sshUrl", "")
420
+ if ssh_url:
421
+ candidates.append((name, ssh_url))
422
+
423
+ return candidates
424
+
425
+
426
+ def _prompt_knowledge_sources(
427
+ project_dir: Path,
428
+ ) -> list[Any]:
429
+ """Interactively prompt the user to attach knowledge sources.
430
+
431
+ Searches GitHub for repos matching ``*shared_knowledge*`` or
432
+ ``*shared-knowledge*`` first, then presents them as candidates.
433
+ Also allows manual URL entry.
434
+
435
+ Returns:
436
+ List of KnowledgeSource to attach.
437
+ """
438
+ from runops.core.knowledge_source import KnowledgeSource
439
+
440
+ # Search first, then ask
441
+ typer.echo("\n Searching GitHub for shared knowledge repos...")
442
+ candidates = _search_knowledge_repos()
443
+
444
+ selected_sources: list[KnowledgeSource] = []
445
+
446
+ if candidates:
447
+ typer.echo("\n Found knowledge repositories:")
448
+ for i, (full_name, _url) in enumerate(candidates, 1):
449
+ typer.echo(f" {i}. {full_name}")
450
+
451
+ selection = typer.prompt(
452
+ "\n Select repos to attach (comma-separated numbers, Enter to skip)",
453
+ default="",
454
+ )
455
+
456
+ for token in selection.split(","):
457
+ token = token.strip()
458
+ if not token:
459
+ continue
460
+ if token.isdigit():
461
+ idx = int(token) - 1
462
+ if 0 <= idx < len(candidates):
463
+ full_name, url = candidates[idx]
464
+ repo_name = full_name.rsplit("/", 1)[-1]
465
+ selected_sources.append(
466
+ KnowledgeSource(
467
+ name=repo_name,
468
+ source_type="git",
469
+ url=url,
470
+ ref="main",
471
+ mount=f"refs/knowledge/{repo_name}",
472
+ )
473
+ )
474
+ else:
475
+ typer.echo(f" Warning: ignoring invalid number '{token}'")
476
+ else:
477
+ typer.echo(" No shared knowledge repos found on GitHub.")
478
+
479
+ # Allow manual entry
480
+ while True:
481
+ manual = typer.prompt(
482
+ "\n Add a knowledge source manually? "
483
+ "(git URL, local path, or Enter to finish)",
484
+ default="",
485
+ )
486
+ if not manual.strip():
487
+ break
488
+
489
+ manual = manual.strip()
490
+ # Detect type
491
+ is_git = (
492
+ manual.startswith("https://")
493
+ or manual.startswith("http://")
494
+ or manual.startswith("git@")
495
+ )
496
+ if is_git:
497
+ # Extract name from URL
498
+ stem = manual.rsplit("/", 1)[-1].rsplit(":", 1)[-1]
499
+ if stem.endswith(".git"):
500
+ stem = stem[:-4]
501
+ source_name = typer.prompt(" Source name", default=stem)
502
+ selected_sources.append(
503
+ KnowledgeSource(
504
+ name=source_name,
505
+ source_type="git",
506
+ url=manual,
507
+ ref="main",
508
+ mount=f"refs/knowledge/{source_name}",
509
+ )
510
+ )
511
+ else:
512
+ # Local path
513
+ p = Path(manual).expanduser()
514
+ source_name = typer.prompt(" Source name", default=p.name)
515
+ selected_sources.append(
516
+ KnowledgeSource(
517
+ name=source_name,
518
+ source_type="path",
519
+ url=manual,
520
+ mount=f"refs/knowledge/{source_name}",
521
+ )
522
+ )
523
+ typer.echo(f" Added: {source_name}")
524
+
525
+ if selected_sources:
526
+ typer.echo(f"\n {len(selected_sources)} knowledge source(s) selected.")
527
+ return selected_sources
528
+
529
+
530
+ def _prompt_simulators() -> tuple[list[str], dict[str, dict[str, Any]]]:
531
+ """Interactively prompt the user to select and configure simulators.
532
+
533
+ Returns:
534
+ Tuple of (simulator_names, {name: config_dict}).
535
+ """
536
+ import runops.adapters # noqa: F401
537
+ from runops.adapters.registry import get_global_registry
538
+
539
+ registry = get_global_registry()
540
+ available = registry.list_adapters()
541
+
542
+ typer.echo("\nAvailable simulators:")
543
+ for i, name in enumerate(available, 1):
544
+ typer.echo(f" {i}. {name}")
545
+
546
+ selection = typer.prompt(
547
+ "\nSelect simulators (comma-separated numbers or names, Enter to skip)",
548
+ default="",
549
+ )
550
+
551
+ if not selection.strip():
552
+ return [], {}
553
+
554
+ # Parse selection — accept both numbers and names
555
+ selected: list[str] = []
556
+ for token in selection.split(","):
557
+ token = token.strip()
558
+ if not token:
559
+ continue
560
+ if token.isdigit():
561
+ idx = int(token) - 1
562
+ if 0 <= idx < len(available):
563
+ selected.append(available[idx])
564
+ else:
565
+ typer.echo(f" Warning: ignoring invalid number '{token}'")
566
+ elif token in available:
567
+ selected.append(token)
568
+ else:
569
+ typer.echo(f" Warning: unknown simulator '{token}', skipping")
570
+
571
+ if not selected:
572
+ return [], {}
573
+
574
+ # Interactive config for each selected simulator
575
+ use_interactive = typer.confirm("\nCustomize simulator settings?", default=False)
576
+
577
+ configs: dict[str, dict[str, Any]] = {}
578
+ for sim_name in selected:
579
+ adapter_cls = registry.get(sim_name)
580
+ if use_interactive:
581
+ configs[sim_name] = adapter_cls.interactive_config()
582
+ else:
583
+ configs[sim_name] = adapter_cls.default_config()
584
+
585
+ return selected, configs
586
+
587
+
588
+ @dataclass
589
+ class _BundledSiteProfile:
590
+ """A bundled site profile loaded from sites/*.toml.
591
+
592
+ Used during ``runops init`` to offer preconfigured site choices.
593
+ The file uses the same ``[site]`` format as project-level ``site.toml``,
594
+ plus an optional ``[launcher]`` section for launcher defaults.
595
+
596
+ Attributes:
597
+ name: Site name (file stem, e.g. "camphor").
598
+ launcher: Launcher-only configuration dict for launchers.toml.
599
+ source_path: Path to the bundled .toml file (copied as site.toml).
600
+ docs_path: Path to companion .md documentation (may not exist).
601
+ """
602
+
603
+ name: str
604
+ launcher: dict[str, Any]
605
+ source_path: Path
606
+ docs_path: Path | None = None
607
+
608
+
609
+ # Legacy alias for backward compatibility with code that references the old name.
610
+ SiteProfile = _BundledSiteProfile
611
+
612
+
613
+ def _load_site_profiles() -> dict[str, _BundledSiteProfile]:
614
+ """Load site profiles from bundled TOML files in runops/sites/.
615
+
616
+ Each file uses the unified format:
617
+ - ``[site]`` section → copied as-is to project ``site.toml``
618
+ - ``[launcher]`` section → used for ``launchers.toml`` defaults
619
+ """
620
+ sites_dir = Path(__file__).resolve().parent.parent / "sites"
621
+ profiles: dict[str, _BundledSiteProfile] = {}
622
+ if not sites_dir.is_dir():
623
+ return profiles
624
+ for toml_file in sorted(sites_dir.glob("*.toml")):
625
+ with open(toml_file, "rb") as f:
626
+ data = tomllib.load(f)
627
+ # Require at least a [site] or [launcher] section
628
+ if "site" not in data and "launcher" not in data:
629
+ continue
630
+ launcher_data = dict(data.get("launcher", {}))
631
+ docs_file = toml_file.with_suffix(".md")
632
+ profiles[toml_file.stem] = _BundledSiteProfile(
633
+ name=toml_file.stem,
634
+ launcher=launcher_data,
635
+ source_path=toml_file,
636
+ docs_path=docs_file if docs_file.is_file() else None,
637
+ )
638
+ return profiles
639
+
640
+
641
+ def _prompt_launchers() -> tuple[dict[str, dict[str, Any]], _BundledSiteProfile | None]:
642
+ """Interactively prompt for launcher configuration.
643
+
644
+ Returns:
645
+ Tuple of (launcher config dict, selected _BundledSiteProfile or None).
646
+ """
647
+ site_profiles = _load_site_profiles()
648
+
649
+ typer.echo("\nLauncher configuration:")
650
+ typer.echo(" Site profiles (preconfigured):")
651
+ site_names = list(site_profiles.keys())
652
+ for i, sname in enumerate(site_names, start=1):
653
+ typer.echo(f" {i}. {sname}")
654
+ offset = len(site_names)
655
+ typer.echo(" Launcher types:")
656
+ typer.echo(f" {offset + 1}. srun (Slurm)")
657
+ typer.echo(f" {offset + 2}. mpirun (OpenMPI)")
658
+ typer.echo(f" {offset + 3}. mpiexec (MPICH)")
659
+
660
+ selection = typer.prompt(
661
+ "\nSelect site profile or launcher type (number or name, Enter to skip)",
662
+ default="",
663
+ )
664
+
665
+ sel = selection.strip()
666
+ if not sel:
667
+ return {}, None
668
+
669
+ # Check site profiles first
670
+ site_map = {str(i): name for i, name in enumerate(site_names, start=1)}
671
+ if sel in site_map:
672
+ profile_name = site_map[sel]
673
+ profile = site_profiles[profile_name]
674
+ return {profile_name: dict(profile.launcher)}, profile
675
+ if sel in site_profiles:
676
+ profile = site_profiles[sel]
677
+ return {sel: dict(profile.launcher)}, profile
678
+
679
+ # Launcher types
680
+ launcher_map = {
681
+ str(offset + 1): "srun",
682
+ str(offset + 2): "mpirun",
683
+ str(offset + 3): "mpiexec",
684
+ }
685
+ launcher_type = launcher_map.get(sel, sel)
686
+
687
+ if launcher_type not in ("srun", "mpirun", "mpiexec"):
688
+ typer.echo(f" Unknown selection '{sel}', skipping")
689
+ return {}, None
690
+
691
+ launcher_name = typer.prompt(" Launcher profile name", default=launcher_type)
692
+
693
+ config: dict[str, Any] = {"type": launcher_type}
694
+
695
+ if launcher_type == "srun":
696
+ use_slurm = typer.confirm(
697
+ " Use SLURM_NTASKS (rely on #SBATCH --ntasks)?", default=True
698
+ )
699
+ config["use_slurm_ntasks"] = use_slurm
700
+ config["args"] = typer.prompt(
701
+ " Extra srun arguments (e.g. --mpi=pmix)", default=""
702
+ )
703
+ elif launcher_type in ("mpirun", "mpiexec"):
704
+ config["args"] = typer.prompt(f" Extra {launcher_type} arguments", default="")
705
+
706
+ # Module loading
707
+ modules_str = typer.prompt(
708
+ " Modules to load (space-separated, Enter to skip)", default=""
709
+ )
710
+ if modules_str.strip():
711
+ config["modules"] = modules_str.strip().split()
712
+
713
+ # Clean empty args
714
+ if not config.get("args"):
715
+ config.pop("args", None)
716
+
717
+ return {launcher_name: config}, None
718
+
719
+
720
+ def _build_simulators_toml_from_configs(
721
+ configs: dict[str, dict[str, Any]],
722
+ ) -> str:
723
+ """Serialize simulator configs to TOML string."""
724
+ full_config: dict[str, Any] = {"simulators": configs}
725
+
726
+ if tomli_w is None:
727
+ lines = ["[simulators]", ""]
728
+ for sim_name, sim_cfg in configs.items():
729
+ lines.append(f"[simulators.{sim_name}]")
730
+ for key, value in sim_cfg.items():
731
+ if isinstance(value, list):
732
+ items = ", ".join(f'"{v}"' for v in value)
733
+ lines.append(f"{key} = [{items}]")
734
+ elif isinstance(value, str):
735
+ lines.append(f'{key} = "{value}"')
736
+ else:
737
+ lines.append(f"{key} = {value}")
738
+ lines.append("")
739
+ return "\n".join(lines) + "\n"
740
+
741
+ import io
742
+
743
+ buf = io.BytesIO()
744
+ tomli_w.dump(full_config, buf)
745
+ return buf.getvalue().decode("utf-8")
746
+
747
+
748
+ def _build_launchers_toml(launchers: dict[str, dict[str, Any]]) -> str:
749
+ """Serialize launcher configs to TOML string."""
750
+ if not launchers:
751
+ return "[launchers]\n"
752
+
753
+ full_config: dict[str, Any] = {"launchers": launchers}
754
+
755
+ if tomli_w is None:
756
+ lines = ["[launchers]", ""]
757
+ for name, cfg in launchers.items():
758
+ lines.append(f"[launchers.{name}]")
759
+ for key, value in cfg.items():
760
+ if isinstance(value, list):
761
+ items = ", ".join(f'"{v}"' for v in value)
762
+ lines.append(f"{key} = [{items}]")
763
+ elif isinstance(value, str):
764
+ lines.append(f'{key} = "{value}"')
765
+ elif isinstance(value, bool):
766
+ lines.append(f"{key} = {str(value).lower()}")
767
+ else:
768
+ lines.append(f"{key} = {value}")
769
+ lines.append("")
770
+ return "\n".join(lines) + "\n"
771
+
772
+ import io
773
+
774
+ buf = io.BytesIO()
775
+ tomli_w.dump(full_config, buf)
776
+ return buf.getvalue().decode("utf-8")
777
+
778
+
779
+ def _build_campaign_toml(project_name: str, simulator_names: list[str]) -> str:
780
+ """Build a minimal campaign.toml skeleton."""
781
+ from runops.templates import render
782
+
783
+ return render(
784
+ "scaffold/campaign.toml.j2",
785
+ schema_base_url=_SCHEMA_BASE_URL,
786
+ project_name=project_name,
787
+ simulator=simulator_names[0] if simulator_names else "",
788
+ )
789
+
790
+
791
+ def _venv_pip_executable(venv_dir: Path) -> Path:
792
+ """Return the pip executable path inside a virtual environment."""
793
+ if sys.platform == "win32":
794
+ return venv_dir / "Scripts" / "pip.exe"
795
+ return venv_dir / "bin" / "pip"
796
+
797
+
798
+ def _find_uv() -> str:
799
+ """Find the uv executable, falling back to 'uv'."""
800
+ uv_path = shutil.which("uv")
801
+ return uv_path if uv_path else "uv"
802
+
803
+
804
+ def _bootstrap_environment(
805
+ project_dir: Path,
806
+ sim_names: list[str],
807
+ runops_repo: str,
808
+ created: list[str],
809
+ skipped: list[str],
810
+ ) -> None:
811
+ """Bootstrap .venv, clone runops into tools/, and editable-install.
812
+
813
+ Args:
814
+ project_dir: Project root directory.
815
+ sim_names: List of simulator names for pip packages.
816
+ runops_repo: Git URL for runops repository.
817
+ created: Mutable list to append created items.
818
+ skipped: Mutable list to append skipped items.
819
+ """
820
+ uv = _find_uv()
821
+ venv_dir = project_dir / ".venv"
822
+ tools_dir = project_dir / "tools"
823
+ runops_dir = tools_dir / "runops"
824
+
825
+ # 1. Create .venv via uv
826
+ if venv_dir.exists():
827
+ skipped.append(".venv")
828
+ else:
829
+ typer.echo(" Creating .venv ...")
830
+ venv_result = subprocess.run(
831
+ [uv, "venv", str(venv_dir)],
832
+ cwd=str(project_dir),
833
+ capture_output=True,
834
+ text=True,
835
+ encoding="utf-8",
836
+ errors="replace",
837
+ check=False,
838
+ )
839
+ if venv_result.returncode == 0:
840
+ created.append(".venv")
841
+ else:
842
+ typer.echo(
843
+ f" Warning: uv venv failed: {(venv_result.stderr or '').strip()}"
844
+ )
845
+ return
846
+
847
+ # 2. Clone runops into tools/
848
+ if runops_dir.exists():
849
+ skipped.append("tools/runops")
850
+ else:
851
+ typer.echo(" Cloning runops into tools/ ...")
852
+ tools_dir.mkdir(exist_ok=True)
853
+ clone_result = subprocess.run(
854
+ ["git", "clone", runops_repo, str(runops_dir)],
855
+ capture_output=True,
856
+ text=True,
857
+ encoding="utf-8",
858
+ errors="replace",
859
+ check=False,
860
+ )
861
+ if clone_result.returncode == 0:
862
+ created.append("tools/runops")
863
+ else:
864
+ typer.echo(
865
+ f" Warning: git clone failed: "
866
+ f"{(clone_result.stderr or '').strip()[:300]}"
867
+ )
868
+ return
869
+
870
+ # 3. Editable install runops into .venv
871
+ typer.echo(" Installing runops (editable) ...")
872
+ install_result = subprocess.run(
873
+ [
874
+ uv,
875
+ "pip",
876
+ "install",
877
+ "-e",
878
+ str(runops_dir),
879
+ "--python",
880
+ str(
881
+ venv_dir
882
+ / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python")
883
+ ),
884
+ ],
885
+ cwd=str(project_dir),
886
+ capture_output=True,
887
+ text=True,
888
+ encoding="utf-8",
889
+ errors="replace",
890
+ check=False,
891
+ )
892
+ if install_result.returncode == 0:
893
+ created.append("uv pip install -e tools/runops")
894
+ else:
895
+ typer.echo(
896
+ f" Warning: editable install failed:\n"
897
+ f" {(install_result.stderr or '').strip()[:300]}"
898
+ )
899
+
900
+ # 4. Install simulator-specific packages
901
+ pip_pkgs = _collect_pip_packages(sim_names) if sim_names else []
902
+ if pip_pkgs:
903
+ typer.echo(f" Installing: {', '.join(pip_pkgs)} ...")
904
+ pkg_result = subprocess.run(
905
+ [
906
+ uv,
907
+ "pip",
908
+ "install",
909
+ *pip_pkgs,
910
+ "--python",
911
+ str(
912
+ venv_dir
913
+ / (
914
+ "Scripts/python.exe"
915
+ if sys.platform == "win32"
916
+ else "bin/python"
917
+ )
918
+ ),
919
+ ],
920
+ cwd=str(project_dir),
921
+ capture_output=True,
922
+ text=True,
923
+ encoding="utf-8",
924
+ errors="replace",
925
+ check=False,
926
+ )
927
+ if pkg_result.returncode == 0:
928
+ created.append(f"pip install ({len(pip_pkgs)} packages)")
929
+ else:
930
+ _safe_echo(
931
+ f" Warning: pip install failed:\n"
932
+ f" {(pkg_result.stderr or '').strip()[:300]}",
933
+ )
934
+
935
+ # 5. Activation hint
936
+ if sys.platform == "win32":
937
+ activate_cmd = r".venv\Scripts\activate"
938
+ else:
939
+ activate_cmd = "source .venv/bin/activate"
940
+ typer.echo(f"\n Next: {activate_cmd}")
941
+ typer.echo(" Then: runops doctor")
942
+
943
+
944
+ def init(
945
+ simulators: Annotated[
946
+ Optional[list[str]],
947
+ typer.Argument(help="Simulator names to configure (e.g. emses beach)."),
948
+ ] = None,
949
+ path: Annotated[
950
+ Optional[Path],
951
+ typer.Option("--path", "-p", help="Directory to initialize (defaults to cwd)."),
952
+ ] = None,
953
+ name: Annotated[
954
+ Optional[str],
955
+ typer.Option("--name", "-n", help="Project name (defaults to directory name)."),
956
+ ] = None,
957
+ yes: Annotated[
958
+ bool,
959
+ typer.Option("--yes", "-y", help="Skip interactive prompts, use defaults."),
960
+ ] = False,
961
+ no_upstream_feedback: Annotated[
962
+ bool,
963
+ typer.Option(
964
+ "--no-upstream-feedback",
965
+ help="Do not include the upstream-feedback rule for the AI agent.",
966
+ ),
967
+ ] = False,
968
+ runops_repo: Annotated[
969
+ str,
970
+ typer.Option(
971
+ "--runops-repo",
972
+ help="Git URL for runops repository.",
973
+ ),
974
+ ] = _DEFAULT_SIMCTL_REPO,
975
+ ) -> None:
976
+ """Initialize a new runops project (runops.toml etc.).
977
+
978
+ By default, runs in interactive mode with guided prompts.
979
+ Use --yes / -y to skip prompts and use defaults.
980
+
981
+ Simulator names can also be passed directly:
982
+ runops init emses beach
983
+
984
+ Bootstrap usage (no prior install needed):
985
+ uvx --from runops runops init
986
+ """
987
+ interactive = not yes
988
+ project_dir = (path or Path.cwd()).resolve()
989
+
990
+ if not project_dir.exists():
991
+ project_dir.mkdir(parents=True)
992
+
993
+ # Interactive project name
994
+ if interactive and not name:
995
+ project_name = typer.prompt("Project name", default=project_dir.name)
996
+ else:
997
+ project_name = name or project_dir.name
998
+
999
+ created: list[str] = []
1000
+ skipped: list[str] = []
1001
+
1002
+ upstream_feedback = not no_upstream_feedback
1003
+
1004
+ # runops.toml
1005
+ harness_line = (
1006
+ f"\n[harness]\nupstream_feedback = {'true' if upstream_feedback else 'false'}\n"
1007
+ )
1008
+ simproject_content = (
1009
+ f"#:schema {_SCHEMA_BASE_URL}/simproject.json\n"
1010
+ f'[project]\nname = "{project_name}"\ndescription = ""\n' + harness_line
1011
+ )
1012
+ if _write_if_missing(project_dir / _SIMPROJECT_FILE, simproject_content):
1013
+ created.append(_SIMPROJECT_FILE)
1014
+ else:
1015
+ skipped.append(_SIMPROJECT_FILE)
1016
+
1017
+ # simulators.toml
1018
+ sim_configs: dict[str, dict[str, Any]] = {}
1019
+ sim_names: list[str] = []
1020
+
1021
+ if simulators:
1022
+ sim_names = simulators
1023
+ sim_content = _build_simulators_toml(simulators)
1024
+ elif interactive:
1025
+ sim_names, sim_configs = _prompt_simulators()
1026
+ if sim_configs:
1027
+ sim_content = _build_simulators_toml_from_configs(sim_configs)
1028
+ else:
1029
+ sim_content = "[simulators]\n"
1030
+ else:
1031
+ sim_content = "[simulators]\n"
1032
+
1033
+ sim_schema = f"#:schema {_SCHEMA_BASE_URL}/simulators.json\n"
1034
+ sim_content = sim_schema + sim_content
1035
+ if _write_if_missing(project_dir / _SIMULATORS_FILE, sim_content):
1036
+ created.append(_SIMULATORS_FILE)
1037
+ else:
1038
+ skipped.append(_SIMULATORS_FILE)
1039
+
1040
+ # launchers.toml
1041
+ site_profile: _BundledSiteProfile | None = None
1042
+ if interactive:
1043
+ launcher_configs, site_profile = _prompt_launchers()
1044
+ launcher_content = _build_launchers_toml(launcher_configs)
1045
+ else:
1046
+ launcher_configs = {
1047
+ "srun": {"type": "srun", "use_slurm_ntasks": True},
1048
+ }
1049
+ launcher_content = _build_launchers_toml(launcher_configs)
1050
+
1051
+ launcher_schema = f"#:schema {_SCHEMA_BASE_URL}/launchers.json\n"
1052
+ launcher_content = launcher_schema + launcher_content
1053
+ if _write_if_missing(project_dir / _LAUNCHERS_FILE, launcher_content):
1054
+ created.append(_LAUNCHERS_FILE)
1055
+ else:
1056
+ skipped.append(_LAUNCHERS_FILE)
1057
+
1058
+ # site.toml — copy from bundled site profile
1059
+ if site_profile:
1060
+ from runops.core.site import _load_site_toml
1061
+
1062
+ site_file = project_dir / "site.toml"
1063
+ if not site_file.exists():
1064
+ # Read bundled file, write only the [site] sections (strip [launcher])
1065
+ with open(site_profile.source_path, "rb") as f:
1066
+ bundled_data = tomllib.load(f)
1067
+ site_only: dict[str, Any] = {}
1068
+ if "site" in bundled_data:
1069
+ site_only["site"] = bundled_data["site"]
1070
+ if site_only and tomli_w is not None:
1071
+ with open(site_file, "wb") as f:
1072
+ tomli_w.dump(site_only, f)
1073
+ created.append("site.toml")
1074
+ elif site_only:
1075
+ skipped.append("site.toml (tomli_w not available)")
1076
+ else:
1077
+ skipped.append("site.toml")
1078
+
1079
+ # Apply per-simulator modules from site profile to simulators.toml
1080
+ site_data_loaded = _load_site_toml(site_profile.source_path)
1081
+ if site_data_loaded.simulator_modules:
1082
+ sim_file = project_dir / _SIMULATORS_FILE
1083
+ if sim_file.exists():
1084
+ with open(sim_file, "rb") as f:
1085
+ existing = tomllib.load(f)
1086
+ sims = existing.get("simulators", {})
1087
+ updated = False
1088
+ for (
1089
+ sim_name,
1090
+ site_modules,
1091
+ ) in site_data_loaded.simulator_modules.items():
1092
+ if sim_name in sims and site_modules:
1093
+ sims[sim_name]["modules"] = site_modules
1094
+ updated = True
1095
+ if updated and tomli_w is not None:
1096
+ existing["simulators"] = sims
1097
+ with open(sim_file, "wb") as f:
1098
+ tomli_w.dump(existing, f)
1099
+
1100
+ # SITE.md — copy companion docs from bundled site profile
1101
+ if site_profile and site_profile.docs_path:
1102
+ site_md = project_dir / "SITE.md"
1103
+ if site_md.exists():
1104
+ skipped.append("SITE.md")
1105
+ else:
1106
+ shutil.copy2(site_profile.docs_path, site_md)
1107
+ created.append("SITE.md")
1108
+
1109
+ # campaign.toml
1110
+ campaign_content = _build_campaign_toml(project_name, sim_names)
1111
+ if _write_if_missing(project_dir / _CAMPAIGN_FILE, campaign_content):
1112
+ created.append(_CAMPAIGN_FILE)
1113
+ else:
1114
+ skipped.append(_CAMPAIGN_FILE)
1115
+
1116
+ # cases/ directory (with per-simulator subdirectories)
1117
+ if _mkdir_if_missing(project_dir / "cases"):
1118
+ created.append("cases/")
1119
+ else:
1120
+ skipped.append("cases/")
1121
+ for sim in sim_names:
1122
+ sim_cases_dir = project_dir / "cases" / sim
1123
+ if _mkdir_if_missing(sim_cases_dir):
1124
+ created.append(f"cases/{sim}/")
1125
+
1126
+ # runs/ directory
1127
+ if _mkdir_if_missing(project_dir / "runs"):
1128
+ created.append("runs/")
1129
+ else:
1130
+ skipped.append("runs/")
1131
+
1132
+ # .runops/ skeleton (insights, facts, generated knowledge)
1133
+ _create_runops_skeleton(project_dir, created)
1134
+
1135
+ # notes/ skeleton (chronological lab notebook + reports)
1136
+ _create_notes_skeleton(project_dir, created)
1137
+
1138
+ # refs/ — clone simulator doc repos
1139
+ if sim_names:
1140
+ refs_created, refs_skipped = _clone_doc_repos(project_dir, sim_names)
1141
+ created.extend(refs_created)
1142
+ skipped.extend(refs_skipped)
1143
+
1144
+ # .gitignore
1145
+ from runops.templates import load_static
1146
+
1147
+ if _write_if_missing(
1148
+ project_dir / ".gitignore", load_static("scaffold/gitignore.txt")
1149
+ ):
1150
+ created.append(".gitignore")
1151
+ else:
1152
+ skipped.append(".gitignore")
1153
+
1154
+ # Interactive knowledge source selection
1155
+ if interactive:
1156
+ knowledge_sources = _prompt_knowledge_sources(project_dir)
1157
+ if knowledge_sources:
1158
+ from runops.core.knowledge_source import save_knowledge_source
1159
+
1160
+ for ks in knowledge_sources:
1161
+ save_knowledge_source(project_dir, ks)
1162
+
1163
+ # Bootstrap: .venv + tools/runops + editable install
1164
+ _bootstrap_environment(project_dir, sim_names, runops_repo, created, skipped)
1165
+
1166
+ # Discover agent docs after bootstrap so tools/runops/docs/ can be imported.
1167
+ knowledge_imports_path = _prepare_knowledge_imports(
1168
+ project_dir,
1169
+ sim_names,
1170
+ sync_sources=True,
1171
+ )
1172
+
1173
+ # Build all harness files (CLAUDE.md, AGENTS.md, skills, rules,
1174
+ # settings.json, subdirectory CLAUDE.md) via the shared builder so that
1175
+ # `runops update-harness` can re-render the same set later.
1176
+ from runops.harness.builder import build_harness_bundle, save_harness_lock
1177
+
1178
+ harness = build_harness_bundle(
1179
+ project_name,
1180
+ sim_names,
1181
+ upstream_feedback=upstream_feedback,
1182
+ knowledge_imports_path=knowledge_imports_path,
1183
+ )
1184
+ for rel_path, content in sorted(harness.files.items()):
1185
+ full_path = project_dir / rel_path
1186
+ full_path.parent.mkdir(parents=True, exist_ok=True)
1187
+ if _write_if_missing(full_path, content):
1188
+ created.append(rel_path)
1189
+ else:
1190
+ skipped.append(rel_path)
1191
+
1192
+ # Persist template hashes so update-harness can detect user edits.
1193
+ save_harness_lock(project_dir, harness.hashes())
1194
+
1195
+ # .vscode/settings.json
1196
+ vscode_dir = project_dir / _VSCODE_DIR
1197
+ vscode_settings = vscode_dir / _VSCODE_SETTINGS
1198
+ if vscode_settings.exists():
1199
+ skipped.append(f"{_VSCODE_DIR}/{_VSCODE_SETTINGS}")
1200
+ else:
1201
+ vscode_dir.mkdir(exist_ok=True)
1202
+ vscode_settings.write_text(
1203
+ load_static("scaffold/vscode_settings.json"), encoding="utf-8"
1204
+ )
1205
+ created.append(f"{_VSCODE_DIR}/{_VSCODE_SETTINGS}")
1206
+
1207
+ # git init
1208
+ fresh_git = False
1209
+ if (project_dir / ".git").exists():
1210
+ skipped.append("git init")
1211
+ else:
1212
+ result = subprocess.run(
1213
+ ["git", "init"],
1214
+ cwd=project_dir,
1215
+ capture_output=True,
1216
+ text=True,
1217
+ encoding="utf-8",
1218
+ errors="replace",
1219
+ check=False,
1220
+ )
1221
+ if result.returncode == 0:
1222
+ created.append("git init")
1223
+ fresh_git = True
1224
+ else:
1225
+ typer.echo(f" Warning: git init failed: {(result.stderr or '').strip()}")
1226
+
1227
+ # Initial commit (only for freshly created repos)
1228
+ if fresh_git:
1229
+ subprocess.run(
1230
+ ["git", "add", "."],
1231
+ cwd=project_dir,
1232
+ capture_output=True,
1233
+ check=False,
1234
+ )
1235
+ result = subprocess.run(
1236
+ ["git", "commit", "-m", "Initial commit"],
1237
+ cwd=project_dir,
1238
+ capture_output=True,
1239
+ text=True,
1240
+ encoding="utf-8",
1241
+ errors="replace",
1242
+ check=False,
1243
+ )
1244
+ if result.returncode == 0:
1245
+ created.append("git commit (Initial commit)")
1246
+ else:
1247
+ typer.echo(
1248
+ f" Warning: initial commit failed: {(result.stderr or '').strip()}"
1249
+ )
1250
+
1251
+ # Print results
1252
+ typer.echo(f"Initialized project '{project_name}' in {project_dir}")
1253
+ if created:
1254
+ typer.echo(" Created:")
1255
+ for item in created:
1256
+ typer.echo(f" {item}")
1257
+ if skipped:
1258
+ typer.echo(" Skipped (already exist):")
1259
+ for item in skipped:
1260
+ typer.echo(f" {item}")
1261
+
1262
+
1263
+ def doctor(
1264
+ path: Annotated[
1265
+ Optional[Path],
1266
+ typer.Argument(help="Project directory to check."),
1267
+ ] = None,
1268
+ ) -> None:
1269
+ """Check the environment and project configuration for issues."""
1270
+ project_dir = (path or Path.cwd()).resolve()
1271
+ failures: list[str] = []
1272
+
1273
+ # Check runops.toml exists and is valid
1274
+ simproject_path = project_dir / _SIMPROJECT_FILE
1275
+ if not simproject_path.exists():
1276
+ typer.echo("[FAIL] runops.toml not found")
1277
+ failures.append(_SIMPROJECT_FILE)
1278
+ else:
1279
+ try:
1280
+ load_project(project_dir)
1281
+ typer.echo("[PASS] runops.toml is valid")
1282
+ except ProjectConfigError as e:
1283
+ typer.echo(f"[FAIL] runops.toml: {e}")
1284
+ failures.append(_SIMPROJECT_FILE)
1285
+
1286
+ # Check simulators.toml exists
1287
+ if (project_dir / _SIMULATORS_FILE).exists():
1288
+ typer.echo("[PASS] simulators.toml found")
1289
+ else:
1290
+ typer.echo("[FAIL] simulators.toml not found")
1291
+ failures.append(_SIMULATORS_FILE)
1292
+
1293
+ # Check launchers.toml exists
1294
+ if (project_dir / _LAUNCHERS_FILE).exists():
1295
+ typer.echo("[PASS] launchers.toml found")
1296
+ else:
1297
+ typer.echo("[FAIL] launchers.toml not found")
1298
+ failures.append(_LAUNCHERS_FILE)
1299
+
1300
+ # Check sbatch availability
1301
+ if shutil.which("sbatch") is not None:
1302
+ typer.echo("[PASS] sbatch is available")
1303
+ else:
1304
+ typer.echo("[FAIL] sbatch not found in PATH")
1305
+ failures.append("sbatch")
1306
+
1307
+ # Check simulator adapters from simulators.toml
1308
+ simulators_path = project_dir / _SIMULATORS_FILE
1309
+ if simulators_path.exists():
1310
+ try:
1311
+ with open(simulators_path, "rb") as f:
1312
+ sim_data = tomllib.load(f)
1313
+ simulators: dict[str, Any] = sim_data.get("simulators", {})
1314
+ if simulators:
1315
+ from runops.adapters.registry import AdapterRegistry
1316
+
1317
+ registry = AdapterRegistry()
1318
+ for sim_name, sim_cfg in simulators.items():
1319
+ if not isinstance(sim_cfg, dict):
1320
+ continue
1321
+ adapter_name = sim_cfg.get("adapter", "")
1322
+ if not adapter_name:
1323
+ continue
1324
+ try:
1325
+ registry.load_from_config({"simulators": {sim_name: sim_cfg}})
1326
+ typer.echo(
1327
+ f"[PASS] Simulator adapter '{adapter_name}' "
1328
+ f"for '{sim_name}' is importable"
1329
+ )
1330
+ except Exception as e:
1331
+ typer.echo(
1332
+ f"[FAIL] Simulator adapter '{adapter_name}' "
1333
+ f"for '{sim_name}': {e}"
1334
+ )
1335
+ failures.append(f"adapter:{adapter_name}")
1336
+ except tomllib.TOMLDecodeError as e:
1337
+ typer.echo(f"[FAIL] simulators.toml parse error: {e}")
1338
+ failures.append(_SIMULATORS_FILE)
1339
+
1340
+ # Check launcher configs from launchers.toml
1341
+ launchers_path = project_dir / _LAUNCHERS_FILE
1342
+ if launchers_path.exists():
1343
+ try:
1344
+ with open(launchers_path, "rb") as f:
1345
+ launcher_data = tomllib.load(f)
1346
+ launchers: dict[str, Any] = launcher_data.get("launchers", {})
1347
+ if launchers:
1348
+ from runops.launchers.base import Launcher, LauncherConfigError
1349
+
1350
+ for lname, lcfg in launchers.items():
1351
+ if not isinstance(lcfg, dict):
1352
+ continue
1353
+ try:
1354
+ Launcher.from_config(lname, lcfg)
1355
+ typer.echo(f"[PASS] Launcher profile '{lname}' is valid")
1356
+ except LauncherConfigError as e:
1357
+ typer.echo(f"[FAIL] Launcher profile '{lname}': {e}")
1358
+ failures.append(f"launcher:{lname}")
1359
+ except tomllib.TOMLDecodeError as e:
1360
+ typer.echo(f"[FAIL] launchers.toml parse error: {e}")
1361
+ failures.append(_LAUNCHERS_FILE)
1362
+
1363
+ # Check run_id uniqueness
1364
+ runs_dir = project_dir / "runs"
1365
+ if runs_dir.is_dir():
1366
+ try:
1367
+ validate_uniqueness(runs_dir)
1368
+ typer.echo("[PASS] No duplicate run_ids")
1369
+ except DuplicateRunIdError as e:
1370
+ typer.echo(f"[FAIL] Duplicate run_id: {e}")
1371
+ failures.append("run_id uniqueness")
1372
+ else:
1373
+ typer.echo("[PASS] No runs/ directory (nothing to check)")
1374
+
1375
+ # Environment detection
1376
+ typer.echo("\n--- Environment ---")
1377
+ try:
1378
+ from runops.core.environment import (
1379
+ detect_environment,
1380
+ load_environment,
1381
+ save_environment,
1382
+ )
1383
+
1384
+ existing = load_environment(project_dir)
1385
+ if existing:
1386
+ typer.echo(
1387
+ f"[PASS] environment.toml found (cluster: {existing.cluster_name})"
1388
+ )
1389
+ if existing.partitions:
1390
+ for p in existing.partitions:
1391
+ default_mark = " (default)" if p.default else ""
1392
+ typer.echo(f" partition: {p.name}{default_mark}")
1393
+ else:
1394
+ typer.echo("[INFO] Detecting environment...")
1395
+ env_info = detect_environment()
1396
+ if env_info.partitions:
1397
+ typer.echo(
1398
+ f" Detected {len(env_info.partitions)} Slurm partition(s)"
1399
+ )
1400
+ try:
1401
+ env_path = save_environment(project_dir, env_info)
1402
+ typer.echo(
1403
+ f"[PASS] Saved environment to {env_path.relative_to(project_dir)}"
1404
+ )
1405
+ except RuntimeError:
1406
+ typer.echo(
1407
+ "[WARN] Could not save environment.toml (tomli_w not installed)"
1408
+ )
1409
+ except Exception as e:
1410
+ typer.echo(f"[WARN] Environment detection failed: {e}")
1411
+
1412
+ # Campaign check
1413
+ campaign_file = project_dir / "campaign.toml"
1414
+ if campaign_file.is_file():
1415
+ try:
1416
+ from runops.core.campaign import load_campaign
1417
+
1418
+ campaign = load_campaign(project_dir)
1419
+ if campaign:
1420
+ typer.echo(f"[PASS] campaign.toml: {campaign.name}")
1421
+ except Exception as e:
1422
+ typer.echo(f"[FAIL] campaign.toml: {e}")
1423
+ failures.append("campaign.toml")
1424
+ else:
1425
+ typer.echo("[INFO] No campaign.toml (optional)")
1426
+
1427
+ # Final verdict
1428
+ if failures:
1429
+ typer.echo(f"\n{len(failures)} check(s) failed.")
1430
+ raise typer.Exit(code=1)
1431
+ else:
1432
+ typer.echo("\nAll checks passed.")