-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcli.rugo
More file actions
473 lines (463 loc) · 19.7 KB
/
Copy pathcli.rugo
File metadata and controls
473 lines (463 loc) · 19.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
# cli.rugo — CLI argument parsing, help, and version for yolo.
#
# yolo's CLI parser is hand-rolled rather than using rugo's `cli` native
# module, because that module does not handle the bare `yolo -- cmd args`
# form (which is documented and well-used). See rugo-quirks.md for details.
#
# This module is otherwise a straight extraction of what used to live in
# yolo.rugo's top half. Functions take their few external dependencies
# (AI_AGENTS hash, VERSION string, DEFAULT_AI_AGENT name) as parameters
# rather than reaching back into yolo.rugo, because rugo modules can only
# see what they require — never the other direction.
use "os"
use "str"
use "color"
use "conv"
# ---------------- Logging ----------------
# Duplicated from yolo.rugo so this module can stand alone (same pattern
# the backend modules follow). Tag is kept identical to yolo.rugo's so
# error output looks uniform — the user shouldn't have to know whether a
# message came from the CLI layer or somewhere else.
def _log(msg)
prefix = color.dim("[yolo]")
puts "#{prefix} #{msg}"
end
def _warn(msg)
prefix = color.dim("[yolo]")
puts "#{prefix} #{color.yellow(msg)}"
end
def _err(msg)
prefix = color.dim("[yolo]")
puts "#{prefix} #{color.red(msg)}"
end
# ---------------- Disk size parsing ----------------
# Parse a human disk size like "32G", "32g", "32M", "32m" (with optional
# trailing "B"/"b" — so "32gb" / "32mb" also work), or a bare integer
# interpreted as MiB (e.g. "16384") for parity with $YOLO_DISK_MB. Returns
# the size as a MiB integer string, or "" if the input can't be parsed.
#
# Also used by yolofile.rugo for `memory:` / `disk-size:` front matter, where
# it is duplicated (small, pure, stable) to avoid a cross-module dependency.
def parse_disk_size(s)
if s == ""
return ""
end
lower = str.lower(s)
if str.ends_with(lower, "b")
lower = str.slice(lower, 0, len(lower) - 1)
end
multiplier = 1
unit_len = 0
if str.ends_with(lower, "g")
multiplier = 1024
unit_len = 1
elsif str.ends_with(lower, "m")
multiplier = 1
unit_len = 1
end
num_str = lower
if unit_len > 0
num_str = str.slice(lower, 0, len(lower) - unit_len)
end
if num_str == ""
return ""
end
n = try conv.to_i(num_str) or -1
if n <= 0
return ""
end
return conv.to_s(n * multiplier)
end
# ---------------- Port publishing ----------------
# A single TCP/UDP port number, 1..65535, digits only. Digits-only is also a
# security property: published port specs flow verbatim into the host-side
# `matchlock run -p …` / `podman run -p …` command lines, so rejecting
# anything but digits (and the single ':' separator below) blocks shell
# injection. Duplicated in yolofile.rugo (small, pure, stable) to keep the
# modules standalone, matching the parse_disk_size convention.
def _valid_port(s)
if s == "" || len(s) > 5
return false
end
for ch in str.chars(s)
if ch < "0" || ch > "9"
return false
end
end
n = try conv.to_i(s) or -1
if n < 1 || n > 65535
return false
end
return true
end
# Normalize a publish spec `[HOST_PORT:]GUEST_PORT` to its canonical
# `HOST_PORT:GUEST_PORT` form. A bare `PORT` expands to `PORT:PORT` so both
# backends bind a deterministic host port (podman would otherwise pick a
# random one). Returns "" if the spec is malformed (more than one ':' or any
# out-of-range / non-numeric port).
def normalize_publish(s)
t = str.trim(s)
if t == ""
return ""
end
idx = str.index(t, ":")
if idx < 0
if !_valid_port(t)
return ""
end
return t + ":" + t
end
host = str.slice(t, 0, idx)
guest = str.slice(t, idx + 1, len(t))
# Reject a second colon (host:guest:extra) — keeps the spec unambiguous and
# free of anything we'd have to escape.
if str.index(guest, ":") >= 0
return ""
end
if !_valid_port(host) || !_valid_port(guest)
return ""
end
return host + ":" + guest
end
# ---------------- Mount specs ----------------
# Parse a single bind-mount spec `HOST:GUEST[:MODE]` into its parts. Returns a
# hash {host, guest, mode, err}; `err` is non-empty (and the other fields "")
# on a malformed spec. Only the *syntax* is checked here — path resolution to
# absolute form, the matchlock-under-/work constraint, and the front-matter
# host-containment gate all live in yolo.rugo (which knows $PWD, the workspace,
# and whether the spec came from the trusted CLI or a Yolofile).
#
# `:` is the field separator, so host/guest paths containing `:` are
# unsupported (same limitation `docker -v` has). MODE is restricted to a fixed
# `ro`/`rw` allowlist so a value can never reach the host shell as free text.
# Duplicated in yolofile.rugo (small, pure, stable) to keep modules standalone,
# matching the normalize_publish / parse_disk_size convention.
def parse_mount(s)
bad = {host: "", guest: "", mode: "", err: ""}
t = str.trim(s)
if t == ""
bad.err = "empty mount spec"
return bad
end
parts = str.split(t, ":")
n = len(parts)
if n < 2 || n > 3
bad.err = "expected HOST:GUEST[:MODE]"
return bad
end
host = str.trim(parts[0])
guest = str.trim(parts[1])
mode = "rw"
if n == 3
mode = str.lower(str.trim(parts[2]))
end
if host == "" || guest == ""
bad.err = "HOST and GUEST must both be non-empty"
return bad
end
# Reject commas in paths: the container backend renders mounts as
# `--mount type=bind,source=HOST,target=GUEST`, where comma separates
# options — a path containing ',' could otherwise smuggle in extra mount
# options. ':' can't occur here (it's the field separator, already consumed
# by the split above).
if str.index(host, ",") >= 0 || str.index(guest, ",") >= 0
bad.err = "',' is not allowed in a mount path"
return bad
end
if mode != "ro" && mode != "rw"
bad.err = "MODE must be 'ro' or 'rw'"
return bad
end
return {host: host, guest: guest, mode: mode, err: ""}
end
# ---------------- Arg parsing ----------------
# Parse a flat argv into: subcommand, -n name, --provisioner, --yolofile,
# --ephemeral, --ai-agent, --backend, --gui / --no-gui, rest, -- passthrough.
#
# `ai_agents` is the yolo-side AI_AGENTS hash, used only for the
# `--ai-agent` optional-value heuristic (if the next arg matches a known
# agent name we consume it; otherwise we leave it for the subcommand /
# positional parser).
#
# `default_ai_agent` is the fallback used when `--ai-agent` is passed with
# no value (or with a value that isn't a known agent name — that path keeps
# the default rather than failing, by historical accident; kept for
# backward compatibility).
#
# `version` is the yolo VERSION string, printed by -V / --version.
def parse_args(args, ai_agents, default_ai_agent, version)
sub = ""
name = ""
prov = ""
no_prov = false
agent = ""
agent_set = false
rest = []
passthrough = []
in_passthrough = false
out_path = ""
force = false
disk_mb_override = ""
backend = ""
gui = false
gui_set = false
audio = false
audio_set = false
yolofile_path = ""
ephemeral = false
publish = []
mounts = []
allow_absolute_mounts = false
matchlock_kernel = ""
matchlock_privileged = false
i = 0
while i < len(args)
a = args[i]
if in_passthrough
passthrough = append(passthrough, a)
elsif a == "--"
in_passthrough = true
elsif a == "-n" || a == "--name"
i = i + 1
name = args[i]
elsif a == "--provisioner" || a == "-P"
i = i + 1
prov = args[i]
elsif a == "--yolofile"
i = i + 1
if i >= len(args)
_err("--yolofile requires a path or https:// URL")
os.exit(2)
end
yolofile_path = args[i]
elsif a == "--ephemeral"
ephemeral = true
elsif a == "--publish" || a == "-p"
i = i + 1
if i >= len(args)
_err("--publish requires a value (e.g. 8080 or 8080:80)")
os.exit(2)
end
spec = normalize_publish(args[i])
if spec == ""
_err("invalid --publish value: '#{args[i]}' (expected [HOST:]GUEST, each port 1-65535)")
os.exit(2)
end
publish = append(publish, spec)
elsif a == "--mount"
i = i + 1
if i >= len(args)
_err("--mount requires a value (HOST:GUEST[:MODE])")
os.exit(2)
end
m = parse_mount(args[i])
if m.err != ""
_err("invalid --mount value: '#{args[i]}' (#{m.err})")
os.exit(2)
end
mounts = append(mounts, {host: m.host, guest: m.guest, mode: m.mode})
elsif a == "--allow-absolute-mounts"
allow_absolute_mounts = true
elsif a == "--no-provision" || a == "--no-provisioner"
no_prov = true
elsif a == "-o" || a == "--output"
i = i + 1
out_path = args[i]
elsif a == "--force"
force = true
elsif a == "--backend" || a == "-b"
i = i + 1
if i >= len(args)
_err("--backend requires a value (matchlock, podman or container)")
os.exit(2)
end
backend = args[i]
elsif a == "--matchlock-privileged"
matchlock_privileged = true
elsif a == "--matchlock-kernel"
i = i + 1
if i >= len(args)
_err("--matchlock-kernel requires a ref (file:///abs/path or an OCI image)")
os.exit(2)
end
matchlock_kernel = args[i]
elsif a == "--gui"
gui = true
gui_set = true
elsif a == "--no-gui"
gui = false
gui_set = true
elsif a == "--audio"
audio = true
audio_set = true
elsif a == "--no-audio"
audio = false
audio_set = true
elsif a == "--disk-size"
i = i + 1
if i >= len(args)
_err("--disk-size requires a value (e.g. 32G, 512M, or a bare MiB integer)")
os.exit(2)
end
parsed_sz = parse_disk_size(args[i])
if parsed_sz == ""
_err("invalid --disk-size value: '#{args[i]}' (expected e.g. 32G, 32g, 512M, 512m, or a bare MiB integer)")
os.exit(2)
end
disk_mb_override = parsed_sz
elsif a == "--ai-agent"
# Optional value: if the next arg exists and isn't another flag /
# subcommand, consume it as the agent name; otherwise default.
agent_set = true
nxt = ""
if i + 1 < len(args)
nxt = args[i + 1]
end
if nxt != "" && !str.starts_with(nxt, "-") && nxt != "--"
# Heuristic: treat as a value only if it matches a known agent name.
# Otherwise it's a subcommand or positional and we keep the default.
if ai_agents[nxt] != nil
agent = nxt
i = i + 1
else
agent = default_ai_agent
end
else
agent = default_ai_agent
end
elsif a == "--no-ai-agent"
agent = ""
agent_set = true
elsif a == "-h" || a == "--help"
print_help(default_ai_agent)
os.exit(0)
elsif a == "-V" || a == "--version"
print_version(version)
os.exit(0)
elsif sub == "" && (a == "ls" || a == "du" || a == "stop" || a == "rm" || a == "logs" || a == "id" || a == "status" || a == "prune" || a == "provision" || a == "provisioners" || a == "export" || a == "import" || a == "install-skills")
sub = a
else
rest = append(rest, a)
end
i = i + 1
end
return _result(sub, name, prov, no_prov, agent, agent_set, rest, passthrough, out_path, force, disk_mb_override, backend, gui, gui_set, yolofile_path, ephemeral, audio, audio_set, publish, mounts, allow_absolute_mounts, matchlock_kernel, matchlock_privileged)
end
# Build the parsed-args hash. Factored out only because parse_args has two
# return points (the -h sentinel path and the normal end-of-loop path) and
# duplicating the 15-field literal twice is asking for drift.
def _result(sub, name, prov, no_prov, agent, agent_set, rest, passthrough, out_path, force, disk_mb, backend, gui, gui_set, yolofile_path, ephemeral, audio, audio_set, publish, mounts, allow_absolute_mounts, matchlock_kernel, matchlock_privileged)
return {sub: sub, name: name, prov: prov, no_prov: no_prov, agent: agent, agent_set: agent_set, rest: rest, passthrough: passthrough, out_path: out_path, force: force, disk_mb: disk_mb, backend: backend, gui: gui, gui_set: gui_set, yolofile_path: yolofile_path, ephemeral: ephemeral, audio: audio, audio_set: audio_set, publish: publish, mounts: mounts, allow_absolute_mounts: allow_absolute_mounts, matchlock_kernel: matchlock_kernel, matchlock_privileged: matchlock_privileged}
end
# ---------------- Help / version ----------------
def print_version(version)
puts "yolo #{version}"
end
def print_help(default_ai_agent)
puts "yolo — fast persistent per-directory dev environments (matchlock VMs, podman or Apple containers)."
puts ""
puts "Usage:"
puts " yolo Ensure VM, auto-provision (once), shell in."
puts " yolo -- CMD ARGS... Run CMD inside the VM."
puts " yolo --provisioner NAME [...] Use a specific provisioner (overrides Yolofile)."
puts " yolo --yolofile PATH|URL [...] Use a Yolofile from a local path or https URL."
puts " yolo --ephemeral [...] Use a throwaway VM and empty temp workspace."
puts " yolo --no-provision [...] Skip auto-provisioning (also: --no-provisioner)."
puts " yolo --ai-agent [NAME] [...] Also install an AI agent (default: #{default_ai_agent})."
puts " Known agents: copilot, opencode."
puts " yolo --backend NAME [...] Pick a backend: matchlock (microVM; Linux"
puts " default, also runs on macOS/Apple Silicon),"
puts " podman (container, GUI/audio capable) or"
puts " container (Apple `container`, macOS default)."
puts " The binding is sticky: once a VM is created the"
puts " backend is recorded and re-used on attach."
puts " yolo --matchlock-privileged [...]"
puts " (matchlock) Run the VM privileged so it can"
puts " host Podman/Docker. Downloads a container-"
puts " ready guest kernel on first use. Also"
puts " YOLO_MATCHLOCK_PRIVILEGED=1."
puts " yolo --matchlock-kernel REF [...]"
puts " (matchlock) Boot a specific guest kernel"
puts " (file:///abs/path or an OCI ref) instead of"
puts " the auto-downloaded one. Also YOLO_MATCHLOCK_KERNEL."
puts " yolo --gui [...] Bind-mount the host Wayland socket into the"
puts " guest so graphical apps render on your"
puts " compositor. Requires --backend podman."
puts " yolo --audio [...] Bind-mount the host PipeWire/PulseAudio"
puts " socket into the guest so apps can play"
puts " sound. Requires --backend podman."
puts " yolo -n NAME [...] Use a named VM."
puts " yolo --publish [HOST:]GUEST [...]"
puts " Publish a guest port to the host (bound to"
puts " 127.0.0.1). Repeatable; also -p. A bare PORT"
puts " maps PORT:PORT. Applied at VM creation; the"
puts " guest service must listen on 0.0.0.0."
puts " yolo --mount HOST:GUEST[:MODE] [...]"
puts " Bind-mount an extra host dir into the guest"
puts " (on top of $PWD at /work). Repeatable. MODE"
puts " is ro|rw (default rw). A relative GUEST lands"
puts " under /work; absolute guest paths need the"
puts " podman/container backend. Applied at VM"
puts " creation."
puts " yolo --allow-absolute-mounts [...]"
puts " Permit a Yolofile 'mount:' whose host path"
puts " resolves outside the project dir. CLI --mount"
puts " paths are always allowed."
puts " yolo --disk-size SIZE [...] Override rootfs disk size for this run."
puts " Accepts 32G, 32g, 512M, 512m, or a bare"
puts " MiB integer. Takes effect when the VM is"
puts " first created."
puts " yolo -V, --version Print yolo version and exit."
puts " yolo -h, --help Print this help and exit."
puts " yolo ls List tracked VMs with live status."
puts " yolo du List tracked VMs with disk usage."
puts " yolo stop [-n NAME] Stop VM (podman/container: preserves state)."
puts " yolo rm [-n NAME] Stop + remove VM and binding."
puts " yolo logs [-n NAME] Show VM log."
puts " yolo id [-n NAME] Print vm-id."
puts " yolo status [-n NAME] Print state + vm-id + applied provisioners."
puts " yolo prune Drop dead name bindings."
puts " yolo provision [--provisioner NAME] [-n NAME]"
puts " Force re-apply a provisioner."
puts " yolo provisioners List provisioners (embedded + ./Yolofile)."
puts " yolo install-skills Install bundled agent skills into"
puts " ~/.agents/skills (overwrites existing)."
puts ""
puts " yolo export [-n NAME] [-o FILE] Export VM rootfs + state into a single"
puts " .tar.gz (matchlock backend only)."
puts " yolo import FILE [-n NAME] [--force]"
puts " Import an export bundle; pins a custom"
puts " matchlock image so the first `yolo -n NAME`"
puts " boots from the captured rootfs."
puts ""
puts "Provisioner resolution (in order):"
puts " 1. --provisioner NAME (explicit)"
puts " 2. ./Yolofile (if present)"
puts " 3. Auto-detected from $PWD:"
puts " go.mod / go.sum / *.go → fedora-go"
puts " Cargo.toml / rust-toolchain.toml → fedora-rust"
puts " Gemfile / *.gemspec / .ruby-version → fedora-ruby"
puts " build.gradle[.kts] / settings.gradle → fedora-android"
puts " 4. Otherwise: no provisioner runs."
puts ""
puts "Yolofile:"
puts " A plain bash script (run as root in the VM). Edit-and-rerun"
puts " automatically re-provisions (content-hashed for the marker)."
puts " May begin with a YAML-style '---' front matter block declaring"
puts " VM-creation overrides (image, cpus, memory, disk-size, backend,"
puts " gui, audio, privileged, publish, mount). See docs/05-yolofile.md for the full format."
puts ""
puts "Env vars (defaults):"
puts " YOLO_IMAGE=fedora:44 OCI image (default depends on backend)"
puts " YOLO_CPUS=2 vCPU count"
puts " YOLO_MEM_MB=2048 Memory (MiB)"
puts " YOLO_DISK_MB=32768 Rootfs disk (MiB) (matchlock only)"
puts " YOLO_WORKSPACE=/work Guest mount point for $PWD"
puts " YOLO_ALLOW= Comma list of allow-listed hosts (enables MITM)"
puts " YOLO_USER= Run as uid:gid inside guest"
puts " YOLO_NAME= Override the auto-derived per-CWD name"
puts " YOLO_BACKEND= Default backend (matchlock/Linux, container/macOS)"
puts " YOLO_CONTAINER_DNS=1.1.1.1 Nameserver(s) for the container backend (space-sep)"
puts " YOLO_MATCHLOCK_PRIVILEGED= Run matchlock VMs privileged (host Podman/Docker)"
puts " YOLO_MATCHLOCK_KERNEL= Custom matchlock guest kernel (file://path or ref)"
end