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/__init__.py +87 -0
- flexlock/api.py +1444 -0
- flexlock/backends/__init__.py +11 -0
- flexlock/backends/base.py +41 -0
- flexlock/backends/pbs.py +219 -0
- flexlock/backends/slurm.py +333 -0
- flexlock/cli.py +953 -0
- flexlock/config.py +121 -0
- flexlock/context.py +7 -0
- flexlock/data_hash.py +310 -0
- flexlock/debug.py +473 -0
- flexlock/diff.py +253 -0
- flexlock/diff_cli.py +155 -0
- flexlock/exceptions.py +50 -0
- flexlock/export.py +134 -0
- flexlock/fingerprint.py +129 -0
- flexlock/flexcli.py +163 -0
- flexlock/freeze.py +291 -0
- flexlock/git_utils.py +196 -0
- flexlock/index.py +300 -0
- flexlock/load_stage.py +60 -0
- flexlock/mlflow.py +192 -0
- flexlock/parallel.py +378 -0
- flexlock/query.py +739 -0
- flexlock/report.py +78 -0
- flexlock/report_template.html +246 -0
- flexlock/resolvers.py +248 -0
- flexlock/run_cli.py +44 -0
- flexlock/run_record.py +173 -0
- flexlock/runner.py +571 -0
- flexlock/save_dir.py +232 -0
- flexlock/skills/flexlock-new-stage/SKILL.md +57 -0
- flexlock/skills/flexlock-report/SKILL.md +50 -0
- flexlock/skills/flexlock-run-and-watch/SKILL.md +60 -0
- flexlock/skills/flexlock-survey/SKILL.md +61 -0
- flexlock/snapshot.py +245 -0
- flexlock/status_cli.py +299 -0
- flexlock/taskdb.py +685 -0
- flexlock/utils.py +710 -0
- flexlock/worker.py +228 -0
- flexlock/worker_cli.py +143 -0
- flexlock-0.8.2.dist-info/METADATA +357 -0
- flexlock-0.8.2.dist-info/RECORD +45 -0
- flexlock-0.8.2.dist-info/WHEEL +4 -0
- flexlock-0.8.2.dist-info/entry_points.txt +7 -0
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()
|