lightcone-cli 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 (46) hide show
  1. lightcone/cli/__init__.py +16 -0
  2. lightcone/cli/claude/lightcone/agents/lc-extractor.md +114 -0
  3. lightcone/cli/claude/lightcone/guides/astra-reference.md +290 -0
  4. lightcone/cli/claude/lightcone/guides/lightcone-cli-reference.md +75 -0
  5. lightcone/cli/claude/lightcone/guides/ui-brand.md +86 -0
  6. lightcone/cli/claude/lightcone/hooks/langfuse_git_commit_hook.py +303 -0
  7. lightcone/cli/claude/lightcone/hooks/langfuse_hook.py +894 -0
  8. lightcone/cli/claude/lightcone/hooks/langfuse_prepare_commit_msg.py +142 -0
  9. lightcone/cli/claude/lightcone/hooks/langfuse_session_init_hook.py +83 -0
  10. lightcone/cli/claude/lightcone/hooks/langfuse_utils.py +457 -0
  11. lightcone/cli/claude/lightcone/scripts/activate-venv.sh +44 -0
  12. lightcone/cli/claude/lightcone/scripts/check-lc-run.sh +140 -0
  13. lightcone/cli/claude/lightcone/scripts/session-start.sh +140 -0
  14. lightcone/cli/claude/lightcone/scripts/validate-on-save.sh +77 -0
  15. lightcone/cli/claude/lightcone/skills/lc-build/SKILL.md +92 -0
  16. lightcone/cli/claude/lightcone/skills/lc-build/assets/loop-prompt.md +92 -0
  17. lightcone/cli/claude/lightcone/skills/lc-build/scripts/setup-lc-build.sh +240 -0
  18. lightcone/cli/claude/lightcone/skills/lc-feedback/SKILL.md +94 -0
  19. lightcone/cli/claude/lightcone/skills/lc-migrate/SKILL.md +98 -0
  20. lightcone/cli/claude/lightcone/skills/lc-new/SKILL.md +183 -0
  21. lightcone/cli/claude/lightcone/skills/lc-verify/SKILL.md +53 -0
  22. lightcone/cli/claude/lightcone/templates/CLAUDE.md +32 -0
  23. lightcone/cli/commands.py +2327 -0
  24. lightcone/cli/plugin.py +34 -0
  25. lightcone/engine/__init__.py +42 -0
  26. lightcone/engine/assets.py +418 -0
  27. lightcone/engine/container.py +370 -0
  28. lightcone/engine/io_manager.py +27 -0
  29. lightcone/engine/runner.py +1017 -0
  30. lightcone/engine/site_registry.py +142 -0
  31. lightcone/engine/status.py +135 -0
  32. lightcone/engine/targets.py +68 -0
  33. lightcone/engine/tree.py +245 -0
  34. lightcone/eval/__init__.py +25 -0
  35. lightcone/eval/build.py +148 -0
  36. lightcone/eval/cli.py +176 -0
  37. lightcone/eval/graders.py +192 -0
  38. lightcone/eval/harness.py +265 -0
  39. lightcone/eval/models.py +117 -0
  40. lightcone/eval/report.py +214 -0
  41. lightcone/eval/sandbox.py +394 -0
  42. lightcone_cli-0.2.0.dist-info/METADATA +16 -0
  43. lightcone_cli-0.2.0.dist-info/RECORD +46 -0
  44. lightcone_cli-0.2.0.dist-info/WHEEL +4 -0
  45. lightcone_cli-0.2.0.dist-info/entry_points.txt +2 -0
  46. lightcone_cli-0.2.0.dist-info/licenses/LICENSE +29 -0
