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,148 @@
1
+ """Auto-build wheels and collect version metadata for eval runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ import subprocess
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ from lightcone.eval.models import VersionInfo
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def _get_repo_root() -> Path:
17
+ """Find the lightcone-cli repo root via git."""
18
+ result = subprocess.run(
19
+ ["git", "rev-parse", "--show-toplevel"],
20
+ capture_output=True, text=True, check=True,
21
+ )
22
+ return Path(result.stdout.strip())
23
+
24
+
25
+ def _get_git_info(repo_root: Path) -> VersionInfo:
26
+ """Collect git metadata from the repo."""
27
+ info = VersionInfo()
28
+
29
+ try:
30
+ result = subprocess.run(
31
+ ["git", "rev-parse", "HEAD"],
32
+ capture_output=True, text=True, check=True, cwd=repo_root,
33
+ )
34
+ info.lightcone_commit = result.stdout.strip()
35
+ except subprocess.CalledProcessError:
36
+ pass
37
+
38
+ try:
39
+ result = subprocess.run(
40
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
41
+ capture_output=True, text=True, check=True, cwd=repo_root,
42
+ )
43
+ info.lightcone_branch = result.stdout.strip()
44
+ except subprocess.CalledProcessError:
45
+ pass
46
+
47
+ try:
48
+ result = subprocess.run(
49
+ ["git", "diff", "--quiet", "HEAD"],
50
+ capture_output=True, cwd=repo_root,
51
+ )
52
+ info.lightcone_dirty = result.returncode != 0
53
+ except subprocess.CalledProcessError:
54
+ info.lightcone_dirty = True
55
+
56
+ return info
57
+
58
+
59
+ def _build_wheel(repo_root: Path, outdir: Path) -> Path:
60
+ """Build a wheel from the repo and return the wheel path."""
61
+ result = subprocess.run(
62
+ ["python", "-m", "build", "--wheel", "--outdir", str(outdir)],
63
+ capture_output=True, text=True, cwd=repo_root,
64
+ )
65
+ if result.returncode != 0:
66
+ raise RuntimeError(f"Wheel build failed:\n{result.stderr}")
67
+
68
+ wheels = list(outdir.glob("lightcone_cli-*.whl"))
69
+ if not wheels:
70
+ raise RuntimeError(f"No lightcone_cli wheel found in {outdir} after build")
71
+ return wheels[0]
72
+
73
+
74
+ def _extract_version(wheel_path: Path) -> str:
75
+ """Extract version string from a wheel filename."""
76
+ # Wheel filenames: {name}-{version}-{tags}.whl
77
+ match = re.match(r"[^-]+-([^-]+)-", wheel_path.name)
78
+ return match.group(1) if match else wheel_path.name
79
+
80
+
81
+ def _build_astra_wheel(outdir: Path) -> Path:
82
+ """Build an ASTRA wheel from the version installed in the current environment.
83
+
84
+ Uses importlib.metadata to find the git URL and commit of the installed astra
85
+ package, then builds a wheel pinned to that exact commit via pip wheel.
86
+ """
87
+ import importlib.metadata
88
+ import json
89
+
90
+ dist = importlib.metadata.distribution("astra")
91
+ url_text = dist.read_text("direct_url.json")
92
+ if not url_text:
93
+ raise RuntimeError(
94
+ "Cannot determine ASTRA source — installed package has no direct_url.json. "
95
+ "Ensure astra is installed from its git repo (not a plain wheel)."
96
+ )
97
+ url_info = json.loads(url_text)
98
+ git_url = url_info["url"]
99
+ commit = url_info.get("vcs_info", {}).get("commit_id", "")
100
+ spec = f"astra @ git+{git_url}@{commit}" if commit else f"astra @ git+{git_url}"
101
+
102
+ logger.info("Building ASTRA wheel from %s (commit %s) ...", git_url, commit[:8] or "HEAD")
103
+ result = subprocess.run(
104
+ ["pip", "wheel", "--no-deps", "--wheel-dir", str(outdir), spec],
105
+ capture_output=True, text=True,
106
+ )
107
+ if result.returncode != 0:
108
+ raise RuntimeError(f"ASTRA wheel build failed:\n{result.stderr}")
109
+
110
+ wheels = list(outdir.glob("astra-*.whl"))
111
+ if not wheels:
112
+ raise RuntimeError(f"No astra wheel found in {outdir} after build")
113
+ return wheels[0]
114
+
115
+
116
+ def build_eval_wheels(evals_dir: Path) -> tuple[VersionInfo, list[Path]]:
117
+ """Build the lightcone-cli and ASTRA wheels for sandbox injection.
118
+
119
+ Returns (version_info, [wheel_paths]).
120
+ Both wheels are built fresh from the current environment:
121
+ - lightcone-cli: built from the local git working tree
122
+ - ASTRA: built from the git URL/commit recorded in the installed package metadata
123
+ """
124
+ repo_root = _get_repo_root()
125
+ version_info = _get_git_info(repo_root)
126
+
127
+ # Build lightcone-cli wheel into a temp dir
128
+ tmpdir = Path(tempfile.mkdtemp(prefix="lightcone-eval-wheels-"))
129
+ logger.info("Building lightcone-cli wheel from %s ...", repo_root)
130
+ lightcone_wheel = _build_wheel(repo_root, tmpdir)
131
+ version_info.lightcone_version = _extract_version(lightcone_wheel)
132
+ logger.info("Built %s (commit %s%s)",
133
+ lightcone_wheel.name,
134
+ version_info.lightcone_commit[:8],
135
+ " dirty" if version_info.lightcone_dirty else "")
136
+
137
+ wheels: list[Path] = [lightcone_wheel]
138
+
139
+ # Build ASTRA wheel from the installed package's git source
140
+ try:
141
+ astra_wheel = _build_astra_wheel(tmpdir)
142
+ version_info.astra_version = _extract_version(astra_wheel)
143
+ wheels.append(astra_wheel)
144
+ logger.info("Built %s", astra_wheel.name)
145
+ except Exception as exc:
146
+ logger.warning("Failed to build ASTRA wheel: %s — sandbox may not have astra CLI", exc)
147
+
148
+ return version_info, wheels
lightcone/eval/cli.py ADDED
@@ -0,0 +1,176 @@
1
+ """CLI commands for the eval harness: lc eval {run, report, compare}."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+ from rich.console import Console
9
+
10
+ console = Console()
11
+
12
+
13
+ @click.group()
14
+ def eval_group() -> None:
15
+ """Evaluate the lightcone-cli build loop against seed tasks."""
16
+ import logging
17
+
18
+ from dotenv import load_dotenv
19
+
20
+ load_dotenv()
21
+
22
+ # Configure logging so sandbox build logs and harness progress are visible
23
+ logging.basicConfig(
24
+ level=logging.INFO,
25
+ format="%(asctime)s %(name)s %(message)s",
26
+ datefmt="%H:%M:%S",
27
+ )
28
+
29
+
30
+ @eval_group.command("run")
31
+ @click.argument("config_path", type=click.Path(exists=True, path_type=Path))
32
+ @click.option("--concurrency", "-c", type=int, default=None, help="Max parallel sandboxes")
33
+ @click.option("--num-trials", "-n", type=int, default=None, help="Override number of trials")
34
+ @click.option("--dry-run", is_flag=True, help="Print trial schedule without running")
35
+ @click.option(
36
+ "--evals-dir",
37
+ type=click.Path(exists=True, path_type=Path),
38
+ default=None,
39
+ help="Path to evals/ directory (default: evals/ in current dir)",
40
+ )
41
+ def run_cmd(
42
+ config_path: Path,
43
+ concurrency: int | None,
44
+ num_trials: int | None,
45
+ dry_run: bool,
46
+ evals_dir: Path | None,
47
+ ) -> None:
48
+ """Run an eval suite from a config file.
49
+
50
+ Examples:
51
+ lc eval run evals/example-run.yaml
52
+ lc eval run evals/example-run.yaml --dry-run
53
+ lc eval run evals/example-run.yaml --num-trials 1 --concurrency 2
54
+ """
55
+ from lightcone.eval.harness import load_run_config, run_eval
56
+ from lightcone.eval.report import compute_summary, print_comparison_table, save_results
57
+
58
+ config = load_run_config(config_path)
59
+
60
+ # Apply CLI overrides
61
+ if concurrency is not None:
62
+ config.max_concurrency = concurrency
63
+ if num_trials is not None:
64
+ config.num_trials = num_trials
65
+
66
+ if evals_dir is None:
67
+ evals_dir = Path.cwd() / "evals"
68
+
69
+ if not evals_dir.exists():
70
+ console.print(f"[red]Error:[/red] Evals directory not found: {evals_dir}")
71
+ raise SystemExit(1)
72
+
73
+ if dry_run:
74
+ console.print("[bold]Dry run — trial schedule:[/bold]\n")
75
+
76
+ def _on_trial_complete(trial: object) -> None:
77
+ from lightcone.eval.models import TrialResult
78
+
79
+ assert isinstance(trial, TrialResult)
80
+ if trial.build_complete:
81
+ status = "[green]complete[/green]"
82
+ else:
83
+ status = "[yellow]incomplete[/yellow]"
84
+ if trial.error:
85
+ status = f"[red]error: {trial.error[:60]}[/red]"
86
+ console.print(
87
+ f" {trial.task_id} "
88
+ f"trial {trial.trial_number}: "
89
+ f"score={trial.composite_score:.2f} {status}"
90
+ )
91
+
92
+ eval_run = run_eval(
93
+ config,
94
+ evals_dir,
95
+ progress_callback=_on_trial_complete,
96
+ dry_run=dry_run,
97
+ )
98
+
99
+ if dry_run:
100
+ schedule = eval_run.summary.get("schedule", [])
101
+ for s in schedule:
102
+ console.print(f" {s['task']} trial {s['trial']}")
103
+ console.print(f"\n[bold]Total: {eval_run.summary.get('total_trials', 0)} trials[/bold]")
104
+ return
105
+
106
+ # Display version info
107
+ v = eval_run.version
108
+ dirty = " [yellow](dirty)[/yellow]" if v.lightcone_dirty else ""
109
+ console.print(
110
+ f"\n[bold]lightcone-cli:[/bold] {v.lightcone_version} "
111
+ f"({v.lightcone_branch} {v.lightcone_commit[:8]}){dirty}"
112
+ )
113
+ if v.astra_version:
114
+ console.print(f"[bold]ASTRA:[/bold] {v.astra_version}")
115
+
116
+ # Compute summary and display
117
+ eval_run.summary = compute_summary(eval_run)
118
+ console.print()
119
+ print_comparison_table(eval_run, console=console)
120
+
121
+ # Save results
122
+ output_path = save_results(eval_run, config.output_dir)
123
+ console.print(f"\n[bold]Results saved to:[/bold] {output_path}")
124
+
125
+
126
+ @eval_group.command("report")
127
+ @click.argument("results_path", type=click.Path(exists=True, path_type=Path))
128
+ @click.option("--json", "as_json", is_flag=True, help="Output raw JSON summary")
129
+ def report_cmd(results_path: Path, as_json: bool) -> None:
130
+ """Display results from a previous eval run.
131
+
132
+ Examples:
133
+ lc eval report eval-results/my-run-20260315.json
134
+ lc eval report eval-results/my-run-20260315.json --json
135
+ """
136
+ import json
137
+
138
+ from lightcone.eval.report import compute_summary, load_results, print_comparison_table
139
+
140
+ eval_run = load_results(results_path)
141
+
142
+ if not eval_run.summary:
143
+ eval_run.summary = compute_summary(eval_run)
144
+
145
+ if as_json:
146
+ console.print(json.dumps(eval_run.summary, indent=2, default=str))
147
+ else:
148
+ print_comparison_table(eval_run, console=console)
149
+
150
+
151
+ @eval_group.command("compare")
152
+ @click.argument("results1", type=click.Path(exists=True, path_type=Path))
153
+ @click.argument("results2", type=click.Path(exists=True, path_type=Path))
154
+ def compare_cmd(results1: Path, results2: Path) -> None:
155
+ """Compare two eval runs side by side.
156
+
157
+ Examples:
158
+ lc eval compare eval-results/run1.json eval-results/run2.json
159
+ """
160
+ from lightcone.eval.report import (
161
+ compute_summary,
162
+ load_results,
163
+ print_comparison_between,
164
+ )
165
+
166
+ run1 = load_results(results1)
167
+ run2 = load_results(results2)
168
+
169
+ if not run1.summary:
170
+ run1.summary = compute_summary(run1)
171
+ if not run2.summary:
172
+ run2.summary = compute_summary(run2)
173
+
174
+ print_comparison_between(run1, run2, console=console)
175
+
176
+
@@ -0,0 +1,192 @@
1
+ """Grader dispatch for eval trials."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from lightcone.eval.models import GraderResult, GraderSpec, GraderType
10
+
11
+ if TYPE_CHECKING:
12
+ from lightcone.eval.sandbox import EvalSandbox
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def _error_result(grader: GraderSpec, error: str, **kwargs: object) -> GraderResult:
18
+ """Create a failed GraderResult with score 0."""
19
+ return GraderResult(
20
+ name=grader.name,
21
+ type=grader.type,
22
+ passed=False,
23
+ score=0.0,
24
+ weight=grader.weight,
25
+ error=error,
26
+ **kwargs, # type: ignore[arg-type]
27
+ )
28
+
29
+
30
+ def run_graders(
31
+ sandbox: EvalSandbox,
32
+ graders: list[GraderSpec],
33
+ evals_dir: Path,
34
+ task_id: str,
35
+ ) -> list[GraderResult]:
36
+ """Run all graders for a trial and return results."""
37
+ results: list[GraderResult] = []
38
+ for grader in graders:
39
+ try:
40
+ result = _run_single_grader(sandbox, grader, evals_dir, task_id)
41
+ except Exception as exc:
42
+ result = _error_result(grader, str(exc))
43
+ results.append(result)
44
+ return results
45
+
46
+
47
+ def _run_single_grader(
48
+ sandbox: EvalSandbox,
49
+ grader: GraderSpec,
50
+ evals_dir: Path,
51
+ task_id: str,
52
+ ) -> GraderResult:
53
+ """Run a single grader and return its result."""
54
+ if grader.type == GraderType.command:
55
+ return _grade_command(sandbox, grader)
56
+ elif grader.type == GraderType.status:
57
+ return _grade_status(sandbox, grader)
58
+ elif grader.type == GraderType.script:
59
+ return _grade_script(sandbox, grader, evals_dir, task_id)
60
+ else:
61
+ return _error_result(grader, f"Unknown grader type: {grader.type}")
62
+
63
+
64
+ def _grade_command(sandbox: EvalSandbox, grader: GraderSpec) -> GraderResult:
65
+ """Run a shell command; exit 0 = pass."""
66
+ if grader.command is None:
67
+ raise ValueError("command grader requires 'command' field")
68
+
69
+ cmd = f"cd {sandbox.WORK_DIR} && {grader.command}"
70
+ result = sandbox.exec(cmd, timeout=grader.timeout)
71
+ passed = result.exit_code == 0
72
+ return GraderResult(
73
+ name=grader.name,
74
+ type=grader.type,
75
+ passed=passed,
76
+ score=1.0 if passed else 0.0,
77
+ weight=grader.weight,
78
+ details=result.output[:2000] if result.output else "",
79
+ )
80
+
81
+
82
+ def _grade_status(sandbox: EvalSandbox, grader: GraderSpec) -> GraderResult:
83
+ """Check output materialization status via lc status.
84
+
85
+ Parses the text table output, counting 'ok' vs 'pending'/'no_recipe' entries.
86
+ """
87
+ cmd = f"cd {sandbox.WORK_DIR} && lc status"
88
+ result = sandbox.exec(cmd, timeout=grader.timeout)
89
+
90
+ if result.exit_code != 0:
91
+ return _error_result(
92
+ grader,
93
+ f"lc status exited with code {result.exit_code}",
94
+ details=result.output[:2000],
95
+ )
96
+
97
+ # Parse the Rich table output for status values
98
+ materialized = 0
99
+ total = 0
100
+ for line in result.output.splitlines():
101
+ # Table rows start with │ and contain status words
102
+ if "│" not in line:
103
+ continue
104
+ cells = [c.strip() for c in line.split("│") if c.strip()]
105
+ if len(cells) < 2:
106
+ continue
107
+ # Skip header row
108
+ if cells[0].lower() in ("output", ""):
109
+ continue
110
+ # Each non-header cell after the first is a status value
111
+ for cell in cells[1:]:
112
+ if cell in ("ok", "pending", "no_recipe"):
113
+ total += 1
114
+ if cell == "ok":
115
+ materialized += 1
116
+
117
+ if total == 0:
118
+ return GraderResult(
119
+ name=grader.name,
120
+ type=grader.type,
121
+ passed=False,
122
+ score=0.0,
123
+ weight=grader.weight,
124
+ details="No outputs found in lc status output",
125
+ )
126
+
127
+ score = materialized / total
128
+ return GraderResult(
129
+ name=grader.name,
130
+ type=grader.type,
131
+ passed=score == 1.0,
132
+ score=score,
133
+ weight=grader.weight,
134
+ details=f"{materialized}/{total} outputs materialized",
135
+ )
136
+
137
+
138
+
139
+ def _grade_script(
140
+ sandbox: EvalSandbox,
141
+ grader: GraderSpec,
142
+ evals_dir: Path,
143
+ task_id: str,
144
+ ) -> GraderResult:
145
+ """Upload and run a custom grading script; exit 0 = pass, last stdout line as score."""
146
+ if grader.script is None:
147
+ raise ValueError("script grader requires 'script' field")
148
+
149
+ # Look for script in task graders dir
150
+ script_path = evals_dir / "tasks" / task_id / "graders" / grader.script
151
+ if not script_path.exists():
152
+ return _error_result(grader, f"Grader script not found: {script_path}")
153
+
154
+ remote_script = f"/tmp/grader_{grader.name}.py"
155
+ sandbox.upload_file(remote_script, script_path.read_bytes())
156
+
157
+ result = sandbox.exec(
158
+ f"cd {sandbox.WORK_DIR} && python {remote_script}",
159
+ timeout=grader.timeout,
160
+ )
161
+
162
+ passed = result.exit_code == 0
163
+ score = 1.0 if passed else 0.0
164
+
165
+ # Try to parse last stdout line as a float score
166
+ if result.output:
167
+ lines = result.output.strip().splitlines()
168
+ if lines:
169
+ try:
170
+ parsed = float(lines[-1].strip())
171
+ if 0.0 <= parsed <= 1.0:
172
+ score = parsed
173
+ passed = score > 0.0
174
+ except ValueError:
175
+ pass
176
+
177
+ return GraderResult(
178
+ name=grader.name,
179
+ type=grader.type,
180
+ passed=passed,
181
+ score=score,
182
+ weight=grader.weight,
183
+ details=result.output[:2000] if result.output else "",
184
+ )
185
+
186
+
187
+ def compute_composite_score(results: list[GraderResult]) -> float:
188
+ """Compute weighted mean of grader scores."""
189
+ total_weight = sum(r.weight for r in results)
190
+ if total_weight == 0:
191
+ return 0.0
192
+ return sum(r.score * r.weight for r in results) / total_weight