nerftools 0.3.1__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.
nerftools/builder.py ADDED
@@ -0,0 +1,746 @@
1
+ """Shell script generation from nerf manifest tool specs (v1).
2
+
3
+ Each tool becomes a self-contained bash script with all argument parsing,
4
+ validation, and error formatting inlined. Three execution modes are supported:
5
+ template (exec with substituted params), passthrough (deny-scan + exec), and
6
+ script (inline bash).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ from nerftools.manifest import PLACEHOLDER_RE, resolve_placeholder
14
+ from nerftools.rendering import maps_to_text, usage_tokens
15
+
16
+ if TYPE_CHECKING:
17
+ import re
18
+ from pathlib import Path
19
+
20
+ from nerftools.manifest import ArgSpec, NerfManifest, ToolSpec
21
+
22
+
23
+ # -- Public API ----------------------------------------------------------------
24
+
25
+
26
+ def build_scripts(
27
+ manifests: list[NerfManifest],
28
+ output_dir: Path,
29
+ *,
30
+ keep_existing: bool = False,
31
+ prefix: str = "nerf-",
32
+ ) -> list[Path]:
33
+ """Generate shell scripts for all tools in all manifests.
34
+
35
+ By default, all files in output_dir are removed before writing so stale
36
+ tools do not linger. Pass keep_existing=True to preserve unmanaged files.
37
+
38
+ The prefix is prepended to every generated script filename and tool name
39
+ within the script (usage, header comment). Defaults to "nerf-".
40
+
41
+ Returns the list of files written.
42
+ """
43
+ output_dir.mkdir(parents=True, exist_ok=True)
44
+
45
+ if not keep_existing:
46
+ for f in output_dir.iterdir():
47
+ if f.is_file():
48
+ f.unlink()
49
+
50
+ written: list[Path] = []
51
+
52
+ for manifest in manifests:
53
+ for tool_name, tool_spec in manifest.tools.items():
54
+ full_name = prefix + tool_name
55
+ script = _build_script(full_name, manifest.package.name, tool_spec)
56
+ out = output_dir / full_name
57
+ out.write_bytes(script.encode("utf-8"))
58
+ out.chmod(0o755)
59
+ written.append(out)
60
+
61
+ return written
62
+
63
+
64
+ def build_script_text(tool_name: str, package_name: str, tool_spec: ToolSpec) -> str:
65
+ """Return the generated script text for a single tool (for testing)."""
66
+ return _build_script(tool_name, package_name, tool_spec)
67
+
68
+
69
+ # -- Script generation ---------------------------------------------------------
70
+
71
+
72
+ def _build_script(tool_name: str, package_name: str, tool_spec: ToolSpec) -> str:
73
+ parts: list[str] = []
74
+
75
+ parts.append("#!/usr/bin/env bash")
76
+ parts.append(f"# {tool_name} -- {tool_spec.description}")
77
+ parts.append(f"# Generated from {package_name} manifest. Do not edit directly.")
78
+ parts.append(f"# nerf:threat:read={tool_spec.threat.read.value}")
79
+ parts.append(f"# nerf:threat:write={tool_spec.threat.write.value}")
80
+ parts.append("")
81
+ parts.append("set -euo pipefail")
82
+ parts.append("")
83
+ parts.append('_NERF_DRY_RUN=""')
84
+ parts.append("")
85
+ parts.append(_usage_function(tool_name, tool_spec))
86
+
87
+ has_params = bool(tool_spec.switches) or bool(tool_spec.options)
88
+ has_positional = bool(tool_spec.arguments)
89
+
90
+ if has_params:
91
+ parts.append("")
92
+ parts.append(_var_declarations(tool_spec))
93
+
94
+ # Flag parser is always emitted (at minimum for --nerf-dry-run and --help)
95
+ is_passthrough = tool_spec.passthrough is not None
96
+ parts.append("")
97
+ parts.append(_flag_parser(
98
+ tool_spec, has_positional=has_positional, is_passthrough=is_passthrough,
99
+ ))
100
+
101
+ if has_positional:
102
+ parts.append("")
103
+ parts.append(_positional_parser(tool_spec.arguments))
104
+
105
+ if has_params:
106
+ validations = _param_validations(tool_name, tool_spec)
107
+ if validations.strip():
108
+ parts.append("")
109
+ parts.append(validations)
110
+
111
+ if has_positional:
112
+ validations = _arg_validations(tool_name, tool_spec.arguments)
113
+ if validations.strip():
114
+ parts.append("")
115
+ parts.append(validations)
116
+
117
+ if tool_spec.env:
118
+ parts.append("")
119
+ parts.append(_env_exports(tool_spec.env))
120
+
121
+ if tool_spec.guards:
122
+ parts.append("")
123
+ parts.append(_guard_checks(tool_name, tool_spec))
124
+
125
+ if tool_spec.pre:
126
+ parts.append("")
127
+ parts.append(_pre_hook(tool_name, tool_spec))
128
+
129
+ if tool_spec.template is not None:
130
+ if tool_spec.template.npm_pkgrun:
131
+ parts.append("")
132
+ parts.append(_npm_pkgrun_resolver())
133
+ parts.append("")
134
+ parts.append(_dry_run_check(tool_name, tool_spec))
135
+ parts.append("")
136
+ parts.append(_template_exec(tool_spec))
137
+ elif tool_spec.passthrough is not None:
138
+ parts.append("")
139
+ parts.append(_passthrough_exec(tool_name, tool_spec))
140
+ elif tool_spec.script is not None:
141
+ parts.append("")
142
+ parts.append(_dry_run_check(tool_name, tool_spec))
143
+ parts.append("")
144
+ parts.append(tool_spec.script.rstrip())
145
+
146
+ parts.append("")
147
+ return "\n".join(parts)
148
+
149
+
150
+ # -- Usage function ------------------------------------------------------------
151
+
152
+
153
+ def _usage_function(tool_name: str, tool_spec: ToolSpec) -> str:
154
+ usage_line = " ".join([tool_name, *usage_tokens(tool_spec)])
155
+ lines = [f"Usage: {usage_line}", ""]
156
+
157
+ # Switches
158
+ if tool_spec.switches:
159
+ lines.append("Switches:")
160
+ for _name, sw in tool_spec.switches.items():
161
+ flag_display = f"{sw.flag}, {sw.short}" if sw.short else f"{sw.flag}"
162
+ lines.append(f" {flag_display}")
163
+ lines.append(f" {sw.description}")
164
+ lines.append("")
165
+
166
+ # Options
167
+ if tool_spec.options:
168
+ lines.append("Options:")
169
+ for name, opt in tool_spec.options.items():
170
+ flag_display = f"{opt.flag}, {opt.short}" if opt.short else f"{opt.flag}"
171
+ required_marker = " (required)" if opt.required else ""
172
+ lines.append(f" {flag_display} <{name}>{required_marker}")
173
+ lines.append(f" {opt.description}")
174
+ _append_constraints(lines, opt.pattern, opt.allow, opt.deny, indent=" ")
175
+ lines.append("")
176
+
177
+ # Arguments
178
+ if tool_spec.arguments:
179
+ lines.append("Arguments:")
180
+ for name, spec in tool_spec.arguments.items():
181
+ required_marker = " (required)" if spec.required else ""
182
+ arg_label = f"<{name}...>" if spec.variadic else f"<{name}>"
183
+ lines.append(f" {arg_label}{required_marker}")
184
+ lines.append(f" {spec.description}")
185
+ _append_constraints(lines, spec.pattern, spec.allow, spec.deny, indent=" ")
186
+ lines.append("")
187
+
188
+ # Maps to (template and passthrough only)
189
+ maps_to = maps_to_text(tool_spec)
190
+ if maps_to:
191
+ lines.append(f"Maps to: {maps_to}")
192
+ lines.append("")
193
+
194
+ # Passthrough deny list
195
+ if tool_spec.passthrough is not None and tool_spec.passthrough.deny:
196
+ denied = ", ".join(tool_spec.passthrough.deny)
197
+ lines.append(f"Denied patterns: {denied}")
198
+ lines.append("")
199
+
200
+ lines.append(tool_spec.description + ".")
201
+
202
+ body = "\n".join(lines)
203
+ return f"usage() {{\n cat >&2 <<'EOF'\n{body}\nEOF\n exit 1\n}}"
204
+
205
+
206
+
207
+ def _append_constraints(
208
+ lines: list[str],
209
+ pattern: str | None,
210
+ allow: tuple[str, ...],
211
+ deny: tuple[str, ...],
212
+ indent: str,
213
+ ) -> None:
214
+ if pattern:
215
+ lines.append(f"{indent}Must match: {pattern}")
216
+ if allow:
217
+ lines.append(f"{indent}Allowed values: {', '.join(allow)}")
218
+ if deny:
219
+ lines.append(f"{indent}Not allowed: {', '.join(deny)}")
220
+
221
+
222
+ # -- Variable declarations and parsing ----------------------------------------
223
+
224
+
225
+ def _var_declarations(tool_spec: ToolSpec) -> str:
226
+ lines = []
227
+ for name, sw in tool_spec.switches.items():
228
+ var = _var_name(name)
229
+ if sw.repeatable:
230
+ lines.append(f"{var}=0")
231
+ else:
232
+ lines.append(f'{var}=""')
233
+ for name, opt in tool_spec.options.items():
234
+ var = _var_name(name)
235
+ if opt.repeatable:
236
+ lines.append(f"{var}=()")
237
+ else:
238
+ lines.append(f'{var}=""')
239
+ return "\n".join(lines)
240
+
241
+
242
+ def _flag_parser(tool_spec: ToolSpec, *, has_positional: bool, is_passthrough: bool = False) -> str:
243
+ cases = []
244
+
245
+ for name, sw in tool_spec.switches.items():
246
+ var = _var_name(name)
247
+ pattern = f"{sw.flag}|{sw.short}" if sw.short else sw.flag
248
+ if sw.repeatable:
249
+ cases.append(f" {pattern}) {var}=$(({var} + 1)); shift 1 ;;")
250
+ else:
251
+ dup_check = (
252
+ f'if [[ -n "${{{var}}}" ]]; then '
253
+ f'echo "error: {sw.flag} can only be specified once" >&2; exit 1; fi; '
254
+ )
255
+ cases.append(f' {pattern}) {dup_check}{var}="true"; shift 1 ;;')
256
+
257
+ for name, opt in tool_spec.options.items():
258
+ var = _var_name(name)
259
+ pattern = f"{opt.flag}|{opt.short}" if opt.short else opt.flag
260
+ if opt.repeatable:
261
+ cases.append(f' {pattern}) {var}+=("{opt.flag}" "$2"); shift 2 ;;')
262
+ else:
263
+ dup_check = (
264
+ f'if [[ -n "${{{var}}}" ]]; then '
265
+ f'echo "error: {opt.flag} can only be specified once" >&2; exit 1; fi; '
266
+ )
267
+ cases.append(f' {pattern}) {dup_check}{var}="$2"; shift 2 ;;')
268
+
269
+ cases.append(' --nerf-dry-run) _NERF_DRY_RUN="true"; shift 1 ;;')
270
+ cases.append(" -h|--help) usage ;;")
271
+ cases.append(" --) shift; break ;;")
272
+ if has_positional or is_passthrough:
273
+ cases.append(" *) break ;;")
274
+ else:
275
+ cases.append(' *) echo "error: unknown argument: $1" >&2; usage ;;')
276
+
277
+ return "\n".join(
278
+ [
279
+ "while [[ $# -gt 0 ]]; do",
280
+ ' case "$1" in',
281
+ *cases,
282
+ " esac",
283
+ "done",
284
+ ]
285
+ )
286
+
287
+
288
+ def _positional_parser(arguments: dict[str, ArgSpec]) -> str:
289
+ lines = []
290
+ for name, spec in arguments.items():
291
+ var = _var_name(name)
292
+ if spec.variadic:
293
+ lines.append(f'{var}=("$@")')
294
+ else:
295
+ lines.append(f'{var}="${{1:-}}"')
296
+ lines.append("shift 2>/dev/null || true")
297
+ return "\n".join(lines)
298
+
299
+
300
+ # -- Validations ---------------------------------------------------------------
301
+
302
+
303
+ def _param_validations(tool_name: str, tool_spec: ToolSpec) -> str:
304
+ lines: list[str] = []
305
+
306
+ for name, opt in tool_spec.options.items():
307
+ var = _var_name(name)
308
+
309
+ if opt.required:
310
+ lines.append(f'if [[ -z "${{{var}}}" ]]; then')
311
+ lines.append(f' echo "error: {tool_name}: missing required option {opt.flag}" >&2')
312
+ lines.append(f' echo " hint: provide {opt.flag} <value>" >&2')
313
+ lines.append(" usage")
314
+ lines.append("fi")
315
+ lines.append("")
316
+
317
+ if opt.pattern:
318
+ anchored = _anchored_pattern(opt.pattern)
319
+ lines.append(f"_NERF_PATTERN='{_shell_escape_sq(anchored)}'")
320
+ lines.append(f'if [[ -n "${{{var}}}" ]] && ! [[ "${{{var}}}" =~ $_NERF_PATTERN ]]; then')
321
+ lines.append(f' echo "error: {tool_name}: option {opt.flag} does not match required pattern" >&2')
322
+ lines.append(f' echo " value: \\"${{{var}}}\\"" >&2')
323
+ lines.append(f' echo " pattern: {opt.pattern}" >&2')
324
+ lines.append(f' echo " hint: value must match {opt.pattern}" >&2')
325
+ lines.append(" exit 1")
326
+ lines.append("fi")
327
+ lines.append("")
328
+
329
+ if opt.allow:
330
+ allow_checks = " && ".join(f'"${{{var}}}" != "{_shell_escape_dq(v)}"' for v in opt.allow)
331
+ vals = ", ".join(opt.allow)
332
+ lines.append(f'if [[ -n "${{{var}}}" ]] && [[ {allow_checks} ]]; then')
333
+ lines.append(f' echo "error: {tool_name}: option {opt.flag} is not an allowed value" >&2')
334
+ lines.append(f' echo " value: \\"${{{var}}}\\"" >&2')
335
+ lines.append(f' echo " allowed: {_shell_escape_dq(vals)}" >&2')
336
+ lines.append(' echo " hint: use one of the allowed values" >&2')
337
+ lines.append(" exit 1")
338
+ lines.append("fi")
339
+ lines.append("")
340
+
341
+ if opt.deny:
342
+ for denied in opt.deny:
343
+ escaped = _shell_escape_dq(denied)
344
+ lines.append(f'if [[ "${{{var}}}" == "{escaped}" ]]; then')
345
+ lines.append(f' echo "error: {tool_name}: option {opt.flag} is not allowed" >&2')
346
+ lines.append(f' echo " value: \\"{escaped}\\"" >&2')
347
+ lines.append(f' echo " denied: {_shell_escape_dq(", ".join(opt.deny))}" >&2')
348
+ lines.append(' echo " hint: use a different value" >&2')
349
+ lines.append(" exit 1")
350
+ lines.append("fi")
351
+ lines.append("")
352
+
353
+ return "\n".join(lines).rstrip()
354
+
355
+
356
+ def _arg_validations(tool_name: str, arguments: dict[str, ArgSpec]) -> str:
357
+ lines: list[str] = []
358
+ for name, spec in arguments.items():
359
+ var = _var_name(name)
360
+
361
+ if spec.variadic:
362
+ if not spec.allow_flags:
363
+ lines.append(f'for _v in "${{{var}[@]}}"; do')
364
+ lines.append(' if [[ "$_v" == -* ]]; then')
365
+ lines.append(f" echo \"error: {tool_name}: <{name}> values cannot start with '-'\" >&2")
366
+ lines.append(' echo " hint: use -- before positional arguments if needed" >&2')
367
+ lines.append(" exit 1")
368
+ lines.append(" fi")
369
+ lines.append("done")
370
+ lines.append("")
371
+ if spec.required:
372
+ lines.append(f"if [[ ${{#{var}[@]}} -eq 0 ]]; then")
373
+ lines.append(f' echo "error: {tool_name}: missing required argument <{name}>" >&2')
374
+ lines.append(' echo " hint: provide at least one value" >&2')
375
+ lines.append(" usage")
376
+ lines.append("fi")
377
+ lines.append("")
378
+ if spec.pattern:
379
+ anchored = _anchored_pattern(spec.pattern)
380
+ lines.append(f"_NERF_PATTERN='{_shell_escape_sq(anchored)}'")
381
+ lines.append(f'for _v in "${{{var}[@]}}"; do')
382
+ lines.append(' if ! [[ "$_v" =~ $_NERF_PATTERN ]]; then')
383
+ lines.append(f' echo "error: {tool_name}: argument <{name}> does not match required pattern" >&2')
384
+ lines.append(' echo " value: \\"$_v\\"" >&2')
385
+ lines.append(f' echo " pattern: {spec.pattern}" >&2')
386
+ lines.append(f' echo " hint: value must match {spec.pattern}" >&2')
387
+ lines.append(" exit 1")
388
+ lines.append(" fi")
389
+ lines.append("done")
390
+ lines.append("")
391
+ if spec.allow:
392
+ allow_checks = " && ".join(f'"$_v" != "{_shell_escape_dq(v)}"' for v in spec.allow)
393
+ vals = ", ".join(spec.allow)
394
+ lines.append(f'for _v in "${{{var}[@]}}"; do')
395
+ lines.append(f" if [[ {allow_checks} ]]; then")
396
+ lines.append(f' echo "error: {tool_name}: argument <{name}> is not an allowed value" >&2')
397
+ lines.append(' echo " value: \\"$_v\\"" >&2')
398
+ lines.append(f' echo " allowed: {_shell_escape_dq(vals)}" >&2')
399
+ lines.append(' echo " hint: use one of the allowed values" >&2')
400
+ lines.append(" exit 1")
401
+ lines.append(" fi")
402
+ lines.append("done")
403
+ lines.append("")
404
+ if spec.deny:
405
+ lines.append(f'for _v in "${{{var}[@]}}"; do')
406
+ for denied in spec.deny:
407
+ escaped = _shell_escape_dq(denied)
408
+ lines.append(f' if [[ "$_v" == "{escaped}" ]]; then')
409
+ lines.append(f' echo "error: {tool_name}: argument <{name}> is not allowed" >&2')
410
+ lines.append(f' echo " value: \\"{escaped}\\"" >&2')
411
+ lines.append(f' echo " denied: {_shell_escape_dq(", ".join(spec.deny))}" >&2')
412
+ lines.append(' echo " hint: use a different value" >&2')
413
+ lines.append(" exit 1")
414
+ lines.append(" fi")
415
+ lines.append("done")
416
+ lines.append("")
417
+ else:
418
+ lines.append(f'if [[ -n "${{{var}}}" ]] && [[ "${{{var}}}" == -* ]]; then')
419
+ lines.append(f" echo \"error: {tool_name}: <{name}> cannot start with '-'\" >&2")
420
+ lines.append(' echo " hint: use -- before positional arguments if needed" >&2')
421
+ lines.append(" exit 1")
422
+ lines.append("fi")
423
+ lines.append("")
424
+ if spec.required:
425
+ lines.append(f'if [[ -z "${{{var}}}" ]]; then')
426
+ lines.append(f' echo "error: {tool_name}: missing required argument <{name}>" >&2')
427
+ lines.append(f' echo " hint: provide a value for <{name}>" >&2')
428
+ lines.append(" usage")
429
+ lines.append("fi")
430
+ lines.append("")
431
+ if spec.pattern:
432
+ anchored = _anchored_pattern(spec.pattern)
433
+ lines.append(f"_NERF_PATTERN='{_shell_escape_sq(anchored)}'")
434
+ lines.append(f'if [[ -n "${{{var}}}" ]] && ! [[ "${{{var}}}" =~ $_NERF_PATTERN ]]; then')
435
+ lines.append(f' echo "error: {tool_name}: argument <{name}> does not match required pattern" >&2')
436
+ lines.append(f' echo " value: \\"${{{var}}}\\"" >&2')
437
+ lines.append(f' echo " pattern: {spec.pattern}" >&2')
438
+ lines.append(f' echo " hint: value must match {spec.pattern}" >&2')
439
+ lines.append(" exit 1")
440
+ lines.append("fi")
441
+ lines.append("")
442
+ if spec.allow:
443
+ allow_checks = " && ".join(f'"${{{var}}}" != "{_shell_escape_dq(v)}"' for v in spec.allow)
444
+ vals = ", ".join(spec.allow)
445
+ lines.append(f'if [[ -n "${{{var}}}" ]] && [[ {allow_checks} ]]; then')
446
+ lines.append(f' echo "error: {tool_name}: argument <{name}> is not an allowed value" >&2')
447
+ lines.append(f' echo " value: \\"${{{var}}}\\"" >&2')
448
+ lines.append(f' echo " allowed: {_shell_escape_dq(vals)}" >&2')
449
+ lines.append(' echo " hint: use one of the allowed values" >&2')
450
+ lines.append(" exit 1")
451
+ lines.append("fi")
452
+ lines.append("")
453
+ if spec.deny:
454
+ for denied in spec.deny:
455
+ escaped = _shell_escape_dq(denied)
456
+ lines.append(f'if [[ "${{{var}}}" == "{escaped}" ]]; then')
457
+ lines.append(f' echo "error: {tool_name}: argument <{name}> is not allowed" >&2')
458
+ lines.append(f' echo " value: \\"{escaped}\\"" >&2')
459
+ lines.append(f' echo " denied: {_shell_escape_dq(", ".join(spec.deny))}" >&2')
460
+ lines.append(' echo " hint: use a different value" >&2')
461
+ lines.append(" exit 1")
462
+ lines.append("fi")
463
+ lines.append("")
464
+
465
+ return "\n".join(lines).rstrip()
466
+
467
+
468
+ # -- Environment ---------------------------------------------------------------
469
+
470
+
471
+ def _env_exports(env: dict[str, str]) -> str:
472
+ lines = []
473
+ for k, v in env.items():
474
+ lines.append(f"export {k}='{_shell_escape_sq(v)}'")
475
+ return "\n".join(lines)
476
+
477
+
478
+ # -- Guard checks --------------------------------------------------------------
479
+
480
+
481
+ def _guard_checks(tool_name: str, tool_spec: ToolSpec) -> str:
482
+ lines: list[str] = []
483
+ for guard in tool_spec.guards:
484
+ safe_msg = guard.fail_message.replace("'", "'\"'\"'")
485
+
486
+ if guard.command is not None:
487
+ cmd_args = _substitute_template_command(guard.command, tool_spec)
488
+ check = " ".join(cmd_args) + " > /dev/null 2>&1"
489
+ lines.append(f"{check} || {{ echo 'error: {tool_name}: {safe_msg}' >&2; exit 1; }}")
490
+ else:
491
+ script_text = _substitute_script(guard.script or "", tool_spec)
492
+ script_lines = script_text.strip().splitlines()
493
+ if len(script_lines) == 1:
494
+ lines.append(f"( {script_lines[0]} ) || {{ echo 'error: {tool_name}: {safe_msg}' >&2; exit 1; }}")
495
+ else:
496
+ lines.append("(")
497
+ for sl in script_lines:
498
+ lines.append(f" {sl}")
499
+ lines.append(f") || {{ echo 'error: {tool_name}: {safe_msg}' >&2; exit 1; }}")
500
+
501
+ return "\n".join(lines)
502
+
503
+
504
+ # -- Pre-hook ------------------------------------------------------------------
505
+
506
+
507
+ def _pre_hook(tool_name: str, tool_spec: ToolSpec) -> str:
508
+ pre_body = _substitute_script(tool_spec.pre or "", tool_spec)
509
+ lines = [
510
+ "_nerf_pre() {",
511
+ ]
512
+ for line in pre_body.strip().splitlines():
513
+ lines.append(f" {line}")
514
+ lines.append("}")
515
+ lines.append("")
516
+ lines.append("_nerf_pre_rc=0")
517
+ lines.append("_nerf_pre || _nerf_pre_rc=$?")
518
+ lines.append("if [ $_nerf_pre_rc -ne 0 ]; then")
519
+ lines.append(f' echo "error: {tool_name}: pre-hook failed (exit code $_nerf_pre_rc)" >&2')
520
+ lines.append(" exit $_nerf_pre_rc")
521
+ lines.append("fi")
522
+ return "\n".join(lines)
523
+
524
+
525
+ # -- Execution modes -----------------------------------------------------------
526
+
527
+
528
+ def _dry_run_check(tool_name: str, tool_spec: ToolSpec) -> str:
529
+ """Generate the --nerf-dry-run output block.
530
+
531
+ Only called for template and script modes. Passthrough mode handles
532
+ dry-run inline in _passthrough_exec (after the deny scan).
533
+ """
534
+ lines = ['if [[ "$_NERF_DRY_RUN" == "true" ]]; then']
535
+
536
+ if tool_spec.template is not None:
537
+ exec_args = _substitute_template_command(tool_spec.template.command, tool_spec)
538
+ if tool_spec.template.npm_pkgrun:
539
+ cmd = 'echo "dry-run: $_PKGRUN ' + " ".join(exec_args) + '"'
540
+ else:
541
+ cmd = 'echo "dry-run: ' + " ".join(exec_args) + '"'
542
+ lines.append(f" {cmd}")
543
+ else:
544
+ lines.append(f' echo "dry-run: {tool_name} would run inline script"')
545
+
546
+ lines.append(" exit 0")
547
+ lines.append("fi")
548
+ return "\n".join(lines)
549
+
550
+
551
+ def _template_exec(tool_spec: ToolSpec) -> str:
552
+ """Generate the exec line for template mode."""
553
+ assert tool_spec.template is not None
554
+ args = _substitute_template_command(tool_spec.template.command, tool_spec)
555
+ if tool_spec.template.npm_pkgrun:
556
+ return 'exec "$_PKGRUN" ' + " ".join(args)
557
+ return "exec " + " ".join(args)
558
+
559
+
560
+ def _passthrough_exec(tool_name: str, tool_spec: ToolSpec) -> str:
561
+ """Generate the deny scan and exec for passthrough mode."""
562
+ assert tool_spec.passthrough is not None
563
+ pt = tool_spec.passthrough
564
+ lines: list[str] = []
565
+
566
+ if pt.deny:
567
+ deny_items = " ".join(f"'{_shell_escape_sq(d)}'" for d in pt.deny)
568
+ lines.append(f"_NERF_DENY_PATTERNS=({deny_items})")
569
+ lines.append("")
570
+ lines.append('for _tok in "$@"; do')
571
+ lines.append(' for _pat in "${_NERF_DENY_PATTERNS[@]}"; do')
572
+ lines.append(' case "$_tok" in')
573
+ lines.append(" $_pat)")
574
+ lines.append(
575
+ f' echo "error: {tool_name}:'
576
+ " token '$_tok' is not allowed"
577
+ ' (matched deny pattern \'$_pat\')" >&2'
578
+ )
579
+ lines.append(' echo " denied patterns: ${_NERF_DENY_PATTERNS[*]}" >&2')
580
+ lines.append(' echo " hint: remove \'$_tok\' and retry" >&2')
581
+ lines.append(" exit 1")
582
+ lines.append(" ;;")
583
+ lines.append(" esac")
584
+ lines.append(" done")
585
+ lines.append("done")
586
+
587
+ exec_parts = [pt.command]
588
+ exec_parts.extend(f"'{_shell_escape_sq(p)}'" for p in pt.prefix)
589
+ exec_parts.append('"$@"')
590
+ exec_parts.extend(f"'{_shell_escape_sq(s)}'" for s in pt.suffix)
591
+ exec_str = " ".join(exec_parts)
592
+
593
+ if lines:
594
+ lines.append("")
595
+
596
+ # Dry-run check after deny scan but before exec
597
+ lines.append('if [[ "$_NERF_DRY_RUN" == "true" ]]; then')
598
+ lines.append(f' echo "dry-run: {exec_str}"')
599
+ lines.append(" exit 0")
600
+ lines.append("fi")
601
+ lines.append("")
602
+ lines.append("exec " + exec_str)
603
+
604
+ return "\n".join(lines)
605
+
606
+
607
+ # -- Substitution helpers ------------------------------------------------------
608
+
609
+
610
+ def _substitute_template_command(
611
+ command: tuple[str, ...],
612
+ tool: ToolSpec,
613
+ ) -> list[str]:
614
+ """Substitute {{param}} placeholders in a command word list.
615
+
616
+ Tokens that are exactly a placeholder get type-aware expansion (conditional
617
+ flags, array expansion, etc.). Tokens with inline placeholders (e.g. a URL
618
+ like "repos/{owner}/{repo}/pulls/{{arguments.pr}}/comments") get simple
619
+ variable substitution within a double-quoted string.
620
+ """
621
+ result: list[str] = []
622
+ for part in command:
623
+ m = PLACEHOLDER_RE.fullmatch(part)
624
+ if m:
625
+ # Whole-token placeholder: type-aware expansion
626
+ ref = m.group(1)
627
+ resolved = resolve_placeholder(ref, tool)
628
+ if resolved is None:
629
+ result.append(part)
630
+ continue
631
+ kind, name = resolved
632
+ var = _var_name(name)
633
+
634
+ if kind == "switches":
635
+ sw = tool.switches[name]
636
+ if sw.repeatable:
637
+ result.append(
638
+ "$(for _ in $(seq 1 $" + var + " 2>/dev/null); do"
639
+ f" echo -n '{_shell_escape_sq(sw.flag)} '; done)"
640
+ )
641
+ else:
642
+ result.append("${" + var + ':+"' + sw.flag + '"' + "}")
643
+
644
+ elif kind == "options":
645
+ opt = tool.options[name]
646
+ if opt.repeatable:
647
+ result.append("${" + var + '[@]+"${' + var + '[@]}"}')
648
+ elif opt.required:
649
+ result.append(f'"${{{var}}}"')
650
+ else:
651
+ result.append("${" + var + ':+"' + opt.flag + '"}')
652
+ result.append("${" + var + ':+"$' + var + '"}')
653
+
654
+ elif kind == "arguments":
655
+ spec = tool.arguments[name]
656
+ if spec.variadic:
657
+ if spec.required:
658
+ result.append(f'"${{{var}[@]}}"')
659
+ else:
660
+ result.append("${" + var + '[@]+"${' + var + '[@]}"}')
661
+ else:
662
+ if spec.required:
663
+ result.append(f'"${{{var}}}"')
664
+ else:
665
+ result.append("${" + var + ':+"$' + var + '"}')
666
+
667
+ elif PLACEHOLDER_RE.search(part):
668
+ # Inline placeholder: simple variable substitution in a quoted string
669
+ def _inline_replace(match: re.Match) -> str: # type: ignore[type-arg]
670
+ ref: str = match.group(1)
671
+ resolved = resolve_placeholder(ref, tool)
672
+ if resolved is None:
673
+ return str(match.group(0))
674
+ _kind, name = resolved
675
+ return "${" + _var_name(name) + "}"
676
+ result.append('"' + PLACEHOLDER_RE.sub(_inline_replace, part) + '"')
677
+
678
+ else:
679
+ result.append(part)
680
+ return result
681
+
682
+
683
+ def _substitute_script(script: str, tool: ToolSpec) -> str:
684
+ """Substitute {{param}} placeholders inline within a bash script string."""
685
+
686
+ def replace(m: re.Match) -> str: # type: ignore[type-arg]
687
+ ref: str = m.group(1)
688
+ resolved = resolve_placeholder(ref, tool)
689
+ if resolved is None:
690
+ return str(m.group(0))
691
+ _kind, name = resolved
692
+ return "${" + _var_name(name) + "}"
693
+
694
+ return PLACEHOLDER_RE.sub(replace, script)
695
+
696
+
697
+ def _npm_pkgrun_resolver() -> str:
698
+ """Generate a preamble that resolves the best npm package runner."""
699
+ return (
700
+ "# Resolve npm package runner\n"
701
+ '_PKGRUN=""\n'
702
+ "for _candidate in bunx pnpx npx; do\n"
703
+ ' if command -v "$_candidate" > /dev/null 2>&1; then\n'
704
+ ' _PKGRUN="$_candidate"\n'
705
+ " break\n"
706
+ " fi\n"
707
+ "done\n"
708
+ 'if [[ -z "$_PKGRUN" ]]; then\n'
709
+ ' echo "error: no npm package runner found (tried bunx, pnpx, npx)" >&2\n'
710
+ " exit 1\n"
711
+ "fi"
712
+ )
713
+
714
+
715
+ # -- Helpers -------------------------------------------------------------------
716
+
717
+
718
+ def _var_name(param_name: str) -> str:
719
+ return param_name.upper().replace("-", "_")
720
+
721
+
722
+ def _anchored_pattern(pattern: str) -> str:
723
+ """Ensure a regex pattern is anchored for full-match in bash =~."""
724
+ if not pattern.startswith("^"):
725
+ pattern = "^" + pattern
726
+ if not pattern.endswith("$"):
727
+ pattern = pattern + "$"
728
+ return pattern
729
+
730
+
731
+ def _shell_escape_dq(value: str) -> str:
732
+ """Escape a string for embedding in double-quoted bash strings."""
733
+ return (
734
+ value
735
+ .replace("\\", "\\\\")
736
+ .replace('"', '\\"')
737
+ .replace("$", "\\$")
738
+ .replace("`", "\\`")
739
+ .replace("\n", "\\n")
740
+ .replace("\r", "\\r")
741
+ )
742
+
743
+
744
+ def _shell_escape_sq(value: str) -> str:
745
+ """Escape a string for embedding in single-quoted bash strings."""
746
+ return value.replace("'", "'\"'\"'")