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/__init__.py +36 -0
- nerftools/builder.py +746 -0
- nerftools/cli.py +147 -0
- nerftools/default_manifests/README.md +15 -0
- nerftools/default_manifests/__init__.py +0 -0
- nerftools/default_manifests/az-boards.yaml +229 -0
- nerftools/default_manifests/az-pipelines.yaml +42 -0
- nerftools/default_manifests/az-repos.yaml +137 -0
- nerftools/default_manifests/gh.yaml +305 -0
- nerftools/default_manifests/git.yaml +202 -0
- nerftools/default_manifests/nx.yaml +88 -0
- nerftools/default_manifests/pkgrun.yaml +58 -0
- nerftools/default_manifests/stdutils.yaml +158 -0
- nerftools/default_manifests/tg.yaml +107 -0
- nerftools/default_manifests/uv.yaml +68 -0
- nerftools/formats.py +380 -0
- nerftools/manifest.py +696 -0
- nerftools/nerfctl/__init__.py +0 -0
- nerftools/nerfctl/claude/grant-allow.sh +170 -0
- nerftools/nerfctl/claude/grant-by-threat.sh +307 -0
- nerftools/nerfctl/claude/grant-deny.sh +164 -0
- nerftools/nerfctl/claude/grant-list.sh +107 -0
- nerftools/nerfctl/claude/grant-reset.sh +155 -0
- nerftools/nerfctl/claude/install-plugin.sh +57 -0
- nerftools/plugin_meta.py +200 -0
- nerftools/rendering.py +98 -0
- nerftools/skill.py +210 -0
- nerftools-0.3.1.dist-info/METADATA +13 -0
- nerftools-0.3.1.dist-info/RECORD +32 -0
- nerftools-0.3.1.dist-info/WHEEL +4 -0
- nerftools-0.3.1.dist-info/entry_points.txt +2 -0
- nerftools-0.3.1.dist-info/licenses/LICENSE +21 -0
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("'", "'\"'\"'")
|