flexlock 0.8.2__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.
flexlock/cli.py ADDED
@@ -0,0 +1,953 @@
1
+ """Unified FlexLock CLI: ls, tag, gc subcommands."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ import yaml
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from git.repo import Repo as GitRepo
10
+
11
+ from .git_utils import sanitize_ref_name
12
+
13
+
14
+ def find_git_repo(start_path="."):
15
+ """Find the git repository from the given path, falling back to CWD."""
16
+ try:
17
+ return GitRepo(start_path, search_parent_directories=True)
18
+ except Exception:
19
+ pass
20
+ # Run dirs are often outside the project git tree (different mount, symlink, etc.)
21
+ # Fall back to searching from CWD so commands work from the project root.
22
+ if str(Path(start_path).resolve()) != str(Path(".").resolve()):
23
+ try:
24
+ return GitRepo(".", search_parent_directories=True)
25
+ except Exception:
26
+ pass
27
+ return None
28
+
29
+
30
+ def find_results_dirs(root="."):
31
+ """Find all directories containing run.lock files."""
32
+ results = []
33
+ for lock_file in Path(root).rglob("run.lock"):
34
+ run_dir = lock_file.parent
35
+ try:
36
+ with open(lock_file) as f:
37
+ data = yaml.safe_load(f)
38
+ results.append({
39
+ "path": str(run_dir),
40
+ "timestamp": data.get("timestamp", ""),
41
+ "config": data.get("config", {}),
42
+ "repos": data.get("repos", {}),
43
+ "lineage": data.get("lineage") or data.get("prevs", {}),
44
+ })
45
+ except Exception:
46
+ results.append({
47
+ "path": str(run_dir),
48
+ "timestamp": "",
49
+ "config": {},
50
+ "repos": {},
51
+ "lineage": {},
52
+ })
53
+ # Sort by timestamp descending
54
+ results.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
55
+ return results
56
+
57
+
58
+ def get_flexlock_tags(repo):
59
+ """Get all flexlock tags from git refs."""
60
+ tags = {}
61
+ prefix = "refs/flexlock/tags/"
62
+ try:
63
+ refs = repo.git.for_each_ref(
64
+ "--format=%(refname) %(objectname)",
65
+ prefix,
66
+ )
67
+ for line in refs.strip().splitlines():
68
+ if not line.strip():
69
+ continue
70
+ parts = line.split()
71
+ ref = parts[0]
72
+ commit = parts[1]
73
+ tag_name = ref[len(prefix):]
74
+ tags[tag_name] = commit
75
+ except Exception:
76
+ pass
77
+ return tags
78
+
79
+
80
+ def get_tag_details(repo, tag_commit_hash):
81
+ """Get details about a tag commit, including linked lineage shadow commits."""
82
+ details = {"parents": [], "message": "", "timestamp": ""}
83
+ try:
84
+ # Get commit message
85
+ details["message"] = repo.git.log(
86
+ "-1", "--format=%B", tag_commit_hash
87
+ ).strip()
88
+ # Get timestamp
89
+ details["timestamp"] = repo.git.log(
90
+ "-1", "--format=%aI", tag_commit_hash
91
+ ).strip()
92
+ # Get parent commits (lineage shadow commits)
93
+ parent_line = repo.git.log(
94
+ "-1", "--format=%P", tag_commit_hash
95
+ ).strip()
96
+ if parent_line:
97
+ details["parents"] = parent_line.split()
98
+ except Exception:
99
+ pass
100
+ return details
101
+
102
+
103
+ def get_shadow_ref_for_path(repo, run_path):
104
+ """Find the shadow commit ref matching a run path."""
105
+ prefix = "refs/flexlock/runs/"
106
+ try:
107
+ refs = repo.git.for_each_ref(
108
+ "--format=%(refname) %(objectname)",
109
+ prefix,
110
+ )
111
+ sanitized = sanitize_ref_name(str(run_path))
112
+ for line in refs.strip().splitlines():
113
+ if not line.strip():
114
+ continue
115
+ ref = line.split()[0]
116
+ ref_suffix = ref[len(prefix):]
117
+ # Match if ref contains the run path
118
+ if sanitized in ref_suffix or ref_suffix in sanitized:
119
+ return line.split()[1]
120
+ except Exception:
121
+ pass
122
+ return None
123
+
124
+
125
+ def collect_lineage_refs(repo, run_dir):
126
+ """Collect shadow commit hashes for a run and all its lineage."""
127
+ refs = []
128
+
129
+ # Get shadow ref for this run
130
+ shadow = get_shadow_ref_for_path(repo, run_dir)
131
+ if shadow:
132
+ refs.append(shadow)
133
+
134
+ # Load run.lock to find lineage
135
+ lock_file = Path(run_dir) / "run.lock"
136
+ if lock_file.exists():
137
+ try:
138
+ with open(lock_file) as f:
139
+ data = yaml.safe_load(f)
140
+
141
+ # Get shadow refs from repos recorded in run.lock
142
+ repos_data = data.get("repos", {})
143
+ for repo_info in repos_data.values():
144
+ commit = repo_info.get("commit")
145
+ if commit and commit not in refs:
146
+ refs.append(commit)
147
+
148
+ # Recurse into lineage
149
+ lineage = data.get("lineage") or data.get("prevs", {})
150
+ for nested_data in lineage.values():
151
+ nested_path = nested_data.get("path") or nested_data.get("config", {}).get("save_dir")
152
+ if nested_path:
153
+ nested_refs = collect_lineage_refs(repo, nested_path)
154
+ for r in nested_refs:
155
+ if r not in refs:
156
+ refs.append(r)
157
+ except Exception:
158
+ pass
159
+
160
+ return refs
161
+
162
+
163
+ # ── ls subcommand ──────────────────────────────────────────────
164
+
165
+ def cmd_ls(args):
166
+ """List runs in results directories."""
167
+ search_root = args.path or "."
168
+ runs = find_results_dirs(search_root)
169
+
170
+ if not runs:
171
+ print(f"No runs found under {search_root}")
172
+ return
173
+
174
+ # Get tags for annotation
175
+ repo = find_git_repo(search_root)
176
+ tags = {}
177
+ tag_to_path = {}
178
+ if repo:
179
+ all_tags = get_flexlock_tags(repo)
180
+ # Build reverse mapping: try to match tags to run paths
181
+ for tag_name, tag_commit in all_tags.items():
182
+ details = get_tag_details(repo, tag_commit)
183
+ # Parse tag message for path info
184
+ for line in details["message"].splitlines():
185
+ if line.startswith("Path: "):
186
+ tagged_path = line[6:].strip()
187
+ tags[tagged_path] = tag_name
188
+ tag_to_path[tag_name] = tagged_path
189
+
190
+ # Display
191
+ if args.format == "json":
192
+ print(json.dumps(runs, indent=2, default=str))
193
+ return
194
+
195
+ for i, run in enumerate(runs):
196
+ path = run["path"]
197
+ ts = run["timestamp"]
198
+ tag_label = ""
199
+ if path in tags:
200
+ tag_label = f" [{tags[path]}]"
201
+
202
+ # Format timestamp nicely
203
+ try:
204
+ dt = datetime.fromisoformat(ts)
205
+ ts_fmt = dt.strftime("%Y-%m-%d %H:%M")
206
+ except (ValueError, TypeError):
207
+ ts_fmt = ts[:16] if ts else "unknown"
208
+
209
+ # Stage name from path
210
+ stage = Path(path).name
211
+
212
+ print(f" {ts_fmt} {stage:20s} {path}{tag_label}")
213
+
214
+ if args.verbose:
215
+ cfg = run.get("config", {})
216
+ if "_target_" in cfg:
217
+ print(f" target: {cfg['_target_']}")
218
+ lineage = run.get("lineage", {})
219
+ if lineage:
220
+ print(f" lineage: {', '.join(lineage.keys())}")
221
+
222
+
223
+ # ── tag subcommand ─────────────────────────────────────────────
224
+
225
+ def cmd_tag(args):
226
+ """Tag a run directory with a human-readable name."""
227
+ if args.list:
228
+ _tag_list(args)
229
+ return
230
+
231
+ if args.delete:
232
+ _tag_delete(args)
233
+ return
234
+
235
+ if not args.name or not args.path:
236
+ print("Usage: flexlock tag <name> <path>", file=sys.stderr)
237
+ sys.exit(1)
238
+
239
+ run_dir = Path(args.path).resolve()
240
+ lock_file = run_dir / "run.lock"
241
+ if not lock_file.exists():
242
+ print(f"Error: No run.lock found at {run_dir}", file=sys.stderr)
243
+ sys.exit(1)
244
+
245
+ repo = find_git_repo(str(run_dir))
246
+ if not repo:
247
+ print("Error: Not in a git repository", file=sys.stderr)
248
+ sys.exit(1)
249
+
250
+ tag_name = sanitize_ref_name(args.name)
251
+ ref = f"refs/flexlock/tags/{tag_name}"
252
+
253
+ # Collect all lineage shadow commits to link as parents
254
+ lineage_refs = collect_lineage_refs(repo, str(run_dir))
255
+
256
+ # Build parent args for commit-tree
257
+ parent_args = []
258
+ for parent_hash in lineage_refs:
259
+ parent_args.extend(["-p", parent_hash])
260
+
261
+ # If no lineage refs found, use HEAD as parent to keep the commit reachable
262
+ if not parent_args:
263
+ parent_args = ["-p", repo.head.commit.hexsha]
264
+
265
+ # Create tag commit with metadata in message
266
+ msg = (
267
+ f"FlexLock Tag: {args.name}\n"
268
+ f"Path: {run_dir}\n"
269
+ f"Tagged: {datetime.now().isoformat()}\n"
270
+ )
271
+ if args.message:
272
+ msg += f"\n{args.message}\n"
273
+
274
+ # Use the tree from the first parent (or HEAD)
275
+ tree_source = lineage_refs[0] if lineage_refs else repo.head.commit.hexsha
276
+ try:
277
+ tree_hash = repo.git.rev_parse(f"{tree_source}^{{tree}}")
278
+ except Exception:
279
+ tree_hash = repo.head.commit.tree.hexsha
280
+
281
+ tag_commit = repo.git.commit_tree(tree_hash, *parent_args, "-m", msg)
282
+ repo.git.update_ref(ref, tag_commit)
283
+
284
+ n_parents = len(lineage_refs)
285
+ print(f"Tagged '{args.name}' -> {run_dir}")
286
+ print(f" ref: {ref}")
287
+ print(f" linked {n_parents} lineage commit(s)")
288
+
289
+
290
+ def _tag_list(args):
291
+ """List all flexlock tags."""
292
+ repo = find_git_repo(".")
293
+ if not repo:
294
+ print("Error: Not in a git repository", file=sys.stderr)
295
+ sys.exit(1)
296
+
297
+ tags = get_flexlock_tags(repo)
298
+ if not tags:
299
+ print("No tags found.")
300
+ return
301
+
302
+ for tag_name, tag_commit in sorted(tags.items()):
303
+ details = get_tag_details(repo, tag_commit)
304
+ ts = details["timestamp"]
305
+ try:
306
+ dt = datetime.fromisoformat(ts)
307
+ ts_fmt = dt.strftime("%Y-%m-%d %H:%M")
308
+ except (ValueError, TypeError):
309
+ ts_fmt = "unknown"
310
+
311
+ path_str = ""
312
+ for line in details["message"].splitlines():
313
+ if line.startswith("Path: "):
314
+ path_str = line[6:].strip()
315
+
316
+ n_parents = len(details["parents"])
317
+ print(f" {tag_name:20s} {ts_fmt} ({n_parents} commits) {path_str}")
318
+
319
+ if args.verbose:
320
+ for parent in details["parents"]:
321
+ try:
322
+ parent_msg = repo.git.log("-1", "--format=%s", parent).strip()
323
+ print(f" {parent[:10]} {parent_msg}")
324
+ except Exception:
325
+ print(f" {parent[:10]}")
326
+
327
+
328
+ def _tag_delete(args):
329
+ """Delete a flexlock tag."""
330
+ repo = find_git_repo(".")
331
+ if not repo:
332
+ print("Error: Not in a git repository", file=sys.stderr)
333
+ sys.exit(1)
334
+
335
+ tag_name = sanitize_ref_name(args.delete)
336
+ ref = f"refs/flexlock/tags/{tag_name}"
337
+
338
+ try:
339
+ repo.git.update_ref("-d", ref)
340
+ print(f"Deleted tag '{args.delete}'")
341
+ except Exception as e:
342
+ print(f"Error: Could not delete tag '{args.delete}': {e}", file=sys.stderr)
343
+ sys.exit(1)
344
+
345
+
346
+ # ── gc subcommand ──────────────────────────────────────────────
347
+
348
+ def cmd_gc(args):
349
+ """Garbage collect untagged run directories and orphaned shadow refs."""
350
+ search_root = args.path or "."
351
+ runs = find_results_dirs(search_root)
352
+
353
+ if not runs:
354
+ print(f"No runs found under {search_root}")
355
+ return
356
+
357
+ # --incomplete: prune only run dirs that have run.lock but no run.complete.
358
+ # These are previous attempts interrupted before the user function returned;
359
+ # no tag protection logic is needed (they were never completed).
360
+ if getattr(args, "incomplete", False):
361
+ incomplete = [
362
+ r for r in runs
363
+ if not (Path(r["path"]) / "run.complete").exists()
364
+ ]
365
+ if not incomplete:
366
+ print("No incomplete runs found.")
367
+ return
368
+ print(f"Found {len(incomplete)} incomplete run(s) (run.lock without run.complete):")
369
+ for run in incomplete:
370
+ print(f" {run['path']}")
371
+ if args.dry_run:
372
+ print("\n(dry run — no files deleted)")
373
+ return
374
+ if not args.force:
375
+ answer = input(f"\nDelete {len(incomplete)} incomplete run directories? [y/N] ")
376
+ if answer.lower() not in ("y", "yes"):
377
+ print("Aborted.")
378
+ return
379
+ import shutil
380
+ deleted = 0
381
+ for run in incomplete:
382
+ try:
383
+ shutil.rmtree(run["path"])
384
+ deleted += 1
385
+ except Exception as e:
386
+ print(f" Error deleting {run['path']}: {e}", file=sys.stderr)
387
+ print(f"Deleted {deleted} incomplete run directories.")
388
+ return
389
+
390
+ repo = find_git_repo(search_root)
391
+
392
+ # Collect tagged paths
393
+ tagged_paths = set()
394
+ if repo:
395
+ all_tags = get_flexlock_tags(repo)
396
+ for tag_name, tag_commit in all_tags.items():
397
+ details = get_tag_details(repo, tag_commit)
398
+ for line in details["message"].splitlines():
399
+ if line.startswith("Path: "):
400
+ tagged_paths.add(line[6:].strip())
401
+
402
+ # Also collect paths that are lineage dependencies of tagged runs
403
+ protected_paths = set(tagged_paths)
404
+ for tagged_path in tagged_paths:
405
+ _collect_lineage_paths(tagged_path, protected_paths)
406
+
407
+ # Identify unprotected runs
408
+ to_remove = []
409
+ to_keep = []
410
+ for run in runs:
411
+ resolved = str(Path(run["path"]).resolve())
412
+ if resolved in protected_paths or run["path"] in protected_paths:
413
+ to_keep.append(run)
414
+ else:
415
+ to_remove.append(run)
416
+
417
+ if not to_remove:
418
+ print("Nothing to clean up — all runs are tagged or are lineage dependencies.")
419
+ return
420
+
421
+ print(f"Found {len(to_remove)} untagged run(s) to remove:")
422
+ for run in to_remove:
423
+ ts = run.get("timestamp", "unknown")
424
+ try:
425
+ dt = datetime.fromisoformat(ts)
426
+ ts_fmt = dt.strftime("%Y-%m-%d %H:%M")
427
+ except (ValueError, TypeError):
428
+ ts_fmt = ts[:16] if ts else "unknown"
429
+ print(f" {ts_fmt} {run['path']}")
430
+
431
+ print(f"\nKeeping {len(to_keep)} tagged/protected run(s).")
432
+
433
+ if args.dry_run:
434
+ print("\n(dry run — no files deleted)")
435
+ return
436
+
437
+ # Confirm
438
+ if not args.force:
439
+ answer = input(f"\nDelete {len(to_remove)} run directories? [y/N] ")
440
+ if answer.lower() not in ("y", "yes"):
441
+ print("Aborted.")
442
+ return
443
+
444
+ # Delete
445
+ import shutil
446
+ deleted = 0
447
+ for run in to_remove:
448
+ try:
449
+ shutil.rmtree(run["path"])
450
+ deleted += 1
451
+ except Exception as e:
452
+ print(f" Error deleting {run['path']}: {e}", file=sys.stderr)
453
+
454
+ print(f"Deleted {deleted} run directories.")
455
+
456
+ # Clean orphaned shadow refs
457
+ if repo and args.refs:
458
+ _gc_shadow_refs(repo, protected_paths)
459
+
460
+
461
+ def _collect_lineage_paths(run_path, protected):
462
+ """Recursively collect all lineage paths as protected."""
463
+ lock_file = Path(run_path) / "run.lock"
464
+ if not lock_file.exists():
465
+ return
466
+
467
+ try:
468
+ with open(lock_file) as f:
469
+ data = yaml.safe_load(f)
470
+ lineage = data.get("lineage") or data.get("prevs", {})
471
+ for nested_data in lineage.values():
472
+ nested_path = nested_data.get("path") or nested_data.get("config", {}).get("save_dir")
473
+ if nested_path:
474
+ resolved = str(Path(nested_path).resolve())
475
+ if resolved not in protected:
476
+ protected.add(resolved)
477
+ protected.add(nested_path)
478
+ _collect_lineage_paths(nested_path, protected)
479
+ except Exception:
480
+ pass
481
+
482
+
483
+ def _gc_shadow_refs(repo, protected_paths):
484
+ """Remove shadow refs that don't belong to any tagged run."""
485
+ prefix = "refs/flexlock/runs/"
486
+ try:
487
+ refs = repo.git.for_each_ref(
488
+ "--format=%(refname)",
489
+ prefix,
490
+ )
491
+ except Exception:
492
+ return
493
+
494
+ # Collect shadow refs that are parents of tag commits
495
+ protected_commits = set()
496
+ all_tags = get_flexlock_tags(repo)
497
+ for tag_commit in all_tags.values():
498
+ details = get_tag_details(repo, tag_commit)
499
+ protected_commits.update(details["parents"])
500
+
501
+ removed = 0
502
+ for ref in refs.strip().splitlines():
503
+ if not ref.strip():
504
+ continue
505
+ try:
506
+ commit = repo.git.rev_parse(ref.strip())
507
+ if commit not in protected_commits:
508
+ repo.git.update_ref("-d", ref.strip())
509
+ removed += 1
510
+ except Exception:
511
+ pass
512
+
513
+ if removed:
514
+ print(f"Cleaned {removed} orphaned shadow ref(s).")
515
+
516
+
517
+ # ── Main entry point ───────────────────────────────────────────
518
+
519
+ def cmd_migrate_cache_markers(args):
520
+ """Backfill run.complete for pre-existing dirs that look complete.
521
+
522
+ The old heuristic (lock + any non-lock file present) is applied once
523
+ to existing run dirs so they remain cache-hits after the upgrade to
524
+ explicit completion markers.
525
+ """
526
+ from .snapshot import write_complete_marker
527
+
528
+ search_root = args.path or "."
529
+ runs = find_results_dirs(search_root)
530
+ if not runs:
531
+ print(f"No runs found under {search_root}")
532
+ return
533
+
534
+ candidates = []
535
+ for run in runs:
536
+ p = Path(run["path"])
537
+ if (p / "run.complete").exists():
538
+ continue
539
+ has_output = False
540
+ for f in p.iterdir():
541
+ if f.name in ("run.lock", "run.complete"):
542
+ continue
543
+ if f.name.startswith("."):
544
+ continue
545
+ has_output = True
546
+ break
547
+ if has_output:
548
+ candidates.append(p)
549
+
550
+ if not candidates:
551
+ print("No runs to migrate — every complete-looking dir already has run.complete.")
552
+ return
553
+
554
+ print(f"Will backfill run.complete in {len(candidates)} dir(s):")
555
+ for p in candidates:
556
+ print(f" {p}")
557
+
558
+ if args.dry_run:
559
+ print("\n(dry run — no markers written)")
560
+ return
561
+
562
+ if not args.force:
563
+ answer = input(f"\nWrite run.complete in {len(candidates)} dirs? [y/N] ")
564
+ if answer.lower() not in ("y", "yes"):
565
+ print("Aborted.")
566
+ return
567
+
568
+ written = 0
569
+ for p in candidates:
570
+ try:
571
+ write_complete_marker(p)
572
+ written += 1
573
+ except Exception as e:
574
+ print(f" Error writing marker for {p}: {e}", file=sys.stderr)
575
+ print(f"Wrote {written} markers.")
576
+
577
+
578
+ def cmd_reindex(args):
579
+ """Rebuild the project-wide fingerprint index from run.lock files."""
580
+ from . import index
581
+
582
+ root = Path(args.path) if args.path else Path(".")
583
+ n = index.reindex(root)
584
+ print(f"Reindexed {n} run(s) under {root}")
585
+
586
+
587
+ # ── show subcommand ────────────────────────────────────────────
588
+
589
+ def cmd_show(args):
590
+ """Show a single run's status, metadata, lineage, and config."""
591
+ from . import query
592
+
593
+ run_dir = Path(args.run_dir)
594
+ if not run_dir.exists():
595
+ print(f"Error: no such directory: {run_dir}", file=sys.stderr)
596
+ sys.exit(1)
597
+
598
+ summary = query.load_run_summary(
599
+ run_dir,
600
+ scan_root=args.root,
601
+ downstream=not args.no_downstream,
602
+ )
603
+
604
+ if args.format == "json":
605
+ print(json.dumps(summary, indent=2, default=str))
606
+ else:
607
+ print(query.format_summary_md(summary))
608
+
609
+
610
+ # ── graph subcommand ───────────────────────────────────────────
611
+
612
+ def cmd_graph(args):
613
+ """Emit the experiment DAG as JSON, Mermaid, or DOT."""
614
+ from . import query
615
+
616
+ root = Path(args.path or ".")
617
+ graph = query.build_graph(root, include_groups=args.groups)
618
+
619
+ if args.format == "mermaid":
620
+ print(query.graph_to_mermaid(graph))
621
+ elif args.format == "dot":
622
+ print(query.graph_to_dot(graph))
623
+ else:
624
+ print(json.dumps(graph, indent=2, default=str))
625
+
626
+
627
+ # ── why subcommand ─────────────────────────────────────────────
628
+
629
+ def cmd_why(args):
630
+ """Explain the difference between two runs (config/git/data + commits)."""
631
+ from . import query
632
+
633
+ for p in (args.run_a, args.run_b):
634
+ if not Path(p).exists():
635
+ print(f"Error: no such directory: {p}", file=sys.stderr)
636
+ sys.exit(1)
637
+
638
+ result = query.why(args.run_a, args.run_b)
639
+ if args.format == "json":
640
+ print(json.dumps(result, indent=2, default=str))
641
+ else:
642
+ print(query.format_why_text(result))
643
+
644
+
645
+ # ── stages subcommand ──────────────────────────────────────────
646
+
647
+ def _load_defaults_cfg(defaults, config_path=None):
648
+ """Load a defaults tree (+ optional -c YAML merge) as a DictConfig.
649
+
650
+ Shared loader used by ``stages``; kept decoupled from argparse so callers
651
+ can pass plain strings.
652
+ """
653
+ from omegaconf import OmegaConf
654
+ from .utils import load_python_defaults
655
+
656
+ loaded = load_python_defaults(defaults)
657
+ cfg = loaded if OmegaConf.is_config(loaded) else OmegaConf.create(loaded)
658
+ if config_path:
659
+ cfg.merge_with(OmegaConf.load(config_path))
660
+ return cfg
661
+
662
+
663
+ def cmd_stages(args):
664
+ """List runnable stages (mappings with _target_) in a defaults tree."""
665
+ from . import query
666
+
667
+ if not args.defaults:
668
+ print("Error: -d/--defaults is required", file=sys.stderr)
669
+ sys.exit(1)
670
+
671
+ cfg = _load_defaults_cfg(args.defaults, args.config)
672
+ stages = query.list_stage_nodes(cfg)
673
+
674
+ if args.format == "json":
675
+ print(json.dumps(stages, indent=2, default=str))
676
+ elif args.format == "keys":
677
+ for s in stages:
678
+ print(s["key"])
679
+ else:
680
+ if not stages:
681
+ print("No stages (mappings with _target_) found.")
682
+ return
683
+ for s in stages:
684
+ indent = " " * max(s["depth"] - 1, 0)
685
+ save = f" → {s['save_dir']}" if s.get("save_dir") else ""
686
+ print(f"{indent}{s['key']:30s} {s['target']}{save}")
687
+
688
+
689
+ # ── report subcommand ──────────────────────────────────────────
690
+
691
+ def cmd_report(args):
692
+ """Generate a static, self-contained HTML report of a results tree."""
693
+ from . import report
694
+
695
+ out = report.generate_report(
696
+ args.path or ".",
697
+ args.output,
698
+ title=args.title,
699
+ include_groups=args.groups,
700
+ embed_configs=args.embed_configs,
701
+ )
702
+ print(f"Wrote report to {out}")
703
+
704
+
705
+ # ── skills subcommand ──────────────────────────────────────────
706
+
707
+ def _parse_skill_frontmatter(text: str) -> dict:
708
+ """Pull ``name``/``description`` from a SKILL.md YAML frontmatter block."""
709
+ meta = {}
710
+ if not text.startswith("---"):
711
+ return meta
712
+ end = text.find("\n---", 3)
713
+ if end == -1:
714
+ return meta
715
+ try:
716
+ meta = yaml.safe_load(text[3:end]) or {}
717
+ except Exception:
718
+ meta = {}
719
+ return meta if isinstance(meta, dict) else {}
720
+
721
+
722
+ def _iter_skills():
723
+ """Yield ``(name, description, traversable_dir)`` for every shipped skill."""
724
+ from importlib.resources import files
725
+
726
+ skills_root = files("flexlock").joinpath("skills")
727
+ if not skills_root.is_dir():
728
+ return
729
+ for entry in sorted(skills_root.iterdir(), key=lambda p: p.name):
730
+ if not entry.is_dir():
731
+ continue
732
+ skill_md = entry.joinpath("SKILL.md")
733
+ if not skill_md.is_file():
734
+ continue
735
+ meta = _parse_skill_frontmatter(skill_md.read_text(encoding="utf-8"))
736
+ yield meta.get("name", entry.name), meta.get("description", ""), entry
737
+
738
+
739
+ def cmd_skills(args):
740
+ """List or install the FlexLock Claude Code skills shipped in the package."""
741
+ import shutil
742
+ from importlib.resources import as_file
743
+
744
+ if args.skills_command == "list":
745
+ found = False
746
+ for name, desc, _ in _iter_skills():
747
+ found = True
748
+ print(f"{name}\n {desc}")
749
+ if not found:
750
+ print("No skills packaged.")
751
+ return
752
+
753
+ # install
754
+ available = {name: entry for name, _, entry in _iter_skills()}
755
+ if not available:
756
+ print("No skills packaged.", file=sys.stderr)
757
+ sys.exit(1)
758
+
759
+ wanted = args.names or list(available.keys())
760
+ unknown = [n for n in wanted if n not in available]
761
+ if unknown:
762
+ print(f"Error: unknown skill(s): {', '.join(unknown)}", file=sys.stderr)
763
+ print(f"Available: {', '.join(available)}", file=sys.stderr)
764
+ sys.exit(1)
765
+
766
+ dest_root = Path(args.dest)
767
+ dest_root.mkdir(parents=True, exist_ok=True)
768
+
769
+ written = []
770
+ for name in wanted:
771
+ target = dest_root / name
772
+ if target.exists() and not args.force:
773
+ print(f"Skipping {name}: {target} exists (use --force to overwrite)")
774
+ continue
775
+ if target.exists():
776
+ shutil.rmtree(target)
777
+ with as_file(available[name]) as src:
778
+ shutil.copytree(src, target)
779
+ written.append(str(target))
780
+
781
+ if written:
782
+ print("Installed:")
783
+ for w in written:
784
+ print(f" {w}")
785
+ print("Upgrade later by re-running `flexlock skills install --force`.")
786
+ else:
787
+ print("Nothing written.")
788
+
789
+
790
+ def main():
791
+ parser = argparse.ArgumentParser(
792
+ prog="flexlock",
793
+ description="FlexLock experiment management CLI",
794
+ )
795
+ subparsers = parser.add_subparsers(dest="command")
796
+
797
+ # ls
798
+ ls_parser = subparsers.add_parser("ls", help="List runs in results directories")
799
+ ls_parser.add_argument("path", nargs="?", help="Root directory to search (default: .)")
800
+ ls_parser.add_argument("-v", "--verbose", action="store_true", help="Show extra details")
801
+ ls_parser.add_argument("--format", choices=["table", "json"], default="table")
802
+ ls_parser.set_defaults(func=cmd_ls)
803
+
804
+ # tag
805
+ tag_parser = subparsers.add_parser("tag", help="Tag a run with a human-readable name")
806
+ tag_parser.add_argument("name", nargs="?", help="Tag name")
807
+ tag_parser.add_argument("path", nargs="?", help="Path to run directory")
808
+ tag_parser.add_argument("-m", "--message", help="Optional tag message")
809
+ tag_parser.add_argument("-l", "--list", action="store_true", help="List all tags")
810
+ tag_parser.add_argument("-d", "--delete", metavar="TAG", help="Delete a tag")
811
+ tag_parser.add_argument("-v", "--verbose", action="store_true", help="Show tag details")
812
+ tag_parser.set_defaults(func=cmd_tag)
813
+
814
+ # gc
815
+ gc_parser = subparsers.add_parser("gc", help="Clean up untagged runs")
816
+ gc_parser.add_argument("path", nargs="?", help="Root directory to search (default: .)")
817
+ gc_parser.add_argument("-n", "--dry-run", action="store_true", help="Show what would be deleted")
818
+ gc_parser.add_argument("-f", "--force", action="store_true", help="Skip confirmation")
819
+ gc_parser.add_argument("--refs", action="store_true", help="Also clean orphaned shadow git refs")
820
+ gc_parser.add_argument(
821
+ "--incomplete",
822
+ action="store_true",
823
+ help="Only delete run dirs with run.lock but no run.complete (interrupted attempts)",
824
+ )
825
+ gc_parser.set_defaults(func=cmd_gc)
826
+
827
+ # migrate-cache-markers
828
+ mig_parser = subparsers.add_parser(
829
+ "migrate-cache-markers",
830
+ help="Backfill run.complete for existing complete-looking run dirs",
831
+ )
832
+ mig_parser.add_argument("path", nargs="?", help="Root directory to search (default: .)")
833
+ mig_parser.add_argument("-n", "--dry-run", action="store_true")
834
+ mig_parser.add_argument("-f", "--force", action="store_true", help="Skip confirmation")
835
+ mig_parser.set_defaults(func=cmd_migrate_cache_markers)
836
+
837
+ # reindex
838
+ reindex_parser = subparsers.add_parser(
839
+ "reindex",
840
+ help="Rebuild the project-wide fingerprint index from run.lock files",
841
+ )
842
+ reindex_parser.add_argument(
843
+ "path", nargs="?", help="Root directory to walk (default: .)"
844
+ )
845
+ reindex_parser.set_defaults(func=cmd_reindex)
846
+
847
+ # show
848
+ show_parser = subparsers.add_parser(
849
+ "show", help="Show a single run's status, metadata, lineage, and config"
850
+ )
851
+ show_parser.add_argument("run_dir", help="Path to the run directory")
852
+ show_parser.add_argument(
853
+ "--format", choices=["md", "json"], default="md",
854
+ help="Output format (default: md; agents use json)",
855
+ )
856
+ show_parser.add_argument(
857
+ "--root", metavar="DIR",
858
+ help="Root to scan for downstream runs and tags (default: run's parent)",
859
+ )
860
+ show_parser.add_argument(
861
+ "--no-downstream", action="store_true",
862
+ help="Skip the downstream lineage scan (faster)",
863
+ )
864
+ show_parser.set_defaults(func=cmd_show)
865
+
866
+ # graph
867
+ graph_parser = subparsers.add_parser(
868
+ "graph", help="Emit the experiment DAG (json/mermaid/dot)"
869
+ )
870
+ graph_parser.add_argument("path", nargs="?", help="Results root (default: .)")
871
+ graph_parser.add_argument(
872
+ "--format", choices=["json", "mermaid", "dot"], default="json"
873
+ )
874
+ graph_parser.add_argument(
875
+ "--groups", action="store_true",
876
+ help="Include same-tree / same-data groupings in JSON output",
877
+ )
878
+ graph_parser.set_defaults(func=cmd_graph)
879
+
880
+ # why
881
+ why_parser = subparsers.add_parser(
882
+ "why", help="Explain the difference between two runs"
883
+ )
884
+ why_parser.add_argument("run_a", help="First run directory")
885
+ why_parser.add_argument("run_b", help="Second run directory")
886
+ why_parser.add_argument("--format", choices=["text", "json"], default="text")
887
+ why_parser.set_defaults(func=cmd_why)
888
+
889
+ # stages
890
+ stages_parser = subparsers.add_parser(
891
+ "stages", help="List runnable stages in a defaults tree"
892
+ )
893
+ stages_parser.add_argument(
894
+ "-d", "--defaults", required=True,
895
+ help="Python import path for the defaults (pkg.mod.var or file.py:var)",
896
+ )
897
+ stages_parser.add_argument(
898
+ "-c", "--config", metavar="FILE", help="Optional YAML to merge into defaults"
899
+ )
900
+ stages_parser.add_argument(
901
+ "--format", choices=["text", "keys", "json"], default="text",
902
+ help="text (default), keys (bare keys for fzf), or json",
903
+ )
904
+ stages_parser.set_defaults(func=cmd_stages)
905
+
906
+ # report
907
+ report_parser = subparsers.add_parser(
908
+ "report", help="Generate a static HTML report of a results tree"
909
+ )
910
+ report_parser.add_argument("path", nargs="?", help="Results root (default: .)")
911
+ report_parser.add_argument(
912
+ "-o", "--output", default="report.html", help="Output HTML file"
913
+ )
914
+ report_parser.add_argument("--title", help="Report title")
915
+ report_parser.add_argument(
916
+ "--groups", action="store_true", help="Include same-tree/same-data groups"
917
+ )
918
+ report_parser.add_argument(
919
+ "--embed-configs", action="store_true",
920
+ help="Embed each run's full config (larger file)",
921
+ )
922
+ report_parser.set_defaults(func=cmd_report)
923
+
924
+ # skills
925
+ skills_parser = subparsers.add_parser(
926
+ "skills", help="List or install the shipped FlexLock Claude Code skills"
927
+ )
928
+ skills_sub = skills_parser.add_subparsers(dest="skills_command", required=True)
929
+ skills_list = skills_sub.add_parser("list", help="List packaged skills")
930
+ skills_list.set_defaults(func=cmd_skills)
931
+ skills_install = skills_sub.add_parser("install", help="Install skill folders")
932
+ skills_install.add_argument(
933
+ "names", nargs="*", help="Skill names to install (default: all)"
934
+ )
935
+ skills_install.add_argument(
936
+ "--dest", default=".claude/skills", help="Destination dir (default: .claude/skills)"
937
+ )
938
+ skills_install.add_argument(
939
+ "--force", action="store_true", help="Overwrite existing skill folders"
940
+ )
941
+ skills_install.set_defaults(func=cmd_skills)
942
+
943
+ args = parser.parse_args()
944
+
945
+ if not args.command:
946
+ parser.print_help()
947
+ sys.exit(1)
948
+
949
+ args.func(args)
950
+
951
+
952
+ if __name__ == "__main__":
953
+ main()