@@ -0,0 +1,34 @@
1
+ """Plugin bundle discovery — finds the Claude Code skills/hooks shipped with lightcone-cli.
2
+
3
+ Kept deliberately leaf (no imports from :mod:`lightcone.cli.commands` or :mod:`lightcone.eval`)
4
+ so it can be used by both the CLI and the eval harness without introducing an import cycle.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+
12
+ def get_plugin_source_dir() -> Path | None:
13
+ """Find the lightcone Claude plugin source directory.
14
+
15
+ Looks for the plugin files in:
16
+
17
+ 1. Bundled location (installed package): ``lightcone/cli/claude/lightcone/``
18
+ 2. Development location (repo): ``claude/lightcone/`` relative to repo root
19
+ """
20
+ import lightcone.cli
21
+
22
+ package_dir = Path(lightcone.cli.__file__).parent
23
+ bundled_plugin = package_dir / "claude" / "lightcone"
24
+ if bundled_plugin.exists():
25
+ return bundled_plugin
26
+
27
+ # Try development location (running from repo)
28
+ # package_dir == <repo>/src/lightcone/cli → parents[2] == <repo>
29
+ repo_root = package_dir.parents[2]
30
+ dev_plugin = repo_root / "claude" / "lightcone"
31
+ if dev_plugin.exists():
32
+ return dev_plugin
33
+
34
+ return None
@@ -0,0 +1,42 @@
1
+ """Lightcone execution engine — Dagster assets, runners, and HPC targets.
2
+
3
+ Provides:
4
+ - build_definitions(): Generate Dagster Definitions from astra.yaml
5
+ - ASTRAContainerRunner: Execute recipes in Docker/SLURM containers
6
+ - ASTRAIOManager: Map (asset, universe) to filesystem paths
7
+ - get_output_status(): Query materialization status
8
+ """
9
+ from lightcone.engine.io_manager import ASTRAIOManager
10
+ from lightcone.engine.runner import (
11
+ ASTRAContainerRunner,
12
+ generate_sbatch_script,
13
+ translate_resources_to_slurm_directives,
14
+ )
15
+ from lightcone.engine.status import get_all_universe_status, get_output_status
16
+ from lightcone.engine.targets import list_targets, load_target, save_target
17
+
18
+ __all__ = [
19
+ "ASTRAContainerRunner",
20
+ "ASTRAIOManager",
21
+ "build_asset_definitions",
22
+ "build_definitions",
23
+ "generate_sbatch_script",
24
+ "get_output_status",
25
+ "get_all_universe_status",
26
+ "list_targets",
27
+ "load_target",
28
+ "save_target",
29
+ "translate_resources_to_slurm_directives",
30
+ ]
31
+
32
+
33
+ def build_definitions(*args, **kwargs):
34
+ """Build Dagster Definitions from astra.yaml. Requires dagster to be installed."""
35
+ from lightcone.engine.assets import build_definitions as _build
36
+ return _build(*args, **kwargs)
37
+
38
+
39
+ def build_asset_definitions(*args, **kwargs):
40
+ """Build asset definitions from astra.yaml. Requires dagster to be installed."""
41
+ from lightcone.engine.assets import build_asset_definitions as _build
42
+ return _build(*args, **kwargs)
@@ -0,0 +1,418 @@
1
+ """Asset factory — generates Dagster assets from astra.yaml output recipes."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import dagster as dg
9
+ from astra.helpers import get_inputs, load_yaml, resolve_analysis_tree
10
+
11
+ from lightcone.engine.container import resolve_container_for_slurm, resolve_container_spec
12
+ from lightcone.engine.runner import ASTRAContainerRunner
13
+ from lightcone.engine.tree import (
14
+ TreeOutput,
15
+ collect_tree_outputs,
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def get_external_inputs(spec: dict[str, Any]) -> dict[str, str]:
22
+ """Return {input_id: source_path} for inputs with a filesystem source."""
23
+ result = {}
24
+ for inp in get_inputs(spec):
25
+ source = inp.get("source")
26
+ if source and isinstance(source, str) and source.startswith("/"):
27
+ result[inp["id"]] = source
28
+ return result
29
+
30
+
31
+ def _resolve_container(
32
+ spec: str | None,
33
+ project_path: Path,
34
+ project_name: str,
35
+ container_runtime: str | None = None,
36
+ local_runtime: str | None = None,
37
+ ) -> str | None:
38
+ """Resolve a container spec, dispatching to the right builder.
39
+
40
+ When *container_runtime* is set (i.e. we are targeting SLURM), uses
41
+ ``resolve_container_for_slurm`` which handles podman-hpc build/migrate
42
+ and podman-hpc migrate automatically. When *local_runtime* is set
43
+ (Docker or Podman detected locally), uses ``resolve_container_spec``
44
+ with that runtime. Returns ``None`` if no runtime is available.
45
+ """
46
+ if container_runtime:
47
+ return resolve_container_for_slurm(
48
+ spec, project_path, project_name, container_runtime,
49
+ )
50
+ if local_runtime:
51
+ return resolve_container_spec(
52
+ spec, project_path, project_name, runtime=local_runtime,
53
+ )
54
+ # No container runtime available — skip building
55
+ return None
56
+
57
+
58
+ def build_asset_definitions(
59
+ spec: dict[str, Any],
60
+ runner: ASTRAContainerRunner | None = None,
61
+ universe_id: str = "baseline",
62
+ project_path: Path | None = None,
63
+ project_name: str | None = None,
64
+ no_build: bool = False,
65
+ container_runtime: str | None = None,
66
+ local_runtime: str | None = None,
67
+ ) -> list[dg.AssetsDefinition | dg.AssetSpec]:
68
+ """Generate one @asset per output with a recipe.
69
+
70
+ Walks the full analysis tree (including sub-analyses) to create assets
71
+ with hierarchical keys like ``[universe, sub_analysis_id, output_id]``.
72
+ """
73
+ # Resolve analysis-level container spec once.
74
+ raw_default = spec.get("container")
75
+ if raw_default is not None and not no_build and (container_runtime or local_runtime):
76
+ _name = project_name or spec.get("name") or "project"
77
+ _path = project_path or Path.cwd()
78
+ default_container = _resolve_container(
79
+ raw_default, _path, _name, container_runtime, local_runtime,
80
+ )
81
+ else:
82
+ default_container = raw_default
83
+
84
+ # Collect external inputs (inputs with filesystem source paths)
85
+ external = get_external_inputs(spec)
86
+ asset_specs = [
87
+ dg.AssetSpec(
88
+ key=dg.AssetKey([universe_id, inp_id]),
89
+ metadata={"source": source, "external": True},
90
+ )
91
+ for inp_id, source in external.items()
92
+ ]
93
+
94
+ assets: list[dg.AssetsDefinition | dg.AssetSpec] = list(asset_specs)
95
+
96
+ # Collect outputs from the full tree (root + sub-analyses)
97
+ tree_outputs = collect_tree_outputs(spec)
98
+
99
+ for tree_out in tree_outputs:
100
+ output_id = tree_out.output_id
101
+ output_def = tree_out.output_def
102
+ recipe = output_def.get("recipe")
103
+
104
+ if not output_id or not recipe:
105
+ # Root-level alias outputs (from: sub.output) become AssetSpecs
106
+ from_ref = output_def.get("from")
107
+ if output_id and from_ref and tree_out.analysis_id is None:
108
+ # Alias: root output referencing a sub-analysis output
109
+ if "." in from_ref:
110
+ sub_id, sub_out = from_ref.split(".", 1)
111
+ assets.append(dg.AssetSpec(
112
+ key=dg.AssetKey([universe_id, output_id]),
113
+ deps=[dg.AssetKey([universe_id, sub_id, sub_out])],
114
+ metadata={"alias_for": from_ref},
115
+ ))
116
+ continue
117
+
118
+ # Determine asset key prefix based on sub-analysis membership
119
+ if tree_out.analysis_id:
120
+ key_prefix = [universe_id, tree_out.analysis_id]
121
+ group_name = tree_out.analysis_id
122
+ else:
123
+ key_prefix = [universe_id]
124
+ group_name = None
125
+
126
+ # Resolve container for this sub-analysis (inheritance chain)
127
+ sub_container = _resolve_sub_container(
128
+ tree_out, spec, default_container, project_path,
129
+ project_name, no_build, container_runtime, local_runtime,
130
+ )
131
+
132
+ # Resolve dependencies: recipe.inputs may reference cross-sub-analysis outputs
133
+ dep_keys = _resolve_recipe_deps(
134
+ recipe, tree_out, spec, universe_id,
135
+ )
136
+
137
+ # Build the external inputs relevant to this sub-analysis
138
+ recipe_input_ids = recipe.get("inputs") or []
139
+ sub_external = {
140
+ k: v for k, v in external.items() if k in recipe_input_ids
141
+ } or None
142
+
143
+ assets.append(
144
+ _build_single_asset(
145
+ output_id, recipe, runner, universe_id, project_path,
146
+ project_name=project_name, default_container=sub_container,
147
+ no_build=no_build, container_runtime=container_runtime,
148
+ local_runtime=local_runtime, external_inputs=sub_external,
149
+ key_prefix=key_prefix, group_name=group_name,
150
+ dep_keys=dep_keys, tree_output=tree_out, spec=spec,
151
+ )
152
+ )
153
+
154
+ return assets
155
+
156
+
157
+ def _resolve_sub_container(
158
+ tree_out: TreeOutput,
159
+ spec: dict[str, Any],
160
+ default_container: str | None,
161
+ project_path: Path | None,
162
+ project_name: str | None,
163
+ no_build: bool,
164
+ container_runtime: str | None,
165
+ local_runtime: str | None,
166
+ ) -> str | None:
167
+ """Resolve container for a tree output with inheritance.
168
+
169
+ Order: recipe-level > sub-analysis-level > root-level (default_container).
170
+ """
171
+ # Check sub-analysis level container
172
+ if tree_out.analysis_id:
173
+ sub_raw = tree_out.analysis_spec.get("container")
174
+ if sub_raw is not None and not no_build and (container_runtime or local_runtime):
175
+ _name = project_name or "project"
176
+ _path = project_path or Path.cwd()
177
+ return _resolve_container(
178
+ sub_raw, _path, _name, container_runtime, local_runtime,
179
+ )
180
+ elif sub_raw is not None:
181
+ return sub_raw
182
+
183
+ return default_container
184
+
185
+
186
+ def _resolve_recipe_deps(
187
+ recipe: dict[str, Any],
188
+ tree_out: TreeOutput,
189
+ spec: dict[str, Any],
190
+ universe_id: str,
191
+ ) -> list[dg.AssetKey] | None:
192
+ """Resolve recipe input dependencies to Dagster asset keys.
193
+
194
+ Handles:
195
+ - Simple IDs within the same analysis scope
196
+ - ``from:`` references on sub-analysis inputs that point to siblings
197
+ """
198
+ input_ids = recipe.get("inputs") or []
199
+ if not input_ids:
200
+ return None
201
+
202
+ deps: list[dg.AssetKey] = []
203
+ analysis_inputs = {
204
+ inp.get("id"): inp
205
+ for inp in get_inputs(tree_out.analysis_spec)
206
+ }
207
+
208
+ for inp_id in input_ids:
209
+ inp_def = analysis_inputs.get(inp_id)
210
+ if inp_def and inp_def.get("from"):
211
+ from_ref = inp_def["from"]
212
+ # ../sibling.output_id -> [universe, sibling, output_id]
213
+ ref = from_ref.removeprefix("../").removeprefix("/")
214
+ if "." in ref:
215
+ sub_id, sub_out = ref.split(".", 1)
216
+ deps.append(dg.AssetKey([universe_id, sub_id, sub_out]))
217
+ continue
218
+
219
+ # Dot-notation cross-analysis reference (e.g. hod_fitting.galaxy_mesh)
220
+ if "." in inp_id:
221
+ sub_id, sub_out = inp_id.split(".", 1)
222
+ deps.append(dg.AssetKey([universe_id, sub_id, sub_out]))
223
+ elif tree_out.analysis_id:
224
+ deps.append(dg.AssetKey([universe_id, tree_out.analysis_id, inp_id]))
225
+ else:
226
+ deps.append(dg.AssetKey([universe_id, inp_id]))
227
+
228
+ return deps
229
+
230
+
231
+ def _load_universe_params(
232
+ project_path: Path | None, universe_id: str
233
+ ) -> dict[str, Any]:
234
+ """Load universe decisions as params dict."""
235
+ if project_path is None:
236
+ return {}
237
+ universe_file = project_path / "universes" / f"{universe_id}.yaml"
238
+ if not universe_file.exists():
239
+ return {}
240
+ universe_data = load_yaml(universe_file)
241
+ return universe_data.get("decisions", {})
242
+
243
+
244
+ def _build_single_asset(
245
+ output_id: str,
246
+ recipe: dict[str, Any],
247
+ runner: ASTRAContainerRunner | None = None,
248
+ universe_id: str = "baseline",
249
+ project_path: Path | None = None,
250
+ project_name: str | None = None,
251
+ default_container: str | None = None,
252
+ no_build: bool = False,
253
+ container_runtime: str | None = None,
254
+ local_runtime: str | None = None,
255
+ external_inputs: dict[str, str] | None = None,
256
+ key_prefix: list[str] | None = None,
257
+ group_name: str | None = None,
258
+ dep_keys: list[dg.AssetKey] | None = None,
259
+ tree_output: TreeOutput | None = None,
260
+ spec: dict[str, Any] | None = None,
261
+ ) -> dg.AssetsDefinition:
262
+ """Build a single Dagster asset from an output recipe."""
263
+ input_ids = recipe.get("inputs") or []
264
+ command = recipe["command"]
265
+ # Filter external inputs to those referenced by this recipe
266
+ recipe_external = {
267
+ k: v for k, v in (external_inputs or {}).items() if k in input_ids
268
+ } or None
269
+ raw_container = recipe.get("container")
270
+ # Resolve per-recipe container spec; fall back to analysis-level default.
271
+ if raw_container is not None and not no_build and (container_runtime or local_runtime):
272
+ _name = project_name or "project"
273
+ _path = project_path or Path.cwd()
274
+ container = _resolve_container(
275
+ raw_container, _path, _name, container_runtime, local_runtime,
276
+ )
277
+ elif raw_container is not None:
278
+ container = raw_container
279
+ else:
280
+ container = default_container
281
+ resources = recipe.get("resources") or {}
282
+
283
+ # Use provided key_prefix or default to [universe_id]
284
+ effective_prefix = key_prefix or [universe_id]
285
+ # Use provided dep_keys or build from input_ids
286
+ effective_deps = dep_keys or [dg.AssetKey([universe_id, i]) for i in input_ids]
287
+
288
+ asset_kwargs: dict[str, Any] = {
289
+ "name": output_id,
290
+ "key_prefix": effective_prefix,
291
+ "deps": effective_deps,
292
+ "metadata": {
293
+ "command": command,
294
+ "container": container or "default",
295
+ },
296
+ }
297
+ if group_name:
298
+ asset_kwargs["group_name"] = group_name
299
+
300
+ @dg.asset(**asset_kwargs)
301
+ def _asset(context) -> dg.MaterializeResult:
302
+ params = _load_universe_params(project_path, universe_id)
303
+
304
+ # Determine working directory for sub-analysis recipes
305
+ cwd_override = None
306
+ if tree_output and tree_output.analysis_path and project_path:
307
+ cwd_override = str(
308
+ (project_path / tree_output.analysis_path).resolve()
309
+ )
310
+
311
+ result = runner.execute(
312
+ command=command,
313
+ container=container,
314
+ inputs=input_ids,
315
+ output_id=output_id,
316
+ universe_id=universe_id,
317
+ resources=resources,
318
+ params=params,
319
+ external_inputs=recipe_external,
320
+ cwd_override=cwd_override,
321
+ )
322
+ if result.metadata.get("stdout"):
323
+ context.log.info(result.metadata["stdout"])
324
+ if result.exit_code != 0:
325
+ stderr = result.metadata.get("stderr", "")
326
+ raise RuntimeError(
327
+ f"Recipe for '{output_id}' failed (exit code {result.exit_code})"
328
+ f"{': ' + stderr if stderr else ''}"
329
+ )
330
+ return dg.MaterializeResult(
331
+ metadata={
332
+ "exit_code": result.exit_code,
333
+ "output_path": str(result.output_path),
334
+ "backend": result.metadata.get("backend", "unknown"),
335
+ }
336
+ )
337
+
338
+ return _asset
339
+
340
+
341
+ def build_definitions(
342
+ project_path: Path,
343
+ target_config: dict[str, Any] | None = None,
344
+ universe_id: str = "baseline",
345
+ no_build: bool = False,
346
+ ) -> dg.Definitions:
347
+ """Build complete Dagster Definitions from an ASTRA project.
348
+
349
+ This is the main entry point for the Dagster integration. When a SLURM
350
+ target is provided, container images are automatically built (podman-hpc)
351
+ or pulled before asset definitions are constructed.
352
+ """
353
+ spec = load_yaml(project_path / "astra.yaml")
354
+ # Resolve sub-analysis tree: expand path: references
355
+ spec = resolve_analysis_tree(spec, project_path)
356
+ project_name = spec.get("name") or project_path.name
357
+
358
+ # Build runner config from target
359
+ runner_config = None
360
+ container_runtime: str | None = None
361
+ local_runtime: str | None = None
362
+ backend = "docker"
363
+
364
+ if target_config:
365
+ backend = target_config.get("backend", "docker")
366
+ container_runtime = target_config.get("container_runtime")
367
+ # Transform flat target_config into the shape the runner expects
368
+ runner_config = {"connection": target_config.get("connection", {})}
369
+ scheduler = {}
370
+ for key in ("site", "account", "qos", "constraint", "node_type",
371
+ "container_runtime", "container_flags",
372
+ "nodes", "time_limit", "extra_slurm_args"):
373
+ if target_config.get(key) is not None:
374
+ scheduler[key] = target_config[key]
375
+ if scheduler:
376
+ runner_config["scheduler"] = scheduler
377
+ else:
378
+ # No target config → local target. Detect available container runtime.
379
+ from lightcone.engine.container import detect_container_runtime
380
+ local_runtime = detect_container_runtime()
381
+ backend = "docker" if local_runtime else "venv"
382
+
383
+ # Resolve analysis-level container spec to a string for the runner.
384
+ # For SLURM targets this triggers podman-hpc build/migrate or
385
+ # podman-hpc migrate automatically. Skipped entirely when no
386
+ # container runtime is available.
387
+ raw_container = spec.get("container")
388
+ if not no_build and (container_runtime or local_runtime):
389
+ default_container = _resolve_container(
390
+ raw_container, project_path, project_name,
391
+ container_runtime, local_runtime,
392
+ )
393
+ else:
394
+ default_container = raw_container
395
+
396
+ # Build runner from target config
397
+ if runner_config:
398
+ runner = ASTRAContainerRunner(
399
+ project_root=str(project_path),
400
+ backend=backend,
401
+ default_container=default_container,
402
+ target_config=runner_config,
403
+ )
404
+ else:
405
+ runner = ASTRAContainerRunner(
406
+ project_root=str(project_path),
407
+ backend=backend,
408
+ default_container=default_container,
409
+ container_runtime=local_runtime,
410
+ )
411
+
412
+ assets = build_asset_definitions(
413
+ spec, runner=runner, universe_id=universe_id, project_path=project_path,
414
+ project_name=project_name, no_build=no_build,
415
+ container_runtime=container_runtime, local_runtime=local_runtime,
416
+ )
417
+
418
+ return dg.Definitions(assets=assets)