on main help sign in
Merge pull request #2 from robcholz/setup-cli-cmd

Setup cli cmd

commit1451c31a4e24d58f7b8edd966493f94ae4433d7e authorFinn Sheng <yupbrokendream@gmail.com> dateSat 7 Feb 2026, 16:37 (8 months ago) parent87f99f2fa22141
12 files changed+1233−536
Cargo.lock+169−244 Cargo.toml+2−5 docs/tasks.md+6−2 src/bin/vibebox-cli.rs+300−29 src/bin/vibebox-supervisor.rs+9−8 src/config.rs+276−16 src/instance.rs+24−7 src/session_manager.rs+116−16 src/tui.rs+217−1 src/vm.rs+44−197 src/vm_manager.rs+60−11 vibebox.toml+10
Cargo.lock+169−244
@@ -32,6 +32,56 @@ version = "0.2.21"
3232source = "registry+https://github.com/rust-lang/crates.io-index"
3333checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
3434
35+[[package]]
36+name = "anstream"
37+version = "0.6.21"
38+source = "registry+https://github.com/rust-lang/crates.io-index"
39+checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
40+dependencies = [
41+ "anstyle",
42+ "anstyle-parse",
43+ "anstyle-query",
44+ "anstyle-wincon",
45+ "colorchoice",
46+ "is_terminal_polyfill",
47+ "utf8parse",
48+]
49+
50+[[package]]
51+name = "anstyle"
52+version = "1.0.13"
53+source = "registry+https://github.com/rust-lang/crates.io-index"
54+checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
55+
56+[[package]]
57+name = "anstyle-parse"
58+version = "0.2.7"
59+source = "registry+https://github.com/rust-lang/crates.io-index"
60+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
61+dependencies = [
62+ "utf8parse",
63+]
64+
65+[[package]]
66+name = "anstyle-query"
67+version = "1.1.5"
68+source = "registry+https://github.com/rust-lang/crates.io-index"
69+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
70+dependencies = [
71+ "windows-sys 0.61.2",
72+]
73+
74+[[package]]
75+name = "anstyle-wincon"
76+version = "3.0.11"
77+source = "registry+https://github.com/rust-lang/crates.io-index"
78+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
79+dependencies = [
80+ "anstyle",
81+ "once_cell_polyfill",
82+ "windows-sys 0.61.2",
83+]
84+
3585[[package]]
3686name = "backtrace"
3787version = "0.3.76"
@@ -68,12 +118,6 @@ version = "3.19.1"
68118source = "registry+https://github.com/rust-lang/crates.io-index"
69119checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
70120
71−[[package]]
72−name = "bytes"
73−version = "1.11.1"
74−source = "registry+https://github.com/rust-lang/crates.io-index"
75−checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
76−
77121[[package]]
78122name = "cassowary"
79123version = "0.3.0"
@@ -95,6 +139,46 @@ version = "1.0.4"
95139source = "registry+https://github.com/rust-lang/crates.io-index"
96140checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
97141
142+[[package]]
143+name = "clap"
144+version = "4.5.57"
145+source = "registry+https://github.com/rust-lang/crates.io-index"
146+checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a"
147+dependencies = [
148+ "clap_builder",
149+ "clap_derive",
150+]
151+
152+[[package]]
153+name = "clap_builder"
154+version = "4.5.57"
155+source = "registry+https://github.com/rust-lang/crates.io-index"
156+checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238"
157+dependencies = [
158+ "anstream",
159+ "anstyle",
160+ "clap_lex",
161+ "strsim",
162+]
163+
164+[[package]]
165+name = "clap_derive"
166+version = "4.5.55"
167+source = "registry+https://github.com/rust-lang/crates.io-index"
168+checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
169+dependencies = [
170+ "heck",
171+ "proc-macro2",
172+ "quote",
173+ "syn",
174+]
175+
176+[[package]]
177+name = "clap_lex"
178+version = "0.7.7"
179+source = "registry+https://github.com/rust-lang/crates.io-index"
180+checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
181+
98182[[package]]
99183name = "color-eyre"
100184version = "0.6.5"
@@ -122,6 +206,12 @@ dependencies = [
122206 "tracing-error",
123207]
124208
209+[[package]]
210+name = "colorchoice"
211+version = "1.0.4"
212+source = "registry+https://github.com/rust-lang/crates.io-index"
213+checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
214+
125215[[package]]
126216name = "compact_str"
127217version = "0.8.1"
@@ -136,6 +226,19 @@ dependencies = [
136226 "static_assertions",
137227]
138228
229+[[package]]
230+name = "console"
231+version = "0.16.2"
232+source = "registry+https://github.com/rust-lang/crates.io-index"
233+checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4"
234+dependencies = [
235+ "encode_unicode",
236+ "libc",
237+ "once_cell",
238+ "unicode-width 0.2.0",
239+ "windows-sys 0.61.2",
240+]
241+
139242[[package]]
140243name = "crossterm"
141244version = "0.28.1"
@@ -206,6 +309,18 @@ dependencies = [
206309 "serde_core",
207310]
208311
312+[[package]]
313+name = "dialoguer"
314+version = "0.12.0"
315+source = "registry+https://github.com/rust-lang/crates.io-index"
316+checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96"
317+dependencies = [
318+ "console",
319+ "shell-words",
320+ "tempfile",
321+ "zeroize",
322+]
323+
209324[[package]]
210325name = "dispatch2"
211326version = "0.3.0"
@@ -224,6 +339,12 @@ version = "1.15.0"
224339source = "registry+https://github.com/rust-lang/crates.io-index"
225340checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
226341
342+[[package]]
343+name = "encode_unicode"
344+version = "1.0.0"
345+source = "registry+https://github.com/rust-lang/crates.io-index"
346+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
347+
227348[[package]]
228349name = "equivalent"
229350version = "1.0.2"
@@ -262,95 +383,12 @@ version = "0.1.5"
262383source = "registry+https://github.com/rust-lang/crates.io-index"
263384checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
264385
265−[[package]]
266−name = "futures"
267−version = "0.3.31"
268−source = "registry+https://github.com/rust-lang/crates.io-index"
269−checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
270−dependencies = [
271− "futures-channel",
272− "futures-core",
273− "futures-executor",
274− "futures-io",
275− "futures-sink",
276− "futures-task",
277− "futures-util",
278−]
279−
280−[[package]]
281−name = "futures-channel"
282−version = "0.3.31"
283−source = "registry+https://github.com/rust-lang/crates.io-index"
284−checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
285−dependencies = [
286− "futures-core",
287− "futures-sink",
288−]
289−
290386[[package]]
291387name = "futures-core"
292388version = "0.3.31"
293389source = "registry+https://github.com/rust-lang/crates.io-index"
294390checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
295391
296−[[package]]
297−name = "futures-executor"
298−version = "0.3.31"
299−source = "registry+https://github.com/rust-lang/crates.io-index"
300−checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
301−dependencies = [
302− "futures-core",
303− "futures-task",
304− "futures-util",
305−]
306−
307−[[package]]
308−name = "futures-io"
309−version = "0.3.31"
310−source = "registry+https://github.com/rust-lang/crates.io-index"
311−checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
312−
313−[[package]]
314−name = "futures-macro"
315−version = "0.3.31"
316−source = "registry+https://github.com/rust-lang/crates.io-index"
317−checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
318−dependencies = [
319− "proc-macro2",
320− "quote",
321− "syn",
322−]
323−
324−[[package]]
325−name = "futures-sink"
326−version = "0.3.31"
327−source = "registry+https://github.com/rust-lang/crates.io-index"
328−checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
329−
330−[[package]]
331−name = "futures-task"
332−version = "0.3.31"
333−source = "registry+https://github.com/rust-lang/crates.io-index"
334−checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
335−
336−[[package]]
337−name = "futures-util"
338−version = "0.3.31"
339−source = "registry+https://github.com/rust-lang/crates.io-index"
340−checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
341−dependencies = [
342− "futures-channel",
343− "futures-core",
344− "futures-io",
345− "futures-macro",
346− "futures-sink",
347− "futures-task",
348− "memchr",
349− "pin-project-lite",
350− "pin-utils",
351− "slab",
352−]
353−
354392[[package]]
355393name = "getrandom"
356394version = "0.3.4"
@@ -436,6 +474,12 @@ dependencies = [
436474 "syn",
437475]
438476
477+[[package]]
478+name = "is_terminal_polyfill"
479+version = "1.70.2"
480+source = "registry+https://github.com/rust-lang/crates.io-index"
481+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
482+
439483[[package]]
440484name = "itertools"
441485version = "0.13.0"
@@ -467,12 +511,6 @@ version = "1.5.0"
467511source = "registry+https://github.com/rust-lang/crates.io-index"
468512checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
469513
470−[[package]]
471−name = "lexopt"
472−version = "0.3.1"
473−source = "registry+https://github.com/rust-lang/crates.io-index"
474−checksum = "9fa0e2a1fcbe2f6be6c42e342259976206b383122fc152e872795338b5a3f3a7"
475−
476514[[package]]
477515name = "libc"
478516version = "0.2.180"
@@ -557,7 +595,7 @@ version = "0.50.3"
557595source = "registry+https://github.com/rust-lang/crates.io-index"
558596checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
559597dependencies = [
560− "windows-sys 0.59.0",
598+ "windows-sys 0.61.2",
561599]
562600
563601[[package]]
@@ -646,6 +684,12 @@ version = "1.21.3"
646684source = "registry+https://github.com/rust-lang/crates.io-index"
647685checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
648686
687+[[package]]
688+name = "once_cell_polyfill"
689+version = "1.70.2"
690+source = "registry+https://github.com/rust-lang/crates.io-index"
691+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
692+
649693[[package]]
650694name = "owo-colors"
651695version = "4.2.3"
@@ -687,12 +731,6 @@ version = "0.2.16"
687731source = "registry+https://github.com/rust-lang/crates.io-index"
688732checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
689733
690−[[package]]
691−name = "pin-utils"
692−version = "0.1.0"
693−source = "registry+https://github.com/rust-lang/crates.io-index"
694−checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
695−
696734[[package]]
697735name = "powerfmt"
698736version = "0.2.0"
@@ -868,6 +906,12 @@ dependencies = [
868906 "lazy_static",
869907]
870908
909+[[package]]
910+name = "shell-words"
911+version = "1.1.1"
912+source = "registry+https://github.com/rust-lang/crates.io-index"
913+checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
914+
871915[[package]]
872916name = "signal-hook"
873917version = "0.3.18"
@@ -899,28 +943,12 @@ dependencies = [
899943 "libc",
900944]
901945
902−[[package]]
903−name = "slab"
904−version = "0.4.12"
905−source = "registry+https://github.com/rust-lang/crates.io-index"
906−checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
907−
908946[[package]]
909947name = "smallvec"
910948version = "1.15.1"
911949source = "registry+https://github.com/rust-lang/crates.io-index"
912950checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
913951
914−[[package]]
915−name = "socket2"
916−version = "0.6.2"
917−source = "registry+https://github.com/rust-lang/crates.io-index"
918−checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
919−dependencies = [
920− "libc",
921− "windows-sys 0.60.2",
922−]
923−
924952[[package]]
925953name = "static_assertions"
926954version = "1.1.0"
@@ -1039,34 +1067,6 @@ dependencies = [
10391067 "time-core",
10401068]
10411069
1042−[[package]]
1043−name = "tokio"
1044−version = "1.49.0"
1045−source = "registry+https://github.com/rust-lang/crates.io-index"
1046−checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
1047−dependencies = [
1048− "bytes",
1049− "libc",
1050− "mio",
1051− "parking_lot",
1052− "pin-project-lite",
1053− "signal-hook-registry",
1054− "socket2",
1055− "tokio-macros",
1056− "windows-sys 0.61.2",
1057−]
1058−
1059−[[package]]
1060−name = "tokio-macros"
1061−version = "2.6.0"
1062−source = "registry+https://github.com/rust-lang/crates.io-index"
1063−checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
1064−dependencies = [
1065− "proc-macro2",
1066− "quote",
1067− "syn",
1068−]
1069−
10701070[[package]]
10711071name = "toml"
10721072version = "0.9.11+spec-1.1.0"
@@ -1177,16 +1177,6 @@ dependencies = [
11771177 "tracing-log",
11781178]
11791179
1180−[[package]]
1181−name = "tui-textarea"
1182−version = "0.7.0"
1183−source = "registry+https://github.com/rust-lang/crates.io-index"
1184−checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae"
1185−dependencies = [
1186− "ratatui",
1187− "unicode-width 0.2.0",
1188−]
1189−
11901180[[package]]
11911181name = "unicode-ident"
11921182version = "1.0.22"
@@ -1222,6 +1212,12 @@ version = "0.2.0"
12221212source = "registry+https://github.com/rust-lang/crates.io-index"
12231213checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
12241214
1215+[[package]]
1216+name = "utf8parse"
1217+version = "0.2.2"
1218+source = "registry+https://github.com/rust-lang/crates.io-index"
1219+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
1220+
12251221[[package]]
12261222name = "uuid"
12271223version = "1.20.0"
@@ -1245,11 +1241,11 @@ name = "vibebox"
12451241version = "0.1.0"
12461242dependencies = [
12471243 "block2",
1244+ "clap",
12481245 "color-eyre",
12491246 "crossterm",
1247+ "dialoguer",
12501248 "dispatch2",
1251− "futures",
1252− "lexopt",
12531249 "libc",
12541250 "objc2",
12551251 "objc2-foundation",
@@ -1259,12 +1255,9 @@ dependencies = [
12591255 "tempfile",
12601256 "thiserror",
12611257 "time",
1262− "tokio",
12631258 "toml",
12641259 "tracing",
12651260 "tracing-subscriber",
1266− "tui-textarea",
1267− "unicode-width 0.2.0",
12681261 "uuid",
12691262]
12701263
@@ -1362,16 +1355,7 @@ version = "0.59.0"
13621355source = "registry+https://github.com/rust-lang/crates.io-index"
13631356checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
13641357dependencies = [
1365− "windows-targets 0.52.6",
1366−]
1367−
1368−[[package]]
1369−name = "windows-sys"
1370−version = "0.60.2"
1371−source = "registry+https://github.com/rust-lang/crates.io-index"
1372−checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
1373−dependencies = [
1374− "windows-targets 0.53.5",
1358+ "windows-targets",
13751359]
13761360
13771361[[package]]
@@ -1389,31 +1373,14 @@ version = "0.52.6"
13891373source = "registry+https://github.com/rust-lang/crates.io-index"
13901374checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
13911375dependencies = [
1392− "windows_aarch64_gnullvm 0.52.6",
1393− "windows_aarch64_msvc 0.52.6",
1394− "windows_i686_gnu 0.52.6",
1395− "windows_i686_gnullvm 0.52.6",
1396− "windows_i686_msvc 0.52.6",
1397− "windows_x86_64_gnu 0.52.6",
1398− "windows_x86_64_gnullvm 0.52.6",
1399− "windows_x86_64_msvc 0.52.6",
1400−]
1401−
1402−[[package]]
1403−name = "windows-targets"
1404−version = "0.53.5"
1405−source = "registry+https://github.com/rust-lang/crates.io-index"
1406−checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
1407−dependencies = [
1408− "windows-link",
1409− "windows_aarch64_gnullvm 0.53.1",
1410− "windows_aarch64_msvc 0.53.1",
1411− "windows_i686_gnu 0.53.1",
1412− "windows_i686_gnullvm 0.53.1",
1413− "windows_i686_msvc 0.53.1",
1414− "windows_x86_64_gnu 0.53.1",
1415− "windows_x86_64_gnullvm 0.53.1",
1416− "windows_x86_64_msvc 0.53.1",
1376+ "windows_aarch64_gnullvm",
1377+ "windows_aarch64_msvc",
1378+ "windows_i686_gnu",
1379+ "windows_i686_gnullvm",
1380+ "windows_i686_msvc",
1381+ "windows_x86_64_gnu",
1382+ "windows_x86_64_gnullvm",
1383+ "windows_x86_64_msvc",
14171384]
14181385
14191386[[package]]
@@ -1422,96 +1389,48 @@ version = "0.52.6"
14221389source = "registry+https://github.com/rust-lang/crates.io-index"
14231390checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
14241391
1425−[[package]]
1426−name = "windows_aarch64_gnullvm"
1427−version = "0.53.1"
1428−source = "registry+https://github.com/rust-lang/crates.io-index"
1429−checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
1430−
14311392[[package]]
14321393name = "windows_aarch64_msvc"
14331394version = "0.52.6"
14341395source = "registry+https://github.com/rust-lang/crates.io-index"
14351396checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
14361397
1437−[[package]]
1438−name = "windows_aarch64_msvc"
1439−version = "0.53.1"
1440−source = "registry+https://github.com/rust-lang/crates.io-index"
1441−checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
1442−
14431398[[package]]
14441399name = "windows_i686_gnu"
14451400version = "0.52.6"
14461401source = "registry+https://github.com/rust-lang/crates.io-index"
14471402checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
14481403
1449−[[package]]
1450−name = "windows_i686_gnu"
1451−version = "0.53.1"
1452−source = "registry+https://github.com/rust-lang/crates.io-index"
1453−checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
1454−
14551404[[package]]
14561405name = "windows_i686_gnullvm"
14571406version = "0.52.6"
14581407source = "registry+https://github.com/rust-lang/crates.io-index"
14591408checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
14601409
1461−[[package]]
1462−name = "windows_i686_gnullvm"
1463−version = "0.53.1"
1464−source = "registry+https://github.com/rust-lang/crates.io-index"
1465−checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
1466−
14671410[[package]]
14681411name = "windows_i686_msvc"
14691412version = "0.52.6"
14701413source = "registry+https://github.com/rust-lang/crates.io-index"
14711414checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
14721415
1473−[[package]]
1474−name = "windows_i686_msvc"
1475−version = "0.53.1"
1476−source = "registry+https://github.com/rust-lang/crates.io-index"
1477−checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
1478−
14791416[[package]]
14801417name = "windows_x86_64_gnu"
14811418version = "0.52.6"
14821419source = "registry+https://github.com/rust-lang/crates.io-index"
14831420checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
14841421
1485−[[package]]
1486−name = "windows_x86_64_gnu"
1487−version = "0.53.1"
1488−source = "registry+https://github.com/rust-lang/crates.io-index"
1489−checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
1490−
14911422[[package]]
14921423name = "windows_x86_64_gnullvm"
14931424version = "0.52.6"
14941425source = "registry+https://github.com/rust-lang/crates.io-index"
14951426checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
14961427
1497−[[package]]
1498−name = "windows_x86_64_gnullvm"
1499−version = "0.53.1"
1500−source = "registry+https://github.com/rust-lang/crates.io-index"
1501−checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
1502−
15031428[[package]]
15041429name = "windows_x86_64_msvc"
15051430version = "0.52.6"
15061431source = "registry+https://github.com/rust-lang/crates.io-index"
15071432checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
15081433
1509−[[package]]
1510−name = "windows_x86_64_msvc"
1511−version = "0.53.1"
1512−source = "registry+https://github.com/rust-lang/crates.io-index"
1513−checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
1514−
15151434[[package]]
15161435name = "winnow"
15171436version = "0.7.14"
@@ -1523,3 +1442,9 @@ name = "wit-bindgen"
15231442version = "0.51.0"
15241443source = "registry+https://github.com/rust-lang/crates.io-index"
15251444checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
1445+
1446+[[package]]
1447+name = "zeroize"
1448+version = "1.8.2"
1449+source = "registry+https://github.com/rust-lang/crates.io-index"
1450+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
Cargo.toml+2−5
@@ -20,7 +20,7 @@ objc2-virtualization = "*"
2020block2 = "*"
2121dispatch2 = "*"
2222libc = "*"
23−lexopt = "0.3"
23+clap = { version = "4", features = ["derive"] }
2424serde = { version = "1", features = ["derive"] }
2525tempfile = "3"
2626thiserror = "2.0.18"
@@ -29,10 +29,7 @@ toml = "0.9.8"
2929uuid = { version = "1", features = ["v7", "serde"] }
3030color-eyre = "0.6.3"
3131crossterm = { version = "0.28.1", features = ["event-stream"] }
32−futures = "0.3.31"
3332ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] }
34−tokio = { version = "1.40.0", features = ["full"] }
35−tui-textarea = { version = "0.7.0", default-features = false, features = ["ratatui"] }
36−unicode-width = "0.2"
3733tracing = "0.1"
3834tracing-subscriber = { version = "0.3", features = ["env-filter"] }
35+dialoguer = "0.12.0"
docs/tasks.md+6−2
@@ -42,8 +42,12 @@
42425. [x] wire up SessionManager.
43436. [x] VM should be separated by a per-session VM daemon process (only accepts if to shut down vm and itself).
44447. [x] setup vibebox commands
45−8. [ ] setup cli commands.
46−9. [ ] fix ui overlap.
45+8. [x] setup cli commands.
46+ 1. [x] Organize all the params.
47+ 2. [x] Remove old cli.
48+ 3. [x] add an actual config file.
49+ 4. [x] set up the cli.
50+9. [ ] fix ui overlap, and consistency issue.
475110. [ ] intensive integration test.
4852
4953## Publish
src/bin/vibebox-cli.rs+300−29
@@ -2,30 +2,64 @@ use std::{
22 env,
33 ffi::OsString,
44 io::{self, IsTerminal, Write},
5+ path::{Path, PathBuf},
56 sync::{Arc, Mutex},
67};
78
9+use clap::Parser;
810use color_eyre::Result;
11+use dialoguer::Confirm;
12+use time::OffsetDateTime;
13+use time::format_description::well_known::Rfc3339;
914use tracing_subscriber::EnvFilter;
1015
1116use vibebox::tui::{AppState, VmInfo};
12−use vibebox::{SessionManager, commands, config, instance, tui, vm, vm_manager};
17+use vibebox::{SessionManager, commands, config, instance, session_manager, tui, vm, vm_manager};
1318
14−const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 30000;
19+#[derive(Debug, Parser)]
20+#[command(name = "vibebox", version, about = "Vibebox CLI")]
21+struct Cli {
22+ /// Path to vibebox.toml (relative to the current directory)
23+ #[arg(short = 'c', long = "config", value_name = "PATH", global = true)]
24+ config: Option<PathBuf>,
25+ #[command(subcommand)]
26+ command: Option<Command>,
27+}
28+
29+#[derive(Debug, clap::Subcommand)]
30+enum Command {
31+ /// List all sessions
32+ List,
33+ /// Delete the current project's .vibebox directory
34+ Clean,
35+ /// Explain mounts and mappings
36+ Explain,
37+}
1538
1639fn main() -> Result<()> {
1740 init_tracing();
1841 color_eyre::install()?;
1942
43+ let cli = Cli::parse();
44+ let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
45+ tracing::info!(cwd = %cwd.display(), "starting vibebox cli");
46+ if let Some(command) = cli.command {
47+ return handle_command(command, &cwd, cli.config.as_deref());
48+ }
49+
50+ let config_override = cli.config.clone();
2051 let raw_args: Vec<OsString> = env::args_os().collect();
52+ let config = config::load_config_with_path(&cwd, config_override.as_deref());
2153
2254 if env::var("VIBEBOX_VM_MANAGER").as_deref() == Ok("1") {
2355 tracing::info!("starting vm manager mode");
24− let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
25− let auto_shutdown_ms = env::var("VIBEBOX_AUTO_SHUTDOWN_MS")
26− .ok()
27− .and_then(|value| value.parse::<u64>().ok())
28− .unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS);
56+ let args = vm::VmArg {
57+ cpu_count: config.box_cfg.cpu_count,
58+ ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
59+ no_default_mounts: false,
60+ mounts: config.box_cfg.mounts.clone(),
61+ };
62+ let auto_shutdown_ms = config.supervisor.auto_shutdown_ms;
2963 tracing::info!(auto_shutdown_ms, "vm manager config");
3064 if let Err(err) = vm_manager::run_manager(args, auto_shutdown_ms) {
3165 tracing::error!(error = %err, "vm manager exited");
@@ -34,33 +68,24 @@ fn main() -> Result<()> {
3468 return Ok(());
3569 }
3670
37− let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
38− tracing::debug!("parsed cli args");
39− if args.version() {
40− vm::print_version();
41− return Ok(());
42− }
43− if args.help() {
44− vm::print_help();
45− return Ok(());
46− }
47−
4871 vm::ensure_signed();
4972
73+ let vm_args = vm::VmArg {
74+ cpu_count: config.box_cfg.cpu_count,
75+ ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
76+ no_default_mounts: false,
77+ mounts: config.box_cfg.mounts.clone(),
78+ };
79+
5080 let vm_info = VmInfo {
5181 version: env!("CARGO_PKG_VERSION").to_string(),
52− max_memory_mb: args.ram_mb(),
53− cpu_cores: args.cpu_count(),
82+ max_memory_mb: vm_args.ram_bytes / (1024 * 1024),
83+ cpu_cores: vm_args.cpu_count,
5484 };
55− let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
56− tracing::info!(cwd = %cwd.display(), "starting vibebox cli");
57− let auto_shutdown_ms = config::load_config(&cwd)
58− .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?
59− .auto_shutdown_ms
60− .unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS);
85+ let auto_shutdown_ms = config.supervisor.auto_shutdown_ms;
6186 if let Ok(manager) = SessionManager::new() {
6287 if let Err(err) = manager.update_global_sessions(&cwd) {
63− tracing::warn!(error = %err, "failed to update global session list");
88+ tracing::warn!(error = %err, "failed to update a global session list");
6489 }
6590 } else {
6691 tracing::warn!("failed to initialize session manager");
@@ -79,14 +104,260 @@ fn main() -> Result<()> {
79104 }
80105
81106 tracing::info!(auto_shutdown_ms, "auto shutdown config");
82− let manager_conn = vm_manager::ensure_manager(&raw_args, auto_shutdown_ms)
83− .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
107+ let manager_conn =
108+ vm_manager::ensure_manager(&raw_args, auto_shutdown_ms, config_override.as_deref())
109+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
84110
85111 instance::run_with_ssh(manager_conn).map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
86112
87113 Ok(())
88114}
89115
116+fn handle_command(command: Command, cwd: &PathBuf, config_override: Option<&Path>) -> Result<()> {
117+ match command {
118+ Command::List => {
119+ let manager = SessionManager::new()?;
120+ let sessions = manager.list_sessions()?;
121+ if sessions.is_empty() {
122+ println!("No sessions were found.");
123+ return Ok(());
124+ }
125+ let rows: Vec<tui::SessionListRow> = sessions
126+ .into_iter()
127+ .map(|session| tui::SessionListRow {
128+ name: project_name(&session.directory),
129+ id: session.id,
130+ directory: relative_to_home(&session.directory),
131+ last_active: format_last_active(session.last_active.as_deref()),
132+ active: if session.active {
133+ "yes".to_string()
134+ } else {
135+ "no".to_string()
136+ },
137+ })
138+ .collect();
139+ tui::render_sessions_table(&rows)?;
140+ Ok(())
141+ }
142+ Command::Clean => {
143+ let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME);
144+ if !instance_dir.exists() {
145+ println!("No .vibebox directory found at {}", instance_dir.display());
146+ return Ok(());
147+ }
148+ let confirmed = Confirm::new()
149+ .with_prompt(format!(
150+ "Delete {} and all its contents?",
151+ instance_dir.display()
152+ ))
153+ .default(false)
154+ .interact()?;
155+ if !confirmed {
156+ println!("Cancelled.");
157+ return Ok(());
158+ }
159+ let manager = SessionManager::new()?;
160+ let summary = manager.clean_project(cwd)?;
161+ println!(
162+ "Deleted {} (removed={}, session_records_removed={})",
163+ summary.instance_dir.display(),
164+ summary.removed_instance_dir,
165+ summary.removed_sessions
166+ );
167+ Ok(())
168+ }
169+ Command::Explain => {
170+ let config = config::load_config_with_path(cwd, config_override);
171+ let mounts = build_mount_rows(cwd, &config)?;
172+ let networks = build_network_rows(cwd)?;
173+ if mounts.is_empty() && networks.is_empty() {
174+ println!("No mounts or network info available.");
175+ return Ok(());
176+ }
177+ tui::render_explain_tables(&mounts, &networks)?;
178+ Ok(())
179+ }
180+ }
181+}
182+
183+fn project_name(directory: &PathBuf) -> String {
184+ directory
185+ .file_name()
186+ .and_then(|name| name.to_str())
187+ .unwrap_or("-")
188+ .to_string()
189+}
190+
191+fn relative_to_home(directory: &PathBuf) -> String {
192+ let Ok(home) = env::var("HOME") else {
193+ return directory.display().to_string();
194+ };
195+ let home_path = PathBuf::from(home);
196+ if let Ok(stripped) = directory.strip_prefix(&home_path) {
197+ if stripped.components().next().is_none() {
198+ return "~".to_string();
199+ }
200+ return format!("~/{}", stripped.display());
201+ }
202+ directory.display().to_string()
203+}
204+
205+fn format_last_active(value: Option<&str>) -> String {
206+ let Some(raw) = value else {
207+ return "-".to_string();
208+ };
209+ let parsed = OffsetDateTime::parse(raw, &Rfc3339);
210+ let Ok(timestamp) = parsed else {
211+ return raw.to_string();
212+ };
213+ let now = OffsetDateTime::now_utc();
214+ let mut seconds = (now - timestamp).whole_seconds();
215+ if seconds < 0 {
216+ seconds = 0;
217+ }
218+ let seconds = seconds as i64;
219+ let week_seconds = 7 * 24 * 60 * 60;
220+ if seconds >= week_seconds {
221+ let formatted =
222+ match time::format_description::parse("[year]-[month]-[day] [hour]:[minute]Z") {
223+ Ok(format) => timestamp
224+ .format(&format)
225+ .unwrap_or_else(|_| raw.to_string()),
226+ Err(_) => timestamp
227+ .format(&Rfc3339)
228+ .unwrap_or_else(|_| raw.to_string()),
229+ };
230+ return formatted;
231+ }
232+ if seconds < 60 {
233+ return "just now".to_string();
234+ }
235+ if seconds < 60 * 60 {
236+ let mins = seconds / 60;
237+ return format!("{} min{} ago", mins, if mins == 1 { "" } else { "s" });
238+ }
239+ if seconds < 60 * 60 * 24 {
240+ let hours = seconds / (60 * 60);
241+ return format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" });
242+ }
243+ let days = seconds / (60 * 60 * 24);
244+ format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
245+}
246+
247+fn build_mount_rows(cwd: &Path, config: &config::Config) -> Result<Vec<tui::MountListRow>> {
248+ let mut rows = Vec::new();
249+ rows.extend(default_mounts(cwd)?);
250+ for spec in &config.box_cfg.mounts {
251+ rows.push(parse_mount_spec(cwd, spec, false)?);
252+ }
253+ Ok(rows)
254+}
255+
256+fn build_network_rows(cwd: &Path) -> Result<Vec<tui::NetworkListRow>> {
257+ let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME);
258+ let mut vm_ip = "-".to_string();
259+ if let Ok(Some(ip)) = instance::read_instance_vm_ip(&instance_dir) {
260+ vm_ip = ip;
261+ }
262+ let host_to_vm = if vm_ip == "-" {
263+ "ssh: <pending>:22".to_string()
264+ } else {
265+ format!("ssh: {vm_ip}:22")
266+ };
267+ let row = tui::NetworkListRow {
268+ network_type: "NAT".to_string(),
269+ vm_ip: vm_ip.clone(),
270+ host_to_vm,
271+ vm_to_host: "none".to_string(),
272+ };
273+ Ok(vec![row])
274+}
275+
276+fn default_mounts(cwd: &Path) -> Result<Vec<tui::MountListRow>> {
277+ let project_name = cwd
278+ .file_name()
279+ .and_then(|name| name.to_str())
280+ .unwrap_or("project");
281+ let project_guest = format!("/root/{project_name}");
282+ let project_host = relative_to_home(&cwd.to_path_buf());
283+ let mut rows = vec![tui::MountListRow {
284+ host: project_host,
285+ guest: project_guest,
286+ mode: "read-write".to_string(),
287+ default_mount: "yes".to_string(),
288+ }];
289+
290+ let home = env::var("HOME")
291+ .map(PathBuf::from)
292+ .unwrap_or_else(|_| PathBuf::from("/"));
293+ let cache_home = env::var("XDG_CACHE_HOME")
294+ .map(PathBuf::from)
295+ .unwrap_or_else(|_| home.join(".cache"));
296+ let cache_dir = cache_home.join(session_manager::GLOBAL_CACHE_DIR_NAME);
297+ let guest_mise_cache = cache_dir.join(".guest-mise-cache");
298+ rows.push(tui::MountListRow {
299+ host: relative_to_home(&guest_mise_cache),
300+ guest: "/root/.local/share/mise".to_string(),
301+ mode: "read-write".to_string(),
302+ default_mount: "yes".to_string(),
303+ });
304+ Ok(rows)
305+}
306+
307+fn parse_mount_spec(cwd: &Path, spec: &str, default_mount: bool) -> Result<tui::MountListRow> {
308+ let parts: Vec<&str> = spec.split(':').collect();
309+ if parts.len() < 2 || parts.len() > 3 {
310+ return Err(color_eyre::eyre::eyre!("invalid mount spec: {spec}"));
311+ }
312+ let host_part = parts[0];
313+ let guest_part = parts[1];
314+ let mode = if parts.len() == 3 {
315+ match parts[2] {
316+ "read-only" => "read-only",
317+ "read-write" => "read-write",
318+ other => {
319+ return Err(color_eyre::eyre::eyre!(
320+ "invalid mount mode '{}'; expected read-only or read-write",
321+ other
322+ ));
323+ }
324+ }
325+ } else {
326+ "read-write"
327+ };
328+ let host_path = resolve_host_path(cwd, host_part);
329+ let host_display = relative_to_home(&host_path);
330+ let guest_display = if Path::new(guest_part).is_absolute() {
331+ guest_part.to_string()
332+ } else {
333+ format!("/root/{guest_part}")
334+ };
335+ Ok(tui::MountListRow {
336+ host: host_display,
337+ guest: guest_display,
338+ mode: mode.to_string(),
339+ default_mount: if default_mount { "yes" } else { "no" }.to_string(),
340+ })
341+}
342+
343+fn resolve_host_path(cwd: &Path, host: &str) -> PathBuf {
344+ if let Some(stripped) = host.strip_prefix("~/") {
345+ if let Ok(home) = env::var("HOME") {
346+ return PathBuf::from(home).join(stripped);
347+ }
348+ } else if host == "~" {
349+ if let Ok(home) = env::var("HOME") {
350+ return PathBuf::from(home);
351+ }
352+ }
353+ let host_path = PathBuf::from(host);
354+ if host_path.is_absolute() {
355+ host_path
356+ } else {
357+ cwd.join(host_path)
358+ }
359+}
360+
90361fn init_tracing() {
91362 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
92363 let ansi = std::io::stderr().is_terminal() && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
src/bin/vibebox-supervisor.rs+9−8
@@ -6,9 +6,7 @@ use std::{
66use color_eyre::Result;
77use tracing_subscriber::EnvFilter;
88
9−use vibebox::{instance, vm, vm_manager};
10−
11−const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 3000;
9+use vibebox::{config, instance, vm, vm_manager};
1210
1311fn main() -> Result<()> {
1412 init_tracing();
@@ -16,14 +14,17 @@ fn main() -> Result<()> {
1614
1715 tracing::info!("starting vm supervisor");
1816 let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
17+ let config = config::load_config(&cwd);
1918 let instance_dir = instance::ensure_instance_dir(&cwd)
2019 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
2120 let _ = instance::touch_last_active(&instance_dir);
22− let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
23− let auto_shutdown_ms = env::var("VIBEBOX_AUTO_SHUTDOWN_MS")
24− .ok()
25− .and_then(|value| value.parse::<u64>().ok())
26− .unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS);
21+ let args = vm::VmArg {
22+ cpu_count: config.box_cfg.cpu_count,
23+ ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
24+ no_default_mounts: false,
25+ mounts: config.box_cfg.mounts.clone(),
26+ };
27+ let auto_shutdown_ms = config.supervisor.auto_shutdown_ms;
2728 tracing::info!(auto_shutdown_ms, "vm supervisor config");
2829
2930 let result = vm_manager::run_manager(args, auto_shutdown_ms);
src/config.rs+276−16
@@ -1,38 +1,298 @@
11use std::{
2− fs, io,
2+ env, fs, io,
33 path::{Path, PathBuf},
44};
55
6−use serde::Deserialize;
6+use serde::{Deserialize, Serialize};
7+
8+use crate::vm::DirectoryShare;
79
810pub const CONFIG_FILENAME: &str = "vibebox.toml";
11+pub const CONFIG_PATH_ENV: &str = "VIBEBOX_CONFIG_PATH";
12+
13+const DEFAULT_CPU_COUNT: usize = 2;
14+const DEFAULT_RAM_MB: u64 = 2048;
15+const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 20000;
16+
17+#[derive(Debug, Clone, Serialize, Deserialize)]
18+pub struct Config {
19+ #[serde(rename = "box")]
20+ pub box_cfg: BoxConfig,
21+ pub supervisor: SupervisorConfig,
22+}
23+
24+impl Default for Config {
25+ fn default() -> Self {
26+ Self {
27+ box_cfg: BoxConfig::default(),
28+ supervisor: SupervisorConfig::default(),
29+ }
30+ }
31+}
32+
33+#[derive(Debug, Clone, Serialize, Deserialize)]
34+pub struct BoxConfig {
35+ pub cpu_count: usize,
36+ pub ram_mb: u64,
37+ pub mounts: Vec<String>,
38+}
39+
40+impl Default for BoxConfig {
41+ fn default() -> Self {
42+ Self {
43+ cpu_count: default_cpu_count(),
44+ ram_mb: default_ram_mb(),
45+ mounts: default_mounts(),
46+ }
47+ }
48+}
949
10−#[derive(Debug, Default, Deserialize)]
11−pub struct ProjectConfig {
12− pub auto_shutdown_ms: Option<u64>,
50+#[derive(Debug, Clone, Serialize, Deserialize)]
51+pub struct SupervisorConfig {
52+ pub auto_shutdown_ms: u64,
53+}
54+
55+impl Default for SupervisorConfig {
56+ fn default() -> Self {
57+ Self {
58+ auto_shutdown_ms: default_auto_shutdown_ms(),
59+ }
60+ }
61+}
62+
63+fn default_cpu_count() -> usize {
64+ DEFAULT_CPU_COUNT
65+}
66+
67+fn default_ram_mb() -> u64 {
68+ DEFAULT_RAM_MB
69+}
70+
71+fn default_auto_shutdown_ms() -> u64 {
72+ DEFAULT_AUTO_SHUTDOWN_MS
73+}
74+
75+fn default_mounts() -> Vec<String> {
76+ let home = match std::env::var("HOME") {
77+ Ok(home) => PathBuf::from(home),
78+ Err(_) => return Vec::new(),
79+ };
80+
81+ let mut mounts = Vec::new();
82+ let codex_host = home.join(".codex");
83+ if codex_host.exists() {
84+ mounts.push("~/.codex:/usr/local/codex:read-write".to_string());
85+ }
86+ let claude_host = home.join(".claude");
87+ if claude_host.exists() {
88+ mounts.push("~/.claude:/usr/local/claude:read-write".to_string());
89+ }
90+ mounts
1391}
1492
1593pub fn config_path(project_root: &Path) -> PathBuf {
1694 project_root.join(CONFIG_FILENAME)
1795}
1896
19−pub fn ensure_config_file(project_root: &Path) -> Result<PathBuf, io::Error> {
20− let path = config_path(project_root);
97+pub fn ensure_config_file(
98+ project_root: &Path,
99+ override_path: Option<&Path>,
100+) -> Result<PathBuf, io::Error> {
101+ let path = resolve_config_path(project_root, override_path);
21102 if !path.exists() {
22− fs::write(&path, "")?;
103+ let default_config = Config::default();
104+ let contents = toml::to_string_pretty(&default_config).unwrap_or_default();
105+ fs::write(&path, contents)?;
23106 tracing::info!(path = %path.display(), "created vibebox config");
24107 }
25108 Ok(path)
26109}
27110
28−pub fn load_config(
29− project_root: &Path,
30−) -> Result<ProjectConfig, Box<dyn std::error::Error + Send + Sync>> {
31− let path = ensure_config_file(project_root)?;
32− let raw = fs::read_to_string(&path)?;
111+pub fn load_config(project_root: &Path) -> Config {
112+ load_config_with_path(project_root, None)
113+}
114+
115+pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>) -> Config {
116+ let path = match ensure_config_file(project_root, override_path) {
117+ Ok(path) => path,
118+ Err(err) => die(&format!("failed to create config: {err}")),
119+ };
120+ let raw = match fs::read_to_string(&path) {
121+ Ok(raw) => raw,
122+ Err(err) => die(&format!("failed to read config: {err}")),
123+ };
124+ let trimmed = raw.trim();
33125 tracing::debug!(path = %path.display(), bytes = raw.len(), "loaded vibebox config");
34− if raw.trim().is_empty() {
35− return Ok(ProjectConfig::default());
126+ if trimmed.is_empty() {
127+ die(&format!(
128+ "config file ({}) is empty. Required fields: [box].cpu_count (integer), [box].ram_mb (integer), [box].mounts (array of strings), [supervisor].auto_shutdown_ms (integer)",
129+ path.display()
130+ ));
131+ }
132+
133+ let value: toml::Value = match toml::from_str(trimmed) {
134+ Ok(value) => value,
135+ Err(err) => die(&format!("invalid config: {err}")),
136+ };
137+ let schema_errors = validate_schema(&value);
138+ if !schema_errors.is_empty() {
139+ let message = format!(
140+ "config file ({}) is missing or invalid fields:\n- {}",
141+ path.display(),
142+ schema_errors.join("\n- ")
143+ );
144+ die(&message);
145+ }
146+
147+ let config: Config = match toml::from_str(trimmed) {
148+ Ok(config) => config,
149+ Err(err) => die(&format!("invalid config: {err}")),
150+ };
151+ validate_or_exit(&config);
152+ config
153+}
154+
155+fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> PathBuf {
156+ let root = match fs::canonicalize(project_root) {
157+ Ok(root) => root,
158+ Err(err) => die(&format!("failed to resolve project root: {err}")),
159+ };
160+
161+ let override_path = override_path
162+ .map(PathBuf::from)
163+ .or_else(|| env::var_os(CONFIG_PATH_ENV).map(PathBuf::from));
164+ let raw_path = if let Some(path) = override_path {
165+ if path.is_absolute() {
166+ path
167+ } else {
168+ project_root.join(path)
169+ }
170+ } else {
171+ config_path(project_root)
172+ };
173+
174+ let normalized = normalize_path(&raw_path);
175+ if !normalized.starts_with(&root) {
176+ die(&format!(
177+ "config path must be within {}: {}",
178+ root.display(),
179+ normalized.display()
180+ ));
181+ }
182+ normalized
183+}
184+
185+fn normalize_path(path: &Path) -> PathBuf {
186+ let mut normalized = PathBuf::new();
187+ for component in path.components() {
188+ match component {
189+ std::path::Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
190+ std::path::Component::RootDir => {
191+ normalized.push(std::path::MAIN_SEPARATOR.to_string());
192+ }
193+ std::path::Component::CurDir => {}
194+ std::path::Component::ParentDir => {
195+ let _ = normalized.pop();
196+ }
197+ std::path::Component::Normal(part) => normalized.push(part),
198+ }
199+ }
200+ normalized
201+}
202+
203+fn validate_schema(value: &toml::Value) -> Vec<String> {
204+ let mut errors = Vec::new();
205+ let root = match value.as_table() {
206+ Some(table) => table,
207+ None => {
208+ errors.push("config must be a table".to_string());
209+ return errors;
210+ }
211+ };
212+
213+ match root.get("box") {
214+ None => errors.push("missing [box] table".to_string()),
215+ Some(value) => match value.as_table() {
216+ Some(table) => {
217+ validate_int(table, "cpu_count", "[box].cpu_count (integer)", &mut errors);
218+ validate_int(table, "ram_mb", "[box].ram_mb (integer)", &mut errors);
219+ validate_string_array(
220+ table,
221+ "mounts",
222+ "[box].mounts (array of strings)",
223+ &mut errors,
224+ );
225+ }
226+ None => errors.push("[box] must be a table".to_string()),
227+ },
228+ }
229+
230+ match root.get("supervisor") {
231+ None => errors.push("missing [supervisor] table".to_string()),
232+ Some(value) => match value.as_table() {
233+ Some(table) => {
234+ validate_int(
235+ table,
236+ "auto_shutdown_ms",
237+ "[supervisor].auto_shutdown_ms (integer)",
238+ &mut errors,
239+ );
240+ }
241+ None => errors.push("[supervisor] must be a table".to_string()),
242+ },
36243 }
37− Ok(toml::from_str::<ProjectConfig>(&raw)?)
244+
245+ errors
246+}
247+
248+fn validate_int(table: &toml::value::Table, key: &str, label: &str, errors: &mut Vec<String>) {
249+ match table.get(key) {
250+ None => errors.push(format!("missing {label}")),
251+ Some(value) => {
252+ if value.as_integer().is_none() {
253+ errors.push(format!("invalid {label}: expected integer"));
254+ }
255+ }
256+ }
257+}
258+
259+fn validate_string_array(
260+ table: &toml::value::Table,
261+ key: &str,
262+ label: &str,
263+ errors: &mut Vec<String>,
264+) {
265+ match table.get(key) {
266+ None => errors.push(format!("missing {label}")),
267+ Some(value) => match value.as_array() {
268+ Some(values) => {
269+ if values.iter().any(|value| !value.is_str()) {
270+ errors.push(format!("invalid {label}: expected array of strings"));
271+ }
272+ }
273+ None => errors.push(format!("invalid {label}: expected array of strings")),
274+ },
275+ }
276+}
277+
278+fn validate_or_exit(config: &Config) {
279+ if config.box_cfg.cpu_count == 0 {
280+ die("box.cpu_count must be >= 1");
281+ }
282+ if config.box_cfg.ram_mb == 0 {
283+ die("box.ram_mb must be >= 1");
284+ }
285+ if config.supervisor.auto_shutdown_ms == 0 {
286+ die("supervisor.auto_shutdown_ms must be >= 1");
287+ }
288+ for spec in &config.box_cfg.mounts {
289+ if let Err(err) = DirectoryShare::from_mount_spec(spec) {
290+ die(&format!("invalid mount spec '{spec}': {err}"));
291+ }
292+ }
293+}
294+
295+fn die(message: &str) -> ! {
296+ eprintln!("[vibebox] {message}");
297+ std::process::exit(1);
38298}
src/instance.rs+24−7
@@ -20,14 +20,13 @@ use uuid::Uuid;
2020
2121use crate::{
2222 commands,
23− session_manager::{INSTANCE_DIR_NAME, INSTANCE_TOML_FILENAME},
23+ session_manager::{INSTANCE_DIR_NAME, INSTANCE_FILENAME},
2424 tui::AppState,
2525 vm::{self, LoginAction, VmInput},
2626};
2727
2828const SSH_KEY_NAME: &str = "ssh_key";
29−#[allow(dead_code)]
30−const SERIAL_LOG_NAME: &str = "serial.log";
29+pub(crate) const SERIAL_LOG_NAME: &str = "serial.log";
3130const DEFAULT_SSH_USER: &str = "vibecoder";
3231const SSH_CONNECT_RETRIES: usize = 30;
3332const SSH_CONNECT_DELAY_MS: u64 = 500;
@@ -126,7 +125,7 @@ pub(crate) fn ensure_ssh_keypair(
126125pub(crate) fn load_or_create_instance_config(
127126 instance_dir: &Path,
128127) -> Result<InstanceConfig, Box<dyn std::error::Error>> {
129− let config_path = instance_dir.join(INSTANCE_TOML_FILENAME);
128+ let config_path = instance_dir.join(INSTANCE_FILENAME);
130129 let mut config = if config_path.exists() {
131130 let raw = fs::read_to_string(&config_path)?;
132131 toml::from_str::<InstanceConfig>(&raw)?
@@ -163,11 +162,30 @@ pub(crate) fn load_or_create_instance_config(
163162 Ok(config)
164163}
165164
165+fn read_instance_config(
166+ instance_dir: &Path,
167+) -> Result<Option<InstanceConfig>, Box<dyn std::error::Error>> {
168+ let config_path = instance_dir.join(INSTANCE_FILENAME);
169+ if !config_path.exists() {
170+ return Ok(None);
171+ }
172+ let raw = fs::read_to_string(&config_path)?;
173+ let config = toml::from_str::<InstanceConfig>(&raw)?;
174+ Ok(Some(config))
175+}
176+
177+pub fn read_instance_vm_ip(
178+ instance_dir: &Path,
179+) -> Result<Option<String>, Box<dyn std::error::Error>> {
180+ let config = read_instance_config(instance_dir)?;
181+ Ok(config.and_then(|cfg| cfg.vm_ipv4))
182+}
183+
166184pub fn touch_last_active(instance_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
167185 let mut config = load_or_create_instance_config(instance_dir)?;
168186 let now = OffsetDateTime::now_utc().format(&Rfc3339)?;
169187 config.last_active = Some(now);
170− write_instance_config(&instance_dir.join(INSTANCE_TOML_FILENAME), &config)?;
188+ write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?;
171189 Ok(())
172190}
173191
@@ -353,7 +371,6 @@ pub(crate) fn build_ssh_login_actions(
353371 vec![LoginAction::Send(setup)]
354372}
355373
356−#[allow(dead_code)]
357374fn spawn_ssh_io(
358375 app: Arc<Mutex<AppState>>,
359376 config: Arc<Mutex<InstanceConfig>>,
@@ -379,7 +396,7 @@ fn spawn_ssh_io(
379396 let ssh_ready = Arc::new(AtomicBool::new(false));
380397 let input_tx_holder: Arc<Mutex<Option<Sender<VmInput>>>> = Arc::new(Mutex::new(None));
381398
382− let instance_path = instance_dir.join(INSTANCE_TOML_FILENAME);
399+ let instance_path = instance_dir.join(INSTANCE_FILENAME);
383400 let config_for_output = config.clone();
384401 let log_for_output = log_file.clone();
385402 let ssh_connected_for_output = ssh_connected.clone();
src/session_manager.rs+116−16
@@ -1,6 +1,7 @@
11use std::{
22 env, fs,
33 io::{self, Write},
4+ os::unix::fs::FileTypeExt,
45 path::{Path, PathBuf},
56};
67
@@ -11,9 +12,10 @@ use crate::config::CONFIG_FILENAME;
1112pub const INSTANCE_DIR_NAME: &str = ".vibebox";
1213pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox";
1314pub const GLOBAL_DIR_NAME: &str = ".vibebox";
14−pub const INSTANCE_TOML_FILENAME: &str = "instance.toml";
15−pub const SESSION_TEMP_PREFIX: &str = "sessions";
15+pub const INSTANCE_FILENAME: &str = "instance.toml";
1616pub const SESSION_TOML_SUFFIX: &str = ".toml";
17+pub const VM_MANAGER_SOCKET_NAME: &str = "vm.sock";
18+pub const VM_MANAGER_PID_NAME: &str = "vm.pid";
1719const SESSIONS_DIR_NAME: &str = "sessions";
1820
1921#[derive(Debug, Clone, PartialEq, Eq)]
@@ -21,6 +23,7 @@ pub struct SessionRecord {
2123 pub directory: PathBuf,
2224 pub id: String,
2325 pub last_active: Option<String>,
26+ pub active: bool,
2427}
2528
2629#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -42,6 +45,13 @@ pub struct SessionManager {
4245 sessions_dir: PathBuf,
4346}
4447
48+#[derive(Debug, Clone, PartialEq, Eq)]
49+pub struct CleanSummary {
50+ pub instance_dir: PathBuf,
51+ pub removed_instance_dir: bool,
52+ pub removed_sessions: usize,
53+}
54+
4555#[derive(Debug, thiserror::Error)]
4656pub enum SessionError {
4757 #[error("HOME environment variable is not set")]
@@ -100,7 +110,8 @@ impl SessionManager {
100110 } else {
101111 tracing::warn!(
102112 directory = %directory.display(),
103− "missing session id in instance.toml"
113+ file = INSTANCE_FILENAME,
114+ "missing session id in instance file"
104115 );
105116 }
106117 }
@@ -138,15 +149,33 @@ impl SessionManager {
138149 let mut records = Vec::with_capacity(sessions.len());
139150 for session in sessions {
140151 let meta = read_instance_metadata(&session.directory)?;
152+ let active = is_session_active(&session.directory);
141153 records.push(SessionRecord {
142154 directory: session.directory,
143155 id: session.id,
144156 last_active: meta.last_active,
157+ active,
145158 });
146159 }
147160 Ok(records)
148161 }
149162
163+ pub fn clean_project(&self, directory: &Path) -> Result<CleanSummary, SessionError> {
164+ let directory = self.normalize_directory(directory)?;
165+ let instance_dir = directory.join(INSTANCE_DIR_NAME);
166+ let mut removed_instance_dir = false;
167+ if instance_dir.exists() {
168+ fs::remove_dir_all(&instance_dir)?;
169+ removed_instance_dir = true;
170+ }
171+ let removed_sessions = self.remove_session_records_for_directory(&directory)?;
172+ Ok(CleanSummary {
173+ instance_dir,
174+ removed_instance_dir,
175+ removed_sessions,
176+ })
177+ }
178+
150179 fn normalize_directory(&self, directory: &Path) -> Result<PathBuf, SessionError> {
151180 if !directory.is_absolute() {
152181 return Err(SessionError::NonAbsoluteDirectory(directory.to_path_buf()));
@@ -158,7 +187,7 @@ impl SessionManager {
158187 }
159188
160189 fn session_path_for(&self, id: &str) -> PathBuf {
161− let filename = format!("{id}.toml");
190+ let filename = format!("{id}{SESSION_TOML_SUFFIX}");
162191 self.sessions_dir.join(filename)
163192 }
164193
@@ -206,6 +235,36 @@ impl SessionManager {
206235
207236 Ok((sessions, removed))
208237 }
238+
239+ fn remove_session_records_for_directory(
240+ &self,
241+ directory: &Path,
242+ ) -> Result<usize, SessionError> {
243+ if !self.sessions_dir.exists() {
244+ return Ok(0);
245+ }
246+ let mut removed = 0usize;
247+ for entry in fs::read_dir(&self.sessions_dir)? {
248+ let entry = entry?;
249+ let path = entry.path();
250+ if !path.is_file() {
251+ continue;
252+ }
253+ let record = read_session_file(&path)?;
254+ if record.directory == directory {
255+ fs::remove_file(&path)?;
256+ removed += 1;
257+ }
258+ }
259+ if removed > 0 {
260+ tracing::info!(
261+ directory = %directory.display(),
262+ removed,
263+ "removed session records"
264+ );
265+ }
266+ Ok(removed)
267+ }
209268}
210269
211270fn is_vibebox_dir(directory: &Path) -> bool {
@@ -215,6 +274,43 @@ fn is_vibebox_dir(directory: &Path) -> bool {
215274 directory.join(CONFIG_FILENAME).is_file()
216275}
217276
277+fn is_session_active(directory: &Path) -> bool {
278+ let instance_dir = directory.join(INSTANCE_DIR_NAME);
279+ let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
280+ let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
281+
282+ let pid = read_pid(&pid_path);
283+ let is_alive = pid.map(pid_is_alive).unwrap_or(false);
284+ if !is_alive {
285+ let _ = fs::remove_file(&pid_path);
286+ return false;
287+ }
288+
289+ if let Ok(metadata) = fs::metadata(&socket_path) {
290+ return metadata.file_type().is_socket();
291+ }
292+
293+ true
294+}
295+
296+fn read_pid(path: &Path) -> Option<u32> {
297+ let content = fs::read_to_string(path).ok()?;
298+ content.trim().parse::<u32>().ok()
299+}
300+
301+fn pid_is_alive(pid: u32) -> bool {
302+ let pid = pid as libc::pid_t;
303+ let result = unsafe { libc::kill(pid, 0) };
304+ if result == 0 {
305+ return true;
306+ }
307+ match std::io::Error::last_os_error().raw_os_error() {
308+ Some(code) if code == libc::EPERM => true,
309+ Some(code) if code == libc::ESRCH => false,
310+ _ => false,
311+ }
312+}
313+
218314fn read_session_file(path: &Path) -> Result<SessionEntry, SessionError> {
219315 let raw = fs::read_to_string(path)?;
220316 let record: SessionEntry = toml::from_str(&raw)?;
@@ -225,9 +321,7 @@ fn read_session_file(path: &Path) -> Result<SessionEntry, SessionError> {
225321}
226322
227323fn read_instance_metadata(directory: &Path) -> Result<InstanceMetadata, SessionError> {
228− let instance_path = directory
229− .join(INSTANCE_DIR_NAME)
230− .join(INSTANCE_TOML_FILENAME);
324+ let instance_path = directory.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME);
231325 if !instance_path.exists() {
232326 return Ok(InstanceMetadata::default());
233327 }
@@ -256,7 +350,7 @@ fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
256350
257351 fs::create_dir_all(parent)?;
258352 let mut temp = tempfile::Builder::new()
259− .prefix(SESSION_TEMP_PREFIX)
353+ .prefix(SESSIONS_DIR_NAME)
260354 .suffix(SESSION_TOML_SUFFIX)
261355 .tempfile_in(parent)?;
262356 temp.write_all(content)?;
@@ -285,7 +379,7 @@ mod tests {
285379 let instance_dir = project_dir.join(INSTANCE_DIR_NAME);
286380 fs::create_dir_all(&instance_dir).unwrap();
287381 let content = format!("id = \"{id}\"\nlast_active = \"{last_active}\"\n");
288− fs::write(instance_dir.join(INSTANCE_TOML_FILENAME), content).unwrap();
382+ fs::write(instance_dir.join(INSTANCE_FILENAME), content).unwrap();
289383 }
290384
291385 #[test]
@@ -306,9 +400,10 @@ mod tests {
306400 assert_eq!(dirs[0], project_dir.canonicalize().unwrap());
307401 assert!(mgr.index_path().exists());
308402
309− let session_path = mgr
310− .index_path()
311− .join("019bf290-cccc-7c23-ba1d-dce7e6d40693.toml");
403+ let session_path = mgr.index_path().join(format!(
404+ "019bf290-cccc-7c23-ba1d-dce7e6d40693{}",
405+ SESSION_TOML_SUFFIX
406+ ));
312407 assert!(session_path.exists());
313408 }
314409
@@ -329,9 +424,10 @@ mod tests {
329424 let sessions = mgr.list_sessions().unwrap();
330425 assert!(sessions.is_empty());
331426
332− let session_path = mgr
333− .index_path()
334− .join("019bf290-cccc-7c23-ba1d-dce7e6d40693.toml");
427+ let session_path = mgr.index_path().join(format!(
428+ "019bf290-cccc-7c23-ba1d-dce7e6d40693{}",
429+ SESSION_TOML_SUFFIX
430+ ));
335431 assert!(!session_path.exists());
336432 }
337433
@@ -340,7 +436,11 @@ mod tests {
340436 let temp = TempDir::new().unwrap();
341437 let mgr = manager(&temp);
342438 fs::create_dir_all(mgr.index_path()).unwrap();
343− fs::write(mgr.index_path().join("bad.toml"), "not toml").unwrap();
439+ fs::write(
440+ mgr.index_path().join(format!("bad{SESSION_TOML_SUFFIX}")),
441+ "not toml",
442+ )
443+ .unwrap();
344444
345445 let err = mgr.list_sessions().unwrap_err();
346446 assert!(matches!(err, SessionError::TomlDe(_)));
src/tui.rs+217−1
@@ -20,7 +20,7 @@ use ratatui::{
2020 layout::{Constraint, Direction, Layout, Rect},
2121 style::{Color, Style},
2222 text::{Line, Span, Text},
23− widgets::{Block, Borders, List, ListItem, Paragraph, Widget},
23+ widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table, Widget},
2424};
2525
2626use crate::vm;
@@ -98,6 +98,31 @@ pub struct VibeboxCommand {
9898 pub description: String,
9999}
100100
101+#[derive(Debug, Clone)]
102+pub struct SessionListRow {
103+ pub name: String,
104+ pub directory: String,
105+ pub last_active: String,
106+ pub active: String,
107+ pub id: String,
108+}
109+
110+#[derive(Debug, Clone)]
111+pub struct MountListRow {
112+ pub host: String,
113+ pub guest: String,
114+ pub mode: String,
115+ pub default_mount: String,
116+}
117+
118+#[derive(Debug, Clone)]
119+pub struct NetworkListRow {
120+ pub network_type: String,
121+ pub vm_ip: String,
122+ pub host_to_vm: String,
123+ pub vm_to_host: String,
124+}
125+
101126#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
102127struct PageLayout {
103128 header: Rect,
@@ -174,6 +199,197 @@ pub fn render_commands_component(app: &mut AppState) -> Result<()> {
174199 Ok(())
175200}
176201
202+pub fn render_sessions_table(rows: &[SessionListRow]) -> Result<()> {
203+ let (width, _) = crossterm::terminal::size()?;
204+ if width == 0 {
205+ return Ok(());
206+ }
207+
208+ let height = (rows.len() as u16).saturating_add(3);
209+ let mut buffer = Buffer::empty(Rect::new(0, 0, width, height));
210+ let area = Rect::new(0, 0, width, height);
211+
212+ let header = Row::new(vec![
213+ Cell::from("Name"),
214+ Cell::from("Last Active"),
215+ Cell::from("Active"),
216+ Cell::from("ID"),
217+ Cell::from("Directory"),
218+ ])
219+ .style(Style::default().fg(Color::Cyan));
220+
221+ let table_rows = rows.iter().map(|row| {
222+ Row::new(vec![
223+ Cell::from(row.name.clone()),
224+ Cell::from(row.last_active.clone()),
225+ Cell::from(row.active.clone()),
226+ Cell::from(row.id.clone()),
227+ Cell::from(row.directory.clone()),
228+ ])
229+ });
230+
231+ let table = Table::new(
232+ table_rows,
233+ [
234+ Constraint::Length(16),
235+ Constraint::Length(14),
236+ Constraint::Length(8),
237+ Constraint::Length(36),
238+ Constraint::Min(24),
239+ ],
240+ )
241+ .header(header)
242+ .block(Block::default().title("Sessions").borders(Borders::ALL))
243+ .column_spacing(2);
244+
245+ table.render(area, &mut buffer);
246+
247+ let mut stdout = io::stdout();
248+ execute!(stdout, Clear(ClearType::All), MoveTo(0, 0), Show)?;
249+ write_buffer_with_style(&buffer, &mut stdout)?;
250+ stdout.flush()?;
251+ Ok(())
252+}
253+
254+pub fn render_mounts_table(rows: &[MountListRow]) -> Result<()> {
255+ let (width, _) = crossterm::terminal::size()?;
256+ if width == 0 {
257+ return Ok(());
258+ }
259+
260+ let height = (rows.len() as u16).saturating_add(3);
261+ let mut buffer = Buffer::empty(Rect::new(0, 0, width, height));
262+ let area = Rect::new(0, 0, width, height);
263+
264+ render_mounts_table_into(rows, area, &mut buffer);
265+
266+ let mut stdout = io::stdout();
267+ execute!(stdout, Clear(ClearType::All), MoveTo(0, 0), Show)?;
268+ write_buffer_with_style(&buffer, &mut stdout)?;
269+ stdout.flush()?;
270+ Ok(())
271+}
272+
273+pub fn render_explain_tables(mounts: &[MountListRow], networks: &[NetworkListRow]) -> Result<()> {
274+ let (width, _) = crossterm::terminal::size()?;
275+ if width == 0 {
276+ return Ok(());
277+ }
278+
279+ let mounts_height = if mounts.is_empty() {
280+ 0
281+ } else {
282+ (mounts.len() as u16).saturating_add(3)
283+ };
284+ let networks_height = if networks.is_empty() {
285+ 0
286+ } else {
287+ (networks.len() as u16).saturating_add(3)
288+ };
289+ let gap = if mounts_height > 0 && networks_height > 0 {
290+ 1
291+ } else {
292+ 0
293+ };
294+ let total_height = mounts_height
295+ .saturating_add(gap)
296+ .saturating_add(networks_height);
297+ if total_height == 0 {
298+ return Ok(());
299+ }
300+
301+ let mut buffer = Buffer::empty(Rect::new(0, 0, width, total_height));
302+ let mut y = 0u16;
303+
304+ if mounts_height > 0 {
305+ let area = Rect::new(0, y, width, mounts_height);
306+ render_mounts_table_into(mounts, area, &mut buffer);
307+ y = y.saturating_add(mounts_height).saturating_add(gap);
308+ }
309+
310+ if networks_height > 0 {
311+ let area = Rect::new(0, y, width, networks_height);
312+ render_networks_table_into(networks, area, &mut buffer);
313+ }
314+
315+ let mut stdout = io::stdout();
316+ execute!(stdout, Clear(ClearType::All), MoveTo(0, 0), Show)?;
317+ write_buffer_with_style(&buffer, &mut stdout)?;
318+ stdout.flush()?;
319+ Ok(())
320+}
321+
322+fn render_mounts_table_into(rows: &[MountListRow], area: Rect, buffer: &mut Buffer) {
323+ let header = Row::new(vec![
324+ Cell::from("Host"),
325+ Cell::from("Guest"),
326+ Cell::from("Mode"),
327+ Cell::from(""),
328+ Cell::from("Default"),
329+ ])
330+ .style(Style::default().fg(Color::Cyan));
331+
332+ let table_rows = rows.iter().map(|row| {
333+ Row::new(vec![
334+ Cell::from(row.host.clone()),
335+ Cell::from(row.guest.clone()),
336+ Cell::from(row.mode.clone()),
337+ Cell::from(""),
338+ Cell::from(row.default_mount.clone()),
339+ ])
340+ });
341+
342+ let table = Table::new(
343+ table_rows,
344+ [
345+ Constraint::Min(24),
346+ Constraint::Min(24),
347+ Constraint::Length(10),
348+ Constraint::Length(1),
349+ Constraint::Length(8),
350+ ],
351+ )
352+ .header(header)
353+ .block(Block::default().title("Mounts").borders(Borders::ALL))
354+ .column_spacing(1);
355+
356+ table.render(area, buffer);
357+}
358+
359+fn render_networks_table_into(rows: &[NetworkListRow], area: Rect, buffer: &mut Buffer) {
360+ let header = Row::new(vec![
361+ Cell::from("Type"),
362+ Cell::from("VM IP"),
363+ Cell::from("Host \u{2192} VM"),
364+ Cell::from("VM \u{2192} Host"),
365+ ])
366+ .style(Style::default().fg(Color::Cyan));
367+
368+ let table_rows = rows.iter().map(|row| {
369+ Row::new(vec![
370+ Cell::from(row.network_type.clone()),
371+ Cell::from(row.vm_ip.clone()),
372+ Cell::from(row.host_to_vm.clone()),
373+ Cell::from(row.vm_to_host.clone()),
374+ ])
375+ });
376+
377+ let table = Table::new(
378+ table_rows,
379+ [
380+ Constraint::Length(8),
381+ Constraint::Length(16),
382+ Constraint::Min(24),
383+ Constraint::Min(20),
384+ ],
385+ )
386+ .header(header)
387+ .block(Block::default().title("Network").borders(Borders::ALL))
388+ .column_spacing(1);
389+
390+ table.render(area, buffer);
391+}
392+
177393pub fn passthrough_vm_io(
178394 app: Arc<Mutex<AppState>>,
179395 output_monitor: Arc<vm::OutputMonitor>,
src/vm.rs+44−197
@@ -1,8 +1,6 @@
11use crate::session_manager::{GLOBAL_CACHE_DIR_NAME, INSTANCE_DIR_NAME};
22use std::{
3− env,
4− ffi::OsString,
5− fs,
3+ env, fs,
64 io::{self, Write},
75 os::{
86 fd::RawFd,
@@ -25,7 +23,6 @@ use std::{
2523
2624use block2::RcBlock;
2725use dispatch2::DispatchQueue;
28−use lexopt::prelude::*;
2926use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
3027use objc2_foundation::*;
3128use objc2_virtualization::*;
@@ -40,9 +37,12 @@ const DEFAULT_CPU_COUNT: usize = 2;
4037const DEFAULT_RAM_MB: u64 = 2048;
4138const DEFAULT_RAM_BYTES: u64 = DEFAULT_RAM_MB * BYTES_PER_MB;
4239const START_TIMEOUT: Duration = Duration::from_secs(60);
43−const DEFAULT_EXPECT_TIMEOUT: Duration = Duration::from_secs(30);
4440const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
4541const PROVISION_SCRIPT: &str = include_str!("provision.sh");
42+const PROVISION_SCRIPT_NAME: &str = "provision.sh";
43+const DEFAULT_RAW_NAME: &str = "default.raw";
44+const INSTANCE_RAW_NAME: &str = "instance.raw";
45+const BASE_DISK_RAW_NAME: &str = "disk.raw";
4646
4747#[derive(Clone)]
4848pub(crate) enum LoginAction {
@@ -83,7 +83,7 @@ impl DirectoryShare {
8383 if parts.len() < 2 || parts.len() > 3 {
8484 return Err(format!("Invalid mount spec: {spec}").into());
8585 }
86− let host = PathBuf::from(parts[0]);
86+ let host = expand_tilde_path(parts[0]);
8787 let guest = PathBuf::from(parts[1]);
8888 let read_only = if parts.len() == 3 {
8989 match parts[2] {
@@ -117,7 +117,27 @@ impl DirectoryShare {
117117 }
118118}
119119
120−pub fn run_with_args<F>(args: CliArgs, io_handler: F) -> Result<(), Box<dyn std::error::Error>>
120+fn expand_tilde_path(value: &str) -> PathBuf {
121+ if let Some(stripped) = value.strip_prefix("~/") {
122+ if let Ok(home) = env::var("HOME") {
123+ return PathBuf::from(home).join(stripped);
124+ }
125+ } else if value == "~" {
126+ if let Ok(home) = env::var("HOME") {
127+ return PathBuf::from(home);
128+ }
129+ }
130+ PathBuf::from(value)
131+}
132+
133+pub struct VmArg {
134+ pub cpu_count: usize,
135+ pub ram_bytes: u64,
136+ pub no_default_mounts: bool,
137+ pub mounts: Vec<String>,
138+}
139+
140+pub fn run_with_args<F>(args: VmArg, io_handler: F) -> Result<(), Box<dyn std::error::Error>>
121141where
122142 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
123143{
@@ -125,7 +145,7 @@ where
125145}
126146
127147pub(crate) fn run_with_args_and_extras<F>(
128− args: CliArgs,
148+ args: VmArg,
129149 io_handler: F,
130150 extra_login_actions: Vec<LoginAction>,
131151 extra_directory_shares: Vec<DirectoryShare>,
@@ -158,8 +178,8 @@ where
158178 basename_compressed.trim_end_matches(".tar.xz")
159179 ));
160180
161− let default_raw = cache_dir.join("default.raw");
162− let instance_raw = instance_dir.join("instance.raw");
181+ let default_raw = cache_dir.join(DEFAULT_RAW_NAME);
182+ let instance_raw = instance_dir.join(INSTANCE_RAW_NAME);
163183
164184 // Prepare system-wide directories
165185 fs::create_dir_all(&cache_dir)?;
@@ -168,22 +188,14 @@ where
168188 let mise_directory_share =
169189 DirectoryShare::new(guest_mise_cache, "/root/.local/share/mise".into(), false)?;
170190
171− let disk_path = if let Some(path) = args.disk {
172− if !path.exists() {
173− return Err(format!("Disk image does not exist: {}", path.display()).into());
174− }
175− path
176− } else {
177− ensure_default_image(
178− &base_raw,
179− &base_compressed,
180− &default_raw,
181− std::slice::from_ref(&mise_directory_share),
182− )?;
183− ensure_instance_disk(&instance_raw, &default_raw)?;
184−
185− instance_raw
186− };
191+ ensure_default_image(
192+ &base_raw,
193+ &base_compressed,
194+ &default_raw,
195+ std::slice::from_ref(&mise_directory_share),
196+ )?;
197+ ensure_instance_disk(&instance_raw, &default_raw)?;
198+ let disk_path = instance_raw;
187199
188200 let mut login_actions = Vec::new();
189201 let mut directory_shares = Vec::new();
@@ -207,22 +219,6 @@ where
207219 );
208220
209221 directory_shares.push(mise_directory_share);
210−
211− // Add default shares, if they exist
212− for share in [
213− DirectoryShare::new(home.join(".codex"), "/usr/local/codex".into(), false),
214− DirectoryShare::new(home.join(".claude"), "/usr/local/claude".into(), false),
215− DirectoryShare::new(
216− "/Users/zhangjie/Documents/Code/CompletePrograms/vibebox/.ssh".into(),
217− "/usr/local/ssh".into(),
218− true,
219− ),
220− ]
221− .into_iter()
222− .flatten()
223− {
224− directory_shares.push(share)
225− }
226222 }
227223
228224 directory_shares.extend(extra_directory_shares);
@@ -237,9 +233,6 @@ where
237233
238234 login_actions.extend(extra_login_actions);
239235
240− // Any user-provided login actions must come after our system ones
241− login_actions.extend(args.login_actions);
242−
243236 run_vm_with_io(
244237 &disk_path,
245238 &login_actions,
@@ -250,156 +243,6 @@ where
250243 )
251244}
252245
253−pub fn print_help() {
254− println!(
255− "Vibe is a quick way to spin up a Linux virtual machine on Mac to sandbox LLM agents.
256−
257−vibe [OPTIONS] [disk-image.raw]
258−
259−Options
260−
261− --help Print this help message.
262− --version Print the version (commit SHA).
263− --no-default-mounts Disable all default mounts.
264− --mount host-path:guest-path[:read-only | :read-write] Mount `host-path` inside VM at `guest-path`.
265− Defaults to read-write.
266− Errors if host-path does not exist.
267− --cpus <count> Number of virtual CPUs (default {DEFAULT_CPU_COUNT}).
268− --ram <megabytes> RAM size in megabytes (default {DEFAULT_RAM_MB}).
269− --script <path/to/script.sh> Run script in VM.
270− --send <some-command> Type `some-command` followed by newline into the VM.
271− --expect <string> [timeout-seconds] Wait for `string` to appear in console output before executing next `--script` or `--send`.
272− If `string` does not appear within timeout (default 30 seconds), shutdown VM with error.
273−"
274− );
275−}
276−
277−pub fn print_version() {
278− println!("Vibe");
279− println!("https://github.com/lynaghk/vibe/");
280− println!("Git SHA: {}", env!("GIT_SHA"));
281−}
282−
283−pub struct CliArgs {
284− disk: Option<PathBuf>,
285− version: bool,
286− help: bool,
287− no_default_mounts: bool,
288− mounts: Vec<String>,
289− login_actions: Vec<LoginAction>,
290− cpu_count: usize,
291− ram_bytes: u64,
292−}
293−
294−impl CliArgs {
295− pub fn version(&self) -> bool {
296− self.version
297− }
298−
299− pub fn help(&self) -> bool {
300− self.help
301− }
302−
303− pub fn cpu_count(&self) -> usize {
304− self.cpu_count
305− }
306−
307− pub fn ram_bytes(&self) -> u64 {
308− self.ram_bytes
309− }
310−
311− pub fn ram_mb(&self) -> u64 {
312− self.ram_bytes / BYTES_PER_MB
313− }
314−}
315−
316−pub fn parse_cli() -> Result<CliArgs, Box<dyn std::error::Error>> {
317− parse_cli_from(env::args_os())
318−}
319−
320−pub fn parse_cli_from<I>(args: I) -> Result<CliArgs, Box<dyn std::error::Error>>
321−where
322− I: IntoIterator<Item = OsString>,
323−{
324− fn os_to_string(value: OsString, flag: &str) -> Result<String, Box<dyn std::error::Error>> {
325− value
326− .into_string()
327− .map_err(|_| format!("{flag} expects valid UTF-8").into())
328− }
329−
330− let mut parser = lexopt::Parser::from_iter(args);
331− let mut disk = None;
332− let mut version = false;
333− let mut help = false;
334− let mut no_default_mounts = false;
335− let mut mounts = Vec::new();
336− let mut login_actions = Vec::new();
337− let mut script_index = 0;
338− let mut cpu_count = DEFAULT_CPU_COUNT;
339− let mut ram_bytes = DEFAULT_RAM_BYTES;
340−
341− while let Some(arg) = parser.next()? {
342− match arg {
343− Long("version") => version = true,
344− Long("help") | Short('h') => help = true,
345− Long("no-default-mounts") => no_default_mounts = true,
346− Long("cpus") => {
347− let value = os_to_string(parser.value()?, "--cpus")?.parse()?;
348− if value == 0 {
349− return Err("--cpus must be >= 1".into());
350− }
351− cpu_count = value;
352− }
353− Long("ram") => {
354− let value: u64 = os_to_string(parser.value()?, "--ram")?.parse()?;
355− if value == 0 {
356− return Err("--ram must be >= 1".into());
357− }
358− ram_bytes = value * BYTES_PER_MB;
359− }
360− Long("mount") => {
361− mounts.push(os_to_string(parser.value()?, "--mount")?);
362− }
363− Long("script") => {
364− login_actions.push(Script {
365− path: os_to_string(parser.value()?, "--script")?.into(),
366− index: script_index,
367− });
368− script_index += 1;
369− }
370− Long("send") => {
371− login_actions.push(Send(os_to_string(parser.value()?, "--send")?));
372− }
373− Long("expect") => {
374− let text = os_to_string(parser.value()?, "--expect")?;
375− let timeout = match parser.optional_value() {
376− Some(value) => Duration::from_secs(os_to_string(value, "--expect")?.parse()?),
377− None => DEFAULT_EXPECT_TIMEOUT,
378− };
379− login_actions.push(Expect { text, timeout });
380− }
381− Value(value) => {
382− if disk.is_some() {
383− return Err("Only one disk path may be provided".into());
384− }
385− disk = Some(PathBuf::from(value));
386− }
387− _ => return Err(arg.unexpected().into()),
388− }
389− }
390−
391− Ok(CliArgs {
392− disk,
393− version,
394− help,
395− no_default_mounts,
396− mounts,
397− login_actions,
398− cpu_count,
399− ram_bytes,
400− })
401−}
402−
403246fn script_command_from_path(
404247 path: &Path,
405248 index: usize,
@@ -629,7 +472,11 @@ fn ensure_base_image(
629472
630473 println!("Decompressing base image...");
631474 let status = Command::new("tar")
632− .args(["-xOf", &base_compressed.to_string_lossy(), "disk.raw"])
475+ .args([
476+ "-xOf",
477+ &base_compressed.to_string_lossy(),
478+ BASE_DISK_RAW_NAME,
479+ ])
633480 .stdout(std::fs::File::create(base_raw)?)
634481 .status()?;
635482
@@ -655,7 +502,7 @@ fn ensure_default_image(
655502 println!("Configuring base image...");
656503 fs::copy(base_raw, default_raw)?;
657504
658− let provision_command = script_command_from_content("provision.sh", PROVISION_SCRIPT)?;
505+ let provision_command = script_command_from_content(PROVISION_SCRIPT_NAME, PROVISION_SCRIPT)?;
659506 run_vm(
660507 default_raw,
661508 &[Send(provision_command)],
src/vm_manager.rs+60−11
@@ -15,24 +15,30 @@ use std::{
1515};
1616
1717use crate::{
18+ config::CONFIG_PATH_ENV,
19+ instance::SERIAL_LOG_NAME,
1820 instance::{
1921 InstanceConfig, build_ssh_login_actions, ensure_instance_dir, ensure_ssh_keypair,
2022 extract_ipv4, load_or_create_instance_config, write_instance_config,
2123 },
22− session_manager::GLOBAL_DIR_NAME,
24+ session_manager::{
25+ GLOBAL_DIR_NAME, INSTANCE_FILENAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME,
26+ },
2327 vm::{self, DirectoryShare, LoginAction, VmInput},
2428};
2529
26−const VM_MANAGER_SOCKET_NAME: &str = "vm.sock";
2730const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
31+const VM_MANAGER_LOG_NAME: &str = "vm_manager.log";
2832
2933pub fn ensure_manager(
3034 raw_args: &[std::ffi::OsString],
3135 auto_shutdown_ms: u64,
36+ config_path: Option<&Path>,
3237) -> Result<UnixStream, Box<dyn std::error::Error>> {
3338 let project_root = env::current_dir()?;
3439 tracing::debug!(root = %project_root.display(), "ensure vm manager");
3540 let instance_dir = ensure_instance_dir(&project_root)?;
41+ cleanup_stale_manager(&instance_dir);
3642 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
3743
3844 if let Ok(stream) = UnixStream::connect(&socket_path) {
@@ -45,7 +51,7 @@ pub fn ensure_manager(
4551 let mut lock_file = acquire_spawn_lock(&lock_path)?;
4652 if lock_file.is_some() {
4753 tracing::info!(path = %socket_path.display(), "spawning vm manager");
48− spawn_manager_process(raw_args, auto_shutdown_ms, &instance_dir)?;
54+ spawn_manager_process(raw_args, auto_shutdown_ms, &instance_dir, config_path)?;
4955 } else {
5056 tracing::info!(
5157 path = %socket_path.display(),
@@ -89,11 +95,12 @@ pub fn ensure_manager(
8995}
9096
9197pub fn run_manager(
92− args: vm::CliArgs,
98+ args: vm::VmArg,
9399 auto_shutdown_ms: u64,
94100) -> Result<(), Box<dyn std::error::Error>> {
95101 let project_root = env::current_dir()?;
96102 tracing::info!(root = %project_root.display(), "vm manager starting");
103+ let _pid_guard = ensure_pid_file(&project_root)?;
97104 run_manager_with(
98105 &project_root,
99106 args,
@@ -111,6 +118,7 @@ fn spawn_manager_process(
111118 raw_args: &[std::ffi::OsString],
112119 auto_shutdown_ms: u64,
113120 instance_dir: &Path,
121+ config_path: Option<&Path>,
114122) -> Result<(), Box<dyn std::error::Error>> {
115123 let exe = env::current_exe()?;
116124 let mut supervisor_exe = exe.clone();
@@ -131,8 +139,11 @@ fn spawn_manager_process(
131139 }
132140 cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
133141 cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
142+ if let Some(path) = config_path {
143+ cmd.env(CONFIG_PATH_ENV, path);
144+ }
134145 tracing::info!(auto_shutdown_ms, "vm manager process spawn requested");
135− let log_path = instance_dir.join("vm_manager.log");
146+ let log_path = instance_dir.join(VM_MANAGER_LOG_NAME);
136147 let log_file = fs::OpenOptions::new()
137148 .create(true)
138149 .write(true)
@@ -153,6 +164,44 @@ fn spawn_manager_process(
153164 Ok(())
154165}
155166
167+fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard, Box<dyn std::error::Error>> {
168+ let instance_dir = ensure_instance_dir(project_root)?;
169+ let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
170+ if let Ok(content) = fs::read_to_string(&pid_path) {
171+ if let Ok(pid) = content.trim().parse::<u32>() {
172+ if pid_is_alive(pid) {
173+ return Err(format!("vm manager already running (pid {pid})").into());
174+ }
175+ }
176+ let _ = fs::remove_file(&pid_path);
177+ }
178+ fs::write(&pid_path, format!("{}\n", std::process::id()))?;
179+ let _ = fs::set_permissions(&pid_path, fs::Permissions::from_mode(0o600));
180+ Ok(PidFileGuard { path: pid_path })
181+}
182+
183+fn cleanup_stale_manager(instance_dir: &Path) {
184+ let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
185+ if let Ok(content) = fs::read_to_string(&pid_path) {
186+ if let Ok(pid) = content.trim().parse::<u32>() {
187+ if pid_is_alive(pid) {
188+ return;
189+ }
190+ }
191+ }
192+ let _ = fs::remove_file(&pid_path);
193+}
194+
195+struct PidFileGuard {
196+ path: PathBuf,
197+}
198+
199+impl Drop for PidFileGuard {
200+ fn drop(&mut self) {
201+ let _ = fs::remove_file(&self.path);
202+ }
203+}
204+
156205fn detach_from_terminal() {
157206 unsafe {
158207 libc::setsid();
@@ -280,7 +329,7 @@ fn spawn_manager_io(
280329 vm_output_fd: std::os::unix::io::OwnedFd,
281330 vm_input_fd: std::os::unix::io::OwnedFd,
282331) -> vm::IoContext {
283− let log_path = instance_dir.join("serial.log");
332+ let log_path = instance_dir.join(SERIAL_LOG_NAME);
284333 let log_file = fs::OpenOptions::new()
285334 .create(true)
286335 .write(true)
@@ -289,7 +338,7 @@ fn spawn_manager_io(
289338 .ok()
290339 .map(|file| Arc::new(Mutex::new(file)));
291340
292− let instance_path = instance_dir.join("instance.toml");
341+ let instance_path = instance_dir.join(INSTANCE_FILENAME);
293342 let config_for_output = config.clone();
294343 let log_for_output = log_file.clone();
295344 let mut line_buf = String::new();
@@ -390,7 +439,7 @@ struct ManagerOptions {
390439trait VmExecutor {
391440 fn run_vm(
392441 &self,
393− args: vm::CliArgs,
442+ args: vm::VmArg,
394443 extra_login_actions: Vec<LoginAction>,
395444 extra_shares: Vec<DirectoryShare>,
396445 config: Arc<Mutex<InstanceConfig>>,
@@ -404,7 +453,7 @@ struct RealVmExecutor;
404453impl VmExecutor for RealVmExecutor {
405454 fn run_vm(
406455 &self,
407− args: vm::CliArgs,
456+ args: vm::VmArg,
408457 extra_login_actions: Vec<LoginAction>,
409458 extra_shares: Vec<DirectoryShare>,
410459 config: Arc<Mutex<InstanceConfig>>,
@@ -433,7 +482,7 @@ impl VmExecutor for RealVmExecutor {
433482
434483fn run_manager_with(
435484 project_root: &Path,
436− args: vm::CliArgs,
485+ args: vm::VmArg,
437486 auto_shutdown_ms: u64,
438487 executor: &dyn VmExecutor,
439488 options: ManagerOptions,
@@ -465,7 +514,7 @@ fn run_manager_with(
465514 let mut config = load_or_create_instance_config(&instance_dir)?;
466515 if config.vm_ipv4.is_some() {
467516 config.vm_ipv4 = None;
468− write_instance_config(&instance_dir.join("instance.toml"), &config)?;
517+ write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?;
469518 }
470519 let config = Arc::new(Mutex::new(config));
471520
vibebox.toml+10
@@ -0,0 +1,10 @@
1+[box]
2+cpu_count = 2
3+ram_mb = 2048
4+mounts = [
5+ "~/.codex:/usr/local/codex:read-write",
6+ "~/.claude:/usr/local/claude:read-write",
7+]
8+
9+[supervisor]
10+auto_shutdown_ms = 20000