clizard 0.1.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.
clizard/core.py ADDED
@@ -0,0 +1,359 @@
1
+ """Generic Claude-style rich CLI, reusable across projects."""
2
+ import time
3
+ import sys
4
+ from rich import box
5
+ from rich.align import Align
6
+ from rich.console import Console, Group
7
+ from rich.live import Live
8
+ from rich.markdown import Markdown
9
+ from rich.panel import Panel
10
+ from rich.prompt import Prompt
11
+ from rich.spinner import Spinner
12
+ from rich.table import Table
13
+ from rich.text import Text
14
+
15
+ from .config import Config
16
+ from .project_info import get_project_info
17
+
18
+
19
+ console = Console()
20
+
21
+ DEFAULT_ASCII = r"""
22
+ .-.
23
+ |o o|
24
+ | = |
25
+ /|___|\
26
+ """
27
+
28
+
29
+ class GenericCLI:
30
+ """Reusable rich-based chat CLI.
31
+
32
+ Pass `handler(prompt, cli) -> str` to process user input, or subclass
33
+ and override `handle(prompt)`. Register extra slash-commands with
34
+ `@cli.command("/foo")`.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ app_name="Generic CLI",
40
+ ascii_art=None,
41
+ accent_color="#d97757",
42
+ dim_color="#9ca3af",
43
+ user_color="#3b82f6",
44
+ settings: dict = None,
45
+ config_path: str = None,
46
+ handler=None,
47
+ tips: list = None,
48
+ updates: list = None,
49
+ hidden_settings=None,
50
+ ):
51
+ self.app_name = app_name
52
+ self.ascii_art = ascii_art or DEFAULT_ASCII
53
+ self.ACCENT = accent_color
54
+ self.DIM = dim_color
55
+ self.USER = user_color
56
+ self.handler = handler
57
+ self.tips = tips or ["/help", "/settings"]
58
+ self.updates = updates or ["Initial release"]
59
+ # Keys that exist in config (e.g. internal/derived values like git
60
+ # remote info or the model name) but shouldn't clutter /settings or
61
+ # the /wizard walkthrough. Still readable/writable via
62
+ # `/settings set <key> <value>` and `self.config.get/set`.
63
+ self.hidden_settings = set(hidden_settings) if hidden_settings else {
64
+ "github_user", "github_repo", "remote_url", "default_branch", "model",
65
+ }
66
+
67
+ defaults = {}
68
+ if settings:
69
+ defaults.update(settings)
70
+ self.config = Config(app_name, defaults=defaults, config_path=config_path)
71
+
72
+ self._commands = {}
73
+ self._register_builtins()
74
+
75
+ # ---------- command registry ----------
76
+ def command(self, name, help_text=""):
77
+ """Decorator: register a slash-command. fn(self, prompt) -> None."""
78
+ def deco(fn):
79
+ self._commands[name] = (fn, help_text)
80
+ return fn
81
+ return deco
82
+
83
+ def register_command(self, name, fn, help_text=""):
84
+ self._commands[name] = (fn, help_text)
85
+
86
+ def _register_builtins(self):
87
+ self.register_command("/help", self._cmd_help, "Show available commands")
88
+ self.register_command("/clear", self._cmd_clear, "Clear the terminal")
89
+ self.register_command("/settings", self._cmd_settings, "View or edit settings")
90
+ self.register_command("/exit", self._cmd_exit, "Exit the session")
91
+ self.register_command("/quit", self._cmd_exit, "Exit the session")
92
+ self.register_command("/install", self._cmd_install, "Install project requirements")
93
+ self.register_command("/docs", self._cmd_docs, "Open the project documentation")
94
+ self.register_command("/wizard", self._cmd_wizard, "Step through all settings, then optionally /run")
95
+
96
+ def _cmd_help(self, prompt):
97
+ lines = "\n".join(f"* `{name}` - {desc}" for name, (_, desc) in sorted(self._commands.items()))
98
+ self.assistant_message(f"### Available Commands\n{lines}")
99
+
100
+ def _cmd_clear(self, prompt):
101
+ console.clear()
102
+
103
+ def _cmd_exit(self, prompt):
104
+ console.print(f"[dim]Goodbye! Thanks for using {self.app_name}.[/dim]")
105
+ raise SystemExit(0)
106
+
107
+ @staticmethod
108
+ def _cast_value(raw: str, current):
109
+ """Cast a string CLI value to match the type of the existing setting."""
110
+ if isinstance(current, bool):
111
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
112
+ if isinstance(current, int) and not isinstance(current, bool):
113
+ try:
114
+ return int(raw)
115
+ except ValueError:
116
+ return raw
117
+ if isinstance(current, float):
118
+ try:
119
+ return float(raw)
120
+ except ValueError:
121
+ return raw
122
+ if raw.lower() == "none":
123
+ return None
124
+ return raw
125
+
126
+ def _cmd_settings(self, prompt):
127
+ # /settings -> interactive view + optional edit
128
+ # /settings set k v... -> direct set (v can contain spaces)
129
+ parts = prompt.split(maxsplit=3)
130
+ if len(parts) >= 3 and parts[1] == "set":
131
+ key = parts[2]
132
+ raw_value = parts[3] if len(parts) > 3 else ""
133
+ current = self.config.get(key)
134
+ value = self._cast_value(raw_value, current)
135
+ self.config.set(key, value)
136
+ console.print(f"[dim]Set {key} = {value!r}[/dim]")
137
+ return
138
+
139
+ self._show_settings_table()
140
+ try:
141
+ answer = Prompt.ask(" ✏️ Edit a setting now?", choices=["y", "n"], default="n")
142
+ except (KeyboardInterrupt, EOFError):
143
+ return
144
+ if answer != "y":
145
+ return
146
+
147
+ keys = [k for k in self.config.settings.keys() if k not in self.hidden_settings]
148
+ key = Prompt.ask(f" Which key? [{'/'.join(keys)}]")
149
+ if key not in self.config.settings:
150
+ self.error(f"Unknown key: {key}")
151
+ return
152
+ current = self.config.get(key)
153
+ raw_value = Prompt.ask(f" New value for [bold]{key}[/bold]", default=str(current))
154
+ value = self._cast_value(raw_value, current)
155
+ self.config.set(key, value)
156
+ console.print(f"[dim]Set {key} = {value!r}[/dim]")
157
+ self._show_settings_table()
158
+
159
+ def _cmd_wizard(self, prompt):
160
+ console.print(f"[bold {self.ACCENT}]Setup Wizard[/bold {self.ACCENT}] — press Enter to keep the current value.\n")
161
+ meta = getattr(self, "arg_meta", {}) or {}
162
+
163
+ for key in list(self.config.settings.keys()):
164
+ if key in self.hidden_settings:
165
+ continue
166
+ current = self.config.get(key)
167
+ info = meta.get(key, {})
168
+ help_text = info.get("help")
169
+ choices = info.get("choices")
170
+
171
+ label = f" [bold]{key}[/bold]"
172
+ if help_text:
173
+ label += f" [dim]({help_text})[/dim]"
174
+ if choices:
175
+ label += f" [dim]choices: {choices}[/dim]"
176
+
177
+ while True:
178
+ raw_value = Prompt.ask(label, default=str(current))
179
+ caster = info.get("type")
180
+ if caster and current is None:
181
+ try:
182
+ value = caster(raw_value)
183
+ except (ValueError, TypeError):
184
+ value = self._cast_value(raw_value, current)
185
+ else:
186
+ value = self._cast_value(raw_value, current)
187
+ if choices and value not in choices:
188
+ self.error(f"Invalid value {value!r}. Choose from {choices}.")
189
+ continue
190
+ break
191
+
192
+ self.config.set(key, value)
193
+
194
+ console.print()
195
+ self._show_settings_table()
196
+
197
+ if "/run" in self._commands:
198
+ try:
199
+ answer = Prompt.ask(" ▶️ Start /run now?", choices=["y", "n"], default="y")
200
+ except (KeyboardInterrupt, EOFError):
201
+ return
202
+ if answer == "y":
203
+ self._commands["/run"][0]("/run")
204
+ else:
205
+ console.print("[dim]No /run command registered.[/dim]")
206
+
207
+ def _cmd_install(self, prompt):
208
+ import subprocess
209
+ from pathlib import Path
210
+
211
+ repo_path = Path(self.config.get("path", "."))
212
+ pyproject = repo_path / "pyproject.toml"
213
+ requirements = repo_path / "requirements.txt"
214
+
215
+ if pyproject.exists():
216
+ cmd = [sys.executable, "-m", "pip", "install", "-e", str(repo_path)]
217
+ elif requirements.exists():
218
+ cmd = [sys.executable, "-m", "pip", "install", "-r", str(requirements)]
219
+ else:
220
+ self.error("No pyproject.toml or requirements.txt found.")
221
+ return
222
+
223
+ console.print(f"[dim]$ {' '.join(cmd)}[/dim]")
224
+ self.status("Installing...")
225
+ try:
226
+ result = subprocess.run(cmd, capture_output=True, text=True)
227
+ except Exception as e:
228
+ self.error(str(e))
229
+ return
230
+
231
+ if result.returncode == 0:
232
+ self.assistant_message("Install completed successfully.")
233
+ else:
234
+ self.error(result.stderr.strip()[-1000:] or "Install failed.")
235
+
236
+ def _cmd_docs(self, prompt):
237
+ import webbrowser
238
+ from pathlib import Path
239
+
240
+ docs_url = self.config.get("docs_url")
241
+ repo_path = Path(self.config.get("path", "."))
242
+ local_docs = repo_path / "docs" / "index.html"
243
+
244
+ if local_docs.exists():
245
+ target = local_docs.resolve().as_uri()
246
+ elif docs_url:
247
+ target = docs_url
248
+ else:
249
+ self.error("No docs/index.html or docs_url configured.")
250
+ return
251
+
252
+ console.print(f"[dim]Opening {target}[/dim]")
253
+ webbrowser.open(target)
254
+
255
+ def _show_settings_table(self):
256
+ table = Table(
257
+ title="Current Configuration Settings",
258
+ box=box.ROUNDED,
259
+ border_style=self.ACCENT,
260
+ show_lines=False,
261
+ )
262
+ table.add_column("Parameter", style=self.ACCENT)
263
+ table.add_column("Current Value")
264
+ for k, v in self.config.settings.items():
265
+ if k in self.hidden_settings:
266
+ continue
267
+ table.add_row(str(k), str(v))
268
+ console.print(table)
269
+
270
+ # ---------- rendering ----------
271
+ def welcome(self):
272
+ repo_info = get_project_info()
273
+
274
+ console.print()
275
+ # Build a centered Text block for the header so all fragments are centered.
276
+ header_text = Text(justify="center")
277
+ header_text.append(f"{self.app_name}\n", style=f"bold {self.ACCENT} reverse")
278
+ # Ensure ascii art is on its own centered block
279
+ header_text.append(f"\n{self.ascii_art}\n", style=self.ACCENT)
280
+ if repo_info.get('description'):
281
+ header_text.append(f"\n{repo_info['description']}")
282
+
283
+ centered_header = Align.center(header_text, vertical="middle")
284
+
285
+ grid = Table.grid(expand=True, padding=(0, 4))
286
+ grid.add_column(ratio=1)
287
+ grid.add_column(ratio=1)
288
+
289
+ tips_md = "\n".join(f" • [cyan]{t}[/cyan]" for t in self.tips)
290
+ updates_md = "\n".join(f"• {u}" for u in self.updates)
291
+
292
+ tips_text = Text.from_markup(f"[bold {self.ACCENT}]Getting started[/bold {self.ACCENT}]\n\nType commands like:\n{tips_md}")
293
+ updates_text = Text.from_markup(f"[bold {self.ACCENT}]What's new[/bold {self.ACCENT}]\n\n{updates_md}")
294
+
295
+ grid.add_row(tips_text, updates_text)
296
+
297
+ layout_group = Group(centered_header, "─" * 64, "", grid)
298
+
299
+ console.print(
300
+ Align.center(
301
+ Panel(layout_group, box=box.ROUNDED, border_style=self.ACCENT, padding=(2, 4), width=76)
302
+ )
303
+ )
304
+ console.print()
305
+
306
+ def user_message(self, text):
307
+ console.print(f"\n[bold {self.USER}]👤 You[/bold {self.USER}] › {text}\n")
308
+
309
+ def assistant_message(self, text):
310
+ console.print(f"[bold {self.ACCENT}]🤖 Assistant[/bold {self.ACCENT}]")
311
+ console.print("═" * 40, style=self.ACCENT)
312
+ console.print(Markdown(text))
313
+ console.print("═" * 40, style="dim")
314
+ console.print()
315
+
316
+ def status(self, text, duration=1.2):
317
+ with Live(Spinner("dots", text=f"[dim]{text}[/dim]", style=self.ACCENT), refresh_per_second=12, transient=True):
318
+ time.sleep(duration)
319
+
320
+ def tool_call(self, tool_name):
321
+ console.print(f" [dim]🛠️ Running tool:[/dim] [italic cyan]{tool_name}[/italic cyan]...")
322
+
323
+ def error(self, text):
324
+ console.print(f"[bold red]✕ Error:[/bold red] {text}")
325
+
326
+ # ---------- main loop ----------
327
+ def handle(self, prompt):
328
+ """Default handler if none supplied; override or pass `handler=`."""
329
+ if self.handler:
330
+ return self.handler(prompt, self)
331
+ return f"Successfully captured your request: **'{prompt}'**."
332
+
333
+ def run(self):
334
+ self.welcome()
335
+ while True:
336
+ try:
337
+ prompt = Prompt.ask(f"[bold {self.ACCENT}]❯[/bold {self.ACCENT}]").strip()
338
+ if not prompt:
339
+ continue
340
+
341
+ cmd = prompt.split(maxsplit=1)[0]
342
+ if cmd in self._commands:
343
+ fn, _ = self._commands[cmd]
344
+ fn(prompt)
345
+ continue
346
+ if cmd.startswith("/"):
347
+ self.error(f"Unknown command: {cmd} (type /help for a list)")
348
+ continue
349
+
350
+ self.user_message(prompt)
351
+ self.status("Thinking...")
352
+ reply = self.handle(prompt)
353
+ self.assistant_message(reply)
354
+
355
+ except SystemExit:
356
+ break
357
+ except (KeyboardInterrupt, EOFError):
358
+ console.print("\n[dim]Session terminated gracefully.[/dim]")
359
+ break
clizard/discover.py ADDED
@@ -0,0 +1,349 @@
1
+ """Find a repo's entry-point main() function and turn its signature into
2
+ editable CLI settings, without needing argparse to be structured any
3
+ particular way.
4
+ """
5
+ import importlib.util
6
+ import inspect
7
+ import sys
8
+ import re
9
+ from pathlib import Path
10
+ from .git_info import get_git_info
11
+
12
+
13
+ """Locate the most likely entry-point .py file in a repo."""
14
+ def _find_entry_file(repo_path="."):
15
+ repo_path = Path(repo_path)
16
+ repo_name = repo_path.name
17
+
18
+ CANDIDATE_FILENAMES = [
19
+ "__main__.py",
20
+ "main.py",
21
+ f"{repo_name}.py",
22
+ ]
23
+
24
+ # Add GitHub repo name if available
25
+ gitinfo = get_git_info()
26
+ if gitinfo.get("github_repo"):
27
+ CANDIDATE_FILENAMES.append(f"{gitinfo['github_repo']}.py")
28
+
29
+ CANDIDATE_FILENAMES = list(dict.fromkeys(CANDIDATE_FILENAMES))
30
+
31
+ # --- 1. Direct top-level candidates ---
32
+ for name in CANDIDATE_FILENAMES:
33
+ # subfolder match
34
+ for candidate in repo_path.glob(f"*/{name}"):
35
+ return candidate
36
+
37
+ # direct match
38
+ direct = repo_path / name
39
+ if direct.exists():
40
+ return direct
41
+
42
+ # --- 2. Find file that actually handles arguments ---
43
+ ARG_PATTERNS = [
44
+ r"sys\.argv",
45
+ r"argparse",
46
+ r"ArgumentParser",
47
+ r"click\.command",
48
+ r"typer\.run",
49
+ ]
50
+
51
+ MAIN_PATTERN = r"def\s+main\s*\("
52
+
53
+ for py_file in repo_path.rglob("*.py"):
54
+ if ".git" in py_file.parts or "site-packages" in py_file.parts:
55
+ continue
56
+
57
+ try:
58
+ text = py_file.read_text(errors="ignore")
59
+ except OSError:
60
+ continue
61
+
62
+ # must define main()
63
+ if not re.search(MAIN_PATTERN, text):
64
+ continue
65
+
66
+ # must reference arguments
67
+ if any(re.search(p, text) for p in ARG_PATTERNS):
68
+ return py_file
69
+
70
+ # --- 3. Fallback: any file with main() and __name__ check ---
71
+ for py_file in repo_path.rglob("*.py"):
72
+ if ".git" in py_file.parts or "site-packages" in py_file.parts:
73
+ continue
74
+
75
+ try:
76
+ text = py_file.read_text(errors="ignore")
77
+ except OSError:
78
+ continue
79
+
80
+ if "__name__" in text and "def main(" in text:
81
+ return py_file
82
+
83
+ return None
84
+
85
+ def _load_module(py_file: Path):
86
+ """Load py_file as a module, preserving package context so that any
87
+ relative imports (`from . import foo`, `from .bar import baz`) inside
88
+ it actually resolve, instead of raising ImportError.
89
+
90
+ If py_file lives inside a package (its directory has an __init__.py),
91
+ we import it as a real submodule of that package via importlib, with
92
+ the package's *parent* directory on sys.path. Otherwise we fall back
93
+ to loading it as a standalone module.
94
+ """
95
+ parent = py_file.parent
96
+
97
+ if (parent / "__init__.py").exists():
98
+ package_name = parent.name
99
+ package_parent_dir = str(parent.parent)
100
+ sys.path.insert(0, package_parent_dir)
101
+ try:
102
+ module_name = f"{package_name}.{py_file.stem}"
103
+ # Drop any stale cached import from a previous discovery run.
104
+ sys.modules.pop(module_name, None)
105
+ sys.modules.pop(package_name, None)
106
+ module = importlib.import_module(module_name)
107
+ finally:
108
+ sys.path.remove(package_parent_dir)
109
+ return module
110
+
111
+ module_name = f"_clizard_discovered_{py_file.stem}"
112
+ spec = importlib.util.spec_from_file_location(module_name, py_file)
113
+ module = importlib.util.module_from_spec(spec)
114
+ sys.path.insert(0, str(py_file.parent))
115
+ try:
116
+ spec.loader.exec_module(module)
117
+ finally:
118
+ sys.path.pop(0)
119
+ return module
120
+
121
+
122
+ def find_main(repo_path="."):
123
+ """Return (module, main_func, file_path) or (None, None, None)."""
124
+ py_file = _find_entry_file(repo_path)
125
+ if py_file is None:
126
+ return None, None, None
127
+
128
+ try:
129
+ module = _load_module(py_file)
130
+ except Exception as e:
131
+ print(f"clizard: failed to import {py_file} to discover main(): {e!r}", file=sys.stderr)
132
+ return None, None, py_file
133
+
134
+ main_func = getattr(module, "main", None)
135
+ if main_func is None or not callable(main_func):
136
+ return module, None, py_file
137
+
138
+ return module, main_func, py_file
139
+
140
+
141
+ def _extract_argparse_settings(main_func):
142
+ """Many main()s take *no* parameters and instead build their own
143
+ argparse.ArgumentParser() internally, reading from sys.argv. In that
144
+ case there's nothing in the signature to inspect, but the settings
145
+ are sitting right there in the source as `parser.add_argument(...)`
146
+ calls. Parse the function's source with `ast` and recover them.
147
+
148
+ Returns (settings, arg_meta). arg_meta entries carry a "flag" (e.g.
149
+ "--username") and "is_flag" (True for store_true/store_false), which
150
+ the generated /run command uses to rebuild sys.argv before calling
151
+ main() with no arguments, since main() will parse it itself.
152
+ """
153
+ import ast
154
+
155
+ settings = {}
156
+ arg_meta = {}
157
+ try:
158
+ source = inspect.getsource(main_func)
159
+ except (OSError, TypeError):
160
+ return settings, arg_meta
161
+
162
+ try:
163
+ tree = ast.parse(source)
164
+ except SyntaxError:
165
+ return settings, arg_meta
166
+
167
+ parser_names = set()
168
+ for node in ast.walk(tree):
169
+ if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
170
+ func = node.value.func
171
+ func_name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", None)
172
+ if func_name == "ArgumentParser":
173
+ for target in node.targets:
174
+ if isinstance(target, ast.Name):
175
+ parser_names.add(target.id)
176
+
177
+ if not parser_names:
178
+ return settings, arg_meta
179
+
180
+ for node in ast.walk(tree):
181
+ if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
182
+ and node.func.attr == "add_argument"):
183
+ continue
184
+ obj = node.func.value
185
+ if not (isinstance(obj, ast.Name) and obj.id in parser_names):
186
+ continue
187
+
188
+ flags = [a.value for a in node.args if isinstance(a, ast.Constant) and isinstance(a.value, str)]
189
+ if not flags:
190
+ continue
191
+ long_flags = [f for f in flags if f.startswith("--")]
192
+ flag = long_flags[0] if long_flags else flags[-1]
193
+ dest_name = flag.lstrip("-").replace("-", "_")
194
+
195
+ kw = {}
196
+ type_name = None
197
+ for keyword in node.keywords:
198
+ if keyword.arg is None:
199
+ continue
200
+ if keyword.arg == "type" and isinstance(keyword.value, ast.Name):
201
+ type_name = keyword.value.id # e.g. "int", "str", "float"
202
+ continue
203
+ try:
204
+ kw[keyword.arg] = ast.literal_eval(keyword.value)
205
+ except (ValueError, SyntaxError):
206
+ kw[keyword.arg] = None
207
+
208
+ dest_name = kw.get("dest", dest_name)
209
+ action = kw.get("action")
210
+ is_flag = action in ("store_true", "store_false")
211
+ default = kw.get("default")
212
+ if is_flag and default is None:
213
+ default = (action == "store_false")
214
+
215
+ settings[dest_name] = default
216
+ arg_meta[dest_name] = {
217
+ "choices": kw.get("choices"),
218
+ "type": type_name,
219
+ "help": kw.get("help"),
220
+ "flag": flag,
221
+ "is_flag": is_flag,
222
+ }
223
+
224
+ return settings, arg_meta
225
+
226
+
227
+ def settings_from_main(main_func):
228
+ """Inspect main()'s signature -> (settings dict, arg_meta dict, call_style).
229
+
230
+ call_style is "kwargs" when main() takes plain keyword parameters
231
+ (settings become those params, and /run calls main(**settings)), or
232
+ "argv" when main() takes no usable parameters but builds its own
233
+ argparse.ArgumentParser() internally (settings are recovered from its
234
+ add_argument() calls via AST, and /run rebuilds sys.argv before
235
+ calling main() with no arguments).
236
+ """
237
+ settings = {}
238
+ arg_meta = {}
239
+ try:
240
+ sig = inspect.signature(main_func)
241
+ except (TypeError, ValueError):
242
+ sig = None
243
+
244
+ if sig is not None:
245
+ for name, param in sig.parameters.items():
246
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
247
+ continue
248
+ default = None if param.default is inspect.Parameter.empty else param.default
249
+ annotation = None if param.annotation is inspect.Parameter.empty else param.annotation
250
+ settings[name] = default
251
+ arg_meta[name] = {
252
+ "choices": None,
253
+ "type": annotation if callable(annotation) else None,
254
+ "help": None,
255
+ "flag": None,
256
+ "is_flag": False,
257
+ }
258
+
259
+ if settings:
260
+ return settings, arg_meta, "kwargs"
261
+
262
+ # main() took no usable params -- see if it builds an argparse parser
263
+ # internally instead, and recover settings from that.
264
+ argv_settings, argv_meta = _extract_argparse_settings(main_func)
265
+ if argv_settings:
266
+ return argv_settings, argv_meta, "argv"
267
+
268
+ return settings, arg_meta, "kwargs"
269
+
270
+
271
+ SNAKEMAKE_CONFIG_CANDIDATES = [
272
+ "config.yaml", "config.yml",
273
+ "config/config.yaml", "config/config.yml",
274
+ "workflow/config.yaml", "workflow/config.yml",
275
+ ]
276
+
277
+
278
+ def find_snakefile(repo_path="."):
279
+ repo_path = Path(repo_path)
280
+ for name in ("Snakefile", "workflow/Snakefile"):
281
+ candidate = repo_path / name
282
+ if candidate.exists():
283
+ return candidate
284
+ matches = list(repo_path.rglob("Snakefile"))
285
+ return matches[0] if matches else None
286
+
287
+
288
+ def find_snakemake_config(repo_path="."):
289
+ """Locate a Snakemake config YAML: explicit `configfile:` in the
290
+ Snakefile takes priority, else common default paths are tried.
291
+ """
292
+ repo_path = Path(repo_path)
293
+ snakefile = find_snakefile(repo_path)
294
+
295
+ if snakefile and snakefile.exists():
296
+ try:
297
+ text = snakefile.read_text(errors="ignore")
298
+ except OSError:
299
+ text = ""
300
+ for line in text.splitlines():
301
+ line = line.strip()
302
+ if line.startswith("configfile:"):
303
+ rel = line.split(":", 1)[1].strip().strip("'\"")
304
+ candidate = (snakefile.parent / rel).resolve()
305
+ if candidate.exists():
306
+ return candidate
307
+
308
+ for rel in SNAKEMAKE_CONFIG_CANDIDATES:
309
+ candidate = repo_path / rel
310
+ if candidate.exists():
311
+ return candidate
312
+
313
+ return None
314
+
315
+
316
+ def settings_from_snakemake_config(config_path):
317
+ """Load a Snakemake config YAML -> flat settings dict (keys prefixed
318
+ 'sm_' to avoid clashing with main()'s own argument names).
319
+ """
320
+ settings = {}
321
+ if config_path is None:
322
+ return settings
323
+ try:
324
+ import yaml
325
+ except ImportError:
326
+ return settings
327
+
328
+ try:
329
+ with open(config_path, "r") as f:
330
+ data = yaml.safe_load(f) or {}
331
+ except (OSError, yaml.YAMLError):
332
+ return settings
333
+
334
+ if not isinstance(data, dict):
335
+ return settings
336
+
337
+ for key, value in data.items():
338
+ settings[f"sm_{key}"] = value
339
+
340
+ return settings
341
+
342
+
343
+ def write_snakemake_config(config_path, settings: dict):
344
+ """Write back sm_-prefixed settings into the Snakemake config YAML."""
345
+ import yaml
346
+
347
+ data = {k[3:]: v for k, v in settings.items() if k.startswith("sm_")}
348
+ with open(config_path, "w") as f:
349
+ yaml.safe_dump(data, f, default_flow_style=False)